// 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 }