From 11a28a14041a212a4e367934609a7f8ef8f6ab7c Mon Sep 17 00:00:00 2001 From: hackerman <3372410+aeneasr@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:40:32 +0200 Subject: [PATCH] feat: better detection if credentials exist on identifier first login (#3963) --- .../flow/login/strategy_form_hydrator.go | 13 ++ selfservice/strategy/code/strategy_login.go | 13 +- .../strategy/code/strategy_login_test.go | 10 +- .../strategy/idfirst/strategy_login.go | 64 +++++--- .../strategy/idfirst/strategy_login_test.go | 148 ++++++++++++++---- ...ed-case=identity_does_not_have_a_oidc.json | 16 +- ...ation_disabled-case=identity_has_oidc.json | 13 -- selfservice/strategy/oidc/strategy_login.go | 49 +++--- .../strategy/oidc/strategy_login_test.go | 8 +- ...count_enumeration_mitigation_disabled.json | 1 + ...count_enumeration_mitigation_enabled.json} | 0 ...count_enumeration_mitigation_disabled.json | 1 + ...count_enumeration_mitigation_enabled.json} | 0 ...inMethodIdentifierFirstIdentification.json | 21 --- selfservice/strategy/passkey/passkey_login.go | 54 +++---- .../strategy/passkey/passkey_login_test.go | 38 ++++- ...count_enumeration_mitigation_disabled.json | 1 + ...count_enumeration_mitigation_enabled.json} | 0 ...count_enumeration_mitigation_disabled.json | 1 + ...count_enumeration_mitigation_enabled.json} | 0 selfservice/strategy/password/login.go | 26 +-- selfservice/strategy/password/login_test.go | 38 ++++- ...count_enumeration_mitigation_disabled.json | 1 + ...ccount_enumeration_mitigation_enabled.json | 1 + ...count_enumeration_mitigation_disabled.json | 1 + ...count_enumeration_mitigation_enabled.json} | 0 ...count_enumeration_mitigation_disabled.json | 1 + ...ccount_enumeration_mitigation_enabled.json | 1 + ...count_enumeration_mitigation_disabled.json | 1 + ...count_enumeration_mitigation_enabled.json} | 0 ..._enabled_and_user_has_mfa_credentials.json | 1 - ...and_user_has_passwordless_credentials.json | 1 - ...inMethodSecondFactor-case=mfa_enabled.json | 1 - selfservice/strategy/webauthn/login.go | 33 ++-- selfservice/strategy/webauthn/login_test.go | 94 ++++++++--- 35 files changed, 427 insertions(+), 224 deletions(-) create mode 100644 selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json rename selfservice/strategy/passkey/.snapshots/{TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier.json => TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_enabled.json} (100%) create mode 100644 selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json rename selfservice/strategy/passkey/.snapshots/{TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options.json => TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_enabled.json} (100%) create mode 100644 selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json rename selfservice/strategy/password/.snapshots/{TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier.json => TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_enabled.json} (100%) create mode 100644 selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json rename selfservice/strategy/password/.snapshots/{TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options.json => TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_enabled.json} (100%) create mode 100644 selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json create mode 100644 selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json create mode 100644 selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json rename selfservice/strategy/webauthn/.snapshots/{TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled.json => TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_enabled.json} (100%) create mode 100644 selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json create mode 100644 selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json create mode 100644 selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json rename selfservice/strategy/webauthn/.snapshots/{TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled.json => TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_enabled.json} (100%) diff --git a/selfservice/flow/login/strategy_form_hydrator.go b/selfservice/flow/login/strategy_form_hydrator.go index 8b226a729d5..8f1fb05a5f0 100644 --- a/selfservice/flow/login/strategy_form_hydrator.go +++ b/selfservice/flow/login/strategy_form_hydrator.go @@ -20,6 +20,19 @@ type FormHydrator interface { PopulateLoginMethodFirstFactor(r *http.Request, sr *Flow) error PopulateLoginMethodSecondFactor(r *http.Request, sr *Flow) error PopulateLoginMethodSecondFactorRefresh(r *http.Request, sr *Flow) error + + // PopulateLoginMethodIdentifierFirstCredentials populates the login form with the first factor credentials. + // This method is called when the login flow is set to identifier first. The method will receive information + // about the identity that is being used to log in and the identifier that was used to find the identity. + // + // The method should populate the login form with the credentials of the identity. + // + // If the method can not find any credentials (because the identity does not exist) idfirst.ErrNoCredentialsFound + // must be returned. When returning idfirst.ErrNoCredentialsFound the strategy will appropriately deal with + // account enumeration mitigation. + // + // This method does however need to take appropriate steps to show/hide certain fields depending on the account + // enumeration configuration. PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *Flow, options ...FormHydratorModifier) error PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, sr *Flow) error } diff --git a/selfservice/strategy/code/strategy_login.go b/selfservice/strategy/code/strategy_login.go index 081cff6c49d..ae10c39a4c8 100644 --- a/selfservice/strategy/code/strategy_login.go +++ b/selfservice/strategy/code/strategy_login.go @@ -10,6 +10,7 @@ import ( "net/http" "strings" + "github.com/ory/kratos/selfservice/strategy/idfirst" "github.com/ory/kratos/text" "github.com/ory/x/sqlcon" @@ -389,11 +390,15 @@ func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, f *lo } func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, f *login.Flow, _ ...login.FormHydratorModifier) error { - if s.deps.Config().SelfServiceCodeStrategy(r.Context()).PasswordlessEnabled { - f.GetUI().Nodes.Append( - node.NewInputField("method", s.ID(), node.CodeGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoSelfServiceLoginCode()), - ) + if !s.deps.Config().SelfServiceCodeStrategy(r.Context()).PasswordlessEnabled { + // We only return this if passwordless is disabled, because if it is enabled we can always sign in using this method. + return idfirst.ErrNoCredentialsFound } + + f.GetUI().Nodes.Append( + node.NewInputField("method", s.ID(), node.CodeGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoSelfServiceLoginCode()), + ) + return nil } diff --git a/selfservice/strategy/code/strategy_login_test.go b/selfservice/strategy/code/strategy_login_test.go index e13524453e1..619acaffcfb 100644 --- a/selfservice/strategy/code/strategy_login_test.go +++ b/selfservice/strategy/code/strategy_login_test.go @@ -13,6 +13,8 @@ import ( "testing" "time" + "github.com/ory/kratos/selfservice/strategy/idfirst" + configtesthelpers "github.com/ory/kratos/driver/config/testhelpers" "github.com/ory/kratos/driver" @@ -916,7 +918,7 @@ func TestFormHydration(t *testing.T) { t.Run("case=WithIdentifier", func(t *testing.T) { t.Run("case=code is used for 2fa", func(t *testing.T) { r, f := newFlow(mfaEnabled, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com"))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) @@ -934,7 +936,7 @@ func TestFormHydration(t *testing.T) { configtesthelpers.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true), t, ) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com"))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) @@ -955,7 +957,7 @@ func TestFormHydration(t *testing.T) { t.Run("case=code is used for 2fa", func(t *testing.T) { r, f := newFlow(mfaEnabled, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) @@ -971,7 +973,7 @@ func TestFormHydration(t *testing.T) { t.Run("case=code is used for 2fa", func(t *testing.T) { r, f := newFlow(mfaEnabled, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) diff --git a/selfservice/strategy/idfirst/strategy_login.go b/selfservice/strategy/idfirst/strategy_login.go index 015475b762e..987b97ca1e9 100644 --- a/selfservice/strategy/idfirst/strategy_login.go +++ b/selfservice/strategy/idfirst/strategy_login.go @@ -6,10 +6,11 @@ package idfirst import ( "net/http" + "github.com/ory/kratos/schema" + "github.com/pkg/errors" "github.com/ory/kratos/identity" - "github.com/ory/kratos/schema" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/session" @@ -22,6 +23,7 @@ import ( var _ login.FormHydrator = new(Strategy) var _ login.Strategy = new(Strategy) +var ErrNoCredentialsFound = errors.New("no credentials found") func (s *Strategy) handleLoginError(w http.ResponseWriter, r *http.Request, f *login.Flow, payload *updateLoginFlowWithIdentifierFirstMethod, err error) error { if f != nil { @@ -64,13 +66,10 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, false, ) if errors.Is(err, sqlcon.ErrNoRows) { - // User not found - if !s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { - // We don't have to mitigate account enumeration and show the user that the account doesn't exist - return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(schema.NewAccountNotFoundError())) - } - + // If the user is not found, we still want to potentially show the UI for some method. That's why we don't exit here. // We have to mitigate account enumeration. So we continue without setting the identity hint. + // + // This will later be handled by `didPopulate`. } else if err != nil { // An error happened during lookup return nil, s.handleLoginError(w, r, f, &p, err) @@ -88,6 +87,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, opts = append(opts, login.WithIdentityHint(identityHint)) opts = append(opts, login.WithIdentifier(p.Identifier)) + didPopulate := false for _, ls := range s.d.LoginStrategies(r.Context()) { populator, ok := ls.(login.FormHydrator) if !ok { @@ -95,10 +95,39 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, } if err := populator.PopulateLoginMethodIdentifierFirstCredentials(r, f, opts...); errors.Is(err, login.ErrBreakLoginPopulate) { + didPopulate = true break + } else if errors.Is(err, ErrNoCredentialsFound) { + // This strategy is not responsible for this flow. We do not set didPopulate to true if that happens. } else if err != nil { return nil, s.handleLoginError(w, r, f, &p, err) + } else { + didPopulate = true + } + } + + // If no strategy populated, it means that the account (very likely) does not exist. We show a user not found error, + // but only if account enumeration mitigation is disabled. Otherwise, we proceed to render the rest of the form. + if !didPopulate && !s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(schema.NewAccountNotFoundError())) + } + + // We found credentials - hide the identifier. + f.UI.GetNodes().RemoveMatching(node.NewInputField("method", s.ID(), s.NodeGroup(), node.InputAttributeTypeSubmit)) + + // We set the identifier to hidden, so it's still available in the form but not visible to the user. + for k, n := range f.UI.Nodes { + if n.ID() != "identifier" { + continue + } + + attrs, ok := f.UI.Nodes[k].Attributes.(*node.InputAttributes) + if !ok { + continue } + + attrs.Type = node.InputAttributeTypeHidden + f.UI.Nodes[k].Attributes = attrs } f.Active = s.ID() @@ -149,25 +178,8 @@ func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Requ return nil } -func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(_ *http.Request, f *login.Flow, _ ...login.FormHydratorModifier) error { - f.UI.GetNodes().RemoveMatching(node.NewInputField("method", s.ID(), s.NodeGroup(), node.InputAttributeTypeSubmit)) - - // We set the identifier to hidden, so it's still available in the form but not visible to the user. - for k, n := range f.UI.Nodes { - if n.ID() != "identifier" { - continue - } - - attrs, ok := f.UI.Nodes[k].Attributes.(*node.InputAttributes) - if !ok { - continue - } - - attrs.Type = node.InputAttributeTypeHidden - f.UI.Nodes[k].Attributes = attrs - } - - return nil +func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(_ *http.Request, f *login.Flow, opts ...login.FormHydratorModifier) error { + return ErrNoCredentialsFound } func (s *Strategy) RegisterLoginRoutes(_ *x.RouterPublic) {} diff --git a/selfservice/strategy/idfirst/strategy_login_test.go b/selfservice/strategy/idfirst/strategy_login_test.go index 32599797854..85e95c37722 100644 --- a/selfservice/strategy/idfirst/strategy_login_test.go +++ b/selfservice/strategy/idfirst/strategy_login_test.go @@ -15,6 +15,10 @@ import ( "testing" "time" + "github.com/ory/kratos/selfservice/strategy/oidc" + + "github.com/ory/kratos/selfservice/strategy/idfirst" + configtesthelpers "github.com/ory/kratos/driver/config/testhelpers" "github.com/gofrs/uuid" @@ -297,36 +301,126 @@ func TestCompleteLogin(t *testing.T) { testhelpers.ExpectURL(isAPI || isSPA, publicTS.URL+login.RouteSubmitFlow, conf.SelfServiceFlowLoginUI(ctx).String())) } - t.Run("should return an error because the credentials are invalid (user does not exist)", func(t *testing.T) { - conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false) + t.Run("should return an error because the user does not exist", func(t *testing.T) { + // In this test we check if the account mitigation behaves correctly by enabling all login strategies EXCEPT + // for the passwordless code strategy. That is because this strategy always shows the login button. + + testhelpers.StrategyEnable(t, conf, identity.CredentialsTypePassword.String(), true) + + testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeOIDC.String(), true) + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC)+".config", &oidc.ConfigurationCollection{Providers: []oidc.Configuration{ + { + ID: "google", + Provider: "google", + Label: "Google", + ClientID: "a", + ClientSecret: "b", + Mapper: "file://", + }, + }}) + + testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeWebAuthn.String(), true) + conf.MustSet(ctx, config.ViperKeyWebAuthnPasswordless, true) + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config.rp.display_name", "Ory Corp") + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config.rp.id", "localhost") + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config.rp.origin", "http://localhost:4455") + + testhelpers.StrategyEnable(t, conf, identity.CredentialsTypePasskey.String(), true) + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".enabled", true) + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config.rp.display_name", "Ory Corp") + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config.rp.id", "localhost") + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config.rp.origins", []string{"http://localhost:4455"}) + t.Cleanup(func() { - conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, nil) + conf.MustSet(ctx, "selfservice.methods.password", nil) + conf.MustSet(ctx, "selfservice.methods.oidc", nil) + conf.MustSet(ctx, "selfservice.methods.passkey", nil) + conf.MustSet(ctx, "selfservice.methods.webauthn", nil) + conf.MustSet(ctx, "selfservice.methods.code", nil) }) - check := func(t *testing.T, body string, start time.Time) { - assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body) - assert.Contains(t, gjson.Get(body, "ui.action").String(), publicTS.URL+login.RouteSubmitFlow, "%s", body) - assert.Contains(t, body, text.NewErrorValidationAccountNotFound().Text) - } + t.Run("account enumeration mitigation enabled", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) - values := func(v url.Values) { - v.Set("identifier", "identifier") - v.Set("method", "identifier_first") - } + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, nil) + }) - t.Run("type=browser", func(t *testing.T) { - start := time.Now() - check(t, expectValidationError(t, false, false, false, values), start) - }) + check := func(t *testing.T, body string, isAPI bool) { + t.Logf("%s", body) + if !isAPI { + assert.Contains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginWebAuthn), "we do expect to see a webauthn trigger:\n%s", body) + assert.Contains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginPasskey), "we do expect to see a passkey trigger button:\n%s", body) + } - t.Run("type=SPA", func(t *testing.T) { - start := time.Now() - check(t, expectValidationError(t, false, false, true, values), start) + assert.Equal(t, "hidden", gjson.Get(body, "ui.nodes.#(attributes.name==identifier).attributes.type").String(), "identifier is hidden to appear that we found an identity even though we did not") + + assert.NotContains(t, body, text.NewErrorValidationAccountNotFound().Text, "we do not expect to see an account not found error:\n%s", body) + + assert.Contains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginPassword), "we do expect to see a password trigger:\n%s", body) + + // We do expect to see the same social sign in buttons that were on the first page: + assert.Contains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginWith), "we do expect to see a oidc trigger:\n%s", body) + assert.Contains(t, body, "google", "we do expect to see a google trigger:\n%s", body) + } + + values := func(v url.Values) { + v.Set("identifier", "identifier") + v.Set("method", "identifier_first") + } + + t.Run("type=browser", func(t *testing.T) { + check(t, expectValidationError(t, false, false, false, values), false) + }) + + t.Run("type=SPA", func(t *testing.T) { + check(t, expectValidationError(t, false, false, true, values), false) + }) + + t.Run("type=api", func(t *testing.T) { + check(t, expectValidationError(t, true, false, false, values), true) + }) }) - t.Run("type=api", func(t *testing.T) { - start := time.Now() - check(t, expectValidationError(t, true, false, false, values), start) + t.Run("account enumeration mitigation disabled", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false) + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, nil) + }) + + check := func(t *testing.T, body string) { + t.Logf("%s", body) + + assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body) + assert.Contains(t, gjson.Get(body, "ui.action").String(), publicTS.URL+login.RouteSubmitFlow, "%s", body) + assert.Contains(t, body, text.NewErrorValidationAccountNotFound().Text, "we do expect to see an error that the account does not exist: %s", body) + + assert.Equal(t, "text", gjson.Get(body, "ui.nodes.#(attributes.name==identifier).attributes.type").String(), "identifier is not hidden and we can see the input field as well") + + assert.NotContains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginPasskey), "we do not expect to see a passkey trigger button: %s", body) + assert.NotContains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginWebAuthn), "we do not expect to see a webauthn trigger: %s", body) + assert.NotContains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginPassword), "we do not expect to see a password trigger: %s", body) + + assert.NotContains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginWith), "we do not expect to see a oidc trigger: %s", body) + assert.NotContains(t, body, "google", "we do not expect to see a google trigger: %s", body) + } + + values := func(v url.Values) { + v.Set("identifier", "identifier") + v.Set("method", "identifier_first") + } + + t.Run("type=browser", func(t *testing.T) { + check(t, expectValidationError(t, false, false, false, values)) + }) + + t.Run("type=SPA", func(t *testing.T) { + check(t, expectValidationError(t, false, false, true, values)) + }) + + t.Run("type=api", func(t *testing.T) { + check(t, expectValidationError(t, true, false, false, values)) + }) }) }) @@ -451,13 +545,13 @@ func TestFormHydration(t *testing.T) { t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) { t.Run("case=no options", func(t *testing.T) { r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f)) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) t.Run("case=WithIdentifier", func(t *testing.T) { r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com"))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) @@ -467,7 +561,7 @@ func TestFormHydration(t *testing.T) { id := identity.NewIdentity("default") r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) @@ -478,14 +572,14 @@ func TestFormHydration(t *testing.T) { id := identity.NewIdentity("default") r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) t.Run("case=identity does not have a password", func(t *testing.T) { id := identity.NewIdentity("default") r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) }) diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_oidc.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_oidc.json index 364b8abc331..19765bd501b 100644 --- a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_oidc.json +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_oidc.json @@ -1,15 +1 @@ -[ - { - "type": "input", - "group": "default", - "attributes": { - "name": "csrf_token", - "type": "hidden", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - } -] +null diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_oidc.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_oidc.json index 9ce35531c24..29f5e9aa106 100644 --- a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_oidc.json +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_oidc.json @@ -1,17 +1,4 @@ [ - { - "type": "input", - "group": "default", - "attributes": { - "name": "csrf_token", - "type": "hidden", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, { "type": "input", "group": "oidc", diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go index b510030fe78..3b5f7229170 100644 --- a/selfservice/strategy/oidc/strategy_login.go +++ b/selfservice/strategy/oidc/strategy_login.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/ory/kratos/selfservice/strategy/idfirst" "github.com/ory/x/stringsx" "github.com/ory/kratos/selfservice/flowhelpers" @@ -336,40 +337,44 @@ func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, sr *l } func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, f *login.Flow, mods ...login.FormHydratorModifier) error { - if f.Type != flow.TypeBrowser { - return nil - } - conf, err := s.Config(r.Context()) if err != nil { return err } o := login.NewFormHydratorOptions(mods) - if s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { - // Account enumeration is mitigated, so we don't modify the providers from the first step at all. - return nil - } - if o.IdentityHint == nil { - // Identity was not found, show all available providers. - return nil + var linked []Provider + if o.IdentityHint != nil { + var err error + // If we have an identity hint we check if the identity has any providers configured. + if linked, err = s.linkedProviders(r.Context(), r, conf, o.IdentityHint); err != nil { + return err + } } - // User is found and enumeration mitigation is disabled. Filter the list! - f.GetUI().UnsetNode("provider") - f.GetUI().SetCSRF(s.d.GenerateCSRFToken(r)) + if len(linked) == 0 { + // If we found no credentials: + if s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + // We found no credentials but do not want to leak that we know that. So we return early and do not + // modify the initial provider list. + return nil + } - // If we have an identity hint we can perform identity credentials discovery and - // show only the providers that can be used to log in as this identity. - linked, err := s.linkedProviders(r.Context(), r, conf, o.IdentityHint) - if err != nil { - return err + // We found no credentials. We remove all the providers and tell the strategy that we found nothing. + f.GetUI().UnsetNode("provider") + return idfirst.ErrNoCredentialsFound } - for _, l := range linked { - lc := l.Config() - AddProvider(f.UI, lc.ID, text.NewInfoLoginWith(stringsx.Coalesce(lc.Label, lc.ID))) + if !s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + // Account enumeration is disabled, so we show all providers that are linked to the identity. + // User is found and enumeration mitigation is disabled. Filter the list! + f.GetUI().UnsetNode("provider") + + for _, l := range linked { + lc := l.Config() + AddProvider(f.UI, lc.ID, text.NewInfoLoginWith(stringsx.Coalesce(lc.Label, lc.ID))) + } } return nil diff --git a/selfservice/strategy/oidc/strategy_login_test.go b/selfservice/strategy/oidc/strategy_login_test.go index 0a22aff6076..12990c4cb5e 100644 --- a/selfservice/strategy/oidc/strategy_login_test.go +++ b/selfservice/strategy/oidc/strategy_login_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/ory/kratos/selfservice/strategy/idfirst" + configtesthelpers "github.com/ory/kratos/driver/config/testhelpers" "github.com/gofrs/uuid" @@ -115,13 +117,13 @@ func TestFormHydration(t *testing.T) { t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) { t.Run("case=no options", func(t *testing.T) { r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f)) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) t.Run("case=WithIdentifier", func(t *testing.T) { r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com"))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) @@ -150,7 +152,7 @@ func TestFormHydration(t *testing.T) { t.Run("case=identity does not have a oidc", func(t *testing.T) { id := identity.NewIdentity("default") r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) }) diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_enabled.json similarity index 100% rename from selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier.json rename to selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_enabled.json diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_enabled.json similarity index 100% rename from selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options.json rename to selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_enabled.json diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json index e4fab3117c1..222443d4988 100644 --- a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json @@ -73,26 +73,5 @@ }, "messages": [], "meta": {} - }, - { - "type": "input", - "group": "passkey", - "attributes": { - "name": "passkey_login_trigger", - "type": "button", - "value": "", - "disabled": false, - "onclick": "window.oryPasskeyLogin()", - "onclickTrigger": "oryPasskeyLogin", - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1010021, - "text": "Sign in with passkey", - "type": "info" - } - } } ] diff --git a/selfservice/strategy/passkey/passkey_login.go b/selfservice/strategy/passkey/passkey_login.go index 1be367addc2..857d6e824d3 100644 --- a/selfservice/strategy/passkey/passkey_login.go +++ b/selfservice/strategy/passkey/passkey_login.go @@ -9,6 +9,8 @@ import ( "net/http" "strings" + "github.com/ory/kratos/selfservice/strategy/idfirst" + "github.com/ory/kratos/x/webauthnx/js" "github.com/go-webauthn/webauthn/protocol" @@ -425,35 +427,39 @@ func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, sr *l func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *login.Flow, opts ...login.FormHydratorModifier) error { if sr.Type != flow.TypeBrowser { - return nil + return errors.WithStack(idfirst.ErrNoCredentialsFound) } o := login.NewFormHydratorOptions(opts) - if o.IdentityHint == nil { - // Identity was not found so add fields - } else { + var count int + if o.IdentityHint != nil { + var err error // If we have an identity hint we can perform identity credentials discovery and // hide this credential if it should not be included. - count, err := s.CountActiveFirstFactorCredentials(o.IdentityHint.Credentials) + count, err = s.CountActiveFirstFactorCredentials(o.IdentityHint.Credentials) if err != nil { return err - } else if count == 0 && !s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { - return nil } } - sr.UI.Nodes.Append(node.NewInputField( - node.PasskeyLoginTrigger, - "", - node.PasskeyGroup, - node.InputAttributeTypeButton, - node.WithInputAttributes(func(attr *node.InputAttributes) { - //nolint:staticcheck - attr.OnClick = js.WebAuthnTriggersPasskeyLogin.String() + "()" // this function is defined in webauthn.js - attr.OnClickTrigger = js.WebAuthnTriggersPasskeyLogin - }), - ).WithMetaLabel(text.NewInfoSelfServiceLoginPasskey())) + if count > 0 || s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + sr.UI.Nodes.Append(node.NewInputField( + node.PasskeyLoginTrigger, + "", + node.PasskeyGroup, + node.InputAttributeTypeButton, + node.WithInputAttributes(func(attr *node.InputAttributes) { + //nolint:staticcheck + attr.OnClick = js.WebAuthnTriggersPasskeyLogin.String() + "()" // this function is defined in webauthn.js + attr.OnClickTrigger = js.WebAuthnTriggersPasskeyLogin + }), + ).WithMetaLabel(text.NewInfoSelfServiceLoginPasskey())) + } + + if count == 0 { + return errors.WithStack(idfirst.ErrNoCredentialsFound) + } return nil } @@ -467,17 +473,5 @@ func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Requ return err } - sr.UI.Nodes.Append(node.NewInputField( - node.PasskeyLoginTrigger, - "", - node.PasskeyGroup, - node.InputAttributeTypeButton, - node.WithInputAttributes(func(attr *node.InputAttributes) { - //nolint:staticcheck - attr.OnClick = js.WebAuthnTriggersPasskeyLogin.String() + "()" // this function is defined in webauthn.js - attr.OnClickTrigger = js.WebAuthnTriggersPasskeyLogin - }), - ).WithMetaLabel(text.NewInfoSelfServiceLoginPasskey())) - return nil } diff --git a/selfservice/strategy/passkey/passkey_login_test.go b/selfservice/strategy/passkey/passkey_login_test.go index 822aacfdd98..028d7281c5e 100644 --- a/selfservice/strategy/passkey/passkey_login_test.go +++ b/selfservice/strategy/passkey/passkey_login_test.go @@ -13,6 +13,8 @@ import ( "testing" "time" + "github.com/ory/kratos/selfservice/strategy/idfirst" + configtesthelpers "github.com/ory/kratos/driver/config/testhelpers" "github.com/gofrs/uuid" @@ -398,15 +400,35 @@ func TestFormHydration(t *testing.T) { t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) { t.Run("case=no options", func(t *testing.T) { - r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f)) - toSnapshot(t, f) + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false) + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) }) t.Run("case=WithIdentifier", func(t *testing.T) { - r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com"))) - toSnapshot(t, f) + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false) + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) }) t.Run("case=WithIdentityHint", func(t *testing.T) { @@ -415,7 +437,7 @@ func TestFormHydration(t *testing.T) { id := identity.NewIdentity("test-provider") r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) @@ -434,7 +456,7 @@ func TestFormHydration(t *testing.T) { t.Run("case=identity does not have a passkey", func(t *testing.T) { id := identity.NewIdentity("default") r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) }) diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 00000000000..19765bd501b --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_enabled.json similarity index 100% rename from selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier.json rename to selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_enabled.json diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 00000000000..19765bd501b --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_enabled.json similarity index 100% rename from selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options.json rename to selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_enabled.json diff --git a/selfservice/strategy/password/login.go b/selfservice/strategy/password/login.go index f4825590834..79c55c177e9 100644 --- a/selfservice/strategy/password/login.go +++ b/selfservice/strategy/password/login.go @@ -10,6 +10,8 @@ import ( "net/http" "time" + "github.com/ory/kratos/selfservice/strategy/idfirst" + "github.com/ory/kratos/selfservice/flowhelpers" "github.com/ory/kratos/session" @@ -186,22 +188,26 @@ func (s *Strategy) PopulateLoginMethodFirstFactor(r *http.Request, sr *login.Flo func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *login.Flow, opts ...login.FormHydratorModifier) error { o := login.NewFormHydratorOptions(opts) - if o.IdentityHint == nil { - // Identity was not found so add fields - } else { + var count int + if o.IdentityHint != nil { + var err error // If we have an identity hint we can perform identity credentials discovery and // hide this credential if it should not be included. - count, err := s.CountActiveFirstFactorCredentials(o.IdentityHint.Credentials) - if err != nil { + if count, err = s.CountActiveFirstFactorCredentials(o.IdentityHint.Credentials); err != nil { return err - } else if count == 0 && !s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { - return nil } } - sr.UI.SetCSRF(s.d.GenerateCSRFToken(r)) - sr.UI.SetNode(NewPasswordNode("password", node.InputAttributeAutocompleteCurrentPassword)) - sr.UI.GetNodes().Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLoginPassword())) + if count > 0 || s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + sr.UI.SetCSRF(s.d.GenerateCSRFToken(r)) + sr.UI.SetNode(NewPasswordNode("password", node.InputAttributeAutocompleteCurrentPassword)) + sr.UI.GetNodes().Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLoginPassword())) + } + + if count == 0 { + return errors.WithStack(idfirst.ErrNoCredentialsFound) + } + return nil } diff --git a/selfservice/strategy/password/login_test.go b/selfservice/strategy/password/login_test.go index 7f05d33146c..2ed77d17849 100644 --- a/selfservice/strategy/password/login_test.go +++ b/selfservice/strategy/password/login_test.go @@ -17,6 +17,8 @@ import ( "testing" "time" + "github.com/ory/kratos/selfservice/strategy/idfirst" + configtesthelpers "github.com/ory/kratos/driver/config/testhelpers" "github.com/ory/x/snapshotx" @@ -955,15 +957,35 @@ func TestFormHydration(t *testing.T) { t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) { t.Run("case=no options", func(t *testing.T) { - r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f)) - toSnapshot(t, f) + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false) + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) }) t.Run("case=WithIdentifier", func(t *testing.T) { - r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com"))) - toSnapshot(t, f) + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false) + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) }) t.Run("case=WithIdentityHint", func(t *testing.T) { @@ -972,7 +994,7 @@ func TestFormHydration(t *testing.T) { id := identity.NewIdentity("default") r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) @@ -991,7 +1013,7 @@ func TestFormHydration(t *testing.T) { t.Run("case=identity does not have a password", func(t *testing.T) { id := identity.NewIdentity("default") r, f := newFlow(ctx, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) }) diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_enabled.json similarity index 100% rename from selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled.json rename to selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_enabled.json diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_enabled.json similarity index 100% rename from selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled.json rename to selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_enabled.json diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=mfa_enabled_and_user_has_mfa_credentials.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=mfa_enabled_and_user_has_mfa_credentials.json index 869b44cf632..1be62bb13f4 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=mfa_enabled_and_user_has_mfa_credentials.json +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=mfa_enabled_and_user_has_mfa_credentials.json @@ -28,7 +28,6 @@ "type": "script", "group": "webauthn", "attributes": { - "src": "http://ORY-MYC0XYW5H3:4433/.well-known/ory/webauthn.js", "async": true, "referrerpolicy": "no-referrer", "crossorigin": "anonymous", diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=passwordless_enabled_and_user_has_passwordless_credentials.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=passwordless_enabled_and_user_has_passwordless_credentials.json index 869b44cf632..1be62bb13f4 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=passwordless_enabled_and_user_has_passwordless_credentials.json +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=passwordless_enabled_and_user_has_passwordless_credentials.json @@ -28,7 +28,6 @@ "type": "script", "group": "webauthn", "attributes": { - "src": "http://ORY-MYC0XYW5H3:4433/.well-known/ory/webauthn.js", "async": true, "referrerpolicy": "no-referrer", "crossorigin": "anonymous", diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=mfa_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=mfa_enabled.json index 869b44cf632..1be62bb13f4 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=mfa_enabled.json +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=mfa_enabled.json @@ -28,7 +28,6 @@ "type": "script", "group": "webauthn", "attributes": { - "src": "http://ORY-MYC0XYW5H3:4433/.well-known/ory/webauthn.js", "async": true, "referrerpolicy": "no-referrer", "crossorigin": "anonymous", diff --git a/selfservice/strategy/webauthn/login.go b/selfservice/strategy/webauthn/login.go index 8ff7d59fe66..fe98d1d88c5 100644 --- a/selfservice/strategy/webauthn/login.go +++ b/selfservice/strategy/webauthn/login.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "github.com/ory/kratos/selfservice/strategy/idfirst" + "github.com/ory/kratos/selfservice/flowhelpers" "github.com/ory/kratos/session" "github.com/ory/kratos/x/webauthnx" @@ -376,30 +378,37 @@ func (s *Strategy) PopulateLoginMethodSecondFactor(r *http.Request, sr *login.Fl func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *login.Flow, opts ...login.FormHydratorModifier) error { if sr.Type != flow.TypeBrowser || !s.d.Config().WebAuthnForPasswordless(r.Context()) { - return nil + return errors.WithStack(idfirst.ErrNoCredentialsFound) } o := login.NewFormHydratorOptions(opts) - if o.IdentityHint == nil { - // Identity was not found so add fields - } else { + + var count int + if o.IdentityHint != nil { + var err error // If we have an identity hint we can perform identity credentials discovery and // hide this credential if it should not be included. - count, err := s.CountActiveFirstFactorCredentials(o.IdentityHint.Credentials) - if err != nil { + if count, err = s.CountActiveFirstFactorCredentials(o.IdentityHint.Credentials); err != nil { return err - } else if count == 0 && !s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + } + } + + if count > 0 || s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + if err := s.populateLoginMethodForPasswordless(r, sr); errors.Is(err, webauthnx.ErrNoCredentials) { + if !s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + return errors.WithStack(idfirst.ErrNoCredentialsFound) + } return nil + } else if err != nil { + return err } } - if err := s.populateLoginMethodForPasswordless(r, sr); errors.Is(err, webauthnx.ErrNoCredentials) { - return nil - } else if err != nil { - return err + if count == 0 { + return errors.WithStack(idfirst.ErrNoCredentialsFound) } - return nil + return nil } func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, sr *login.Flow) error { diff --git a/selfservice/strategy/webauthn/login_test.go b/selfservice/strategy/webauthn/login_test.go index 75f4b2ba332..f1323b9b678 100644 --- a/selfservice/strategy/webauthn/login_test.go +++ b/selfservice/strategy/webauthn/login_test.go @@ -15,6 +15,8 @@ import ( "testing" "time" + "github.com/ory/kratos/selfservice/strategy/idfirst" + "github.com/ory/x/jsonx" "github.com/go-webauthn/webauthn/protocol" @@ -671,7 +673,7 @@ func TestFormHydration(t *testing.T) { f.UI.Nodes.ResetNodes("csrf_token") f.UI.Nodes.ResetNodes("identifier") f.UI.Nodes.ResetNodes("webauthn_login_trigger") - snapshotx.SnapshotT(t, f.UI.Nodes, snapshotx.ExceptNestedKeys("onclick", "nonce")) + snapshotx.SnapshotT(t, f.UI.Nodes, snapshotx.ExceptNestedKeys("onclick", "nonce", "src")) } newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *login.Flow) { @@ -768,29 +770,85 @@ func TestFormHydration(t *testing.T) { t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) { t.Run("case=no options", func(t *testing.T) { t.Run("case=passwordless enabled", func(t *testing.T) { - r, f := newFlow(passwordlessEnabled, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f)) - toSnapshot(t, f) + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false), + t, + ) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true), + t, + ) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) }) t.Run("case=mfa enabled", func(t *testing.T) { - r, f := newFlow(mfaEnabled, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f)) - toSnapshot(t, f) + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false), + t, + ) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true), + t, + ) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) }) }) t.Run("case=WithIdentifier", func(t *testing.T) { t.Run("case=passwordless enabled", func(t *testing.T) { - r, f := newFlow(passwordlessEnabled, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com"))) - toSnapshot(t, f) + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false), + t, + ) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true), + t, + ) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) }) t.Run("case=mfa enabled", func(t *testing.T) { - r, f := newFlow(mfaEnabled, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com"))) - toSnapshot(t, f) + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false), + t, + ) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true), + t, + ) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) }) }) @@ -802,13 +860,13 @@ func TestFormHydration(t *testing.T) { id := identity.NewIdentity("test-provider") t.Run("case=passwordless enabled", func(t *testing.T) { r, f := newFlow(passwordlessEnabled, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) t.Run("case=mfa enabled", func(t *testing.T) { r, f := newFlow(mfaEnabled, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) }) @@ -828,7 +886,7 @@ func TestFormHydration(t *testing.T) { t.Run("case=mfa enabled", func(t *testing.T) { r, f := newFlow(mfaEnabled, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) }) @@ -837,14 +895,14 @@ func TestFormHydration(t *testing.T) { t.Run("case=passwordless enabled", func(t *testing.T) { id := identity.NewIdentity("default") r, f := newFlow(passwordlessEnabled, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) t.Run("case=mfa enabled", func(t *testing.T) { id := identity.NewIdentity("default") r, f := newFlow(mfaEnabled, t) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) })