// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package registration_test import ( "context" "fmt" "net/http" nethttptest "net/http/httptest" "net/url" "testing" "github.com/gobuffalo/httptest" "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/hydra" "github.com/ory/kratos/identity" "github.com/ory/kratos/pkg" "github.com/ory/kratos/pkg/testhelpers" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/registration" "github.com/ory/kratos/selfservice/hook" "github.com/ory/kratos/session" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" "github.com/ory/x/sqlcon" "github.com/ory/x/sqlxx" ) func TestRegistrationExecutor(t *testing.T) { t.Parallel() ctx := context.Background() for _, strategy := range identity.AllCredentialTypes { strategy := strategy.String() t.Run("strategy="+strategy, func(t *testing.T) { t.Parallel() conf, reg := pkg.NewFastRegistryWithMocks(t) reg.SetHydra(hydra.NewFake()) testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json") conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToServer.URL) newServer := func(t *testing.T, i *identity.Identity, ft flow.Type, flowCallbacks ...func(*registration.Flow)) *httptest.Server { router := http.NewServeMux() handleErr := testhelpers.SelfServiceHookRegistrationErrorHandler router.HandleFunc("GET /registration/pre", func(w http.ResponseWriter, r *http.Request) { f, err := registration.NewFlow(reg, r, ft) require.NoError(t, err) if handleErr(t, w, r, reg.RegistrationHookExecutor().PreRegistrationHook(w, r, f)) { _, _ = w.Write([]byte("ok")) } }) router.HandleFunc("GET /registration/post", func(w http.ResponseWriter, r *http.Request) { if i == nil { i = testhelpers.SelfServiceHookFakeIdentity(t) } regFlow, err := registration.NewFlow(reg, r, ft) require.NoError(t, err) regFlow.RequestURL = x.RequestURL(r).String() for _, callback := range flowCallbacks { callback(regFlow) } _ = handleErr(t, w, r, reg.RegistrationHookExecutor().PostRegistrationHook(w, r, regFlow, i, session.AuthenticationMethod{ Method: identity.CredentialsType(strategy), AAL: identity.AuthenticatorAssuranceLevel1, })) }) ts := httptest.NewServer(router) t.Cleanup(ts.Close) conf.MustSet(ctx, config.ViperKeyPublicBaseURL, ts.URL) return ts } makeRequestPost := testhelpers.SelfServiceMakeRegistrationPostHookRequest viperSetPost := testhelpers.SelfServiceHookRegistrationViperSetPost t.Run("method=PostRegistrationHook", func(t *testing.T) { t.Run("case=pass without hooks", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) i := testhelpers.SelfServiceHookFakeIdentity(t) ts := newServer(t, i, flow.TypeBrowser) res, body := makeRequestPost(t, ts, false, url.Values{}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.EqualValues(t, returnToServer.URL, res.Request.URL.String()) actual, err := reg.IdentityPool().GetIdentity(context.Background(), i.ID, identity.ExpandNothing) require.NoError(t, err) assert.Equal(t, actual.Traits, i.Traits) }) t.Run("case=pass without hooks if ajax client", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) i := testhelpers.SelfServiceHookFakeIdentity(t) ts := newServer(t, i, flow.TypeBrowser) res, body := makeRequestPost(t, ts, true, url.Values{}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.Contains(t, res.Request.URL.String(), ts.URL) assert.JSONEq(t, fmt.Sprintf(`[{"action":"redirect_browser_to","redirect_browser_to":"%s"}]`, returnToServer.URL), gjson.Get(body, "continue_with").Raw) actual, err := reg.IdentityPool().GetIdentity(context.Background(), i.ID, identity.ExpandNothing) require.NoError(t, err) assert.Equal(t, actual.Traits, i.Traits) }) t.Run("case=pass if hooks pass", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) viperSetPost(t, conf, strategy, []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}}) res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.EqualValues(t, returnToServer.URL, res.Request.URL.String()) }) t.Run("case=fail if hooks fail", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) viperSetPost(t, conf, strategy, []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostRegistrationPrePersistHook": "abort"}`)}}) i := testhelpers.SelfServiceHookFakeIdentity(t) res, body := makeRequestPost(t, newServer(t, i, flow.TypeBrowser), false, url.Values{}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.Equal(t, "", body) _, err := reg.IdentityPool().GetIdentity(context.Background(), i.ID, identity.ExpandNothing) require.Error(t, err) }) t.Run("case=use return_to value", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnToServer.URL}) res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{"return_to": {returnToServer.URL + "/kratos"}}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.EqualValues(t, returnToServer.URL+"/kratos", res.Request.URL.String()) }) t.Run("case=use nested config value", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) testhelpers.SelfServiceHookRegistrationSetDefaultRedirectToStrategy(t, conf, strategy, returnToServer.URL+"/kratos") res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.EqualValues(t, returnToServer.URL+"/kratos", res.Request.URL.String()) }) t.Run("case=use nested config value", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnToServer.URL + "/kratos"}) testhelpers.SelfServiceHookRegistrationSetDefaultRedirectTo(t, conf, returnToServer.URL+"/not-kratos") testhelpers.SelfServiceHookRegistrationSetDefaultRedirectToStrategy(t, conf, strategy, returnToServer.URL+"/kratos") res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.EqualValues(t, returnToServer.URL+"/kratos", res.Request.URL.String()) }) t.Run("case=pass if hooks pass", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) viperSetPost(t, conf, strategy, []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}}) res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), false, url.Values{}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.EqualValues(t, returnToServer.URL, res.Request.URL.String()) }) t.Run("case=send a json response for API clients", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) res, body := makeRequestPost(t, newServer(t, nil, flow.TypeAPI), true, url.Values{}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.NotEmpty(t, gjson.Get(body, "identity.id")) }) t.Run("case=pass without hooks for browser flow with application/json", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) res, body := makeRequestPost(t, newServer(t, nil, flow.TypeBrowser), true, url.Values{}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.NotEmpty(t, gjson.Get(body, "identity.id")) assert.Empty(t, gjson.Get(body, "session.token")) assert.Empty(t, gjson.Get(body, "session_token")) }) t.Run("case=should redirect to verification UI if show_verification_ui hook is set", func(t *testing.T) { verificationTS := testhelpers.NewVerificationUIFlowEchoServer(t, reg) t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) conf.MustSet(ctx, config.ViperKeySelfServiceVerificationEnabled, true) conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".hooks", []map[string]interface{}{ { "hook": hook.KeyVerificationUI, }, }) i := testhelpers.SelfServiceHookFakeIdentity(t) i.Traits = identity.Traits(`{"email": "verifiable@ory.sh"}`) res, body := makeRequestPost(t, newServer(t, i, flow.TypeBrowser), false, url.Values{}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.Contains(t, res.Request.URL.String(), verificationTS.URL) assert.NotEmpty(t, res.Request.URL.Query().Get("flow")) }) t.Run("case=should redirect to verification UI if there is a login_challenge", func(t *testing.T) { verificationTS := testhelpers.NewVerificationUIFlowEchoServer(t, reg) t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) conf.MustSet(ctx, config.ViperKeySelfServiceVerificationEnabled, true) conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".hooks", []map[string]interface{}{{ "hook": hook.KeyVerificationUI, }}) i := testhelpers.SelfServiceHookFakeIdentity(t) i.Traits = identity.Traits(`{"email": "verifiable-valid-login_challenge@ory.sh"}`) withOAuthChallenge := func(f *registration.Flow) { f.OAuth2LoginChallenge = hydra.FakeValidLoginChallenge } res, body := makeRequestPost(t, newServer(t, i, flow.TypeBrowser, withOAuthChallenge), false, url.Values{}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.Contains(t, res.Request.URL.String(), verificationTS.URL) flowID := res.Request.URL.Query().Get("flow") require.NotEmpty(t, flowID) flow, err := reg.VerificationFlowPersister().GetVerificationFlow(ctx, uuid.Must(uuid.FromString(flowID))) require.NoError(t, err) assert.Equal(t, hydra.FakeValidLoginChallenge, flow.OAuth2LoginChallenge.String()) }) t.Run("case=should redirect to first verification UI if show_verification_ui hook is set and multiple verifiable addresses", func(t *testing.T) { verificationTS := testhelpers.NewVerificationUIFlowEchoServer(t, reg) t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) conf.MustSet(ctx, config.ViperKeySelfServiceVerificationEnabled, true) conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".hooks", []map[string]interface{}{ { "hook": hook.KeyVerificationUI, }, }) i := testhelpers.SelfServiceHookFakeIdentity(t) i.SchemaID = testhelpers.UseIdentitySchema(t, conf, "file://./stub/registration-multi-email.schema.json") i.Traits = identity.Traits(`{"emails": ["one@ory.sh", "two@ory.sh"]}`) res, body := makeRequestPost(t, newServer(t, i, flow.TypeBrowser), false, url.Values{}) require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body) assert.Contains(t, res.Request.URL.String(), verificationTS.URL) assert.NotEmpty(t, res.Request.URL.Query().Get("flow")) }) t.Run("case=should still sent session if show_verification_ui is set after session hook", func(t *testing.T) { verificationTS := testhelpers.NewVerificationUIFlowEchoServer(t, reg) t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) conf.MustSet(ctx, config.ViperKeySelfServiceVerificationEnabled, true) conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationAfter+".hooks", []map[string]interface{}{ { "hook": hook.KeyVerificationUI, }, { "hook": hook.KeySessionIssuer, }, }) i := testhelpers.SelfServiceHookFakeIdentity(t) i.Traits = identity.Traits(`{"email": "verifiable4@ory.sh"}`) jar := testhelpers.EasyCookieJar(t, nil) s := newServer(t, i, flow.TypeBrowser) s.Client().Jar = jar res, _ := makeRequestPost(t, s, false, url.Values{}) assert.EqualValues(t, http.StatusOK, res.StatusCode) assert.Contains(t, res.Request.URL.String(), verificationTS.URL) assert.NotEmpty(t, res.Request.URL.Query().Get("flow")) u, err := url.Parse(s.URL) require.NoError(t, err) cookies := jar.Cookies(u) require.Len(t, cookies, 1) assert.Equal(t, "ory_kratos_session", cookies[0].Name) }) }) t.Run("case=preserve trait values on duplicate credential error", func(t *testing.T) { t.Parallel() t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) tosSchema := testhelpers.UseIdentitySchema(t, conf, "file://./stub/registration-tos.schema.json") // Create an existing identity with a known email so the next registration triggers a unique violation. existing := &identity.Identity{ SchemaID: tosSchema, Traits: identity.Traits(`{"email":"duplicate@ory.sh"}`), State: identity.StateActive, } existing.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{"duplicate@ory.sh"}, Config: []byte(`{"hashed_password":"$argon2id$v=19$m=65536,t=1,p=1$abc$def"}`), }) require.NoError(t, reg.IdentityManager().Create(context.Background(), existing)) // Build a new identity with the same email and tos=true. duplicate := testhelpers.SelfServiceHookFakeIdentity(t) duplicate.SchemaID = tosSchema duplicate.Traits = identity.Traits(`{"email":"duplicate@ory.sh","tos":true}`) duplicate.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Type: identity.CredentialsTypePassword, Identifiers: []string{"duplicate@ory.sh"}, Config: []byte(`{"hashed_password":"$argon2id$v=19$m=65536,t=1,p=1$abc$def"}`), }) regFlow, err := registration.NewFlow(reg, nethttptest.NewRequest("GET", returnToServer.URL, nil), flow.TypeBrowser) require.NoError(t, err) regFlow.RequestURL = returnToServer.URL // Add a checkbox node for traits.tos (simulating PopulateRegistrationMethod from the profile strategy). regFlow.UI.Nodes.Upsert(node.NewInputField("traits.tos", false, node.DefaultGroup, node.InputAttributeTypeCheckbox)) err = reg.RegistrationHookExecutor().PostRegistrationHook( nethttptest.NewRecorder(), nethttptest.NewRequest("GET", returnToServer.URL, nil), regFlow, duplicate, session.AuthenticationMethod{ Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1, }, ) require.Error(t, err) // The TOS checkbox node must retain the submitted value (true) from the identity traits. tosNode := regFlow.UI.Nodes.Find("traits.tos") require.NotNil(t, tosNode, "traits.tos node must exist in the flow UI") assert.Equal(t, true, tosNode.GetValue(), "traits.tos must be true after duplicate credential error") }) for _, kind := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} { t.Run("type="+string(kind)+"/method=PreRegistrationHook", testhelpers.TestSelfServicePreHook( config.ViperKeySelfServiceRegistrationBeforeHooks, testhelpers.SelfServiceMakeRegistrationPreHookRequest, func(t *testing.T) *httptest.Server { return newServer(t, nil, kind) }, conf, )) } }) } } func TestPostRegistrationHookAccountLinkingOnly(t *testing.T) { t.Parallel() ctx := t.Context() conf, reg := pkg.NewFastRegistryWithMocks(t) reg.SetHydra(hydra.NewFake()) testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json") conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToServer.URL) conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, false) newIdentityWithEmail := func(email string) *identity.Identity { i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) i.Traits = identity.Traits(`{"email":"` + email + `"}`) i.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$fakefakefakefakefakefa"}`), }) return i } runPostRegistrationHook := func(t *testing.T, i *identity.Identity) error { r := httptest.NewRequest("GET", "/registration/post", nil) r = r.WithContext(ctx) regFlow, err := registration.NewFlow(reg, r, flow.TypeAPI) require.NoError(t, err) registration.WithFlowAccountLinking()(regFlow) return reg.RegistrationHookExecutor().PostRegistrationHook(httptest.NewRecorder(), r, regFlow, i, session.AuthenticationMethod{ Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1, }) } t.Run("case=rejects a new identity when registration is disabled", func(t *testing.T) { email := "linking-guard-new@ory.sh" err := runPostRegistrationHook(t, newIdentityWithEmail(email)) require.ErrorContains(t, err, registration.ErrRegistrationDisabled().Error()) _, _, err = reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, identity.CredentialsTypePassword, email) require.ErrorIs(t, err, sqlcon.ErrNoRows()) }) t.Run("case=proceeds into the duplicate conflict when an identity exists", func(t *testing.T) { email := "linking-guard-existing@ory.sh" existing := newIdentityWithEmail(email) require.NoError(t, reg.IdentityManager().Create(ctx, existing)) err := runPostRegistrationHook(t, newIdentityWithEmail(email)) // The guard must let the create attempt happen so that the unique // violation triggers the account linking conversion. require.ErrorIs(t, err, sqlcon.ErrUniqueViolation()) }) }