From fb066feff5b05e1db55b871ab301b7f8220d773b Mon Sep 17 00:00:00 2001 From: camero2734 <42698419+camero2734@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:10:30 +0200 Subject: [PATCH 1/4] Add account enumeration mitigation for registration flow - Add AntiEnumerationFlow flag to registration flow - Send duplicate registration email when account exists - Skip identity creation error when enumeration mitigation enabled --- .schemastore/config.schema.json | 14 +- .../registration/duplicate/email.body.gotmpl | 5 + .../duplicate/email.body.plaintext.gotmpl | 5 + .../duplicate/email.subject.gotmpl | 1 + .../template/email/registration_duplicate.go | 56 +++++ .../email/registration_duplicate_test.go | 60 +++++ courier/template/type.go | 23 +- courier/templates.go | 6 + driver/config/config.go | 6 + driver/registry_default.go | 1 + driver/registry_default_registration.go | 15 +- embedx/config.schema.json | 14 +- selfservice/flow/registration/flow.go | 2 + selfservice/flow/registration/hook.go | 97 +++++--- selfservice/flow/registration/sender.go | 72 ++++++ selfservice/flow/verification/session.go | 49 ++++ .../strategy_verification_session_test.go | 215 ++++++++++++++++++ .../strategy_verification_session_test.go | 187 +++++++++++++++ .../password/registration_enumeration_test.go | 135 +++++++++++ 19 files changed, 910 insertions(+), 53 deletions(-) create mode 100644 courier/template/courier/builtin/templates/registration/duplicate/email.body.gotmpl create mode 100644 courier/template/courier/builtin/templates/registration/duplicate/email.body.plaintext.gotmpl create mode 100644 courier/template/courier/builtin/templates/registration/duplicate/email.subject.gotmpl create mode 100644 courier/template/email/registration_duplicate.go create mode 100644 courier/template/email/registration_duplicate_test.go create mode 100644 selfservice/flow/registration/sender.go create mode 100644 selfservice/flow/verification/session.go create mode 100644 selfservice/strategy/code/strategy_verification_session_test.go create mode 100644 selfservice/strategy/link/strategy_verification_session_test.go create mode 100644 selfservice/strategy/password/registration_enumeration_test.go diff --git a/.schemastore/config.schema.json b/.schemastore/config.schema.json index 3a8297d9d1c7..44a466c0737a 100644 --- a/.schemastore/config.schema.json +++ b/.schemastore/config.schema.json @@ -1011,7 +1011,19 @@ "$ref": "#/definitions/defaultReturnTo" }, "hooks": { - "$ref": "#/definitions/selfServiceHooks" + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/selfServiceSessionIssuerHook" + }, + { + "$ref": "#/definitions/selfServiceWebHook" + } + ] + }, + "uniqueItems": true, + "additionalItems": false } } }, diff --git a/courier/template/courier/builtin/templates/registration/duplicate/email.body.gotmpl b/courier/template/courier/builtin/templates/registration/duplicate/email.body.gotmpl new file mode 100644 index 000000000000..b4ad3702286d --- /dev/null +++ b/courier/template/courier/builtin/templates/registration/duplicate/email.body.gotmpl @@ -0,0 +1,5 @@ +Someone tried to create an account with this email address. + +If this was you, you may already have an account. Try signing in instead. + +If this was not you, you can safely ignore this email. diff --git a/courier/template/courier/builtin/templates/registration/duplicate/email.body.plaintext.gotmpl b/courier/template/courier/builtin/templates/registration/duplicate/email.body.plaintext.gotmpl new file mode 100644 index 000000000000..b4ad3702286d --- /dev/null +++ b/courier/template/courier/builtin/templates/registration/duplicate/email.body.plaintext.gotmpl @@ -0,0 +1,5 @@ +Someone tried to create an account with this email address. + +If this was you, you may already have an account. Try signing in instead. + +If this was not you, you can safely ignore this email. diff --git a/courier/template/courier/builtin/templates/registration/duplicate/email.subject.gotmpl b/courier/template/courier/builtin/templates/registration/duplicate/email.subject.gotmpl new file mode 100644 index 000000000000..e72b0e00125f --- /dev/null +++ b/courier/template/courier/builtin/templates/registration/duplicate/email.subject.gotmpl @@ -0,0 +1 @@ +Account registration attempt diff --git a/courier/template/email/registration_duplicate.go b/courier/template/email/registration_duplicate.go new file mode 100644 index 000000000000..0211b602745c --- /dev/null +++ b/courier/template/email/registration_duplicate.go @@ -0,0 +1,56 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package email + +import ( + "context" + "encoding/json" + "os" + "strings" + + "github.com/ory/kratos/courier/template" +) + +type ( + RegistrationDuplicate struct { + d template.Dependencies + m *RegistrationDuplicateModel + } + RegistrationDuplicateModel struct { + To string `json:"to"` + Identity map[string]interface{} `json:"identity"` + RequestURL string `json:"request_url"` + TransientPayload map[string]interface{} `json:"transient_payload"` + } +) + +func NewRegistrationDuplicate(d template.Dependencies, m *RegistrationDuplicateModel) *RegistrationDuplicate { + return &RegistrationDuplicate{d: d, m: m} +} + +func (t *RegistrationDuplicate) EmailRecipient() (string, error) { + return t.m.To, nil +} + +func (t *RegistrationDuplicate) EmailSubject(ctx context.Context) (string, error) { + subject, err := template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "registration/duplicate/email.subject.gotmpl", "registration/duplicate/email.subject*", t.m, t.d.CourierConfig().CourierTemplatesRegistrationDuplicate(ctx).Subject) + + return strings.TrimSpace(subject), err +} + +func (t *RegistrationDuplicate) EmailBody(ctx context.Context) (string, error) { + return template.LoadHTML(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "registration/duplicate/email.body.gotmpl", "registration/duplicate/email.body*", t.m, t.d.CourierConfig().CourierTemplatesRegistrationDuplicate(ctx).Body.HTML) +} + +func (t *RegistrationDuplicate) EmailBodyPlaintext(ctx context.Context) (string, error) { + return template.LoadText(ctx, t.d, os.DirFS(t.d.CourierConfig().CourierTemplatesRoot(ctx)), "registration/duplicate/email.body.plaintext.gotmpl", "registration/duplicate/email.body.plaintext*", t.m, t.d.CourierConfig().CourierTemplatesRegistrationDuplicate(ctx).Body.PlainText) +} + +func (t *RegistrationDuplicate) MarshalJSON() ([]byte, error) { + return json.Marshal(t.m) +} + +func (t *RegistrationDuplicate) TemplateType() template.TemplateType { + return template.TypeRegistrationDuplicateEmail +} diff --git a/courier/template/email/registration_duplicate_test.go b/courier/template/email/registration_duplicate_test.go new file mode 100644 index 000000000000..44327889534a --- /dev/null +++ b/courier/template/email/registration_duplicate_test.go @@ -0,0 +1,60 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package email_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/kratos/courier/template/email" + "github.com/ory/kratos/internal" +) + +func TestNewRegistrationDuplicate(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + _, reg := internal.NewFastRegistryWithMocks(t) + + model := &email.RegistrationDuplicateModel{ + To: "test@example.com", + RequestURL: "https://www.ory.sh/verify", + TransientPayload: map[string]interface{}{ + "foo": "bar", + }, + } + + tpl := email.NewRegistrationDuplicate(reg, model) + + t.Run("case=renders subject", func(t *testing.T) { + subject, err := tpl.EmailSubject(ctx) + require.NoError(t, err) + assert.NotEmpty(t, subject) + assert.Contains(t, subject, "Account registration attempt") + }) + + t.Run("case=renders body html", func(t *testing.T) { + body, err := tpl.EmailBody(ctx) + require.NoError(t, err) + assert.NotEmpty(t, body) + assert.Contains(t, body, "Someone tried to create an account") + }) + + t.Run("case=renders body plaintext", func(t *testing.T) { + body, err := tpl.EmailBodyPlaintext(ctx) + require.NoError(t, err) + assert.NotEmpty(t, body) + assert.Contains(t, body, "Someone tried to create an account") + }) + + t.Run("case=email recipient", func(t *testing.T) { + recipient, err := tpl.EmailRecipient() + require.NoError(t, err) + assert.Equal(t, "test@example.com", recipient) + }) +} diff --git a/courier/template/type.go b/courier/template/type.go index 4fc0b9bccca2..46b02b1c9dd0 100644 --- a/courier/template/type.go +++ b/courier/template/type.go @@ -9,15 +9,16 @@ package template type TemplateType string const ( - TypeRecoveryInvalid TemplateType = "recovery_invalid" - TypeRecoveryValid TemplateType = "recovery_valid" - TypeRecoveryCodeInvalid TemplateType = "recovery_code_invalid" - TypeRecoveryCodeValid TemplateType = "recovery_code_valid" - TypeVerificationInvalid TemplateType = "verification_invalid" - TypeVerificationValid TemplateType = "verification_valid" - TypeVerificationCodeInvalid TemplateType = "verification_code_invalid" - TypeVerificationCodeValid TemplateType = "verification_code_valid" - TypeTestStub TemplateType = "stub" - TypeLoginCodeValid TemplateType = "login_code_valid" - TypeRegistrationCodeValid TemplateType = "registration_code_valid" + TypeRecoveryInvalid TemplateType = "recovery_invalid" + TypeRecoveryValid TemplateType = "recovery_valid" + TypeRecoveryCodeInvalid TemplateType = "recovery_code_invalid" + TypeRecoveryCodeValid TemplateType = "recovery_code_valid" + TypeVerificationInvalid TemplateType = "verification_invalid" + TypeVerificationValid TemplateType = "verification_valid" + TypeVerificationCodeInvalid TemplateType = "verification_code_invalid" + TypeVerificationCodeValid TemplateType = "verification_code_valid" + TypeTestStub TemplateType = "stub" + TypeLoginCodeValid TemplateType = "login_code_valid" + TypeRegistrationCodeValid TemplateType = "registration_code_valid" + TypeRegistrationDuplicateEmail TemplateType = "registration_duplicate" ) diff --git a/courier/templates.go b/courier/templates.go index 4ffb2c3f766a..621ced41d2c0 100644 --- a/courier/templates.go +++ b/courier/templates.go @@ -97,6 +97,12 @@ func NewEmailTemplateFromMessage(d template.Dependencies, msg Message) (EmailTem return nil, err } return email.NewRegistrationCodeValid(d, &t), nil + case template.TypeRegistrationDuplicateEmail: + var t email.RegistrationDuplicateModel + if err := json.Unmarshal(msg.TemplateData, &t); err != nil { + return nil, err + } + return email.NewRegistrationDuplicate(d, &t), nil default: return nil, errors.Errorf("received unexpected message template type: %s", msg.TemplateType) } diff --git a/driver/config/config.go b/driver/config/config.go index b392413e02e4..09837e17ab3c 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -76,6 +76,7 @@ const ( ViperKeyCourierHTTPRequestConfig = "courier.http.request_config" ViperKeyCourierTemplatesLoginCodeValidEmail = "courier.templates.login_code.valid.email" ViperKeyCourierTemplatesRegistrationCodeValidEmail = "courier.templates.registration_code.valid.email" + ViperKeyCourierTemplatesRegistrationDuplicateEmail = "courier.templates.registration.duplicate.email" ViperKeyCourierSMTP = "courier.smtp" ViperKeyCourierSMTPFrom = "courier.smtp.from_address" ViperKeyCourierSMTPFromName = "courier.smtp.from_name" @@ -314,6 +315,7 @@ type ( CourierTemplatesVerificationCodeValid(ctx context.Context) *CourierEmailTemplate CourierTemplatesLoginCodeValid(ctx context.Context) *CourierEmailTemplate CourierTemplatesRegistrationCodeValid(ctx context.Context) *CourierEmailTemplate + CourierTemplatesRegistrationDuplicate(ctx context.Context) *CourierEmailTemplate CourierSMSTemplatesVerificationCodeValid(ctx context.Context) *CourierSMSTemplate CourierSMSTemplatesRecoveryCodeValid(ctx context.Context) *CourierSMSTemplate CourierSMSTemplatesLoginCodeValid(ctx context.Context) *CourierSMSTemplate @@ -1177,6 +1179,10 @@ func (p *Config) CourierSMSTemplatesRegistrationCodeValid(ctx context.Context) * return p.CourierSMSTemplatesHelper(ctx, ViperKeyCourierTemplatesRegistrationCodeValidSMS) } +func (p *Config) CourierTemplatesRegistrationDuplicate(ctx context.Context) *CourierEmailTemplate { + return p.CourierEmailTemplatesHelper(ctx, ViperKeyCourierTemplatesRegistrationDuplicateEmail) +} + func (p *Config) CourierTemplatesLoginCodeValid(ctx context.Context) *CourierEmailTemplate { return p.CourierEmailTemplatesHelper(ctx, ViperKeyCourierTemplatesLoginCodeValidEmail) } diff --git a/driver/registry_default.go b/driver/registry_default.go index 455e003cb80d..507f8b459b48 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -122,6 +122,7 @@ type RegistryDefault struct { selfserviceRegistrationHandler *registration.Handler seflserviceRegistrationErrorHandler *registration.ErrorHandler selfserviceRegistrationRequestErrorHandler *registration.ErrorHandler + selfserviceRegistrationSender *registration.Sender selfserviceLoginExecutor *login.HookExecutor selfserviceLoginHandler *login.Handler diff --git a/driver/registry_default_registration.go b/driver/registry_default_registration.go index eccfa7c56d05..4cd6bcea32d4 100644 --- a/driver/registry_default_registration.go +++ b/driver/registry_default_registration.go @@ -13,7 +13,7 @@ import ( ) func (m *RegistryDefault) PostRegistrationPrePersistHooks(ctx context.Context, credentialsType identity.CredentialsType) ([]registration.PostHookPrePersistExecutor, error) { - hooks, err := getHooks[registration.PostHookPrePersistExecutor](m, string(credentialsType), m.Config().SelfServiceFlowRegistrationAfterHooks(ctx, string(credentialsType))) + hooks, err := getHooks[registration.PostHookPrePersistExecutor](m, ctx, string(credentialsType), m.Config().SelfServiceFlowRegistrationAfterHooks(ctx, string(credentialsType))) if err != nil { return nil, err } @@ -22,14 +22,14 @@ func (m *RegistryDefault) PostRegistrationPrePersistHooks(ctx context.Context, c } func (m *RegistryDefault) PostRegistrationPostPersistHooks(ctx context.Context, credentialsType identity.CredentialsType) ([]registration.PostHookPostPersistExecutor, error) { - hooks, err := getHooks[registration.PostHookPostPersistExecutor](m, string(credentialsType), m.Config().SelfServiceFlowRegistrationAfterHooks(ctx, string(credentialsType))) + hooks, err := getHooks[registration.PostHookPostPersistExecutor](m, ctx, string(credentialsType), m.Config().SelfServiceFlowRegistrationAfterHooks(ctx, string(credentialsType))) if err != nil { return nil, err } if len(hooks) == 0 { // since we don't want merging hooks defined in a specific strategy and // global hooks are added only if no strategy specific hooks are defined - hooks, err = getHooks[registration.PostHookPostPersistExecutor](m, config.HookGlobal, m.Config().SelfServiceFlowRegistrationAfterHooks(ctx, config.HookGlobal)) + hooks, err = getHooks[registration.PostHookPostPersistExecutor](m, ctx, config.HookGlobal, m.Config().SelfServiceFlowRegistrationAfterHooks(ctx, config.HookGlobal)) if err != nil { return nil, err } @@ -44,7 +44,7 @@ func (m *RegistryDefault) PostRegistrationPostPersistHooks(ctx context.Context, } func (m *RegistryDefault) PreRegistrationHooks(ctx context.Context) ([]registration.PreHookExecutor, error) { - return getHooks[registration.PreHookExecutor](m, "", m.Config().SelfServiceFlowRegistrationBeforeHooks(ctx)) + return getHooks[registration.PreHookExecutor](m, ctx, "", m.Config().SelfServiceFlowRegistrationBeforeHooks(ctx)) } func (m *RegistryDefault) RegistrationExecutor() *registration.HookExecutor { @@ -83,3 +83,10 @@ func (m *RegistryDefault) RegistrationFlowErrorHandler() *registration.ErrorHand return m.selfserviceRegistrationRequestErrorHandler } + +func (m *RegistryDefault) RegistrationSender() *registration.Sender { + if m.selfserviceRegistrationSender == nil { + m.selfserviceRegistrationSender = registration.NewSender(m) + } + return m.selfserviceRegistrationSender +} diff --git a/embedx/config.schema.json b/embedx/config.schema.json index e69c24108729..7fbe80744fde 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -1018,7 +1018,19 @@ "$ref": "#/definitions/defaultReturnTo" }, "hooks": { - "$ref": "#/definitions/selfServiceHooks" + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/selfServiceSessionIssuerHook" + }, + { + "$ref": "#/definitions/selfServiceWebHook" + } + ] + }, + "uniqueItems": true, + "additionalItems": false } } }, diff --git a/selfservice/flow/registration/flow.go b/selfservice/flow/registration/flow.go index cb0301f1deb2..1229aa187965 100644 --- a/selfservice/flow/registration/flow.go +++ b/selfservice/flow/registration/flow.go @@ -126,6 +126,8 @@ type Flow struct { IDToken string `json:"-" faker:"-" db:"-"` // Only used internally RawIDTokenNonce string `json:"-" db:"-"` + // Only used internally + AntiEnumerationFlow bool `json:"-" db:"-"` // IdentitySchema optionally holds the ID of the identity schema that is used // for this flow. This value can be set by the user when creating the flow and diff --git a/selfservice/flow/registration/hook.go b/selfservice/flow/registration/hook.go index b2f73dd98fff..0c07ee1686a7 100644 --- a/selfservice/flow/registration/hook.go +++ b/selfservice/flow/registration/hook.go @@ -90,6 +90,7 @@ type ( x.WriterProvider x.TracingProvider sessiontokenexchange.PersistenceProvider + RegistrationSenderProvider } HookExecutor struct { d executorDependencies @@ -97,6 +98,9 @@ type ( HookExecutorProvider interface { RegistrationExecutor() *HookExecutor } + RegistrationSenderProvider interface { + RegistrationSender() *Sender + } ) func NewHookExecutor(d executorDependencies) *HookExecutor { @@ -163,28 +167,35 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque // would imply that the identity has to exist already. if err := e.d.IdentityManager().Create(ctx, i); err != nil { if errors.Is(err, sqlcon.ErrUniqueViolation) { - strategy, err := e.d.AllLoginStrategies().Strategy(ct) - if err != nil { - return err - } - - if strategy, ok := strategy.(login.LinkableStrategy); ok { - duplicateIdentifier, err := e.getDuplicateIdentifier(ctx, i) + if e.d.Config().SecurityAccountEnumerationMitigate(ctx) { + e.d.Logger().WithError(err).Info("Identity already exists, but continuing due to account enumeration mitigation being enabled.") + registrationFlow.AntiEnumerationFlow = true + } else { + strategy, err := e.d.AllLoginStrategies().Strategy(ct) if err != nil { return err } - if err := strategy.SetDuplicateCredentials( - registrationFlow, - duplicateIdentifier, - i.Credentials[ct], - provider, - ); err != nil { - return err + if strategy, ok := strategy.(login.LinkableStrategy); ok { + duplicateIdentifier, err := e.getDuplicateIdentifier(ctx, i) + if err != nil { + return err + } + + if err := strategy.SetDuplicateCredentials( + registrationFlow, + duplicateIdentifier, + i.Credentials[ct], + provider, + ); err != nil { + return err + } } } } - return err + if !registrationFlow.AntiEnumerationFlow { + return err + } } // At this point the identity is already created and will not be rolled back, so @@ -205,33 +216,44 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque return err } - span.SetAttributes(otelx.StringAttrs(map[string]string{ - "return_to": returnTo.String(), - "flow_type": string(registrationFlow.Type), - "redirect_reason": "registration successful", - })...) - if registrationFlow.Type == flow.TypeBrowser && x.IsJSONRequest(r) { registrationFlow.AddContinueWith(flow.NewContinueWithRedirectBrowserTo(returnTo.String())) } - e.d.Audit(). - WithRequest(r). - WithField("identity_id", i.ID). - Info("A new identity has registered using self-service registration.") + if !registrationFlow.AntiEnumerationFlow { + span.SetAttributes(otelx.StringAttrs(map[string]string{ + "return_to": returnTo.String(), + "flow_type": string(registrationFlow.Type), + "redirect_reason": "registration successful", + })...) - span.AddEvent(events.NewRegistrationSucceeded(ctx, registrationFlow.ID, i.ID, string(registrationFlow.Type), registrationFlow.Active.String(), provider)) + e.d.Audit(). + WithRequest(r). + WithField("identity_id", i.ID). + Info("A new identity has registered using self-service registration.") + + span.AddEvent(events.NewRegistrationSucceeded(ctx, registrationFlow.ID, i.ID, string(registrationFlow.Type), registrationFlow.Active.String(), provider)) + } else { + // To avoid account enumeration (when enabled), we do not return an error here. Instead, we continue + // as if the identity was just created and send an email to the address explaining the situation. + if err := e.d.RegistrationSender().SendDuplicateRegistrationEmail(ctx, i, registrationFlow); err != nil { + e.d.Logger().WithError(err).Error("Failed to send duplicate registration email") + } + } s := session.NewInactiveSession() - s.CompletedLoginForWithProvider(ct, identity.AuthenticatorAssuranceLevel1, provider, organizationID) - if err := e.d.SessionManager().ActivateSession(r, s, i, time.Now().UTC()); err != nil { - return err - } + if !registrationFlow.AntiEnumerationFlow { + if err := e.d.SessionManager().ActivateSession(r, s, i, time.Now().UTC()); err != nil { + return err + } - // We persist the session here so that subsequent hooks (like verification) can use it. - if err := e.d.SessionPersister().UpsertSession(ctx, s); err != nil { - return err + // We persist the session here so that subsequent hooks (like verification) can use it. + if err := e.d.SessionPersister().UpsertSession(ctx, s); err != nil { + return err + } + } else { + s.Identity = i } e.d.Logger(). @@ -303,10 +325,13 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque return nil } - e.d.Writer().Write(w, r, &APIFlowResponse{ - Identity: i, + response := &APIFlowResponse{ ContinueWith: registrationFlow.ContinueWith(), - }) + } + if !e.d.Config().SecurityAccountEnumerationMitigate(ctx) { + response.Identity = i + } + e.d.Writer().Write(w, r, response) return nil } diff --git a/selfservice/flow/registration/sender.go b/selfservice/flow/registration/sender.go new file mode 100644 index 000000000000..ecf6800d927d --- /dev/null +++ b/selfservice/flow/registration/sender.go @@ -0,0 +1,72 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package registration + +import ( + "context" + "encoding/json" + + "github.com/ory/kratos/courier" + "github.com/ory/kratos/courier/template" + "github.com/ory/kratos/courier/template/email" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/x" +) + +type senderDependencies interface { + courier.Provider + courier.ConfigProvider + x.HTTPClientProvider +} + +type Sender struct { + d senderDependencies +} + +func NewSender(d senderDependencies) *Sender { + return &Sender{d: d} +} + +func (s *Sender) SendDuplicateRegistrationEmail(ctx context.Context, i *identity.Identity, f *Flow) error { + emailAddr := "" + for _, a := range i.VerifiableAddresses { + if a.Via == identity.AddressTypeEmail { + emailAddr = a.Value + break + } + } + + if emailAddr == "" { + return nil + } + + identityMap := make(map[string]interface{}) + if i.Traits != nil { + if err := json.Unmarshal(i.Traits, &identityMap); err != nil { + return err + } + } + + var transientPayload map[string]interface{} + if f.TransientPayload != nil { + if err := json.Unmarshal(f.TransientPayload, &transientPayload); err != nil { + return err + } + } + + model := &email.RegistrationDuplicateModel{ + To: emailAddr, + Identity: identityMap, + RequestURL: f.RequestURL, + TransientPayload: transientPayload, + } + + c, err := s.d.Courier(ctx) + if err != nil { + return err + } + + _, err = c.QueueEmail(ctx, email.NewRegistrationDuplicate(s.d.(template.Dependencies), model)) + return err +} diff --git a/selfservice/flow/verification/session.go b/selfservice/flow/verification/session.go new file mode 100644 index 000000000000..4d48cd8862a8 --- /dev/null +++ b/selfservice/flow/verification/session.go @@ -0,0 +1,49 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package verification + +import ( + "github.com/ory/kratos/identity" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/session" +) + +// The Response for Verification Flows via API +// +// swagger:model successfulNativeVerification +type APIFlowResponse struct { + // The Session Token + // + // This field is only set when the session hook is configured as a post-verification hook. + // + // A session token is equivalent to a session cookie, but it can be sent in the HTTP Authorization + // Header: + // + // Authorization: bearer ${session-token} + // + // The session token is only issued for API flows, not for Browser flows! + Token string `json:"session_token,omitempty"` + + // The Session + // + // This field is only set when the session hook is configured as a post-verification hook. + // + // The session contains information about the user, the session device, and so on. + // This is only available for API flows, not for Browser flows! + Session *session.Session `json:"session,omitempty"` + + // The Identity + // + // The identity that was verified. + // + // required: true + Identity *identity.Identity `json:"identity"` + + // Contains a list of actions, that could follow this flow + // + // It can, for example, contain a reference to another flow or the session token. + // + // required: false + ContinueWith []flow.ContinueWith `json:"continue_with"` +} diff --git a/selfservice/strategy/code/strategy_verification_session_test.go b/selfservice/strategy/code/strategy_verification_session_test.go new file mode 100644 index 000000000000..96f9d5512c12 --- /dev/null +++ b/selfservice/strategy/code/strategy_verification_session_test.go @@ -0,0 +1,215 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package code_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "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/identity" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/x/sqlxx" +) + +func TestVerificationWithSessionHook(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + initViper(t, ctx, conf) + + conf.MustSet(ctx, config.ViperKeySelfServiceVerificationAfter+".hooks", []map[string]interface{}{ + {"hook": "session"}, + }) + + _ = testhelpers.NewVerificationUIFlowEchoServer(t, reg) + _ = testhelpers.NewErrorTestServer(t, reg) + + public, _ := testhelpers.NewKratosServerWithCSRF(t, reg) + + createIdentity := func(email string) *identity.Identity { + id := &identity.Identity{ + Credentials: map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypeCodeAuth: { + Type: identity.CredentialsTypeCodeAuth, + Identifiers: []string{email}, + Config: sqlxx.JSONRawMessage(`{}`), + }, + }, + Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, email)), + SchemaID: config.DefaultIdentityTraitsSchemaID, + State: identity.StateActive, + } + require.NoError(t, reg.IdentityManager().Create(ctx, id, identity.ManagerAllowWriteProtectedTraits)) + return id + } + + t.Run("case=browser flow creates session after verification", func(t *testing.T) { + email := testhelpers.RandomEmail() + _ = createIdentity(email) + + client := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeVerificationFlowViaBrowser(t, client, false, public) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("email", email) + values.Set("method", "code") + + body, resp := testhelpers.VerificationMakeRequest(t, false, f, client, testhelpers.EncodeFormAsJSON(t, false, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Use code") + require.Contains(t, message.Body, "Verify your account") + + verificationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + require.NotEmpty(t, verificationCode) + + values.Set("code", verificationCode) + body, resp = testhelpers.VerificationMakeRequest(t, false, f, client, testhelpers.EncodeFormAsJSON(t, false, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + session, _, err := testhelpers.NewSDKCustomClient(public, client).FrontendAPI.ToSession(ctx).Execute() + require.NoError(t, err) + require.NotNil(t, session) + traitsJSON, _ := session.Identity.Traits.(map[string]interface{}) + assert.Equal(t, email, traitsJSON["email"]) + }) + + t.Run("case=SPA flow creates session after verification", func(t *testing.T) { + email := testhelpers.RandomEmail() + _ = createIdentity(email) + + client := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeVerificationFlowViaBrowser(t, client, true, public) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("email", email) + values.Set("method", "code") + + body, resp := testhelpers.VerificationMakeRequest(t, false, f, client, testhelpers.EncodeFormAsJSON(t, false, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Use code") + verificationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + values.Set("code", verificationCode) + body, resp = testhelpers.VerificationMakeRequest(t, false, f, client, testhelpers.EncodeFormAsJSON(t, false, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + session, _, err := testhelpers.NewSDKCustomClient(public, client).FrontendAPI.ToSession(ctx).Execute() + require.NoError(t, err) + require.NotNil(t, session) + traitsJSON, _ := session.Identity.Traits.(map[string]interface{}) + assert.Equal(t, email, traitsJSON["email"]) + }) + + t.Run("case=API flow returns session token after verification", func(t *testing.T) { + email := testhelpers.RandomEmail() + _ = createIdentity(email) + + client := &http.Client{} + f := testhelpers.InitializeVerificationFlowViaAPI(t, client, public) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("email", email) + values.Set("method", "code") + + body, resp := testhelpers.VerificationMakeRequest(t, true, f, client, testhelpers.EncodeFormAsJSON(t, true, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Use code") + verificationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + flowID := gjson.Get(body, "id").String() + vf, _, err := testhelpers.NewSDKCustomClient(public, client).FrontendAPI.GetVerificationFlow(ctx).Id(flowID).Execute() + require.NoError(t, err) + + values = testhelpers.SDKFormFieldsToURLValues(vf.Ui.Nodes) + values.Set("code", verificationCode) + values.Set("method", "code") + + body, resp = testhelpers.VerificationMakeRequest(t, true, vf, client, testhelpers.EncodeFormAsJSON(t, true, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + sessionToken := gjson.Get(body, "session_token").String() + require.NotEmpty(t, sessionToken, "session_token should be present in API response") + + sessionData := gjson.Get(body, "session") + require.True(t, sessionData.Exists(), "session should be present in API response") + assert.Equal(t, email, gjson.Get(body, "session.identity.traits.email").String()) + + identityData := gjson.Get(body, "identity") + require.True(t, identityData.Exists(), "identity should be present") + assert.Equal(t, email, gjson.Get(body, "identity.traits.email").String()) + }) + + t.Run("case=verification flow with session hook doesn't set anti_enumeration_flow flag", func(t *testing.T) { + email := testhelpers.RandomEmail() + _ = createIdentity(email) + + client := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeVerificationFlowViaBrowser(t, client, false, public) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("email", email) + values.Set("method", "code") + + body, resp := testhelpers.VerificationMakeRequest(t, false, f, client, testhelpers.EncodeFormAsJSON(t, false, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + flowID := gjson.Get(body, "id").String() + flow, err := reg.VerificationFlowPersister().GetVerificationFlow(ctx, uuid.Must(uuid.FromString(flowID))) + require.NoError(t, err) + assert.False(t, flow.AntiEnumerationFlow, "verification flow should not be marked as anti-enumeration") + }) + + t.Run("case=continue_with contains session info for API flow", func(t *testing.T) { + email := testhelpers.RandomEmail() + _ = createIdentity(email) + + client := &http.Client{} + f := testhelpers.InitializeVerificationFlowViaAPI(t, client, public) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("email", email) + values.Set("method", "code") + + body, resp := testhelpers.VerificationMakeRequest(t, true, f, client, testhelpers.EncodeFormAsJSON(t, true, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Use code") + verificationCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + flowID := gjson.Get(body, "id").String() + vf, _, err := testhelpers.NewSDKCustomClient(public, client).FrontendAPI.GetVerificationFlow(ctx).Id(flowID).Execute() + require.NoError(t, err) + + values = testhelpers.SDKFormFieldsToURLValues(vf.Ui.Nodes) + values.Set("code", verificationCode) + values.Set("method", "code") + + body, resp = testhelpers.VerificationMakeRequest(t, true, vf, client, testhelpers.EncodeFormAsJSON(t, true, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + continueWith := gjson.Get(body, "continue_with").Array() + require.NotEmpty(t, continueWith, "continue_with should be present") + + var found bool + for _, item := range continueWith { + if item.Get("action").String() == "set_ory_session_token" { + found = true + assert.NotEmpty(t, item.Get("ory_session_token").String()) + break + } + } + assert.True(t, found, "continue_with should contain set_ory_session_token action") + }) +} diff --git a/selfservice/strategy/link/strategy_verification_session_test.go b/selfservice/strategy/link/strategy_verification_session_test.go new file mode 100644 index 000000000000..cd5f9f593899 --- /dev/null +++ b/selfservice/strategy/link/strategy_verification_session_test.go @@ -0,0 +1,187 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package link_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/x/ioutilx" + "github.com/ory/x/sqlxx" +) + +func TestVerificationWithSessionHook(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + initViper(t, conf) + + conf.MustSet(ctx, config.ViperKeySelfServiceVerificationAfter+".hooks", []map[string]interface{}{ + {"hook": "session"}, + }) + + _ = testhelpers.NewVerificationUIFlowEchoServer(t, reg) + _ = testhelpers.NewErrorTestServer(t, reg) + + public, _ := testhelpers.NewKratosServerWithCSRF(t, reg) + + createIdentity := func(email string) *identity.Identity { + id := &identity.Identity{ + Credentials: map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypePassword: { + Type: identity.CredentialsTypePassword, + Identifiers: []string{email}, + Config: sqlxx.JSONRawMessage(`{"hashed_password":"foo"}`), + }, + }, + Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, email)), + SchemaID: config.DefaultIdentityTraitsSchemaID, + State: identity.StateActive, + } + require.NoError(t, reg.IdentityManager().Create(ctx, id, identity.ManagerAllowWriteProtectedTraits)) + return id + } + + t.Run("case=browser flow creates session after verification", func(t *testing.T) { + email := testhelpers.RandomEmail() + _ = createIdentity(email) + + client := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeVerificationFlowViaBrowser(t, client, false, public) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("email", email) + values.Set("method", "link") + + body, resp := testhelpers.VerificationMakeRequest(t, false, f, client, testhelpers.EncodeFormAsJSON(t, false, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Please verify") + require.Contains(t, message.Body, "Verify your account") + + verificationLink := testhelpers.CourierExpectLinkInMessage(t, message, 1) + require.NotEmpty(t, verificationLink) + + resp, err := client.Get(verificationLink) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.EqualValues(t, http.StatusOK, resp.StatusCode) + + session, _, err := testhelpers.NewSDKCustomClient(public, client).FrontendAPI.ToSession(ctx).Execute() + require.NoError(t, err) + require.NotNil(t, session) + traitsJSON, _ := session.Identity.Traits.(map[string]interface{}) + assert.Equal(t, email, traitsJSON["email"]) + }) + + t.Run("case=SPA flow creates session after verification", func(t *testing.T) { + email := testhelpers.RandomEmail() + _ = createIdentity(email) + + client := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeVerificationFlowViaBrowser(t, client, true, public) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("email", email) + values.Set("method", "link") + + body, resp := testhelpers.VerificationMakeRequest(t, false, f, client, testhelpers.EncodeFormAsJSON(t, false, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Please verify") + verificationLink := testhelpers.CourierExpectLinkInMessage(t, message, 1) + + resp, err := client.Get(verificationLink) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.EqualValues(t, http.StatusOK, resp.StatusCode) + + session, _, err := testhelpers.NewSDKCustomClient(public, client).FrontendAPI.ToSession(ctx).Execute() + require.NoError(t, err) + require.NotNil(t, session) + traitsJSON, _ := session.Identity.Traits.(map[string]interface{}) + assert.Equal(t, email, traitsJSON["email"]) + }) + + t.Run("case=API flow returns session token after verification", func(t *testing.T) { + email := testhelpers.RandomEmail() + _ = createIdentity(email) + + client := &http.Client{} + f := testhelpers.InitializeVerificationFlowViaAPI(t, client, public) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("email", email) + values.Set("method", "link") + + body, resp := testhelpers.VerificationMakeRequest(t, true, f, client, testhelpers.EncodeFormAsJSON(t, true, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Please verify") + verificationLink := testhelpers.CourierExpectLinkInMessage(t, message, 1) + + resp, err := client.Get(verificationLink) + require.NoError(t, err) + body = string(ioutilx.MustReadAll(resp.Body)) + require.NoError(t, resp.Body.Close()) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + sessionToken := gjson.Get(body, "session_token").String() + require.NotEmpty(t, sessionToken, "session_token should be present in API response") + + sessionData := gjson.Get(body, "session") + require.True(t, sessionData.Exists(), "session should be present in API response") + assert.Equal(t, email, gjson.Get(body, "session.identity.traits.email").String()) + + identityData := gjson.Get(body, "identity") + require.True(t, identityData.Exists(), "identity should be present") + assert.Equal(t, email, gjson.Get(body, "identity.traits.email").String()) + }) + + t.Run("case=continue_with contains session info for API flow", func(t *testing.T) { + email := testhelpers.RandomEmail() + _ = createIdentity(email) + + client := &http.Client{} + f := testhelpers.InitializeVerificationFlowViaAPI(t, client, public) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("email", email) + values.Set("method", "link") + + body, resp := testhelpers.VerificationMakeRequest(t, true, f, client, testhelpers.EncodeFormAsJSON(t, true, values)) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Please verify") + verificationLink := testhelpers.CourierExpectLinkInMessage(t, message, 1) + + resp, err := client.Get(verificationLink) + require.NoError(t, err) + body = string(ioutilx.MustReadAll(resp.Body)) + require.NoError(t, resp.Body.Close()) + require.EqualValues(t, http.StatusOK, resp.StatusCode, body) + + continueWith := gjson.Get(body, "continue_with").Array() + require.NotEmpty(t, continueWith, "continue_with should be present") + + var found bool + for _, item := range continueWith { + if item.Get("action").String() == "set_ory_session_token" { + found = true + assert.NotEmpty(t, item.Get("ory_session_token").String()) + break + } + } + assert.True(t, found, "continue_with should contain set_ory_session_token action") + }) +} diff --git a/selfservice/strategy/password/registration_enumeration_test.go b/selfservice/strategy/password/registration_enumeration_test.go new file mode 100644 index 000000000000..847c1c9169a5 --- /dev/null +++ b/selfservice/strategy/password/registration_enumeration_test.go @@ -0,0 +1,135 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package password_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/x/configx" + "github.com/ory/x/sqlxx" +) + +func TestRegistrationAccountEnumerationMitigation(t *testing.T) { + ctx := context.Background() + _, reg := internal.NewFastRegistryWithMocks(t, configx.WithValues(map[string]interface{}{ + config.ViperKeySecurityAccountEnumerationMitigate: true, + config.ViperKeyDefaultIdentitySchemaID: "default", + config.ViperKeyIdentitySchemas: config.Schemas{{ID: "default", URL: "file://./stub/email.schema.json"}}, + fmt.Sprintf("%s.%s.enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypePassword.String()): true, + config.ViperKeySelfServiceBrowserDefaultReturnTo: "https://www.ory.sh", + config.ViperKeyURLsAllowedReturnToDomains: []string{"https://www.ory.sh"}, + })) + + _ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg) + _ = testhelpers.NewErrorTestServer(t, reg) + _ = testhelpers.NewVerificationUIFlowEchoServer(t, reg) + + public, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg) + + existingEmail := testhelpers.RandomEmail() + existingIdentity := &identity.Identity{ + Credentials: map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypePassword: { + Type: identity.CredentialsTypePassword, + Identifiers: []string{existingEmail}, + Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), + }, + }, + Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, existingEmail)), + SchemaID: config.DefaultIdentityTraitsSchemaID, + State: identity.StateActive, + } + require.NoError(t, reg.IdentityManager().Create(ctx, existingIdentity, identity.ManagerAllowWriteProtectedTraits)) + require.NotNil(t, existingIdentity) + + t.Run("case=duplicate registration does not return error with account enumeration mitigation", func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, public, false, false, false) + + body, err := json.Marshal(f) + require.NoError(t, err) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("traits.email", existingEmail) + values.Set("password", "some-password-123") + values.Set("method", "password") + + actual, resp := testhelpers.RegistrationMakeRequest(t, false, false, f, client, testhelpers.EncodeFormAsJSON(t, false, values)) + + require.EqualValues(t, http.StatusOK, resp.StatusCode, actual) + + csrfToken := gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() + assert.NotEmpty(t, csrfToken) + + assert.Empty(t, gjson.Get(actual, "ui.messages").Array(), "should not contain error messages") + + message := testhelpers.CourierExpectMessage(ctx, t, reg, existingEmail, "Account registration attempt") + assert.Contains(t, message.Subject, "Account registration attempt") + assert.Contains(t, message.Body, "Someone tried to create an account with this email address.") + }) + + t.Run("case=duplicate registration sends duplicate email with account enumeration mitigation", func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, public, false, false, false) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("traits.email", existingEmail) + values.Set("password", "some-password-123") + values.Set("method", "password") + + actual, resp := testhelpers.RegistrationMakeRequest(t, false, false, f, client, testhelpers.EncodeFormAsJSON(t, false, values)) + + require.EqualValues(t, http.StatusOK, resp.StatusCode, actual) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, existingEmail, "Account registration attempt") + assert.Contains(t, message.Subject, "Account registration attempt") + assert.Contains(t, message.Body, "Someone tried to create an account with this email address.") + }) + + t.Run("case=API flow does not return identity with account enumeration mitigation", func(t *testing.T) { + client := &http.Client{} + f := testhelpers.InitializeRegistrationFlowViaAPI(t, client, public) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("traits.email", existingEmail) + values.Set("password", "some-password-123") + values.Set("method", "password") + + actual, resp := testhelpers.RegistrationMakeRequest(t, true, false, f, client, testhelpers.EncodeFormAsJSON(t, true, values)) + + require.EqualValues(t, http.StatusOK, resp.StatusCode, actual) + + assert.Empty(t, gjson.Get(actual, "identity").String(), "identity should not be returned") + }) + + t.Run("case=session not created during duplicate registration", func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, public, false, false, false) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + values.Set("traits.email", existingEmail) + values.Set("password", "some-password-123") + values.Set("method", "password") + + actual, resp := testhelpers.RegistrationMakeRequest(t, false, false, f, client, testhelpers.EncodeFormAsJSON(t, false, values)) + + require.EqualValues(t, http.StatusOK, resp.StatusCode, actual) + + session, _, err := testhelpers.NewSDKCustomClient(public, client).FrontendAPI.ToSession(ctx).Execute() + require.Error(t, err) + assert.Nil(t, session) + }) +} From 4dd899ed552454c71d7d55d13c0ae5c085fc7577 Mon Sep 17 00:00:00 2001 From: camero2734 <42698419+camero2734@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:10:39 +0200 Subject: [PATCH 2/4] Add session issuing to verification flow - Create session automatically after successful verification - Add session token to API verification response - Support continue_with items in verification flow - Update all post-verification hooks to accept session parameter --- driver/registry_default_hooks.go | 13 +++- driver/registry_default_login.go | 6 +- driver/registry_default_recovery.go | 4 +- driver/registry_default_settings.go | 8 +-- driver/registry_default_test.go | 16 +++++ driver/registry_default_verification.go | 4 +- selfservice/flow/verification/flow.go | 15 ++++ selfservice/flow/verification/hook.go | 23 +++++-- selfservice/flow/verification/hook_test.go | 9 +-- selfservice/hook/error.go | 2 +- selfservice/hook/session_issuer.go | 61 +++++++++++++++++ selfservice/hook/verification.go | 9 ++- selfservice/hook/web_hook.go | 8 ++- selfservice/hook/web_hook_integration_test.go | 68 ++++++++++++++++++- .../strategy/code/strategy_verification.go | 7 ++ .../strategy/link/strategy_verification.go | 7 ++ 16 files changed, 231 insertions(+), 29 deletions(-) diff --git a/driver/registry_default_hooks.go b/driver/registry_default_hooks.go index e23b1926aa18..5dea862b5165 100644 --- a/driver/registry_default_hooks.go +++ b/driver/registry_default_hooks.go @@ -4,12 +4,14 @@ package driver import ( + "context" "encoding/json" "fmt" "github.com/pkg/errors" "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" "github.com/ory/kratos/request" "github.com/ory/kratos/selfservice/hook" ) @@ -52,11 +54,12 @@ func (m *RegistryDefault) HookShowVerificationUI() *hook.ShowVerificationUIHook func (m *RegistryDefault) WithHooks(hooks map[string]func(config.SelfServiceHook) interface{}) { m.injectedSelfserviceHooks = hooks } + func (m *RegistryDefault) WithExtraHandlers(handlers []NewHandlerRegistrar) { m.extraHandlerFactories = handlers } -func getHooks[T any](m *RegistryDefault, credentialsType string, configs []config.SelfServiceHook) ([]T, error) { +func getHooks[T any](m *RegistryDefault, ctx context.Context, credentialsType string, configs []config.SelfServiceHook) ([]T, error) { hooks := make([]T, 0, len(configs)) var addSessionIssuer bool @@ -64,6 +67,14 @@ allHooksLoop: for _, h := range configs { switch h.Name { case hook.KeySessionIssuer: + if m.Config().SecurityAccountEnumerationMitigate(ctx) && credentialsType != identity.CredentialsTypeOIDC.String() { + m.l.WithField("for", credentialsType).Error("The 'session' hook is incompatible with account enumeration mitigation") + return nil, errors.Errorf( + "the 'session' hook for %s is incompatible with security.account_enumeration.mitigate=true: "+ + "issuing sessions during anti-enumeration flows would leak information about existing accounts. "+ + "Please remove the 'session' hook from selfservice.flows.registration.after.%s.hooks or disable account enumeration mitigation", + credentialsType, credentialsType) + } // The session issuer hook always needs to come last. addSessionIssuer = true case hook.KeySessionDestroyer: diff --git a/driver/registry_default_login.go b/driver/registry_default_login.go index 3149d64dca72..db6ab09ea8bd 100644 --- a/driver/registry_default_login.go +++ b/driver/registry_default_login.go @@ -19,11 +19,11 @@ func (m *RegistryDefault) LoginHookExecutor() *login.HookExecutor { } func (m *RegistryDefault) PreLoginHooks(ctx context.Context) ([]login.PreHookExecutor, error) { - return getHooks[login.PreHookExecutor](m, "", m.Config().SelfServiceFlowLoginBeforeHooks(ctx)) + return getHooks[login.PreHookExecutor](m, ctx, "", m.Config().SelfServiceFlowLoginBeforeHooks(ctx)) } func (m *RegistryDefault) PostLoginHooks(ctx context.Context, credentialsType identity.CredentialsType) ([]login.PostHookExecutor, error) { - hooks, err := getHooks[login.PostHookExecutor](m, string(credentialsType), m.Config().SelfServiceFlowLoginAfterHooks(ctx, string(credentialsType))) + hooks, err := getHooks[login.PostHookExecutor](m, ctx, string(credentialsType), m.Config().SelfServiceFlowLoginAfterHooks(ctx, string(credentialsType))) if err != nil { return nil, err } @@ -33,7 +33,7 @@ func (m *RegistryDefault) PostLoginHooks(ctx context.Context, credentialsType id // since we don't want merging hooks defined in a specific strategy and global hooks // global hooks are added only if no strategy specific hooks are defined - return getHooks[login.PostHookExecutor](m, config.HookGlobal, m.Config().SelfServiceFlowLoginAfterHooks(ctx, config.HookGlobal)) + return getHooks[login.PostHookExecutor](m, ctx, config.HookGlobal, m.Config().SelfServiceFlowLoginAfterHooks(ctx, config.HookGlobal)) } func (m *RegistryDefault) LoginHandler() *login.Handler { diff --git a/driver/registry_default_recovery.go b/driver/registry_default_recovery.go index 2084b6de6476..ee8438902e6c 100644 --- a/driver/registry_default_recovery.go +++ b/driver/registry_default_recovery.go @@ -70,11 +70,11 @@ func (m *RegistryDefault) RecoveryExecutor() *recovery.HookExecutor { } func (m *RegistryDefault) PreRecoveryHooks(ctx context.Context) ([]recovery.PreHookExecutor, error) { - return getHooks[recovery.PreHookExecutor](m, "", m.Config().SelfServiceFlowRecoveryBeforeHooks(ctx)) + return getHooks[recovery.PreHookExecutor](m, ctx, "", m.Config().SelfServiceFlowRecoveryBeforeHooks(ctx)) } func (m *RegistryDefault) PostRecoveryHooks(ctx context.Context) ([]recovery.PostHookExecutor, error) { - return getHooks[recovery.PostHookExecutor](m, config.HookGlobal, m.Config().SelfServiceFlowRecoveryAfterHooks(ctx, config.HookGlobal)) + return getHooks[recovery.PostHookExecutor](m, ctx, config.HookGlobal, m.Config().SelfServiceFlowRecoveryAfterHooks(ctx, config.HookGlobal)) } func (m *RegistryDefault) CodeSender() *code.Sender { diff --git a/driver/registry_default_settings.go b/driver/registry_default_settings.go index e2724736ccda..ee0ba336b347 100644 --- a/driver/registry_default_settings.go +++ b/driver/registry_default_settings.go @@ -12,22 +12,22 @@ import ( ) func (m *RegistryDefault) PostSettingsPrePersistHooks(ctx context.Context, settingsType string) ([]settings.PostHookPrePersistExecutor, error) { - return getHooks[settings.PostHookPrePersistExecutor](m, settingsType, m.Config().SelfServiceFlowSettingsAfterHooks(ctx, settingsType)) + return getHooks[settings.PostHookPrePersistExecutor](m, ctx, settingsType, m.Config().SelfServiceFlowSettingsAfterHooks(ctx, settingsType)) } func (m *RegistryDefault) PreSettingsHooks(ctx context.Context) ([]settings.PreHookExecutor, error) { - return getHooks[settings.PreHookExecutor](m, "", m.Config().SelfServiceFlowSettingsBeforeHooks(ctx)) + return getHooks[settings.PreHookExecutor](m, ctx, "", m.Config().SelfServiceFlowSettingsBeforeHooks(ctx)) } func (m *RegistryDefault) PostSettingsPostPersistHooks(ctx context.Context, settingsType string) ([]settings.PostHookPostPersistExecutor, error) { - hooks, err := getHooks[settings.PostHookPostPersistExecutor](m, settingsType, m.Config().SelfServiceFlowSettingsAfterHooks(ctx, settingsType)) + hooks, err := getHooks[settings.PostHookPostPersistExecutor](m, ctx, settingsType, m.Config().SelfServiceFlowSettingsAfterHooks(ctx, settingsType)) if err != nil { return nil, err } if len(hooks) == 0 { // since we don't want merging hooks defined in a specific strategy and // global hooks are added only if no strategy specific hooks are defined - hooks, err = getHooks[settings.PostHookPostPersistExecutor](m, config.HookGlobal, m.Config().SelfServiceFlowSettingsAfterHooks(ctx, config.HookGlobal)) + hooks, err = getHooks[settings.PostHookPostPersistExecutor](m, ctx, config.HookGlobal, m.Config().SelfServiceFlowSettingsAfterHooks(ctx, config.HookGlobal)) if err != nil { return nil, err } diff --git a/driver/registry_default_test.go b/driver/registry_default_test.go index a391423396f2..579a6b47a69a 100644 --- a/driver/registry_default_test.go +++ b/driver/registry_default_test.go @@ -347,6 +347,22 @@ func TestDriverDefault_Hooks(t *testing.T) { assert.Equal(t, tc.expect(reg), h) }) } + + // Error cases + t.Run("errors when session hook and anti-enumeration are both enabled", func(t *testing.T) { + t.Parallel() + + ctx := contextx.WithConfigValues(ctx, map[string]any{ + config.ViperKeySelfServiceRegistrationAfter + ".password.hooks": []map[string]any{ + {"hook": "session"}, + }, + config.ViperKeySecurityAccountEnumerationMitigate: true, + }) + + _, err := reg.PostRegistrationPostPersistHooks(ctx, identity.CredentialsTypePassword) + require.Error(t, err) + assert.Contains(t, err.Error(), "session' hook for password is incompatible with security.account_enumeration.mitigate=true") + }) }) t.Run("type=login", func(t *testing.T) { diff --git a/driver/registry_default_verification.go b/driver/registry_default_verification.go index 62c5d162db9c..28536459727b 100644 --- a/driver/registry_default_verification.go +++ b/driver/registry_default_verification.go @@ -92,9 +92,9 @@ func (m *RegistryDefault) VerificationExecutor() *verification.HookExecutor { } func (m *RegistryDefault) PreVerificationHooks(ctx context.Context) ([]verification.PreHookExecutor, error) { - return getHooks[verification.PreHookExecutor](m, "", m.Config().SelfServiceFlowVerificationBeforeHooks(ctx)) + return getHooks[verification.PreHookExecutor](m, ctx, "", m.Config().SelfServiceFlowVerificationBeforeHooks(ctx)) } func (m *RegistryDefault) PostVerificationHooks(ctx context.Context) ([]verification.PostHookExecutor, error) { - return getHooks[verification.PostHookExecutor](m, config.HookGlobal, m.Config().SelfServiceFlowVerificationAfterHooks(ctx, config.HookGlobal)) + return getHooks[verification.PostHookExecutor](m, ctx, config.HookGlobal, m.Config().SelfServiceFlowVerificationAfterHooks(ctx, config.HookGlobal)) } diff --git a/selfservice/flow/verification/flow.go b/selfservice/flow/verification/flow.go index c08f4e88e3cd..50877f9bc613 100644 --- a/selfservice/flow/verification/flow.go +++ b/selfservice/flow/verification/flow.go @@ -87,6 +87,10 @@ type Flow struct { // CSRFToken contains the anti-csrf token associated with this request. CSRFToken string `json:"-" db:"csrf_token"` + // This flow exists to mitigate account enumeration attacks, the email should not be sent and + // no valid code/link should be generated. + AntiEnumerationFlow bool `json:"-" db:"-" faker:"-"` + // CreatedAt is a helper struct field for gobuffalo.pop. CreatedAt time.Time `json:"-" faker:"-" db:"created_at"` // UpdatedAt is a helper struct field for gobuffalo.pop. @@ -97,6 +101,11 @@ type Flow struct { // // required: false TransientPayload json.RawMessage `json:"transient_payload,omitempty" faker:"-" db:"-"` + + // Contains a list of actions, that could follow this flow + // + // It can, for example, contain a reference to another flow or the session token. + ContinueWithItems []flow.ContinueWith `json:"-" db:"-" faker:"-"` } type OAuth2LoginChallengeParams struct { @@ -204,6 +213,12 @@ func (f *Flow) GetTransientPayload() json.RawMessage { return f.TransientPa func (f *Flow) GetOAuth2LoginChallenge() sqlxx.NullString { return f.OAuth2LoginChallenge } func (f *Flow) GetUI() *container.Container { return f.UI } +func (f *Flow) AddContinueWith(c flow.ContinueWith) { + f.ContinueWithItems = append(f.ContinueWithItems, c) +} + +func (f *Flow) ContinueWith() []flow.ContinueWith { return f.ContinueWithItems } + func (f *Flow) Valid() error { if f.ExpiresAt.Before(time.Now()) { return errors.WithStack(flow.NewFlowExpiredError(f.ExpiresAt)) diff --git a/selfservice/flow/verification/hook.go b/selfservice/flow/verification/hook.go index 194062360744..57b9a893b496 100644 --- a/selfservice/flow/verification/hook.go +++ b/selfservice/flow/verification/hook.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "net/http" + "time" "github.com/ory/kratos/x/nosurfx" @@ -29,9 +30,9 @@ type ( PreHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow) error PostHookExecutor interface { - ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error + ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity, s *session.Session) error } - PostHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error + PostHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity, s *session.Session) error HooksProvider interface { PostVerificationHooks(ctx context.Context) ([]PostHookExecutor, error) @@ -51,8 +52,8 @@ func (f PreHookExecutorFunc) ExecuteVerificationPreHook(w http.ResponseWriter, r return f(w, r, a) } -func (f PostHookExecutorFunc) ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error { - return f(w, r, a, i) +func (f PostHookExecutorFunc) ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity, s *session.Session) error { + return f(w, r, a, i, s) } type ( @@ -61,6 +62,7 @@ type ( identity.ManagementProvider identity.ValidationProvider session.PersistenceProvider + session.ManagementProvider HooksProvider nosurfx.CSRFTokenGeneratorProvider x.LoggingProvider @@ -101,12 +103,23 @@ func (e *HookExecutor) PostVerificationHook(w http.ResponseWriter, r *http.Reque WithRequest(r). WithField("identity_id", i.ID). Debug("Running ExecutePostVerificationHooks.") + + sess := session.NewInactiveSession() + sess.CompletedLoginForWithProvider(identity.CredentialsTypeCodeAuth, identity.AuthenticatorAssuranceLevel1, "", "") + if err := e.d.SessionManager().ActivateSession(r, sess, i, time.Now().UTC()); err != nil { + return err + } + + if err := e.d.SessionPersister().UpsertSession(r.Context(), sess); err != nil { + return err + } + hooks, err := e.d.PostVerificationHooks(r.Context()) if err != nil { return err } for k, executor := range hooks { - if err := executor.ExecutePostVerificationHook(w, r, a, i); err != nil { + if err := executor.ExecutePostVerificationHook(w, r, a, i, sess); err != nil { return flow.HandleHookError(w, r, a, i.Traits, node.LinkGroup, err, e.d, e.d) } diff --git a/selfservice/flow/verification/hook_test.go b/selfservice/flow/verification/hook_test.go index ef6a7a1959fb..520ad07a5a58 100644 --- a/selfservice/flow/verification/hook_test.go +++ b/selfservice/flow/verification/hook_test.go @@ -30,6 +30,7 @@ import ( func TestVerificationExecutor(t *testing.T) { ctx := context.Background() conf, reg := internal.NewFastRegistryWithMocks(t) + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json") newServer := func(t *testing.T, i *identity.Identity, ft flow.Type) *httptest.Server { router := http.NewServeMux() @@ -63,7 +64,7 @@ func TestVerificationExecutor(t *testing.T) { t.Run("method=PostVerificationHook", func(t *testing.T) { t.Run("case=pass without hooks", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) - i := testhelpers.SelfServiceHookFakeIdentity(t) + i := testhelpers.SelfServiceHookCreateFakeIdentity(t, reg) ts := newServer(t, i, flow.TypeBrowser) res, _ := testhelpers.SelfServiceMakeHookRequest(t, ts, "/verification/post", false, url.Values{}) @@ -75,7 +76,7 @@ func TestVerificationExecutor(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceVerificationAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}}) - i := testhelpers.SelfServiceHookFakeIdentity(t) + i := testhelpers.SelfServiceHookCreateFakeIdentity(t, reg) ts := newServer(t, i, flow.TypeBrowser) res, _ := testhelpers.SelfServiceMakeHookRequest(t, ts, "/verification/post", false, url.Values{}) @@ -87,7 +88,7 @@ func TestVerificationExecutor(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceVerificationAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostVerificationHook": "abort"}`)}}) - i := testhelpers.SelfServiceHookFakeIdentity(t) + i := testhelpers.SelfServiceHookCreateFakeIdentity(t, reg) ts := newServer(t, i, flow.TypeBrowser) res, body := testhelpers.SelfServiceMakeHookRequest(t, ts, "/verification/post", false, url.Values{}) @@ -101,7 +102,7 @@ func TestVerificationExecutor(t *testing.T) { config.ViperKeySelfServiceVerificationBeforeHooks, testhelpers.SelfServiceMakeVerificationPreHookRequest, func(t *testing.T) *httptest.Server { - i := testhelpers.SelfServiceHookFakeIdentity(t) + i := testhelpers.SelfServiceHookCreateFakeIdentity(t, reg) return newServer(t, i, kind) }, conf, diff --git a/selfservice/hook/error.go b/selfservice/hook/error.go index bb396578e5f8..9ac786940e8d 100644 --- a/selfservice/hook/error.go +++ b/selfservice/hook/error.go @@ -96,6 +96,6 @@ func (e Error) ExecuteVerificationPreHook(w http.ResponseWriter, r *http.Request return e.err("ExecuteVerificationPreHook", verification.ErrHookAbortFlow) } -func (e Error) ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *verification.Flow, i *identity.Identity) error { +func (e Error) ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *verification.Flow, i *identity.Identity, s *session.Session) error { return e.err("ExecutePostVerificationHook", verification.ErrHookAbortFlow) } diff --git a/selfservice/hook/session_issuer.go b/selfservice/hook/session_issuer.go index 48a7d3b4e14d..4de3abd4711a 100644 --- a/selfservice/hook/session_issuer.go +++ b/selfservice/hook/session_issuer.go @@ -19,6 +19,7 @@ import ( "github.com/ory/kratos/driver/config" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/registration" + "github.com/ory/kratos/selfservice/flow/verification" "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/kratos/session" "github.com/ory/kratos/x" @@ -26,6 +27,7 @@ import ( ) var _ registration.PostHookPostPersistExecutor = new(SessionIssuer) +var _ verification.PostHookExecutor = new(SessionIssuer) type ( sessionIssuerDependencies interface { @@ -153,3 +155,62 @@ func willVerificationFollow(f *registration.Flow) bool { } return false } + +func (e *SessionIssuer) ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *verification.Flow, i *identity.Identity, s *session.Session) error { + return otelx.WithSpan(r.Context(), "selfservice.hook.SessionIssuer.ExecutePostVerificationHook", func(ctx context.Context) error { + return e.executePostVerificationHook(w, r.WithContext(ctx), a, i, s) + }) +} + +func (e *SessionIssuer) executePostVerificationHook(w http.ResponseWriter, r *http.Request, a *verification.Flow, i *identity.Identity, s *session.Session) error { + if a.Type == flow.TypeAPI { + if handled, err := e.r.SessionManager().MaybeRedirectAPICodeFlow(w, r, a, s.ID, node.LinkGroup); err != nil { + return errors.WithStack(err) + } else if handled { + return nil + } + + a.AddContinueWith(flow.NewContinueWithSetToken(s.Token)) + e.r.Writer().Write(w, r, &verification.APIFlowResponse{ + Session: s, + Token: s.Token, + Identity: i, + ContinueWith: a.ContinueWithItems, + }) + + trace.SpanFromContext(r.Context()).AddEvent(events.NewLoginSucceeded(r.Context(), &events.LoginSucceededOpts{ + SessionID: s.ID, + IdentityID: i.ID, + FlowID: a.ID, + FlowType: string(a.Type), + Method: a.Active.String(), + })) + + return errors.WithStack(verification.ErrHookAbortFlow) + } + + // cookie is issued both for browser and for SPA flows + if err := e.r.SessionManager().IssueCookie(r.Context(), w, r, s); err != nil { + return err + } + + trace.SpanFromContext(r.Context()).AddEvent(events.NewLoginSucceeded(r.Context(), &events.LoginSucceededOpts{ + SessionID: s.ID, + IdentityID: s.Identity.ID, + FlowID: a.ID, + FlowType: string(a.Type), + Method: a.Active.String(), + })) + + // SPA flows additionally send the session + if x.IsJSONRequest(r) { + e.r.Writer().Write(w, r, &verification.APIFlowResponse{ + Session: s, + Identity: i, + ContinueWith: a.ContinueWithItems, + }) + return errors.WithStack(verification.ErrHookAbortFlow) + } + + return nil +} diff --git a/selfservice/hook/verification.go b/selfservice/hook/verification.go index 25816d95e489..bb8f5dcde43b 100644 --- a/selfservice/hook/verification.go +++ b/selfservice/hook/verification.go @@ -57,7 +57,7 @@ func NewVerifier(r verifierDependencies) *Verifier { func (e *Verifier) ExecutePostRegistrationPostPersistHook(w http.ResponseWriter, r *http.Request, f *registration.Flow, s *session.Session) error { return otelx.WithSpan(r.Context(), "selfservice.hook.Verifier.ExecutePostRegistrationPostPersistHook", func(ctx context.Context) error { - return e.do(w, r.WithContext(ctx), s.Identity, f, func(v *verification.Flow) { + return e.do(w, r.WithContext(ctx), s.Identity, f.AntiEnumerationFlow, f, func(v *verification.Flow) { v.OAuth2LoginChallenge = f.OAuth2LoginChallenge v.SessionID = uuid.NullUUID{UUID: s.ID, Valid: true} v.IdentityID = uuid.NullUUID{UUID: s.Identity.ID, Valid: true} @@ -68,7 +68,7 @@ func (e *Verifier) ExecutePostRegistrationPostPersistHook(w http.ResponseWriter, func (e *Verifier) ExecuteSettingsPostPersistHook(w http.ResponseWriter, r *http.Request, f *settings.Flow, i *identity.Identity, _ *session.Session) error { return otelx.WithSpan(r.Context(), "selfservice.hook.Verifier.ExecuteSettingsPostPersistHook", func(ctx context.Context) error { - return e.do(w, r.WithContext(ctx), i, f, nil) + return e.do(w, r.WithContext(ctx), i, false, f, nil) }) } @@ -81,7 +81,7 @@ func (e *Verifier) ExecuteLoginPostHook(w http.ResponseWriter, r *http.Request, return nil } - return e.do(w, r.WithContext(ctx), s.Identity, f, nil) + return e.do(w, r.WithContext(ctx), s.Identity, false, f, nil) } const InternalContextRegistrationVerificationFlow = "registration_verification_flow_continue_with" @@ -90,6 +90,7 @@ func (e *Verifier) do( w http.ResponseWriter, r *http.Request, i *identity.Identity, + antiEnumerationFlow bool, f interface { flow.FlowWithContinueWith flow.InternalContexter @@ -146,6 +147,8 @@ func (e *Verifier) do( return err } + verificationFlow.AntiEnumerationFlow = antiEnumerationFlow + if flowCallback != nil { flowCallback(verificationFlow) } diff --git a/selfservice/hook/web_hook.go b/selfservice/hook/web_hook.go index dc595c30d562..f2ab88ce3b39 100644 --- a/selfservice/hook/web_hook.go +++ b/selfservice/hook/web_hook.go @@ -161,7 +161,7 @@ func (e *WebHook) ExecuteVerificationPreHook(_ http.ResponseWriter, req *http.Re }) } -func (e *WebHook) ExecutePostVerificationHook(_ http.ResponseWriter, req *http.Request, flow *verification.Flow, id *identity.Identity) error { +func (e *WebHook) ExecutePostVerificationHook(_ http.ResponseWriter, req *http.Request, flow *verification.Flow, id *identity.Identity, sess *session.Session) error { return otelx.WithSpan(req.Context(), "selfservice.hook.WebHook.ExecutePostVerificationHook", func(ctx context.Context) error { return e.execute(ctx, &templateContext{ Flow: flow, @@ -233,6 +233,12 @@ func (e *WebHook) ExecutePostRegistrationPostPersistHook(_ http.ResponseWriter, return nil } + // Skip webhook execution for anti-enumeration flows since no real identity was created + if flow.AntiEnumerationFlow { + e.deps.Logger().WithField("flow", flow.ID).Debug("Skipping webhook execution for anti-enumeration registration flow") + return nil + } + // We want to decouple the request from the hook execution, so that the hooks still execute even // if the request is canceled. ctx := context.WithoutCancel(req.Context()) diff --git a/selfservice/hook/web_hook_integration_test.go b/selfservice/hook/web_hook_integration_test.go index b40f48b6f671..849be65705e1 100644 --- a/selfservice/hook/web_hook_integration_test.go +++ b/selfservice/hook/web_hook_integration_test.go @@ -277,7 +277,7 @@ func TestWebHooks(t *testing.T) { uc: "Post Verification Hook", createFlow: func() flow.Flow { return &verification.Flow{ID: x.NewUUID(), TransientPayload: transientPayload} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { - return wh.ExecutePostVerificationHook(nil, req, f.(*verification.Flow), s.Identity) + return wh.ExecutePostVerificationHook(nil, req, f.(*verification.Flow), s.Identity, s) }, expectedBody: func(req *http.Request, f flow.Flow, s *session.Session) string { return bodyWithFlowAndIdentityAndTransientPayload(req, f, s, transientPayload) @@ -579,7 +579,7 @@ func TestWebHooks(t *testing.T) { uc: "Post Verification Hook - no block", createFlow: func() flow.Flow { return &verification.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { - return wh.ExecutePostVerificationHook(nil, req, f.(*verification.Flow), s.Identity) + return wh.ExecutePostVerificationHook(nil, req, f.(*verification.Flow), s.Identity, s) }, webHookResponse: func() (int, []byte) { return http.StatusOK, []byte{} @@ -590,7 +590,7 @@ func TestWebHooks(t *testing.T) { uc: "Post Verification Hook - block", createFlow: func() flow.Flow { return &verification.Flow{ID: x.NewUUID()} }, callWebHook: func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error { - return wh.ExecutePostVerificationHook(nil, req, f.(*verification.Flow), s.Identity) + return wh.ExecutePostVerificationHook(nil, req, f.(*verification.Flow), s.Identity, s) }, webHookResponse: func() (int, []byte) { return http.StatusBadRequest, webHookResponse @@ -1425,3 +1425,65 @@ func TestRemoveDisallowedHeaders(t *testing.T) { require.Equal(t, []string{"text/html"}, h) }) } + +func TestWebHookSkipsAntiEnumeration(t *testing.T) { + t.Parallel() + ctx := context.Background() + _, reg := internal.NewFastRegistryWithMocks(t) + logger := logrusx.New("kratos", "test") + whDeps := struct { + x.SimpleLoggerWithClient + *jsonnetsecure.TestProvider + config.Provider + }{ + x.SimpleLoggerWithClient{L: logger, C: reg.HTTPClient(ctx), T: otelx.NewNoop(logger, &otelx.Config{ServiceName: "kratos"})}, + jsonnetsecure.NewTestProvider(t), + reg, + } + + webhookCalled := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webhookCalled = true + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(ts.Close) + + wh := hook.NewWebHook(&whDeps, &request.Config{ + Method: "POST", + URL: ts.URL + "/webhook", + TemplateURI: "file://./stub/test_body.jsonnet", + }) + + req := &http.Request{ + Host: "www.ory.sh", + Header: map[string][]string{}, + URL: &url.URL{Path: "/registration"}, + Method: http.MethodPost, + } + + t.Run("skips webhook for anti-enumeration flow", func(t *testing.T) { + webhookCalled = false + flow := ®istration.Flow{ + ID: x.NewUUID(), + AntiEnumerationFlow: true, + } + s := &session.Session{ID: x.NewUUID(), Identity: &identity.Identity{ID: x.NewUUID()}} + + err := wh.ExecutePostRegistrationPostPersistHook(nil, req, flow, s) + require.NoError(t, err) + require.False(t, webhookCalled, "webhook should not be called for anti-enumeration flow") + }) + + t.Run("executes webhook for normal flow", func(t *testing.T) { + webhookCalled = false + flow := ®istration.Flow{ + ID: x.NewUUID(), + AntiEnumerationFlow: false, + } + s := &session.Session{ID: x.NewUUID(), Identity: &identity.Identity{ID: x.NewUUID()}} + + err := wh.ExecutePostRegistrationPostPersistHook(nil, req, flow, s) + require.NoError(t, err) + require.True(t, webhookCalled, "webhook should be called for normal flow") + }) +} diff --git a/selfservice/strategy/code/strategy_verification.go b/selfservice/strategy/code/strategy_verification.go index c9c2dc97dac5..4f303f1a46f8 100644 --- a/selfservice/strategy/code/strategy_verification.go +++ b/selfservice/strategy/code/strategy_verification.go @@ -293,6 +293,9 @@ func (s *Strategy) verificationUseCode(ctx context.Context, w http.ResponseWrite } if err := s.deps.VerificationExecutor().PostVerificationHook(w, r, f, i); err != nil { + if errors.Is(err, verification.ErrHookAbortFlow) { + return errors.WithStack(flow.ErrCompletedByStrategy) + } return s.retryVerificationFlowWithError(ctx, w, r, f.Type, err) } @@ -367,6 +370,10 @@ func (s *Strategy) retryVerificationFlowWithError(ctx context.Context, w http.Re } func (s *Strategy) SendVerificationCode(ctx context.Context, f *verification.Flow, i *identity.Identity, a *identity.VerifiableAddress) (err error) { + if f.AntiEnumerationFlow { + return + } + rawCode := GenerateCode() code, err := s.deps.VerificationCodePersister().CreateVerificationCode(ctx, &CreateVerificationCodeParams{ diff --git a/selfservice/strategy/link/strategy_verification.go b/selfservice/strategy/link/strategy_verification.go index 20ec9eb1dbf0..3ec4090011c6 100644 --- a/selfservice/strategy/link/strategy_verification.go +++ b/selfservice/strategy/link/strategy_verification.go @@ -256,6 +256,9 @@ func (s *Strategy) verificationUseToken(ctx context.Context, w http.ResponseWrit } if err := s.d.VerificationExecutor().PostVerificationHook(w, r, f, i); err != nil { + if errors.Is(err, verification.ErrHookAbortFlow) { + return errors.WithStack(flow.ErrCompletedByStrategy) + } return s.retryVerificationFlowWithError(ctx, w, r, flow.TypeBrowser, err) } @@ -318,6 +321,10 @@ func (s *Strategy) retryVerificationFlowWithError(ctx context.Context, w http.Re } func (s *Strategy) SendVerificationCode(ctx context.Context, f *verification.Flow, i *identity.Identity, a *identity.VerifiableAddress) error { + if f.AntiEnumerationFlow { + return nil + } + token := NewSelfServiceVerificationToken(a, f, s.d.Config().SelfServiceLinkMethodLifespan(ctx)) if err := s.d.VerificationTokenPersister().CreateVerificationToken(ctx, token); err != nil { return err From 07fa6e2823d4cc2329526efaff49006de85496e3 Mon Sep 17 00:00:00 2001 From: camero2734 <42698419+camero2734@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:34:03 +0200 Subject: [PATCH 3/4] Format --- oryx/errorsx/errors.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/oryx/errorsx/errors.go b/oryx/errorsx/errors.go index a9ab38d35fd0..801141cd4c86 100644 --- a/oryx/errorsx/errors.go +++ b/oryx/errorsx/errors.go @@ -4,8 +4,9 @@ package errorsx import ( - "github.com/ory/herodot" "github.com/pkg/errors" + + "github.com/ory/herodot" ) // Cause returns the underlying cause of the error, if possible. From c064ea8b5048c9d86e8961612318ef8f65ae15fa Mon Sep 17 00:00:00 2001 From: camero2734 <42698419+camero2734@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:24:06 +0200 Subject: [PATCH 4/4] Fix for browser flows by ensuring CSRF token is properly updated after session issuance --- driver/registry_default_hooks.go | 14 +++++++++++++- selfservice/hook/session_issuer.go | 12 ++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/driver/registry_default_hooks.go b/driver/registry_default_hooks.go index 5dea862b5165..42f24b1cb3a6 100644 --- a/driver/registry_default_hooks.go +++ b/driver/registry_default_hooks.go @@ -59,6 +59,17 @@ func (m *RegistryDefault) WithExtraHandlers(handlers []NewHandlerRegistrar) { m.extraHandlerFactories = handlers } +func isEnumerationSafeType(credentialsType string) bool { + switch credentialsType { + case identity.CredentialsTypeOIDC.String(), + identity.CredentialsTypeCodeAuth.String(), + config.HookGlobal: + return true + default: + return false + } +} + func getHooks[T any](m *RegistryDefault, ctx context.Context, credentialsType string, configs []config.SelfServiceHook) ([]T, error) { hooks := make([]T, 0, len(configs)) @@ -67,7 +78,8 @@ allHooksLoop: for _, h := range configs { switch h.Name { case hook.KeySessionIssuer: - if m.Config().SecurityAccountEnumerationMitigate(ctx) && credentialsType != identity.CredentialsTypeOIDC.String() { + + if m.Config().SecurityAccountEnumerationMitigate(ctx) && !isEnumerationSafeType(credentialsType) { m.l.WithField("for", credentialsType).Error("The 'session' hook is incompatible with account enumeration mitigation") return nil, errors.Errorf( "the 'session' hook for %s is incompatible with security.account_enumeration.mitigate=true: "+ diff --git a/selfservice/hook/session_issuer.go b/selfservice/hook/session_issuer.go index 4de3abd4711a..8969e936d30d 100644 --- a/selfservice/hook/session_issuer.go +++ b/selfservice/hook/session_issuer.go @@ -13,6 +13,7 @@ import ( "github.com/ory/kratos/identity" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x/events" + "github.com/ory/kratos/x/nosurfx" "github.com/pkg/errors" @@ -34,8 +35,10 @@ type ( session.ManagementProvider session.PersistenceProvider sessiontokenexchange.PersistenceProvider + verification.FlowPersistenceProvider config.Provider x.WriterProvider + nosurfx.CSRFTokenGeneratorProvider hydra.Provider } SessionIssuerProvider interface { @@ -194,6 +197,15 @@ func (e *SessionIssuer) executePostVerificationHook(w http.ResponseWriter, r *ht return err } + // The CSRF token was regenered when the cookie was issued; we now need to + // make sure the flow has the new CSRF token set. + if a.Type == flow.TypeBrowser { + a.SetCSRFToken(e.r.GenerateCSRFToken(r)) + if err := e.r.VerificationFlowPersister().UpdateVerificationFlow(r.Context(), a); err != nil { + return err + } + } + trace.SpanFromContext(r.Context()).AddEvent(events.NewLoginSucceeded(r.Context(), &events.LoginSucceededOpts{ SessionID: s.ID, IdentityID: s.Identity.ID,