Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
416 lines
16 KiB
Go
416 lines
16 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package passkey_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/sjson"
|
|
|
|
"github.com/ory/x/configx"
|
|
|
|
"github.com/ory/kratos/driver"
|
|
"github.com/ory/kratos/driver/config"
|
|
"github.com/ory/kratos/identity"
|
|
"github.com/ory/kratos/pkg"
|
|
kratos "github.com/ory/kratos/pkg/httpclient"
|
|
"github.com/ory/kratos/pkg/testhelpers"
|
|
"github.com/ory/kratos/selfservice/flow/login"
|
|
"github.com/ory/kratos/selfservice/flow/registration"
|
|
"github.com/ory/kratos/ui/node"
|
|
"github.com/ory/kratos/x"
|
|
"github.com/ory/x/assertx"
|
|
"github.com/ory/x/sqlxx"
|
|
)
|
|
|
|
func newRegistrationRegistry(t *testing.T, cfgOpts ...configx.OptionModifier) *driver.RegistryDefault {
|
|
_, reg := pkg.NewFastRegistryWithMocks(t, append([]configx.OptionModifier{
|
|
configx.WithValues(map[string]any{
|
|
config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePassword) + ".enabled": true,
|
|
config.ViperKeySelfServiceRegistrationEnableLegacyOneStep: true,
|
|
config.ViperKeySelfServiceRegistrationLoginHints: true,
|
|
}),
|
|
withEnabledPasskeyStrategy,
|
|
}, cfgOpts...)...)
|
|
return reg
|
|
}
|
|
|
|
func newLoginRegistry(t *testing.T, cfgOpts ...configx.OptionModifier) *driver.RegistryDefault {
|
|
_, reg := pkg.NewFastRegistryWithMocks(t, append([]configx.OptionModifier{
|
|
configx.WithValues(map[string]any{
|
|
config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePassword) + ".enabled": true,
|
|
config.ViperKeySelfServiceRegistrationLoginHints: true,
|
|
}),
|
|
withEnabledPasskeyStrategy,
|
|
}, cfgOpts...)...)
|
|
return reg
|
|
}
|
|
|
|
var withEnabledPasskeyStrategy = func() configx.OptionModifier {
|
|
key := config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePasskey)
|
|
return configx.WithValues(map[string]any{
|
|
key + ".enabled": true,
|
|
key + ".config.rp.display_name": "Ory Corp",
|
|
key + ".config.rp.id": "localhost",
|
|
key + ".config.rp.origins": []string{"http://localhost:4455"},
|
|
})
|
|
}()
|
|
|
|
type fixture struct {
|
|
conf *config.Config
|
|
reg *driver.RegistryDefault
|
|
|
|
publicTS *httptest.Server
|
|
redirTS *httptest.Server
|
|
redirNoSessionTS *httptest.Server
|
|
uiTS *httptest.Server
|
|
errTS *httptest.Server
|
|
loginTS *httptest.Server
|
|
}
|
|
|
|
func newRegistrationFixture(t *testing.T, cfgOpts ...configx.OptionModifier) *fixture {
|
|
fix := new(fixture)
|
|
fix.reg = newRegistrationRegistry(t, append([]configx.OptionModifier{
|
|
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/registration.schema.json")),
|
|
configx.WithValue(config.ViperKeySecretsDefault, []string{"not-a-secure-session-key"}),
|
|
}, cfgOpts...)...)
|
|
fix.conf = fix.reg.Config()
|
|
|
|
fix.publicTS, _ = testhelpers.NewKratosServer(t, fix.reg)
|
|
|
|
_ = testhelpers.NewErrorTestServer(t, fix.reg)
|
|
_ = testhelpers.NewRegistrationUIFlowEchoServer(t, fix.reg)
|
|
_ = testhelpers.NewRedirSessionEchoTS(t, fix.reg)
|
|
|
|
fix.redirTS = testhelpers.NewRedirSessionEchoTS(t, fix.reg)
|
|
fix.redirNoSessionTS = testhelpers.NewRedirNoSessionTS(t, fix.reg)
|
|
|
|
fix.useReturnToFromTS(fix.redirTS)
|
|
|
|
return fix
|
|
}
|
|
|
|
func newLoginFixture(t *testing.T, cfgOpts ...configx.OptionModifier) *fixture {
|
|
fix := new(fixture)
|
|
fix.reg = newLoginRegistry(t, append([]configx.OptionModifier{
|
|
configx.WithValues(map[string]any{
|
|
config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePassword) + ".enabled": false,
|
|
config.ViperKeySecretsDefault: []string{"not-a-secure-session-key"},
|
|
}),
|
|
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/login.schema.json")),
|
|
}, cfgOpts...)...)
|
|
fix.conf = fix.reg.Config()
|
|
|
|
fix.publicTS, _ = testhelpers.NewKratosServer(t, fix.reg)
|
|
|
|
fix.errTS = testhelpers.NewErrorTestServer(t, fix.reg)
|
|
fix.uiTS = testhelpers.NewLoginUIFlowEchoServer(t, fix.reg)
|
|
fix.loginTS = fix.uiTS
|
|
|
|
// Overwrite these two to make it more explicit when tests fail
|
|
fix.conf.MustSet(t.Context(), config.ViperKeySelfServiceLoginUI, fix.uiTS.URL+"/login-ts")
|
|
|
|
fix.redirTS = testhelpers.NewRedirSessionEchoTS(t, fix.reg)
|
|
fix.redirNoSessionTS = testhelpers.NewRedirNoSessionTS(t, fix.reg)
|
|
|
|
fix.useReturnToFromTS(fix.redirTS)
|
|
|
|
return fix
|
|
}
|
|
|
|
func newSettingsFixture(t *testing.T, cfgOpts ...configx.OptionModifier) *fixture {
|
|
fix := newLoginFixture(t, append([]configx.OptionModifier{
|
|
configx.WithValues(map[string]any{
|
|
config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter: "1m",
|
|
config.ViperKeySelfServiceSettingsRequiredAAL: "aal1",
|
|
config.ViperKeySessionWhoAmIAAL: "aal1",
|
|
config.ViperKeySelfServiceStrategyConfig + ".profile.enabled": false,
|
|
}),
|
|
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/settings.schema.json")),
|
|
}, cfgOpts...)...)
|
|
fix.uiTS = testhelpers.NewSettingsUIFlowEchoServer(t, fix.reg)
|
|
|
|
return fix
|
|
}
|
|
|
|
func (fix *fixture) checkURL(t *testing.T, shouldRedirect bool, res *http.Response) {
|
|
if shouldRedirect {
|
|
assert.Contains(t, res.Request.URL.String(), fix.uiTS.URL+"/login-ts")
|
|
} else {
|
|
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+login.RouteSubmitFlow)
|
|
}
|
|
}
|
|
|
|
func (fix *fixture) loginViaBrowser(t *testing.T, spa bool, cb func(url.Values), browserClient *http.Client, opts ...testhelpers.InitFlowWithOption) (string, *http.Response) {
|
|
f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, fix.publicTS, false, spa, false, false, opts...)
|
|
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
|
cb(values)
|
|
return testhelpers.LoginMakeRequest(t, false, spa, f, browserClient, values.Encode())
|
|
}
|
|
|
|
func (fix *fixture) createIdentityWithPasskey(t *testing.T, c identity.Credentials) *identity.Identity {
|
|
var id identity.Identity
|
|
require.NoError(t, json.Unmarshal(loginSuccessIdentity, &id))
|
|
|
|
id.SetCredentials(identity.CredentialsTypePasskey, identity.Credentials{
|
|
Identifiers: []string{"some-random-user-handle"},
|
|
Config: c.Config,
|
|
Type: identity.CredentialsTypePasskey,
|
|
Version: c.Version,
|
|
})
|
|
|
|
// We clean up the identity in case it has been created before
|
|
_ = fix.reg.PrivilegedIdentityPool().DeleteIdentity(t.Context(), id.ID)
|
|
|
|
require.NoError(t, fix.reg.PrivilegedIdentityPool().CreateIdentity(t.Context(), &id))
|
|
|
|
return &id
|
|
}
|
|
|
|
func (fix *fixture) submitWebAuthnLoginFlowWithClient(t *testing.T, isSPA bool, f *kratos.LoginFlow, contextFixture []byte, client *http.Client, cb func(values url.Values)) (string, *http.Response, *kratos.LoginFlow) {
|
|
// We inject the session to replay
|
|
interim, err := fix.reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(f.Id))
|
|
require.NoError(t, err)
|
|
interim.InternalContext = contextFixture
|
|
require.NoError(t, fix.reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), interim))
|
|
|
|
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
|
|
cb(values)
|
|
|
|
// We use the response replay
|
|
body, res := testhelpers.LoginMakeRequest(t, false, isSPA, f, client, values.Encode())
|
|
return body, res, f
|
|
}
|
|
|
|
func (fix *fixture) submitWebAuthnLoginWithClient(t *testing.T, isSPA bool, contextFixture []byte, client *http.Client, cb func(values url.Values), opts ...testhelpers.InitFlowWithOption) (string, *http.Response, *kratos.LoginFlow) {
|
|
f := testhelpers.InitializeLoginFlowViaBrowser(t, client, fix.publicTS, false, isSPA, false, false, opts...)
|
|
return fix.submitWebAuthnLoginFlowWithClient(t, isSPA, f, contextFixture, client, cb)
|
|
}
|
|
|
|
func (fix *fixture) submitWebAuthnLogin(t *testing.T, ctx context.Context, isSPA bool, id *identity.Identity, contextFixture []byte, cb func(values url.Values), opts ...testhelpers.InitFlowWithOption) (string, *http.Response, *kratos.LoginFlow) {
|
|
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, fix.reg, id)
|
|
return fix.submitWebAuthnLoginWithClient(t, isSPA, contextFixture, browserClient, cb, opts...)
|
|
}
|
|
|
|
// useReturnToFromTS sets the "return to" server, which will assert the session
|
|
// state (redirTS: enforce that a session exists, redirNoSessionTS: enforce that
|
|
// no session exists)
|
|
func (fix *fixture) useReturnToFromTS(ts *httptest.Server) {
|
|
fix.conf.MustSet(context.TODO(), config.ViperKeySelfServiceBrowserDefaultReturnTo, ts.URL+"/default-return-to")
|
|
fix.conf.MustSet(context.TODO(), config.ViperKeySelfServiceRegistrationAfter+"."+config.DefaultBrowserReturnURL, ts.URL+"/registration-return-ts")
|
|
}
|
|
func (fix *fixture) useRedirTS() { fix.useReturnToFromTS(fix.redirTS) }
|
|
func (fix *fixture) useRedirNoSessionTS() { fix.useReturnToFromTS(fix.redirNoSessionTS) }
|
|
|
|
func (fix *fixture) disableSessionAfterRegistration() {
|
|
fix.conf.MustSet(context.TODO(), config.HookStrategyKey(
|
|
config.ViperKeySelfServiceRegistrationAfter,
|
|
identity.CredentialsTypePasskey.String(),
|
|
), nil)
|
|
}
|
|
|
|
func (fix *fixture) enableSessionAfterRegistration() {
|
|
fix.conf.MustSet(context.TODO(), config.HookStrategyKey(
|
|
config.ViperKeySelfServiceRegistrationAfter,
|
|
identity.CredentialsTypePasskey.String(),
|
|
), []config.SelfServiceHook{{Name: "session"}})
|
|
}
|
|
|
|
type submitPasskeyOpt struct {
|
|
initFlowOpts []testhelpers.InitFlowWithOption
|
|
userID string
|
|
internalContext sqlxx.JSONRawMessage
|
|
}
|
|
|
|
type submitPasskeyOption func(o *submitPasskeyOpt)
|
|
|
|
func withUserID(id string) submitPasskeyOption {
|
|
return func(o *submitPasskeyOpt) {
|
|
o.userID = base64.StdEncoding.EncodeToString([]byte(id))
|
|
}
|
|
}
|
|
|
|
func withInternalContext(ic sqlxx.JSONRawMessage) submitPasskeyOption {
|
|
return func(o *submitPasskeyOpt) {
|
|
o.internalContext = ic
|
|
}
|
|
}
|
|
|
|
func withInitFlowWithOption(ifo []testhelpers.InitFlowWithOption) submitPasskeyOption {
|
|
return func(o *submitPasskeyOpt) {
|
|
o.initFlowOpts = ifo
|
|
}
|
|
}
|
|
|
|
func (fix *fixture) submitPasskeyBrowserRegistration(
|
|
t *testing.T,
|
|
flowType string,
|
|
client *http.Client,
|
|
cb func(values url.Values),
|
|
opts ...submitPasskeyOption,
|
|
) (string, *http.Response, *kratos.RegistrationFlow) {
|
|
return fix.submitPasskeyRegistration(t.Context(), t, flowType, client, cb, append([]submitPasskeyOption{withInternalContext(registrationFixtureSuccessBrowserInternalContext)}, opts...)...)
|
|
}
|
|
|
|
func (fix *fixture) submitPasskeyAndroidRegistration(
|
|
t *testing.T,
|
|
flowType string,
|
|
client *http.Client,
|
|
cb func(values url.Values),
|
|
opts ...submitPasskeyOption,
|
|
) (string, *http.Response, *kratos.RegistrationFlow) {
|
|
return fix.submitPasskeyRegistration(t.Context(), t, flowType, client, cb,
|
|
append([]submitPasskeyOption{withInternalContext(
|
|
registrationFixtureSuccessAndroidInternalContext,
|
|
)}, opts...)...)
|
|
}
|
|
|
|
func (fix *fixture) submitPasskeyRegistration(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
flowType string,
|
|
client *http.Client,
|
|
cb func(values url.Values),
|
|
opts ...submitPasskeyOption,
|
|
) (string, *http.Response, *kratos.RegistrationFlow) {
|
|
o := &submitPasskeyOpt{}
|
|
for _, fn := range opts {
|
|
fn(o)
|
|
}
|
|
|
|
isSPA := flowType == "spa"
|
|
isAPI := flowType == "api"
|
|
|
|
var regFlow *kratos.RegistrationFlow
|
|
if isAPI {
|
|
regFlow = testhelpers.InitializeRegistrationFlowViaAPI(t, client, fix.publicTS, o.initFlowOpts...)
|
|
} else {
|
|
regFlow = testhelpers.InitializeRegistrationFlowViaBrowserCtx(ctx, t, client, fix.publicTS, isSPA, false, false, o.initFlowOpts...)
|
|
}
|
|
|
|
// First step: fill out traits and click on "sign up with passkey"
|
|
values := testhelpers.SDKFormFieldsToURLValues(regFlow.Ui.Nodes)
|
|
cb(values)
|
|
passkeyRegisterVal := values.Get(node.PasskeyRegister) // needed in the second step
|
|
values.Del(node.PasskeyRegister)
|
|
values.Set("method", "passkey")
|
|
|
|
// Encode payload based on flow type
|
|
var payload string
|
|
if isAPI {
|
|
payload = testhelpers.EncodeFormAsJSON(t, true, values)
|
|
} else {
|
|
payload = values.Encode()
|
|
}
|
|
_, _ = testhelpers.RegistrationMakeRequest(t, isAPI, isSPA, regFlow, client, payload)
|
|
|
|
// We inject the session to replay
|
|
interim, err := fix.reg.RegistrationFlowPersister().GetRegistrationFlow(ctx, uuid.FromStringOrNil(regFlow.Id))
|
|
require.NoError(t, err)
|
|
if len(o.internalContext) > 0 {
|
|
interim.InternalContext = o.internalContext
|
|
}
|
|
if o.userID != "" {
|
|
interim.InternalContext, err = sjson.SetBytes(interim.InternalContext, "passkey_session_data.user_id", o.userID)
|
|
require.NoError(t, err)
|
|
}
|
|
require.NoError(t, fix.reg.RegistrationFlowPersister().UpdateRegistrationFlow(ctx, interim))
|
|
|
|
// Second step: fill out passkey response
|
|
values.Set(node.PasskeyRegister, passkeyRegisterVal)
|
|
if isAPI {
|
|
payload = testhelpers.EncodeFormAsJSON(t, true, values)
|
|
} else {
|
|
payload = values.Encode()
|
|
}
|
|
body, res := testhelpers.RegistrationMakeRequest(t, isAPI, isSPA, regFlow, client, payload)
|
|
|
|
return body, res, regFlow
|
|
}
|
|
|
|
func (fix *fixture) makeRegistration(t *testing.T, flowType string, values func(v url.Values), opts ...submitPasskeyOption) (actual string, res *http.Response, fetchedFlow *registration.Flow) {
|
|
actual, res, actualFlow := fix.submitPasskeyBrowserRegistration(t, flowType, testhelpers.NewClientWithCookies(t), values, opts...)
|
|
fetchedFlow, err := fix.reg.RegistrationFlowPersister().GetRegistrationFlow(t.Context(), uuid.FromStringOrNil(actualFlow.Id))
|
|
require.NoError(t, err)
|
|
|
|
return actual, res, fetchedFlow
|
|
}
|
|
|
|
func (fix *fixture) makeSuccessfulRegistration(t *testing.T, flowType string, expectReturnTo string, values func(v url.Values), opts ...submitPasskeyOption) (actual string) {
|
|
actual, res, _ := fix.makeRegistration(t, flowType, values, opts...)
|
|
if flowType == "spa" {
|
|
expectReturnTo = fix.publicTS.URL
|
|
}
|
|
if flowType == "api" {
|
|
// API does not redirect; response body should contain JSON with identity/session fields.
|
|
// Leave detailed assertions to the calling test.
|
|
return actual
|
|
}
|
|
assert.Contains(t, res.Request.URL.String(), expectReturnTo, "%+v\n\t%s", res.Request, assertx.PrettifyJSONPayload(t, actual))
|
|
return actual
|
|
}
|
|
|
|
func (fix *fixture) makeUnsuccessfulRegistration(t *testing.T, flowType string, expectReturnTo string, values func(v url.Values), opts ...submitPasskeyOption) (actual string, res *http.Response) {
|
|
actual, res, _ = fix.makeRegistration(t, flowType, values, opts...)
|
|
return actual, res
|
|
}
|
|
|
|
func (fix *fixture) createIdentityWithoutPasskey(t *testing.T) *identity.Identity {
|
|
id := fix.createIdentity(t)
|
|
delete(id.Credentials, identity.CredentialsTypePasskey)
|
|
require.NoError(t, fix.reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), id))
|
|
return id
|
|
}
|
|
|
|
func (fix *fixture) createIdentityAndReturnIdentifier(t *testing.T, conf []byte) (*identity.Identity, string) {
|
|
identifier := x.NewUUID().String() + "@ory.sh"
|
|
password := x.NewUUID().String()
|
|
p, err := fix.reg.Hasher(t.Context()).Generate(t.Context(), []byte(password))
|
|
require.NoError(t, err)
|
|
i := &identity.Identity{
|
|
Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, identifier)),
|
|
VerifiableAddresses: []identity.VerifiableAddress{
|
|
{
|
|
Value: identifier,
|
|
Verified: false,
|
|
CreatedAt: time.Now(),
|
|
},
|
|
},
|
|
}
|
|
if conf == nil {
|
|
conf = []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo"},{"id":"YmFyYmFy","display_name":"bar"}]}`)
|
|
}
|
|
require.NoError(t, fix.reg.PrivilegedIdentityPool().CreateIdentity(t.Context(), i))
|
|
i.Credentials = map[identity.CredentialsType]identity.Credentials{
|
|
identity.CredentialsTypePassword: {
|
|
Type: identity.CredentialsTypePassword,
|
|
Identifiers: []string{identifier},
|
|
Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`),
|
|
},
|
|
identity.CredentialsTypePasskey: {
|
|
Type: identity.CredentialsTypePasskey,
|
|
Identifiers: []string{identifier},
|
|
Config: conf,
|
|
},
|
|
}
|
|
require.NoError(t, fix.reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), i))
|
|
return i, identifier
|
|
}
|
|
|
|
func (fix *fixture) createIdentity(t *testing.T) *identity.Identity {
|
|
id, _ := fix.createIdentityAndReturnIdentifier(t, nil)
|
|
return id
|
|
}
|