// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package code_test import ( "context" _ "embed" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "github.com/ory/herodot" "github.com/ory/x/configx" "github.com/ory/x/sqlxx" "github.com/ory/kratos/courier" "github.com/ory/kratos/driver" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/hydra" "github.com/ory/kratos/identity" "github.com/ory/kratos/pkg" oryClient "github.com/ory/kratos/pkg/httpclient" "github.com/ory/kratos/pkg/testhelpers" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/registration" "github.com/ory/kratos/selfservice/strategy/code" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x/nosurfx" "github.com/ory/pop/v6" "github.com/ory/x/assertx" "github.com/ory/x/snapshotx" "github.com/ory/x/sqlcon" ) type state struct { flowID string client *http.Client email string testServer *httptest.Server resultIdentity *identity.Identity body string } func TestRegistrationCodeStrategyDisabled(t *testing.T) { _, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/code.identity.schema.json")), configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, false)), configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeCodeAuth, false)), configx.WithValues(map[string]any{ fmt.Sprintf("%s.%s.passwordless_enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth): false, config.ViperKeySelfServiceRegistrationEnableLegacyOneStep: true, }), ) _ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg) _ = testhelpers.NewErrorTestServer(t, reg) public, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg) client := testhelpers.NewClientWithCookies(t) resp, err := client.Get(public.URL + registration.RouteInitBrowserFlow) require.NoError(t, err) require.EqualValues(t, http.StatusOK, resp.StatusCode) body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Falsef(t, gjson.GetBytes(body, "ui.nodes.#(attributes.value==code)").Exists(), "%s", body) // attempt to still submit the code form even though it doesn't exist payload := strings.NewReader(url.Values{ "csrf_token": {gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String()}, "method": {"code"}, "traits.email": {testhelpers.RandomEmail()}, }.Encode()) req, err := http.NewRequestWithContext(t.Context(), "POST", public.URL+registration.RouteSubmitFlow+"?flow="+gjson.GetBytes(body, "id").String(), payload) require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err = client.Do(req) require.NoError(t, err) require.Equal(t, http.StatusNotFound, resp.StatusCode) body, err = io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, "This endpoint was disabled by system administrator. Please check your url or contact the system administrator to enable it.", gjson.GetBytes(body, "error.reason").String()) } func TestRegistrationCodeStrategy(t *testing.T) { t.Parallel() type ApiType string const ( ApiTypeBrowser ApiType = "browser" ApiTypeSPA ApiType = "spa" ApiTypeNative ApiType = "api" ) setup := func(ctx context.Context, t *testing.T, cfgOpts ...configx.OptionModifier) (*config.Config, *driver.RegistryDefault, *httptest.Server) { conf, reg := pkg.NewFastRegistryWithMocks(t, append([]configx.OptionModifier{ configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/code.identity.schema.json")), configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, false)), configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeCodeAuth, true)), configx.WithValues(map[string]any{ fmt.Sprintf("%s.%s.passwordless_enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth): true, config.ViperKeySelfServiceRegistrationAfter + ".code.hooks": []map[string]any{ {"hook": "session"}, }, config.ViperKeySelfServiceRegistrationEnableLegacyOneStep: true, }), }, cfgOpts...)...) _ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg) _ = testhelpers.NewErrorTestServer(t, reg) public, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg) return conf, reg, public } createRegistrationFlowInternal := func(ctx context.Context, t *testing.T, public *httptest.Server, apiType ApiType, identitySchema string) *state { t.Helper() var client *http.Client if apiType == ApiTypeNative { client = &http.Client{} } else { client = testhelpers.NewClientWithCookies(t) } client.Transport = testhelpers.NewTransportWithLogger(testhelpers.NewTestTransport(t), t).RoundTripper var clientInit *oryClient.RegistrationFlow if apiType == ApiTypeNative { clientInit = testhelpers.InitializeRegistrationFlowViaAPICtx(ctx, t, client, public, testhelpers.InitFlowWithIdentitySchema(identitySchema)) } else { clientInit = testhelpers.InitializeRegistrationFlowViaBrowserCtx(ctx, t, client, public, apiType == ApiTypeSPA, false, false, testhelpers.InitFlowWithIdentitySchema(identitySchema)) } body, err := json.Marshal(clientInit) require.NoError(t, err) csrfToken := gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() if apiType == ApiTypeNative { require.Emptyf(t, csrfToken, "expected an empty value for csrf_token on native api flows but got %s", body) } else { require.NotEmpty(t, csrfToken) } require.Truef(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.email)").Exists(), "%s", body) require.Truef(t, gjson.GetBytes(body, "ui.nodes.#(attributes.value==code)").Exists(), "%s", body) return &state{ client: client, flowID: clientInit.GetId(), testServer: public, } } createRegistrationFlow := func(ctx context.Context, t *testing.T, public *httptest.Server, apiType ApiType) *state { return createRegistrationFlowInternal(ctx, t, public, apiType, "") } createRegistrationFlowWithIdentity := func(ctx context.Context, t *testing.T, public *httptest.Server, apiType ApiType, identitySchema string) *state { return createRegistrationFlowInternal(ctx, t, public, apiType, identitySchema) } type onSubmitAssertion func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) registerNewUser := func(ctx context.Context, t *testing.T, s *state, apiType ApiType, submitAssertion onSubmitAssertion) *state { t.Helper() if s.email == "" { s.email = testhelpers.RandomEmail() } rf, resp, err := testhelpers.NewSDKCustomClient(s.testServer, s.client).FrontendAPI.GetRegistrationFlow(context.Background()).Id(s.flowID).Execute() require.NoError(t, err) require.EqualValues(t, http.StatusOK, resp.StatusCode) values := testhelpers.SDKFormFieldsToURLValues(rf.Ui.Nodes) values.Set("traits.email", s.email) values.Set("traits.tos", "1") values.Set("method", "code") body, resp := testhelpers.RegistrationMakeRequest(t, apiType == ApiTypeNative, apiType == ApiTypeSPA, rf, s.client, testhelpers.EncodeFormAsJSON(t, apiType == ApiTypeNative, values)) s.body = body if submitAssertion != nil { submitAssertion(ctx, t, s, body, resp) return s } t.Logf("%v", body) if apiType == ApiTypeBrowser { require.EqualValues(t, http.StatusOK, resp.StatusCode) } else { require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) } csrfToken := gjson.Get(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() if apiType == ApiTypeNative { assert.Emptyf(t, csrfToken, "expected an empty value for csrf_token on native api flows but got %s", body) } else { assert.NotEmptyf(t, csrfToken, "%s", body) } require.Equal(t, s.email, gjson.Get(body, "ui.nodes.#(attributes.name==traits.email).attributes.value").String()) return s } submitOTP := func(ctx context.Context, t *testing.T, reg *driver.RegistryDefault, s *state, vals func(v *url.Values), apiType ApiType, submitAssertion onSubmitAssertion) *state { t.Helper() rf, resp, err := testhelpers.NewSDKCustomClient(s.testServer, s.client).FrontendAPI.GetRegistrationFlow(context.Background()).Id(s.flowID).Execute() require.NoError(t, err) require.EqualValues(t, http.StatusOK, resp.StatusCode) values := testhelpers.SDKFormFieldsToURLValues(rf.Ui.Nodes) // the sdk to values always adds resend which isn't what we always need here. // so we delete it here. // the custom vals func can add it again if needed. values.Del("resend") values.Set("traits.email", s.email) values.Set("traits.tos", "1") vals(&values) body, resp := testhelpers.RegistrationMakeRequest(t, apiType == ApiTypeNative, apiType == ApiTypeSPA, rf, s.client, testhelpers.EncodeFormAsJSON(t, apiType == ApiTypeNative, values)) s.body = body if submitAssertion != nil { submitAssertion(ctx, t, s, body, resp) return s } require.Equal(t, http.StatusOK, resp.StatusCode, body) verifiableAddress, err := reg.PrivilegedIdentityPool().FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, s.email) require.NoError(t, err) require.Equal(t, strings.ToLower(s.email), verifiableAddress.Value) id, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, verifiableAddress.IdentityID) require.NoError(t, err) require.NotNil(t, id.ID) _, ok := id.GetCredentials(identity.CredentialsTypeCodeAuth) require.True(t, ok) s.resultIdentity = id return s } t.Run("test=different flows on the same configurations", func(t *testing.T) { t.Parallel() ctx := context.Background() conf, reg, public := setup(ctx, t) for _, tc := range []struct { d string apiType ApiType }{ { d: "SPA client", apiType: ApiTypeSPA, }, { d: "Browser client", apiType: ApiTypeBrowser, }, { d: "Native client", apiType: ApiTypeNative, }, } { t.Run("flow="+tc.d, func(t *testing.T) { t.Run("case=should be able to register with code identity credentials", func(t *testing.T) { ctx := context.Background() // 1. Initiate flow state := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) state = registerNewUser(ctx, t, state, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, state.email, "Use code") assert.Contains(t, message.Body, "Complete your account registration with the following code") registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, registrationCode) // 3. Submit OTP state = submitOTP(ctx, t, reg, state, func(v *url.Values) { v.Set("code", registrationCode) }, tc.apiType, nil) switch tc.apiType { case ApiTypeSPA: assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(state.body, "continue_with.0.action").String(), "%s", state.body) assert.Contains(t, gjson.Get(state.body, "continue_with.0.redirect_browser_to").String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%s", state.body) case ApiTypeBrowser: assert.Empty(t, gjson.Get(state.body, "continue_with").Array(), "%s", state.body) case ApiTypeNative: assert.NotContains(t, gjson.Get(state.body, "continue_with").Raw, string(flow.ContinueWithActionRedirectBrowserToString), "%s", state.body) } }) t.Run("case=should normalize email address on sign up", func(t *testing.T) { ctx := context.Background() // 1. Initiate flow state := createRegistrationFlow(ctx, t, public, tc.apiType) sourceMail := testhelpers.RandomEmail() state.email = strings.ToUpper(sourceMail) assert.NotEqual(t, sourceMail, state.email) // 2. Submit Identifier (email) state = registerNewUser(ctx, t, state, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, sourceMail, "Use code") assert.Contains(t, message.Body, "Complete your account registration with the following code") registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, registrationCode) // 3. Submit OTP state = submitOTP(ctx, t, reg, state, func(v *url.Values) { v.Set("code", registrationCode) }, tc.apiType, nil) creds, ok := state.resultIdentity.GetCredentials(identity.CredentialsTypeCodeAuth) require.True(t, ok) require.Len(t, creds.Identifiers, 1) assert.Equal(t, sourceMail, creds.Identifiers[0]) }) t.Run("case=should be able to resend the code", func(t *testing.T) { ctx := context.Background() s := createRegistrationFlow(ctx, t, public, tc.apiType) s = registerNewUser(ctx, t, s, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { if tc.apiType == ApiTypeBrowser { require.EqualValues(t, http.StatusOK, resp.StatusCode) } else { require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) } csrfToken := gjson.Get(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() if tc.apiType == ApiTypeNative { require.Empty(t, csrfToken, "expected the csrf_token to be empty but got %s", body) } else { require.NotEmptyf(t, csrfToken, "expected the csrf_token to exist but got %s", body) } require.Equal(t, s.email, gjson.Get(body, "ui.nodes.#(attributes.name==traits.email).attributes.value").String()) attr := gjson.Get(body, "ui.nodes.#(attributes.name==method)#").String() require.NotEmpty(t, attr) val := gjson.Get(attr, "#(attributes.type==hidden).attributes.value").String() require.Equal(t, "code", val, body) }) message := testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Use code") assert.Contains(t, message.Body, "Complete your account registration with the following code") registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, registrationCode) // resend code s = submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("resend", "code") }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode) } else { require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) } csrfToken := gjson.Get(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() if tc.apiType == ApiTypeNative { assert.Emptyf(t, csrfToken, "expected an empty value for csrf_token on native api flows but got %s", body) } else { require.NotEmptyf(t, csrfToken, "expected to find the csrf_token but got %s", body) } require.Containsf(t, gjson.Get(body, "ui.messages").String(), "A code has been sent to the address(es) you provided", "%s", body) }) // get the new code from email message = testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Use code") assert.Contains(t, message.Body, "Complete your account registration with the following code") registrationCode2 := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, registrationCode2) require.NotEqual(t, registrationCode, registrationCode2) // try submit old code s = submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", registrationCode) }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) } else { require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) } require.Contains(t, gjson.Get(body, "ui.messages").String(), "The registration code is invalid or has already been used. Please try again") }) submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", registrationCode2) }, tc.apiType, nil) }) t.Run("case=swapping out traits should not be possible on code submit", func(t *testing.T) { ctx := context.Background() // 1. Initiate flow s := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) s = registerNewUser(ctx, t, s, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Use code") assert.Contains(t, message.Body, "Complete your account registration with the following code") registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, registrationCode) s.email = "not-" + s.email // swap out email // 3. Submit OTP submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", registrationCode) }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) } else { require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) } require.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The provided traits do not match the traits previously associated with this flow.") }) }) t.Run("case=swapping out traits that aren't strings should not be possible on code submit", func(t *testing.T) { ctx := context.Background() // 1. Initiate flow s := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) s = registerNewUser(ctx, t, s, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Use code") assert.Contains(t, message.Body, "Complete your account registration") registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, registrationCode) // 3. Submit OTP submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", registrationCode) v.Set("traits.tos", "0") }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) } else { require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) } require.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The provided traits do not match the traits previously associated with this flow.") }) }) t.Run("case=code should not be able to use more than 5 times", func(t *testing.T) { ctx := context.Background() // 1. Initiate flow s := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) s = registerNewUser(ctx, t, s, tc.apiType, nil) err := reg.Persister().Transaction(ctx, func(ctx context.Context, connection *pop.Connection) error { count, err := connection.RawQuery(fmt.Sprintf("SELECT * FROM %s WHERE selfservice_registration_flow_id = ?", new(code.RegistrationCode).TableName(ctx)), uuid.FromStringOrNil(s.flowID)).Count(new(code.RegistrationCode)) require.NoError(t, err) require.Equal(t, 1, count) return nil }) require.NoError(t, err) for i := 0; i < 5; i++ { s = submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", "111111") }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) } else { require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) } require.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The registration code is invalid or has already been used") }) } s = submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", "111111") }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) } else { require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) } require.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "The request was submitted too often.") }) }) }) } }) t.Run("test=cases with different configs", func(t *testing.T) { t.Parallel() ctx := context.Background() conf, reg, public := setup(ctx, t) for _, tc := range []struct { d string apiType ApiType }{ { d: "SPA client", apiType: ApiTypeSPA, }, { d: "Browser client", apiType: ApiTypeBrowser, }, { d: "Native client", apiType: ApiTypeNative, }, } { t.Run("test="+tc.d, func(t *testing.T) { t.Run("case=should fail when schema does not contain the `code` extension", func(t *testing.T) { testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/no-code.schema.json") conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, false) t.Cleanup(func() { conf.MustSet(ctx, config.ViperKeyCodeConfigMissingCredentialFallbackEnabled, true) testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.identity.schema.json") }) // 1. Initiate flow s := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) registerNewUser(ctx, t, s, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { if tc.apiType == ApiTypeBrowser { // we expect a redirect to the registration page with the flow id require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, conf.SelfServiceFlowRegistrationUI(ctx).Path, resp.Request.URL.Path) rf, resp, err := testhelpers.NewSDKCustomClient(public, s.client).FrontendAPI.GetRegistrationFlow(ctx).Id(resp.Request.URL.Query().Get("flow")).Execute() require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) body, err := json.Marshal(rf) require.NoError(t, err) require.Contains(t, gjson.GetBytes(body, "ui.messages").String(), "Could not find any login identifiers") } else { require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%v", body) require.Contains(t, gjson.Get(body, "ui.messages").String(), "Could not find any login identifiers") } }) }) t.Run("case=should have verifiable address even if after session hook is disabled", func(t *testing.T) { // disable the after session hook conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".code.hooks", []map[string]any{}) t.Cleanup(func() { conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".code.hooks", []map[string]any{ {"hook": "session"}, }) }) // 1. Initiate flow state := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) state = registerNewUser(ctx, t, state, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, state.email, "Use code") assert.Contains(t, message.Body, "Complete your account registration with the following code") registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, registrationCode) // 3. Submit OTP submitOTP(ctx, t, reg, state, func(v *url.Values) { v.Set("code", registrationCode) }, tc.apiType, nil) }) t.Run("case=code should expire", func(t *testing.T) { conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.config.lifespan", "10ns") t.Cleanup(func() { conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.config.lifespan", "1h") }) // 1. Initiate flow s := createRegistrationFlow(ctx, t, public, tc.apiType) // 2. Submit Identifier (email) s = registerNewUser(ctx, t, s, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, s.email, "Use code") assert.Contains(t, message.Body, "Complete your account registration with the following code") registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, registrationCode) submitOTP(ctx, t, reg, s, func(v *url.Values) { v.Set("code", registrationCode) }, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { if tc.apiType == ApiTypeBrowser { require.Equal(t, http.StatusOK, resp.StatusCode, "%s", body) } else { require.Equal(t, http.StatusBadRequest, resp.StatusCode, "%s", body) } require.Contains(t, gjson.Get(body, "ui.messages").String(), "The registration code is invalid or has already been used. Please try again", "%s", body) }) }) }) } }) t.Run("test=multi-schema select", func(t *testing.T) { t.Parallel() ctx := context.Background() conf, reg, public := setup(ctx, t, configx.WithValues(map[string]any{ config.ViperKeyIdentitySchemas: config.Schemas{ {ID: "code", URL: "file://./stub/code.identity.schema.json", SelfserviceSelectable: true}, {ID: "no-code", URL: "file://stub/no-code.schema.json", SelfserviceSelectable: true}, }, config.ViperKeyDefaultIdentitySchemaID: "no-code", })) for _, tc := range []struct { d string apiType ApiType }{ { d: "SPA client", apiType: ApiTypeSPA, }, { d: "Browser client", apiType: ApiTypeBrowser, }, { d: "Native client", apiType: ApiTypeNative, }, } { t.Run("flow="+tc.d, func(t *testing.T) { t.Run("case=should be able to register with code identity credentials", func(t *testing.T) { ctx := context.Background() // 1. Initiate flow state := createRegistrationFlowWithIdentity(ctx, t, public, tc.apiType, "code") state.email = testhelpers.RandomEmail() // 2. Submit Identifier (email) state = registerNewUser(ctx, t, state, tc.apiType, nil) message := testhelpers.CourierExpectMessage(ctx, t, reg, state.email, "Use code") assert.Contains(t, message.Body, "Complete your account registration with the following code") registrationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, registrationCode) // 3. Submit OTP state = submitOTP(ctx, t, reg, state, func(v *url.Values) { v.Set("code", registrationCode) }, tc.apiType, nil) switch tc.apiType { case ApiTypeSPA: assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(state.body, "continue_with.0.action").String(), "%s", state.body) assert.Contains(t, gjson.Get(state.body, "continue_with.0.redirect_browser_to").String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%s", state.body) case ApiTypeBrowser: assert.Empty(t, gjson.Get(state.body, "continue_with").Array(), "%s", state.body) case ApiTypeNative: assert.NotContains(t, gjson.Get(state.body, "continue_with").Raw, string(flow.ContinueWithActionRedirectBrowserToString), "%s", state.body) } identity, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, identity.CredentialsTypeCodeAuth, state.email) require.NoError(t, err, sqlcon.ErrNoRows()) assert.NotEmpty(t, identity.ID, "%s", identity.ID) assert.Equal(t, state.email, gjson.Get(identity.Traits.String(), "email").String(), "%s", identity.Traits.String()) assert.Equal(t, "code", identity.SchemaID, "%s", identity.SchemaID) }) t.Run("case=registration should fail with invalid form data", func(t *testing.T) { // 1. Initiate flow s := createRegistrationFlowWithIdentity(t.Context(), t, public, tc.apiType, "code") s.email = "invalidemail" // 2. Submit Identifier (email) registerNewUser(t.Context(), t, s, tc.apiType, func(ctx context.Context, t *testing.T, s *state, body string, resp *http.Response) { if tc.apiType == ApiTypeBrowser { require.EqualValues(t, http.StatusOK, resp.StatusCode) } else { require.EqualValues(t, http.StatusBadRequest, resp.StatusCode) } require.Equal(t, int64(4000040), gjson.Get(body, "ui.nodes.#(attributes.name==traits.email).messages.0.id").Int(), "%s", body) require.Equal(t, "Enter a valid email address", gjson.Get(body, "ui.nodes.#(attributes.name==traits.email).messages.0.text").String(), "%s", body) }) }) }) } }) } func TestPopulateRegistrationMethod(t *testing.T) { t.Parallel() _, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://stub/code.identity.schema.json")), configx.WithValue(fmt.Sprintf("%s.%s.passwordless_enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth), true), ) s, err := reg.AllRegistrationStrategies().Strategy(identity.CredentialsTypeCodeAuth) require.NoError(t, err) fh, ok := s.(registration.FormHydrator) require.True(t, ok) toSnapshot := func(t *testing.T, f node.Nodes) { t.Helper() // The CSRF token has a unique value that messes with the snapshot - ignore it. f.ResetNodes("csrf_token") snapshotx.SnapshotT(t, f, snapshotx.ExceptNestedKeys("nonce", "src")) } newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *registration.Flow) { r := httptest.NewRequest("GET", "/self-service/registration/browser", nil) r = r.WithContext(ctx) t.Helper() f, err := registration.NewFlow(reg, r, flow.TypeBrowser) f.UI.Nodes = make(node.Nodes, 0) require.NoError(t, err) return r, f } t.Run("method=PopulateRegistrationMethod", func(t *testing.T) { r, f := newFlow(t.Context(), t) require.NoError(t, fh.PopulateRegistrationMethod(r, f)) toSnapshot(t, f.UI.Nodes) }) t.Run("method=PopulateRegistrationMethodProfile", func(t *testing.T) { r, f := newFlow(t.Context(), t) require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f)) toSnapshot(t, f.UI.Nodes) }) t.Run("method=PopulateRegistrationMethodCredentials", func(t *testing.T) { r, f := newFlow(t.Context(), t) require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f)) toSnapshot(t, f.UI.Nodes) }) t.Run("method=idempotency", func(t *testing.T) { r, f := newFlow(t.Context(), t) var snapshots []node.Nodes t.Run("case=1", func(t *testing.T) { require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f)) snapshots = append(snapshots, f.UI.Nodes) toSnapshot(t, f.UI.Nodes) }) t.Run("case=2", func(t *testing.T) { require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f)) snapshots = append(snapshots, f.UI.Nodes) toSnapshot(t, f.UI.Nodes) }) t.Run("case=3", func(t *testing.T) { require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f)) snapshots = append(snapshots, f.UI.Nodes) toSnapshot(t, f.UI.Nodes) }) t.Run("case=4", func(t *testing.T) { require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f)) snapshots = append(snapshots, f.UI.Nodes) toSnapshot(t, f.UI.Nodes) }) t.Run("case=evaluate", func(t *testing.T) { assertx.EqualAsJSON(t, snapshots[0], snapshots[2]) assertx.EqualAsJSON(t, snapshots[1], snapshots[3]) }) }) } func TestCodeRegistrationWithLoginChallenge(t *testing.T) { t.Parallel() _, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeCodeAuth), map[string]any{ "enabled": true, "passwordless_enabled": true, }), configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/code.identity.schema.json")), ) reg.SetHydra(hydra.NewFake()) reg.WithCSRFTokenGenerator(nosurfx.FakeCSRFTokenGenerator) s := code.NewStrategy(reg) loginChallenge := hydra.FakeValidLoginChallenge newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *registration.Flow) { t.Helper() r := httptest.NewRequest("GET", "/self-service/registration/browser", nil) r = r.WithContext(ctx) f, err := registration.NewFlow(reg, r, flow.TypeBrowser) require.NoError(t, err) require.NoError(t, reg.RegistrationFlowPersister().CreateRegistrationFlow(ctx, f)) return r, f } t.Run("case=fetches login challenge on code input state", func(t *testing.T) { _, f := newFlow(t.Context(), t) f.OAuth2LoginChallenge = sqlxx.NullString(loginChallenge) i := identity.NewIdentity(f.IdentitySchema.ID(t.Context(), reg.Config())) email := testhelpers.RandomEmail() body := gjson.Parse(`{ "method": "code", "traits.email": "` + email + `", "csrf_token": "` + f.CSRFToken + `" }`) r := httptest.NewRequest("POST", "/self-service/registration/browser", strings.NewReader(body.Raw)) r.Header.Add("Content-Type", "application/json") err := s.Register(httptest.NewRecorder(), r, f, i) require.ErrorIs(t, err, flow.ErrCompletedByStrategy) require.NotNil(t, f.HydraLoginRequest) messages, err := reg.CourierPersister().NextMessages(t.Context(), 10) require.NoError(t, err) require.Len(t, messages, 1) assert.Equal(t, loginChallenge, gjson.GetBytes(messages[0].TemplateData, "oauth2_login_request.challenge").String()) assert.Equal(t, hydra.FakeClientID, gjson.GetBytes(messages[0].TemplateData, "oauth2_login_request.client.client_id").String()) assert.Equal(t, hydra.FakeClientName, gjson.GetBytes(messages[0].TemplateData, "oauth2_login_request.client.client_name").String()) assert.Equal(t, hydra.FakeClientLogoURI, gjson.GetBytes(messages[0].TemplateData, "oauth2_login_request.client.logo_uri").String()) }) t.Run("case=returns error if login challenge is invalid", func(t *testing.T) { _, f := newFlow(t.Context(), t) f.OAuth2LoginChallenge = sqlxx.NullString(hydra.FakeInvalidLoginChallenge) i := identity.NewIdentity(f.IdentitySchema.ID(t.Context(), reg.Config())) email := testhelpers.RandomEmail() body := gjson.Parse(`{ "method": "code", "traits.email": "` + email + `", "csrf_token": "` + f.CSRFToken + `" }`) r := httptest.NewRequest("POST", "/self-service/registration/browser", strings.NewReader(body.Raw)) r.Header.Add("Content-Type", "application/json") err := s.Register(httptest.NewRecorder(), r, f, i) require.ErrorIs(t, err, herodot.ErrBadRequest()) require.Nil(t, f.HydraLoginRequest) // Pin that the invalid login challenge aborts before any one-time code is queued. _, err = reg.CourierPersister().NextMessages(t.Context(), 1) require.ErrorIs(t, err, courier.ErrQueueEmpty) }) }