diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9c2ee0555b4..59697709319 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -289,6 +289,7 @@ jobs: uses: actions/checkout@v3 with: repository: ory/kratos-selfservice-ui-node + ref: jonas-jonas/two-step path: node-ui - run: | cd node-ui diff --git a/.gitignore b/.gitignore index f792761b2b7..42d798e427a 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ test/e2e/kratos.*.yml # VSCode debug artifact __debug_bin .debug.sqlite.db +.last-run.json \ No newline at end of file diff --git a/selfservice/flow/login/strategy_form_hydrator.go b/selfservice/flow/login/strategy_form_hydrator.go index 8f1fb05a5f0..8f665a0f043 100644 --- a/selfservice/flow/login/strategy_form_hydrator.go +++ b/selfservice/flow/login/strategy_form_hydrator.go @@ -41,7 +41,6 @@ var ErrBreakLoginPopulate = errors.New("skip rest of login form population") type FormHydratorOptions struct { IdentityHint *identity.Identity - Identifier string } type FormHydratorModifier func(o *FormHydratorOptions) @@ -52,12 +51,6 @@ func WithIdentityHint(i *identity.Identity) FormHydratorModifier { } } -func WithIdentifier(i string) FormHydratorModifier { - return func(o *FormHydratorOptions) { - o.Identifier = i - } -} - func NewFormHydratorOptions(modifiers []FormHydratorModifier) *FormHydratorOptions { o := new(FormHydratorOptions) for _, m := range modifiers { diff --git a/selfservice/flow/login/strategy_form_hydrator_test.go b/selfservice/flow/login/strategy_form_hydrator_test.go index 863a1031051..64d0f0f54d6 100644 --- a/selfservice/flow/login/strategy_form_hydrator_test.go +++ b/selfservice/flow/login/strategy_form_hydrator_test.go @@ -16,9 +16,3 @@ func TestWithIdentityHint(t *testing.T) { opts := NewFormHydratorOptions([]FormHydratorModifier{WithIdentityHint(expected)}) assert.Equal(t, expected, opts.IdentityHint) } - -func TestWithIdentifier(t *testing.T) { - expected := "identifier" - opts := NewFormHydratorOptions([]FormHydratorModifier{WithIdentifier(expected)}) - assert.Equal(t, expected, opts.Identifier) -} diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=with_no_identity-case=code_is_used_for_2fa.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=with_no_identity-case=code_is_used_for_2fa.json new file mode 100644 index 00000000000..19765bd501b --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=with_no_identity-case=code_is_used_for_2fa.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=with_no_identity-case=code_is_used_for_passwordless_login.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=with_no_identity-case=code_is_used_for_passwordless_login.json new file mode 100644 index 00000000000..19765bd501b --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=with_no_identity-case=code_is_used_for_passwordless_login.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/code/strategy_login.go b/selfservice/strategy/code/strategy_login.go index ae10c39a4c8..a6d9af4fad0 100644 --- a/selfservice/strategy/code/strategy_login.go +++ b/selfservice/strategy/code/strategy_login.go @@ -32,8 +32,10 @@ import ( "github.com/ory/x/decoderx" ) -var _ login.FormHydrator = new(Strategy) -var _ login.Strategy = new(Strategy) +var ( + _ login.FormHydrator = new(Strategy) + _ login.Strategy = new(Strategy) +) // Update Login flow using the code method // @@ -389,16 +391,21 @@ func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, f *lo return s.PopulateMethod(r, f) } -func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, f *login.Flow, _ ...login.FormHydratorModifier) error { +func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, f *login.Flow, opts ...login.FormHydratorModifier) error { 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 + return errors.WithStack(idfirst.ErrNoCredentialsFound) + } + o := login.NewFormHydratorOptions(opts) + + // If the identity hint is nil and account enumeration mitigation is disabled, we return an error. + if o.IdentityHint == nil && !s.deps.Config().SecurityAccountEnumerationMitigate(r.Context()) { + return errors.WithStack(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 619acaffcfb..660e6f3352f 100644 --- a/selfservice/strategy/code/strategy_login_test.go +++ b/selfservice/strategy/code/strategy_login_test.go @@ -915,20 +915,6 @@ 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.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) - toSnapshot(t, f) - }) - - t.Run("case=code is used for passwordless login", 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=WithIdentityHint", func(t *testing.T) { t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { t.Run("case=code is used for 2fa", func(t *testing.T) { @@ -936,7 +922,7 @@ func TestFormHydration(t *testing.T) { configtesthelpers.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true), t, ) - require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) toSnapshot(t, f) }) @@ -945,12 +931,25 @@ func TestFormHydration(t *testing.T) { configtesthelpers.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true), t, ) - require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com"))) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f)) toSnapshot(t, f) }) }) t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + t.Run("case=with no identity", func(t *testing.T) { + t.Run("case=code is used for 2fa", func(t *testing.T) { + r, f := newFlow(mfaEnabled, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=code is used for passwordless login", func(t *testing.T) { + r, f := newFlow(passwordlessEnabled, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + }) t.Run("case=identity has code method", func(t *testing.T) { identifier := x.NewUUID().String() id := createIdentity(ctx, t, reg, false, identifier) diff --git a/selfservice/strategy/idfirst/strategy_login.go b/selfservice/strategy/idfirst/strategy_login.go index 987b97ca1e9..7b4df627b6b 100644 --- a/selfservice/strategy/idfirst/strategy_login.go +++ b/selfservice/strategy/idfirst/strategy_login.go @@ -21,9 +21,11 @@ import ( "github.com/ory/x/sqlcon" ) -var _ login.FormHydrator = new(Strategy) -var _ login.Strategy = new(Strategy) -var ErrNoCredentialsFound = errors.New("no credentials found") +var ( + _ login.FormHydrator = new(Strategy) + _ login.Strategy = new(Strategy) + 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 { @@ -85,7 +87,6 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, // Add identity hint opts = append(opts, login.WithIdentityHint(identityHint)) - opts = append(opts, login.WithIdentifier(p.Identifier)) didPopulate := false for _, ls := range s.d.LoginStrategies(r.Context()) { diff --git a/selfservice/strategy/idfirst/strategy_login_test.go b/selfservice/strategy/idfirst/strategy_login_test.go index 85e95c37722..5aa88c456b2 100644 --- a/selfservice/strategy/idfirst/strategy_login_test.go +++ b/selfservice/strategy/idfirst/strategy_login_test.go @@ -53,10 +53,10 @@ func TestCompleteLogin(t *testing.T) { // We enable the password method to test the identifier first strategy - //ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword), map[string]interface{}{"enabled": true}) + // ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword), map[string]interface{}{"enabled": true}) conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword), map[string]interface{}{"enabled": true}) - //ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceLoginFlowStyle, "identifier_first") + // ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceLoginFlowStyle, "identifier_first") conf.MustSet(ctx, config.ViperKeySelfServiceLoginFlowStyle, "identifier_first") router := x.NewRouterPublic() @@ -67,16 +67,16 @@ func TestCompleteLogin(t *testing.T) { redirTS := testhelpers.NewRedirSessionEchoTS(t, reg) // Overwrite these two: - //ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceErrorUI, errTS.URL+"/error-ts") + // ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceErrorUI, errTS.URL+"/error-ts") conf.MustSet(ctx, config.ViperKeySelfServiceErrorUI, errTS.URL+"/error-ts") - //ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceLoginUI, uiTS.URL+"/login-ts") + // ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceLoginUI, uiTS.URL+"/login-ts") conf.MustSet(ctx, config.ViperKeySelfServiceLoginUI, uiTS.URL+"/login-ts") - //ctx = testhelpers.WithDefaultIdentitySchemaFromRaw(ctx, loginSchema) + // ctx = testhelpers.WithDefaultIdentitySchemaFromRaw(ctx, loginSchema) testhelpers.SetDefaultIdentitySchemaFromRaw(conf, loginSchema) - //ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySecretsDefault, []string{"not-a-secure-session-key"}) + // ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySecretsDefault, []string{"not-a-secure-session-key"}) conf.MustSet(ctx, config.ViperKeySecretsDefault, []string{"not-a-secure-session-key"}) //ensureFieldsExist := func(t *testing.T, body []byte) { @@ -549,12 +549,6 @@ func TestFormHydration(t *testing.T) { toSnapshot(t, f) }) - t.Run("case=WithIdentifier", func(t *testing.T) { - 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) { t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) diff --git a/selfservice/strategy/oidc/strategy_login_test.go b/selfservice/strategy/oidc/strategy_login_test.go index 12990c4cb5e..074019dedb1 100644 --- a/selfservice/strategy/oidc/strategy_login_test.go +++ b/selfservice/strategy/oidc/strategy_login_test.go @@ -51,7 +51,6 @@ func TestFormHydration(t *testing.T) { map[string]interface{}{ "providers": []map[string]interface{}{ { - "provider": "generic", "id": providerID, "client_id": "invalid", @@ -121,12 +120,6 @@ func TestFormHydration(t *testing.T) { toSnapshot(t, f) }) - t.Run("case=WithIdentifier", func(t *testing.T) { - 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) { t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) diff --git a/selfservice/strategy/passkey/passkey_login_test.go b/selfservice/strategy/passkey/passkey_login_test.go index 028d7281c5e..db2a42190f9 100644 --- a/selfservice/strategy/passkey/passkey_login_test.go +++ b/selfservice/strategy/passkey/passkey_login_test.go @@ -415,22 +415,6 @@ func TestFormHydration(t *testing.T) { }) }) - t.Run("case=WithIdentifier", func(t *testing.T) { - 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) { t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) diff --git a/selfservice/strategy/password/login.go b/selfservice/strategy/password/login.go index 4b915d48513..3b487a59057 100644 --- a/selfservice/strategy/password/login.go +++ b/selfservice/strategy/password/login.go @@ -10,23 +10,23 @@ import ( "net/http" "time" - "github.com/ory/kratos/hash" - "github.com/ory/kratos/selfservice/flowhelpers" - "github.com/ory/kratos/selfservice/hook" - "github.com/ory/kratos/selfservice/strategy/idfirst" - "github.com/ory/kratos/session" - "github.com/ory/x/stringsx" "github.com/gofrs/uuid" - "github.com/pkg/errors" "github.com/ory/herodot" - "github.com/ory/x/decoderx" + "github.com/ory/kratos/hash" "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/selfservice/flowhelpers" + "github.com/ory/kratos/selfservice/hook" + "github.com/ory/kratos/selfservice/strategy/idfirst" + "github.com/ory/kratos/session" "github.com/ory/kratos/text" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" + "github.com/ory/x/decoderx" + "github.com/ory/x/stringsx" + "github.com/pkg/errors" ) var _ login.FormHydrator = new(Strategy) @@ -34,7 +34,7 @@ var _ login.FormHydrator = new(Strategy) func (s *Strategy) RegisterLoginRoutes(r *x.RouterPublic) { } -func (s *Strategy) handleLoginError(w http.ResponseWriter, r *http.Request, f *login.Flow, payload *updateLoginFlowWithPasswordMethod, err error) error { +func (s *Strategy) handleLoginError(r *http.Request, f *login.Flow, payload *updateLoginFlowWithPasswordMethod, err error) error { if f != nil { f.UI.Nodes.ResetNodes("password") f.UI.Nodes.SetValueAttribute("identifier", stringsx.Coalesce(payload.Identifier, payload.LegacyIdentifier)) @@ -60,19 +60,19 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, decoderx.HTTPDecoderSetValidatePayloads(true), decoderx.MustHTTPRawJSONSchemaCompiler(loginSchema), decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil { - return nil, s.handleLoginError(w, r, f, &p, err) + return nil, s.handleLoginError(r, f, &p, err) } f.TransientPayload = p.TransientPayload if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config().DisableAPIFlowEnforcement(r.Context()), s.d.GenerateCSRFToken, p.CSRFToken); err != nil { - return nil, s.handleLoginError(w, r, f, &p, err) + return nil, s.handleLoginError(r, f, &p, err) } identifier := stringsx.Coalesce(p.Identifier, p.LegacyIdentifier) i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), s.ID(), identifier) if err != nil { time.Sleep(x.RandomDelay(s.d.Config().HasherArgon2(r.Context()).ExpectedDuration, s.d.Config().HasherArgon2(r.Context()).ExpectedDeviation)) - return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(schema.NewInvalidCredentialsError())) + return nil, s.handleLoginError(r, f, &p, errors.WithStack(schema.NewInvalidCredentialsError())) } var o identity.CredentialsPassword @@ -90,27 +90,27 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, migrationHook := hook.NewPasswordMigrationHook(s.d, pwHook.Config) err = migrationHook.Execute(r.Context(), &hook.PasswordMigrationRequest{Identifier: identifier, Password: p.Password}) if err != nil { - return nil, s.handleLoginError(w, r, f, &p, err) + return nil, s.handleLoginError(r, f, &p, err) } if err := s.migratePasswordHash(r.Context(), i.ID, []byte(p.Password)); err != nil { - return nil, s.handleLoginError(w, r, f, &p, err) + return nil, s.handleLoginError(r, f, &p, err) } } else { if err := hash.Compare(r.Context(), []byte(p.Password), []byte(o.HashedPassword)); err != nil { - return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(schema.NewInvalidCredentialsError())) + return nil, s.handleLoginError(r, f, &p, errors.WithStack(schema.NewInvalidCredentialsError())) } if !s.d.Hasher(r.Context()).Understands([]byte(o.HashedPassword)) { if err := s.migratePasswordHash(r.Context(), i.ID, []byte(p.Password)); err != nil { - return nil, s.handleLoginError(w, r, f, &p, err) + return nil, s.handleLoginError(r, f, &p, err) } } } f.Active = s.ID() if err = s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), f); err != nil { - return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(herodot.ErrInternalServerError.WithReason("Could not update flow").WithDebug(err.Error()))) + return nil, s.handleLoginError(r, f, &p, errors.WithStack(herodot.ErrInternalServerError.WithReason("Could not update flow").WithDebug(err.Error()))) } return i, nil diff --git a/selfservice/strategy/password/login_test.go b/selfservice/strategy/password/login_test.go index edd816ae526..55281a6bb39 100644 --- a/selfservice/strategy/password/login_test.go +++ b/selfservice/strategy/password/login_test.go @@ -1171,22 +1171,6 @@ func TestFormHydration(t *testing.T) { }) }) - t.Run("case=WithIdentifier", func(t *testing.T) { - 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) { t.Run("case=account enumeration mitigation enabled and identity has no password", func(t *testing.T) { ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) diff --git a/selfservice/strategy/webauthn/login_test.go b/selfservice/strategy/webauthn/login_test.go index f1323b9b678..ef7a40662dc 100644 --- a/selfservice/strategy/webauthn/login_test.go +++ b/selfservice/strategy/webauthn/login_test.go @@ -810,48 +810,6 @@ func TestFormHydration(t *testing.T) { }) }) - t.Run("case=WithIdentifier", func(t *testing.T) { - t.Run("case=passwordless enabled", func(t *testing.T) { - 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) { - 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) - }) - }) - }) - t.Run("case=WithIdentityHint", func(t *testing.T) { t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { mfaEnabled := configtesthelpers.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true) diff --git a/test/e2e/cypress/support/commands.ts b/test/e2e/cypress/support/commands.ts index 199dfa81a79..89b5c7cb15c 100644 --- a/test/e2e/cypress/support/commands.ts +++ b/test/e2e/cypress/support/commands.ts @@ -17,7 +17,7 @@ import { import dayjs from "dayjs" import YAML from "yamljs" import { MailMessage, Strategy } from "." -import { OryKratosConfiguration } from "./config" +import { OryKratosConfiguration } from "../../shared/config" import { UiNode } from "@ory/kratos-client" import { ConfigBuilder } from "./configHelpers" diff --git a/test/e2e/cypress/support/configHelpers.ts b/test/e2e/cypress/support/configHelpers.ts index c8ccf05b70d..0fc72864294 100644 --- a/test/e2e/cypress/support/configHelpers.ts +++ b/test/e2e/cypress/support/configHelpers.ts @@ -1,7 +1,7 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { OryKratosConfiguration } from "./config" +import { OryKratosConfiguration } from "../../shared/config" export class ConfigBuilder { constructor(readonly config: OryKratosConfiguration) {} diff --git a/test/e2e/cypress/support/index.d.ts b/test/e2e/cypress/support/index.d.ts index 9cfeb083f12..a6a7120937d 100644 --- a/test/e2e/cypress/support/index.d.ts +++ b/test/e2e/cypress/support/index.d.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Session as KratosSession } from "@ory/kratos-client" -import { OryKratosConfiguration } from "./config" +import { OryKratosConfiguration } from "../../shared/config" import { ConfigBuilder } from "./configHelpers" export interface MailMessage { diff --git a/test/e2e/cypress/tsconfig.json b/test/e2e/cypress/tsconfig.json index 6042605a688..dd9b96adae4 100644 --- a/test/e2e/cypress/tsconfig.json +++ b/test/e2e/cypress/tsconfig.json @@ -2,15 +2,9 @@ "compilerOptions": { "baseUrl": "../../../node_modules", "target": "es5", - "lib": [ - "es2015", - "dom" - ], + "lib": ["es2015", "dom"], "types": ["cypress", "node"], - "esModuleInterop": true, + "esModuleInterop": true }, - "include": [ - "**/*.ts", - "support/index.ts", - ], + "include": ["**/*.ts", "support/index.ts", "../shared/config.d.ts"] } diff --git a/test/e2e/package-lock.json b/test/e2e/package-lock.json index f7f7bd88539..f6452591bda 100644 --- a/test/e2e/package-lock.json +++ b/test/e2e/package-lock.json @@ -8,13 +8,14 @@ "name": "@ory/kratos-e2e-suite", "version": "0.0.1", "dependencies": { - "@faker-js/faker": "7.6.0", + "@faker-js/faker": "8.4.1", "async-retry": "1.3.3", - "mailhog": "4.16.0" + "mailhog": "4.16.0", + "promise-retry": "^2.0.1" }, "devDependencies": { - "@ory/kratos-client": "0.0.0-next.8d3b018594f7", - "@playwright/test": "1.34.0", + "@ory/kratos-client": "1.2.0", + "@playwright/test": "1.44.1", "@types/async-retry": "1.4.5", "@types/node": "16.9.6", "@types/yamljs": "0.2.31", @@ -98,12 +99,19 @@ } }, "node_modules/@faker-js/faker": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-7.6.0.tgz", - "integrity": "sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", + "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", "engines": { - "node": ">=14.0.0", - "npm": ">=6.0.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" } }, "node_modules/@hapi/hoek": { @@ -128,12 +136,13 @@ "dev": true }, "node_modules/@ory/kratos-client": { - "version": "0.0.0-next.8d3b018594f7", - "resolved": "https://registry.npmjs.org/@ory/kratos-client/-/kratos-client-0.0.0-next.8d3b018594f7.tgz", - "integrity": "sha512-TkpjBo6Z6UUEJIJCR2EDdpKVDNgQHzwDWZbOjz3xTOUoGipMBykvIfluP58Jwkpt2rIXUkt9+L+u1mFFvD/tqA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ory/kratos-client/-/kratos-client-1.2.0.tgz", + "integrity": "sha512-W6jFkVEjnoq5ylGOvYOOaNvEZ1cGSEN/YJsZTcBVye81nQtW5R7QWClvNsJVD1LjwgWGMVKWglrFlfHvvkKnmg==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "axios": "^0.21.1" + "axios": "^1.6.1" } }, "node_modules/@otplib/core": { @@ -184,22 +193,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.34.0.tgz", - "integrity": "sha512-GIALJVODOIrMflLV54H3Cow635OfrTwOu24ZTDyKC66uchtFX2NcCRq83cLdakMjZKYK78lODNLQSYBj2OgaTw==", + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz", + "integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@types/node": "*", - "playwright-core": "1.34.0" + "playwright": "1.44.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=14" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=16" } }, "node_modules/@sideway/address": { @@ -534,14 +540,39 @@ "dev": true }, "node_modules/axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "dev": true, + "license": "MIT", "dependencies": { - "follow-redirects": "^1.14.0" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" } }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1121,6 +1152,11 @@ "node": ">=8.6" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, "node_modules/es5-ext": { "version": "0.10.62", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", @@ -1304,9 +1340,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "dev": true, "funding": [ { @@ -1314,6 +1350,7 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -1373,6 +1410,7 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2336,13 +2374,36 @@ "node": ">=0.10.0" } }, + "node_modules/playwright": { + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz", + "integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.44.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, "node_modules/playwright-core": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.34.0.tgz", - "integrity": "sha512-fMUY1+iR6kYbJF/EsOOqzBA99ZHXbw9sYPNjwA4X/oV0hVF/1aGlWYBGPVUEqxBkGANDKMziYoOdKGU5DIP5Gg==", + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz", + "integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==", "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, "engines": { - "node": ">=14" + "node": ">=16" } }, "node_modules/prettier": { @@ -2381,6 +2442,26 @@ "node": ">= 0.6.0" } }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "engines": { + "node": ">= 4" + } + }, "node_modules/proxy-from-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", @@ -3091,9 +3172,9 @@ } }, "@faker-js/faker": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-7.6.0.tgz", - "integrity": "sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==" + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", + "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==" }, "@hapi/hoek": { "version": "9.3.0", @@ -3117,12 +3198,12 @@ "dev": true }, "@ory/kratos-client": { - "version": "0.0.0-next.8d3b018594f7", - "resolved": "https://registry.npmjs.org/@ory/kratos-client/-/kratos-client-0.0.0-next.8d3b018594f7.tgz", - "integrity": "sha512-TkpjBo6Z6UUEJIJCR2EDdpKVDNgQHzwDWZbOjz3xTOUoGipMBykvIfluP58Jwkpt2rIXUkt9+L+u1mFFvD/tqA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ory/kratos-client/-/kratos-client-1.2.0.tgz", + "integrity": "sha512-W6jFkVEjnoq5ylGOvYOOaNvEZ1cGSEN/YJsZTcBVye81nQtW5R7QWClvNsJVD1LjwgWGMVKWglrFlfHvvkKnmg==", "dev": true, "requires": { - "axios": "^0.21.1" + "axios": "^1.6.1" } }, "@otplib/core": { @@ -3173,14 +3254,12 @@ } }, "@playwright/test": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.34.0.tgz", - "integrity": "sha512-GIALJVODOIrMflLV54H3Cow635OfrTwOu24ZTDyKC66uchtFX2NcCRq83cLdakMjZKYK78lODNLQSYBj2OgaTw==", + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz", + "integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==", "dev": true, "requires": { - "@types/node": "*", - "fsevents": "2.3.2", - "playwright-core": "1.34.0" + "playwright": "1.44.1" } }, "@sideway/address": { @@ -3459,12 +3538,33 @@ "dev": true }, "axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "dev": true, "requires": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + }, + "dependencies": { + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + } } }, "balanced-match": { @@ -3914,6 +4014,11 @@ "ansi-colors": "^4.1.1" } }, + "err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, "es5-ext": { "version": "0.10.62", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", @@ -4066,9 +4171,9 @@ } }, "follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "dev": true }, "forever-agent": { @@ -4825,10 +4930,20 @@ "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true }, + "playwright": { + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz", + "integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.44.1" + } + }, "playwright-core": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.34.0.tgz", - "integrity": "sha512-fMUY1+iR6kYbJF/EsOOqzBA99ZHXbw9sYPNjwA4X/oV0hVF/1aGlWYBGPVUEqxBkGANDKMziYoOdKGU5DIP5Gg==", + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz", + "integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==", "dev": true }, "prettier": { @@ -4849,6 +4964,22 @@ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "dev": true }, + "promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "dependencies": { + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==" + } + } + }, "proxy-from-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", diff --git a/test/e2e/package.json b/test/e2e/package.json index d4106cbb8aa..05b04db6880 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -11,13 +11,14 @@ "wait-on": "wait-on" }, "dependencies": { - "@faker-js/faker": "7.6.0", + "@faker-js/faker": "8.4.1", "async-retry": "1.3.3", - "mailhog": "4.16.0" + "mailhog": "4.16.0", + "promise-retry": "^2.0.1" }, "devDependencies": { - "@ory/kratos-client": "0.0.0-next.8d3b018594f7", - "@playwright/test": "1.34.0", + "@ory/kratos-client": "1.2.0", + "@playwright/test": "1.44.1", "@types/async-retry": "1.4.5", "@types/node": "16.9.6", "@types/yamljs": "0.2.31", diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts index 71a67dfd879..a3f81c73060 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -4,7 +4,7 @@ import { defineConfig, devices } from "@playwright/test" import * as dotenv from "dotenv" -dotenv.config({ path: "playwright/playwright.env" }) +dotenv.config({ path: __dirname + "/playwright/playwright.env" }) /** * See https://playwright.dev/docs/test-configuration. @@ -17,19 +17,28 @@ export default defineConfig({ workers: 1, reporter: process.env.CI ? [["github"], ["html"], ["list"]] : "html", - globalSetup: "./playwright/setup/global_setup.ts", - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { trace: process.env.CI ? "retain-on-failure" : "on", - baseURL: "http://localhost:19006", }, /* Configure projects for major browsers */ projects: [ { - name: "Mobile Chrome", - use: { ...devices["Pixel 5"] }, + name: "mobile-chrome", + testMatch: "mobile/**/*.spec.ts", + use: { + ...devices["Pixel 5"], + baseURL: "http://localhost:19006", + }, + }, + { + name: "chromium", + testMatch: "desktop/**/*.spec.ts", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://localhost:4455", + }, }, ], @@ -42,7 +51,6 @@ export default defineConfig({ ].join(" && "), cwd: "../..", url: "http://localhost:4433/health/ready", - reuseExistingServer: false, env: { DSN: dbToDsn(), COURIER_SMTP_CONNECTION_URI: diff --git a/test/e2e/playwright/actions/login.ts b/test/e2e/playwright/actions/login.ts new file mode 100644 index 00000000000..806f16466dc --- /dev/null +++ b/test/e2e/playwright/actions/login.ts @@ -0,0 +1,47 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { APIRequestContext } from "@playwright/test" +import { findCsrfToken } from "../lib/helper" +import { LoginFlow, Session } from "@ory/kratos-client" +import { expectJSONResponse } from "../lib/request" +import { expect } from "../fixtures" + +export async function loginWithPassword( + user: { password: string; traits: { email: string } }, + r: APIRequestContext, + baseUrl: string, +): Promise { + const { ui } = await expectJSONResponse( + await r.get(baseUrl + "/self-service/login/browser", { + headers: { + Accept: "application/json", + }, + }), + { + message: "Initializing login flow failed", + }, + ) + + const res = await r.post(ui.action, { + headers: { + Accept: "application/json", + }, + data: { + identifier: user.traits.email, + password: user.password, + method: "password", + csrf_token: findCsrfToken(ui), + }, + }) + const { session } = await expectJSONResponse<{ session: Session }>(res) + expect(session?.identity?.traits.email).toEqual(user.traits.email) + expect( + res.headersArray().find( + ({ name, value }) => + name.toLowerCase() === "set-cookie" && + (value.indexOf("ory_session_") > -1 || // Ory Network + value.indexOf("ory_kratos_session") > -1), // Locally hosted + ), + ).toBeDefined() +} diff --git a/test/e2e/playwright/actions/mail.ts b/test/e2e/playwright/actions/mail.ts index 871608bc204..172c6d8ab84 100644 --- a/test/e2e/playwright/actions/mail.ts +++ b/test/e2e/playwright/actions/mail.ts @@ -8,14 +8,29 @@ const mh = mailhog({ basePath: "http://localhost:8025/api", }) -export function search(...props: Parameters) { +type searchProps = { + query: string + kind: "to" | "from" | "containing" + /** + * + * @param message an email message + * @returns decide whether to include the message in the result + */ + filter?: (message: mailhog.Message) => boolean +} + +export function search({ query, kind, filter }: searchProps) { return retry( async () => { - const res = await mh.search(...props) + const res = await mh.search(query, kind) if (res.total === 0) { throw new Error("no emails found") } - return res.items + const result = filter ? res.items.filter(filter) : res.items + if (result.length === 0) { + throw new Error("no emails found") + } + return result }, { retries: 3, diff --git a/test/e2e/playwright/actions/session.ts b/test/e2e/playwright/actions/session.ts new file mode 100644 index 00000000000..5d77b6de7b5 --- /dev/null +++ b/test/e2e/playwright/actions/session.ts @@ -0,0 +1,38 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { APIRequestContext, expect } from "@playwright/test" +import { Session } from "@ory/kratos-client" + +export async function hasSession( + r: APIRequestContext, + kratosPublicURL: string, +): Promise { + const resp = await r.get(kratosPublicURL + "/sessions/whoami", { + failOnStatusCode: true, + }) + const session = await resp.json() + expect(session).toBeDefined() + expect(session.active).toBe(true) +} + +export async function getSession( + r: APIRequestContext, + kratosPublicURL: string, +): Promise { + const resp = await r.get(kratosPublicURL + "/sessions/whoami", { + failOnStatusCode: true, + }) + return resp.json() +} + +export async function hasNoSession( + r: APIRequestContext, + kratosPublicURL: string, +): Promise { + const resp = await r.get(kratosPublicURL + "/sessions/whoami", { + failOnStatusCode: false, + }) + expect(resp.status()).toBe(401) + return resp.json() +} diff --git a/test/e2e/playwright/fixtures/index.ts b/test/e2e/playwright/fixtures/index.ts index 3b1264e84c0..b915dd4d937 100644 --- a/test/e2e/playwright/fixtures/index.ts +++ b/test/e2e/playwright/fixtures/index.ts @@ -1,13 +1,24 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 +import { faker } from "@faker-js/faker" import { Identity } from "@ory/kratos-client" -import { test as base, expect } from "@playwright/test" -import { OryKratosConfiguration } from "../../cypress/support/config" +import { + CDPSession, + test as base, + expect as baseExpect, + APIRequestContext, + Page, +} from "@playwright/test" +import { writeFile } from "fs/promises" import { merge } from "lodash" +import { OryKratosConfiguration } from "../../shared/config" import { default_config } from "../setup/default_config" -import { writeFile } from "fs/promises" -import { faker } from "@faker-js/faker" +import { APIResponse } from "playwright-core" +import { SessionWithResponse } from "../types" +import { retryOptions } from "../lib/request" +import promiseRetry from "promise-retry" +import { Protocol } from "playwright-core/types/protocol" // from https://stackoverflow.com/questions/61132262/typescript-deep-partial type DeepPartial = T extends object @@ -17,12 +28,23 @@ type DeepPartial = T extends object : T type TestFixtures = { - identity: Identity + identity: { oryIdentity: Identity; email: string; password: string } configOverride: DeepPartial - config: void + config: OryKratosConfiguration + virtualAuthenticatorOptions: Partial + pageCDPSession: CDPSession + virtualAuthenticator: Protocol.WebAuthn.addVirtualAuthenticatorReturnValue } -type WorkerFixtures = {} +type WorkerFixtures = { + kratosAdminURL: string + kratosPublicURL: string + mode: + | "reconfigure_kratos" + | "reconfigure_ory_network_project" + | "existing_kratos" + | "existing_ory_network_project" +} export const test = base.extend({ configOverride: {}, @@ -34,9 +56,11 @@ export const test = base.extend({ const configRevision = await resp.body() + const fileDirectory = __dirname + "/../.." + await writeFile( - "playwright/kratos.config.json", - JSON.stringify(configToWrite), + fileDirectory + "/playwright/kratos.config.json", + JSON.stringify(configToWrite, null, 2), ) await expect(async () => { const resp = await request.get("http://localhost:4434/health/config") @@ -44,21 +68,166 @@ export const test = base.extend({ expect(updatedRevision).not.toBe(configRevision) }).toPass() - await use() + await use(configToWrite) }, { auto: true }, ], - identity: async ({ request }, use) => { + virtualAuthenticatorOptions: undefined, + pageCDPSession: async ({ page }, use) => { + const cdpSession = await page.context().newCDPSession(page) + await use(cdpSession) + await cdpSession.detach() + }, + virtualAuthenticator: async ( + { pageCDPSession, virtualAuthenticatorOptions }, + use, + ) => { + await pageCDPSession.send("WebAuthn.enable") + const { authenticatorId } = await pageCDPSession.send( + "WebAuthn.addVirtualAuthenticator", + { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + ...virtualAuthenticatorOptions, + }, + }, + ) + await use({ authenticatorId }) + await pageCDPSession.send("WebAuthn.removeVirtualAuthenticator", { + authenticatorId, + }) + + await pageCDPSession.send("WebAuthn.disable") + }, + identity: async ({ request }, use, i) => { + const email = faker.internet.email({ provider: "ory.sh" }) + const password = faker.internet.password() const resp = await request.post("http://localhost:4434/admin/identities", { data: { schema_id: "email", traits: { - email: faker.internet.email(undefined, undefined, "ory.sh"), + email, website: faker.internet.url(), }, + + credentials: { + password: { + config: { + password, + }, + }, + }, }, }) + const oryIdentity = await resp.json() + i.attach("identity", { + body: JSON.stringify(oryIdentity, null, 2), + contentType: "application/json", + }) expect(resp.status()).toBe(201) - await use(await resp.json()) + await use({ + oryIdentity, + email, + password, + }) }, + kratosAdminURL: ["http://localhost:4434", { option: true, scope: "worker" }], + kratosPublicURL: ["http://localhost:4433", { option: true, scope: "worker" }], +}) + +export const expect = baseExpect.extend({ + toHaveSession, + toMatchResponseData, }) + +async function toHaveSession( + requestOrPage: APIRequestContext | Page, + baseUrl: string, +) { + let r: APIRequestContext + if ("request" in requestOrPage) { + r = requestOrPage.request + } else { + r = requestOrPage + } + let pass = true + + let responseData: string + let response: APIResponse = null + try { + const result = await promiseRetry( + () => + r + .get(baseUrl + "/sessions/whoami", { + failOnStatusCode: false, + }) + .then( + async (res: APIResponse): Promise => { + return { + session: await res.json(), + response: res, + } + }, + ), + retryOptions, + ) + pass = !!result.session.active + responseData = await result.response.text() + response = result.response + } catch (e) { + pass = false + responseData = JSON.stringify(e.message, undefined, 2) + } + + const message = () => + this.utils.matcherHint("toHaveSession", undefined, undefined, { + isNot: this.isNot, + }) + + `\n + \n + Expected: ${this.isNot ? "not" : ""} to have session\n + Session data received: ${responseData}\n + Headers: ${JSON.stringify(response?.headers(), null, 2)}\n + ` + + return { + message, + pass, + name: "toHaveSession", + } +} + +async function toMatchResponseData( + res: APIResponse, + options: { + statusCode?: number + failureHint?: string + }, +) { + const body = await res.text() + const statusCode = options.statusCode ?? 200 + const failureHint = options.failureHint ?? "" + const message = () => + this.utils.matcherHint("toMatch", undefined, undefined, { + isNot: this.isNot, + }) + + `\n + ${failureHint} + \n + Expected: ${this.isNot ? "not" : ""} to match\n + Status Code: ${statusCode}\n + Body: ${body}\n + Headers: ${JSON.stringify(res.headers(), null, 2)}\n + URL: ${JSON.stringify(res.url(), null, 2)}\n + ` + + return { + message, + pass: res.status() === statusCode, + name: "toMatch", + } +} diff --git a/test/e2e/playwright/kratos.base-config.json b/test/e2e/playwright/kratos.base-config.json index 83b24587bd6..e9abcfd7f4f 100644 --- a/test/e2e/playwright/kratos.base-config.json +++ b/test/e2e/playwright/kratos.base-config.json @@ -4,6 +4,10 @@ { "id": "default", "url": "file://test/e2e/profiles/oidc/identity.traits.schema.json" + }, + { + "id": "email", + "url": "file://test/e2e/profiles/email/identity.traits.schema.json" } ] }, diff --git a/test/e2e/playwright/lib/helper.ts b/test/e2e/playwright/lib/helper.ts index 929b39b7457..da5fb76c13b 100644 --- a/test/e2e/playwright/lib/helper.ts +++ b/test/e2e/playwright/lib/helper.ts @@ -2,6 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { Message } from "mailhog" +import { + UiContainer, + UiNodeAttributes, + UiNodeInputAttributes, +} from "@ory/kratos-client" +import { expect } from "../fixtures" +import { LoginFlowStyle, OryKratosConfiguration } from "../../shared/config" export const codeRegex = /(\d{6})/ @@ -18,3 +25,50 @@ export function extractCode(mail: Message) { } return null } + +export function findCsrfToken(ui: UiContainer) { + const csrf = ui.nodes + .filter((node) => isUiNodeInputAttributes(node.attributes)) + // Since we filter all non-input attributes, the following as is ok: + .map( + (node): UiNodeInputAttributes => node.attributes as UiNodeInputAttributes, + ) + .find(({ name }) => name === "csrf_token")?.value + expect(csrf).toBeDefined() + return csrf +} + +export function isUiNodeInputAttributes( + attrs: UiNodeAttributes, +): attrs is UiNodeInputAttributes & { + node_type: "input" +} { + return attrs.node_type === "input" +} + +export const toConfig = ({ + style = "identifier_first", + mitigateEnumeration = false, + selfservice, +}: { + style?: LoginFlowStyle + mitigateEnumeration?: boolean + selfservice?: Partial +}) => ({ + selfservice: { + default_browser_return_url: "http://localhost:4455/welcome", + ...selfservice, + flows: { + login: { + ...selfservice?.flows?.login, + style, + }, + ...selfservice?.flows, + }, + }, + security: { + account_enumeration: { + mitigate: mitigateEnumeration, + }, + }, +}) diff --git a/test/e2e/playwright/lib/request.ts b/test/e2e/playwright/lib/request.ts new file mode 100644 index 00000000000..5732e5a34ac --- /dev/null +++ b/test/e2e/playwright/lib/request.ts @@ -0,0 +1,34 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { APIResponse } from "playwright-core" +import { expect } from "../fixtures" +import { OperationOptions } from "retry" + +export type RetryOptions = OperationOptions + +export const retryOptions: RetryOptions = { + retries: 20, + factor: 1, + maxTimeout: 500, + minTimeout: 250, + randomize: false, +} + +export async function expectJSONResponse( + res: APIResponse, + { statusCode = 200, message }: { statusCode?: number; message?: string } = {}, +): Promise { + await expect(res).toMatchResponseData({ + statusCode, + failureHint: message, + }) + try { + return (await res.json()) as T + } catch (e) { + const body = await res.text() + throw Error( + `Expected to be able to parse body as json: ${e} (body: ${body})`, + ) + } +} diff --git a/test/e2e/playwright/models/elements/login.ts b/test/e2e/playwright/models/elements/login.ts new file mode 100644 index 00000000000..6fcfad6ad6b --- /dev/null +++ b/test/e2e/playwright/models/elements/login.ts @@ -0,0 +1,246 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { expect, Locator, Page } from "@playwright/test" +import { createInputLocator, InputLocator } from "../../selectors/input" +import { URLSearchParams } from "node:url" +import { OryKratosConfiguration } from "../../../shared/config" + +enum LoginStyle { + IdentifierFirst = "identifier_first", + OneStep = "one_step", +} + +type SubmitOptions = { + submitWithKeyboard?: boolean + waitForURL?: string | RegExp +} + +export class LoginPage { + public submitPassword: Locator + public github: Locator + public google: Locator + public signup: Locator + + public identifier: InputLocator + public password: InputLocator + public totpInput: InputLocator + public totpSubmit: Locator + public lookupInput: InputLocator + public lookupSubmit: Locator + public codeSubmit = this.page.locator('button[type="submit"][value="code"]') + public codeInput = createInputLocator(this.page, "code") + + public alert: Locator + + constructor(readonly page: Page, readonly config: OryKratosConfiguration) { + this.identifier = createInputLocator(page, "identifier") + this.password = createInputLocator(page, "password") + this.totpInput = createInputLocator(page, "totp_code") + this.lookupInput = createInputLocator(page, "lookup_secret") + + this.submitPassword = page.locator( + '[type="submit"][name="method"][value="password"]', + ) + + this.github = page.locator('[name="provider"][value="github"]') + this.google = page.locator('[name="provider"][value="google"]') + + this.totpSubmit = page.locator('[name="method"][value="totp"]') + this.lookupSubmit = page.locator('[name="method"][value="lookup_secret"]') + + this.signup = page.locator('[data-testid="signup-link"]') + + // this.submitHydra = page.locator('[name="provider"][value="hydra"]') + // this.forgotPasswordLink = page.locator( + // "[data-testid='forgot-password-link']", + // ) + // this.logoutLink = page.locator("[data-testid='logout-link']") + } + + async submitIdentifierFirst(identifier: string) { + await this.inputField("identifier").fill(identifier) + await this.submit("identifier_first", { + waitForURL: new RegExp(this.config.selfservice.flows.login.ui_url), + }) + } + + async loginWithPassword( + identifier: string, + password: string, + opts?: SubmitOptions, + ) { + switch (this.config.selfservice.flows.login.style) { + case LoginStyle.IdentifierFirst: + await this.submitIdentifierFirst(identifier) + break + case LoginStyle.OneStep: + await this.inputField("identifier").fill(identifier) + break + } + + await this.inputField("password").fill(password) + await this.submit("password", opts) + } + + async triggerLoginWithCode(identifier: string, opts?: SubmitOptions) { + switch (this.config.selfservice.flows.login.style) { + case LoginStyle.IdentifierFirst: + await this.submitIdentifierFirst(identifier) + break + case LoginStyle.OneStep: + await this.inputField("identifier").fill(identifier) + break + } + + await this.codeSubmit.click() + } + + async open({ + aal, + refresh, + }: { + aal?: string + refresh?: boolean + } = {}) { + const p = new URLSearchParams() + if (refresh) { + p.append("refresh", "true") + } + + if (aal) { + p.append("aal", aal) + } + + await Promise.all([ + this.page.goto( + this.config.selfservice.flows.login.ui_url + "?" + p.toString(), + ), + this.isReady(), + this.page.waitForURL((url) => + url.toString().includes(this.config.selfservice.flows.login.ui_url), + ), + ]) + await this.isReady() + } + + async isReady() { + await expect(this.inputField("csrf_token").nth(0)).toBeHidden() + } + + submitMethod(method: string) { + switch (method) { + case "google": + case "github": + case "hydra": + return this.page.locator(`[name="provider"][value="${method}"]`) + } + return this.page.locator(`[name="method"][value="${method}"]`) + } + + inputField(name: string) { + return this.page.locator(`input[name=${name}]`) + } + + async submit(method: string, opts?: SubmitOptions) { + const nav = opts?.waitForURL + ? this.page.waitForURL(opts.waitForURL) + : Promise.resolve() + if (opts?.submitWithKeyboard) { + await this.page.keyboard.press("Enter") + } else { + await this.submitMethod(method).click() + } + + await nav + } + + // + // async submitPasswordForm( + // id: string, + // password: string, + // expectURL: string | RegExp, + // options: { + // submitWithKeyboard?: boolean + // style?: LoginStyle + // } = { + // submitWithKeyboard: false, + // style: LoginStyle.OneStep, + // }, + // ) { + // await this.isReady() + // await this.inputField("identifier").fill(id) + // + // if (options.style === LoginStyle.IdentifierFirst) { + // await this.submitMethod("identifier_first").click() + // await this.inputField("password").fill(password) + // } else { + // await this.inputField("password").fill(password) + // } + // + // const nav = this.page.waitForURL(expectURL) + // + // if (submitWithKeyboard) { + // await this.page.keyboard.press("Enter") + // } else { + // await this.submitPassword.click() + // } + // + // await nav + // } + // + // readonly baseURL: string + // readonly submitHydra: Locator + // readonly forgotPasswordLink: Locator + // readonly logoutLink: Locator + // + // async goto(returnTo?: string, refresh?: boolean) { + // const u = new URL(routes.hosted.login(this.baseURL)) + // if (returnTo) { + // u.searchParams.append("return_to", returnTo) + // } + // if (refresh) { + // u.searchParams.append("refresh", refresh.toString()) + // } + // await this.page.goto(u.toString()) + // await this.isReady() + // } + // + // async loginWithHydra(email: string, password: string) { + // await this.submitHydra.click() + // await this.page.waitForURL(new RegExp(OIDC_PROVIDER)) + // + // await this.page.locator("input[name=email]").fill(email) + // await this.page.locator("input[name=password]").fill(password) + // + // await this.page.locator("input[name=submit][id=accept]").click() + // } + // + // async loginWithOIDC(email = generateEmail(), password = generatePassword()) { + // await this.page.fill('[name="email"]', email) + // await this.page.fill('[name="password"]', password) + // await this.page.click("#accept") + // } + // + // async loginAndAcceptConsent( + // email = generateEmail(), + // password = generatePassword(), + // {rememberConsent = true, rememberLogin = false} = {}, + // ) { + // await this.page.fill('[name="email"]', email) + // await this.page.fill('[name="password"]', password) + // rememberLogin && (await this.page.check('[name="remember"]')) + // await this.page.click("#accept") + // + // await this.page.click("#offline") + // await this.page.click("#openid") + // rememberConsent && (await this.page.check("[name=remember]")) + // await this.page.click("#accept") + // + // return email + // } + // + // async expectAlert(id: string) { + // await this.page.getByTestId(`ui/message/${id}`).waitFor() + // } +} diff --git a/test/e2e/playwright/selectors/input.ts b/test/e2e/playwright/selectors/input.ts new file mode 100644 index 00000000000..c4f1f35d027 --- /dev/null +++ b/test/e2e/playwright/selectors/input.ts @@ -0,0 +1,19 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { Locator, Page } from "@playwright/test" + +export interface InputLocator { + input: Locator + message: Locator + label: Locator +} + +export const createInputLocator = (page: Page, field: string): InputLocator => { + const prefix = `[data-testid="node/input/${field}"]` + return { + input: page.locator(`${prefix} input`), + label: page.locator(`${prefix} label`), + message: page.locator(`${prefix} p`), + } +} diff --git a/test/e2e/playwright/setup/default_config.ts b/test/e2e/playwright/setup/default_config.ts index b9249917b03..5e4f5d1e2e7 100644 --- a/test/e2e/playwright/setup/default_config.ts +++ b/test/e2e/playwright/setup/default_config.ts @@ -1,7 +1,7 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { OryKratosConfiguration } from "../../cypress/support/config" +import { OryKratosConfiguration } from "../../shared/config" export const default_config: OryKratosConfiguration = { dsn: "", @@ -11,6 +11,10 @@ export const default_config: OryKratosConfiguration = { id: "default", url: "file://test/e2e/profiles/oidc/identity.traits.schema.json", }, + { + id: "email", + url: "file://test/e2e/profiles/email/identity.traits.schema.json", + }, ], }, serve: { @@ -40,7 +44,7 @@ export const default_config: OryKratosConfiguration = { cipher: ["secret-thirty-two-character-long"], }, selfservice: { - default_browser_return_url: "http://localhost:4455/", + default_browser_return_url: "http://localhost:4455/welcome", allowed_return_urls: [ "http://localhost:4455", "http://localhost:19006", diff --git a/test/e2e/playwright/setup/global_setup.ts b/test/e2e/playwright/setup/global_setup.ts deleted file mode 100644 index 92a42432fc9..00000000000 --- a/test/e2e/playwright/setup/global_setup.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright © 2023 Ory Corp -// SPDX-License-Identifier: Apache-2.0 - -import fs from "fs" -import { default_config } from "./default_config" - -export default async function globalSetup() { - await fs.promises.writeFile( - "playwright/kratos.config.json", - JSON.stringify(default_config), - ) -} diff --git a/test/e2e/playwright/tests/desktop/identifier_first/code.login.spec.ts b/test/e2e/playwright/tests/desktop/identifier_first/code.login.spec.ts new file mode 100644 index 00000000000..b94dbf29669 --- /dev/null +++ b/test/e2e/playwright/tests/desktop/identifier_first/code.login.spec.ts @@ -0,0 +1,224 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { expect } from "@playwright/test" +import { search } from "../../../actions/mail" +import { getSession, hasNoSession, hasSession } from "../../../actions/session" +import { test } from "../../../fixtures" +import { extractCode, toConfig } from "../../../lib/helper" +import { LoginPage } from "../../../models/elements/login" + +test.describe.parallel("account enumeration protection off", () => { + test.use({ + configOverride: toConfig({ + style: "identifier_first", + mitigateEnumeration: false, + selfservice: { + methods: { + code: { + passwordless_enabled: true, + }, + }, + }, + }), + }) + + test("login fails because user does not exist", async ({ page, config }) => { + const login = new LoginPage(page, config) + await login.open() + + await login.submitIdentifierFirst("i@donot.exist") + + await expect( + page.locator('[data-testid="ui/message/4000037"]'), + "expect account not exist message to be shown", + ).toBeVisible() + }) + + test("login with wrong code fails", async ({ + page, + identity, + kratosPublicURL, + config, + }) => { + const login = new LoginPage(page, config) + await login.open() + + await login.triggerLoginWithCode(identity.email) + + await login.codeInput.input.fill("123123") + + await login.codeSubmit.getByText("Continue").click() + + await hasNoSession(page.request, kratosPublicURL) + await expect( + page.locator('[data-testid="ui/message/4010008"]'), + "expect to be shown a wrong code error", + ).toBeVisible() + }) + + test("login succeeds", async ({ + page, + identity, + config, + kratosPublicURL, + }) => { + const login = new LoginPage(page, config) + await login.open() + + await login.triggerLoginWithCode(identity.email) + + const mails = await search({ query: identity.email, kind: "to" }) + expect(mails).toHaveLength(1) + + const code = extractCode(mails[0]) + + await login.codeInput.input.fill(code) + + await login.codeSubmit.getByText("Continue").click() + + await hasSession(page.request, kratosPublicURL) + }) +}) + +test.describe("account enumeration protection on", () => { + test.use({ + configOverride: toConfig({ + style: "identifier_first", + mitigateEnumeration: true, + selfservice: { + methods: { + code: { + passwordless_enabled: true, + }, + }, + }, + }), + }) + + test("login fails because user does not exist", async ({ page, config }) => { + const login = new LoginPage(page, config) + await login.open() + + await login.submitIdentifierFirst("i@donot.exist") + + await expect( + page.locator('button[name="method"][value="code"]'), + "expect to show the code form", + ).toBeVisible() + }) + + test("login with wrong code fails", async ({ + page, + identity, + kratosPublicURL, + config, + }) => { + const login = new LoginPage(page, config) + await login.open() + + await login.triggerLoginWithCode(identity.email) + + await login.codeInput.input.fill("123123") + + await login.codeSubmit.getByText("Continue").click() + + await hasNoSession(page.request, kratosPublicURL) + await expect( + page.locator('[data-testid="ui/message/4010008"]'), + "expect to be shown a wrong code error", + ).toBeVisible() + }) + + test("login succeeds", async ({ + page, + identity, + config, + kratosPublicURL, + }) => { + const login = new LoginPage(page, config) + await login.open() + + await login.triggerLoginWithCode(identity.email) + + const mails = await search({ query: identity.email, kind: "to" }) + expect(mails).toHaveLength(1) + + const code = extractCode(mails[0]) + + await login.codeInput.input.fill(code) + + await login.codeSubmit.getByText("Continue").click() + + await hasSession(page.request, kratosPublicURL) + }) +}) + +test.describe(() => { + test.use({ + configOverride: toConfig({ + style: "identifier_first", + mitigateEnumeration: false, + selfservice: { + methods: { + code: { + passwordless_enabled: true, + }, + }, + }, + }), + }) + test("refresh", async ({ page, identity, config, kratosPublicURL }) => { + const login = new LoginPage(page, config) + + const [initialSession, initialCode] = + await test.step("initial login", async () => { + await login.open() + await login.triggerLoginWithCode(identity.email) + + const mails = await search({ query: identity.email, kind: "to" }) + expect(mails).toHaveLength(1) + + const code = extractCode(mails[0]) + + await login.codeInput.input.fill(code) + + await login.codeSubmit.getByText("Continue").click() + + const session = await getSession(page.request, kratosPublicURL) + expect(session).toBeDefined() + expect(session.active).toBe(true) + return [session, code] + }) + + await login.open({ + refresh: true, + }) + await login.inputField("identifier").fill(identity.email) + await login.submit("code") + + const mails = await search({ + query: identity.email, + kind: "to", + filter: (m) => !m.html.includes(initialCode), + }) + expect(mails).toHaveLength(1) + + const code = extractCode(mails[0]) + + await login.codeInput.input.fill(code) + + await login.codeSubmit.getByText("Continue").click() + await page.waitForURL( + new RegExp(config.selfservice.default_browser_return_url), + ) + + const newSession = await getSession(page.request, kratosPublicURL) + expect(newSession).toBeDefined() + expect(newSession.active).toBe(true) + + expect(initialSession.authenticated_at).not.toEqual( + newSession.authenticated_at, + ) + }) +}) diff --git a/test/e2e/playwright/tests/desktop/identifier_first/oidc.login.spec.ts b/test/e2e/playwright/tests/desktop/identifier_first/oidc.login.spec.ts new file mode 100644 index 00000000000..f14dbaebff0 --- /dev/null +++ b/test/e2e/playwright/tests/desktop/identifier_first/oidc.login.spec.ts @@ -0,0 +1,157 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { faker } from "@faker-js/faker" +import { expect, Page } from "@playwright/test" +import { getSession, hasSession } from "../../../actions/session" +import { test } from "../../../fixtures" +import { toConfig } from "../../../lib/helper" +import { LoginPage } from "../../../models/elements/login" +import { OryKratosConfiguration } from "../../../../shared/config" + +async function loginHydra(page: Page) { + return test.step("login with hydra", async () => { + await page + .locator("input[name=username]") + .fill(faker.internet.email({ provider: "ory.sh" })) + await page.locator("button[name=action][value=accept]").click() + await page.locator("#offline").check() + await page.locator("#openid").check() + + await page.locator("input[name=website]").fill(faker.internet.url()) + + await page.locator("button[name=action][value=accept]").click() + }) +} + +async function registerWithHydra( + page: Page, + config: OryKratosConfiguration, + kratosPublicURL: string, +) { + return await test.step("register", async () => { + await page.goto("/registration") + + await page.locator(`button[name=provider][value=hydra]`).click() + + const email = faker.internet.email({ provider: "ory.sh" }) + await page.locator("input[name=username]").fill(email) + await page.locator("#remember").check() + await page.locator("button[name=action][value=accept]").click() + await page.locator("#offline").check() + await page.locator("#openid").check() + + await page.locator("input[name=website]").fill(faker.internet.url()) + + await page.locator("button[name=action][value=accept]").click() + await page.waitForURL( + new RegExp(config.selfservice.default_browser_return_url), + ) + await page.context().clearCookies({ + domain: new URL(kratosPublicURL).hostname, + }) + + await expect( + getSession(page.request, kratosPublicURL), + ).rejects.toThrowError() + return email + }) +} + +for (const mitigateEnumeration of [true, false]) { + test.describe(`account enumeration protection ${ + mitigateEnumeration ? "on" : "off" + }`, () => { + test.use({ + configOverride: toConfig({ mitigateEnumeration }), + }) + + test("login", async ({ page, config, kratosPublicURL }) => { + const login = new LoginPage(page, config) + await login.open() + + await page.locator(`button[name=provider][value=hydra]`).click() + + await loginHydra(page) + + await page.waitForURL( + new RegExp(config.selfservice.default_browser_return_url), + ) + + await hasSession(page.request, kratosPublicURL) + }) + + test("oidc sign in on second step", async ({ + page, + config, + kratosPublicURL, + }) => { + const email = await registerWithHydra(page, config, kratosPublicURL) + + const login = new LoginPage(page, config) + await login.open() + + await login.submitIdentifierFirst(email) + + // If account enumeration is mitigated, we should see the password method, + // because the identity has not set up a password + await expect( + page.locator('button[name="method"][value="password"]'), + "hide the password method", + ).toBeVisible({ visible: mitigateEnumeration }) + + await page.locator(`button[name=provider][value=hydra]`).click() + + await loginHydra(page) + + await page.waitForURL( + new RegExp(config.selfservice.default_browser_return_url), + ) + + const session = await getSession(page.request, kratosPublicURL) + expect(session).toBeDefined() + expect(session.active).toBe(true) + }) + }) +} + +test("login with refresh", async ({ page, config, kratosPublicURL }) => { + await registerWithHydra(page, config, kratosPublicURL) + + const login = new LoginPage(page, config) + + const initialSession = await test.step("initial login", async () => { + await login.open() + await page.locator(`button[name=provider][value=hydra]`).click() + + await loginHydra(page) + + await page.waitForURL( + new RegExp(config.selfservice.default_browser_return_url), + ) + return await getSession(page.request, kratosPublicURL) + }) + + await test.step("refresh login", async () => { + await login.open({ + refresh: true, + }) + + await expect( + page.locator('[data-testid="ui/message/1010003"]'), + "show the refresh message", + ).toBeVisible() + + await page.locator(`button[name=provider][value=hydra]`).click() + + await loginHydra(page) + + await page.waitForURL( + new RegExp(config.selfservice.default_browser_return_url), + ) + const newSession = await getSession(page.request, kratosPublicURL) + expect(initialSession.authenticated_at).not.toEqual( + newSession.authenticated_at, + ) + }) +}) diff --git a/test/e2e/playwright/tests/desktop/identifier_first/passkeys.login.spec.ts b/test/e2e/playwright/tests/desktop/identifier_first/passkeys.login.spec.ts new file mode 100644 index 00000000000..36ee107df31 --- /dev/null +++ b/test/e2e/playwright/tests/desktop/identifier_first/passkeys.login.spec.ts @@ -0,0 +1,233 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { faker } from "@faker-js/faker" +import { CDPSession, expect, Page } from "@playwright/test" +import { OryKratosConfiguration } from "../../../../shared/config" +import { getSession } from "../../../actions/session" +import { test } from "../../../fixtures" +import { toConfig } from "../../../lib/helper" +import { LoginPage } from "../../../models/elements/login" + +async function toggleAutomaticPresenceSimulation( + cdpSession: CDPSession, + authenticatorId: string, + enabled: boolean, +) { + await cdpSession.send("WebAuthn.setAutomaticPresenceSimulation", { + authenticatorId, + enabled, + }) +} + +async function registerWithPasskey( + page: Page, + pageCDPSession: CDPSession, + config: OryKratosConfiguration, + authenticatorId: string, + simulatePresence: boolean, +) { + return await test.step("create webauthn identity", async () => { + await page.goto("/registration") + const identifier = faker.internet.email() + await page.locator(`input[name="traits.email"]`).fill(identifier) + await page + .locator(`input[name="traits.website"]`) + .fill(faker.internet.url()) + await page.locator("button[name=method][value=profile]").click() + + await toggleAutomaticPresenceSimulation( + pageCDPSession, + authenticatorId, + true, + ) + await page.locator("button[name=passkey_register_trigger]").click() + + await toggleAutomaticPresenceSimulation( + pageCDPSession, + authenticatorId, + simulatePresence, + ) + + await page.waitForURL( + new RegExp(config.selfservice.default_browser_return_url), + ) + return identifier + }) +} + +const passkeyConfig = { + methods: { + passkey: { + enabled: true, + config: { + rp: { + display_name: "ORY", + id: "localhost", + origins: ["http://localhost:4455"], + }, + }, + }, + }, +} + +for (const mitigateEnumeration of [true, false]) { + test.describe(`account enumeration protection ${ + mitigateEnumeration ? "on" : "off" + }`, () => { + test.use({ + configOverride: toConfig({ + mitigateEnumeration, + style: "identifier_first", + selfservice: passkeyConfig, + }), + }) + + for (const simulatePresence of [true, false]) { + test.describe(`${ + simulatePresence ? "with" : "without" + } automatic presence proof`, () => { + test.use({ + virtualAuthenticatorOptions: { + automaticPresenceSimulation: simulatePresence, + // hasResidentKey: simulatePresence, + }, + }) + test("login", async ({ + config, + page, + kratosPublicURL, + virtualAuthenticator, + pageCDPSession, + }) => { + const identifier = await registerWithPasskey( + page, + pageCDPSession, + config, + virtualAuthenticator.authenticatorId, + simulatePresence, + ) + await page.context().clearCookies({}) + + const login = new LoginPage(page, config) + await login.open() + + if (!simulatePresence) { + await login.submitIdentifierFirst(identifier) + + const passkeyLoginTrigger = page.locator( + "button[name=passkey_login_trigger]", + ) + await passkeyLoginTrigger.waitFor() + + await page.waitForLoadState("load") + + await toggleAutomaticPresenceSimulation( + pageCDPSession, + virtualAuthenticator.authenticatorId, + true, + ) + + await passkeyLoginTrigger.click() + + await toggleAutomaticPresenceSimulation( + pageCDPSession, + virtualAuthenticator.authenticatorId, + false, + ) + } + + await page.waitForURL( + new RegExp(config.selfservice.default_browser_return_url), + ) + + await expect( + getSession(page.request, kratosPublicURL), + ).resolves.toMatchObject({ + active: true, + identity: { + traits: { + email: identifier, + }, + }, + }) + }) + }) + } + }) +} + +test.describe("without automatic presence simulation", () => { + test.use({ + virtualAuthenticatorOptions: { + automaticPresenceSimulation: false, + }, + configOverride: toConfig({ + selfservice: passkeyConfig, + }), + }) + test("login with refresh", async ({ + page, + config, + kratosPublicURL, + pageCDPSession, + virtualAuthenticator, + }) => { + const identifier = await registerWithPasskey( + page, + pageCDPSession, + config, + virtualAuthenticator.authenticatorId, + true, + ) + + const login = new LoginPage(page, config) + // Due to resetting automatic presence simulating to "true" in the previous step, + // opening the login page automatically triggers the passkey login + await login.open() + + await expect( + getSession(page.request, kratosPublicURL), + ).resolves.toMatchObject({ + active: true, + identity: { + traits: { + email: identifier, + }, + }, + }) + + await login.open({ + refresh: true, + }) + + await expect( + page.locator('[data-testid="ui/message/1010003"]'), + "show the refresh message", + ).toBeVisible() + + const originalSession = await getSession(page.request, kratosPublicURL) + + const passkeyLoginTrigger = page.locator( + "button[name=passkey_login_trigger]", + ) + await passkeyLoginTrigger.waitFor() + + await page.waitForLoadState("load") + + await toggleAutomaticPresenceSimulation( + pageCDPSession, + virtualAuthenticator.authenticatorId, + true, + ) + + await passkeyLoginTrigger.click() + await page.waitForURL( + new RegExp(config.selfservice.default_browser_return_url), + ) + const newSession = await getSession(page.request, kratosPublicURL) + expect(originalSession.authenticated_at).not.toEqual( + newSession.authenticated_at, + ) + }) +}) diff --git a/test/e2e/playwright/tests/desktop/identifier_first/password.login.spec.ts b/test/e2e/playwright/tests/desktop/identifier_first/password.login.spec.ts new file mode 100644 index 00000000000..2891dbb0e88 --- /dev/null +++ b/test/e2e/playwright/tests/desktop/identifier_first/password.login.spec.ts @@ -0,0 +1,214 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { expect } from "@playwright/test" +import { loginWithPassword } from "../../../actions/login" +import { getSession, hasNoSession, hasSession } from "../../../actions/session" +import { test } from "../../../fixtures" +import { toConfig } from "../../../lib/helper" +import { LoginPage } from "../../../models/elements/login" + +// These can run in parallel because they use the same config. +test.describe("account enumeration protection off", () => { + test.use({ + configOverride: toConfig({ + style: "identifier_first", + mitigateEnumeration: false, + }), + }) + + test.describe.configure({ mode: "parallel" }) + + test("login fails because user does not exist", async ({ page, config }) => { + const login = new LoginPage(page, config) + await login.open() + + await login.submitIdentifierFirst("i@donot.exist") + + await expect( + page.locator('[data-testid="ui/message/4000037"]'), + "expect account not exist message to be shown", + ).toBeVisible() + }) + + test("login with wrong password fails", async ({ + page, + identity, + kratosPublicURL, + config, + }) => { + const login = new LoginPage(page, config) + await login.open() + + await login.loginWithPassword(identity.email, "wrong-password") + await login.isReady() + + await hasNoSession(page.request, kratosPublicURL) + await expect( + page.locator('[data-testid="ui/message/4000006"]'), + "expect to be shown a credentials do not exist error", + ).toBeVisible() + }) + + test("login succeeds", async ({ + page, + identity, + config, + kratosPublicURL, + }) => { + const login = new LoginPage(page, config) + await login.open() + + await login.inputField("identifier").fill(identity.email) + await login.submit("identifier_first", { + waitForURL: new RegExp(config.selfservice.flows.login.ui_url), + }) + + await login.inputField("password").fill(identity.password) + await login.submit("password", { + waitForURL: new RegExp(config.selfservice.default_browser_return_url), + }) + + await hasSession(page.request, kratosPublicURL) + }) + + test("login with refresh", async ({ + page, + config, + identity, + kratosPublicURL, + }) => { + await loginWithPassword( + { + password: identity.password, + traits: { + email: identity.email, + }, + }, + page.request, + kratosPublicURL, + ) + + const login = new LoginPage(page, config) + await login.open({ + refresh: true, + }) + + await expect( + page.locator('[data-testid="ui/message/1010003"]'), + "show the refresh message", + ).toBeVisible() + + const originalSession = await getSession(page.request, kratosPublicURL) + await login.inputField("password").fill(identity.password) + await login.submit("password", { + waitForURL: new RegExp(config.selfservice.default_browser_return_url), + }) + const newSession = await getSession(page.request, kratosPublicURL) + expect(originalSession.authenticated_at).not.toEqual( + newSession.authenticated_at, + ) + }) +}) + +test.describe("account enumeration protection on", () => { + test.use({ + configOverride: toConfig({ + style: "identifier_first", + mitigateEnumeration: true, + }), + }) + + test.describe.configure({ mode: "parallel" }) + test("login fails because user does not exist", async ({ page, config }) => { + const login = new LoginPage(page, config) + await login.open() + + await login.submitIdentifierFirst("i@donot.exist") + + await expect( + page.locator('button[name="method"][value="password"]'), + "expect to show the password form", + ).toBeVisible() + }) + + test("login with wrong password fails", async ({ + page, + identity, + kratosPublicURL, + config, + }) => { + const login = new LoginPage(page, config) + await login.open() + + await login.loginWithPassword(identity.email, "wrong-password") + await login.isReady() + + await hasNoSession(page.request, kratosPublicURL) + await expect( + page.locator('[data-testid="ui/message/4000006"]'), + "expect to be shown a credentials do not exist error", + ).toBeVisible() + }) + + test("login succeeds", async ({ + page, + // projectFrontendClient, + identity, + config, + kratosPublicURL, + }) => { + const login = new LoginPage(page, config) + await login.open() + + await login.inputField("identifier").fill(identity.email) + await login.submit("identifier_first", { + waitForURL: new RegExp(config.selfservice.flows.login.ui_url), + }) + + await login.inputField("password").fill(identity.password) + await login.submit("password", { + waitForURL: new RegExp(config.selfservice.default_browser_return_url), + }) + + await hasSession(page.request, kratosPublicURL) + }) + + test("login with refresh", async ({ + page, + config, + identity, + kratosPublicURL, + }) => { + await loginWithPassword( + { + password: identity.password, + traits: { + email: identity.email, + }, + }, + page.request, + kratosPublicURL, + ) + + const login = new LoginPage(page, config) + await login.open({ + refresh: true, + }) + + await expect( + page.locator('[data-testid="ui/message/1010003"]'), + "show the refresh message", + ).toBeVisible() + + const originalSession = await getSession(page.request, kratosPublicURL) + await login.inputField("password").fill(identity.password) + await login.submit("password", { + waitForURL: new RegExp(config.selfservice.default_browser_return_url), + }) + const newSession = await getSession(page.request, kratosPublicURL) + expect(originalSession.authenticated_at).not.toEqual( + newSession.authenticated_at, + ) + }) +}) diff --git a/test/e2e/playwright/tests/app_login.spec.ts b/test/e2e/playwright/tests/mobile/app_login.spec.ts similarity index 97% rename from test/e2e/playwright/tests/app_login.spec.ts rename to test/e2e/playwright/tests/mobile/app_login.spec.ts index 600a49a48ce..327fbbf3e54 100644 --- a/test/e2e/playwright/tests/app_login.spec.ts +++ b/test/e2e/playwright/tests/mobile/app_login.spec.ts @@ -1,7 +1,8 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { test, expect, Page } from "@playwright/test" +import { expect, Page } from "@playwright/test" +import { test } from "../../fixtures" test.describe.configure({ mode: "parallel" }) diff --git a/test/e2e/playwright/tests/app_recovery.spec.ts b/test/e2e/playwright/tests/mobile/app_recovery.spec.ts similarity index 78% rename from test/e2e/playwright/tests/app_recovery.spec.ts rename to test/e2e/playwright/tests/mobile/app_recovery.spec.ts index 629abd3c05b..c065e096544 100644 --- a/test/e2e/playwright/tests/app_recovery.spec.ts +++ b/test/e2e/playwright/tests/mobile/app_recovery.spec.ts @@ -2,9 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { expect } from "@playwright/test" -import { test } from "../fixtures" -import { search } from "../actions/mail" -import { extractCode } from "../lib/helper" +import { test } from "../../fixtures" +import { search } from "../../actions/mail" +import { extractCode } from "../../lib/helper" const schemaConfig = { default_schema_id: "email", @@ -31,11 +31,11 @@ test.describe("Recovery", () => { test("succeeds with a valid email address", async ({ page, identity }) => { await page.goto("/Recovery") - await page.getByTestId("email").fill(identity.traits.email) + await page.getByTestId("email").fill(identity.email) await page.getByTestId("submit-form").click() await expect(page.getByTestId("ui/message/1060003")).toBeVisible() - const mails = await search(identity.traits.email, "to") + const mails = await search({ query: identity.email, kind: "to" }) expect(mails).toHaveLength(1) const code = extractCode(mails[0]) @@ -43,13 +43,13 @@ test.describe("Recovery", () => { await test.step("enter wrong code", async () => { await page.getByTestId("code").fill(wrongCode) - await page.getByText("Submit").click() + await page.getByText("Continue").click() await expect(page.getByTestId("ui/message/4060006")).toBeVisible() }) await test.step("enter correct code", async () => { await page.getByTestId("code").fill(code) - await page.getByText("Submit").click() + await page.getByText("Continue").click() await page.waitForURL(/Settings/) await expect(page.getByTestId("ui/message/1060001").first()).toBeVisible() }) @@ -58,13 +58,13 @@ test.describe("Recovery", () => { test("wrong email address does not get sent", async ({ page, identity }) => { await page.goto("/Recovery") - const wrongEmailAddress = "wrong-" + identity.traits.email + const wrongEmailAddress = "wrong-" + identity.email await page.getByTestId("email").fill(wrongEmailAddress) await page.getByTestId("submit-form").click() await expect(page.getByTestId("ui/message/1060003")).toBeVisible() try { - await search(identity.traits.email, "to") + await search({ query: identity.email, kind: "to" }) expect(false).toBeTruthy() } catch (e) { // this is expected @@ -74,11 +74,11 @@ test.describe("Recovery", () => { test("fails with an invalid code", async ({ page, identity }) => { await page.goto("/Recovery") - await page.getByTestId("email").fill(identity.traits.email) + await page.getByTestId("email").fill(identity.email) await page.getByTestId("submit-form").click() await page.getByTestId("ui/message/1060003").isVisible() - const mails = await search(identity.traits.email, "to") + const mails = await search({ query: identity.email, kind: "to" }) expect(mails).toHaveLength(1) const code = extractCode(mails[0]) @@ -87,14 +87,14 @@ test.describe("Recovery", () => { await test.step("enter wrong repeatedly", async () => { for (let i = 0; i < 10; i++) { await page.getByTestId("code").fill(wrongCode) - await page.getByText("Submit", { exact: true }).click() + await page.getByText("Continue", { exact: true }).click() await expect(page.getByTestId("ui/message/4060006")).toBeVisible() } }) await test.step("enter correct code fails", async () => { await page.getByTestId("code").fill(code) - await page.getByText("Submit", { exact: true }).click() + await page.getByText("Continue", { exact: true }).click() await expect(page.getByTestId("ui/message/4060006")).toBeVisible() }) }) @@ -123,17 +123,17 @@ test.describe("Recovery", () => { test("fails with an expired code", async ({ page, identity }) => { await page.goto("/Recovery") - await page.getByTestId("email").fill(identity.traits.email) + await page.getByTestId("email").fill(identity.email) await page.getByTestId("submit-form").click() await page.getByTestId("ui/message/1060003").isVisible() - const mails = await search(identity.traits.email, "to") + const mails = await search({ query: identity.email, kind: "to" }) expect(mails).toHaveLength(1) const code = extractCode(mails[0]) await page.getByTestId("code").fill(code) - await page.getByText("Submit", { exact: true }).click() + await page.getByText("Continue", { exact: true }).click() await expect(page.getByTestId("email")).toBeVisible() }) }) diff --git a/test/e2e/playwright/types/index.ts b/test/e2e/playwright/types/index.ts new file mode 100644 index 00000000000..ddb3431774c --- /dev/null +++ b/test/e2e/playwright/types/index.ts @@ -0,0 +1,10 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { APIResponse } from "playwright-core" +import { Session } from "@ory/kratos-client" + +export type SessionWithResponse = { + session: Session + response: APIResponse +} diff --git a/test/e2e/render-kratos-config.sh b/test/e2e/render-kratos-config.sh index 5867904216e..55a60965e28 100755 --- a/test/e2e/render-kratos-config.sh +++ b/test/e2e/render-kratos-config.sh @@ -10,7 +10,7 @@ ory_x_version="$(cd $dir/../..; go list -f '{{.Version}}' -m github.com/ory/x)" curl -s https://raw.githubusercontent.com/ory/x/$ory_x_version/otelx/config.schema.json > $dir/.tracing-config.schema.json -(cd $dir; sed "s!ory://tracing-config!.tracing-config.schema.json!g;" $dir/../../embedx/config.schema.json | npx json2ts --strictIndexSignatures > $dir/cypress/support/config.d.ts) +(cd $dir; sed "s!ory://tracing-config!.tracing-config.schema.json!g;" $dir/../../embedx/config.schema.json | npx json2ts --strictIndexSignatures > $dir/shared/config.d.ts) rm $dir/.tracing-config.schema.json diff --git a/test/e2e/cypress/support/config.d.ts b/test/e2e/shared/config.d.ts similarity index 90% rename from test/e2e/cypress/support/config.d.ts rename to test/e2e/shared/config.d.ts index c7e9742aed3..f423ba19855 100644 --- a/test/e2e/cypress/support/config.d.ts +++ b/test/e2e/shared/config.d.ts @@ -53,10 +53,18 @@ export type ProvideLoginHintsOnFailedRegistration = boolean * URL where the Registration UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node). */ export type RegistrationUIURL = string +/** + * Two-step registration is a significantly improved sign up flow and recommended when using more than one sign up methods. To revert to one-step registration, set this to `true`. + */ +export type DisableTwoStepRegistration = boolean /** * URL where the Login UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node). */ export type LoginUIURL = string +/** + * The style of the login flow. If set to `one_step` the login flow will be a one-step process. If set to `identifier_first` (experimental!) the login flow will first ask for the identifier and then the credentials. + */ +export type LoginFlowStyle = "one_step" | "identifier_first" /** * If set to true will enable [Email and Phone Verification and Account Activation](https://www.ory.sh/kratos/docs/self-service/flows/verify-email-account-activation/). */ @@ -110,14 +118,6 @@ export type EnablesLinkMethod = boolean export type OverrideTheBaseURLWhichShouldBeUsedAsTheBaseForRecoveryAndVerificationLinks = string export type HowLongALinkIsValidFor = string -export type EnablesLoginAndRegistrationWithTheCodeMethod = boolean -export type EnablesLoginFlowsCodeMethodToFulfilMFARequests = boolean -/** - * This setting allows the code method to always login a user with code if they have registered with another authentication method such as password or social sign in. - */ -export type PasswordlessLoginFallbackEnabled = boolean -export type EnablesCodeMethod = boolean -export type HowLongACodeIsValidFor = string export type EnablesUsernameEmailAndPasswordMethod = boolean /** * Allows changing the default HIBP host to a self hosted version. @@ -178,6 +178,19 @@ export type RelyingPartyRPConfig = origins: string[] [k: string]: unknown | undefined } +export type EnablesThePasskeyMethod = boolean +/** + * A name to help the user identify this RP. + */ +export type RelyingPartyDisplayName = string +/** + * The id must be a subset of the domain currently in the browser. + */ +export type RelyingPartyIdentifier = string +/** + * A list of explicit RP origins. If left empty, this defaults to either `origin` or `id`, prepended with the current protocol schema (HTTP or HTTPS). + */ +export type RelyingPartyOrigins = string[] export type EnablesOpenIDConnectMethod = boolean /** * Can be used to modify the base URL for OAuth2 Redirect URLs. If unset, the Public Base URL will be used. @@ -202,6 +215,7 @@ export type SelfServiceOIDCProvider = SelfServiceOIDCProvider1 & { requested_claims?: OpenIDConnectClaims organization_id?: OrganizationID additional_id_token_audiences?: AdditionalClientIdsAllowedWhenUsingIDTokenSubmission + claims_source?: ClaimsSource } export type SelfServiceOIDCProvider1 = { [k: string]: unknown | undefined @@ -230,7 +244,9 @@ export type Provider = | "dingtalk" | "patreon" | "linkedin" + | "linkedin_v2" | "lark" + | "x" export type OptionalStringWhichWillBeUsedWhenGeneratingLabelsForUIButtons = string /** @@ -262,6 +278,10 @@ export type ApplePrivateKey = string */ export type OrganizationID = string export type AdditionalClientIdsAllowedWhenUsingIDTokenSubmission = string[] +/** + * Can be either `userinfo` (calls the userinfo endpoint to get the claims) or `id_token` (takes the claims from the id token). It defaults to `id_token` + */ +export type ClaimsSource = "id_token" | "userinfo" /** * A list and configuration of OAuth2 and OpenID Connect providers Ory Kratos should integrate with. */ @@ -517,14 +537,26 @@ export type AddExemptURLsToPrivateIPRanges = string[] * If enabled allows Ory Sessions to be cached. Only effective in the Ory Network. */ export type EnableOrySessionsCaching = boolean +/** + * Set how long Ory Sessions are cached on the edge. If unset, the session expiry will be used. Only effective in the Ory Network. + */ +export type SetOrySessionEdgeCachingMaximumAge = string /** * If enabled allows new flow transitions using `continue_with` items. */ export type EnableNewFlowTransitionsUsingContinueWithItems = boolean /** - * Secifies which organizations are available. Only effective in the Ory Network. + * If enabled allows faster session extension by skipping the session lookup. Disabling this feature will be deprecated in the future. + */ +export type EnableFasterSessionExtension = boolean +/** + * Please use selfservice.methods.b2b instead. This key will be removed. Only effective in the Ory Network. */ export type Organizations = unknown[] +/** + * A fallback URL template used when looking up identity schemas. + */ +export type FallbackURLTemplateForIdentitySchemas = string export interface OryKratosConfiguration2 { selfservice: { @@ -551,10 +583,12 @@ export interface OryKratosConfiguration2 { lifespan?: string before?: SelfServiceBeforeRegistration after?: SelfServiceAfterRegistration + enable_legacy_one_step?: DisableTwoStepRegistration } login?: { ui_url?: LoginUIURL lifespan?: string + style?: LoginFlowStyle before?: SelfServiceBeforeLogin after?: SelfServiceAfterLogin } @@ -565,6 +599,7 @@ export interface OryKratosConfiguration2 { } } methods?: { + b2b?: SingleSignOnForB2B profile?: { enabled?: EnablesProfileManagementMethod } @@ -572,13 +607,22 @@ export interface OryKratosConfiguration2 { enabled?: EnablesLinkMethod config?: LinkConfiguration } - code?: { - passwordless_enabled?: EnablesLoginAndRegistrationWithTheCodeMethod - mfa_enabled?: EnablesLoginFlowsCodeMethodToFulfilMFARequests - passwordless_login_fallback_enabled?: PasswordlessLoginFallbackEnabled - enabled?: EnablesCodeMethod - config?: CodeConfiguration - } + code?: + | { + passwordless_enabled?: true + mfa_enabled?: false + [k: string]: unknown | undefined + } + | { + mfa_enabled?: true + passwordless_enabled?: false + [k: string]: unknown | undefined + } + | { + mfa_enabled?: false + passwordless_enabled?: false + [k: string]: unknown | undefined + } password?: { enabled?: EnablesUsernameEmailAndPasswordMethod config?: PasswordConfiguration @@ -594,6 +638,10 @@ export interface OryKratosConfiguration2 { enabled?: EnablesTheWebAuthnMethod config?: WebAuthnConfiguration } + passkey?: { + enabled?: EnablesThePasskeyMethod + config?: PasskeyConfiguration + } oidc?: SpecifyOpenIDConnectAndOAuth2Configuration } } @@ -701,6 +749,16 @@ export interface OryKratosConfiguration2 { } earliest_possible_extend?: EarliestPossibleSessionExtension } + security?: { + account_enumeration?: { + /** + * Mitigate account enumeration by making it harder to figure out if an identifier (email, phone number) exists or not. Enabling this setting degrades user experience. This setting does not mitigate all possible attack vectors yet. + */ + mitigate?: boolean + [k: string]: unknown | undefined + } + [k: string]: unknown | undefined + } version?: TheKratosVersionThisConfigIsWrittenFor dev?: boolean help?: boolean @@ -720,6 +778,7 @@ export interface OryKratosConfiguration2 { clients?: GlobalOutgoingNetworkSettings feature_flags?: FeatureFlags organizations?: Organizations + enterprise?: EnterpriseFeatures } export interface SelfServiceAfterSettings { default_browser_return_url?: RedirectBrowsersToSetURLPerDefault @@ -727,6 +786,7 @@ export interface SelfServiceAfterSettings { totp?: SelfServiceAfterSettingsAuthMethod oidc?: SelfServiceAfterSettingsAuthMethod webauthn?: SelfServiceAfterSettingsAuthMethod + passkey?: SelfServiceAfterSettingsAuthMethod lookup_secret?: SelfServiceAfterSettingsAuthMethod profile?: SelfServiceAfterSettingsMethod hooks?: SelfServiceHooks @@ -762,6 +822,7 @@ export interface SelfServiceAfterRegistration { default_browser_return_url?: RedirectBrowsersToSetURLPerDefault password?: SelfServiceAfterRegistrationMethod webauthn?: SelfServiceAfterRegistrationMethod + passkey?: SelfServiceAfterRegistrationMethod oidc?: SelfServiceAfterRegistrationMethod code?: SelfServiceAfterRegistrationMethod hooks?: SelfServiceHooks @@ -788,6 +849,7 @@ export interface SelfServiceAfterLogin { default_browser_return_url?: RedirectBrowsersToSetURLPerDefault password?: SelfServiceAfterDefaultLoginMethod webauthn?: SelfServiceAfterDefaultLoginMethod + passkey?: SelfServiceAfterDefaultLoginMethod oidc?: SelfServiceAfterOIDCLoginMethod code?: SelfServiceAfterDefaultLoginMethod totp?: SelfServiceAfterDefaultLoginMethod @@ -796,6 +858,8 @@ export interface SelfServiceAfterLogin { | SelfServiceWebHook | SelfServiceSessionRevokerHook | SelfServiceRequireVerifiedAddressHook + | SelfServiceVerificationHook + | SelfServiceShowVerificationUIHook | B2BSSOHook )[] } @@ -805,11 +869,16 @@ export interface SelfServiceAfterDefaultLoginMethod { | SelfServiceSessionRevokerHook | SelfServiceRequireVerifiedAddressHook | SelfServiceWebHook + | SelfServiceVerificationHook + | SelfServiceShowVerificationUIHook )[] } export interface SelfServiceRequireVerifiedAddressHook { hook: "require_verified_address" } +export interface SelfServiceVerificationHook { + hook: "verification" +} export interface SelfServiceAfterOIDCLoginMethod { default_browser_return_url?: RedirectBrowsersToSetURLPerDefault hooks?: ( @@ -851,6 +920,25 @@ export interface SelfServiceAfterRecovery { export interface SelfServiceBeforeRecovery { hooks?: SelfServiceHooks } +/** + * Single Sign-On for B2B allows your customers to bring their own (workforce) identity server (e.g. OneLogin). This feature is not available in the open source licensed code. + */ +export interface SingleSignOnForB2B { + config?: { + organizations?: { + /** + * The ID of the organization. + */ + id?: string + /** + * The label of the organization. + */ + label?: string + domains?: string[] + [k: string]: unknown | undefined + }[] + } +} /** * Additional configuration for the link strategy. */ @@ -859,13 +947,6 @@ export interface LinkConfiguration { lifespan?: HowLongALinkIsValidFor [k: string]: unknown | undefined } -/** - * Additional configuration for the code strategy. - */ -export interface CodeConfiguration { - lifespan?: HowLongACodeIsValidFor - [k: string]: unknown | undefined -} /** * Define how passwords are validated. */ @@ -884,6 +965,15 @@ export interface WebAuthnConfiguration { passwordless?: UseForPasswordlessFlows rp?: RelyingPartyRPConfig } +export interface PasskeyConfiguration { + rp?: RelyingPartyRPConfig1 +} +export interface RelyingPartyRPConfig1 { + display_name: RelyingPartyDisplayName + id: RelyingPartyIdentifier + origins?: RelyingPartyOrigins + [k: string]: unknown | undefined +} export interface SpecifyOpenIDConnectAndOAuth2Configuration { enabled?: EnablesOpenIDConnectMethod config?: { @@ -963,6 +1053,7 @@ export interface CourierConfiguration { login_code?: { valid?: { email: EmailCourierTemplate + sms?: SmsCourierTemplate } } } @@ -1080,7 +1171,7 @@ export interface WebHookAuthBasicAuthProperties { * Configures outgoing emails using the SMTP protocol. */ export interface SMTPConfiguration { - connection_uri: SMTPConnectionString + connection_uri?: SMTPConnectionString client_cert_path?: SMTPClientCertificatePath client_key_path?: SMTPClientPrivateKeyPath from_address?: SMTPSenderAddress @@ -1375,5 +1466,13 @@ export interface GlobalHTTPClientConfiguration { } export interface FeatureFlags { cacheable_sessions?: EnableOrySessionsCaching + cacheable_sessions_max_age?: SetOrySessionEdgeCachingMaximumAge use_continue_with_transitions?: EnableNewFlowTransitionsUsingContinueWithItems + faster_session_extend?: EnableFasterSessionExtension +} +/** + * Specifies enterprise features. Only effective in the Ory Network or with a valid license. + */ +export interface EnterpriseFeatures { + identity_schema_fallback_url_template?: FallbackURLTemplateForIdentitySchemas }