diff --git a/.gitignore b/.gitignore index f792761b2b71..42d798e427ad 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/.schema/openapi/patches/schema.yaml b/.schema/openapi/patches/schema.yaml index 206aceb2708e..ff661ce4079d 100644 --- a/.schema/openapi/patches/schema.yaml +++ b/.schema/openapi/patches/schema.yaml @@ -43,6 +43,7 @@ set_ory_session_token: "#/components/schemas/continueWithSetOrySessionToken" show_settings_ui: "#/components/schemas/continueWithSettingsUi" show_recovery_ui: "#/components/schemas/continueWithRecoveryUi" + redirect_browser_to: "#/components/schemas/continueWithRedirectBrowserTo" - op: add path: /components/schemas/continueWith/oneOf @@ -51,3 +52,4 @@ - "$ref": "#/components/schemas/continueWithSetOrySessionToken" - "$ref": "#/components/schemas/continueWithSettingsUi" - "$ref": "#/components/schemas/continueWithRecoveryUi" + - "$ref": "#/components/schemas/continueWithRedirectBrowserTo" diff --git a/.schema/openapi/patches/selfservice.yaml b/.schema/openapi/patches/selfservice.yaml index 81d82247586b..7887c1c2da74 100644 --- a/.schema/openapi/patches/selfservice.yaml +++ b/.schema/openapi/patches/selfservice.yaml @@ -19,6 +19,7 @@ - "$ref": "#/components/schemas/updateRegistrationFlowWithWebAuthnMethod" - "$ref": "#/components/schemas/updateRegistrationFlowWithCodeMethod" - "$ref": "#/components/schemas/updateRegistrationFlowWithPasskeyMethod" + - "$ref": "#/components/schemas/updateRegistrationFlowWithProfileMethod" - op: add path: /components/schemas/updateRegistrationFlowBody/discriminator value: @@ -28,7 +29,8 @@ oidc: "#/components/schemas/updateRegistrationFlowWithOidcMethod" webauthn: "#/components/schemas/updateRegistrationFlowWithWebAuthnMethod" code: "#/components/schemas/updateRegistrationFlowWithCodeMethod" - passKey: "#/components/schemas/updateRegistrationFlowWithPasskeyMethod" + passkey: "#/components/schemas/updateRegistrationFlowWithPasskeyMethod" + profile: "#/components/schemas/updateRegistrationFlowWithProfileMethod" - op: add path: /components/schemas/registrationFlowState/enum value: @@ -50,6 +52,7 @@ - "$ref": "#/components/schemas/updateLoginFlowWithLookupSecretMethod" - "$ref": "#/components/schemas/updateLoginFlowWithCodeMethod" - "$ref": "#/components/schemas/updateLoginFlowWithPasskeyMethod" + - "$ref": "#/components/schemas/updateLoginFlowWithIdentifierFirstMethod" - op: add path: /components/schemas/updateLoginFlowBody/discriminator value: @@ -62,6 +65,7 @@ lookup_secret: "#/components/schemas/updateLoginFlowWithLookupSecretMethod" code: "#/components/schemas/updateLoginFlowWithCodeMethod" passkey: "#/components/schemas/updateLoginFlowWithPasskeyMethod" + identifier_first: "#/components/schemas/updateLoginFlowWithIdentifierFirstMethod" - op: add path: /components/schemas/loginFlowState/enum value: diff --git a/Makefile b/Makefile index 7af282d469c3..0395085b9849 100644 --- a/Makefile +++ b/Makefile @@ -193,8 +193,8 @@ migrations-sync: .bin/ory ory dev pop migration sync persistence/sql/migrations/templates persistence/sql/migratest/testdata script/add-down-migrations.sh -.PHONY: test-update-snapshots -test-update-snapshots: +.PHONY: test-refresh +test-refresh: UPDATE_SNAPSHOTS=true go test -tags sqlite,json1,refresh -short ./... .PHONY: post-release diff --git a/cmd/clidoc/main.go b/cmd/clidoc/main.go index 6ed8df8d1748..a498aa9dfca6 100644 --- a/cmd/clidoc/main.go +++ b/cmd/clidoc/main.go @@ -177,6 +177,8 @@ func init() { "NewErrorValidationAddressUnknown": text.NewErrorValidationAddressUnknown(), "NewInfoSelfServiceLoginCodeMFA": text.NewInfoSelfServiceLoginCodeMFA(), "NewInfoSelfServiceLoginCodeMFAHint": text.NewInfoSelfServiceLoginCodeMFAHint("{maskedIdentifier}"), + "NewInfoLoginPassword": text.NewInfoLoginPassword(), + "NewErrorValidationAccountNotFound": text.NewErrorValidationAccountNotFound(), } } diff --git a/courier/sms_test.go b/courier/sms_test.go index a93a7974bf71..5dc727048be6 100644 --- a/courier/sms_test.go +++ b/courier/sms_test.go @@ -63,6 +63,7 @@ func TestQueueSMS(t *testing.T) { Body: body.Body, }) })) + t.Cleanup(srv.Close) requestConfig := fmt.Sprintf(`{ "url": "%s", @@ -112,8 +113,6 @@ func TestQueueSMS(t *testing.T) { assert.Equal(t, expected.To, message.To) assert.Equal(t, fmt.Sprintf("stub sms body %s\n", expected.Body), message.Body) } - - srv.Close() } func TestDisallowedInternalNetwork(t *testing.T) { diff --git a/driver/config/config.go b/driver/config/config.go index 81be527a2632..0c5b11ee0175 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -134,6 +134,8 @@ const ( ViperKeySelfServiceRegistrationAfter = "selfservice.flows.registration.after" ViperKeySelfServiceRegistrationBeforeHooks = "selfservice.flows.registration.before.hooks" ViperKeySelfServiceLoginUI = "selfservice.flows.login.ui_url" + ViperKeySelfServiceLoginFlowStyle = "selfservice.flows.login.style" + ViperKeySecurityAccountEnumerationMitigate = "security.account_enumeration.mitigate" ViperKeySelfServiceLoginRequestLifespan = "selfservice.flows.login.lifespan" ViperKeySelfServiceLoginAfter = "selfservice.flows.login.after" ViperKeySelfServiceLoginBeforeHooks = "selfservice.flows.login.before.hooks" @@ -774,7 +776,7 @@ func (p *Config) SelfServiceStrategy(ctx context.Context, strategy string) *Self var err error config, err = json.Marshal(pp.GetF(basePath+".config", config)) if err != nil { - p.l.WithError(err).Warn("Unable to marshal self service strategy configuration.") + p.l.WithError(err).Warn("Unable to marshal self-service strategy configuration.") config = json.RawMessage("{}") } @@ -782,6 +784,8 @@ func (p *Config) SelfServiceStrategy(ctx context.Context, strategy string) *Self // we need to forcibly set these values here: defaultEnabled := false switch strategy { + case "identifier_first": + defaultEnabled = p.SelfServiceLoginFlowIdentifierFirstEnabled(ctx) case "code", "password", "profile": defaultEnabled = true } @@ -1612,3 +1616,16 @@ func (p *Config) PasswordMigrationHook(ctx context.Context) (hook *PasswordMigra return hook } + +func (p *Config) SelfServiceLoginFlowIdentifierFirstEnabled(ctx context.Context) bool { + switch p.GetProvider(ctx).String(ViperKeySelfServiceLoginFlowStyle) { + case "identifier_first": + return true + default: + return false + } +} + +func (p *Config) SecurityAccountEnumerationMitigate(ctx context.Context) bool { + return p.GetProvider(ctx).Bool(ViperKeySecurityAccountEnumerationMitigate) +} diff --git a/driver/registry_default.go b/driver/registry_default.go index eab63a120981..c77ab5d783f2 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -12,6 +12,8 @@ import ( "testing" "time" + "github.com/ory/kratos/selfservice/strategy/idfirst" + "github.com/cenkalti/backoff" "github.com/dgraph-io/ristretto" "github.com/gobuffalo/pop/v6" @@ -324,6 +326,7 @@ func (m *RegistryDefault) selfServiceStrategies() []any { passkey.NewStrategy(m), webauthn.NewStrategy(m), lookup.NewStrategy(m), + idfirst.NewStrategy(m), } } } @@ -379,6 +382,7 @@ nextStrategy: continue nextStrategy } } + if m.strategyLoginEnabled(ctx, s.ID().String()) { loginStrategies = append(loginStrategies, s) } diff --git a/driver/registry_default_test.go b/driver/registry_default_test.go index fa3e7772c62a..a52b4fc6072c 100644 --- a/driver/registry_default_test.go +++ b/driver/registry_default_test.go @@ -872,7 +872,7 @@ func TestDefaultRegistry_AllStrategies(t *testing.T) { _, reg := internal.NewVeryFastRegistryWithoutDB(t) t.Run("case=all login strategies", func(t *testing.T) { - expects := []string{"password", "oidc", "code", "totp", "passkey", "webauthn", "lookup_secret"} + expects := []string{"password", "oidc", "code", "totp", "passkey", "webauthn", "lookup_secret", "identifier_first"} s := reg.AllLoginStrategies() require.Len(t, s, len(expects)) for k, e := range expects { diff --git a/embedx/config.schema.json b/embedx/config.schema.json index c62b3c39f00c..f810dd41af48 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -1298,10 +1298,10 @@ }, "style": { "title": "Login Flow Style", - "description": "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.", + "description": "The style of the login flow. If set to `unified` 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.", "type": "string", - "enum": ["one_step", "identifier_first"], - "default": "one_step" + "enum": ["unified", "identifier_first"], + "default": "unified" }, "before": { "$ref": "#/definitions/selfServiceBeforeLogin" @@ -1557,12 +1557,6 @@ "title": "Enables login flows code method to fulfil MFA requests", "default": false }, - "passwordless_login_fallback_enabled": { - "type": "boolean", - "title": "Passwordless Login Fallback Enabled", - "description": "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.", - "default": false - }, "enabled": { "type": "boolean", "title": "Enables Code Method", @@ -2879,6 +2873,21 @@ } } }, + "security": { + "type": "object", + "properties": { + "account_enumeration": { + "type": "object", + "properties": { + "mitigate": { + "type": "boolean", + "default": false, + "description": "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." + } + } + } + } + }, "version": { "title": "The kratos version this config is written for.", "description": "SemVer according to https://semver.org/ prefixed with `v` as in our releases.", diff --git a/examples/go/pkg/common.go b/examples/go/pkg/common.go index edb8c17e2e19..f39725103e33 100644 --- a/examples/go/pkg/common.go +++ b/examples/go/pkg/common.go @@ -11,11 +11,9 @@ import ( "os" "testing" - "github.com/ory/kratos/x" - - "github.com/ory/kratos/internal/testhelpers" - ory "github.com/ory/client-go" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/x" ) func PrintJSONPretty(v interface{}) { diff --git a/internal/client-go/.openapi-generator/FILES b/internal/client-go/.openapi-generator/FILES index fdf34c5e1507..c573997505d8 100644 --- a/internal/client-go/.openapi-generator/FILES +++ b/internal/client-go/.openapi-generator/FILES @@ -15,6 +15,7 @@ docs/ConsistencyRequestParameters.md docs/ContinueWith.md docs/ContinueWithRecoveryUi.md docs/ContinueWithRecoveryUiFlow.md +docs/ContinueWithRedirectBrowserTo.md docs/ContinueWithSetOrySessionToken.md docs/ContinueWithSettingsUi.md docs/ContinueWithSettingsUiFlow.md @@ -99,6 +100,7 @@ docs/UiText.md docs/UpdateIdentityBody.md docs/UpdateLoginFlowBody.md docs/UpdateLoginFlowWithCodeMethod.md +docs/UpdateLoginFlowWithIdentifierFirstMethod.md docs/UpdateLoginFlowWithLookupSecretMethod.md docs/UpdateLoginFlowWithOidcMethod.md docs/UpdateLoginFlowWithPasskeyMethod.md @@ -139,6 +141,7 @@ model_consistency_request_parameters.go model_continue_with.go model_continue_with_recovery_ui.go model_continue_with_recovery_ui_flow.go +model_continue_with_redirect_browser_to.go model_continue_with_set_ory_session_token.go model_continue_with_settings_ui.go model_continue_with_settings_ui_flow.go @@ -219,6 +222,7 @@ model_ui_text.go model_update_identity_body.go model_update_login_flow_body.go model_update_login_flow_with_code_method.go +model_update_login_flow_with_identifier_first_method.go model_update_login_flow_with_lookup_secret_method.go model_update_login_flow_with_oidc_method.go model_update_login_flow_with_passkey_method.go diff --git a/internal/client-go/README.md b/internal/client-go/README.md index 04dd61ab7d1e..85af88a0d079 100644 --- a/internal/client-go/README.md +++ b/internal/client-go/README.md @@ -142,6 +142,7 @@ Class | Method | HTTP request | Description - [ContinueWith](docs/ContinueWith.md) - [ContinueWithRecoveryUi](docs/ContinueWithRecoveryUi.md) - [ContinueWithRecoveryUiFlow](docs/ContinueWithRecoveryUiFlow.md) + - [ContinueWithRedirectBrowserTo](docs/ContinueWithRedirectBrowserTo.md) - [ContinueWithSetOrySessionToken](docs/ContinueWithSetOrySessionToken.md) - [ContinueWithSettingsUi](docs/ContinueWithSettingsUi.md) - [ContinueWithSettingsUiFlow](docs/ContinueWithSettingsUiFlow.md) @@ -222,6 +223,7 @@ Class | Method | HTTP request | Description - [UpdateIdentityBody](docs/UpdateIdentityBody.md) - [UpdateLoginFlowBody](docs/UpdateLoginFlowBody.md) - [UpdateLoginFlowWithCodeMethod](docs/UpdateLoginFlowWithCodeMethod.md) + - [UpdateLoginFlowWithIdentifierFirstMethod](docs/UpdateLoginFlowWithIdentifierFirstMethod.md) - [UpdateLoginFlowWithLookupSecretMethod](docs/UpdateLoginFlowWithLookupSecretMethod.md) - [UpdateLoginFlowWithOidcMethod](docs/UpdateLoginFlowWithOidcMethod.md) - [UpdateLoginFlowWithPasskeyMethod](docs/UpdateLoginFlowWithPasskeyMethod.md) diff --git a/internal/client-go/model_continue_with.go b/internal/client-go/model_continue_with.go index 9e97dbf479e7..6fb1056836e6 100644 --- a/internal/client-go/model_continue_with.go +++ b/internal/client-go/model_continue_with.go @@ -19,6 +19,7 @@ import ( // ContinueWith - struct for ContinueWith type ContinueWith struct { ContinueWithRecoveryUi *ContinueWithRecoveryUi + ContinueWithRedirectBrowserTo *ContinueWithRedirectBrowserTo ContinueWithSetOrySessionToken *ContinueWithSetOrySessionToken ContinueWithSettingsUi *ContinueWithSettingsUi ContinueWithVerificationUi *ContinueWithVerificationUi @@ -31,6 +32,13 @@ func ContinueWithRecoveryUiAsContinueWith(v *ContinueWithRecoveryUi) ContinueWit } } +// ContinueWithRedirectBrowserToAsContinueWith is a convenience function that returns ContinueWithRedirectBrowserTo wrapped in ContinueWith +func ContinueWithRedirectBrowserToAsContinueWith(v *ContinueWithRedirectBrowserTo) ContinueWith { + return ContinueWith{ + ContinueWithRedirectBrowserTo: v, + } +} + // ContinueWithSetOrySessionTokenAsContinueWith is a convenience function that returns ContinueWithSetOrySessionToken wrapped in ContinueWith func ContinueWithSetOrySessionTokenAsContinueWith(v *ContinueWithSetOrySessionToken) ContinueWith { return ContinueWith{ @@ -62,6 +70,18 @@ func (dst *ContinueWith) UnmarshalJSON(data []byte) error { return fmt.Errorf("Failed to unmarshal JSON into map for the discrimintor lookup.") } + // check if the discriminator value is 'redirect_browser_to' + if jsonDict["action"] == "redirect_browser_to" { + // try to unmarshal JSON data into ContinueWithRedirectBrowserTo + err = json.Unmarshal(data, &dst.ContinueWithRedirectBrowserTo) + if err == nil { + return nil // data stored in dst.ContinueWithRedirectBrowserTo, return on the first match + } else { + dst.ContinueWithRedirectBrowserTo = nil + return fmt.Errorf("Failed to unmarshal ContinueWith as ContinueWithRedirectBrowserTo: %s", err.Error()) + } + } + // check if the discriminator value is 'set_ory_session_token' if jsonDict["action"] == "set_ory_session_token" { // try to unmarshal JSON data into ContinueWithSetOrySessionToken @@ -122,6 +142,18 @@ func (dst *ContinueWith) UnmarshalJSON(data []byte) error { } } + // check if the discriminator value is 'continueWithRedirectBrowserTo' + if jsonDict["action"] == "continueWithRedirectBrowserTo" { + // try to unmarshal JSON data into ContinueWithRedirectBrowserTo + err = json.Unmarshal(data, &dst.ContinueWithRedirectBrowserTo) + if err == nil { + return nil // data stored in dst.ContinueWithRedirectBrowserTo, return on the first match + } else { + dst.ContinueWithRedirectBrowserTo = nil + return fmt.Errorf("Failed to unmarshal ContinueWith as ContinueWithRedirectBrowserTo: %s", err.Error()) + } + } + // check if the discriminator value is 'continueWithSetOrySessionToken' if jsonDict["action"] == "continueWithSetOrySessionToken" { // try to unmarshal JSON data into ContinueWithSetOrySessionToken @@ -167,6 +199,10 @@ func (src ContinueWith) MarshalJSON() ([]byte, error) { return json.Marshal(&src.ContinueWithRecoveryUi) } + if src.ContinueWithRedirectBrowserTo != nil { + return json.Marshal(&src.ContinueWithRedirectBrowserTo) + } + if src.ContinueWithSetOrySessionToken != nil { return json.Marshal(&src.ContinueWithSetOrySessionToken) } @@ -191,6 +227,10 @@ func (obj *ContinueWith) GetActualInstance() interface{} { return obj.ContinueWithRecoveryUi } + if obj.ContinueWithRedirectBrowserTo != nil { + return obj.ContinueWithRedirectBrowserTo + } + if obj.ContinueWithSetOrySessionToken != nil { return obj.ContinueWithSetOrySessionToken } diff --git a/internal/client-go/model_continue_with_recovery_ui_flow.go b/internal/client-go/model_continue_with_recovery_ui_flow.go index 3fde7e717ef2..251725a73c3b 100644 --- a/internal/client-go/model_continue_with_recovery_ui_flow.go +++ b/internal/client-go/model_continue_with_recovery_ui_flow.go @@ -19,7 +19,7 @@ import ( type ContinueWithRecoveryUiFlow struct { // The ID of the recovery flow Id string `json:"id"` - // The URL of the recovery flow + // The URL of the recovery flow If this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows. Url *string `json:"url,omitempty"` } diff --git a/internal/client-go/model_continue_with_redirect_browser_to.go b/internal/client-go/model_continue_with_redirect_browser_to.go new file mode 100644 index 000000000000..20c3e4f3c562 --- /dev/null +++ b/internal/client-go/model_continue_with_redirect_browser_to.go @@ -0,0 +1,138 @@ +/* + * Ory Identities API + * + * This is the API specification for Ory Identities with features such as registration, login, recovery, account verification, profile settings, password reset, identity management, session management, email and sms delivery, and more. + * + * API version: + * Contact: office@ory.sh + */ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package client + +import ( + "encoding/json" +) + +// ContinueWithRedirectBrowserTo Indicates, that the UI flow could be continued by showing a recovery ui +type ContinueWithRedirectBrowserTo struct { + // Action will always be `redirect_browser_to` redirect_browser_to ContinueWithActionRedirectBrowserToString + Action string `json:"action"` + // The URL to redirect the browser to + RedirectBrowserTo string `json:"redirect_browser_to"` +} + +// NewContinueWithRedirectBrowserTo instantiates a new ContinueWithRedirectBrowserTo object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewContinueWithRedirectBrowserTo(action string, redirectBrowserTo string) *ContinueWithRedirectBrowserTo { + this := ContinueWithRedirectBrowserTo{} + this.Action = action + this.RedirectBrowserTo = redirectBrowserTo + return &this +} + +// NewContinueWithRedirectBrowserToWithDefaults instantiates a new ContinueWithRedirectBrowserTo object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewContinueWithRedirectBrowserToWithDefaults() *ContinueWithRedirectBrowserTo { + this := ContinueWithRedirectBrowserTo{} + return &this +} + +// GetAction returns the Action field value +func (o *ContinueWithRedirectBrowserTo) GetAction() string { + if o == nil { + var ret string + return ret + } + + return o.Action +} + +// GetActionOk returns a tuple with the Action field value +// and a boolean to check if the value has been set. +func (o *ContinueWithRedirectBrowserTo) GetActionOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Action, true +} + +// SetAction sets field value +func (o *ContinueWithRedirectBrowserTo) SetAction(v string) { + o.Action = v +} + +// GetRedirectBrowserTo returns the RedirectBrowserTo field value +func (o *ContinueWithRedirectBrowserTo) GetRedirectBrowserTo() string { + if o == nil { + var ret string + return ret + } + + return o.RedirectBrowserTo +} + +// GetRedirectBrowserToOk returns a tuple with the RedirectBrowserTo field value +// and a boolean to check if the value has been set. +func (o *ContinueWithRedirectBrowserTo) GetRedirectBrowserToOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.RedirectBrowserTo, true +} + +// SetRedirectBrowserTo sets field value +func (o *ContinueWithRedirectBrowserTo) SetRedirectBrowserTo(v string) { + o.RedirectBrowserTo = v +} + +func (o ContinueWithRedirectBrowserTo) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if true { + toSerialize["action"] = o.Action + } + if true { + toSerialize["redirect_browser_to"] = o.RedirectBrowserTo + } + return json.Marshal(toSerialize) +} + +type NullableContinueWithRedirectBrowserTo struct { + value *ContinueWithRedirectBrowserTo + isSet bool +} + +func (v NullableContinueWithRedirectBrowserTo) Get() *ContinueWithRedirectBrowserTo { + return v.value +} + +func (v *NullableContinueWithRedirectBrowserTo) Set(val *ContinueWithRedirectBrowserTo) { + v.value = val + v.isSet = true +} + +func (v NullableContinueWithRedirectBrowserTo) IsSet() bool { + return v.isSet +} + +func (v *NullableContinueWithRedirectBrowserTo) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableContinueWithRedirectBrowserTo(val *ContinueWithRedirectBrowserTo) *NullableContinueWithRedirectBrowserTo { + return &NullableContinueWithRedirectBrowserTo{value: val, isSet: true} +} + +func (v NullableContinueWithRedirectBrowserTo) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableContinueWithRedirectBrowserTo) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/internal/client-go/model_continue_with_settings_ui_flow.go b/internal/client-go/model_continue_with_settings_ui_flow.go index 4ccaf74ef1b8..d6e9b9441f99 100644 --- a/internal/client-go/model_continue_with_settings_ui_flow.go +++ b/internal/client-go/model_continue_with_settings_ui_flow.go @@ -19,6 +19,8 @@ import ( type ContinueWithSettingsUiFlow struct { // The ID of the settings flow Id string `json:"id"` + // The URL of the settings flow If this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows. + Url *string `json:"url,omitempty"` } // NewContinueWithSettingsUiFlow instantiates a new ContinueWithSettingsUiFlow object @@ -63,11 +65,46 @@ func (o *ContinueWithSettingsUiFlow) SetId(v string) { o.Id = v } +// GetUrl returns the Url field value if set, zero value otherwise. +func (o *ContinueWithSettingsUiFlow) GetUrl() string { + if o == nil || o.Url == nil { + var ret string + return ret + } + return *o.Url +} + +// GetUrlOk returns a tuple with the Url field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ContinueWithSettingsUiFlow) GetUrlOk() (*string, bool) { + if o == nil || o.Url == nil { + return nil, false + } + return o.Url, true +} + +// HasUrl returns a boolean if a field has been set. +func (o *ContinueWithSettingsUiFlow) HasUrl() bool { + if o != nil && o.Url != nil { + return true + } + + return false +} + +// SetUrl gets a reference to the given string and assigns it to the Url field. +func (o *ContinueWithSettingsUiFlow) SetUrl(v string) { + o.Url = &v +} + func (o ContinueWithSettingsUiFlow) MarshalJSON() ([]byte, error) { toSerialize := map[string]interface{}{} if true { toSerialize["id"] = o.Id } + if o.Url != nil { + toSerialize["url"] = o.Url + } return json.Marshal(toSerialize) } diff --git a/internal/client-go/model_continue_with_verification_ui_flow.go b/internal/client-go/model_continue_with_verification_ui_flow.go index 8fdd4609cf93..3c73a0761339 100644 --- a/internal/client-go/model_continue_with_verification_ui_flow.go +++ b/internal/client-go/model_continue_with_verification_ui_flow.go @@ -19,7 +19,7 @@ import ( type ContinueWithVerificationUiFlow struct { // The ID of the verification flow Id string `json:"id"` - // The URL of the verification flow + // The URL of the verification flow If this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows. Url *string `json:"url,omitempty"` // The address that should be verified in this flow VerifiableAddress string `json:"verifiable_address"` diff --git a/internal/client-go/model_ui_node.go b/internal/client-go/model_ui_node.go index e73f3c5e37d8..3582d9e85f67 100644 --- a/internal/client-go/model_ui_node.go +++ b/internal/client-go/model_ui_node.go @@ -18,7 +18,7 @@ import ( // UiNode Nodes are represented as HTML elements or their native UI equivalents. For example, a node can be an `` tag, or an `` but also `some plain text`. type UiNode struct { Attributes UiNodeAttributes `json:"attributes"` - // Group specifies which group (e.g. password authenticator) this node belongs to. default DefaultGroup password PasswordGroup oidc OpenIDConnectGroup profile ProfileGroup link LinkGroup code CodeGroup totp TOTPGroup lookup_secret LookupGroup webauthn WebAuthnGroup passkey PasskeyGroup + // Group specifies which group (e.g. password authenticator) this node belongs to. default DefaultGroup password PasswordGroup oidc OpenIDConnectGroup profile ProfileGroup link LinkGroup code CodeGroup totp TOTPGroup lookup_secret LookupGroup webauthn WebAuthnGroup passkey PasskeyGroup identifier_first IdentifierFirstGroup Group string `json:"group"` Messages []UiText `json:"messages"` Meta UiNodeMeta `json:"meta"` diff --git a/internal/client-go/model_ui_node_input_attributes.go b/internal/client-go/model_ui_node_input_attributes.go index b373dda7ccfd..f8deff5d5417 100644 --- a/internal/client-go/model_ui_node_input_attributes.go +++ b/internal/client-go/model_ui_node_input_attributes.go @@ -22,14 +22,20 @@ type UiNodeInputAttributes struct { // Sets the input's disabled field to true or false. Disabled bool `json:"disabled"` Label *UiText `json:"label,omitempty"` + // MaxLength may contain the input's maximum length. + Maxlength *int64 `json:"maxlength,omitempty"` // The input's element name. Name string `json:"name"` // NodeType represents this node's types. It is a mirror of `node.type` and is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is \"input\". text Text input Input img Image a Anchor script Script NodeType string `json:"node_type"` - // OnClick may contain javascript which should be executed on click. This is primarily used for WebAuthn. + // OnClick may contain javascript which should be executed on click. This is primarily used for WebAuthn. Deprecated: Using OnClick requires the use of eval() which is a security risk. Use OnClickTrigger instead. Onclick *string `json:"onclick,omitempty"` - // OnLoad may contain javascript which should be executed on load. This is primarily used for WebAuthn. + // OnClickTrigger may contain a WebAuthn trigger which should be executed on click. The trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login. oryWebAuthnRegistration WebAuthnTriggersWebAuthnRegistration oryWebAuthnLogin WebAuthnTriggersWebAuthnLogin oryPasskeyLogin WebAuthnTriggersPasskeyLogin oryPasskeyLoginAutocompleteInit WebAuthnTriggersPasskeyLoginAutocompleteInit oryPasskeyRegistration WebAuthnTriggersPasskeyRegistration oryPasskeySettingsRegistration WebAuthnTriggersPasskeySettingsRegistration + OnclickTrigger *string `json:"onclickTrigger,omitempty"` + // OnLoad may contain javascript which should be executed on load. This is primarily used for WebAuthn. Deprecated: Using OnLoad requires the use of eval() which is a security risk. Use OnLoadTrigger instead. Onload *string `json:"onload,omitempty"` + // OnLoadTrigger may contain a WebAuthn trigger which should be executed on load. The trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login. oryWebAuthnRegistration WebAuthnTriggersWebAuthnRegistration oryWebAuthnLogin WebAuthnTriggersWebAuthnLogin oryPasskeyLogin WebAuthnTriggersPasskeyLogin oryPasskeyLoginAutocompleteInit WebAuthnTriggersPasskeyLoginAutocompleteInit oryPasskeyRegistration WebAuthnTriggersPasskeyRegistration oryPasskeySettingsRegistration WebAuthnTriggersPasskeySettingsRegistration + OnloadTrigger *string `json:"onloadTrigger,omitempty"` // The input's pattern. Pattern *string `json:"pattern,omitempty"` // Mark this input field as required. @@ -149,6 +155,38 @@ func (o *UiNodeInputAttributes) SetLabel(v UiText) { o.Label = &v } +// GetMaxlength returns the Maxlength field value if set, zero value otherwise. +func (o *UiNodeInputAttributes) GetMaxlength() int64 { + if o == nil || o.Maxlength == nil { + var ret int64 + return ret + } + return *o.Maxlength +} + +// GetMaxlengthOk returns a tuple with the Maxlength field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UiNodeInputAttributes) GetMaxlengthOk() (*int64, bool) { + if o == nil || o.Maxlength == nil { + return nil, false + } + return o.Maxlength, true +} + +// HasMaxlength returns a boolean if a field has been set. +func (o *UiNodeInputAttributes) HasMaxlength() bool { + if o != nil && o.Maxlength != nil { + return true + } + + return false +} + +// SetMaxlength gets a reference to the given int64 and assigns it to the Maxlength field. +func (o *UiNodeInputAttributes) SetMaxlength(v int64) { + o.Maxlength = &v +} + // GetName returns the Name field value func (o *UiNodeInputAttributes) GetName() string { if o == nil { @@ -229,6 +267,38 @@ func (o *UiNodeInputAttributes) SetOnclick(v string) { o.Onclick = &v } +// GetOnclickTrigger returns the OnclickTrigger field value if set, zero value otherwise. +func (o *UiNodeInputAttributes) GetOnclickTrigger() string { + if o == nil || o.OnclickTrigger == nil { + var ret string + return ret + } + return *o.OnclickTrigger +} + +// GetOnclickTriggerOk returns a tuple with the OnclickTrigger field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UiNodeInputAttributes) GetOnclickTriggerOk() (*string, bool) { + if o == nil || o.OnclickTrigger == nil { + return nil, false + } + return o.OnclickTrigger, true +} + +// HasOnclickTrigger returns a boolean if a field has been set. +func (o *UiNodeInputAttributes) HasOnclickTrigger() bool { + if o != nil && o.OnclickTrigger != nil { + return true + } + + return false +} + +// SetOnclickTrigger gets a reference to the given string and assigns it to the OnclickTrigger field. +func (o *UiNodeInputAttributes) SetOnclickTrigger(v string) { + o.OnclickTrigger = &v +} + // GetOnload returns the Onload field value if set, zero value otherwise. func (o *UiNodeInputAttributes) GetOnload() string { if o == nil || o.Onload == nil { @@ -261,6 +331,38 @@ func (o *UiNodeInputAttributes) SetOnload(v string) { o.Onload = &v } +// GetOnloadTrigger returns the OnloadTrigger field value if set, zero value otherwise. +func (o *UiNodeInputAttributes) GetOnloadTrigger() string { + if o == nil || o.OnloadTrigger == nil { + var ret string + return ret + } + return *o.OnloadTrigger +} + +// GetOnloadTriggerOk returns a tuple with the OnloadTrigger field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UiNodeInputAttributes) GetOnloadTriggerOk() (*string, bool) { + if o == nil || o.OnloadTrigger == nil { + return nil, false + } + return o.OnloadTrigger, true +} + +// HasOnloadTrigger returns a boolean if a field has been set. +func (o *UiNodeInputAttributes) HasOnloadTrigger() bool { + if o != nil && o.OnloadTrigger != nil { + return true + } + + return false +} + +// SetOnloadTrigger gets a reference to the given string and assigns it to the OnloadTrigger field. +func (o *UiNodeInputAttributes) SetOnloadTrigger(v string) { + o.OnloadTrigger = &v +} + // GetPattern returns the Pattern field value if set, zero value otherwise. func (o *UiNodeInputAttributes) GetPattern() string { if o == nil || o.Pattern == nil { @@ -393,6 +495,9 @@ func (o UiNodeInputAttributes) MarshalJSON() ([]byte, error) { if o.Label != nil { toSerialize["label"] = o.Label } + if o.Maxlength != nil { + toSerialize["maxlength"] = o.Maxlength + } if true { toSerialize["name"] = o.Name } @@ -402,9 +507,15 @@ func (o UiNodeInputAttributes) MarshalJSON() ([]byte, error) { if o.Onclick != nil { toSerialize["onclick"] = o.Onclick } + if o.OnclickTrigger != nil { + toSerialize["onclickTrigger"] = o.OnclickTrigger + } if o.Onload != nil { toSerialize["onload"] = o.Onload } + if o.OnloadTrigger != nil { + toSerialize["onloadTrigger"] = o.OnloadTrigger + } if o.Pattern != nil { toSerialize["pattern"] = o.Pattern } diff --git a/internal/client-go/model_update_login_flow_body.go b/internal/client-go/model_update_login_flow_body.go index b8bb05734e3c..f0d79322c54f 100644 --- a/internal/client-go/model_update_login_flow_body.go +++ b/internal/client-go/model_update_login_flow_body.go @@ -18,13 +18,14 @@ import ( // UpdateLoginFlowBody - struct for UpdateLoginFlowBody type UpdateLoginFlowBody struct { - UpdateLoginFlowWithCodeMethod *UpdateLoginFlowWithCodeMethod - UpdateLoginFlowWithLookupSecretMethod *UpdateLoginFlowWithLookupSecretMethod - UpdateLoginFlowWithOidcMethod *UpdateLoginFlowWithOidcMethod - UpdateLoginFlowWithPasskeyMethod *UpdateLoginFlowWithPasskeyMethod - UpdateLoginFlowWithPasswordMethod *UpdateLoginFlowWithPasswordMethod - UpdateLoginFlowWithTotpMethod *UpdateLoginFlowWithTotpMethod - UpdateLoginFlowWithWebAuthnMethod *UpdateLoginFlowWithWebAuthnMethod + UpdateLoginFlowWithCodeMethod *UpdateLoginFlowWithCodeMethod + UpdateLoginFlowWithIdentifierFirstMethod *UpdateLoginFlowWithIdentifierFirstMethod + UpdateLoginFlowWithLookupSecretMethod *UpdateLoginFlowWithLookupSecretMethod + UpdateLoginFlowWithOidcMethod *UpdateLoginFlowWithOidcMethod + UpdateLoginFlowWithPasskeyMethod *UpdateLoginFlowWithPasskeyMethod + UpdateLoginFlowWithPasswordMethod *UpdateLoginFlowWithPasswordMethod + UpdateLoginFlowWithTotpMethod *UpdateLoginFlowWithTotpMethod + UpdateLoginFlowWithWebAuthnMethod *UpdateLoginFlowWithWebAuthnMethod } // UpdateLoginFlowWithCodeMethodAsUpdateLoginFlowBody is a convenience function that returns UpdateLoginFlowWithCodeMethod wrapped in UpdateLoginFlowBody @@ -34,6 +35,13 @@ func UpdateLoginFlowWithCodeMethodAsUpdateLoginFlowBody(v *UpdateLoginFlowWithCo } } +// UpdateLoginFlowWithIdentifierFirstMethodAsUpdateLoginFlowBody is a convenience function that returns UpdateLoginFlowWithIdentifierFirstMethod wrapped in UpdateLoginFlowBody +func UpdateLoginFlowWithIdentifierFirstMethodAsUpdateLoginFlowBody(v *UpdateLoginFlowWithIdentifierFirstMethod) UpdateLoginFlowBody { + return UpdateLoginFlowBody{ + UpdateLoginFlowWithIdentifierFirstMethod: v, + } +} + // UpdateLoginFlowWithLookupSecretMethodAsUpdateLoginFlowBody is a convenience function that returns UpdateLoginFlowWithLookupSecretMethod wrapped in UpdateLoginFlowBody func UpdateLoginFlowWithLookupSecretMethodAsUpdateLoginFlowBody(v *UpdateLoginFlowWithLookupSecretMethod) UpdateLoginFlowBody { return UpdateLoginFlowBody{ @@ -98,6 +106,18 @@ func (dst *UpdateLoginFlowBody) UnmarshalJSON(data []byte) error { } } + // check if the discriminator value is 'identifier_first' + if jsonDict["method"] == "identifier_first" { + // try to unmarshal JSON data into UpdateLoginFlowWithIdentifierFirstMethod + err = json.Unmarshal(data, &dst.UpdateLoginFlowWithIdentifierFirstMethod) + if err == nil { + return nil // data stored in dst.UpdateLoginFlowWithIdentifierFirstMethod, return on the first match + } else { + dst.UpdateLoginFlowWithIdentifierFirstMethod = nil + return fmt.Errorf("Failed to unmarshal UpdateLoginFlowBody as UpdateLoginFlowWithIdentifierFirstMethod: %s", err.Error()) + } + } + // check if the discriminator value is 'lookup_secret' if jsonDict["method"] == "lookup_secret" { // try to unmarshal JSON data into UpdateLoginFlowWithLookupSecretMethod @@ -182,6 +202,18 @@ func (dst *UpdateLoginFlowBody) UnmarshalJSON(data []byte) error { } } + // check if the discriminator value is 'updateLoginFlowWithIdentifierFirstMethod' + if jsonDict["method"] == "updateLoginFlowWithIdentifierFirstMethod" { + // try to unmarshal JSON data into UpdateLoginFlowWithIdentifierFirstMethod + err = json.Unmarshal(data, &dst.UpdateLoginFlowWithIdentifierFirstMethod) + if err == nil { + return nil // data stored in dst.UpdateLoginFlowWithIdentifierFirstMethod, return on the first match + } else { + dst.UpdateLoginFlowWithIdentifierFirstMethod = nil + return fmt.Errorf("Failed to unmarshal UpdateLoginFlowBody as UpdateLoginFlowWithIdentifierFirstMethod: %s", err.Error()) + } + } + // check if the discriminator value is 'updateLoginFlowWithLookupSecretMethod' if jsonDict["method"] == "updateLoginFlowWithLookupSecretMethod" { // try to unmarshal JSON data into UpdateLoginFlowWithLookupSecretMethod @@ -263,6 +295,10 @@ func (src UpdateLoginFlowBody) MarshalJSON() ([]byte, error) { return json.Marshal(&src.UpdateLoginFlowWithCodeMethod) } + if src.UpdateLoginFlowWithIdentifierFirstMethod != nil { + return json.Marshal(&src.UpdateLoginFlowWithIdentifierFirstMethod) + } + if src.UpdateLoginFlowWithLookupSecretMethod != nil { return json.Marshal(&src.UpdateLoginFlowWithLookupSecretMethod) } @@ -299,6 +335,10 @@ func (obj *UpdateLoginFlowBody) GetActualInstance() interface{} { return obj.UpdateLoginFlowWithCodeMethod } + if obj.UpdateLoginFlowWithIdentifierFirstMethod != nil { + return obj.UpdateLoginFlowWithIdentifierFirstMethod + } + if obj.UpdateLoginFlowWithLookupSecretMethod != nil { return obj.UpdateLoginFlowWithLookupSecretMethod } diff --git a/internal/client-go/model_update_login_flow_with_identifier_first_method.go b/internal/client-go/model_update_login_flow_with_identifier_first_method.go new file mode 100644 index 000000000000..70cf8002990d --- /dev/null +++ b/internal/client-go/model_update_login_flow_with_identifier_first_method.go @@ -0,0 +1,212 @@ +/* + * Ory Identities API + * + * This is the API specification for Ory Identities with features such as registration, login, recovery, account verification, profile settings, password reset, identity management, session management, email and sms delivery, and more. + * + * API version: + * Contact: office@ory.sh + */ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package client + +import ( + "encoding/json" +) + +// UpdateLoginFlowWithIdentifierFirstMethod Update Login Flow with Multi-Step Method +type UpdateLoginFlowWithIdentifierFirstMethod struct { + // Sending the anti-csrf token is only required for browser login flows. + CsrfToken *string `json:"csrf_token,omitempty"` + // Identifier is the email or username of the user trying to log in. + Identifier string `json:"identifier"` + // Method should be set to \"password\" when logging in using the identifier and password strategy. + Method string `json:"method"` + // Transient data to pass along to any webhooks + TransientPayload map[string]interface{} `json:"transient_payload,omitempty"` +} + +// NewUpdateLoginFlowWithIdentifierFirstMethod instantiates a new UpdateLoginFlowWithIdentifierFirstMethod object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUpdateLoginFlowWithIdentifierFirstMethod(identifier string, method string) *UpdateLoginFlowWithIdentifierFirstMethod { + this := UpdateLoginFlowWithIdentifierFirstMethod{} + this.Identifier = identifier + this.Method = method + return &this +} + +// NewUpdateLoginFlowWithIdentifierFirstMethodWithDefaults instantiates a new UpdateLoginFlowWithIdentifierFirstMethod object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUpdateLoginFlowWithIdentifierFirstMethodWithDefaults() *UpdateLoginFlowWithIdentifierFirstMethod { + this := UpdateLoginFlowWithIdentifierFirstMethod{} + return &this +} + +// GetCsrfToken returns the CsrfToken field value if set, zero value otherwise. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetCsrfToken() string { + if o == nil || o.CsrfToken == nil { + var ret string + return ret + } + return *o.CsrfToken +} + +// GetCsrfTokenOk returns a tuple with the CsrfToken field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetCsrfTokenOk() (*string, bool) { + if o == nil || o.CsrfToken == nil { + return nil, false + } + return o.CsrfToken, true +} + +// HasCsrfToken returns a boolean if a field has been set. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) HasCsrfToken() bool { + if o != nil && o.CsrfToken != nil { + return true + } + + return false +} + +// SetCsrfToken gets a reference to the given string and assigns it to the CsrfToken field. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) SetCsrfToken(v string) { + o.CsrfToken = &v +} + +// GetIdentifier returns the Identifier field value +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetIdentifier() string { + if o == nil { + var ret string + return ret + } + + return o.Identifier +} + +// GetIdentifierOk returns a tuple with the Identifier field value +// and a boolean to check if the value has been set. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetIdentifierOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Identifier, true +} + +// SetIdentifier sets field value +func (o *UpdateLoginFlowWithIdentifierFirstMethod) SetIdentifier(v string) { + o.Identifier = v +} + +// GetMethod returns the Method field value +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetMethod() string { + if o == nil { + var ret string + return ret + } + + return o.Method +} + +// GetMethodOk returns a tuple with the Method field value +// and a boolean to check if the value has been set. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetMethodOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Method, true +} + +// SetMethod sets field value +func (o *UpdateLoginFlowWithIdentifierFirstMethod) SetMethod(v string) { + o.Method = v +} + +// GetTransientPayload returns the TransientPayload field value if set, zero value otherwise. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetTransientPayload() map[string]interface{} { + if o == nil || o.TransientPayload == nil { + var ret map[string]interface{} + return ret + } + return o.TransientPayload +} + +// GetTransientPayloadOk returns a tuple with the TransientPayload field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetTransientPayloadOk() (map[string]interface{}, bool) { + if o == nil || o.TransientPayload == nil { + return nil, false + } + return o.TransientPayload, true +} + +// HasTransientPayload returns a boolean if a field has been set. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) HasTransientPayload() bool { + if o != nil && o.TransientPayload != nil { + return true + } + + return false +} + +// SetTransientPayload gets a reference to the given map[string]interface{} and assigns it to the TransientPayload field. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) SetTransientPayload(v map[string]interface{}) { + o.TransientPayload = v +} + +func (o UpdateLoginFlowWithIdentifierFirstMethod) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if o.CsrfToken != nil { + toSerialize["csrf_token"] = o.CsrfToken + } + if true { + toSerialize["identifier"] = o.Identifier + } + if true { + toSerialize["method"] = o.Method + } + if o.TransientPayload != nil { + toSerialize["transient_payload"] = o.TransientPayload + } + return json.Marshal(toSerialize) +} + +type NullableUpdateLoginFlowWithIdentifierFirstMethod struct { + value *UpdateLoginFlowWithIdentifierFirstMethod + isSet bool +} + +func (v NullableUpdateLoginFlowWithIdentifierFirstMethod) Get() *UpdateLoginFlowWithIdentifierFirstMethod { + return v.value +} + +func (v *NullableUpdateLoginFlowWithIdentifierFirstMethod) Set(val *UpdateLoginFlowWithIdentifierFirstMethod) { + v.value = val + v.isSet = true +} + +func (v NullableUpdateLoginFlowWithIdentifierFirstMethod) IsSet() bool { + return v.isSet +} + +func (v *NullableUpdateLoginFlowWithIdentifierFirstMethod) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUpdateLoginFlowWithIdentifierFirstMethod(val *UpdateLoginFlowWithIdentifierFirstMethod) *NullableUpdateLoginFlowWithIdentifierFirstMethod { + return &NullableUpdateLoginFlowWithIdentifierFirstMethod{value: val, isSet: true} +} + +func (v NullableUpdateLoginFlowWithIdentifierFirstMethod) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUpdateLoginFlowWithIdentifierFirstMethod) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/internal/client-go/model_update_registration_flow_body.go b/internal/client-go/model_update_registration_flow_body.go index 64374c620f8f..82a578cfc4d3 100644 --- a/internal/client-go/model_update_registration_flow_body.go +++ b/internal/client-go/model_update_registration_flow_body.go @@ -22,6 +22,7 @@ type UpdateRegistrationFlowBody struct { UpdateRegistrationFlowWithOidcMethod *UpdateRegistrationFlowWithOidcMethod UpdateRegistrationFlowWithPasskeyMethod *UpdateRegistrationFlowWithPasskeyMethod UpdateRegistrationFlowWithPasswordMethod *UpdateRegistrationFlowWithPasswordMethod + UpdateRegistrationFlowWithProfileMethod *UpdateRegistrationFlowWithProfileMethod UpdateRegistrationFlowWithWebAuthnMethod *UpdateRegistrationFlowWithWebAuthnMethod } @@ -53,6 +54,13 @@ func UpdateRegistrationFlowWithPasswordMethodAsUpdateRegistrationFlowBody(v *Upd } } +// UpdateRegistrationFlowWithProfileMethodAsUpdateRegistrationFlowBody is a convenience function that returns UpdateRegistrationFlowWithProfileMethod wrapped in UpdateRegistrationFlowBody +func UpdateRegistrationFlowWithProfileMethodAsUpdateRegistrationFlowBody(v *UpdateRegistrationFlowWithProfileMethod) UpdateRegistrationFlowBody { + return UpdateRegistrationFlowBody{ + UpdateRegistrationFlowWithProfileMethod: v, + } +} + // UpdateRegistrationFlowWithWebAuthnMethodAsUpdateRegistrationFlowBody is a convenience function that returns UpdateRegistrationFlowWithWebAuthnMethod wrapped in UpdateRegistrationFlowBody func UpdateRegistrationFlowWithWebAuthnMethodAsUpdateRegistrationFlowBody(v *UpdateRegistrationFlowWithWebAuthnMethod) UpdateRegistrationFlowBody { return UpdateRegistrationFlowBody{ @@ -94,8 +102,8 @@ func (dst *UpdateRegistrationFlowBody) UnmarshalJSON(data []byte) error { } } - // check if the discriminator value is 'passKey' - if jsonDict["method"] == "passKey" { + // check if the discriminator value is 'passkey' + if jsonDict["method"] == "passkey" { // try to unmarshal JSON data into UpdateRegistrationFlowWithPasskeyMethod err = json.Unmarshal(data, &dst.UpdateRegistrationFlowWithPasskeyMethod) if err == nil { @@ -118,6 +126,18 @@ func (dst *UpdateRegistrationFlowBody) UnmarshalJSON(data []byte) error { } } + // check if the discriminator value is 'profile' + if jsonDict["method"] == "profile" { + // try to unmarshal JSON data into UpdateRegistrationFlowWithProfileMethod + err = json.Unmarshal(data, &dst.UpdateRegistrationFlowWithProfileMethod) + if err == nil { + return nil // data stored in dst.UpdateRegistrationFlowWithProfileMethod, return on the first match + } else { + dst.UpdateRegistrationFlowWithProfileMethod = nil + return fmt.Errorf("Failed to unmarshal UpdateRegistrationFlowBody as UpdateRegistrationFlowWithProfileMethod: %s", err.Error()) + } + } + // check if the discriminator value is 'webauthn' if jsonDict["method"] == "webauthn" { // try to unmarshal JSON data into UpdateRegistrationFlowWithWebAuthnMethod @@ -178,6 +198,18 @@ func (dst *UpdateRegistrationFlowBody) UnmarshalJSON(data []byte) error { } } + // check if the discriminator value is 'updateRegistrationFlowWithProfileMethod' + if jsonDict["method"] == "updateRegistrationFlowWithProfileMethod" { + // try to unmarshal JSON data into UpdateRegistrationFlowWithProfileMethod + err = json.Unmarshal(data, &dst.UpdateRegistrationFlowWithProfileMethod) + if err == nil { + return nil // data stored in dst.UpdateRegistrationFlowWithProfileMethod, return on the first match + } else { + dst.UpdateRegistrationFlowWithProfileMethod = nil + return fmt.Errorf("Failed to unmarshal UpdateRegistrationFlowBody as UpdateRegistrationFlowWithProfileMethod: %s", err.Error()) + } + } + // check if the discriminator value is 'updateRegistrationFlowWithWebAuthnMethod' if jsonDict["method"] == "updateRegistrationFlowWithWebAuthnMethod" { // try to unmarshal JSON data into UpdateRegistrationFlowWithWebAuthnMethod @@ -211,6 +243,10 @@ func (src UpdateRegistrationFlowBody) MarshalJSON() ([]byte, error) { return json.Marshal(&src.UpdateRegistrationFlowWithPasswordMethod) } + if src.UpdateRegistrationFlowWithProfileMethod != nil { + return json.Marshal(&src.UpdateRegistrationFlowWithProfileMethod) + } + if src.UpdateRegistrationFlowWithWebAuthnMethod != nil { return json.Marshal(&src.UpdateRegistrationFlowWithWebAuthnMethod) } @@ -239,6 +275,10 @@ func (obj *UpdateRegistrationFlowBody) GetActualInstance() interface{} { return obj.UpdateRegistrationFlowWithPasswordMethod } + if obj.UpdateRegistrationFlowWithProfileMethod != nil { + return obj.UpdateRegistrationFlowWithProfileMethod + } + if obj.UpdateRegistrationFlowWithWebAuthnMethod != nil { return obj.UpdateRegistrationFlowWithWebAuthnMethod } diff --git a/internal/driver.go b/internal/driver.go index b95b2dc7c0a9..521be82264d1 100644 --- a/internal/driver.go +++ b/internal/driver.go @@ -53,6 +53,7 @@ func NewConfigurationWithDefaults(t testing.TB, opts ...configx.OptionModifier) config.ViperKeyCourierSMTPURL: "smtp://foo:bar@baz.com/", config.ViperKeySelfServiceBrowserDefaultReturnTo: "https://www.ory.sh/redirect-not-set", config.ViperKeySecretsCipher: []string{"secret-thirty-two-character-long"}, + config.ViperKeySelfServiceLoginFlowStyle: "unified", }), configx.SkipValidation(), }, opts...) diff --git a/internal/httpclient/.openapi-generator/FILES b/internal/httpclient/.openapi-generator/FILES index fdf34c5e1507..c573997505d8 100644 --- a/internal/httpclient/.openapi-generator/FILES +++ b/internal/httpclient/.openapi-generator/FILES @@ -15,6 +15,7 @@ docs/ConsistencyRequestParameters.md docs/ContinueWith.md docs/ContinueWithRecoveryUi.md docs/ContinueWithRecoveryUiFlow.md +docs/ContinueWithRedirectBrowserTo.md docs/ContinueWithSetOrySessionToken.md docs/ContinueWithSettingsUi.md docs/ContinueWithSettingsUiFlow.md @@ -99,6 +100,7 @@ docs/UiText.md docs/UpdateIdentityBody.md docs/UpdateLoginFlowBody.md docs/UpdateLoginFlowWithCodeMethod.md +docs/UpdateLoginFlowWithIdentifierFirstMethod.md docs/UpdateLoginFlowWithLookupSecretMethod.md docs/UpdateLoginFlowWithOidcMethod.md docs/UpdateLoginFlowWithPasskeyMethod.md @@ -139,6 +141,7 @@ model_consistency_request_parameters.go model_continue_with.go model_continue_with_recovery_ui.go model_continue_with_recovery_ui_flow.go +model_continue_with_redirect_browser_to.go model_continue_with_set_ory_session_token.go model_continue_with_settings_ui.go model_continue_with_settings_ui_flow.go @@ -219,6 +222,7 @@ model_ui_text.go model_update_identity_body.go model_update_login_flow_body.go model_update_login_flow_with_code_method.go +model_update_login_flow_with_identifier_first_method.go model_update_login_flow_with_lookup_secret_method.go model_update_login_flow_with_oidc_method.go model_update_login_flow_with_passkey_method.go diff --git a/internal/httpclient/README.md b/internal/httpclient/README.md index 04dd61ab7d1e..85af88a0d079 100644 --- a/internal/httpclient/README.md +++ b/internal/httpclient/README.md @@ -142,6 +142,7 @@ Class | Method | HTTP request | Description - [ContinueWith](docs/ContinueWith.md) - [ContinueWithRecoveryUi](docs/ContinueWithRecoveryUi.md) - [ContinueWithRecoveryUiFlow](docs/ContinueWithRecoveryUiFlow.md) + - [ContinueWithRedirectBrowserTo](docs/ContinueWithRedirectBrowserTo.md) - [ContinueWithSetOrySessionToken](docs/ContinueWithSetOrySessionToken.md) - [ContinueWithSettingsUi](docs/ContinueWithSettingsUi.md) - [ContinueWithSettingsUiFlow](docs/ContinueWithSettingsUiFlow.md) @@ -222,6 +223,7 @@ Class | Method | HTTP request | Description - [UpdateIdentityBody](docs/UpdateIdentityBody.md) - [UpdateLoginFlowBody](docs/UpdateLoginFlowBody.md) - [UpdateLoginFlowWithCodeMethod](docs/UpdateLoginFlowWithCodeMethod.md) + - [UpdateLoginFlowWithIdentifierFirstMethod](docs/UpdateLoginFlowWithIdentifierFirstMethod.md) - [UpdateLoginFlowWithLookupSecretMethod](docs/UpdateLoginFlowWithLookupSecretMethod.md) - [UpdateLoginFlowWithOidcMethod](docs/UpdateLoginFlowWithOidcMethod.md) - [UpdateLoginFlowWithPasskeyMethod](docs/UpdateLoginFlowWithPasskeyMethod.md) diff --git a/internal/httpclient/model_continue_with.go b/internal/httpclient/model_continue_with.go index 9e97dbf479e7..6fb1056836e6 100644 --- a/internal/httpclient/model_continue_with.go +++ b/internal/httpclient/model_continue_with.go @@ -19,6 +19,7 @@ import ( // ContinueWith - struct for ContinueWith type ContinueWith struct { ContinueWithRecoveryUi *ContinueWithRecoveryUi + ContinueWithRedirectBrowserTo *ContinueWithRedirectBrowserTo ContinueWithSetOrySessionToken *ContinueWithSetOrySessionToken ContinueWithSettingsUi *ContinueWithSettingsUi ContinueWithVerificationUi *ContinueWithVerificationUi @@ -31,6 +32,13 @@ func ContinueWithRecoveryUiAsContinueWith(v *ContinueWithRecoveryUi) ContinueWit } } +// ContinueWithRedirectBrowserToAsContinueWith is a convenience function that returns ContinueWithRedirectBrowserTo wrapped in ContinueWith +func ContinueWithRedirectBrowserToAsContinueWith(v *ContinueWithRedirectBrowserTo) ContinueWith { + return ContinueWith{ + ContinueWithRedirectBrowserTo: v, + } +} + // ContinueWithSetOrySessionTokenAsContinueWith is a convenience function that returns ContinueWithSetOrySessionToken wrapped in ContinueWith func ContinueWithSetOrySessionTokenAsContinueWith(v *ContinueWithSetOrySessionToken) ContinueWith { return ContinueWith{ @@ -62,6 +70,18 @@ func (dst *ContinueWith) UnmarshalJSON(data []byte) error { return fmt.Errorf("Failed to unmarshal JSON into map for the discrimintor lookup.") } + // check if the discriminator value is 'redirect_browser_to' + if jsonDict["action"] == "redirect_browser_to" { + // try to unmarshal JSON data into ContinueWithRedirectBrowserTo + err = json.Unmarshal(data, &dst.ContinueWithRedirectBrowserTo) + if err == nil { + return nil // data stored in dst.ContinueWithRedirectBrowserTo, return on the first match + } else { + dst.ContinueWithRedirectBrowserTo = nil + return fmt.Errorf("Failed to unmarshal ContinueWith as ContinueWithRedirectBrowserTo: %s", err.Error()) + } + } + // check if the discriminator value is 'set_ory_session_token' if jsonDict["action"] == "set_ory_session_token" { // try to unmarshal JSON data into ContinueWithSetOrySessionToken @@ -122,6 +142,18 @@ func (dst *ContinueWith) UnmarshalJSON(data []byte) error { } } + // check if the discriminator value is 'continueWithRedirectBrowserTo' + if jsonDict["action"] == "continueWithRedirectBrowserTo" { + // try to unmarshal JSON data into ContinueWithRedirectBrowserTo + err = json.Unmarshal(data, &dst.ContinueWithRedirectBrowserTo) + if err == nil { + return nil // data stored in dst.ContinueWithRedirectBrowserTo, return on the first match + } else { + dst.ContinueWithRedirectBrowserTo = nil + return fmt.Errorf("Failed to unmarshal ContinueWith as ContinueWithRedirectBrowserTo: %s", err.Error()) + } + } + // check if the discriminator value is 'continueWithSetOrySessionToken' if jsonDict["action"] == "continueWithSetOrySessionToken" { // try to unmarshal JSON data into ContinueWithSetOrySessionToken @@ -167,6 +199,10 @@ func (src ContinueWith) MarshalJSON() ([]byte, error) { return json.Marshal(&src.ContinueWithRecoveryUi) } + if src.ContinueWithRedirectBrowserTo != nil { + return json.Marshal(&src.ContinueWithRedirectBrowserTo) + } + if src.ContinueWithSetOrySessionToken != nil { return json.Marshal(&src.ContinueWithSetOrySessionToken) } @@ -191,6 +227,10 @@ func (obj *ContinueWith) GetActualInstance() interface{} { return obj.ContinueWithRecoveryUi } + if obj.ContinueWithRedirectBrowserTo != nil { + return obj.ContinueWithRedirectBrowserTo + } + if obj.ContinueWithSetOrySessionToken != nil { return obj.ContinueWithSetOrySessionToken } diff --git a/internal/httpclient/model_continue_with_recovery_ui_flow.go b/internal/httpclient/model_continue_with_recovery_ui_flow.go index 3fde7e717ef2..251725a73c3b 100644 --- a/internal/httpclient/model_continue_with_recovery_ui_flow.go +++ b/internal/httpclient/model_continue_with_recovery_ui_flow.go @@ -19,7 +19,7 @@ import ( type ContinueWithRecoveryUiFlow struct { // The ID of the recovery flow Id string `json:"id"` - // The URL of the recovery flow + // The URL of the recovery flow If this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows. Url *string `json:"url,omitempty"` } diff --git a/internal/httpclient/model_continue_with_redirect_browser_to.go b/internal/httpclient/model_continue_with_redirect_browser_to.go new file mode 100644 index 000000000000..20c3e4f3c562 --- /dev/null +++ b/internal/httpclient/model_continue_with_redirect_browser_to.go @@ -0,0 +1,138 @@ +/* + * Ory Identities API + * + * This is the API specification for Ory Identities with features such as registration, login, recovery, account verification, profile settings, password reset, identity management, session management, email and sms delivery, and more. + * + * API version: + * Contact: office@ory.sh + */ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package client + +import ( + "encoding/json" +) + +// ContinueWithRedirectBrowserTo Indicates, that the UI flow could be continued by showing a recovery ui +type ContinueWithRedirectBrowserTo struct { + // Action will always be `redirect_browser_to` redirect_browser_to ContinueWithActionRedirectBrowserToString + Action string `json:"action"` + // The URL to redirect the browser to + RedirectBrowserTo string `json:"redirect_browser_to"` +} + +// NewContinueWithRedirectBrowserTo instantiates a new ContinueWithRedirectBrowserTo object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewContinueWithRedirectBrowserTo(action string, redirectBrowserTo string) *ContinueWithRedirectBrowserTo { + this := ContinueWithRedirectBrowserTo{} + this.Action = action + this.RedirectBrowserTo = redirectBrowserTo + return &this +} + +// NewContinueWithRedirectBrowserToWithDefaults instantiates a new ContinueWithRedirectBrowserTo object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewContinueWithRedirectBrowserToWithDefaults() *ContinueWithRedirectBrowserTo { + this := ContinueWithRedirectBrowserTo{} + return &this +} + +// GetAction returns the Action field value +func (o *ContinueWithRedirectBrowserTo) GetAction() string { + if o == nil { + var ret string + return ret + } + + return o.Action +} + +// GetActionOk returns a tuple with the Action field value +// and a boolean to check if the value has been set. +func (o *ContinueWithRedirectBrowserTo) GetActionOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Action, true +} + +// SetAction sets field value +func (o *ContinueWithRedirectBrowserTo) SetAction(v string) { + o.Action = v +} + +// GetRedirectBrowserTo returns the RedirectBrowserTo field value +func (o *ContinueWithRedirectBrowserTo) GetRedirectBrowserTo() string { + if o == nil { + var ret string + return ret + } + + return o.RedirectBrowserTo +} + +// GetRedirectBrowserToOk returns a tuple with the RedirectBrowserTo field value +// and a boolean to check if the value has been set. +func (o *ContinueWithRedirectBrowserTo) GetRedirectBrowserToOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.RedirectBrowserTo, true +} + +// SetRedirectBrowserTo sets field value +func (o *ContinueWithRedirectBrowserTo) SetRedirectBrowserTo(v string) { + o.RedirectBrowserTo = v +} + +func (o ContinueWithRedirectBrowserTo) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if true { + toSerialize["action"] = o.Action + } + if true { + toSerialize["redirect_browser_to"] = o.RedirectBrowserTo + } + return json.Marshal(toSerialize) +} + +type NullableContinueWithRedirectBrowserTo struct { + value *ContinueWithRedirectBrowserTo + isSet bool +} + +func (v NullableContinueWithRedirectBrowserTo) Get() *ContinueWithRedirectBrowserTo { + return v.value +} + +func (v *NullableContinueWithRedirectBrowserTo) Set(val *ContinueWithRedirectBrowserTo) { + v.value = val + v.isSet = true +} + +func (v NullableContinueWithRedirectBrowserTo) IsSet() bool { + return v.isSet +} + +func (v *NullableContinueWithRedirectBrowserTo) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableContinueWithRedirectBrowserTo(val *ContinueWithRedirectBrowserTo) *NullableContinueWithRedirectBrowserTo { + return &NullableContinueWithRedirectBrowserTo{value: val, isSet: true} +} + +func (v NullableContinueWithRedirectBrowserTo) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableContinueWithRedirectBrowserTo) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/internal/httpclient/model_continue_with_settings_ui_flow.go b/internal/httpclient/model_continue_with_settings_ui_flow.go index 4ccaf74ef1b8..d6e9b9441f99 100644 --- a/internal/httpclient/model_continue_with_settings_ui_flow.go +++ b/internal/httpclient/model_continue_with_settings_ui_flow.go @@ -19,6 +19,8 @@ import ( type ContinueWithSettingsUiFlow struct { // The ID of the settings flow Id string `json:"id"` + // The URL of the settings flow If this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows. + Url *string `json:"url,omitempty"` } // NewContinueWithSettingsUiFlow instantiates a new ContinueWithSettingsUiFlow object @@ -63,11 +65,46 @@ func (o *ContinueWithSettingsUiFlow) SetId(v string) { o.Id = v } +// GetUrl returns the Url field value if set, zero value otherwise. +func (o *ContinueWithSettingsUiFlow) GetUrl() string { + if o == nil || o.Url == nil { + var ret string + return ret + } + return *o.Url +} + +// GetUrlOk returns a tuple with the Url field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ContinueWithSettingsUiFlow) GetUrlOk() (*string, bool) { + if o == nil || o.Url == nil { + return nil, false + } + return o.Url, true +} + +// HasUrl returns a boolean if a field has been set. +func (o *ContinueWithSettingsUiFlow) HasUrl() bool { + if o != nil && o.Url != nil { + return true + } + + return false +} + +// SetUrl gets a reference to the given string and assigns it to the Url field. +func (o *ContinueWithSettingsUiFlow) SetUrl(v string) { + o.Url = &v +} + func (o ContinueWithSettingsUiFlow) MarshalJSON() ([]byte, error) { toSerialize := map[string]interface{}{} if true { toSerialize["id"] = o.Id } + if o.Url != nil { + toSerialize["url"] = o.Url + } return json.Marshal(toSerialize) } diff --git a/internal/httpclient/model_continue_with_verification_ui_flow.go b/internal/httpclient/model_continue_with_verification_ui_flow.go index 8fdd4609cf93..3c73a0761339 100644 --- a/internal/httpclient/model_continue_with_verification_ui_flow.go +++ b/internal/httpclient/model_continue_with_verification_ui_flow.go @@ -19,7 +19,7 @@ import ( type ContinueWithVerificationUiFlow struct { // The ID of the verification flow Id string `json:"id"` - // The URL of the verification flow + // The URL of the verification flow If this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows. Url *string `json:"url,omitempty"` // The address that should be verified in this flow VerifiableAddress string `json:"verifiable_address"` diff --git a/internal/httpclient/model_ui_node.go b/internal/httpclient/model_ui_node.go index e73f3c5e37d8..3582d9e85f67 100644 --- a/internal/httpclient/model_ui_node.go +++ b/internal/httpclient/model_ui_node.go @@ -18,7 +18,7 @@ import ( // UiNode Nodes are represented as HTML elements or their native UI equivalents. For example, a node can be an `` tag, or an `` but also `some plain text`. type UiNode struct { Attributes UiNodeAttributes `json:"attributes"` - // Group specifies which group (e.g. password authenticator) this node belongs to. default DefaultGroup password PasswordGroup oidc OpenIDConnectGroup profile ProfileGroup link LinkGroup code CodeGroup totp TOTPGroup lookup_secret LookupGroup webauthn WebAuthnGroup passkey PasskeyGroup + // Group specifies which group (e.g. password authenticator) this node belongs to. default DefaultGroup password PasswordGroup oidc OpenIDConnectGroup profile ProfileGroup link LinkGroup code CodeGroup totp TOTPGroup lookup_secret LookupGroup webauthn WebAuthnGroup passkey PasskeyGroup identifier_first IdentifierFirstGroup Group string `json:"group"` Messages []UiText `json:"messages"` Meta UiNodeMeta `json:"meta"` diff --git a/internal/httpclient/model_ui_node_input_attributes.go b/internal/httpclient/model_ui_node_input_attributes.go index b373dda7ccfd..f8deff5d5417 100644 --- a/internal/httpclient/model_ui_node_input_attributes.go +++ b/internal/httpclient/model_ui_node_input_attributes.go @@ -22,14 +22,20 @@ type UiNodeInputAttributes struct { // Sets the input's disabled field to true or false. Disabled bool `json:"disabled"` Label *UiText `json:"label,omitempty"` + // MaxLength may contain the input's maximum length. + Maxlength *int64 `json:"maxlength,omitempty"` // The input's element name. Name string `json:"name"` // NodeType represents this node's types. It is a mirror of `node.type` and is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is \"input\". text Text input Input img Image a Anchor script Script NodeType string `json:"node_type"` - // OnClick may contain javascript which should be executed on click. This is primarily used for WebAuthn. + // OnClick may contain javascript which should be executed on click. This is primarily used for WebAuthn. Deprecated: Using OnClick requires the use of eval() which is a security risk. Use OnClickTrigger instead. Onclick *string `json:"onclick,omitempty"` - // OnLoad may contain javascript which should be executed on load. This is primarily used for WebAuthn. + // OnClickTrigger may contain a WebAuthn trigger which should be executed on click. The trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login. oryWebAuthnRegistration WebAuthnTriggersWebAuthnRegistration oryWebAuthnLogin WebAuthnTriggersWebAuthnLogin oryPasskeyLogin WebAuthnTriggersPasskeyLogin oryPasskeyLoginAutocompleteInit WebAuthnTriggersPasskeyLoginAutocompleteInit oryPasskeyRegistration WebAuthnTriggersPasskeyRegistration oryPasskeySettingsRegistration WebAuthnTriggersPasskeySettingsRegistration + OnclickTrigger *string `json:"onclickTrigger,omitempty"` + // OnLoad may contain javascript which should be executed on load. This is primarily used for WebAuthn. Deprecated: Using OnLoad requires the use of eval() which is a security risk. Use OnLoadTrigger instead. Onload *string `json:"onload,omitempty"` + // OnLoadTrigger may contain a WebAuthn trigger which should be executed on load. The trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login. oryWebAuthnRegistration WebAuthnTriggersWebAuthnRegistration oryWebAuthnLogin WebAuthnTriggersWebAuthnLogin oryPasskeyLogin WebAuthnTriggersPasskeyLogin oryPasskeyLoginAutocompleteInit WebAuthnTriggersPasskeyLoginAutocompleteInit oryPasskeyRegistration WebAuthnTriggersPasskeyRegistration oryPasskeySettingsRegistration WebAuthnTriggersPasskeySettingsRegistration + OnloadTrigger *string `json:"onloadTrigger,omitempty"` // The input's pattern. Pattern *string `json:"pattern,omitempty"` // Mark this input field as required. @@ -149,6 +155,38 @@ func (o *UiNodeInputAttributes) SetLabel(v UiText) { o.Label = &v } +// GetMaxlength returns the Maxlength field value if set, zero value otherwise. +func (o *UiNodeInputAttributes) GetMaxlength() int64 { + if o == nil || o.Maxlength == nil { + var ret int64 + return ret + } + return *o.Maxlength +} + +// GetMaxlengthOk returns a tuple with the Maxlength field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UiNodeInputAttributes) GetMaxlengthOk() (*int64, bool) { + if o == nil || o.Maxlength == nil { + return nil, false + } + return o.Maxlength, true +} + +// HasMaxlength returns a boolean if a field has been set. +func (o *UiNodeInputAttributes) HasMaxlength() bool { + if o != nil && o.Maxlength != nil { + return true + } + + return false +} + +// SetMaxlength gets a reference to the given int64 and assigns it to the Maxlength field. +func (o *UiNodeInputAttributes) SetMaxlength(v int64) { + o.Maxlength = &v +} + // GetName returns the Name field value func (o *UiNodeInputAttributes) GetName() string { if o == nil { @@ -229,6 +267,38 @@ func (o *UiNodeInputAttributes) SetOnclick(v string) { o.Onclick = &v } +// GetOnclickTrigger returns the OnclickTrigger field value if set, zero value otherwise. +func (o *UiNodeInputAttributes) GetOnclickTrigger() string { + if o == nil || o.OnclickTrigger == nil { + var ret string + return ret + } + return *o.OnclickTrigger +} + +// GetOnclickTriggerOk returns a tuple with the OnclickTrigger field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UiNodeInputAttributes) GetOnclickTriggerOk() (*string, bool) { + if o == nil || o.OnclickTrigger == nil { + return nil, false + } + return o.OnclickTrigger, true +} + +// HasOnclickTrigger returns a boolean if a field has been set. +func (o *UiNodeInputAttributes) HasOnclickTrigger() bool { + if o != nil && o.OnclickTrigger != nil { + return true + } + + return false +} + +// SetOnclickTrigger gets a reference to the given string and assigns it to the OnclickTrigger field. +func (o *UiNodeInputAttributes) SetOnclickTrigger(v string) { + o.OnclickTrigger = &v +} + // GetOnload returns the Onload field value if set, zero value otherwise. func (o *UiNodeInputAttributes) GetOnload() string { if o == nil || o.Onload == nil { @@ -261,6 +331,38 @@ func (o *UiNodeInputAttributes) SetOnload(v string) { o.Onload = &v } +// GetOnloadTrigger returns the OnloadTrigger field value if set, zero value otherwise. +func (o *UiNodeInputAttributes) GetOnloadTrigger() string { + if o == nil || o.OnloadTrigger == nil { + var ret string + return ret + } + return *o.OnloadTrigger +} + +// GetOnloadTriggerOk returns a tuple with the OnloadTrigger field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UiNodeInputAttributes) GetOnloadTriggerOk() (*string, bool) { + if o == nil || o.OnloadTrigger == nil { + return nil, false + } + return o.OnloadTrigger, true +} + +// HasOnloadTrigger returns a boolean if a field has been set. +func (o *UiNodeInputAttributes) HasOnloadTrigger() bool { + if o != nil && o.OnloadTrigger != nil { + return true + } + + return false +} + +// SetOnloadTrigger gets a reference to the given string and assigns it to the OnloadTrigger field. +func (o *UiNodeInputAttributes) SetOnloadTrigger(v string) { + o.OnloadTrigger = &v +} + // GetPattern returns the Pattern field value if set, zero value otherwise. func (o *UiNodeInputAttributes) GetPattern() string { if o == nil || o.Pattern == nil { @@ -393,6 +495,9 @@ func (o UiNodeInputAttributes) MarshalJSON() ([]byte, error) { if o.Label != nil { toSerialize["label"] = o.Label } + if o.Maxlength != nil { + toSerialize["maxlength"] = o.Maxlength + } if true { toSerialize["name"] = o.Name } @@ -402,9 +507,15 @@ func (o UiNodeInputAttributes) MarshalJSON() ([]byte, error) { if o.Onclick != nil { toSerialize["onclick"] = o.Onclick } + if o.OnclickTrigger != nil { + toSerialize["onclickTrigger"] = o.OnclickTrigger + } if o.Onload != nil { toSerialize["onload"] = o.Onload } + if o.OnloadTrigger != nil { + toSerialize["onloadTrigger"] = o.OnloadTrigger + } if o.Pattern != nil { toSerialize["pattern"] = o.Pattern } diff --git a/internal/httpclient/model_update_login_flow_body.go b/internal/httpclient/model_update_login_flow_body.go index b8bb05734e3c..f0d79322c54f 100644 --- a/internal/httpclient/model_update_login_flow_body.go +++ b/internal/httpclient/model_update_login_flow_body.go @@ -18,13 +18,14 @@ import ( // UpdateLoginFlowBody - struct for UpdateLoginFlowBody type UpdateLoginFlowBody struct { - UpdateLoginFlowWithCodeMethod *UpdateLoginFlowWithCodeMethod - UpdateLoginFlowWithLookupSecretMethod *UpdateLoginFlowWithLookupSecretMethod - UpdateLoginFlowWithOidcMethod *UpdateLoginFlowWithOidcMethod - UpdateLoginFlowWithPasskeyMethod *UpdateLoginFlowWithPasskeyMethod - UpdateLoginFlowWithPasswordMethod *UpdateLoginFlowWithPasswordMethod - UpdateLoginFlowWithTotpMethod *UpdateLoginFlowWithTotpMethod - UpdateLoginFlowWithWebAuthnMethod *UpdateLoginFlowWithWebAuthnMethod + UpdateLoginFlowWithCodeMethod *UpdateLoginFlowWithCodeMethod + UpdateLoginFlowWithIdentifierFirstMethod *UpdateLoginFlowWithIdentifierFirstMethod + UpdateLoginFlowWithLookupSecretMethod *UpdateLoginFlowWithLookupSecretMethod + UpdateLoginFlowWithOidcMethod *UpdateLoginFlowWithOidcMethod + UpdateLoginFlowWithPasskeyMethod *UpdateLoginFlowWithPasskeyMethod + UpdateLoginFlowWithPasswordMethod *UpdateLoginFlowWithPasswordMethod + UpdateLoginFlowWithTotpMethod *UpdateLoginFlowWithTotpMethod + UpdateLoginFlowWithWebAuthnMethod *UpdateLoginFlowWithWebAuthnMethod } // UpdateLoginFlowWithCodeMethodAsUpdateLoginFlowBody is a convenience function that returns UpdateLoginFlowWithCodeMethod wrapped in UpdateLoginFlowBody @@ -34,6 +35,13 @@ func UpdateLoginFlowWithCodeMethodAsUpdateLoginFlowBody(v *UpdateLoginFlowWithCo } } +// UpdateLoginFlowWithIdentifierFirstMethodAsUpdateLoginFlowBody is a convenience function that returns UpdateLoginFlowWithIdentifierFirstMethod wrapped in UpdateLoginFlowBody +func UpdateLoginFlowWithIdentifierFirstMethodAsUpdateLoginFlowBody(v *UpdateLoginFlowWithIdentifierFirstMethod) UpdateLoginFlowBody { + return UpdateLoginFlowBody{ + UpdateLoginFlowWithIdentifierFirstMethod: v, + } +} + // UpdateLoginFlowWithLookupSecretMethodAsUpdateLoginFlowBody is a convenience function that returns UpdateLoginFlowWithLookupSecretMethod wrapped in UpdateLoginFlowBody func UpdateLoginFlowWithLookupSecretMethodAsUpdateLoginFlowBody(v *UpdateLoginFlowWithLookupSecretMethod) UpdateLoginFlowBody { return UpdateLoginFlowBody{ @@ -98,6 +106,18 @@ func (dst *UpdateLoginFlowBody) UnmarshalJSON(data []byte) error { } } + // check if the discriminator value is 'identifier_first' + if jsonDict["method"] == "identifier_first" { + // try to unmarshal JSON data into UpdateLoginFlowWithIdentifierFirstMethod + err = json.Unmarshal(data, &dst.UpdateLoginFlowWithIdentifierFirstMethod) + if err == nil { + return nil // data stored in dst.UpdateLoginFlowWithIdentifierFirstMethod, return on the first match + } else { + dst.UpdateLoginFlowWithIdentifierFirstMethod = nil + return fmt.Errorf("Failed to unmarshal UpdateLoginFlowBody as UpdateLoginFlowWithIdentifierFirstMethod: %s", err.Error()) + } + } + // check if the discriminator value is 'lookup_secret' if jsonDict["method"] == "lookup_secret" { // try to unmarshal JSON data into UpdateLoginFlowWithLookupSecretMethod @@ -182,6 +202,18 @@ func (dst *UpdateLoginFlowBody) UnmarshalJSON(data []byte) error { } } + // check if the discriminator value is 'updateLoginFlowWithIdentifierFirstMethod' + if jsonDict["method"] == "updateLoginFlowWithIdentifierFirstMethod" { + // try to unmarshal JSON data into UpdateLoginFlowWithIdentifierFirstMethod + err = json.Unmarshal(data, &dst.UpdateLoginFlowWithIdentifierFirstMethod) + if err == nil { + return nil // data stored in dst.UpdateLoginFlowWithIdentifierFirstMethod, return on the first match + } else { + dst.UpdateLoginFlowWithIdentifierFirstMethod = nil + return fmt.Errorf("Failed to unmarshal UpdateLoginFlowBody as UpdateLoginFlowWithIdentifierFirstMethod: %s", err.Error()) + } + } + // check if the discriminator value is 'updateLoginFlowWithLookupSecretMethod' if jsonDict["method"] == "updateLoginFlowWithLookupSecretMethod" { // try to unmarshal JSON data into UpdateLoginFlowWithLookupSecretMethod @@ -263,6 +295,10 @@ func (src UpdateLoginFlowBody) MarshalJSON() ([]byte, error) { return json.Marshal(&src.UpdateLoginFlowWithCodeMethod) } + if src.UpdateLoginFlowWithIdentifierFirstMethod != nil { + return json.Marshal(&src.UpdateLoginFlowWithIdentifierFirstMethod) + } + if src.UpdateLoginFlowWithLookupSecretMethod != nil { return json.Marshal(&src.UpdateLoginFlowWithLookupSecretMethod) } @@ -299,6 +335,10 @@ func (obj *UpdateLoginFlowBody) GetActualInstance() interface{} { return obj.UpdateLoginFlowWithCodeMethod } + if obj.UpdateLoginFlowWithIdentifierFirstMethod != nil { + return obj.UpdateLoginFlowWithIdentifierFirstMethod + } + if obj.UpdateLoginFlowWithLookupSecretMethod != nil { return obj.UpdateLoginFlowWithLookupSecretMethod } diff --git a/internal/httpclient/model_update_login_flow_with_identifier_first_method.go b/internal/httpclient/model_update_login_flow_with_identifier_first_method.go new file mode 100644 index 000000000000..70cf8002990d --- /dev/null +++ b/internal/httpclient/model_update_login_flow_with_identifier_first_method.go @@ -0,0 +1,212 @@ +/* + * Ory Identities API + * + * This is the API specification for Ory Identities with features such as registration, login, recovery, account verification, profile settings, password reset, identity management, session management, email and sms delivery, and more. + * + * API version: + * Contact: office@ory.sh + */ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package client + +import ( + "encoding/json" +) + +// UpdateLoginFlowWithIdentifierFirstMethod Update Login Flow with Multi-Step Method +type UpdateLoginFlowWithIdentifierFirstMethod struct { + // Sending the anti-csrf token is only required for browser login flows. + CsrfToken *string `json:"csrf_token,omitempty"` + // Identifier is the email or username of the user trying to log in. + Identifier string `json:"identifier"` + // Method should be set to \"password\" when logging in using the identifier and password strategy. + Method string `json:"method"` + // Transient data to pass along to any webhooks + TransientPayload map[string]interface{} `json:"transient_payload,omitempty"` +} + +// NewUpdateLoginFlowWithIdentifierFirstMethod instantiates a new UpdateLoginFlowWithIdentifierFirstMethod object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUpdateLoginFlowWithIdentifierFirstMethod(identifier string, method string) *UpdateLoginFlowWithIdentifierFirstMethod { + this := UpdateLoginFlowWithIdentifierFirstMethod{} + this.Identifier = identifier + this.Method = method + return &this +} + +// NewUpdateLoginFlowWithIdentifierFirstMethodWithDefaults instantiates a new UpdateLoginFlowWithIdentifierFirstMethod object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUpdateLoginFlowWithIdentifierFirstMethodWithDefaults() *UpdateLoginFlowWithIdentifierFirstMethod { + this := UpdateLoginFlowWithIdentifierFirstMethod{} + return &this +} + +// GetCsrfToken returns the CsrfToken field value if set, zero value otherwise. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetCsrfToken() string { + if o == nil || o.CsrfToken == nil { + var ret string + return ret + } + return *o.CsrfToken +} + +// GetCsrfTokenOk returns a tuple with the CsrfToken field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetCsrfTokenOk() (*string, bool) { + if o == nil || o.CsrfToken == nil { + return nil, false + } + return o.CsrfToken, true +} + +// HasCsrfToken returns a boolean if a field has been set. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) HasCsrfToken() bool { + if o != nil && o.CsrfToken != nil { + return true + } + + return false +} + +// SetCsrfToken gets a reference to the given string and assigns it to the CsrfToken field. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) SetCsrfToken(v string) { + o.CsrfToken = &v +} + +// GetIdentifier returns the Identifier field value +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetIdentifier() string { + if o == nil { + var ret string + return ret + } + + return o.Identifier +} + +// GetIdentifierOk returns a tuple with the Identifier field value +// and a boolean to check if the value has been set. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetIdentifierOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Identifier, true +} + +// SetIdentifier sets field value +func (o *UpdateLoginFlowWithIdentifierFirstMethod) SetIdentifier(v string) { + o.Identifier = v +} + +// GetMethod returns the Method field value +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetMethod() string { + if o == nil { + var ret string + return ret + } + + return o.Method +} + +// GetMethodOk returns a tuple with the Method field value +// and a boolean to check if the value has been set. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetMethodOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Method, true +} + +// SetMethod sets field value +func (o *UpdateLoginFlowWithIdentifierFirstMethod) SetMethod(v string) { + o.Method = v +} + +// GetTransientPayload returns the TransientPayload field value if set, zero value otherwise. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetTransientPayload() map[string]interface{} { + if o == nil || o.TransientPayload == nil { + var ret map[string]interface{} + return ret + } + return o.TransientPayload +} + +// GetTransientPayloadOk returns a tuple with the TransientPayload field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) GetTransientPayloadOk() (map[string]interface{}, bool) { + if o == nil || o.TransientPayload == nil { + return nil, false + } + return o.TransientPayload, true +} + +// HasTransientPayload returns a boolean if a field has been set. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) HasTransientPayload() bool { + if o != nil && o.TransientPayload != nil { + return true + } + + return false +} + +// SetTransientPayload gets a reference to the given map[string]interface{} and assigns it to the TransientPayload field. +func (o *UpdateLoginFlowWithIdentifierFirstMethod) SetTransientPayload(v map[string]interface{}) { + o.TransientPayload = v +} + +func (o UpdateLoginFlowWithIdentifierFirstMethod) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if o.CsrfToken != nil { + toSerialize["csrf_token"] = o.CsrfToken + } + if true { + toSerialize["identifier"] = o.Identifier + } + if true { + toSerialize["method"] = o.Method + } + if o.TransientPayload != nil { + toSerialize["transient_payload"] = o.TransientPayload + } + return json.Marshal(toSerialize) +} + +type NullableUpdateLoginFlowWithIdentifierFirstMethod struct { + value *UpdateLoginFlowWithIdentifierFirstMethod + isSet bool +} + +func (v NullableUpdateLoginFlowWithIdentifierFirstMethod) Get() *UpdateLoginFlowWithIdentifierFirstMethod { + return v.value +} + +func (v *NullableUpdateLoginFlowWithIdentifierFirstMethod) Set(val *UpdateLoginFlowWithIdentifierFirstMethod) { + v.value = val + v.isSet = true +} + +func (v NullableUpdateLoginFlowWithIdentifierFirstMethod) IsSet() bool { + return v.isSet +} + +func (v *NullableUpdateLoginFlowWithIdentifierFirstMethod) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUpdateLoginFlowWithIdentifierFirstMethod(val *UpdateLoginFlowWithIdentifierFirstMethod) *NullableUpdateLoginFlowWithIdentifierFirstMethod { + return &NullableUpdateLoginFlowWithIdentifierFirstMethod{value: val, isSet: true} +} + +func (v NullableUpdateLoginFlowWithIdentifierFirstMethod) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUpdateLoginFlowWithIdentifierFirstMethod) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/internal/httpclient/model_update_registration_flow_body.go b/internal/httpclient/model_update_registration_flow_body.go index 64374c620f8f..82a578cfc4d3 100644 --- a/internal/httpclient/model_update_registration_flow_body.go +++ b/internal/httpclient/model_update_registration_flow_body.go @@ -22,6 +22,7 @@ type UpdateRegistrationFlowBody struct { UpdateRegistrationFlowWithOidcMethod *UpdateRegistrationFlowWithOidcMethod UpdateRegistrationFlowWithPasskeyMethod *UpdateRegistrationFlowWithPasskeyMethod UpdateRegistrationFlowWithPasswordMethod *UpdateRegistrationFlowWithPasswordMethod + UpdateRegistrationFlowWithProfileMethod *UpdateRegistrationFlowWithProfileMethod UpdateRegistrationFlowWithWebAuthnMethod *UpdateRegistrationFlowWithWebAuthnMethod } @@ -53,6 +54,13 @@ func UpdateRegistrationFlowWithPasswordMethodAsUpdateRegistrationFlowBody(v *Upd } } +// UpdateRegistrationFlowWithProfileMethodAsUpdateRegistrationFlowBody is a convenience function that returns UpdateRegistrationFlowWithProfileMethod wrapped in UpdateRegistrationFlowBody +func UpdateRegistrationFlowWithProfileMethodAsUpdateRegistrationFlowBody(v *UpdateRegistrationFlowWithProfileMethod) UpdateRegistrationFlowBody { + return UpdateRegistrationFlowBody{ + UpdateRegistrationFlowWithProfileMethod: v, + } +} + // UpdateRegistrationFlowWithWebAuthnMethodAsUpdateRegistrationFlowBody is a convenience function that returns UpdateRegistrationFlowWithWebAuthnMethod wrapped in UpdateRegistrationFlowBody func UpdateRegistrationFlowWithWebAuthnMethodAsUpdateRegistrationFlowBody(v *UpdateRegistrationFlowWithWebAuthnMethod) UpdateRegistrationFlowBody { return UpdateRegistrationFlowBody{ @@ -94,8 +102,8 @@ func (dst *UpdateRegistrationFlowBody) UnmarshalJSON(data []byte) error { } } - // check if the discriminator value is 'passKey' - if jsonDict["method"] == "passKey" { + // check if the discriminator value is 'passkey' + if jsonDict["method"] == "passkey" { // try to unmarshal JSON data into UpdateRegistrationFlowWithPasskeyMethod err = json.Unmarshal(data, &dst.UpdateRegistrationFlowWithPasskeyMethod) if err == nil { @@ -118,6 +126,18 @@ func (dst *UpdateRegistrationFlowBody) UnmarshalJSON(data []byte) error { } } + // check if the discriminator value is 'profile' + if jsonDict["method"] == "profile" { + // try to unmarshal JSON data into UpdateRegistrationFlowWithProfileMethod + err = json.Unmarshal(data, &dst.UpdateRegistrationFlowWithProfileMethod) + if err == nil { + return nil // data stored in dst.UpdateRegistrationFlowWithProfileMethod, return on the first match + } else { + dst.UpdateRegistrationFlowWithProfileMethod = nil + return fmt.Errorf("Failed to unmarshal UpdateRegistrationFlowBody as UpdateRegistrationFlowWithProfileMethod: %s", err.Error()) + } + } + // check if the discriminator value is 'webauthn' if jsonDict["method"] == "webauthn" { // try to unmarshal JSON data into UpdateRegistrationFlowWithWebAuthnMethod @@ -178,6 +198,18 @@ func (dst *UpdateRegistrationFlowBody) UnmarshalJSON(data []byte) error { } } + // check if the discriminator value is 'updateRegistrationFlowWithProfileMethod' + if jsonDict["method"] == "updateRegistrationFlowWithProfileMethod" { + // try to unmarshal JSON data into UpdateRegistrationFlowWithProfileMethod + err = json.Unmarshal(data, &dst.UpdateRegistrationFlowWithProfileMethod) + if err == nil { + return nil // data stored in dst.UpdateRegistrationFlowWithProfileMethod, return on the first match + } else { + dst.UpdateRegistrationFlowWithProfileMethod = nil + return fmt.Errorf("Failed to unmarshal UpdateRegistrationFlowBody as UpdateRegistrationFlowWithProfileMethod: %s", err.Error()) + } + } + // check if the discriminator value is 'updateRegistrationFlowWithWebAuthnMethod' if jsonDict["method"] == "updateRegistrationFlowWithWebAuthnMethod" { // try to unmarshal JSON data into UpdateRegistrationFlowWithWebAuthnMethod @@ -211,6 +243,10 @@ func (src UpdateRegistrationFlowBody) MarshalJSON() ([]byte, error) { return json.Marshal(&src.UpdateRegistrationFlowWithPasswordMethod) } + if src.UpdateRegistrationFlowWithProfileMethod != nil { + return json.Marshal(&src.UpdateRegistrationFlowWithProfileMethod) + } + if src.UpdateRegistrationFlowWithWebAuthnMethod != nil { return json.Marshal(&src.UpdateRegistrationFlowWithWebAuthnMethod) } @@ -239,6 +275,10 @@ func (obj *UpdateRegistrationFlowBody) GetActualInstance() interface{} { return obj.UpdateRegistrationFlowWithPasswordMethod } + if obj.UpdateRegistrationFlowWithProfileMethod != nil { + return obj.UpdateRegistrationFlowWithProfileMethod + } + if obj.UpdateRegistrationFlowWithWebAuthnMethod != nil { return obj.UpdateRegistrationFlowWithWebAuthnMethod } diff --git a/internal/registrationhelpers/helpers.go b/internal/registrationhelpers/helpers.go index 9fbf7f08211d..6fd76bd6ef1a 100644 --- a/internal/registrationhelpers/helpers.go +++ b/internal/registrationhelpers/helpers.go @@ -288,7 +288,7 @@ func AssertCommonErrorCases(t *testing.T, flows []string) { t.Run("description=can call endpoints only without session", func(t *testing.T) { values := url.Values{} t.Run("type=browser", func(t *testing.T) { - res, err := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, reg). + res, err := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, ctx, reg). Do(httpx.MustNewRequest("POST", publicTS.URL+registration.RouteSubmitFlow, strings.NewReader(values.Encode()), "application/x-www-form-urlencoded")) require.NoError(t, err) defer res.Body.Close() @@ -297,7 +297,7 @@ func AssertCommonErrorCases(t *testing.T, flows []string) { }) t.Run("type=api", func(t *testing.T) { - res, err := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, reg). + res, err := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, ctx, reg). Do(httpx.MustNewRequest("POST", publicTS.URL+registration.RouteSubmitFlow, strings.NewReader(testhelpers.EncodeFormAsJSON(t, true, values)), "application/json")) require.NoError(t, err) assert.Len(t, res.Cookies(), 0) @@ -337,7 +337,7 @@ func AssertCommonErrorCases(t *testing.T, flows []string) { values := url.Values{} t.Run("type=browser", func(t *testing.T) { - res, err := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, reg). + res, err := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, ctx, reg). Do(httpx.MustNewRequest("POST", publicTS.URL+registration.RouteSubmitFlow, strings.NewReader(values.Encode()), "application/x-www-form-urlencoded")) require.NoError(t, err) defer res.Body.Close() @@ -346,7 +346,7 @@ func AssertCommonErrorCases(t *testing.T, flows []string) { }) t.Run("type=api", func(t *testing.T) { - res, err := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, reg). + res, err := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, ctx, reg). Do(httpx.MustNewRequest("POST", publicTS.URL+registration.RouteSubmitFlow, strings.NewReader(testhelpers.EncodeFormAsJSON(t, true, values)), "application/json")) require.NoError(t, err) assert.Len(t, res.Cookies(), 0) diff --git a/internal/testhelpers/selfservice_login.go b/internal/testhelpers/selfservice_login.go index 2bd20f81dfd4..1ef8db2e58ba 100644 --- a/internal/testhelpers/selfservice_login.go +++ b/internal/testhelpers/selfservice_login.go @@ -59,6 +59,18 @@ type initFlowOptions struct { refresh bool oauth2LoginChallenge string via string + ctx context.Context +} + +func newInitFlowOptions(opts []InitFlowWithOption) *initFlowOptions { + return new(initFlowOptions).apply(opts) +} + +func (o *initFlowOptions) Context() context.Context { + if o.ctx == nil { + return context.Background() + } + return o.ctx } func (o *initFlowOptions) apply(opts []InitFlowWithOption) *initFlowOptions { @@ -116,6 +128,12 @@ func InitFlowWithRefresh() InitFlowWithOption { } } +func InitFlowWithContext(ctx context.Context) InitFlowWithOption { + return func(o *initFlowOptions) { + o.ctx = ctx + } +} + func InitFlowWithOAuth2LoginChallenge(hlc string) InitFlowWithOption { return func(o *initFlowOptions) { o.oauth2LoginChallenge = hlc @@ -134,12 +152,13 @@ func InitializeLoginFlowViaBrowser(t *testing.T, client *http.Client, ts *httpte req, err := http.NewRequest("GET", getURLFromInitOptions(ts, login.RouteInitBrowserFlow, forced, opts...), nil) require.NoError(t, err) + o := newInitFlowOptions(opts) if isSPA { req.Header.Set("Accept", "application/json") } - res, err := client.Do(req) + res, err := client.Do(req.WithContext(o.Context())) require.NoError(t, err) body := x.MustReadAll(res.Body) require.NoError(t, res.Body.Close()) @@ -167,11 +186,11 @@ func InitializeLoginFlowViaBrowser(t *testing.T, client *http.Client, ts *httpte return rs } -func InitializeLoginFlowViaAPI(t *testing.T, client *http.Client, ts *httptest.Server, forced bool, opts ...InitFlowWithOption) *kratos.LoginFlow { +func InitializeLoginFlowViaAPIWithContext(t *testing.T, ctx context.Context, client *http.Client, ts *httptest.Server, forced bool, opts ...InitFlowWithOption) *kratos.LoginFlow { publicClient := NewSDKCustomClient(ts, client) o := new(initFlowOptions).apply(opts) - req := publicClient.FrontendApi.CreateNativeLoginFlow(context.Background()).Refresh(forced) + req := publicClient.FrontendApi.CreateNativeLoginFlow(ctx).Refresh(forced) if o.aal != "" { req = req.Aal(string(o.aal)) } @@ -186,6 +205,10 @@ func InitializeLoginFlowViaAPI(t *testing.T, client *http.Client, ts *httptest.S return rs } +func InitializeLoginFlowViaAPI(t *testing.T, client *http.Client, ts *httptest.Server, forced bool, opts ...InitFlowWithOption) *kratos.LoginFlow { + return InitializeLoginFlowViaAPIWithContext(t, context.Background(), client, ts, forced, opts...) +} + func LoginMakeRequest( t *testing.T, isAPI bool, @@ -193,6 +216,18 @@ func LoginMakeRequest( f *kratos.LoginFlow, hc *http.Client, values string, +) (string, *http.Response) { + return LoginMakeRequestWithContext(t, context.Background(), isAPI, isSPA, f, hc, values) +} + +func LoginMakeRequestWithContext( + t *testing.T, + ctx context.Context, + isAPI bool, + isSPA bool, + f *kratos.LoginFlow, + hc *http.Client, + values string, ) (string, *http.Response) { require.NotEmpty(t, f.Ui.Action) @@ -201,7 +236,7 @@ func LoginMakeRequest( req.Header.Set("Accept", "application/json") } - res, err := hc.Do(req) + res, err := hc.Do(req.WithContext(ctx)) require.NoError(t, err, "action: %s", f.Ui.Action) defer res.Body.Close() diff --git a/internal/testhelpers/session.go b/internal/testhelpers/session.go index 29e8997f3438..1d2cecc824db 100644 --- a/internal/testhelpers/session.go +++ b/internal/testhelpers/session.go @@ -42,25 +42,25 @@ func NewSessionClient(t *testing.T, u string) *http.Client { return c } -func maybePersistSession(t *testing.T, reg *driver.RegistryDefault, sess *session.Session) { - id, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), sess.Identity.ID) +func maybePersistSession(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, sess *session.Session) { + id, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, sess.Identity.ID) if err != nil { - require.NoError(t, sess.Identity.SetAvailableAAL(context.Background(), reg.IdentityManager())) - require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), sess.Identity)) - id, err = reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), sess.Identity.ID) + require.NoError(t, sess.Identity.SetAvailableAAL(ctx, reg.IdentityManager())) + require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, sess.Identity)) + id, err = reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, sess.Identity.ID) require.NoError(t, err) } sess.Identity = id sess.IdentityID = id.ID - require.NoError(t, err, reg.SessionPersister().UpsertSession(context.Background(), sess)) + require.NoError(t, err, reg.SessionPersister().UpsertSession(ctx, sess)) } -func NewHTTPClientWithSessionCookie(t *testing.T, reg *driver.RegistryDefault, sess *session.Session) *http.Client { - maybePersistSession(t, reg, sess) +func NewHTTPClientWithSessionCookie(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, sess *session.Session) *http.Client { + maybePersistSession(t, ctx, reg, sess) var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.NoError(t, reg.SessionManager().IssueCookie(context.Background(), w, r, sess)) + require.NoError(t, reg.SessionManager().IssueCookie(ctx, w, r, sess)) }) if _, ok := reg.CSRFHandler().(*nosurf.CSRFHandler); ok { @@ -75,11 +75,11 @@ func NewHTTPClientWithSessionCookie(t *testing.T, reg *driver.RegistryDefault, s return c } -func NewHTTPClientWithSessionCookieLocalhost(t *testing.T, reg *driver.RegistryDefault, sess *session.Session) *http.Client { - maybePersistSession(t, reg, sess) +func NewHTTPClientWithSessionCookieLocalhost(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, sess *session.Session) *http.Client { + maybePersistSession(t, ctx, reg, sess) var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.NoError(t, reg.SessionManager().IssueCookie(context.Background(), w, r, sess)) + require.NoError(t, reg.SessionManager().IssueCookie(ctx, w, r, sess)) }) if _, ok := reg.CSRFHandler().(*nosurf.CSRFHandler); ok { @@ -96,11 +96,11 @@ func NewHTTPClientWithSessionCookieLocalhost(t *testing.T, reg *driver.RegistryD return c } -func NewNoRedirectHTTPClientWithSessionCookie(t *testing.T, reg *driver.RegistryDefault, sess *session.Session) *http.Client { - maybePersistSession(t, reg, sess) +func NewNoRedirectHTTPClientWithSessionCookie(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, sess *session.Session) *http.Client { + maybePersistSession(t, ctx, reg, sess) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.NoError(t, reg.SessionManager().IssueCookie(context.Background(), w, r, sess)) + require.NoError(t, reg.SessionManager().IssueCookie(ctx, w, r, sess)) })) defer ts.Close() @@ -130,8 +130,8 @@ func (ct *TransportWithLogger) RoundTrip(req *http.Request) (*http.Response, err return ct.RoundTripper.RoundTrip(req) } -func NewHTTPClientWithSessionToken(t *testing.T, reg *driver.RegistryDefault, sess *session.Session) *http.Client { - maybePersistSession(t, reg, sess) +func NewHTTPClientWithSessionToken(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, sess *session.Session) *http.Client { + maybePersistSession(t, ctx, reg, sess) return &http.Client{ Transport: NewTransportWithHeader(t, http.Header{ @@ -140,10 +140,14 @@ func NewHTTPClientWithSessionToken(t *testing.T, reg *driver.RegistryDefault, se } } -func NewHTTPClientWithArbitrarySessionToken(t *testing.T, reg *driver.RegistryDefault) *http.Client { +func NewHTTPClientWithArbitrarySessionToken(t *testing.T, ctx context.Context, reg *driver.RegistryDefault) *http.Client { + return NewHTTPClientWithArbitrarySessionTokenAndTraits(t, ctx, reg, nil) +} + +func NewHTTPClientWithArbitrarySessionTokenAndTraits(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, traits identity.Traits) *http.Client { req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil) s, err := session.NewActiveSession(req, - &identity.Identity{ID: x.NewUUID(), State: identity.StateActive}, + &identity.Identity{ID: x.NewUUID(), State: identity.StateActive, Traits: traits}, NewSessionLifespanProvider(time.Hour), time.Now(), identity.CredentialsTypePassword, @@ -151,10 +155,10 @@ func NewHTTPClientWithArbitrarySessionToken(t *testing.T, reg *driver.RegistryDe ) require.NoError(t, err, "Could not initialize session from identity.") - return NewHTTPClientWithSessionToken(t, reg, s) + return NewHTTPClientWithSessionToken(t, ctx, reg, s) } -func NewHTTPClientWithArbitrarySessionCookie(t *testing.T, reg *driver.RegistryDefault) *http.Client { +func NewHTTPClientWithArbitrarySessionCookie(t *testing.T, ctx context.Context, reg *driver.RegistryDefault) *http.Client { req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil) s, err := session.NewActiveSession(req, &identity.Identity{ID: x.NewUUID(), State: identity.StateActive, Traits: []byte("{}")}, @@ -165,10 +169,10 @@ func NewHTTPClientWithArbitrarySessionCookie(t *testing.T, reg *driver.RegistryD ) require.NoError(t, err, "Could not initialize session from identity.") - return NewHTTPClientWithSessionCookie(t, reg, s) + return NewHTTPClientWithSessionCookie(t, ctx, reg, s) } -func NewNoRedirectHTTPClientWithArbitrarySessionCookie(t *testing.T, reg *driver.RegistryDefault) *http.Client { +func NewNoRedirectHTTPClientWithArbitrarySessionCookie(t *testing.T, ctx context.Context, reg *driver.RegistryDefault) *http.Client { req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil) s, err := session.NewActiveSession(req, &identity.Identity{ID: x.NewUUID(), State: identity.StateActive}, @@ -179,10 +183,10 @@ func NewNoRedirectHTTPClientWithArbitrarySessionCookie(t *testing.T, reg *driver ) require.NoError(t, err, "Could not initialize session from identity.") - return NewNoRedirectHTTPClientWithSessionCookie(t, reg, s) + return NewNoRedirectHTTPClientWithSessionCookie(t, ctx, reg, s) } -func NewHTTPClientWithIdentitySessionCookie(t *testing.T, reg *driver.RegistryDefault, id *identity.Identity) *http.Client { +func NewHTTPClientWithIdentitySessionCookie(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, id *identity.Identity) *http.Client { req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil) s, err := session.NewActiveSession(req, id, @@ -193,10 +197,10 @@ func NewHTTPClientWithIdentitySessionCookie(t *testing.T, reg *driver.RegistryDe ) require.NoError(t, err, "Could not initialize session from identity.") - return NewHTTPClientWithSessionCookie(t, reg, s) + return NewHTTPClientWithSessionCookie(t, ctx, reg, s) } -func NewHTTPClientWithIdentitySessionCookieLocalhost(t *testing.T, reg *driver.RegistryDefault, id *identity.Identity) *http.Client { +func NewHTTPClientWithIdentitySessionCookieLocalhost(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, id *identity.Identity) *http.Client { req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil) s, err := session.NewActiveSession(req, id, @@ -207,10 +211,10 @@ func NewHTTPClientWithIdentitySessionCookieLocalhost(t *testing.T, reg *driver.R ) require.NoError(t, err, "Could not initialize session from identity.") - return NewHTTPClientWithSessionCookieLocalhost(t, reg, s) + return NewHTTPClientWithSessionCookieLocalhost(t, ctx, reg, s) } -func NewHTTPClientWithIdentitySessionToken(t *testing.T, reg *driver.RegistryDefault, id *identity.Identity) *http.Client { +func NewHTTPClientWithIdentitySessionToken(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, id *identity.Identity) *http.Client { req := NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil) s, err := session.NewActiveSession(req, id, @@ -221,7 +225,7 @@ func NewHTTPClientWithIdentitySessionToken(t *testing.T, reg *driver.RegistryDef ) require.NoError(t, err, "Could not initialize session from identity.") - return NewHTTPClientWithSessionToken(t, reg, s) + return NewHTTPClientWithSessionToken(t, ctx, reg, s) } func EnsureAAL(t *testing.T, c *http.Client, ts *httptest.Server, aal string, methods ...string) { @@ -236,8 +240,8 @@ func EnsureAAL(t *testing.T, c *http.Client, ts *httptest.Server, aal string, me assert.Len(t, gjson.GetBytes(sess, "authentication_methods").Array(), 1+len(methods)) } -func NewAuthorizedTransport(t *testing.T, reg *driver.RegistryDefault, sess *session.Session) *TransportWithHeader { - maybePersistSession(t, reg, sess) +func NewAuthorizedTransport(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, sess *session.Session) *TransportWithHeader { + maybePersistSession(t, ctx, reg, sess) return NewTransportWithHeader(t, http.Header{ "Authorization": {"Bearer " + sess.Token}, @@ -259,6 +263,10 @@ type TransportWithHeader struct { h http.Header } +func (ct *TransportWithHeader) GetHeader() http.Header { + return ct.h +} + func (ct *TransportWithHeader) RoundTrip(req *http.Request) (*http.Response, error) { for k := range ct.h { req.Header.Set(k, ct.h.Get(k)) diff --git a/schema/errors.go b/schema/errors.go index 30c1f72976f3..6ff52a5047c2 100644 --- a/schema/errors.go +++ b/schema/errors.go @@ -117,12 +117,21 @@ func NewInvalidCredentialsError() error { ValidationError: &jsonschema.ValidationError{ Message: `the provided credentials are invalid, check for spelling mistakes in your password or username, email address, or phone number`, InstancePtr: "#/", - Context: &ValidationErrorContextPasswordPolicyViolation{}, }, Messages: new(text.Messages).Add(text.NewErrorValidationInvalidCredentials()), }) } +func NewAccountNotFoundError() error { + return errors.WithStack(&ValidationError{ + ValidationError: &jsonschema.ValidationError{ + Message: "this account does not exist or has no login method configured", + InstancePtr: "#/identifier", + }, + Messages: new(text.Messages).Add(text.NewErrorValidationAccountNotFound()), + }) +} + type ValidationErrorContextDuplicateCredentialsError struct { AvailableCredentials []string `json:"available_credential_types"` AvailableOIDCProviders []string `json:"available_oidc_providers"` diff --git a/schema/handler.go b/schema/handler.go index ff06bc8c43ef..fe2842b14a56 100644 --- a/schema/handler.go +++ b/schema/handler.go @@ -13,18 +13,14 @@ import ( "os" "strings" - "github.com/ory/x/otelx" - - "github.com/ory/x/pagination/migrationpagination" - - "github.com/ory/kratos/driver/config" - "github.com/julienschmidt/httprouter" "github.com/pkg/errors" "github.com/ory/herodot" - + "github.com/ory/kratos/driver/config" "github.com/ory/kratos/x" + "github.com/ory/x/otelx" + "github.com/ory/x/pagination/migrationpagination" ) type ( diff --git a/schema/handler_test.go b/schema/handler_test.go index a5bdf06a5a83..615d8092269d 100644 --- a/schema/handler_test.go +++ b/schema/handler_test.go @@ -15,13 +15,11 @@ import ( "strings" "testing" - "github.com/ory/client-go" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/ory/client-go" _ "github.com/ory/jsonschema/v3/fileloader" - "github.com/ory/kratos/driver/config" "github.com/ory/kratos/internal" "github.com/ory/kratos/schema" diff --git a/selfservice/flow/continue_with.go b/selfservice/flow/continue_with.go index 7a5f9ce22410..bac63d72a273 100644 --- a/selfservice/flow/continue_with.go +++ b/selfservice/flow/continue_with.go @@ -89,6 +89,8 @@ type ContinueWithVerificationUIFlow struct { // The URL of the verification flow // + // If this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows. + // // required: false URL string `json:"url,omitempty"` } @@ -134,6 +136,7 @@ type ContinueWithSettingsUI struct { // // required: true Action ContinueWithActionShowSettingsUI `json:"action"` + // Flow contains the ID of the verification flow // // required: true @@ -146,13 +149,21 @@ type ContinueWithSettingsUIFlow struct { // // required: true ID uuid.UUID `json:"id"` + + // The URL of the settings flow + // + // If this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows. + // + // required: false + URL string `json:"url,omitempty"` } -func NewContinueWithSettingsUI(f Flow) *ContinueWithSettingsUI { +func NewContinueWithSettingsUI(f Flow, redirectTo string) *ContinueWithSettingsUI { return &ContinueWithSettingsUI{ Action: ContinueWithActionShowSettingsUIString, Flow: ContinueWithSettingsUIFlow{ - ID: f.GetID(), + ID: f.GetID(), + URL: redirectTo, }, } } @@ -188,6 +199,8 @@ type ContinueWithRecoveryUIFlow struct { // The URL of the recovery flow // + // If this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows. + // // required: false URL string `json:"url,omitempty"` } @@ -201,6 +214,36 @@ func NewContinueWithRecoveryUI(f Flow) *ContinueWithRecoveryUI { } } +// swagger:enum ContinueWithActionRedirectBrowserTo +type ContinueWithActionRedirectBrowserTo string + +// #nosec G101 -- only a key constant +const ( + ContinueWithActionRedirectBrowserToString ContinueWithActionRedirectBrowserTo = "redirect_browser_to" +) + +// Indicates, that the UI flow could be continued by showing a recovery ui +// +// swagger:model continueWithRedirectBrowserTo +type ContinueWithRedirectBrowserTo struct { + // Action will always be `redirect_browser_to` + // + // required: true + Action ContinueWithActionRedirectBrowserTo `json:"action"` + + // The URL to redirect the browser to + // + // required: true + RedirectTo string `json:"redirect_browser_to"` +} + +func NewContinueWithRedirectBrowserTo(redirectTo string) *ContinueWithRedirectBrowserTo { + return &ContinueWithRedirectBrowserTo{ + Action: ContinueWithActionRedirectBrowserToString, + RedirectTo: redirectTo, + } +} + func ErrorWithContinueWith(err *herodot.DefaultError, continueWith ...ContinueWith) *herodot.DefaultError { if err.DetailsField == nil { err.DetailsField = map[string]interface{}{} diff --git a/selfservice/flow/login/error_test.go b/selfservice/flow/login/error_test.go index 5cc78c35bda1..e53f831e3891 100644 --- a/selfservice/flow/login/error_test.go +++ b/selfservice/flow/login/error_test.go @@ -74,7 +74,12 @@ func TestHandleError(t *testing.T) { require.NoError(t, err) for _, s := range reg.LoginStrategies(context.Background()) { - require.NoError(t, s.PopulateLoginMethod(req, identity.AuthenticatorAssuranceLevel1, f)) + switch s.(type) { + case login.UnifiedFormHydrator: + require.NoError(t, s.(login.UnifiedFormHydrator).PopulateLoginMethod(req, identity.AuthenticatorAssuranceLevel1, f)) + case login.FormHydrator: + require.NoError(t, s.(login.FormHydrator).PopulateLoginMethodFirstFactor(req, f)) + } } require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), f)) diff --git a/selfservice/flow/login/flow.go b/selfservice/flow/login/flow.go index a01d449a2751..06b189d6c351 100644 --- a/selfservice/flow/login/flow.go +++ b/selfservice/flow/login/flow.go @@ -230,9 +230,9 @@ func (f Flow) GetID() uuid.UUID { return f.ID } -// IsForced returns true if the login flow was triggered to re-authenticate the user. +// IsRefresh returns true if the login flow was triggered to re-authenticate the user. // This is the case if the refresh query parameter is set to true. -func (f *Flow) IsForced() bool { +func (f *Flow) IsRefresh() bool { return f.Refresh } diff --git a/selfservice/flow/login/handler.go b/selfservice/flow/login/handler.go index 88b3712602a0..63c38731ef19 100644 --- a/selfservice/flow/login/handler.go +++ b/selfservice/flow/login/handler.go @@ -212,8 +212,39 @@ preLoginHook: } for _, s := range h.d.LoginStrategies(r.Context(), strategyFilters...) { - if err := s.PopulateLoginMethod(r, f.RequestedAAL, f); err != nil { - return nil, nil, err + var populateErr error + + switch strategy := s.(type) { + case FormHydrator: + switch { + case f.RequestedAAL == identity.AuthenticatorAssuranceLevel1: + switch { + case f.IsRefresh(): + // Refreshing takes precedence over identifier_first auth which can not be a refresh flow. + // Therefor this comes first. + populateErr = strategy.PopulateLoginMethodFirstFactorRefresh(r, f) + case h.d.Config().SelfServiceLoginFlowIdentifierFirstEnabled(r.Context()): + populateErr = strategy.PopulateLoginMethodIdentifierFirstIdentification(r, f) + default: + populateErr = strategy.PopulateLoginMethodFirstFactor(r, f) + } + case f.RequestedAAL == identity.AuthenticatorAssuranceLevel2: + switch { + case f.IsRefresh(): + // Refresh takes precedence. + populateErr = strategy.PopulateLoginMethodSecondFactorRefresh(r, f) + default: + populateErr = strategy.PopulateLoginMethodSecondFactor(r, f) + } + } + case UnifiedFormHydrator: + populateErr = strategy.PopulateLoginMethod(r, f.RequestedAAL, f) + default: + populateErr = errors.WithStack(x.PseudoPanic.WithReasonf("A login strategy was expected to implement one of the interfaces UnifiedFormHydrator or FormHydrator but did not.")) + } + + if populateErr != nil { + return nil, nil, populateErr } } diff --git a/selfservice/flow/login/hook.go b/selfservice/flow/login/hook.go index f0e06ccfc934..f662290d598b 100644 --- a/selfservice/flow/login/hook.go +++ b/selfservice/flow/login/hook.go @@ -159,6 +159,10 @@ func (e *HookExecutor) PostLoginHook( "redirect_reason": "login successful", })...) + if f.Type == flow.TypeBrowser { + f.AddContinueWith(flow.NewContinueWithRedirectBrowserTo(returnTo.String())) + } + classified := s s = s.Declassified() diff --git a/selfservice/flow/login/hook_test.go b/selfservice/flow/login/hook_test.go index fe73f22d7eef..7d7f8e158174 100644 --- a/selfservice/flow/login/hook_test.go +++ b/selfservice/flow/login/hook_test.go @@ -93,6 +93,17 @@ func TestLoginExecutor(t *testing.T) { assert.EqualValues(t, "https://www.ory.sh/", res.Request.URL.String()) }) + t.Run("case=pass without hooks if client is ajax", func(t *testing.T) { + t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) + + ts := newServer(t, flow.TypeBrowser, nil) + res, body := makeRequestPost(t, ts, true, url.Values{}) + assert.EqualValues(t, http.StatusOK, res.StatusCode) + assert.Contains(t, res.Request.URL.String(), ts.URL) + assert.EqualValues(t, gjson.Get(body, "continue_with").Raw, `[{"action":"redirect_browser_to","redirect_browser_to":"https://www.ory.sh/"}]`) + t.Logf("%s", body) + }) + t.Run("case=pass if hooks pass", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) viperSetPost(t, conf, strategy.String(), []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}}) @@ -286,6 +297,7 @@ func TestLoginExecutor(t *testing.T) { }) }) }) + t.Run("case=maybe links credential", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) diff --git a/selfservice/flow/login/strategy.go b/selfservice/flow/login/strategy.go index c70ad9cc8684..fec71d3beb1d 100644 --- a/selfservice/flow/login/strategy.go +++ b/selfservice/flow/login/strategy.go @@ -20,7 +20,6 @@ type Strategy interface { ID() identity.CredentialsType NodeGroup() node.UiNodeGroup RegisterLoginRoutes(*x.RouterPublic) - PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, sr *Flow) error Login(w http.ResponseWriter, r *http.Request, f *Flow, sess *session.Session) (i *identity.Identity, err error) CompletedAuthenticationMethod(ctx context.Context, methods session.AuthenticationMethods) session.AuthenticationMethod } diff --git a/selfservice/flow/login/strategy_form_hydrator.go b/selfservice/flow/login/strategy_form_hydrator.go new file mode 100644 index 000000000000..098c195b5df4 --- /dev/null +++ b/selfservice/flow/login/strategy_form_hydrator.go @@ -0,0 +1,67 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package login + +import ( + "net/http" + + "github.com/pkg/errors" + + "github.com/ory/kratos/identity" +) + +type UnifiedFormHydrator interface { + PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, sr *Flow) error +} + +type FormHydrator interface { + PopulateLoginMethodFirstFactorRefresh(r *http.Request, sr *Flow) error + PopulateLoginMethodFirstFactor(r *http.Request, sr *Flow) error + PopulateLoginMethodSecondFactor(r *http.Request, sr *Flow) error + PopulateLoginMethodSecondFactorRefresh(r *http.Request, sr *Flow) error + + // PopulateLoginMethodIdentifierFirstCredentials populates the login form with the first factor credentials. + // This method is called when the login flow is set to identifier first. The method will receive information + // about the identity that is being used to log in and the identifier that was used to find the identity. + // + // The method should populate the login form with the credentials of the identity. + // + // If the method can not find any credentials (because the identity does not exist) idfirst.ErrNoCredentialsFound + // must be returned. When returning idfirst.ErrNoCredentialsFound the strategy will appropriately deal with + // account enumeration mitigation. + // + // This method does however need to take appropriate steps to show/hide certain fields depending on the account + // enumeration configuration. + PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *Flow, options ...FormHydratorModifier) error + PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, sr *Flow) error +} + +var ErrBreakLoginPopulate = errors.New("skip rest of login form population") + +type FormHydratorOptions struct { + IdentityHint *identity.Identity + Identifier string +} + +type FormHydratorModifier func(o *FormHydratorOptions) + +func WithIdentityHint(i *identity.Identity) FormHydratorModifier { + return func(o *FormHydratorOptions) { + o.IdentityHint = i + } +} + +func WithIdentifier(i string) FormHydratorModifier { + return func(o *FormHydratorOptions) { + o.Identifier = i + } +} + +func NewFormHydratorOptions(modifiers []FormHydratorModifier) *FormHydratorOptions { + o := new(FormHydratorOptions) + for _, m := range modifiers { + m(o) + } + return o +} diff --git a/selfservice/flow/login/strategy_form_hydrator_test.go b/selfservice/flow/login/strategy_form_hydrator_test.go new file mode 100644 index 000000000000..863a1031051d --- /dev/null +++ b/selfservice/flow/login/strategy_form_hydrator_test.go @@ -0,0 +1,24 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package login + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ory/kratos/identity" +) + +func TestWithIdentityHint(t *testing.T) { + expected := new(identity.Identity) + 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/flow/recovery/.snapshots/TestHandleError-flow=api-case=fails_if_active_strategy_is_disabled.json b/selfservice/flow/recovery/.snapshots/TestHandleError-flow=api-case=fails_if_active_strategy_is_disabled.json index f4c0270da2dc..17eb6e965bcb 100644 --- a/selfservice/flow/recovery/.snapshots/TestHandleError-flow=api-case=fails_if_active_strategy_is_disabled.json +++ b/selfservice/flow/recovery/.snapshots/TestHandleError-flow=api-case=fails_if_active_strategy_is_disabled.json @@ -50,8 +50,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } } diff --git a/selfservice/flow/recovery/.snapshots/TestHandleError-flow=spa-case=fails_if_active_strategy_is_disabled.json b/selfservice/flow/recovery/.snapshots/TestHandleError-flow=spa-case=fails_if_active_strategy_is_disabled.json index 56782eed4571..a9ad1e527fb4 100644 --- a/selfservice/flow/recovery/.snapshots/TestHandleError-flow=spa-case=fails_if_active_strategy_is_disabled.json +++ b/selfservice/flow/recovery/.snapshots/TestHandleError-flow=spa-case=fails_if_active_strategy_is_disabled.json @@ -50,8 +50,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } } diff --git a/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=api-case=fails_if_active_strategy_is_disabled.json b/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=api-case=fails_if_active_strategy_is_disabled.json index f4c0270da2dc..17eb6e965bcb 100644 --- a/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=api-case=fails_if_active_strategy_is_disabled.json +++ b/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=api-case=fails_if_active_strategy_is_disabled.json @@ -50,8 +50,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } } diff --git a/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=spa-case=fails_if_active_strategy_is_disabled.json b/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=spa-case=fails_if_active_strategy_is_disabled.json index 56782eed4571..a9ad1e527fb4 100644 --- a/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=spa-case=fails_if_active_strategy_is_disabled.json +++ b/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=spa-case=fails_if_active_strategy_is_disabled.json @@ -50,8 +50,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } } diff --git a/selfservice/flow/registration/hook.go b/selfservice/flow/registration/hook.go index 6a997009c1c5..e44be2487bbb 100644 --- a/selfservice/flow/registration/hook.go +++ b/selfservice/flow/registration/hook.go @@ -192,12 +192,17 @@ func (e *HookExecutor) PostRegistrationHook(w http.ResponseWriter, r *http.Reque if err != nil { return err } + span.SetAttributes(otelx.StringAttrs(map[string]string{ "return_to": returnTo.String(), - "flow_type": string(flow.TypeBrowser), + "flow_type": string(registrationFlow.Type), "redirect_reason": "registration successful", })...) + if registrationFlow.Type == flow.TypeBrowser && x.IsJSONRequest(r) { + registrationFlow.AddContinueWith(flow.NewContinueWithRedirectBrowserTo(returnTo.String())) + } + e.d.Audit(). WithRequest(r). WithField("identity_id", i.ID). diff --git a/selfservice/flow/registration/hook_test.go b/selfservice/flow/registration/hook_test.go index 3761692e3f45..9e60b33f1f52 100644 --- a/selfservice/flow/registration/hook_test.go +++ b/selfservice/flow/registration/hook_test.go @@ -91,6 +91,21 @@ func TestRegistrationExecutor(t *testing.T) { assert.Equal(t, actual.Traits, i.Traits) }) + t.Run("case=pass without hooks if ajax client", func(t *testing.T) { + t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) + i := testhelpers.SelfServiceHookFakeIdentity(t) + + ts := newServer(t, i, flow.TypeBrowser) + res, body := makeRequestPost(t, ts, true, url.Values{}) + assert.EqualValues(t, http.StatusOK, res.StatusCode) + assert.Contains(t, res.Request.URL.String(), ts.URL) + assert.EqualValues(t, gjson.Get(body, "continue_with").Raw, `[{"action":"redirect_browser_to","redirect_browser_to":"https://www.ory.sh/"}]`) + + actual, err := reg.IdentityPool().GetIdentity(context.Background(), i.ID, identity.ExpandNothing) + require.NoError(t, err) + assert.Equal(t, actual.Traits, i.Traits) + }) + t.Run("case=pass if hooks pass", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) viperSetPost(t, conf, strategy, []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}}) diff --git a/selfservice/flow/settings/error_test.go b/selfservice/flow/settings/error_test.go index 5776cd2b6942..5cdebc400df6 100644 --- a/selfservice/flow/settings/error_test.go +++ b/selfservice/flow/settings/error_test.go @@ -153,7 +153,7 @@ func TestHandleError(t *testing.T) { // This needs an authenticated client in order to call the RouteGetFlow endpoint s, err := session.NewActiveSession(req, &id, testhelpers.NewSessionLifespanProvider(time.Hour), time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) require.NoError(t, err) - c := testhelpers.NewHTTPClientWithSessionToken(t, reg, s) + c := testhelpers.NewHTTPClientWithSessionToken(t, ctx, reg, s) settingsFlow = newFlow(t, time.Minute, tc.t) flowError = flow.NewFlowExpiredError(expiredAnHourAgo) diff --git a/selfservice/flow/settings/handler_test.go b/selfservice/flow/settings/handler_test.go index 35d34fd735fc..584a7f3e5535 100644 --- a/selfservice/flow/settings/handler_test.go +++ b/selfservice/flow/settings/handler_test.go @@ -67,8 +67,8 @@ func TestHandler(t *testing.T) { primaryIdentity := &identity.Identity{ID: x.NewUUID(), Traits: identity.Traits(`{}`)} require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), primaryIdentity)) - primaryUser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, primaryIdentity) - otherUser := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, reg) + primaryUser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, primaryIdentity) + otherUser := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, ctx, reg) newExpiredFlow := func() *settings.Flow { f, err := settings.NewFlow(conf, -time.Minute, @@ -133,7 +133,7 @@ func TestHandler(t *testing.T) { return initAuthenticatedFlow(t, hc, false, true, opts...) } - aal2Identity := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, &identity.Identity{ + aal2Identity := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, &identity.Identity{ State: identity.StateActive, Traits: []byte(`{"email":"foo@bar"}`), Credentials: map[identity.CredentialsType]identity.Credentials{ @@ -151,7 +151,7 @@ func TestHandler(t *testing.T) { }) t.Run("description=success", func(t *testing.T) { - user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, reg) + user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, ctx, reg) res, body := initFlow(t, user1, true) assert.Contains(t, res.Request.URL.String(), settings.RouteInitAPIFlow) assertion(t, body, true) @@ -206,7 +206,7 @@ func TestHandler(t *testing.T) { }) t.Run("description=success", func(t *testing.T) { - user1 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, reg) + user1 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, ctx, reg) res, body := initFlow(t, user1, false) assert.Contains(t, res.Request.URL.String(), reg.Config().SelfServiceFlowSettingsUI(ctx).String()) assertion(t, body, false) @@ -241,7 +241,7 @@ func TestHandler(t *testing.T) { }) t.Run("case=redirects with 303", func(t *testing.T) { - c := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, reg) + c := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, ctx, reg) // prevent the redirect c.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse @@ -268,7 +268,7 @@ func TestHandler(t *testing.T) { }) t.Run("description=success", func(t *testing.T) { - user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, reg) + user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, ctx, reg) res, body := initSPAFlow(t, user1) assert.Contains(t, res.Request.URL.String(), settings.RouteInitBrowserFlow) assertion(t, body, false) @@ -277,7 +277,7 @@ func TestHandler(t *testing.T) { t.Run("description=can not init if identity has aal2 but session has aal1", func(t *testing.T) { email := testhelpers.RandomEmail() conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL) - user1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, &identity.Identity{ + user1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, &identity.Identity{ State: identity.StateActive, Traits: []byte(`{"email":"` + email + `"}`), Credentials: map[identity.CredentialsType]identity.Credentials{ @@ -303,7 +303,7 @@ func TestHandler(t *testing.T) { t.Run("description=settings return_to should persist through mfa flows", func(t *testing.T) { email := testhelpers.RandomEmail() conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL) - user1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, &identity.Identity{ + user1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, &identity.Identity{ State: identity.StateActive, Traits: []byte(`{"email":"` + email + `"}`), Credentials: map[identity.CredentialsType]identity.Credentials{ @@ -356,7 +356,7 @@ func TestHandler(t *testing.T) { returnTo := "https://www.ory.sh" conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTo}) - client := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, reg) + client := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, ctx, reg) body := testhelpers.EasyGetBody(t, client, publicTS.URL+settings.RouteInitBrowserFlow+"?return_to="+returnTo) // Expire the flow @@ -385,8 +385,8 @@ func TestHandler(t *testing.T) { t.Run("description=should fail to fetch request if identity changed", func(t *testing.T) { t.Run("type=api", func(t *testing.T) { - user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, reg) - user2 := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, reg) + user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, ctx, reg) + user2 := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, ctx, reg) res, err := user1.Get(publicTS.URL + settings.RouteInitAPIFlow) require.NoError(t, err) @@ -537,8 +537,8 @@ func TestHandler(t *testing.T) { t.Run("description=fail to submit form as another user", func(t *testing.T) { t.Run("type=api", func(t *testing.T) { - user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, reg) - user2 := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, reg) + user1 := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, ctx, reg) + user2 := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, ctx, reg) _, body := initFlow(t, user1, true) var f kratos.SettingsFlow require.NoError(t, json.Unmarshal(body, &f)) @@ -549,8 +549,8 @@ func TestHandler(t *testing.T) { }) t.Run("type=spa", func(t *testing.T) { - user1 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, reg) - user2 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, reg) + user1 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, ctx, reg) + user2 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, ctx, reg) _, body := initFlow(t, user1, true) var f kratos.SettingsFlow require.NoError(t, json.Unmarshal(body, &f)) @@ -561,8 +561,8 @@ func TestHandler(t *testing.T) { }) t.Run("type=browser", func(t *testing.T) { - user1 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, reg) - user2 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, reg) + user1 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, ctx, reg) + user2 := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, ctx, reg) _, body := initFlow(t, user1, true) var f kratos.SettingsFlow require.NoError(t, json.Unmarshal(body, &f)) @@ -602,7 +602,7 @@ func TestHandler(t *testing.T) { t.Run("case=relative redirect when self-service settings ui is a relative url", func(t *testing.T) { reg.Config().MustSet(ctx, config.ViperKeySelfServiceSettingsURL, "/settings-ts") - user1 := testhelpers.NewNoRedirectHTTPClientWithArbitrarySessionCookie(t, reg) + user1 := testhelpers.NewNoRedirectHTTPClientWithArbitrarySessionCookie(t, ctx, reg) res, _ := initFlow(t, user1, false) assert.Regexp( t, diff --git a/selfservice/flow/settings/hook.go b/selfservice/flow/settings/hook.go index b688fd0fc431..88741e766736 100644 --- a/selfservice/flow/settings/hook.go +++ b/selfservice/flow/settings/hook.go @@ -308,6 +308,7 @@ func (e *HookExecutor) PostSettingsHook(w http.ResponseWriter, r *http.Request, } // ContinueWith items are transient items, not stored in the database, and need to be carried over here, so // they can be returned to the client. + ctxUpdate.Flow.AddContinueWith(flow.NewContinueWithRedirectBrowserTo(returnTo.String())) updatedFlow.ContinueWithItems = ctxUpdate.Flow.ContinueWithItems e.d.Writer().Write(w, r, updatedFlow) diff --git a/selfservice/flow/settings/hook_test.go b/selfservice/flow/settings/hook_test.go index 0ab28a1504f9..5253bb2886f2 100644 --- a/selfservice/flow/settings/hook_test.go +++ b/selfservice/flow/settings/hook_test.go @@ -101,6 +101,16 @@ func TestSettingsExecutor(t *testing.T) { assert.Contains(t, res.Request.URL.String(), uiURL) }) + t.Run("case=pass without hooks if ajax client", func(t *testing.T) { + t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) + + ts := newServer(t, nil, flow.TypeBrowser) + res, body := makeRequestPost(t, ts, true, url.Values{}) + assert.EqualValues(t, http.StatusOK, res.StatusCode) + assert.Contains(t, res.Request.URL.String(), ts.URL) + assert.EqualValues(t, gjson.Get(body, "continue_with.0.action").String(), "redirect_browser_to") + }) + t.Run("case=pass if hooks pass", func(t *testing.T) { t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf)) diff --git a/selfservice/flow/state.go b/selfservice/flow/state.go index 76a0683fc19d..a6b4f1a98966 100644 --- a/selfservice/flow/state.go +++ b/selfservice/flow/state.go @@ -33,7 +33,11 @@ const ( StateSuccess State = "success" ) -var states = []State{StateChooseMethod, StateEmailSent, StatePassedChallenge} +var states = []State{ + StateChooseMethod, + StateEmailSent, + StatePassedChallenge, +} func indexOf(current State) int { for k, s := range states { diff --git a/selfservice/flowhelpers/login.go b/selfservice/flowhelpers/login.go index 2e97f85ebe30..60c17176a740 100644 --- a/selfservice/flowhelpers/login.go +++ b/selfservice/flowhelpers/login.go @@ -15,11 +15,11 @@ func GuessForcedLoginIdentifier(r *http.Request, d interface { session.ManagementProvider identity.PrivilegedPoolProvider }, f interface { - IsForced() bool + IsRefresh() bool }, ct identity.CredentialsType) (identifier string, id *identity.Identity, creds *identity.Credentials) { var ok bool // This block adds the identifier to the method when the request is forced - as a hint for the user. - if !f.IsForced() { + if !f.IsRefresh() { // do nothing } else if sess, err := d.SessionManager().FetchFromRequest(r.Context(), r); err != nil { // do nothing diff --git a/selfservice/strategy/code/.snapshots/TestAdminStrategy-case=form_should_not_contain_email_field_when_creating_recovery_code.json b/selfservice/strategy/code/.snapshots/TestAdminStrategy-case=form_should_not_contain_email_field_when_creating_recovery_code.json index a9f46bedb5c4..736578d0e543 100644 --- a/selfservice/strategy/code/.snapshots/TestAdminStrategy-case=form_should_not_contain_email_field_when_creating_recovery_code.json +++ b/selfservice/strategy/code/.snapshots/TestAdminStrategy-case=form_should_not_contain_email_field_when_creating_recovery_code.json @@ -6,7 +6,9 @@ "name": "code", "type": "text", "required": true, + "pattern": "[0-9]+", "disabled": false, + "maxlength": 6, "node_type": "input" }, "messages": [], @@ -31,8 +33,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } } diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor-case=code_is_used_for_2fa_but_request_is_1fa.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor-case=code_is_used_for_2fa_but_request_is_1fa.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor-case=code_is_used_for_2fa_but_request_is_1fa.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor-case=code_is_used_for_passwordless_login_and_request_is_1fa.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor-case=code_is_used_for_passwordless_login_and_request_is_1fa.json new file mode 100644 index 000000000000..8e9874d00cbf --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor-case=code_is_used_for_passwordless_login_and_request_is_1fa.json @@ -0,0 +1,54 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "text", + "value": "", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070004, + "text": "ID", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010015, + "text": "Send sign in code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + } +] diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh-case=code_is_used_for_2fa_and_request_is_1fa_with_refresh.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh-case=code_is_used_for_2fa_and_request_is_1fa_with_refresh.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh-case=code_is_used_for_2fa_and_request_is_1fa_with_refresh.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh-case=code_is_used_for_passwordless_login_and_request_is_1fa_with_refresh.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh-case=code_is_used_for_passwordless_login_and_request_is_1fa_with_refresh.json new file mode 100644 index 000000000000..8e9874d00cbf --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh-case=code_is_used_for_passwordless_login_and_request_is_1fa_with_refresh.json @@ -0,0 +1,54 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "text", + "value": "", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070004, + "text": "ID", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010015, + "text": "Send sign in code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + } +] diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=code_is_used_for_2fa.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=code_is_used_for_2fa.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=code_is_used_for_2fa.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=code_is_used_for_passwordless_login.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=code_is_used_for_passwordless_login.json new file mode 100644 index 000000000000..66b84bf1a436 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=code_is_used_for_passwordless_login.json @@ -0,0 +1,21 @@ +[ + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010015, + "text": "Send sign in code", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_code_method-case=code_is_used_for_2fa.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_code_method-case=code_is_used_for_2fa.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_code_method-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=identity_does_not_have_a_code_method-case=code_is_used_for_passwordless_login.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_code_method-case=code_is_used_for_passwordless_login.json new file mode 100644 index 000000000000..66b84bf1a436 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_code_method-case=code_is_used_for_passwordless_login.json @@ -0,0 +1,21 @@ +[ + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010015, + "text": "Send sign in code", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_code_method-case=code_is_used_for_2fa.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_code_method-case=code_is_used_for_2fa.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_code_method-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=identity_has_code_method-case=code_is_used_for_passwordless_login.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_code_method-case=code_is_used_for_passwordless_login.json new file mode 100644 index 000000000000..66b84bf1a436 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_code_method-case=code_is_used_for_passwordless_login.json @@ -0,0 +1,21 @@ +[ + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010015, + "text": "Send sign in code", + "type": "info" + } + } + } +] 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 000000000000..19765bd501b6 --- /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 000000000000..19765bd501b6 --- /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/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled-case=code_is_used_for_2fa.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled-case=code_is_used_for_2fa.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled-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_enabled-case=code_is_used_for_passwordless_login.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled-case=code_is_used_for_passwordless_login.json new file mode 100644 index 000000000000..66b84bf1a436 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled-case=code_is_used_for_passwordless_login.json @@ -0,0 +1,21 @@ +[ + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010015, + "text": "Send sign in code", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=code_is_used_for_2fa.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=code_is_used_for_2fa.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=code_is_used_for_2fa.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=code_is_used_for_passwordless_login.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=code_is_used_for_passwordless_login.json new file mode 100644 index 000000000000..8e9874d00cbf --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=code_is_used_for_passwordless_login.json @@ -0,0 +1,54 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "text", + "value": "", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070004, + "text": "ID", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010015, + "text": "Send sign in code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + } +] diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor#01-case=code_is_used_for_2fa_and_request_is_2fa.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor#01-case=code_is_used_for_2fa_and_request_is_2fa.json new file mode 100644 index 000000000000..60b142ed4181 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor#01-case=code_is_used_for_2fa_and_request_is_2fa.json @@ -0,0 +1,66 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "text", + "value": "", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [ + { + "id": 1010020, + "text": "We will send a code to fo****@ory.sh. To verify that this is your address please enter it here.", + "type": "info", + "context": { + "masked_to": "fo****@ory.sh" + } + } + ], + "meta": { + "label": { + "id": 1070002, + "text": "", + "type": "info", + "context": { + "title": "" + } + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010019, + "text": "Continue with code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + } +] diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor#01-case=code_is_used_for_passwordless_login_and_request_is_2fa.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor#01-case=code_is_used_for_passwordless_login_and_request_is_2fa.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor#01-case=code_is_used_for_passwordless_login_and_request_is_2fa.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=code_is_used_for_2fa.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=code_is_used_for_2fa.json new file mode 100644 index 000000000000..60b142ed4181 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=code_is_used_for_2fa.json @@ -0,0 +1,66 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "text", + "value": "", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [ + { + "id": 1010020, + "text": "We will send a code to fo****@ory.sh. To verify that this is your address please enter it here.", + "type": "info", + "context": { + "masked_to": "fo****@ory.sh" + } + } + ], + "meta": { + "label": { + "id": 1070002, + "text": "", + "type": "info", + "context": { + "title": "" + } + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010019, + "text": "Continue with code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + } +] diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=code_is_used_for_passwordless_login.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=code_is_used_for_passwordless_login.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=code_is_used_for_passwordless_login.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh-case=code_is_used_for_2fa_and_request_is_2fa_with_refresh.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh-case=code_is_used_for_2fa_and_request_is_2fa_with_refresh.json new file mode 100644 index 000000000000..60b142ed4181 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh-case=code_is_used_for_2fa_and_request_is_2fa_with_refresh.json @@ -0,0 +1,66 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "text", + "value": "", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [ + { + "id": 1010020, + "text": "We will send a code to fo****@ory.sh. To verify that this is your address please enter it here.", + "type": "info", + "context": { + "masked_to": "fo****@ory.sh" + } + } + ], + "meta": { + "label": { + "id": 1070002, + "text": "", + "type": "info", + "context": { + "title": "" + } + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010019, + "text": "Continue with code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + } +] diff --git a/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh-case=code_is_used_for_passwordless_login_and_request_is_2fa_with_refresh.json b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh-case=code_is_used_for_passwordless_login_and_request_is_2fa_with_refresh.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh-case=code_is_used_for_passwordless_login_and_request_is_2fa_with_refresh.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads.json b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads.json index ec1092ad77a6..195ca691e981 100644 --- a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads.json +++ b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads.json @@ -43,8 +43,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } }, diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission.json b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission.json index dbf1dcd2cbb7..a5ab6784616a 100644 --- a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission.json +++ b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission.json @@ -21,6 +21,7 @@ "required": true, "pattern": "[0-9]+", "disabled": false, + "maxlength": 6, "node_type": "input" }, "messages": [], @@ -58,8 +59,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } } diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=api.json index ec1092ad77a6..195ca691e981 100644 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=api.json +++ b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=api.json @@ -43,8 +43,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } }, diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=browser.json index ec1092ad77a6..195ca691e981 100644 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=browser.json +++ b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=browser.json @@ -43,8 +43,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } }, diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=spa.json index ec1092ad77a6..195ca691e981 100644 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=spa.json +++ b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=spa.json @@ -43,8 +43,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } }, diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json index dbf1dcd2cbb7..a5ab6784616a 100644 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json +++ b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json @@ -21,6 +21,7 @@ "required": true, "pattern": "[0-9]+", "disabled": false, + "maxlength": 6, "node_type": "input" }, "messages": [], @@ -58,8 +59,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } } diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json index dbf1dcd2cbb7..a5ab6784616a 100644 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json +++ b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json @@ -21,6 +21,7 @@ "required": true, "pattern": "[0-9]+", "disabled": false, + "maxlength": 6, "node_type": "input" }, "messages": [], @@ -58,8 +59,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } } diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json index dbf1dcd2cbb7..a5ab6784616a 100644 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json +++ b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json @@ -21,6 +21,7 @@ "required": true, "pattern": "[0-9]+", "disabled": false, + "maxlength": 6, "node_type": "input" }, "messages": [], @@ -58,8 +59,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } } diff --git a/selfservice/strategy/code/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads.json b/selfservice/strategy/code/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads.json index 37f61ac9e827..01def57fd58f 100644 --- a/selfservice/strategy/code/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads.json +++ b/selfservice/strategy/code/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads.json @@ -30,8 +30,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } }, diff --git a/selfservice/strategy/code/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads_after_submission.json b/selfservice/strategy/code/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads_after_submission.json index 42456da54dc5..7e7096cd7358 100644 --- a/selfservice/strategy/code/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads_after_submission.json +++ b/selfservice/strategy/code/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads_after_submission.json @@ -44,8 +44,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } } diff --git a/selfservice/strategy/code/strategy.go b/selfservice/strategy/code/strategy.go index ee3ce353e4ae..2d275fdff85f 100644 --- a/selfservice/strategy/code/strategy.go +++ b/selfservice/strategy/code/strategy.go @@ -180,6 +180,7 @@ func (s *Strategy) PopulateMethod(r *http.Request, f flow.Flow) error { if f.GetType() == flow.TypeBrowser { f.GetUI().SetCSRF(s.deps.GenerateCSRFToken(r)) } + return nil } @@ -192,7 +193,7 @@ func (s *Strategy) populateChooseMethodFlow(r *http.Request, f flow.Flow) error node.NewInputField("email", nil, node.CodeGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute). WithMetaLabel(text.NewInfoNodeInputEmail()), ) - codeMetaLabel = text.NewInfoNodeLabelSubmit() + codeMetaLabel = text.NewInfoNodeLabelContinue() case *login.Flow: ds, err := s.deps.Config().DefaultIdentityTraitsSchemaURL(ctx) if err != nil { @@ -299,15 +300,10 @@ func (s *Strategy) populateEmailSentFlow(ctx context.Context, f flow.Flow) error // preserve the login identifier that was submitted // so we can retry the code flow with the same data for _, n := range f.GetUI().Nodes { - if n.Group == node.DefaultGroup { - // we don't need the user to change the values here - // for better UX let's make them disabled - // when there are errors we won't hide the fields - if len(n.Messages) == 0 { - if input, ok := n.Attributes.(*node.InputAttributes); ok { - input.Type = "hidden" - n.Attributes = input - } + if n.ID() == "identifier" { + if input, ok := n.Attributes.(*node.InputAttributes); ok { + input.Type = "hidden" + n.Attributes = input } freshNodes = append(freshNodes, n) } @@ -373,7 +369,7 @@ func (s *Strategy) populateEmailSentFlow(ctx context.Context, f flow.Flow) error // code submit button freshNodes. Append(node.NewInputField("method", s.ID(), node.CodeGroup, node.InputAttributeTypeSubmit). - WithMetaLabel(text.NewInfoNodeLabelSubmit())) + WithMetaLabel(text.NewInfoNodeLabelContinue())) if resendNode != nil { freshNodes.Append(resendNode) diff --git a/selfservice/strategy/code/strategy_login.go b/selfservice/strategy/code/strategy_login.go index a9d7459f5c56..a6d9af4fad0f 100644 --- a/selfservice/strategy/code/strategy_login.go +++ b/selfservice/strategy/code/strategy_login.go @@ -10,6 +10,9 @@ import ( "net/http" "strings" + "github.com/ory/kratos/selfservice/strategy/idfirst" + "github.com/ory/kratos/text" + "github.com/ory/x/sqlcon" "github.com/pkg/errors" @@ -29,7 +32,10 @@ import ( "github.com/ory/x/decoderx" ) -var _ login.Strategy = new(Strategy) +var ( + _ login.FormHydrator = new(Strategy) + _ login.Strategy = new(Strategy) +) // Update Login flow using the code method // @@ -112,10 +118,6 @@ func (s *Strategy) HandleLoginError(r *http.Request, f *login.Flow, body *update return err } -func (s *Strategy) PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, lf *login.Flow) error { - return s.PopulateMethod(r, lf) -} - // findIdentityByIdentifier returns the identity and the code credential for the given identifier. // If the identity does not have a code credential, it will attempt to find // the identity through other credentials matching the identifier. @@ -372,3 +374,41 @@ func (s *Strategy) loginVerifyCode(ctx context.Context, r *http.Request, f *logi return i, nil } + +func (s *Strategy) PopulateLoginMethodFirstFactorRefresh(r *http.Request, f *login.Flow) error { + return s.PopulateMethod(r, f) +} + +func (s *Strategy) PopulateLoginMethodFirstFactor(r *http.Request, f *login.Flow) error { + return s.PopulateMethod(r, f) +} + +func (s *Strategy) PopulateLoginMethodSecondFactor(r *http.Request, f *login.Flow) error { + return s.PopulateMethod(r, f) +} + +func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, f *login.Flow) error { + return s.PopulateMethod(r, f) +} + +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 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 +} + +func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, f *login.Flow) error { + return nil +} diff --git a/selfservice/strategy/code/strategy_login_test.go b/selfservice/strategy/code/strategy_login_test.go index 19cac6d38375..ea30e7256c34 100644 --- a/selfservice/strategy/code/strategy_login_test.go +++ b/selfservice/strategy/code/strategy_login_test.go @@ -11,6 +11,16 @@ import ( "net/http/httptest" "net/url" "testing" + "time" + + "github.com/ory/kratos/selfservice/strategy/idfirst" + + configtesthelpers "github.com/ory/kratos/driver/config/testhelpers" + + "github.com/ory/kratos/driver" + "github.com/ory/kratos/selfservice/flow/login" + + "github.com/ory/kratos/selfservice/flow" "github.com/ory/x/ioutilx" "github.com/ory/x/snapshotx" @@ -32,6 +42,41 @@ import ( "github.com/ory/x/sqlxx" ) +func createIdentity(ctx context.Context, t *testing.T, reg driver.Registry, withoutCodeCredential bool, moreIdentifiers ...string) *identity.Identity { + t.Helper() + i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) + email := testhelpers.RandomEmail() + + ids := fmt.Sprintf(`"email":"%s"`, email) + for i, identifier := range moreIdentifiers { + ids = fmt.Sprintf(`%s,"email_%d":"%s"`, ids, i+1, identifier) + } + + i.Traits = identity.Traits(fmt.Sprintf(`{"tos": true, %s}`, ids)) + + credentials := map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypePassword: {Identifiers: append([]string{email}, moreIdentifiers...), Type: identity.CredentialsTypePassword, Config: sqlxx.JSONRawMessage("{\"some\" : \"secret\"}")}, + identity.CredentialsTypeOIDC: {Type: identity.CredentialsTypeOIDC, Identifiers: append([]string{email}, moreIdentifiers...), Config: sqlxx.JSONRawMessage("{\"some\" : \"secret\"}")}, + identity.CredentialsTypeWebAuthn: {Type: identity.CredentialsTypeWebAuthn, Identifiers: append([]string{email}, moreIdentifiers...), Config: sqlxx.JSONRawMessage("{\"some\" : \"secret\", \"user_handle\": \"rVIFaWRcTTuQLkXFmQWpgA==\"}")}, + } + if !withoutCodeCredential { + credentials[identity.CredentialsTypeCodeAuth] = identity.Credentials{Type: identity.CredentialsTypeCodeAuth, Identifiers: append([]string{email}, moreIdentifiers...), Config: sqlxx.JSONRawMessage("{\"address_type\": \"email\", \"used_at\": \"2023-07-26T16:59:06+02:00\"}")} + } + i.Credentials = credentials + + var va []identity.VerifiableAddress + for _, identifier := range moreIdentifiers { + va = append(va, identity.VerifiableAddress{Value: identifier, Verified: false, Status: identity.VerifiableAddressStatusCompleted}) + } + + va = append(va, identity.VerifiableAddress{Value: email, Verified: true, Status: identity.VerifiableAddressStatusCompleted}) + + i.VerifiableAddresses = va + + require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i)) + return i +} + func TestLoginCodeStrategy(t *testing.T) { ctx := context.Background() conf, reg := internal.NewFastRegistryWithMocks(t) @@ -46,41 +91,6 @@ func TestLoginCodeStrategy(t *testing.T) { public, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg) - createIdentity := func(ctx context.Context, t *testing.T, withoutCodeCredential bool, moreIdentifiers ...string) *identity.Identity { - t.Helper() - i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) - email := testhelpers.RandomEmail() - - ids := fmt.Sprintf(`"email":"%s"`, email) - for i, identifier := range moreIdentifiers { - ids = fmt.Sprintf(`%s,"email_%d":"%s"`, ids, i+1, identifier) - } - - i.Traits = identity.Traits(fmt.Sprintf(`{"tos": true, %s}`, ids)) - - credentials := map[identity.CredentialsType]identity.Credentials{ - identity.CredentialsTypePassword: {Identifiers: append([]string{email}, moreIdentifiers...), Type: identity.CredentialsTypePassword, Config: sqlxx.JSONRawMessage("{\"some\" : \"secret\"}")}, - identity.CredentialsTypeOIDC: {Type: identity.CredentialsTypeOIDC, Identifiers: append([]string{email}, moreIdentifiers...), Config: sqlxx.JSONRawMessage("{\"some\" : \"secret\"}")}, - identity.CredentialsTypeWebAuthn: {Type: identity.CredentialsTypeWebAuthn, Identifiers: append([]string{email}, moreIdentifiers...), Config: sqlxx.JSONRawMessage("{\"some\" : \"secret\", \"user_handle\": \"rVIFaWRcTTuQLkXFmQWpgA==\"}")}, - } - if !withoutCodeCredential { - credentials[identity.CredentialsTypeCodeAuth] = identity.Credentials{Type: identity.CredentialsTypeCodeAuth, Identifiers: append([]string{email}, moreIdentifiers...), Config: sqlxx.JSONRawMessage("{\"address_type\": \"email\", \"used_at\": \"2023-07-26T16:59:06+02:00\"}")} - } - i.Credentials = credentials - - var va []identity.VerifiableAddress - for _, identifier := range moreIdentifiers { - va = append(va, identity.VerifiableAddress{Value: identifier, Verified: false, Status: identity.VerifiableAddressStatusCompleted}) - } - - va = append(va, identity.VerifiableAddress{Value: email, Verified: true, Status: identity.VerifiableAddressStatusCompleted}) - - i.VerifiableAddresses = va - - require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i)) - return i - } - type state struct { flowID string identity *identity.Identity @@ -102,7 +112,7 @@ func TestLoginCodeStrategy(t *testing.T) { createLoginFlow := func(ctx context.Context, t *testing.T, public *httptest.Server, apiType ApiType, withoutCodeCredential bool, moreIdentifiers ...string) *state { t.Helper() - identity := createIdentity(ctx, t, withoutCodeCredential, moreIdentifiers...) + identity := createIdentity(ctx, t, reg, withoutCodeCredential, moreIdentifiers...) var client *http.Client if apiType == ApiTypeNative { @@ -247,9 +257,15 @@ func TestLoginCodeStrategy(t *testing.T) { assert.NotEmpty(t, loginCode) // 3. Submit OTP - submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { + state := submitLogin(ctx, t, s, tc.apiType, func(v *url.Values) { v.Set("code", loginCode) }, true, nil) + if tc.apiType == ApiTypeSPA { + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(state.body, "continue_with.0.action").String(), "%s", state.body) + assert.Contains(t, gjson.Get(state.body, "continue_with.0.redirect_browser_to").String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%s", state.body) + } else { + assert.Empty(t, gjson.Get(state.body, "continue_with").Array(), "%s", state.body) + } }) t.Run("case=new identities automatically have login with code", func(t *testing.T) { @@ -587,14 +603,14 @@ func TestLoginCodeStrategy(t *testing.T) { t.Run("case=should be able to get AAL2 session", func(t *testing.T) { t.Cleanup(testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/default.schema.json")) // doesn't have the code credential - identity := createIdentity(ctx, t, true) + identity := createIdentity(ctx, t, reg, true) var cl *http.Client var f *oryClient.LoginFlow if tc.apiType == ApiTypeNative { - cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, identity) + cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, identity) f = testhelpers.InitializeLoginFlowViaAPI(t, cl, public, false, testhelpers.InitFlowWithAAL("aal2"), testhelpers.InitFlowWithVia("email")) } else { - cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, reg, identity) + cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, ctx, reg, identity) f = testhelpers.InitializeLoginFlowViaBrowser(t, cl, public, false, tc.apiType == ApiTypeSPA, false, false, testhelpers.InitFlowWithAAL("aal2"), testhelpers.InitFlowWithVia("email")) } @@ -627,14 +643,14 @@ func TestLoginCodeStrategy(t *testing.T) { testhelpers.EnsureAAL(t, cl, public, "aal2", "code") }) t.Run("case=cannot use different identifier", func(t *testing.T) { - identity := createIdentity(ctx, t, false) + identity := createIdentity(ctx, t, reg, false) var cl *http.Client var f *oryClient.LoginFlow if tc.apiType == ApiTypeNative { - cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, identity) + cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, identity) f = testhelpers.InitializeLoginFlowViaAPI(t, cl, public, false, testhelpers.InitFlowWithAAL("aal2"), testhelpers.InitFlowWithVia("email")) } else { - cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, reg, identity) + cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, ctx, reg, identity) f = testhelpers.InitializeLoginFlowViaBrowser(t, cl, public, false, tc.apiType == ApiTypeSPA, false, false, testhelpers.InitFlowWithAAL("aal2"), testhelpers.InitFlowWithVia("email")) } @@ -661,14 +677,14 @@ func TestLoginCodeStrategy(t *testing.T) { t.Run("case=verify initial payload", func(t *testing.T) { fixedEmail := fmt.Sprintf("fixed_mfa_test_%s@ory.sh", tc.apiType) - identity := createIdentity(ctx, t, false, fixedEmail) + identity := createIdentity(ctx, t, reg, false, fixedEmail) var cl *http.Client var f *oryClient.LoginFlow if tc.apiType == ApiTypeNative { - cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, identity) + cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, identity) f = testhelpers.InitializeLoginFlowViaAPI(t, cl, public, false, testhelpers.InitFlowWithAAL("aal2"), testhelpers.InitFlowWithVia("email_1")) } else { - cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, reg, identity) + cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, ctx, reg, identity) f = testhelpers.InitializeLoginFlowViaBrowser(t, cl, public, false, tc.apiType == ApiTypeSPA, false, false, testhelpers.InitFlowWithAAL("aal2"), testhelpers.InitFlowWithVia("email_1")) } @@ -678,15 +694,15 @@ func TestLoginCodeStrategy(t *testing.T) { }) t.Run("case=using a non existing identity trait results in an error", func(t *testing.T) { - identity := createIdentity(ctx, t, false) + identity := createIdentity(ctx, t, reg, false) var cl *http.Client var res *http.Response var err error if tc.apiType == ApiTypeNative { - cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, identity) + cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, identity) res, err = cl.Get(public.URL + "/self-service/login/api?aal=aal2&via=doesnt_exist") } else { - cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, reg, identity) + cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, ctx, reg, identity) res, err = cl.Get(public.URL + "/self-service/login/browser?aal=aal2&via=doesnt_exist") } require.NoError(t, err) @@ -699,15 +715,15 @@ func TestLoginCodeStrategy(t *testing.T) { }) t.Run("case=missing via parameter results results in an error", func(t *testing.T) { - identity := createIdentity(ctx, t, false) + identity := createIdentity(ctx, t, reg, false) var cl *http.Client var res *http.Response var err error if tc.apiType == ApiTypeNative { - cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, identity) + cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, identity) res, err = cl.Get(public.URL + "/self-service/login/api?aal=aal2") } else { - cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, reg, identity) + cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, ctx, reg, identity) res, err = cl.Get(public.URL + "/self-service/login/browser?aal=aal2") } require.NoError(t, err) @@ -718,16 +734,17 @@ func TestLoginCodeStrategy(t *testing.T) { } require.Equal(t, "AAL2 login via code requires the `via` query parameter", gjson.GetBytes(body, "reason").String(), "%s", body) }) + t.Run("case=unset trait in identity should lead to an error", func(t *testing.T) { - identity := createIdentity(ctx, t, false) + identity := createIdentity(ctx, t, reg, false) var cl *http.Client var res *http.Response var err error if tc.apiType == ApiTypeNative { - cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, identity) + cl = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, identity) res, err = cl.Get(public.URL + "/self-service/login/api?aal=aal2&via=email_1") } else { - cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, reg, identity) + cl = testhelpers.NewHTTPClientWithIdentitySessionCookieLocalhost(t, ctx, reg, identity) res, err = cl.Get(public.URL + "/self-service/login/browser?aal=aal2&via=email_1") } require.NoError(t, err) @@ -742,3 +759,236 @@ func TestLoginCodeStrategy(t *testing.T) { }) } } + +func TestFormHydration(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeCodeAuth), map[string]interface{}{ + "enabled": true, + "passwordless_enabled": true, + }) + ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/default.schema.json") + + s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsTypeCodeAuth) + require.NoError(t, err) + fh, ok := s.(login.FormHydrator) + require.True(t, ok) + + toSnapshot := func(t *testing.T, f *login.Flow) { + t.Helper() + // The CSRF token has a unique value that messes with the snapshot - ignore it. + f.UI.Nodes.ResetNodes("csrf_token") + snapshotx.SnapshotT(t, f.UI.Nodes) + } + newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *login.Flow) { + r := httptest.NewRequest("GET", "/self-service/login/browser", nil) + r = r.WithContext(ctx) + t.Helper() + f, err := login.NewFlow(conf, time.Minute, "csrf_token", r, flow.TypeBrowser) + require.NoError(t, err) + return r, f + } + + passwordlessEnabled := configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeCodeAuth), map[string]interface{}{ + "enabled": true, + "passwordless_enabled": true, + "mfa_enabled": false, + }) + + mfaEnabled := configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeCodeAuth), map[string]interface{}{ + "enabled": true, + "passwordless_enabled": false, + "mfa_enabled": true, + }) + + toMFARequest := func(r *http.Request, f *login.Flow) { + f.RequestedAAL = identity.AuthenticatorAssuranceLevel2 + r.URL = &url.URL{Path: "/", RawQuery: "via=email"} + // I only fear god. + r.Header = testhelpers.NewHTTPClientWithArbitrarySessionTokenAndTraits(t, ctx, reg, []byte(`{"email":"foo@ory.sh"}`)).Transport.(*testhelpers.TransportWithHeader).GetHeader() + } + + t.Run("method=PopulateLoginMethodSecondFactor", func(t *testing.T) { + test := func(t *testing.T, ctx context.Context) { + r, f := newFlow(ctx, t) + toMFARequest(r, f) + + r.Header = testhelpers.NewHTTPClientWithArbitrarySessionTokenAndTraits(t, ctx, reg, []byte(`{"email":"foo@ory.sh"}`)).Transport.(*testhelpers.TransportWithHeader).GetHeader() + + // We still use the legacy hydrator under the hood here and thus need to set this correctly. + f.RequestedAAL = identity.AuthenticatorAssuranceLevel2 + r.URL = &url.URL{Path: "/", RawQuery: "via=email"} + + require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f)) + toSnapshot(t, f) + } + + t.Run("case=code is used for 2fa", func(t *testing.T) { + test(t, mfaEnabled) + }) + + t.Run("case=code is used for passwordless login", func(t *testing.T) { + test(t, passwordlessEnabled) + }) + }) + + t.Run("method=PopulateLoginMethodFirstFactor", func(t *testing.T) { + t.Run("case=code is used for 2fa but request is 1fa", func(t *testing.T) { + r, f := newFlow(mfaEnabled, t) + f.RequestedAAL = identity.AuthenticatorAssuranceLevel1 + require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f)) + toSnapshot(t, f) + }) + + t.Run("case=code is used for passwordless login and request is 1fa", func(t *testing.T) { + r, f := newFlow(passwordlessEnabled, t) + f.RequestedAAL = identity.AuthenticatorAssuranceLevel1 + require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f)) + toSnapshot(t, f) + }) + }) + + t.Run("method=PopulateLoginMethodFirstFactorRefresh", func(t *testing.T) { + t.Run("case=code is used for passwordless login and request is 1fa with refresh", func(t *testing.T) { + r, f := newFlow(passwordlessEnabled, t) + f.RequestedAAL = identity.AuthenticatorAssuranceLevel1 + f.Refresh = true + require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("case=code is used for 2fa and request is 1fa with refresh", func(t *testing.T) { + r, f := newFlow(mfaEnabled, t) + f.RequestedAAL = identity.AuthenticatorAssuranceLevel1 + f.Refresh = true + require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f)) + toSnapshot(t, f) + }) + }) + + t.Run("method=PopulateLoginMethodSecondFactor", func(t *testing.T) { + t.Run("case=code is used for 2fa and request is 2fa", func(t *testing.T) { + r, f := newFlow(mfaEnabled, t) + toMFARequest(r, f) + require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f)) + toSnapshot(t, f) + }) + + t.Run("case=code is used for passwordless login and request is 2fa", func(t *testing.T) { + r, f := newFlow(passwordlessEnabled, t) + toMFARequest(r, f) + require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f)) + toSnapshot(t, f) + }) + }) + + t.Run("method=PopulateLoginMethodSecondFactorRefresh", func(t *testing.T) { + t.Run("case=code is used for 2fa and request is 2fa with refresh", func(t *testing.T) { + r, f := newFlow(mfaEnabled, t) + toMFARequest(r, f) + f.Refresh = true + require.NoError(t, fh.PopulateLoginMethodSecondFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("case=code is used for passwordless login and request is 2fa with refresh", func(t *testing.T) { + r, f := newFlow(passwordlessEnabled, t) + toMFARequest(r, f) + f.Refresh = true + require.NoError(t, fh.PopulateLoginMethodSecondFactorRefresh(r, f)) + toSnapshot(t, f) + }) + }) + + t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) { + t.Run("case=no options", func(t *testing.T) { + t.Run("case=code is used for 2fa", func(t *testing.T) { + r, f := newFlow(mfaEnabled, t) + require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f)) + 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.PopulateLoginMethodFirstFactor(r, f)) + 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) { + 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=code is used for passwordless login", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true), + t, + ) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com"))) + toSnapshot(t, f) + }) + }) + + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + 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) + + 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.WithIdentityHint(id)), 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.WithIdentityHint(id))) + toSnapshot(t, f) + }) + }) + + t.Run("case=identity does not have a code method", func(t *testing.T) { + id := identity.NewIdentity("default") + + 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.WithIdentityHint(id)), 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.WithIdentityHint(id))) + toSnapshot(t, f) + }) + }) + }) + }) + }) + + t.Run("method=PopulateLoginMethodIdentifierFirstIdentification", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstIdentification(r, f)) + toSnapshot(t, f) + }) +} diff --git a/selfservice/strategy/code/strategy_recovery.go b/selfservice/strategy/code/strategy_recovery.go index 758e81d04fd9..f33356f2df31 100644 --- a/selfservice/strategy/code/strategy_recovery.go +++ b/selfservice/strategy/code/strategy_recovery.go @@ -43,7 +43,7 @@ func (s *Strategy) PopulateRecoveryMethod(r *http.Request, f *recovery.Flow) err f.UI. GetNodes(). Append(node.NewInputField("method", s.RecoveryStrategyID(), node.CodeGroup, node.InputAttributeTypeSubmit). - WithMetaLabel(text.NewInfoNodeLabelSubmit())) + WithMetaLabel(text.NewInfoNodeLabelContinue())) return nil } @@ -235,12 +235,13 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, } if s.deps.Config().UseContinueWithTransitions(ctx) { + redirectTo := sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String() switch { case f.Type.IsAPI(), x.IsJSONRequest(r): - f.ContinueWith = append(f.ContinueWith, flow.NewContinueWithSettingsUI(sf)) + f.ContinueWith = append(f.ContinueWith, flow.NewContinueWithSettingsUI(sf, redirectTo)) s.deps.Writer().Write(w, r, f) default: - http.Redirect(w, r, sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String(), http.StatusSeeOther) + http.Redirect(w, r, redirectTo, http.StatusSeeOther) } } else { if x.IsJSONRequest(r) { @@ -405,6 +406,7 @@ func (s *Strategy) recoveryHandleFormSubmission(w http.ResponseWriter, r *http.R f.UI.Nodes.Append(node.NewInputField("code", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithInputAttributes(func(a *node.InputAttributes) { a.Required = true a.Pattern = "[0-9]+" + a.MaxLength = CodeLength })). WithMetaLabel(text.NewInfoNodeLabelRecoveryCode()), ) @@ -413,7 +415,7 @@ func (s *Strategy) recoveryHandleFormSubmission(w http.ResponseWriter, r *http.R f.UI. GetNodes(). Append(node.NewInputField("method", s.RecoveryStrategyID(), node.CodeGroup, node.InputAttributeTypeSubmit). - WithMetaLabel(text.NewInfoNodeLabelSubmit())) + WithMetaLabel(text.NewInfoNodeLabelContinue())) f.UI.Nodes.Append(node.NewInputField("email", body.Email, node.CodeGroup, node.InputAttributeTypeSubmit). WithMetaLabel(text.NewInfoNodeResendOTP()), diff --git a/selfservice/strategy/code/strategy_recovery_admin.go b/selfservice/strategy/code/strategy_recovery_admin.go index 028bb811bcaa..63aa36a90edd 100644 --- a/selfservice/strategy/code/strategy_recovery_admin.go +++ b/selfservice/strategy/code/strategy_recovery_admin.go @@ -178,13 +178,16 @@ func (s *Strategy) createRecoveryCodeForIdentity(w http.ResponseWriter, r *http. recoveryFlow.DangerousSkipCSRFCheck = true recoveryFlow.State = flow.StateEmailSent recoveryFlow.UI.Nodes = node.Nodes{} - recoveryFlow.UI.Nodes.Append(node.NewInputField("code", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). + recoveryFlow.UI.Nodes.Append(node.NewInputField("code", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute, node.WithInputAttributes(func(a *node.InputAttributes) { + a.Pattern = "[0-9]+" + a.MaxLength = CodeLength + })). WithMetaLabel(text.NewInfoNodeLabelRecoveryCode()), ) recoveryFlow.UI.Nodes. Append(node.NewInputField("method", s.RecoveryStrategyID(), node.CodeGroup, node.InputAttributeTypeSubmit). - WithMetaLabel(text.NewInfoNodeLabelSubmit())) + WithMetaLabel(text.NewInfoNodeLabelContinue())) if err := s.deps.RecoveryFlowPersister().CreateRecoveryFlow(ctx, recoveryFlow); err != nil { s.deps.Writer().WriteError(w, r, err) diff --git a/selfservice/strategy/code/strategy_recovery_test.go b/selfservice/strategy/code/strategy_recovery_test.go index 98f3d9c7f21c..c5016b28710b 100644 --- a/selfservice/strategy/code/strategy_recovery_test.go +++ b/selfservice/strategy/code/strategy_recovery_test.go @@ -532,7 +532,7 @@ func TestRecovery(t *testing.T) { require.NoError(t, err) // Add the authentication to the request - client.Transport = testhelpers.NewTransportWithLogger(testhelpers.NewAuthorizedTransport(t, reg, session), t).RoundTripper + client.Transport = testhelpers.NewTransportWithLogger(testhelpers.NewAuthorizedTransport(t, ctx, reg, session), t).RoundTripper v := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) v.Set("email", "some-email@example.org") @@ -1373,7 +1373,7 @@ func TestRecovery_WithContinueWith(t *testing.T) { require.NoError(t, err) // Add the authentication to the request - client.Transport = testhelpers.NewTransportWithLogger(testhelpers.NewAuthorizedTransport(t, reg, session), t).RoundTripper + client.Transport = testhelpers.NewTransportWithLogger(testhelpers.NewAuthorizedTransport(t, ctx, reg, session), t).RoundTripper v := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) v.Set("email", "some-email@example.org") diff --git a/selfservice/strategy/code/strategy_registration_test.go b/selfservice/strategy/code/strategy_registration_test.go index 27c645a94190..0b6caaa15da1 100644 --- a/selfservice/strategy/code/strategy_registration_test.go +++ b/selfservice/strategy/code/strategy_registration_test.go @@ -15,6 +15,8 @@ import ( "strings" "testing" + "github.com/ory/kratos/selfservice/flow" + "github.com/gobuffalo/pop/v6" "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" @@ -37,6 +39,7 @@ type state struct { email string testServer *httptest.Server resultIdentity *identity.Identity + body string } func TestRegistrationCodeStrategyDisabled(t *testing.T) { @@ -172,6 +175,7 @@ func TestRegistrationCodeStrategy(t *testing.T) { values.Set("method", "code") body, resp := testhelpers.RegistrationMakeRequest(t, apiType == ApiTypeNative, apiType == ApiTypeSPA, rf, s.client, testhelpers.EncodeFormAsJSON(t, apiType == ApiTypeNative, values)) + s.body = body if submitAssertion != nil { submitAssertion(ctx, t, s, body, resp) @@ -213,6 +217,7 @@ func TestRegistrationCodeStrategy(t *testing.T) { vals(&values) body, resp := testhelpers.RegistrationMakeRequest(t, apiType == ApiTypeNative, apiType == ApiTypeSPA, rf, s.client, testhelpers.EncodeFormAsJSON(t, apiType == ApiTypeNative, values)) + s.body = body if submitAssertion != nil { submitAssertion(ctx, t, s, body, resp) @@ -240,7 +245,7 @@ func TestRegistrationCodeStrategy(t *testing.T) { t.Parallel() ctx := context.Background() - _, reg, public := setup(ctx, t) + conf, reg, public := setup(ctx, t) for _, tc := range []struct { d string @@ -279,6 +284,15 @@ func TestRegistrationCodeStrategy(t *testing.T) { state = submitOTP(ctx, t, reg, state, func(v *url.Values) { v.Set("code", registrationCode) }, tc.apiType, nil) + + if tc.apiType == ApiTypeSPA { + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(state.body, "continue_with.0.action").String(), "%s", state.body) + assert.Contains(t, gjson.Get(state.body, "continue_with.0.redirect_browser_to").String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%s", state.body) + } else if tc.apiType == ApiTypeSPA { + assert.Empty(t, gjson.Get(state.body, "continue_with").Array(), "%s", state.body) + } else if tc.apiType == ApiTypeNative { + assert.NotContains(t, gjson.Get(state.body, "continue_with").Raw, string(flow.ContinueWithActionRedirectBrowserToString), "%s", state.body) + } }) t.Run("case=should normalize email address on sign up", func(t *testing.T) { diff --git a/selfservice/strategy/idfirst/.schema/login.schema.json b/selfservice/strategy/idfirst/.schema/login.schema.json new file mode 100644 index 000000000000..02ebd4ca9d9f --- /dev/null +++ b/selfservice/strategy/idfirst/.schema/login.schema.json @@ -0,0 +1,24 @@ +{ + "$id": "https://schemas.ory.sh/kratos/selfservice/strategy/identity_disovery/login.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "csrf_token": { + "type": "string" + }, + "identifier": { + "type": "string", + "minLength": 1 + }, + "method": { + "type": "string", + "enum": [ + "identifier_first" + ] + }, + "transient_payload": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor.json b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh.json b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier.json b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_password.json b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_password.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_password.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_password.json b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_password.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_password.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options.json b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json new file mode 100644 index 000000000000..086d65ade752 --- /dev/null +++ b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json @@ -0,0 +1,54 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "identifier_first", + "attributes": { + "name": "identifier", + "type": "text", + "value": "", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070004, + "text": "ID", + "type": "info" + } + } + }, + { + "type": "input", + "group": "identifier_first", + "attributes": { + "name": "method", + "type": "submit", + "value": "identifier_first", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070009, + "text": "Continue", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh.json b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor.json b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/idfirst/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/idfirst/schema.go b/selfservice/strategy/idfirst/schema.go new file mode 100644 index 000000000000..77a0d37f54d7 --- /dev/null +++ b/selfservice/strategy/idfirst/schema.go @@ -0,0 +1,11 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package idfirst + +import ( + _ "embed" +) + +//go:embed .schema/login.schema.json +var loginSchema []byte diff --git a/selfservice/strategy/idfirst/strategy.go b/selfservice/strategy/idfirst/strategy.go new file mode 100644 index 000000000000..b4590ce45634 --- /dev/null +++ b/selfservice/strategy/idfirst/strategy.go @@ -0,0 +1,68 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package idfirst + +import ( + "context" + + "github.com/go-playground/validator/v10" + + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/selfservice/flow/login" + "github.com/ory/kratos/session" + "github.com/ory/kratos/ui/node" + "github.com/ory/kratos/x" + "github.com/ory/x/decoderx" +) + +type dependencies interface { + x.LoggingProvider + x.WriterProvider + x.CSRFTokenGeneratorProvider + x.CSRFProvider + + config.Provider + + identity.PrivilegedPoolProvider + login.StrategyProvider + login.FlowPersistenceProvider +} + +type Strategy struct { + d dependencies + v *validator.Validate + hd *decoderx.HTTP +} + +func NewStrategy(d any) *Strategy { + return &Strategy{ + d: d.(dependencies), + v: validator.New(), + hd: decoderx.NewHTTP(), + } +} + +func (s *Strategy) CountActiveFirstFactorCredentials(cc map[identity.CredentialsType]identity.Credentials) (count int, err error) { + return 0, nil +} + +func (s *Strategy) CountActiveMultiFactorCredentials(cc map[identity.CredentialsType]identity.Credentials) (count int, err error) { + return 0, nil +} + +func (s *Strategy) ID() identity.CredentialsType { + return identity.CredentialsType(node.IdentifierFirstGroup) +} + +func (s *Strategy) CompletedAuthenticationMethod(ctx context.Context, _ session.AuthenticationMethods) session.AuthenticationMethod { + return session.AuthenticationMethod{ + Method: s.ID(), + AAL: identity.AuthenticatorAssuranceLevel1, + } +} + +func (s *Strategy) NodeGroup() node.UiNodeGroup { + return node.IdentifierFirstGroup +} diff --git a/selfservice/strategy/idfirst/strategy_login.go b/selfservice/strategy/idfirst/strategy_login.go new file mode 100644 index 000000000000..d479ae1a5b05 --- /dev/null +++ b/selfservice/strategy/idfirst/strategy_login.go @@ -0,0 +1,187 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package idfirst + +import ( + "net/http" + + "github.com/ory/kratos/schema" + + "github.com/pkg/errors" + + "github.com/ory/kratos/identity" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/login" + "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/sqlcon" +) + +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 { + f.UI.Nodes.SetValueAttribute("identifier", payload.Identifier) + if f.Type == flow.TypeBrowser { + f.UI.SetCSRF(s.d.GenerateCSRFToken(r)) + } + } + + return err +} + +func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, _ *session.Session) (_ *identity.Identity, err error) { + if !s.d.Config().SelfServiceLoginFlowIdentifierFirstEnabled(r.Context()) { + return nil, errors.WithStack(flow.ErrStrategyNotResponsible) + } + + if err := login.CheckAAL(f, identity.AuthenticatorAssuranceLevel1); err != nil { + return nil, err + } + + var p updateLoginFlowWithIdentifierFirstMethod + if err := s.hd.Decode(r, &p, + decoderx.HTTPDecoderSetValidatePayloads(true), + decoderx.MustHTTPRawJSONSchemaCompiler(loginSchema), + decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil { + return nil, s.handleLoginError(w, 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) + } + + var opts []login.FormHydratorModifier + + // Look up the user by the identifier. + identityHint, err := s.d.PrivilegedIdentityPool().FindIdentityByCredentialIdentifier(r.Context(), p.Identifier, + // We are dealing with user input -> lookup should be case-insensitive. + false, + ) + if errors.Is(err, sqlcon.ErrNoRows) { + // If the user is not found, we still want to potentially show the UI for some method. That's why we don't exit here. + // We have to mitigate account enumeration. So we continue without setting the identity hint. + // + // This will later be handled by `didPopulate`. + } else if err != nil { + // An error happened during lookup + return nil, s.handleLoginError(w, r, f, &p, err) + } else if !s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + // Hydrate credentials + if err := s.d.PrivilegedIdentityPool().HydrateIdentityAssociations(r.Context(), identityHint, identity.ExpandCredentials); err != nil { + return nil, s.handleLoginError(w, r, f, &p, err) + } + } + + f.UI.ResetMessages() + f.UI.Nodes.SetValueAttribute("identifier", p.Identifier) + + // 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()) { + populator, ok := ls.(login.FormHydrator) + if !ok { + continue + } + + if err := populator.PopulateLoginMethodIdentifierFirstCredentials(r, f, opts...); errors.Is(err, login.ErrBreakLoginPopulate) { + didPopulate = true + break + } else if errors.Is(err, ErrNoCredentialsFound) { + // This strategy is not responsible for this flow. We do not set didPopulate to true if that happens. + } else if err != nil { + return nil, s.handleLoginError(w, r, f, &p, err) + } else { + didPopulate = true + } + } + + // If no strategy populated, it means that the account (very likely) does not exist. We show a user not found error, + // but only if account enumeration mitigation is disabled. Otherwise, we proceed to render the rest of the form. + if !didPopulate && !s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + return nil, s.handleLoginError(w, r, f, &p, errors.WithStack(schema.NewAccountNotFoundError())) + } + + // We found credentials - hide the identifier. + f.UI.GetNodes().RemoveMatching(node.NewInputField("method", s.ID(), s.NodeGroup(), node.InputAttributeTypeSubmit)) + + // We set the identifier to hidden, so it's still available in the form but not visible to the user. + for k, n := range f.UI.Nodes { + if n.ID() != "identifier" { + continue + } + + attrs, ok := f.UI.Nodes[k].Attributes.(*node.InputAttributes) + if !ok { + continue + } + + attrs.Type = node.InputAttributeTypeHidden + f.UI.Nodes[k].Attributes = attrs + } + + f.Active = s.ID() + if err = s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), f); err != nil { + return nil, s.handleLoginError(w, r, f, &p, err) + } + + if x.IsJSONRequest(r) { + s.d.Writer().WriteCode(w, r, http.StatusBadRequest, f) + } else { + http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowLoginUI(r.Context())).String(), http.StatusSeeOther) + } + + return nil, flow.ErrCompletedByStrategy +} + +func (s *Strategy) PopulateLoginMethodFirstFactorRefresh(r *http.Request, sr *login.Flow) error { + return nil +} + +func (s *Strategy) PopulateLoginMethodFirstFactor(r *http.Request, sr *login.Flow) error { + return nil +} + +func (s *Strategy) PopulateLoginMethodSecondFactor(r *http.Request, sr *login.Flow) error { + return nil +} + +func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, sr *login.Flow) error { + return nil +} + +func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, f *login.Flow) error { + f.UI.SetCSRF(s.d.GenerateCSRFToken(r)) + + ds, err := s.d.Config().DefaultIdentityTraitsSchemaURL(r.Context()) + if err != nil { + return err + } + + identifierLabel, err := login.GetIdentifierLabelFromSchema(r.Context(), ds.String()) + if err != nil { + return err + } + + f.UI.SetNode(node.NewInputField("identifier", "", s.NodeGroup(), node.InputAttributeTypeText, node.WithRequiredInputAttribute).WithMetaLabel(identifierLabel)) + f.UI.GetNodes().Append(node.NewInputField("method", s.ID(), s.NodeGroup(), node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelContinue())) + return nil +} + +func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(_ *http.Request, f *login.Flow, opts ...login.FormHydratorModifier) error { + return ErrNoCredentialsFound +} + +func (s *Strategy) RegisterLoginRoutes(_ *x.RouterPublic) {} diff --git a/selfservice/strategy/idfirst/strategy_login_test.go b/selfservice/strategy/idfirst/strategy_login_test.go new file mode 100644 index 000000000000..4b69a2b44fd4 --- /dev/null +++ b/selfservice/strategy/idfirst/strategy_login_test.go @@ -0,0 +1,594 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package idfirst_test + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/ory/kratos/selfservice/strategy/oidc" + + "github.com/ory/kratos/selfservice/strategy/idfirst" + + configtesthelpers "github.com/ory/kratos/driver/config/testhelpers" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" + + kratos "github.com/ory/kratos/internal/httpclient" + "github.com/ory/kratos/text" + "github.com/ory/kratos/x" + "github.com/ory/x/assertx" + "github.com/ory/x/ioutilx" + "github.com/ory/x/urlx" + + "github.com/stretchr/testify/require" + + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/login" + "github.com/ory/kratos/ui/node" + "github.com/ory/x/snapshotx" +) + +//go:embed stub/default.schema.json +var loginSchema []byte + +func TestCompleteLogin(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(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}) + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword), map[string]interface{}{"enabled": true}) + + // ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceLoginFlowStyle, "identifier_first") + conf.MustSet(ctx, config.ViperKeySelfServiceLoginFlowStyle, "identifier_first") + + router := x.NewRouterPublic() + publicTS, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, x.NewRouterAdmin()) + + errTS := testhelpers.NewErrorTestServer(t, reg) + uiTS := testhelpers.NewLoginUIFlowEchoServer(t, reg) + redirTS := testhelpers.NewRedirSessionEchoTS(t, reg) + + // Overwrite these two: + // 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") + conf.MustSet(ctx, config.ViperKeySelfServiceLoginUI, uiTS.URL+"/login-ts") + + // ctx = testhelpers.WithDefaultIdentitySchemaFromRaw(ctx, loginSchema) + testhelpers.SetDefaultIdentitySchemaFromRaw(conf, loginSchema) + + // 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) { + // registrationhelpers.CheckFormContent(t, body, "identifier", + // "password", + // "csrf_token") + //} + + apiClient := testhelpers.NewDebugClient(t) + + t.Run("case=should show the error ui because the request payload is malformed", func(t *testing.T) { + t.Run("type=api", func(t *testing.T) { + f := testhelpers.InitializeLoginFlowViaAPIWithContext(t, ctx, apiClient, publicTS, false) + + body, res := testhelpers.LoginMakeRequestWithContext(t, ctx, true, false, f, apiClient, "14=)=!(%)$/ZP()GHIÖ") + assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow) + assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body) + assert.Contains(t, body, `Expected JSON sent in request body to be an object but got: Number`) + }) + + t.Run("type=browser", func(t *testing.T) { + browserClient := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, false, false, false, testhelpers.InitFlowWithContext(ctx)) + + body, res := testhelpers.LoginMakeRequestWithContext(t, ctx, false, false, f, browserClient, "14=)=!(%)$/ZP()GHIÖ") + assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/login-ts") + assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body) + assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "invalid URL escape", "%s", body) + }) + + t.Run("type=spa", func(t *testing.T) { + browserClient := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, true, false, false, testhelpers.InitFlowWithContext(ctx)) + + body, res := testhelpers.LoginMakeRequestWithContext(t, ctx, false, true, f, browserClient, "14=)=!(%)$/ZP()GHIÖ") + assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow) + assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body) + assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "invalid URL escape", "%s", body) + }) + }) + + t.Run("case=should fail because identifier first can not handle AAL2", func(t *testing.T) { + f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false) + + update, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(f.Id)) + require.NoError(t, err) + update.RequestedAAL = identity.AuthenticatorAssuranceLevel2 + require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(context.Background(), update)) + + req, err := http.NewRequest("POST", f.Ui.Action, bytes.NewBufferString(`{"method":"identifier_first"}`)) + require.NoError(t, err) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + actual, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router.Router, req) + assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow) + assert.Equal(t, text.NewErrorValidationLoginNoStrategyFound().Text, gjson.GetBytes(actual, "ui.messages.0.text").String()) + }) + + t.Run("should return an error because the request does not exist", func(t *testing.T) { + check := func(t *testing.T, actual string) { + assert.Equal(t, int64(http.StatusNotFound), gjson.Get(actual, "code").Int(), "%s", actual) + assert.Equal(t, "Not Found", gjson.Get(actual, "status").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "message").String(), "Unable to locate the resource", "%s", actual) + } + + fakeFlow := &kratos.LoginFlow{ + Ui: kratos.UiContainer{ + Action: publicTS.URL + login.RouteSubmitFlow + "?flow=" + x.NewUUID().String(), + }, + } + + t.Run("type=api", func(t *testing.T) { + actual, res := testhelpers.LoginMakeRequestWithContext(t, ctx, true, false, fakeFlow, apiClient, "{}") + assert.Len(t, res.Cookies(), 0) + assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow) + check(t, gjson.Get(actual, "error").Raw) + }) + + t.Run("type=browser", func(t *testing.T) { + browserClient := testhelpers.NewClientWithCookies(t) + actual, res := testhelpers.LoginMakeRequestWithContext(t, ctx, false, false, fakeFlow, browserClient, "") + assert.Contains(t, res.Request.URL.String(), errTS.URL) + check(t, actual) + }) + + t.Run("type=api", func(t *testing.T) { + actual, res := testhelpers.LoginMakeRequestWithContext(t, ctx, false, true, fakeFlow, apiClient, "{}") + assert.Len(t, res.Cookies(), 0) + assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow) + check(t, gjson.Get(actual, "error").Raw) + }) + }) + + t.Run("case=should return an error because the request is expired", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeySelfServiceLoginRequestLifespan, time.Millisecond*10) + conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySelfServiceLoginRequestLifespan, time.Hour) + conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, nil) + }) + + values := url.Values{ + "csrf_token": {x.FakeCSRFToken}, + "identifier": {"identifier"}, + "method": {"identifier_first"}, + } + + t.Run("type=api", func(t *testing.T) { + f := testhelpers.InitializeLoginFlowViaAPIWithContext(t, ctx, apiClient, publicTS, false) + + time.Sleep(time.Millisecond * 60) + actual, res := testhelpers.LoginMakeRequestWithContext(t, ctx, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values)) + assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow) + assert.NotEqual(t, "00000000-0000-0000-0000-000000000000", gjson.Get(actual, "use_flow_id").String()) + assertx.EqualAsJSONExcept(t, flow.NewFlowExpiredError(time.Now()), json.RawMessage(actual), []string{"use_flow_id", "since", "expired_at"}, "expired", "%s", actual) + }) + + t.Run("type=browser", func(t *testing.T) { + browserClient := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, false, false, false) + + time.Sleep(time.Millisecond * 60) + actual, res := testhelpers.LoginMakeRequestWithContext(t, ctx, false, false, f, browserClient, values.Encode()) + assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/login-ts") + assert.NotEqual(t, f.Id, gjson.Get(actual, "id").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "ui.messages.0.text").String(), "expired", "%s", actual) + }) + + t.Run("type=SPA", func(t *testing.T) { + browserClient := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, true, false, false) + + time.Sleep(time.Millisecond * 60) + actual, res := testhelpers.LoginMakeRequestWithContext(t, ctx, false, true, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values)) + assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow) + assert.NotEqual(t, "00000000-0000-0000-0000-000000000000", gjson.Get(actual, "use_flow_id").String()) + assertx.EqualAsJSONExcept(t, flow.NewFlowExpiredError(time.Now()), json.RawMessage(actual), []string{"use_flow_id", "since", "expired_at"}, "expired", "%s", actual) + }) + }) + + t.Run("case=should have correct CSRF behavior", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, nil) + }) + + values := url.Values{ + "method": {"identifier_first"}, + "csrf_token": {"invalid_token"}, + "identifier": {"login-identifier-csrf-browser"}, + } + + t.Run("case=should fail because of missing CSRF token/type=browser", func(t *testing.T) { + browserClient := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, false, false, false) + + actual, res := testhelpers.LoginMakeRequest(t, false, false, f, browserClient, values.Encode()) + assert.EqualValues(t, http.StatusOK, res.StatusCode) + assertx.EqualAsJSON(t, x.ErrInvalidCSRFToken, + json.RawMessage(actual), "%s", actual) + }) + + t.Run("case=should fail because of missing CSRF token/type=spa", func(t *testing.T) { + browserClient := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, true, false, false) + + actual, res := testhelpers.LoginMakeRequest(t, false, true, f, browserClient, values.Encode()) + assert.EqualValues(t, http.StatusForbidden, res.StatusCode) + assertx.EqualAsJSON(t, x.ErrInvalidCSRFToken, + json.RawMessage(gjson.Get(actual, "error").Raw), "%s", actual) + }) + + t.Run("case=should pass even without CSRF token/type=api", func(t *testing.T) { + f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false) + + actual, res := testhelpers.LoginMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values)) + assert.EqualValues(t, http.StatusBadRequest, res.StatusCode) + assert.Contains(t, actual, "1010022") + }) + + t.Run("case=should fail with correct CSRF error cause/type=api", func(t *testing.T) { + for k, tc := range []struct { + mod func(http.Header) + exp string + }{ + { + mod: func(h http.Header) { + h.Add("Cookie", "name=bar") + }, + exp: "The HTTP Request Header included the \\\"Cookie\\\" key", + }, + { + mod: func(h http.Header) { + h.Add("Origin", "www.bar.com") + }, + exp: "The HTTP Request Header included the \\\"Origin\\\" key", + }, + } { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false) + + req := testhelpers.NewRequest(t, true, "POST", f.Ui.Action, bytes.NewBufferString(testhelpers.EncodeFormAsJSON(t, true, values))) + tc.mod(req.Header) + + res, err := apiClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + + actual := string(ioutilx.MustReadAll(res.Body)) + assert.EqualValues(t, http.StatusBadRequest, res.StatusCode) + assert.Contains(t, actual, tc.exp) + }) + } + }) + }) + + expectValidationError := func(t *testing.T, isAPI, refresh, isSPA bool, values func(url.Values)) string { + return testhelpers.SubmitLoginForm(t, isAPI, nil, publicTS, values, + isSPA, refresh, + testhelpers.ExpectStatusCode(isAPI || isSPA, http.StatusBadRequest, http.StatusOK), + testhelpers.ExpectURL(isAPI || isSPA, publicTS.URL+login.RouteSubmitFlow, conf.SelfServiceFlowLoginUI(ctx).String())) + } + + t.Run("should return an error because the user does not exist", func(t *testing.T) { + // In this test we check if the account mitigation behaves correctly by enabling all login strategies EXCEPT + // for the passwordless code strategy. That is because this strategy always shows the login button. + + testhelpers.StrategyEnable(t, conf, identity.CredentialsTypePassword.String(), true) + + testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeOIDC.String(), true) + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC)+".config", &oidc.ConfigurationCollection{Providers: []oidc.Configuration{ + { + ID: "google", + Provider: "google", + Label: "Google", + ClientID: "a", + ClientSecret: "b", + Mapper: "file://", + }, + }}) + + testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeWebAuthn.String(), true) + conf.MustSet(ctx, config.ViperKeyWebAuthnPasswordless, true) + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config.rp.display_name", "Ory Corp") + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config.rp.id", "localhost") + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config.rp.origin", "http://localhost:4455") + + testhelpers.StrategyEnable(t, conf, identity.CredentialsTypePasskey.String(), true) + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".enabled", true) + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config.rp.display_name", "Ory Corp") + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config.rp.id", "localhost") + conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config.rp.origins", []string{"http://localhost:4455"}) + + t.Cleanup(func() { + conf.MustSet(ctx, "selfservice.methods.password", nil) + conf.MustSet(ctx, "selfservice.methods.oidc", nil) + conf.MustSet(ctx, "selfservice.methods.passkey", nil) + conf.MustSet(ctx, "selfservice.methods.webauthn", nil) + conf.MustSet(ctx, "selfservice.methods.code", nil) + }) + + t.Run("account enumeration mitigation enabled", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) + + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, nil) + }) + + check := func(t *testing.T, body string, isAPI bool) { + t.Logf("%s", body) + if !isAPI { + assert.Contains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginWebAuthn), "we do expect to see a webauthn trigger:\n%s", body) + assert.Contains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginPasskey), "we do expect to see a passkey trigger button:\n%s", body) + } + + assert.Equal(t, "hidden", gjson.Get(body, "ui.nodes.#(attributes.name==identifier).attributes.type").String(), "identifier is hidden to appear that we found an identity even though we did not") + + assert.NotContains(t, body, text.NewErrorValidationAccountNotFound().Text, "we do not expect to see an account not found error:\n%s", body) + + assert.Contains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginPassword), "we do expect to see a password trigger:\n%s", body) + + // We do expect to see the same social sign in buttons that were on the first page: + assert.Contains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginWith), "we do expect to see a oidc trigger:\n%s", body) + assert.Contains(t, body, "google", "we do expect to see a google trigger:\n%s", body) + } + + values := func(v url.Values) { + v.Set("identifier", "identifier") + v.Set("method", "identifier_first") + } + + t.Run("type=browser", func(t *testing.T) { + check(t, expectValidationError(t, false, false, false, values), false) + }) + + t.Run("type=SPA", func(t *testing.T) { + check(t, expectValidationError(t, false, false, true, values), false) + }) + + t.Run("type=api", func(t *testing.T) { + check(t, expectValidationError(t, true, false, false, values), true) + }) + }) + + t.Run("account enumeration mitigation disabled", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false) + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySecurityAccountEnumerationMitigate, nil) + }) + + check := func(t *testing.T, body string) { + t.Logf("%s", body) + + assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body) + assert.Contains(t, gjson.Get(body, "ui.action").String(), publicTS.URL+login.RouteSubmitFlow, "%s", body) + assert.Contains(t, body, text.NewErrorValidationAccountNotFound().Text, "we do expect to see an error that the account does not exist: %s", body) + + assert.Equal(t, "text", gjson.Get(body, "ui.nodes.#(attributes.name==identifier).attributes.type").String(), "identifier is not hidden and we can see the input field as well") + + assert.NotContains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginPasskey), "we do not expect to see a passkey trigger button: %s", body) + assert.NotContains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginWebAuthn), "we do not expect to see a webauthn trigger: %s", body) + assert.NotContains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginPassword), "we do not expect to see a password trigger: %s", body) + + assert.NotContains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginWith), "we do not expect to see a oidc trigger: %s", body) + assert.NotContains(t, body, "google", "we do not expect to see a google trigger: %s", body) + } + + values := func(v url.Values) { + v.Set("identifier", "identifier") + v.Set("method", "identifier_first") + } + + t.Run("type=browser", func(t *testing.T) { + check(t, expectValidationError(t, false, false, false, values)) + }) + + t.Run("type=SPA", func(t *testing.T) { + check(t, expectValidationError(t, false, false, true, values)) + }) + + t.Run("type=api", func(t *testing.T) { + check(t, expectValidationError(t, true, false, false, values)) + }) + }) + }) + + t.Run("should pass with real request", func(t *testing.T) { + identifier, pwd := x.NewUUID().String(), "password" + createIdentity(ctx, reg, t, identifier, pwd) + + firstValues := func(v url.Values) { + v.Set("identifier", identifier) + v.Set("method", "identifier_first") + } + + secondValues := func(v url.Values) { + v.Set("identifier", identifier) + v.Set("password", pwd) + v.Set("method", "password") + } + + t.Run("type=browser", func(t *testing.T) { + browserClient := testhelpers.NewClientWithCookies(t) + + secondStep := testhelpers.SubmitLoginForm(t, false, browserClient, publicTS, firstValues, + true, false, http.StatusBadRequest, publicTS.URL+login.RouteSubmitFlow) + t.Logf("secondStep: %s", secondStep) + assert.Contains(t, secondStep, "current-password") + assert.Contains(t, secondStep, `"value":"password"`) + + body := testhelpers.SubmitLoginForm(t, false, browserClient, publicTS, secondValues, + false, false, http.StatusOK, redirTS.URL) + + assert.Equal(t, identifier, gjson.Get(body, "identity.traits.subject").String(), "%s", body) + }) + + t.Run("type=spa", func(t *testing.T) { + hc := testhelpers.NewClientWithCookies(t) + + secondStep := testhelpers.SubmitLoginForm(t, false, hc, publicTS, firstValues, + true, false, http.StatusBadRequest, publicTS.URL+login.RouteSubmitFlow) + t.Logf("secondStep: %s", secondStep) + assert.Contains(t, secondStep, "current-password") + assert.Contains(t, secondStep, `"value":"password"`) + + body := testhelpers.SubmitLoginForm(t, false, hc, publicTS, secondValues, + true, false, http.StatusOK, publicTS.URL+login.RouteSubmitFlow) + + assert.Equal(t, identifier, gjson.Get(body, "session.identity.traits.subject").String(), "%s", body) + assert.Empty(t, gjson.Get(body, "session_token").String(), "%s", body) + assert.Empty(t, gjson.Get(body, "session.token").String(), "%s", body) + + // Was the session cookie set? + require.NotEmpty(t, hc.Jar.Cookies(urlx.ParseOrPanic(publicTS.URL)), "%+v", hc.Jar) + }) + + t.Run("type=api", func(t *testing.T) { + secondStep := testhelpers.SubmitLoginForm(t, true, nil, publicTS, firstValues, + false, false, http.StatusBadRequest, publicTS.URL+login.RouteSubmitFlow) + t.Logf("secondStep: %s", secondStep) + assert.Contains(t, secondStep, "current-password") + assert.Contains(t, secondStep, `"value":"password"`) + + body := testhelpers.SubmitLoginForm(t, true, nil, publicTS, secondValues, + false, false, http.StatusOK, publicTS.URL+login.RouteSubmitFlow) + + assert.Equal(t, identifier, gjson.Get(body, "session.identity.traits.subject").String(), "%s", body) + st := gjson.Get(body, "session_token").String() + assert.NotEmpty(t, st, "%s", body) + }) + }) +} + +func TestFormHydration(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceLoginFlowStyle, "identifier_first") + + ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/default.schema.json") + s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsType(node.IdentifierFirstGroup)) + require.NoError(t, err) + fh, ok := s.(login.FormHydrator) + require.True(t, ok) + + toSnapshot := func(t *testing.T, f *login.Flow) { + t.Helper() + // The CSRF token has a unique value that messes with the snapshot - ignore it. + f.UI.Nodes.ResetNodes("csrf_token") + snapshotx.SnapshotT(t, f.UI.Nodes) + } + newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *login.Flow) { + r := httptest.NewRequest("GET", "/self-service/login/browser", nil) + r = r.WithContext(ctx) + t.Helper() + f, err := login.NewFlow(conf, time.Minute, "csrf_token", r, flow.TypeBrowser) + require.NoError(t, err) + return r, f + } + + t.Run("method=PopulateLoginMethodSecondFactor", func(t *testing.T) { + r, f := newFlow(ctx, t) + f.RequestedAAL = identity.AuthenticatorAssuranceLevel2 + require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodFirstFactor", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodFirstFactorRefresh", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodRefresh", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodSecondFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) { + t.Run("case=no options", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=WithIdentifier", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.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) + + id := identity.NewIdentity("default") + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false) + + t.Run("case=identity has password", func(t *testing.T) { + id := identity.NewIdentity("default") + + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=identity does not have a password", func(t *testing.T) { + id := identity.NewIdentity("default") + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + }) + }) + }) + + t.Run("method=PopulateLoginMethodIdentifierFirstIdentification", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstIdentification(r, f)) + toSnapshot(t, f) + }) +} diff --git a/selfservice/strategy/idfirst/strategy_test.go b/selfservice/strategy/idfirst/strategy_test.go new file mode 100644 index 000000000000..f6d483090abb --- /dev/null +++ b/selfservice/strategy/idfirst/strategy_test.go @@ -0,0 +1,91 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package idfirst_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ory/kratos/driver" + "github.com/ory/kratos/x" + "github.com/ory/x/sqlxx" + + "github.com/ory/kratos/internal" + "github.com/ory/kratos/selfservice/strategy/idfirst" + "github.com/ory/kratos/ui/node" + + "github.com/stretchr/testify/assert" + + "github.com/ory/kratos/identity" + "github.com/ory/kratos/session" +) + +func TestCountActiveFirstFactorCredentials(t *testing.T) { + _, reg := internal.NewFastRegistryWithMocks(t) + s := idfirst.NewStrategy(reg) + cc := make(map[identity.CredentialsType]identity.Credentials) + + count, err := s.CountActiveFirstFactorCredentials(cc) + assert.NoError(t, err) + assert.Equal(t, 0, count) +} + +func TestCountActiveMultiFactorCredentials(t *testing.T) { + _, reg := internal.NewFastRegistryWithMocks(t) + s := idfirst.NewStrategy(reg) + cc := make(map[identity.CredentialsType]identity.Credentials) + + count, err := s.CountActiveMultiFactorCredentials(cc) + assert.NoError(t, err) + assert.Equal(t, 0, count) +} + +func TestCompletedAuthenticationMethod(t *testing.T) { + _, reg := internal.NewFastRegistryWithMocks(t) + s := idfirst.NewStrategy(reg) + ctx := context.Background() + + method := s.CompletedAuthenticationMethod(ctx, session.AuthenticationMethods{}) + assert.Equal(t, s.ID(), method.Method) + assert.Equal(t, identity.AuthenticatorAssuranceLevel1, method.AAL) +} + +func TestNodeGroup(t *testing.T) { + _, reg := internal.NewFastRegistryWithMocks(t) + s := idfirst.NewStrategy(reg) + + group := s.NodeGroup() + assert.Equal(t, node.IdentifierFirstGroup, group) +} + +func createIdentity(ctx context.Context, reg *driver.RegistryDefault, t *testing.T, identifier, password string) *identity.Identity { + p, _ := reg.Hasher(ctx).Generate(context.Background(), []byte(password)) + iId := x.NewUUID() + id := &identity.Identity{ + ID: iId, + Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)), + Credentials: map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypePassword: { + Type: identity.CredentialsTypePassword, + Identifiers: []string{identifier}, + Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`), + }, + }, + VerifiableAddresses: []identity.VerifiableAddress{ + { + ID: x.NewUUID(), + Value: identifier, + Verified: false, + CreatedAt: time.Now(), + IdentityID: iId, + }, + }, + } + require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), id)) + return id +} diff --git a/selfservice/strategy/idfirst/stub/default.schema.json b/selfservice/strategy/idfirst/stub/default.schema.json new file mode 100644 index 000000000000..8dc923266050 --- /dev/null +++ b/selfservice/strategy/idfirst/stub/default.schema.json @@ -0,0 +1,29 @@ +{ + "$id": "https://example.com/person.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + } + }, + "verification": { + "via": "email" + }, + "recovery": { + "via": "email" + } + } + } + } + } + } +} diff --git a/selfservice/strategy/idfirst/types.go b/selfservice/strategy/idfirst/types.go new file mode 100644 index 000000000000..a8838043782a --- /dev/null +++ b/selfservice/strategy/idfirst/types.go @@ -0,0 +1,29 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package idfirst + +import "encoding/json" + +// Update Login Flow with Multi-Step Method +// +// swagger:model updateLoginFlowWithIdentifierFirstMethod +type updateLoginFlowWithIdentifierFirstMethod struct { + // Method should be set to "password" when logging in using the identifier and password strategy. + // + // required: true + Method string `json:"method"` + + // Sending the anti-csrf token is only required for browser login flows. + CSRFToken string `json:"csrf_token"` + + // Identifier is the email or username of the user trying to log in. + // + // required: true + Identifier string `json:"identifier"` + + // Transient data to pass along to any webhooks + // + // required: false + TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"` +} diff --git a/selfservice/strategy/link/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads.json b/selfservice/strategy/link/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads.json index 3bb3cbbf3ef6..5ac9946936c8 100644 --- a/selfservice/strategy/link/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads.json +++ b/selfservice/strategy/link/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads.json @@ -43,8 +43,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } }, diff --git a/selfservice/strategy/link/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission.json b/selfservice/strategy/link/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission.json index 498575cfee1b..1a8d048fe37d 100644 --- a/selfservice/strategy/link/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission.json +++ b/selfservice/strategy/link/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission.json @@ -45,8 +45,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } } diff --git a/selfservice/strategy/link/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads.json b/selfservice/strategy/link/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads.json index 3bb3cbbf3ef6..5ac9946936c8 100644 --- a/selfservice/strategy/link/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads.json +++ b/selfservice/strategy/link/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads.json @@ -43,8 +43,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } }, diff --git a/selfservice/strategy/link/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads_after_submission.json b/selfservice/strategy/link/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads_after_submission.json index 498575cfee1b..1a8d048fe37d 100644 --- a/selfservice/strategy/link/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads_after_submission.json +++ b/selfservice/strategy/link/.snapshots/TestVerification-description=should_set_all_the_correct_verification_payloads_after_submission.json @@ -45,8 +45,8 @@ "messages": [], "meta": { "label": { - "id": 1070005, - "text": "Submit", + "id": 1070009, + "text": "Continue", "type": "info" } } diff --git a/selfservice/strategy/link/strategy_recovery.go b/selfservice/strategy/link/strategy_recovery.go index 184399ca1002..e6d91051c2c4 100644 --- a/selfservice/strategy/link/strategy_recovery.go +++ b/selfservice/strategy/link/strategy_recovery.go @@ -56,7 +56,7 @@ func (s *Strategy) PopulateRecoveryMethod(r *http.Request, f *recovery.Flow) err // v0.5: form.Field{Name: "email", Type: "email", Required: true}, node.NewInputField("email", nil, node.LinkGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoNodeInputEmail()), ) - f.UI.GetNodes().Append(node.NewInputField("method", s.RecoveryStrategyID(), node.LinkGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelSubmit())) + f.UI.GetNodes().Append(node.NewInputField("method", s.RecoveryStrategyID(), node.LinkGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelContinue())) return nil } diff --git a/selfservice/strategy/link/strategy_recovery_test.go b/selfservice/strategy/link/strategy_recovery_test.go index 7b56ca5f1728..6b9240a4325b 100644 --- a/selfservice/strategy/link/strategy_recovery_test.go +++ b/selfservice/strategy/link/strategy_recovery_test.go @@ -369,7 +369,7 @@ func TestRecovery(t *testing.T) { v.Set("email", "some-email@example.org") v.Set("method", "link") - authClient := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, reg) + authClient := testhelpers.NewHTTPClientWithArbitrarySessionToken(t, ctx, reg) if isAPI { req := httptest.NewRequest("GET", "/sessions/whoami", nil) s, err := session.NewActiveSession(req, @@ -380,7 +380,7 @@ func TestRecovery(t *testing.T) { identity.AuthenticatorAssuranceLevel1, ) require.NoError(t, err) - authClient = testhelpers.NewHTTPClientWithSessionCookieLocalhost(t, reg, s) + authClient = testhelpers.NewHTTPClientWithSessionCookieLocalhost(t, ctx, reg, s) } body, res := testhelpers.RecoveryMakeRequest(t, isAPI || isSPA, f, authClient, testhelpers.EncodeFormAsJSON(t, isAPI || isSPA, v)) @@ -675,7 +675,7 @@ func TestRecovery(t *testing.T) { v.Set("email", email) } - cl := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + cl := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) check(t, expectSuccess(t, nil, false, false, values), email, cl, func(_ *http.Client, req *http.Request) (*http.Response, error) { _, res := testhelpers.MockMakeAuthenticatedRequestWithClientAndID(t, reg, conf, publicRouter.Router, req, cl, id) return res, nil diff --git a/selfservice/strategy/link/strategy_verification.go b/selfservice/strategy/link/strategy_verification.go index a2a72ea9a277..61f95da52fef 100644 --- a/selfservice/strategy/link/strategy_verification.go +++ b/selfservice/strategy/link/strategy_verification.go @@ -44,7 +44,7 @@ func (s *Strategy) PopulateVerificationMethod(r *http.Request, f *verification.F // v0.5: form.Field{Name: "email", Type: "email", Required: true} node.NewInputField("email", nil, node.LinkGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoNodeInputEmail()), ) - f.UI.GetNodes().Append(node.NewInputField("method", s.VerificationStrategyID(), node.LinkGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelSubmit())) + f.UI.GetNodes().Append(node.NewInputField("method", s.VerificationStrategyID(), node.LinkGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelContinue())) return nil } diff --git a/selfservice/strategy/lookup/login_test.go b/selfservice/strategy/lookup/login_test.go index c4896962c660..c746d744f059 100644 --- a/selfservice/strategy/lookup/login_test.go +++ b/selfservice/strategy/lookup/login_test.go @@ -14,6 +14,8 @@ import ( "testing" "time" + "github.com/ory/kratos/selfservice/flow" + "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -55,7 +57,7 @@ func TestCompleteLogin(t *testing.T) { t.Run("case=lookup payload is set when identity has lookup", func(t *testing.T) { id, _ := createIdentity(t, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{"0.attributes.value"}) }) @@ -63,7 +65,7 @@ func TestCompleteLogin(t *testing.T) { t.Run("case=lookup payload is not set when identity has no lookup", func(t *testing.T) { id := createIdentityWithoutLookup(t, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) assertx.EqualAsJSON(t, nil, f.Ui.Nodes) }) @@ -71,7 +73,7 @@ func TestCompleteLogin(t *testing.T) { t.Run("case=lookup payload is not set when identity has no lookup", func(t *testing.T) { id := createIdentityWithoutLookup(t, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) assertx.EqualAsJSON(t, nil, f.Ui.Nodes) }) @@ -86,7 +88,7 @@ func TestCompleteLogin(t *testing.T) { } doAPIFlow := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) { - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) return doAPIFlowWithClient(t, v, id, apiClient, false) } @@ -99,7 +101,7 @@ func TestCompleteLogin(t *testing.T) { } doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity) (string, *http.Response) { - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) return doBrowserFlowWithClient(t, spa, v, id, browserClient, false) } @@ -235,30 +237,35 @@ func TestCompleteLogin(t *testing.T) { } t.Run("type=api", func(t *testing.T) { - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) body, res := doAPIFlowWithClient(t, payload("key-0"), id, apiClient, false) check(t, false, body, res, "key-0", 2) // We can still use another key body, res = doAPIFlowWithClient(t, payload("key-2"), id, apiClient, true) check(t, false, body, res, "key-2", 3) + assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body) }) t.Run("type=browser", func(t *testing.T) { - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) body, res := doBrowserFlowWithClient(t, false, payload("key-3"), id, browserClient, false) check(t, true, body, res, "key-3", 2) // We can still use another key body, res = doBrowserFlowWithClient(t, false, payload("key-5"), id, browserClient, true) check(t, true, body, res, "key-5", 3) + assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body) }) t.Run("type=spa", func(t *testing.T) { - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) body, res := doBrowserFlowWithClient(t, true, payload("key-6"), id, browserClient, false) check(t, false, body, res, "key-6", 2) // We can still use another key body, res = doBrowserFlowWithClient(t, true, payload("key-8"), id, browserClient, true) check(t, false, body, res, "key-8", 3) + + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body) + assert.Contains(t, gjson.Get(body, "continue_with.0.redirect_browser_to").String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%s", body) }) }) diff --git a/selfservice/strategy/lookup/settings_test.go b/selfservice/strategy/lookup/settings_test.go index fce2be4c0974..101f5919d04a 100644 --- a/selfservice/strategy/lookup/settings_test.go +++ b/selfservice/strategy/lookup/settings_test.go @@ -111,7 +111,7 @@ func TestCompleteSettings(t *testing.T) { conf.MustSet(ctx, config.ViperKeySecretsDefault, []string{"not-a-secure-session-key"}) doAPIFlow := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) { - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) v(values) @@ -120,7 +120,7 @@ func TestCompleteSettings(t *testing.T) { } doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity) (string, *http.Response) { - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) v(values) @@ -129,7 +129,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("case=hide recovery codes behind reveal button and show disable button", func(t *testing.T) { id, _ := createIdentity(t, reg) - browserClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) t.Run("case=spa", func(t *testing.T) { f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, true, publicTS) @@ -142,7 +142,7 @@ func TestCompleteSettings(t *testing.T) { }) t.Run("case=api", func(t *testing.T) { - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{"0.attributes.value"}) }) @@ -150,7 +150,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("case=button for regeneration is displayed when identity has no recovery codes yet", func(t *testing.T) { id := createIdentityWithoutLookup(t, reg) - browserClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) t.Run("case=spa", func(t *testing.T) { f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, true, publicTS) @@ -163,7 +163,7 @@ func TestCompleteSettings(t *testing.T) { }) t.Run("case=api", func(t *testing.T) { - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{"0.attributes.value"}) }) @@ -389,7 +389,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("type=api", func(t *testing.T) { id, _ := createIdentity(t, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) @@ -410,7 +410,7 @@ func TestCompleteSettings(t *testing.T) { runBrowser := func(t *testing.T, spa bool) { id, _ := createIdentity(t, reg) - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) @@ -423,8 +423,11 @@ func TestCompleteSettings(t *testing.T) { if spa { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), uiTS.URL, "%s", actual) } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) + assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual) } assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String())) @@ -480,7 +483,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("type=api", func(t *testing.T) { id, _ := createIdentity(t, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) @@ -498,7 +501,7 @@ func TestCompleteSettings(t *testing.T) { runBrowser := func(t *testing.T, spa bool) { id, _ := createIdentity(t, reg) - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) @@ -508,8 +511,11 @@ func TestCompleteSettings(t *testing.T) { if spa { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), uiTS.URL, "%s", actual) } else { assert.Contains(t, res.Request.URL.String(), uiTS.URL) + assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual) } assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(actual, "state").String())) diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor.json new file mode 100644 index 000000000000..9ce35531c24a --- /dev/null +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor.json @@ -0,0 +1,37 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "oidc", + "attributes": { + "name": "provider", + "type": "submit", + "value": "test-provider", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010002, + "text": "Sign in with test-provider", + "type": "info", + "context": { + "provider": "test-provider" + } + } + } + } +] diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh.json new file mode 100644 index 000000000000..9ce35531c24a --- /dev/null +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh.json @@ -0,0 +1,37 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "oidc", + "attributes": { + "name": "provider", + "type": "submit", + "value": "test-provider", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010002, + "text": "Sign in with test-provider", + "type": "info", + "context": { + "provider": "test-provider" + } + } + } + } +] diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_oidc.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_oidc.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_oidc.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_oidc.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_oidc.json new file mode 100644 index 000000000000..29f5e9aa1061 --- /dev/null +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_oidc.json @@ -0,0 +1,24 @@ +[ + { + "type": "input", + "group": "oidc", + "attributes": { + "name": "provider", + "type": "submit", + "value": "test-provider", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010002, + "text": "Sign in with test-provider", + "type": "info", + "context": { + "provider": "test-provider" + } + } + } + } +] diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json new file mode 100644 index 000000000000..9ce35531c24a --- /dev/null +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json @@ -0,0 +1,37 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "oidc", + "attributes": { + "name": "provider", + "type": "submit", + "value": "test-provider", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010002, + "text": "Sign in with test-provider", + "type": "info", + "context": { + "provider": "test-provider" + } + } + } + } +] diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh.json new file mode 100644 index 000000000000..9ce35531c24a --- /dev/null +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh.json @@ -0,0 +1,37 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "oidc", + "attributes": { + "name": "provider", + "type": "submit", + "value": "test-provider", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010002, + "text": "Sign in with test-provider", + "type": "info", + "context": { + "provider": "test-provider" + } + } + } + } +] diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh.json b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh.json new file mode 100644 index 000000000000..364b8abc331c --- /dev/null +++ b/selfservice/strategy/oidc/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh.json @@ -0,0 +1,15 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + } +] diff --git a/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod.json b/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod.json deleted file mode 100644 index bacf802cd191..000000000000 --- a/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "method": "POST", - "nodes": [ - { - "type": "input", - "group": "default", - "attributes": { - "name": "csrf_token", - "type": "hidden", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "oidc", - "attributes": { - "name": "provider", - "type": "submit", - "value": "valid", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1010002, - "text": "Sign in with valid", - "type": "info", - "context": { - "provider": "valid" - } - } - } - }, - { - "type": "input", - "group": "oidc", - "attributes": { - "name": "provider", - "type": "submit", - "value": "secondProvider", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1010002, - "text": "Sign in with secondProvider", - "type": "info", - "context": { - "provider": "secondProvider" - } - } - } - }, - { - "type": "input", - "group": "oidc", - "attributes": { - "name": "provider", - "type": "submit", - "value": "claimsViaUserInfo", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1010002, - "text": "Sign in with claimsViaUserInfo", - "type": "info", - "context": { - "provider": "claimsViaUserInfo" - } - } - } - }, - { - "type": "input", - "group": "oidc", - "attributes": { - "name": "provider", - "type": "submit", - "value": "invalid-issuer", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1010002, - "text": "Sign in with invalid-issuer", - "type": "info", - "context": { - "provider": "invalid-issuer" - } - } - } - } - ] -} diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index 6515d06367ee..102339b77991 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -24,7 +24,6 @@ import ( "golang.org/x/oauth2" "github.com/ory/kratos/cipher" - "github.com/ory/kratos/selfservice/flowhelpers" "github.com/ory/kratos/selfservice/sessiontokenexchange" "github.com/ory/x/jsonnetsecure" "github.com/ory/x/otelx" @@ -119,9 +118,9 @@ type Dependencies interface { func isForced(req interface{}) bool { f, ok := req.(interface { - IsForced() bool + IsRefresh() bool }) - return ok && f.IsForced() + return ok && f.IsRefresh() } // Strategy implements selfservice.LoginStrategy, selfservice.RegistrationStrategy and selfservice.SettingsStrategy. @@ -537,38 +536,8 @@ func (s *Strategy) populateMethod(r *http.Request, f flow.Flow, message func(pro return err } - providers := conf.Providers - - if lf, ok := f.(*login.Flow); ok && lf.IsForced() { - if _, id, c := flowhelpers.GuessForcedLoginIdentifier(r, s.d, lf, s.ID()); id != nil { - if c == nil { - // no OIDC credentials, don't add any providers - providers = nil - } else { - var credentials identity.CredentialsOIDC - if err := json.Unmarshal(c.Config, &credentials); err != nil { - // failed to read OIDC credentials, don't add any providers - providers = nil - } else { - // add only providers that can actually be used to log in as this identity - providers = make([]Configuration, 0, len(conf.Providers)) - for i := range conf.Providers { - for j := range credentials.Providers { - if conf.Providers[i].ID == credentials.Providers[j].Provider { - providers = append(providers, conf.Providers[i]) - break - } - } - } - } - } - } - } - - // does not need sorting because there is only one field - c := f.GetUI() - c.SetCSRF(s.d.GenerateCSRFToken(r)) - AddProviders(c, providers, message) + f.GetUI().SetCSRF(s.d.GenerateCSRFToken(r)) + AddProviders(f.GetUI(), conf.Providers, message) return nil } diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go index 42b948ec7c11..3b5f72291704 100644 --- a/selfservice/strategy/oidc/strategy_login.go +++ b/selfservice/strategy/oidc/strategy_login.go @@ -10,6 +10,11 @@ import ( "strings" "time" + "github.com/ory/kratos/selfservice/strategy/idfirst" + "github.com/ory/x/stringsx" + + "github.com/ory/kratos/selfservice/flowhelpers" + "github.com/julienschmidt/httprouter" "github.com/ory/kratos/session" @@ -34,21 +39,15 @@ import ( "github.com/ory/kratos/x" ) -var _ login.Strategy = new(Strategy) +var ( + _ login.FormHydrator = new(Strategy) + _ login.Strategy = new(Strategy) +) func (s *Strategy) RegisterLoginRoutes(r *x.RouterPublic) { s.setRoutes(r) } -func (s *Strategy) PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, l *login.Flow) error { - // This strategy can only solve AAL1 - if requestedAAL > identity.AuthenticatorAssuranceLevel1 { - return nil - } - - return s.populateMethod(r, l, text.NewInfoLoginWith) -} - // Update Login Flow with OpenID Connect Method // // swagger:model updateLoginFlowWithOidcMethod @@ -290,3 +289,97 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, errors.WithStack(flow.ErrCompletedByStrategy) } + +func (s *Strategy) PopulateLoginMethodFirstFactorRefresh(r *http.Request, lf *login.Flow) error { + conf, err := s.Config(r.Context()) + if err != nil { + return err + } + + var providers []Configuration + _, id, c := flowhelpers.GuessForcedLoginIdentifier(r, s.d, lf, s.ID()) + if id == nil || c == nil { + providers = nil + } else { + var credentials identity.CredentialsOIDC + if err := json.Unmarshal(c.Config, &credentials); err != nil { + // failed to read OIDC credentials, don't add any providers + providers = nil + } else { + // add only providers that can actually be used to log in as this identity + providers = make([]Configuration, 0, len(conf.Providers)) + for i := range conf.Providers { + for j := range credentials.Providers { + if conf.Providers[i].ID == credentials.Providers[j].Provider { + providers = append(providers, conf.Providers[i]) + break + } + } + } + } + } + + lf.UI.SetCSRF(s.d.GenerateCSRFToken(r)) + AddProviders(lf.UI, providers, text.NewInfoLoginWith) + return nil +} + +func (s *Strategy) PopulateLoginMethodFirstFactor(r *http.Request, f *login.Flow) error { + return s.populateMethod(r, f, text.NewInfoLoginWith) +} + +func (s *Strategy) PopulateLoginMethodSecondFactor(r *http.Request, sr *login.Flow) error { + return nil +} + +func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, sr *login.Flow) error { + return nil +} + +func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, f *login.Flow, mods ...login.FormHydratorModifier) error { + conf, err := s.Config(r.Context()) + if err != nil { + return err + } + + o := login.NewFormHydratorOptions(mods) + + var linked []Provider + if o.IdentityHint != nil { + var err error + // If we have an identity hint we check if the identity has any providers configured. + if linked, err = s.linkedProviders(r.Context(), r, conf, o.IdentityHint); err != nil { + return err + } + } + + if len(linked) == 0 { + // If we found no credentials: + if s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + // We found no credentials but do not want to leak that we know that. So we return early and do not + // modify the initial provider list. + return nil + } + + // We found no credentials. We remove all the providers and tell the strategy that we found nothing. + f.GetUI().UnsetNode("provider") + return idfirst.ErrNoCredentialsFound + } + + if !s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + // Account enumeration is disabled, so we show all providers that are linked to the identity. + // User is found and enumeration mitigation is disabled. Filter the list! + f.GetUI().UnsetNode("provider") + + for _, l := range linked { + lc := l.Config() + AddProvider(f.UI, lc.ID, text.NewInfoLoginWith(stringsx.Coalesce(lc.Label, lc.ID))) + } + } + + return nil +} + +func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, f *login.Flow) error { + return s.populateMethod(r, f, text.NewInfoLoginWith) +} diff --git a/selfservice/strategy/oidc/strategy_login_test.go b/selfservice/strategy/oidc/strategy_login_test.go new file mode 100644 index 000000000000..2191d5cf59a7 --- /dev/null +++ b/selfservice/strategy/oidc/strategy_login_test.go @@ -0,0 +1,166 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ory/kratos/selfservice/strategy/idfirst" + + configtesthelpers "github.com/ory/kratos/driver/config/testhelpers" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/require" + + "github.com/ory/kratos/driver" + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/login" + "github.com/ory/kratos/x" + "github.com/ory/x/snapshotx" +) + +func createIdentity(t *testing.T, ctx context.Context, reg driver.Registry, id uuid.UUID, provider string) *identity.Identity { + creds, err := identity.NewCredentialsOIDC(new(identity.CredentialsOIDCEncryptedTokens), provider, id.String(), "") + require.NoError(t, err) + + i := identity.NewIdentity("default") + i.SetCredentials(identity.CredentialsTypeOIDC, *creds) + + require.NoError(t, reg.IdentityManager().Create(ctx, i)) + return i +} + +func TestFormHydration(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + providerID := "test-provider" + + ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC)+".enabled", true) + ctx = configtesthelpers.WithConfigValue( + ctx, + config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC)+".config", + map[string]interface{}{ + "providers": []map[string]interface{}{ + { + "provider": "generic", + "id": providerID, + "client_id": "invalid", + "client_secret": "invalid", + "issuer_url": "https://foobar/", + "mapper_url": "file://./stub/oidc.facebook.jsonnet", + }, + }, + }, + ) + ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://stub/stub.schema.json") + + s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsTypeOIDC) + require.NoError(t, err) + fh, ok := s.(login.FormHydrator) + require.True(t, ok) + + toSnapshot := func(t *testing.T, f *login.Flow) { + t.Helper() + // The CSRF token has a unique value that messes with the snapshot - ignore it. + f.UI.Nodes.ResetNodes("csrf_token") + snapshotx.SnapshotT(t, f.UI.Nodes) + } + newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *login.Flow) { + r := httptest.NewRequest("GET", "/self-service/login/browser", nil) + r = r.WithContext(ctx) + t.Helper() + f, err := login.NewFlow(conf, time.Minute, "csrf_token", r, flow.TypeBrowser) + require.NoError(t, err) + return r, f + } + + t.Run("method=PopulateLoginMethodSecondFactor", func(t *testing.T) { + r, f := newFlow(ctx, t) + f.RequestedAAL = identity.AuthenticatorAssuranceLevel2 + require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodFirstFactor", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodFirstFactorRefresh", func(t *testing.T) { + r, f := newFlow(ctx, t) + + id := createIdentity(t, ctx, reg, x.NewUUID(), providerID) + r.Header = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader() + f.Refresh = true + + require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodSecondFactorRefresh", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) { + t.Run("case=no options", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=WithIdentifier", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.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) + + id := identity.NewIdentity(providerID) + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false) + + t.Run("case=identity has oidc", func(t *testing.T) { + identifier := x.NewUUID() + id := createIdentity(t, ctx, reg, identifier, providerID) + + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + toSnapshot(t, f) + }) + + t.Run("case=identity does not have a oidc", func(t *testing.T) { + id := identity.NewIdentity("default") + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + }) + }) + }) + + t.Run("method=PopulateLoginMethodIdentifierFirstIdentification", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstIdentification(r, f)) + toSnapshot(t, f) + }) +} diff --git a/selfservice/strategy/oidc/strategy_test.go b/selfservice/strategy/oidc/strategy_test.go index 65c8f09b2e06..0f41fd622d0e 100644 --- a/selfservice/strategy/oidc/strategy_test.go +++ b/selfservice/strategy/oidc/strategy_test.go @@ -1415,16 +1415,6 @@ func TestStrategy(t *testing.T) { snapshotx.SnapshotTExcept(t, sr.UI, []string{"action", "nodes.0.attributes.value"}) }) - - t.Run("method=TestPopulateLoginMethod", func(t *testing.T) { - conf.MustSet(ctx, config.ViperKeyPublicBaseURL, "https://foo/") - - sr, err := login.NewFlow(conf, time.Minute, "nosurf", &http.Request{URL: urlx.ParseOrPanic("/")}, flow.TypeBrowser) - require.NoError(t, err) - require.NoError(t, reg.LoginStrategies(context.Background()).MustStrategy(identity.CredentialsTypeOIDC).(*oidc.Strategy).PopulateLoginMethod(&http.Request{}, identity.AuthenticatorAssuranceLevel1, sr)) - - snapshotx.SnapshotTExcept(t, sr.UI, []string{"action", "nodes.0.attributes.value"}) - }) } func prettyJSON(t *testing.T, body []byte) string { @@ -1533,7 +1523,7 @@ func TestCountActiveFirstFactorCredentials(t *testing.T) { func TestDisabledEndpoint(t *testing.T) { conf, reg := internal.NewFastRegistryWithMocks(t) testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeOIDC.String(), false) - + ctx := context.Background() publicTS, _ := testhelpers.NewKratosServer(t, reg) t.Run("case=should not callback when oidc method is disabled", func(t *testing.T) { @@ -1551,7 +1541,7 @@ func TestDisabledEndpoint(t *testing.T) { t.Run("flow=settings", func(t *testing.T) { testhelpers.SetDefaultIdentitySchema(conf, "file://stub/stub.schema.json") - c := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, reg) + c := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, ctx, reg) f := testhelpers.InitializeSettingsFlowViaAPI(t, c, publicTS) res, err := c.PostForm(f.Ui.Action, url.Values{"link": {"oidc"}}) diff --git a/selfservice/strategy/passkey/.snapshots/TestCompleteLogin-flow=passwordless-case=passkey_button_exists.json b/selfservice/strategy/passkey/.snapshots/TestCompleteLogin-flow=passwordless-case=passkey_button_exists.json index d2dd6567d240..ffb5ec222642 100644 --- a/selfservice/strategy/passkey/.snapshots/TestCompleteLogin-flow=passwordless-case=passkey_button_exists.json +++ b/selfservice/strategy/passkey/.snapshots/TestCompleteLogin-flow=passwordless-case=passkey_button_exists.json @@ -38,7 +38,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" @@ -53,8 +53,10 @@ "disabled": false, "name": "passkey_login_trigger", "node_type": "input", - "onclick": "window.__oryPasskeyLogin()", - "onload": "window.__oryPasskeyLoginAutocompleteInit()", + "onclick": "window.oryPasskeyLogin()", + "onclickTrigger": "oryPasskeyLogin", + "onload": "window.oryPasskeyLoginAutocompleteInit()", + "onloadTrigger": "oryPasskeyLoginAutocompleteInit", "type": "button", "value": "" }, @@ -74,6 +76,8 @@ "disabled": false, "name": "passkey_login", "node_type": "input", + "onload": "window.oryPasskeyLoginAutocompleteInit()", + "onloadTrigger": "oryPasskeyLoginAutocompleteInit", "type": "hidden" }, "group": "passkey", diff --git a/selfservice/strategy/passkey/.snapshots/TestCompleteLogin-flow=refresh-case=refresh_passwordless_credentials-browser.json b/selfservice/strategy/passkey/.snapshots/TestCompleteLogin-flow=refresh-case=refresh_passwordless_credentials-browser.json index c331d4f4280f..1e026fb9979a 100644 --- a/selfservice/strategy/passkey/.snapshots/TestCompleteLogin-flow=refresh-case=refresh_passwordless_credentials-browser.json +++ b/selfservice/strategy/passkey/.snapshots/TestCompleteLogin-flow=refresh-case=refresh_passwordless_credentials-browser.json @@ -30,7 +30,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" @@ -45,7 +45,8 @@ "disabled": false, "name": "passkey_login_trigger", "node_type": "input", - "onclick": "window.__oryPasskeyLogin()", + "onclick": "window.oryPasskeyLogin()", + "onclickTrigger": "oryPasskeyLogin", "type": "button", "value": "" }, diff --git a/selfservice/strategy/passkey/.snapshots/TestCompleteLogin-flow=refresh-case=refresh_passwordless_credentials-spa.json b/selfservice/strategy/passkey/.snapshots/TestCompleteLogin-flow=refresh-case=refresh_passwordless_credentials-spa.json index c331d4f4280f..1e026fb9979a 100644 --- a/selfservice/strategy/passkey/.snapshots/TestCompleteLogin-flow=refresh-case=refresh_passwordless_credentials-spa.json +++ b/selfservice/strategy/passkey/.snapshots/TestCompleteLogin-flow=refresh-case=refresh_passwordless_credentials-spa.json @@ -30,7 +30,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" @@ -45,7 +45,8 @@ "disabled": false, "name": "passkey_login_trigger", "node_type": "input", - "onclick": "window.__oryPasskeyLogin()", + "onclick": "window.oryPasskeyLogin()", + "onclickTrigger": "oryPasskeyLogin", "type": "button", "value": "" }, diff --git a/selfservice/strategy/passkey/.snapshots/TestCompleteSettings-case=a_device_is_shown_which_can_be_unlinked.json b/selfservice/strategy/passkey/.snapshots/TestCompleteSettings-case=a_device_is_shown_which_can_be_unlinked.json index f9032e39049d..a0383567eda4 100644 --- a/selfservice/strategy/passkey/.snapshots/TestCompleteSettings-case=a_device_is_shown_which_can_be_unlinked.json +++ b/selfservice/strategy/passkey/.snapshots/TestCompleteSettings-case=a_device_is_shown_which_can_be_unlinked.json @@ -4,7 +4,8 @@ "disabled": false, "name": "passkey_register_trigger", "node_type": "input", - "onclick": "window.__oryPasskeySettingsRegistration()", + "onclick": "window.oryPasskeySettingsRegistration()", + "onclickTrigger": "oryPasskeySettingsRegistration", "type": "button", "value": "" }, @@ -109,7 +110,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" diff --git a/selfservice/strategy/passkey/.snapshots/TestCompleteSettings-case=one_activation_element_is_shown.json b/selfservice/strategy/passkey/.snapshots/TestCompleteSettings-case=one_activation_element_is_shown.json index 7e5c5b3d082b..8d91edf04ce5 100644 --- a/selfservice/strategy/passkey/.snapshots/TestCompleteSettings-case=one_activation_element_is_shown.json +++ b/selfservice/strategy/passkey/.snapshots/TestCompleteSettings-case=one_activation_element_is_shown.json @@ -4,7 +4,8 @@ "disabled": false, "name": "passkey_register_trigger", "node_type": "input", - "onclick": "window.__oryPasskeySettingsRegistration()", + "onclick": "window.oryPasskeySettingsRegistration()", + "onclickTrigger": "oryPasskeySettingsRegistration", "type": "button", "value": "" }, @@ -61,7 +62,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor.json new file mode 100644 index 000000000000..3e9aa5b5199e --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor.json @@ -0,0 +1,100 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "text", + "value": "", + "required": true, + "autocomplete": "username webauthn", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070004, + "text": "ID", + "type": "info" + } + } + }, + { + "type": "input", + "group": "passkey", + "attributes": { + "name": "passkey_challenge", + "type": "hidden", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "script", + "group": "webauthn", + "attributes": { + "async": true, + "referrerpolicy": "no-referrer", + "crossorigin": "anonymous", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", + "type": "text/javascript", + "id": "webauthn_script", + "node_type": "script" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "passkey", + "attributes": { + "name": "passkey_login", + "type": "hidden", + "disabled": false, + "onload": "window.oryPasskeyLoginAutocompleteInit()", + "onloadTrigger": "oryPasskeyLoginAutocompleteInit", + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "passkey", + "attributes": { + "name": "passkey_login_trigger", + "type": "button", + "value": "", + "disabled": false, + "onclick": "window.oryPasskeyLogin()", + "onclickTrigger": "oryPasskeyLogin", + "onload": "window.oryPasskeyLoginAutocompleteInit()", + "onloadTrigger": "oryPasskeyLoginAutocompleteInit", + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010021, + "text": "Sign in with passkey", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh.json new file mode 100644 index 000000000000..33d9f8afd952 --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh.json @@ -0,0 +1,88 @@ +[ + { + "type": "input", + "group": "passkey", + "attributes": { + "name": "passkey_challenge", + "type": "hidden", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "script", + "group": "webauthn", + "attributes": { + "async": true, + "referrerpolicy": "no-referrer", + "crossorigin": "anonymous", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", + "type": "text/javascript", + "id": "webauthn_script", + "node_type": "script" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "passkey", + "attributes": { + "name": "passkey_login", + "type": "hidden", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "passkey", + "attributes": { + "name": "passkey_login_trigger", + "type": "button", + "value": "", + "disabled": false, + "onclick": "window.oryPasskeyLogin()", + "onclickTrigger": "oryPasskeyLogin", + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010021, + "text": "Sign in with passkey", + "type": "info" + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "hidden", + "value": "", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + } +] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 000000000000..94263a4da9d1 --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1,23 @@ +[ + { + "type": "input", + "group": "passkey", + "attributes": { + "name": "passkey_login_trigger", + "type": "button", + "value": "", + "disabled": false, + "onclick": "window.oryPasskeyLogin()", + "onclickTrigger": "oryPasskeyLogin", + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010021, + "text": "Sign in with passkey", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_passkey.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_passkey.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_passkey.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_passkey.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_passkey.json new file mode 100644 index 000000000000..94263a4da9d1 --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_passkey.json @@ -0,0 +1,23 @@ +[ + { + "type": "input", + "group": "passkey", + "attributes": { + "name": "passkey_login_trigger", + "type": "button", + "value": "", + "disabled": false, + "onclick": "window.oryPasskeyLogin()", + "onclickTrigger": "oryPasskeyLogin", + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010021, + "text": "Sign in with passkey", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 000000000000..94263a4da9d1 --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1,23 @@ +[ + { + "type": "input", + "group": "passkey", + "attributes": { + "name": "passkey_login_trigger", + "type": "button", + "value": "", + "disabled": false, + "onclick": "window.oryPasskeyLogin()", + "onclickTrigger": "oryPasskeyLogin", + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010021, + "text": "Sign in with passkey", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 000000000000..94263a4da9d1 --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1,23 @@ +[ + { + "type": "input", + "group": "passkey", + "attributes": { + "name": "passkey_login_trigger", + "type": "button", + "value": "", + "disabled": false, + "onclick": "window.oryPasskeyLogin()", + "onclickTrigger": "oryPasskeyLogin", + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010021, + "text": "Sign in with passkey", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json new file mode 100644 index 000000000000..222443d4988b --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json @@ -0,0 +1,77 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "text", + "value": "", + "required": true, + "autocomplete": "username webauthn", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070004, + "text": "ID", + "type": "info" + } + } + }, + { + "type": "input", + "group": "passkey", + "attributes": { + "name": "passkey_challenge", + "type": "hidden", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "script", + "group": "webauthn", + "attributes": { + "async": true, + "referrerpolicy": "no-referrer", + "crossorigin": "anonymous", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", + "type": "text/javascript", + "id": "webauthn_script", + "node_type": "script" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "passkey", + "attributes": { + "name": "passkey_login", + "type": "hidden", + "disabled": false, + "onload": "window.oryPasskeyLoginAutocompleteInit()", + "onloadTrigger": "oryPasskeyLoginAutocompleteInit", + "node_type": "input" + }, + "messages": [], + "meta": {} + } +] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh.json b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/passkey/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/passkey/.snapshots/TestRegistration-case=passkey_button_exists-browser.json b/selfservice/strategy/passkey/.snapshots/TestRegistration-case=passkey_button_exists-browser.json index 18e0cda77811..e4c5160c9697 100644 --- a/selfservice/strategy/passkey/.snapshots/TestRegistration-case=passkey_button_exists-browser.json +++ b/selfservice/strategy/passkey/.snapshots/TestRegistration-case=passkey_button_exists-browser.json @@ -43,7 +43,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" @@ -70,7 +70,8 @@ "disabled": false, "name": "passkey_register_trigger", "node_type": "input", - "onclick": "window.__oryPasskeyRegistration()", + "onclick": "window.oryPasskeyRegistration()", + "onclickTrigger": "oryPasskeyRegistration", "type": "button" }, "group": "passkey", diff --git a/selfservice/strategy/passkey/.snapshots/TestRegistration-case=passkey_button_exists-spa.json b/selfservice/strategy/passkey/.snapshots/TestRegistration-case=passkey_button_exists-spa.json index 18e0cda77811..e4c5160c9697 100644 --- a/selfservice/strategy/passkey/.snapshots/TestRegistration-case=passkey_button_exists-spa.json +++ b/selfservice/strategy/passkey/.snapshots/TestRegistration-case=passkey_button_exists-spa.json @@ -43,7 +43,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" @@ -70,7 +70,8 @@ "disabled": false, "name": "passkey_register_trigger", "node_type": "input", - "onclick": "window.__oryPasskeyRegistration()", + "onclick": "window.oryPasskeyRegistration()", + "onclickTrigger": "oryPasskeyRegistration", "type": "button" }, "group": "passkey", diff --git a/selfservice/strategy/passkey/passkey_login.go b/selfservice/strategy/passkey/passkey_login.go index 63d5ec66f2f0..857d6e824d32 100644 --- a/selfservice/strategy/passkey/passkey_login.go +++ b/selfservice/strategy/passkey/passkey_login.go @@ -9,6 +9,10 @@ import ( "net/http" "strings" + "github.com/ory/kratos/selfservice/strategy/idfirst" + + "github.com/ory/kratos/x/webauthnx/js" + "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" "github.com/pkg/errors" @@ -29,23 +33,13 @@ import ( "github.com/ory/x/decoderx" ) +var _ login.FormHydrator = new(Strategy) + func (s *Strategy) RegisterLoginRoutes(r *x.RouterPublic) { webauthnx.RegisterWebauthnRoute(r) } -func (s *Strategy) PopulateLoginMethod(r *http.Request, aal identity.AuthenticatorAssuranceLevel, sr *login.Flow) error { - if sr.Type != flow.TypeBrowser || aal != identity.AuthenticatorAssuranceLevel1 { - return nil - } - - return s.populateLoginMethodForPasskeys(r, sr) -} - func (s *Strategy) populateLoginMethodForPasskeys(r *http.Request, loginFlow *login.Flow) error { - if loginFlow.IsForced() { - return s.populateLoginMethodForRefresh(r, loginFlow) - } - ctx := r.Context() loginFlow.UI.SetCSRF(s.d.GenerateCSRFToken(r)) @@ -100,7 +94,8 @@ func (s *Strategy) populateLoginMethodForPasskeys(r *http.Request, loginFlow *lo Name: node.PasskeyChallenge, Type: node.InputAttributeTypeHidden, FieldValue: string(injectWebAuthnOptions), - }}) + }, + }) loginFlow.UI.Nodes.Upsert(webauthnx.NewWebAuthnScript(s.d.Config().SelfPublicURL(ctx))) @@ -109,122 +104,12 @@ func (s *Strategy) populateLoginMethodForPasskeys(r *http.Request, loginFlow *lo Group: node.PasskeyGroup, Meta: &node.Meta{}, Attributes: &node.InputAttributes{ - Name: node.PasskeyLogin, - Type: node.InputAttributeTypeHidden, - }}) - - loginFlow.UI.Nodes.Append(node.NewInputField( - node.PasskeyLoginTrigger, - "", - node.PasskeyGroup, - node.InputAttributeTypeButton, - node.WithInputAttributes(func(attr *node.InputAttributes) { - attr.OnClick = "window.__oryPasskeyLogin()" // this function is defined in webauthn.js - attr.OnLoad = "window.__oryPasskeyLoginAutocompleteInit()" // same here - }), - ).WithMetaLabel(text.NewInfoSelfServiceLoginPasskey())) - - return nil -} - -func (s *Strategy) populateLoginMethodForRefresh(r *http.Request, loginFlow *login.Flow) error { - ctx := r.Context() - - identifier, id, _ := flowhelpers.GuessForcedLoginIdentifier(r, s.d, loginFlow, s.ID()) - if identifier == "" { - return nil - } - - id, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), id.ID) - if err != nil { - return err - } - - cred, ok := id.GetCredentials(s.ID()) - if !ok { - // Identity has no passkey - return nil - } - - var conf identity.CredentialsWebAuthnConfig - if err := json.Unmarshal(cred.Config, &conf); err != nil { - return errors.WithStack(err) - } - - webAuthCreds := conf.Credentials.ToWebAuthn() - if len(webAuthCreds) == 0 { - // Identity has no webauthn - return nil - } - - passkeyIdentifier := s.PasskeyDisplayNameFromIdentity(ctx, id) - - webAuthn, err := webauthn.New(s.d.Config().PasskeyConfig(ctx)) - if err != nil { - return errors.WithStack(err) - } - option, sessionData, err := webAuthn.BeginLogin(&webauthnx.User{ - Name: passkeyIdentifier, - ID: conf.UserHandle, - Credentials: webAuthCreds, - Config: webAuthn.Config, + Name: node.PasskeyLogin, + Type: node.InputAttributeTypeHidden, + OnLoad: js.WebAuthnTriggersPasskeyLoginAutocompleteInit.String() + "()", + OnLoadTrigger: js.WebAuthnTriggersPasskeyLoginAutocompleteInit, + }, }) - if err != nil { - return errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to initiate passkey login.").WithDebug(err.Error())) - } - - loginFlow.InternalContext, err = sjson.SetBytes( - loginFlow.InternalContext, - flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData), - sessionData, - ) - if err != nil { - return errors.WithStack(err) - } - - injectWebAuthnOptions, err := json.Marshal(option) - if err != nil { - return errors.WithStack(err) - } - - loginFlow.UI.Nodes.Upsert(&node.Node{ - Type: node.Input, - Group: node.PasskeyGroup, - Meta: &node.Meta{}, - Attributes: &node.InputAttributes{ - Name: node.PasskeyChallenge, - Type: node.InputAttributeTypeHidden, - FieldValue: string(injectWebAuthnOptions), - }}) - - loginFlow.UI.Nodes.Append(webauthnx.NewWebAuthnScript(s.d.Config().SelfPublicURL(ctx))) - - loginFlow.UI.Nodes.Upsert(&node.Node{ - Type: node.Input, - Group: node.PasskeyGroup, - Meta: &node.Meta{}, - Attributes: &node.InputAttributes{ - Name: node.PasskeyLogin, - Type: node.InputAttributeTypeHidden, - }}) - - loginFlow.UI.Nodes.Append(node.NewInputField( - node.PasskeyLoginTrigger, - "", - node.PasskeyGroup, - node.InputAttributeTypeButton, - node.WithInputAttributes(func(attr *node.InputAttributes) { - attr.OnClick = "window.__oryPasskeyLogin()" // this function is defined in webauthn.js - }), - ).WithMetaLabel(text.NewInfoSelfServiceLoginPasskey())) - - loginFlow.UI.SetCSRF(s.d.GenerateCSRFToken(r)) - loginFlow.UI.SetNode(node.NewInputField( - "identifier", - passkeyIdentifier, - node.DefaultGroup, - node.InputAttributeTypeHidden, - )) return nil } @@ -393,3 +278,200 @@ func (s *Strategy) loginAuthenticate(_ http.ResponseWriter, r *http.Request, f * return i, nil } + +func (s *Strategy) PopulateLoginMethodFirstFactorRefresh(r *http.Request, f *login.Flow) error { + if f.Type != flow.TypeBrowser { + return nil + } + + ctx := r.Context() + + identifier, id, _ := flowhelpers.GuessForcedLoginIdentifier(r, s.d, f, s.ID()) + if identifier == "" { + return nil + } + + id, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), id.ID) + if err != nil { + return err + } + + cred, ok := id.GetCredentials(s.ID()) + if !ok { + // Identity has no passkey + return nil + } + + var conf identity.CredentialsWebAuthnConfig + if err := json.Unmarshal(cred.Config, &conf); err != nil { + return errors.WithStack(err) + } + + webAuthCreds := conf.Credentials.ToWebAuthn() + if len(webAuthCreds) == 0 { + // Identity has no webauthn + return nil + } + + passkeyIdentifier := s.PasskeyDisplayNameFromIdentity(ctx, id) + + webAuthn, err := webauthn.New(s.d.Config().PasskeyConfig(ctx)) + if err != nil { + return errors.WithStack(err) + } + option, sessionData, err := webAuthn.BeginLogin(&webauthnx.User{ + Name: passkeyIdentifier, + ID: conf.UserHandle, + Credentials: webAuthCreds, + Config: webAuthn.Config, + }) + if err != nil { + return errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to initiate passkey login.").WithDebug(err.Error())) + } + + f.InternalContext, err = sjson.SetBytes( + f.InternalContext, + flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData), + sessionData, + ) + if err != nil { + return errors.WithStack(err) + } + + injectWebAuthnOptions, err := json.Marshal(option) + if err != nil { + return errors.WithStack(err) + } + + f.UI.Nodes.Upsert(&node.Node{ + Type: node.Input, + Group: node.PasskeyGroup, + Meta: &node.Meta{}, + Attributes: &node.InputAttributes{ + Name: node.PasskeyChallenge, + Type: node.InputAttributeTypeHidden, + FieldValue: string(injectWebAuthnOptions), + }, + }) + + f.UI.Nodes.Append(webauthnx.NewWebAuthnScript(s.d.Config().SelfPublicURL(ctx))) + + f.UI.Nodes.Upsert(&node.Node{ + Type: node.Input, + Group: node.PasskeyGroup, + Meta: &node.Meta{}, + Attributes: &node.InputAttributes{ + Name: node.PasskeyLogin, + Type: node.InputAttributeTypeHidden, + }, + }) + + f.UI.Nodes.Append(node.NewInputField( + node.PasskeyLoginTrigger, + "", + node.PasskeyGroup, + node.InputAttributeTypeButton, + node.WithInputAttributes(func(attr *node.InputAttributes) { + //nolint:staticcheck + attr.OnClick = js.WebAuthnTriggersPasskeyLogin.String() + "()" // this function is defined in webauthn.js + attr.OnClickTrigger = js.WebAuthnTriggersPasskeyLogin + }), + ).WithMetaLabel(text.NewInfoSelfServiceLoginPasskey())) + + f.UI.SetCSRF(s.d.GenerateCSRFToken(r)) + f.UI.SetNode(node.NewInputField( + "identifier", + passkeyIdentifier, + node.DefaultGroup, + node.InputAttributeTypeHidden, + )) + + return nil +} + +func (s *Strategy) PopulateLoginMethodFirstFactor(r *http.Request, f *login.Flow) error { + if f.Type != flow.TypeBrowser { + return nil + } + + if err := s.populateLoginMethodForPasskeys(r, f); err != nil { + return err + } + + f.UI.Nodes.Append(node.NewInputField( + node.PasskeyLoginTrigger, + "", + node.PasskeyGroup, + node.InputAttributeTypeButton, + node.WithInputAttributes(func(attr *node.InputAttributes) { + //nolint:staticcheck + attr.OnClick = js.WebAuthnTriggersPasskeyLogin.String() + "()" // this function is defined in webauthn.js + attr.OnClickTrigger = js.WebAuthnTriggersPasskeyLogin + + //nolint:staticcheck + attr.OnLoad = js.WebAuthnTriggersPasskeyLoginAutocompleteInit.String() + "()" // same here + attr.OnLoadTrigger = js.WebAuthnTriggersPasskeyLoginAutocompleteInit + }), + ).WithMetaLabel(text.NewInfoSelfServiceLoginPasskey())) + + return nil +} + +func (s *Strategy) PopulateLoginMethodSecondFactor(r *http.Request, sr *login.Flow) error { + return nil +} + +func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, sr *login.Flow) error { + return nil +} + +func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *login.Flow, opts ...login.FormHydratorModifier) error { + if sr.Type != flow.TypeBrowser { + return errors.WithStack(idfirst.ErrNoCredentialsFound) + } + + o := login.NewFormHydratorOptions(opts) + + var count int + if o.IdentityHint != nil { + var err error + // If we have an identity hint we can perform identity credentials discovery and + // hide this credential if it should not be included. + count, err = s.CountActiveFirstFactorCredentials(o.IdentityHint.Credentials) + if err != nil { + return err + } + } + + if count > 0 || s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + sr.UI.Nodes.Append(node.NewInputField( + node.PasskeyLoginTrigger, + "", + node.PasskeyGroup, + node.InputAttributeTypeButton, + node.WithInputAttributes(func(attr *node.InputAttributes) { + //nolint:staticcheck + attr.OnClick = js.WebAuthnTriggersPasskeyLogin.String() + "()" // this function is defined in webauthn.js + attr.OnClickTrigger = js.WebAuthnTriggersPasskeyLogin + }), + ).WithMetaLabel(text.NewInfoSelfServiceLoginPasskey())) + } + + if count == 0 { + return errors.WithStack(idfirst.ErrNoCredentialsFound) + } + + return nil +} + +func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, sr *login.Flow) error { + if sr.Type != flow.TypeBrowser { + return nil + } + + if err := s.populateLoginMethodForPasskeys(r, sr); err != nil { + return err + } + + return nil +} diff --git a/selfservice/strategy/passkey/passkey_login_test.go b/selfservice/strategy/passkey/passkey_login_test.go index cae6aa0ee4dd..028d7281c5e6 100644 --- a/selfservice/strategy/passkey/passkey_login_test.go +++ b/selfservice/strategy/passkey/passkey_login_test.go @@ -8,22 +8,31 @@ import ( _ "embed" "encoding/json" "net/http" + "net/http/httptest" "net/url" "testing" + "time" + + "github.com/ory/kratos/selfservice/strategy/idfirst" + + configtesthelpers "github.com/ory/kratos/driver/config/testhelpers" "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" + "github.com/ory/kratos/driver" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" "github.com/ory/kratos/internal/testhelpers" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/selfservice/strategy/passkey" "github.com/ory/kratos/text" "github.com/ory/kratos/ui/node" + "github.com/ory/kratos/x" "github.com/ory/x/snapshotx" ) @@ -45,12 +54,12 @@ func TestPopulateLoginMethod(t *testing.T) { t.Run("case=should not handle AAL2", func(t *testing.T) { loginFlow := &login.Flow{Type: flow.TypeBrowser} - assert.Nil(t, s.PopulateLoginMethod(nil, identity.AuthenticatorAssuranceLevel2, loginFlow)) + assert.Nil(t, s.PopulateLoginMethodSecondFactor(nil, loginFlow)) }) t.Run("case=should not handle API flows", func(t *testing.T) { loginFlow := &login.Flow{Type: flow.TypeAPI} - assert.Nil(t, s.PopulateLoginMethod(nil, identity.AuthenticatorAssuranceLevel1, loginFlow)) + assert.Nil(t, s.PopulateLoginMethodFirstFactor(nil, loginFlow)) }) } @@ -209,7 +218,14 @@ func TestCompleteLogin(t *testing.T) { actualFlow, err := fix.reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(f.Id)) require.NoError(t, err) + assert.Empty(t, gjson.GetBytes(actualFlow.InternalContext, flow.PrefixInternalContextKey(identity.CredentialsTypePasskey, passkey.InternalContextKeySessionData))) + if spa { + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body) + assert.Contains(t, gjson.Get(body, "continue_with.0.redirect_browser_to").String(), fix.conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%s", body) + } else { + assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body) + } } // We test here that login works even if the identity schema contains @@ -233,8 +249,8 @@ func TestCompleteLogin(t *testing.T) { fix.conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, "aal1") loginFixtureSuccessEmail := gjson.GetBytes(loginSuccessIdentity, "traits.email").String() - run := func(t *testing.T, id *identity.Identity, context, response []byte, isSPA bool, expectedAAL identity.AuthenticatorAssuranceLevel) { - body, res, f := fix.submitWebAuthnLogin(t, isSPA, id, context, func(values url.Values) { + run := func(t *testing.T, ctx context.Context, id *identity.Identity, context, response []byte, isSPA bool, expectedAAL identity.AuthenticatorAssuranceLevel) { + body, res, f := fix.submitWebAuthnLogin(t, ctx, isSPA, id, context, func(values url.Values) { values.Set("identifier", loginFixtureSuccessEmail) values.Set(node.PasskeyLogin, string(response)) }, testhelpers.InitFlowWithRefresh()) @@ -290,10 +306,166 @@ func TestCompleteLogin(t *testing.T) { "spa", } { t.Run(f, func(t *testing.T) { - run(t, id, tc.context, tc.response, f == "spa", expectedAAL) + run(t, ctx, id, tc.context, tc.response, f == "spa", expectedAAL) }) } }) } }) } + +func createIdentity(t *testing.T, ctx context.Context, reg driver.Registry, id uuid.UUID) *identity.Identity { + i := identity.NewIdentity("default") + i.SetCredentials(identity.CredentialsTypePasskey, identity.Credentials{ + Identifiers: []string{id.String()}, + Config: loginPasswordlessCredentials, + Type: identity.CredentialsTypePasskey, + Version: 1, + }) + + require.NoError(t, reg.IdentityManager().Create(ctx, i)) + return i +} + +func TestFormHydration(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + + ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".enabled", true) + ctx = configtesthelpers.WithConfigValue( + ctx, + config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config", + map[string]interface{}{ + "rp": map[string]interface{}{ + "display_name": "foo", + "id": "localhost", + "origins": []string{"http://localhost"}, + }, + }, + ) + ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://stub/login.schema.json") + + s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsTypePasskey) + require.NoError(t, err) + fh, ok := s.(login.FormHydrator) + require.True(t, ok) + + toSnapshot := func(t *testing.T, f *login.Flow) { + t.Helper() + // The CSRF token has a unique value that messes with the snapshot - ignore it. + f.UI.Nodes.ResetNodes("csrf_token") + f.UI.Nodes.ResetNodes("passkey_challenge") + snapshotx.SnapshotT(t, f.UI.Nodes, snapshotx.ExceptNestedKeys("nonce", "src")) + } + + newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *login.Flow) { + r := httptest.NewRequest("GET", "/self-service/login/browser", nil) + r = r.WithContext(ctx) + t.Helper() + f, err := login.NewFlow(conf, time.Minute, "csrf_token", r, flow.TypeBrowser) + f.UI.Nodes = make(node.Nodes, 0) + require.NoError(t, err) + return r, f + } + + t.Run("method=PopulateLoginMethodSecondFactor", func(t *testing.T) { + r, f := newFlow(ctx, t) + f.RequestedAAL = identity.AuthenticatorAssuranceLevel2 + require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodFirstFactor", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodFirstFactorRefresh", func(t *testing.T) { + r, f := newFlow(ctx, t) + + id := createIdentity(t, ctx, reg, x.NewUUID()) + r.Header = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader() + f.Refresh = true + + require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodSecondFactorRefresh", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodSecondFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) { + t.Run("case=no options", 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), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + }) + + t.Run("case=WithIdentifier", func(t *testing.T) { + 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) + + id := identity.NewIdentity("test-provider") + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false) + + t.Run("case=identity has passkey", func(t *testing.T) { + identifier := x.NewUUID() + id := createIdentity(t, ctx, reg, identifier) + + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + toSnapshot(t, f) + }) + + t.Run("case=identity does not have a passkey", func(t *testing.T) { + id := identity.NewIdentity("default") + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + }) + }) + }) + + t.Run("method=PopulateLoginMethodIdentifierFirstIdentification", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstIdentification(r, f)) + toSnapshot(t, f) + }) +} diff --git a/selfservice/strategy/passkey/passkey_registration.go b/selfservice/strategy/passkey/passkey_registration.go index 88efd420d725..9be753f70c40 100644 --- a/selfservice/strategy/passkey/passkey_registration.go +++ b/selfservice/strategy/passkey/passkey_registration.go @@ -11,6 +11,8 @@ import ( "net/url" "strings" + "github.com/ory/kratos/x/webauthnx/js" + "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" "github.com/pkg/errors" @@ -280,9 +282,10 @@ func (s *Strategy) PopulateRegistrationMethod(r *http.Request, regFlow *registra Group: node.PasskeyGroup, Meta: &node.Meta{Label: text.NewInfoSelfServiceRegistrationRegisterPasskey()}, Attributes: &node.InputAttributes{ - Name: node.PasskeyRegisterTrigger, - Type: node.InputAttributeTypeButton, - OnClick: "window.__oryPasskeyRegistration()", // defined in webauthn.js + Name: node.PasskeyRegisterTrigger, + Type: node.InputAttributeTypeButton, + OnClick: js.WebAuthnTriggersPasskeyRegistration.String() + "()", // defined in webauthn.js + OnClickTrigger: js.WebAuthnTriggersPasskeyRegistration, }}) // Passkey nodes end diff --git a/selfservice/strategy/passkey/passkey_registration_test.go b/selfservice/strategy/passkey/passkey_registration_test.go index d495e8c4dfe4..d7191207cedb 100644 --- a/selfservice/strategy/passkey/passkey_registration_test.go +++ b/selfservice/strategy/passkey/passkey_registration_test.go @@ -8,6 +8,8 @@ import ( "net/url" "testing" + "github.com/ory/kratos/selfservice/flow" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -327,6 +329,13 @@ func TestRegistration(t *testing.T) { i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(fix.ctx, identity.CredentialsTypePasskey, userID) require.NoError(t, err) assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual) + + if f == "spa" { + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), fix.redirNoSessionTS.URL+"/registration-return-ts", "%s", actual) + } else { + assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual) + } }) } }) diff --git a/selfservice/strategy/passkey/passkey_settings.go b/selfservice/strategy/passkey/passkey_settings.go index 548a261e442b..89423bf8adeb 100644 --- a/selfservice/strategy/passkey/passkey_settings.go +++ b/selfservice/strategy/passkey/passkey_settings.go @@ -11,6 +11,8 @@ import ( "strings" "time" + "github.com/ory/kratos/x/webauthnx/js" + "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" "github.com/gofrs/uuid" @@ -114,7 +116,9 @@ func (s *Strategy) PopulateSettingsMethod(r *http.Request, id *identity.Identity node.PasskeyGroup, node.InputAttributeTypeButton, node.WithInputAttributes(func(a *node.InputAttributes) { - a.OnClick = "window.__oryPasskeySettingsRegistration()" + //nolint:staticcheck + a.OnClick = js.WebAuthnTriggersPasskeySettingsRegistration.String() + "()" + a.OnClickTrigger = js.WebAuthnTriggersPasskeySettingsRegistration }), ).WithMetaLabel(text.NewInfoSelfServiceSettingsRegisterPasskey())) diff --git a/selfservice/strategy/passkey/passkey_settings_test.go b/selfservice/strategy/passkey/passkey_settings_test.go index ced111071711..a37fe39a38f1 100644 --- a/selfservice/strategy/passkey/passkey_settings_test.go +++ b/selfservice/strategy/passkey/passkey_settings_test.go @@ -54,7 +54,7 @@ func TestCompleteSettings(t *testing.T) { fix := newSettingsFixture(t) fix.conf.MustSet(ctx, config.ViperKeyPasskeyRPID, "") id := fix.createIdentity(t) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, fix.reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, fix.reg, id) req, err := http.NewRequest("GET", fix.publicTS.URL+settings.RouteInitBrowserFlow, nil) require.NoError(t, err) @@ -67,7 +67,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("case=a device is shown which can be unlinked", func(t *testing.T) { id := fix.createIdentity(t) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, fix.reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, fix.reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, apiClient, true, fix.publicTS) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ @@ -81,7 +81,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("case=invalid credentials", func(t *testing.T) { id, _ := fix.createIdentityAndReturnIdentifier(t, []byte(`{invalid}`)) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, fix.reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, fix.reg, id) req, err := http.NewRequest("GET", fix.publicTS.URL+settings.RouteInitBrowserFlow, nil) require.NoError(t, err) @@ -95,7 +95,7 @@ func TestCompleteSettings(t *testing.T) { id := fix.createIdentityWithoutPasskey(t) require.NoError(t, fix.reg.PrivilegedIdentityPool().UpdateIdentity(fix.ctx, id)) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, fix.reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, fix.reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, apiClient, true, fix.publicTS) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ @@ -110,7 +110,7 @@ func TestCompleteSettings(t *testing.T) { id := fix.createIdentityWithoutPasskey(t) require.NoError(t, fix.reg.PrivilegedIdentityPool().UpdateIdentity(fix.ctx, id)) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, fix.reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, fix.reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, fix.publicTS) for _, n := range f.Ui.Nodes { assert.NotEqual(t, n.Group, "passkey", "unexpected group: %s", n.Group) @@ -118,7 +118,7 @@ func TestCompleteSettings(t *testing.T) { }) doAPIFlow := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) { - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, fix.reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, fix.reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, fix.publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) v(values) @@ -127,7 +127,7 @@ func TestCompleteSettings(t *testing.T) { } doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity) (string, *http.Response) { - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, fix.reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, fix.reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, fix.publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) v(values) @@ -234,7 +234,7 @@ func TestCompleteSettings(t *testing.T) { var id identity.Identity require.NoError(t, json.Unmarshal(settingsFixtureSuccessIdentity, &id)) _ = fix.reg.PrivilegedIdentityPool().DeleteIdentity(fix.ctx, id.ID) - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, fix.reg, &id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, fix.reg, &id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, fix.publicTS) // We inject the session to replay @@ -271,6 +271,13 @@ func TestCompleteSettings(t *testing.T) { flow.PrefixInternalContextKey(identity.CredentialsTypePasskey, passkey.InternalContextKeySessionData))) testhelpers.EnsureAAL(t, browserClient, fix.publicTS, "aal1", string(identity.CredentialsTypePasskey)) + + if spa { + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body) + assert.Contains(t, gjson.Get(body, "continue_with.0.redirect_browser_to").String(), fix.uiTS.URL, "%s", body) + } else { + assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body) + } } t.Run("type=browser", func(t *testing.T) { @@ -431,7 +438,7 @@ func TestCompleteSettings(t *testing.T) { var id identity.Identity require.NoError(t, json.Unmarshal(settingsFixtureSuccessIdentity, &id)) _ = fix.reg.PrivilegedIdentityPool().DeleteIdentity(fix.ctx, id.ID) - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, fix.reg, &id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, fix.reg, &id) req, err := http.NewRequest("GET", fix.publicTS.URL+settings.RouteInitBrowserFlow, nil) require.NoError(t, err) diff --git a/selfservice/strategy/passkey/testfixture_test.go b/selfservice/strategy/passkey/testfixture_test.go index d7abf8459fb6..1f3090177341 100644 --- a/selfservice/strategy/passkey/testfixture_test.go +++ b/selfservice/strategy/passkey/testfixture_test.go @@ -207,8 +207,8 @@ func (fix *fixture) submitWebAuthnLoginWithClient(t *testing.T, isSPA bool, cont return fix.submitWebAuthnLoginFlowWithClient(t, isSPA, f, contextFixture, client, cb) } -func (fix *fixture) submitWebAuthnLogin(t *testing.T, isSPA bool, id *identity.Identity, contextFixture []byte, cb func(values url.Values), opts ...testhelpers.InitFlowWithOption) (string, *http.Response, *kratos.LoginFlow) { - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, fix.reg, id) +func (fix *fixture) submitWebAuthnLogin(t *testing.T, ctx context.Context, isSPA bool, id *identity.Identity, contextFixture []byte, cb func(values url.Values), opts ...testhelpers.InitFlowWithOption) (string, *http.Response, *kratos.LoginFlow) { + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, fix.reg, id) return fix.submitWebAuthnLoginWithClient(t, isSPA, contextFixture, browserClient, cb, opts...) } diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor.json new file mode 100644 index 000000000000..4b13f4012f6f --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor.json @@ -0,0 +1,74 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "text", + "value": "", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070004, + "text": "ID", + "type": "info" + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "password", + "attributes": { + "name": "password", + "type": "password", + "required": true, + "autocomplete": "current-password", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070001, + "text": "Password", + "type": "info" + } + } + }, + { + "type": "input", + "group": "password", + "attributes": { + "name": "method", + "type": "submit", + "value": "password", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010022, + "text": "Sign in with password", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh.json new file mode 100644 index 000000000000..eb9d0e213786 --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactorRefresh.json @@ -0,0 +1,67 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "hidden", + "value": "some@user.com", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "password", + "attributes": { + "name": "password", + "type": "password", + "required": true, + "autocomplete": "current-password", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070001, + "text": "Password", + "type": "info" + } + } + }, + { + "type": "input", + "group": "password", + "attributes": { + "name": "method", + "type": "submit", + "value": "password", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010001, + "text": "Sign in", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 000000000000..831d9f07ba25 --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1,54 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "password", + "attributes": { + "name": "password", + "type": "password", + "required": true, + "autocomplete": "current-password", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070001, + "text": "Password", + "type": "info" + } + } + }, + { + "type": "input", + "group": "password", + "attributes": { + "name": "method", + "type": "submit", + "value": "password", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010022, + "text": "Sign in with password", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_password.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_password.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_password.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_password.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_password.json new file mode 100644 index 000000000000..831d9f07ba25 --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_password.json @@ -0,0 +1,54 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "password", + "attributes": { + "name": "password", + "type": "password", + "required": true, + "autocomplete": "current-password", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070001, + "text": "Password", + "type": "info" + } + } + }, + { + "type": "input", + "group": "password", + "attributes": { + "name": "method", + "type": "submit", + "value": "password", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010022, + "text": "Sign in with password", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled_and_identity_has_no_password.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled_and_identity_has_no_password.json new file mode 100644 index 000000000000..831d9f07ba25 --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled_and_identity_has_no_password.json @@ -0,0 +1,54 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "password", + "attributes": { + "name": "password", + "type": "password", + "required": true, + "autocomplete": "current-password", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070001, + "text": "Password", + "type": "info" + } + } + }, + { + "type": "input", + "group": "password", + "attributes": { + "name": "method", + "type": "submit", + "value": "password", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010022, + "text": "Sign in with password", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 000000000000..831d9f07ba25 --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1,54 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "password", + "attributes": { + "name": "password", + "type": "password", + "required": true, + "autocomplete": "current-password", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070001, + "text": "Password", + "type": "info" + } + } + }, + { + "type": "input", + "group": "password", + "attributes": { + "name": "method", + "type": "submit", + "value": "password", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010022, + "text": "Sign in with password", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh.json b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh.json new file mode 100644 index 000000000000..19765bd501b6 --- /dev/null +++ b/selfservice/strategy/password/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactorRefresh.json @@ -0,0 +1 @@ +null diff --git a/selfservice/strategy/password/login.go b/selfservice/strategy/password/login.go index 3600e29b4e0e..89730d299463 100644 --- a/selfservice/strategy/password/login.go +++ b/selfservice/strategy/password/login.go @@ -10,33 +10,32 @@ 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/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" ) +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)) @@ -62,19 +61,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 @@ -92,27 +91,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 @@ -144,43 +143,87 @@ func (s *Strategy) migratePasswordHash(ctx context.Context, identifier uuid.UUID return s.d.PrivilegedIdentityPool().UpdateIdentity(ctx, i) } -func (s *Strategy) PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, sr *login.Flow) error { - // This strategy can only solve AAL1 - if requestedAAL > identity.AuthenticatorAssuranceLevel1 { +func (s *Strategy) PopulateLoginMethodFirstFactorRefresh(r *http.Request, sr *login.Flow) error { + identifier, id, _ := flowhelpers.GuessForcedLoginIdentifier(r, s.d, sr, s.ID()) + if identifier == "" { return nil } - if sr.IsForced() { - // We only show this method on a refresh request if the user has indeed a password set. - identifier, id, _ := flowhelpers.GuessForcedLoginIdentifier(r, s.d, sr, s.ID()) - if identifier == "" { - return nil - } + // If we don't have a password set, do not show the password field. + count, err := s.CountActiveFirstFactorCredentials(id.Credentials) + if err != nil { + return err + } else if count == 0 { + return nil + } - count, err := s.CountActiveFirstFactorCredentials(id.Credentials) - if err != nil { + sr.UI.SetCSRF(s.d.GenerateCSRFToken(r)) + sr.UI.SetNode(node.NewInputField("identifier", identifier, node.DefaultGroup, node.InputAttributeTypeHidden)) + sr.UI.SetNode(NewPasswordNode("password", node.InputAttributeAutocompleteCurrentPassword)) + sr.UI.GetNodes().Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLogin())) + return nil +} + +func (s *Strategy) PopulateLoginMethodSecondFactor(r *http.Request, sr *login.Flow) error { + return nil +} + +func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, sr *login.Flow) error { + return nil +} + +func (s *Strategy) addIdentifierNode(r *http.Request, sr *login.Flow) error { + ds, err := s.d.Config().DefaultIdentityTraitsSchemaURL(r.Context()) + if err != nil { + return err + } + + identifierLabel, err := login.GetIdentifierLabelFromSchema(r.Context(), ds.String()) + if err != nil { + return err + } + + sr.UI.SetNode(node.NewInputField("identifier", "", node.DefaultGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute).WithMetaLabel(identifierLabel)) + return nil +} + +func (s *Strategy) PopulateLoginMethodFirstFactor(r *http.Request, sr *login.Flow) error { + if err := s.addIdentifierNode(r, sr); err != nil { + return err + } + + sr.UI.SetCSRF(s.d.GenerateCSRFToken(r)) + sr.UI.SetNode(NewPasswordNode("password", node.InputAttributeAutocompleteCurrentPassword)) + sr.UI.GetNodes().Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLoginPassword())) + return nil +} + +func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *login.Flow, opts ...login.FormHydratorModifier) error { + o := login.NewFormHydratorOptions(opts) + + var count int + if o.IdentityHint != nil { + var err error + // If we have an identity hint we can perform identity credentials discovery and + // hide this credential if it should not be included. + if count, err = s.CountActiveFirstFactorCredentials(o.IdentityHint.Credentials); err != nil { return err - } else if count == 0 { - return nil } + } + if count > 0 || s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { sr.UI.SetCSRF(s.d.GenerateCSRFToken(r)) - sr.UI.SetNode(node.NewInputField("identifier", identifier, node.DefaultGroup, node.InputAttributeTypeHidden)) - } else { - ds, err := s.d.Config().DefaultIdentityTraitsSchemaURL(r.Context()) - if err != nil { - return err - } - identifierLabel, err := login.GetIdentifierLabelFromSchema(r.Context(), ds.String()) - if err != nil { - return err - } - sr.UI.SetNode(node.NewInputField("identifier", "", node.DefaultGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute).WithMetaLabel(identifierLabel)) + sr.UI.SetNode(NewPasswordNode("password", node.InputAttributeAutocompleteCurrentPassword)) + sr.UI.GetNodes().Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLoginPassword())) } - sr.UI.SetCSRF(s.d.GenerateCSRFToken(r)) - sr.UI.SetNode(NewPasswordNode("password", node.InputAttributeAutocompleteCurrentPassword)) - sr.UI.GetNodes().Append(node.NewInputField("method", "password", node.PasswordGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLogin())) + if count == 0 { + return errors.WithStack(idfirst.ErrNoCredentialsFound) + } + + return nil +} +func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, sr *login.Flow) error { return nil } diff --git a/selfservice/strategy/password/login_test.go b/selfservice/strategy/password/login_test.go index 8c2f2cb73245..9ddfd807afe4 100644 --- a/selfservice/strategy/password/login_test.go +++ b/selfservice/strategy/password/login_test.go @@ -11,27 +11,35 @@ import ( "fmt" "io" "net/http" + "net/http/httptest" "net/url" "strings" "testing" "time" - "github.com/gobuffalo/httptest" + "github.com/ory/kratos/selfservice/strategy/idfirst" + + configtesthelpers "github.com/ory/kratos/driver/config/testhelpers" + + "github.com/ory/x/snapshotx" + + "github.com/ory/kratos/driver" + "github.com/ory/kratos/internal/registrationhelpers" + + "github.com/ory/kratos/selfservice/flow" + "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" - "github.com/ory/kratos/driver" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/hash" "github.com/ory/kratos/identity" "github.com/ory/kratos/internal" kratos "github.com/ory/kratos/internal/httpclient" - "github.com/ory/kratos/internal/registrationhelpers" "github.com/ory/kratos/internal/testhelpers" "github.com/ory/kratos/schema" - "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/text" "github.com/ory/kratos/x" @@ -45,10 +53,10 @@ import ( //go:embed stub/login.schema.json var loginSchema []byte -func createIdentity(ctx context.Context, reg *driver.RegistryDefault, t *testing.T, identifier, password string) { +func createIdentity(ctx context.Context, reg *driver.RegistryDefault, t *testing.T, identifier, password string) *identity.Identity { p, _ := reg.Hasher(ctx).Generate(context.Background(), []byte(password)) iId := x.NewUUID() - require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &identity.Identity{ + id := &identity.Identity{ ID: iId, Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)), Credentials: map[identity.CredentialsType]identity.Credentials{ @@ -67,7 +75,9 @@ func createIdentity(ctx context.Context, reg *driver.RegistryDefault, t *testing IdentityID: iId, }, }, - })) + } + require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, id)) + return id } func TestCompleteLogin(t *testing.T) { @@ -510,7 +520,7 @@ func TestCompleteLogin(t *testing.T) { t.Run("do not show password method if identity has no password set", func(t *testing.T) { id := identity.NewIdentity("") - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) res, err := browserClient.Get(publicTS.URL + login.RouteInitBrowserFlow + "?refresh=true") require.NoError(t, err) @@ -570,7 +580,7 @@ func TestCompleteLogin(t *testing.T) { t.Run("do not show password method if identity has no password set", func(t *testing.T) { id := identity.NewIdentity("") - hc := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + hc := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) res, err := hc.Do(testhelpers.NewHTTPGetAJAXRequest(t, publicTS.URL+login.RouteInitBrowserFlow+"?refresh=true")) require.NoError(t, err) @@ -629,7 +639,7 @@ func TestCompleteLogin(t *testing.T) { t.Run("do not show password method if identity has no password set", func(t *testing.T) { id := identity.NewIdentity("") - hc := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + hc := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) res, err := hc.Do(testhelpers.NewHTTPGetAJAXRequest(t, publicTS.URL+login.RouteInitAPIFlow+"?refresh=true")) require.NoError(t, err) @@ -737,6 +747,32 @@ func TestCompleteLogin(t *testing.T) { assert.Equal(t, identifier, gjson.Get(body, "identity.traits.subject").String(), "%s", body) }) + t.Run("should succeed and include redirect continue_with in SPA flow", func(t *testing.T) { + identifier, pwd := x.NewUUID().String(), "password" + createIdentity(ctx, reg, t, identifier, pwd) + + browserClient := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, true, false, false) + values := url.Values{"method": {"password"}, "identifier": {strings.ToUpper(identifier)}, "password": {pwd}, "csrf_token": {x.FakeCSRFToken}}.Encode() + body, res := testhelpers.LoginMakeRequest(t, false, true, f, browserClient, values) + + assert.EqualValues(t, http.StatusOK, res.StatusCode) + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body) + assert.EqualValues(t, conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), gjson.Get(body, "continue_with.0.redirect_browser_to").String(), "%s", body) + }) + + t.Run("should succeed and not have redirect continue_with in api flow", func(t *testing.T) { + identifier, pwd := x.NewUUID().String(), "password" + createIdentity(ctx, reg, t, identifier, pwd) + browserClient := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false) + + body, res := testhelpers.LoginMakeRequest(t, true, true, f, browserClient, fmt.Sprintf(`{"method":"password","identifier":"%s","password":"%s"}`, strings.ToUpper(identifier), pwd)) + + assert.EqualValues(t, http.StatusOK, res.StatusCode, body) + assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body) + }) + t.Run("should login even if old form field name is used", func(t *testing.T) { identifier, pwd := x.NewUUID().String(), "password" createIdentity(ctx, reg, t, identifier, pwd) @@ -983,7 +1019,6 @@ func TestCompleteLogin(t *testing.T) { }, expectSuccess: false, }} { - t.Run("case="+tc.name, func(t *testing.T) { if tc.setupFn != nil { cleanup := tc.setupFn() @@ -1064,3 +1099,129 @@ func TestCompleteLogin(t *testing.T) { } }) } + +func TestFormHydration(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword), map[string]interface{}{"enabled": true}) + ctx = testhelpers.WithDefaultIdentitySchemaFromRaw(ctx, loginSchema) + + s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsTypePassword) + require.NoError(t, err) + fh, ok := s.(login.FormHydrator) + require.True(t, ok) + + toSnapshot := func(t *testing.T, f *login.Flow) { + t.Helper() + // The CSRF token has a unique value that messes with the snapshot - ignore it. + f.UI.Nodes.ResetNodes("csrf_token") + snapshotx.SnapshotT(t, f.UI.Nodes) + } + newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *login.Flow) { + r := httptest.NewRequest("GET", "/self-service/login/browser", nil) + r = r.WithContext(ctx) + t.Helper() + f, err := login.NewFlow(conf, time.Minute, "csrf_token", r, flow.TypeBrowser) + require.NoError(t, err) + return r, f + } + + t.Run("method=PopulateLoginMethodSecondFactor", func(t *testing.T) { + r, f := newFlow(ctx, t) + f.RequestedAAL = identity.AuthenticatorAssuranceLevel2 + require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodFirstFactor", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodFirstFactorRefresh", func(t *testing.T) { + r, f := newFlow(ctx, t) + id := createIdentity(ctx, reg, t, "some@user.com", "password") + r.Header = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader() + f.Refresh = true + require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodSecondFactorRefresh", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodSecondFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) { + t.Run("case=no options", 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), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true) + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + }) + + t.Run("case=WithIdentifier", func(t *testing.T) { + 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) + + id := identity.NewIdentity("default") + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + ctx := configtesthelpers.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false) + + t.Run("case=identity has password", func(t *testing.T) { + identifier, pwd := x.NewUUID().String(), "password" + id := createIdentity(ctx, reg, t, identifier, pwd) + + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + toSnapshot(t, f) + }) + + t.Run("case=identity does not have a password", func(t *testing.T) { + id := identity.NewIdentity("default") + r, f := newFlow(ctx, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + }) + }) + }) + + t.Run("method=PopulateLoginMethodIdentifierFirstIdentification", func(t *testing.T) { + r, f := newFlow(ctx, t) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstIdentification(r, f)) + toSnapshot(t, f) + }) +} diff --git a/selfservice/strategy/password/registration_test.go b/selfservice/strategy/password/registration_test.go index 14bf2382b212..d52ca2d77707 100644 --- a/selfservice/strategy/password/registration_test.go +++ b/selfservice/strategy/password/registration_test.go @@ -14,6 +14,8 @@ import ( "testing" "time" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/driver" "github.com/ory/kratos/internal/registrationhelpers" @@ -106,7 +108,7 @@ func TestRegistration(t *testing.T) { }) }) - var expectLoginBody = func(t *testing.T, browserRedirTS *httptest.Server, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string { + var expectRegistrationBody = func(t *testing.T, browserRedirTS *httptest.Server, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string { if isAPI { return testhelpers.SubmitRegistrationForm(t, isAPI, hc, publicTS, values, isSPA, http.StatusOK, @@ -126,17 +128,17 @@ func TestRegistration(t *testing.T) { isSPA, http.StatusOK, expectReturnTo) } - var expectSuccessfulLogin = func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string { + var expectSuccessfulRegistration = func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string { useReturnToFromTS(redirTS) - return expectLoginBody(t, redirTS, isAPI, isSPA, hc, values) + return expectRegistrationBody(t, redirTS, isAPI, isSPA, hc, values) } - var expectNoLogin = func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string { + var expectNoRegistration = func(t *testing.T, isAPI, isSPA bool, hc *http.Client, values func(url.Values)) string { useReturnToFromTS(redirNoSessionTS) t.Cleanup(func() { useReturnToFromTS(redirTS) }) - return expectLoginBody(t, redirNoSessionTS, isAPI, isSPA, hc, values) + return expectRegistrationBody(t, redirNoSessionTS, isAPI, isSPA, hc, values) } t.Run("case=should reject invalid transient payload", func(t *testing.T) { @@ -178,7 +180,7 @@ func TestRegistration(t *testing.T) { t.Run("type=api", func(t *testing.T) { username := x.NewUUID().String() - body := expectSuccessfulLogin(t, true, false, nil, func(v url.Values) { + body := expectSuccessfulRegistration(t, true, false, nil, func(v url.Values) { setValues(username, v) }) assert.Equal(t, username, gjson.Get(body, "identity.traits.username").String(), "%s", body) @@ -188,7 +190,7 @@ func TestRegistration(t *testing.T) { t.Run("type=spa", func(t *testing.T) { username := x.NewUUID().String() - body := expectSuccessfulLogin(t, false, true, nil, func(v url.Values) { + body := expectSuccessfulRegistration(t, false, true, nil, func(v url.Values) { setValues(username, v) }) assert.Equal(t, username, gjson.Get(body, "identity.traits.username").String(), "%s", body) @@ -198,7 +200,7 @@ func TestRegistration(t *testing.T) { t.Run("type=browser", func(t *testing.T) { username := x.NewUUID().String() - body := expectSuccessfulLogin(t, false, false, nil, func(v url.Values) { + body := expectSuccessfulRegistration(t, false, false, nil, func(v url.Values) { setValues(username, v) }) assert.Equal(t, username, gjson.Get(body, "identity.traits.username").String(), "%s", body) @@ -213,7 +215,7 @@ func TestRegistration(t *testing.T) { }) t.Run("type=api", func(t *testing.T) { - body := expectSuccessfulLogin(t, true, false, nil, func(v url.Values) { + body := expectSuccessfulRegistration(t, true, false, nil, func(v url.Values) { v.Set("traits.username", "registration-identifier-8-api") v.Set("password", x.NewUUID().String()) v.Set("traits.foobar", "bar") @@ -221,10 +223,11 @@ func TestRegistration(t *testing.T) { assert.Equal(t, `registration-identifier-8-api`, gjson.Get(body, "identity.traits.username").String(), "%s", body) assert.NotEmpty(t, gjson.Get(body, "session_token").String(), "%s", body) assert.NotEmpty(t, gjson.Get(body, "session.id").String(), "%s", body) + assert.NotContains(t, gjson.Get(body, "continue_with").Raw, string(flow.ContinueWithActionRedirectBrowserToString), "%s", body) }) t.Run("type=spa", func(t *testing.T) { - body := expectSuccessfulLogin(t, false, true, nil, func(v url.Values) { + body := expectSuccessfulRegistration(t, false, true, nil, func(v url.Values) { v.Set("traits.username", "registration-identifier-8-spa") v.Set("password", x.NewUUID().String()) v.Set("traits.foobar", "bar") @@ -232,15 +235,17 @@ func TestRegistration(t *testing.T) { assert.Equal(t, `registration-identifier-8-spa`, gjson.Get(body, "identity.traits.username").String(), "%s", body) assert.Empty(t, gjson.Get(body, "session_token").String(), "%s", body) assert.NotEmpty(t, gjson.Get(body, "session.id").String(), "%s", body) + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body) }) t.Run("type=browser", func(t *testing.T) { - body := expectSuccessfulLogin(t, false, false, nil, func(v url.Values) { + body := expectSuccessfulRegistration(t, false, false, nil, func(v url.Values) { v.Set("traits.username", "registration-identifier-8-browser") v.Set("password", x.NewUUID().String()) v.Set("traits.foobar", "bar") }) assert.Equal(t, `registration-identifier-8-browser`, gjson.Get(body, "identity.traits.username").String(), "%s", body) + assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body) }) }) @@ -249,7 +254,7 @@ func TestRegistration(t *testing.T) { conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil) t.Run("type=api", func(t *testing.T) { - body := expectNoLogin(t, true, false, nil, func(v url.Values) { + body := expectNoRegistration(t, true, false, nil, func(v url.Values) { v.Set("traits.username", "registration-identifier-8-api-nosession") v.Set("password", x.NewUUID().String()) v.Set("traits.foobar", "bar") @@ -260,7 +265,7 @@ func TestRegistration(t *testing.T) { }) t.Run("type=spa", func(t *testing.T) { - expectNoLogin(t, false, true, nil, func(v url.Values) { + expectNoRegistration(t, false, true, nil, func(v url.Values) { v.Set("traits.username", "registration-identifier-8-spa-nosession") v.Set("password", x.NewUUID().String()) v.Set("traits.foobar", "bar") @@ -268,7 +273,7 @@ func TestRegistration(t *testing.T) { }) t.Run("type=browser", func(t *testing.T) { - expectNoLogin(t, false, false, nil, func(v url.Values) { + expectNoRegistration(t, false, false, nil, func(v url.Values) { v.Set("traits.username", "registration-identifier-8-browser-nosession") v.Set("password", x.NewUUID().String()) v.Set("traits.foobar", "bar") @@ -300,7 +305,7 @@ func TestRegistration(t *testing.T) { v.Set("traits.foobar", "bar") } - _ = expectSuccessfulLogin(t, true, false, apiClient, values) + _ = expectSuccessfulRegistration(t, true, false, apiClient, values) body := testhelpers.SubmitRegistrationForm(t, true, apiClient, publicTS, applyTransform(values, transform), false, http.StatusBadRequest, publicTS.URL+registration.RouteSubmitFlow) @@ -314,7 +319,7 @@ func TestRegistration(t *testing.T) { v.Set("traits.foobar", "bar") } - _ = expectSuccessfulLogin(t, false, true, nil, values) + _ = expectSuccessfulRegistration(t, false, true, nil, values) body := registrationhelpers.ExpectValidationError(t, publicTS, conf, "spa", applyTransform(values, transform)) assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "You tried signing in with registration-identifier-8-spa-duplicate-"+suffix+" which is already in use by another account. You can sign in using your password.", "%s", body) }) @@ -326,7 +331,7 @@ func TestRegistration(t *testing.T) { v.Set("traits.foobar", "bar") } - _ = expectSuccessfulLogin(t, false, false, nil, values) + _ = expectSuccessfulRegistration(t, false, false, nil, values) body := registrationhelpers.ExpectValidationError(t, publicTS, conf, "browser", applyTransform(values, transform)) assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "You tried signing in with registration-identifier-8-browser-duplicate-"+suffix+" which is already in use by another account. You can sign in using your password.", "%s", body) }) @@ -541,7 +546,7 @@ func TestRegistration(t *testing.T) { }) t.Run("type=api", func(t *testing.T) { - actual := expectSuccessfulLogin(t, true, false, nil, func(v url.Values) { + actual := expectSuccessfulRegistration(t, true, false, nil, func(v url.Values) { v.Set("traits.username", "registration-identifier-10-api") v.Set("password", x.NewUUID().String()) v.Set("traits.foobar", "bar") @@ -550,7 +555,7 @@ func TestRegistration(t *testing.T) { }) t.Run("type=spa", func(t *testing.T) { - actual := expectSuccessfulLogin(t, false, false, nil, func(v url.Values) { + actual := expectSuccessfulRegistration(t, false, false, nil, func(v url.Values) { v.Set("traits.username", "registration-identifier-10-spa") v.Set("password", x.NewUUID().String()) v.Set("traits.foobar", "bar") @@ -559,7 +564,7 @@ func TestRegistration(t *testing.T) { }) t.Run("type=browser", func(t *testing.T) { - actual := expectSuccessfulLogin(t, false, false, nil, func(v url.Values) { + actual := expectSuccessfulRegistration(t, false, false, nil, func(v url.Values) { v.Set("traits.username", "registration-identifier-10-browser") v.Set("password", x.NewUUID().String()) v.Set("traits.foobar", "bar") @@ -620,7 +625,7 @@ func TestRegistration(t *testing.T) { username := "registration-custom-schema" t.Run("type=api", func(t *testing.T) { - body := expectNoLogin(t, true, false, nil, func(v url.Values) { + body := expectNoRegistration(t, true, false, nil, func(v url.Values) { v.Set("traits.username", username+"-api") v.Set("password", x.NewUUID().String()) v.Set("traits.baz", "bar") @@ -631,7 +636,7 @@ func TestRegistration(t *testing.T) { }) t.Run("type=spa", func(t *testing.T) { - expectNoLogin(t, false, true, nil, func(v url.Values) { + expectNoRegistration(t, false, true, nil, func(v url.Values) { v.Set("traits.username", username+"-spa") v.Set("password", x.NewUUID().String()) v.Set("traits.baz", "bar") @@ -639,7 +644,7 @@ func TestRegistration(t *testing.T) { }) t.Run("type=browser", func(t *testing.T) { - expectNoLogin(t, false, false, nil, func(v url.Values) { + expectNoRegistration(t, false, false, nil, func(v url.Values) { v.Set("traits.username", username+"-browser") v.Set("password", x.NewUUID().String()) v.Set("traits.baz", "bar") diff --git a/selfservice/strategy/password/settings_test.go b/selfservice/strategy/password/settings_test.go index a4ee7e6c7fa0..e5ea909856b0 100644 --- a/selfservice/strategy/password/settings_test.go +++ b/selfservice/strategy/password/settings_test.go @@ -13,6 +13,8 @@ import ( "strings" "testing" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/internal/settingshelpers" "github.com/ory/kratos/text" @@ -82,7 +84,7 @@ func TestSettings(t *testing.T) { testhelpers.StrategyEnable(t, conf, identity.CredentialsTypePassword.String(), true) testhelpers.StrategyEnable(t, conf, settings.StrategyProfile, true) - _ = testhelpers.NewSettingsUIFlowEchoServer(t, reg) + settingsUI := testhelpers.NewSettingsUIFlowEchoServer(t, reg) _ = testhelpers.NewErrorTestServer(t, reg) _ = testhelpers.NewLoginUIWith401Response(t, conf) conf.MustSet(ctx, config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "1m") @@ -94,10 +96,10 @@ func TestSettings(t *testing.T) { publicTS, _ := testhelpers.NewKratosServer(t, reg) - browserUser1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, browserIdentity1) - browserUser2 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, browserIdentity2) - apiUser1 := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, apiIdentity1) - apiUser2 := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, apiIdentity2) + browserUser1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, browserIdentity1) + browserUser2 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, browserIdentity2) + apiUser1 := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, apiIdentity1) + apiUser2 := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, apiIdentity2) t.Run("description=not authorized to call endpoints without a session", func(t *testing.T) { c := testhelpers.NewDebugClient(t) @@ -242,15 +244,20 @@ func TestSettings(t *testing.T) { t.Run("type=api", func(t *testing.T) { actual := testhelpers.SubmitSettingsForm(t, true, false, apiUser1, publicTS, payload, http.StatusOK, publicTS.URL+settings.RouteSubmitFlow) check(t, actual) + assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual) }) t.Run("type=spa", func(t *testing.T) { actual := testhelpers.SubmitSettingsForm(t, false, true, browserUser1, publicTS, payload, http.StatusOK, publicTS.URL+settings.RouteSubmitFlow) check(t, actual) + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), settingsUI.URL, "%s", actual) }) t.Run("type=browser", func(t *testing.T) { - check(t, testhelpers.SubmitSettingsForm(t, false, false, browserUser1, publicTS, payload, http.StatusOK, conf.SelfServiceFlowSettingsUI(ctx).String())) + actual := testhelpers.SubmitSettingsForm(t, false, false, browserUser1, publicTS, payload, http.StatusOK, conf.SelfServiceFlowSettingsUI(ctx).String()) + check(t, actual) + assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual) }) }) @@ -340,9 +347,9 @@ func TestSettings(t *testing.T) { bi := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh") si := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh") ai := newIdentityWithoutCredentials(x.NewUUID().String() + "@ory.sh") - browserUser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, bi) - spaUser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, si) - apiUser := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, ai) + browserUser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, bi) + spaUser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, si) + apiUser := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, ai) var check = func(t *testing.T, actual string, id *identity.Identity) { assert.Equal(t, "success", gjson.Get(actual, "state").String(), "%s", actual) @@ -433,12 +440,12 @@ func TestSettings(t *testing.T) { var initClients = func(isAPI, isSPA bool, id *identity.Identity) (client1, client2 *http.Client) { if isAPI { - client1 = testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) - client2 = testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + client1 = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) + client2 = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) return client1, client2 } - client1 = testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) - client2 = testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + client1 = testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) + client2 = testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) return client1, client2 } @@ -485,8 +492,8 @@ func TestSettings(t *testing.T) { testhelpers.SetDefaultIdentitySchema(conf, "file://stub/missing-identifier.schema.json") id := newIdentityWithoutCredentials(testhelpers.RandomEmail()) - browser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) - api := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + browser := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) + api := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) for _, f := range []string{"spa", "api", "browser"} { t.Run("type="+f, func(t *testing.T) { diff --git a/selfservice/strategy/password/strategy_disabled_test.go b/selfservice/strategy/password/strategy_disabled_test.go index 6cf92147c3a4..e95e98c5913e 100644 --- a/selfservice/strategy/password/strategy_disabled_test.go +++ b/selfservice/strategy/password/strategy_disabled_test.go @@ -4,6 +4,7 @@ package password_test import ( + "context" "io" "net/http" "net/url" @@ -54,7 +55,7 @@ func TestDisabledEndpoint(t *testing.T) { t.Run("case=should not settings when password method is disabled", func(t *testing.T) { testhelpers.SetDefaultIdentitySchema(conf, "file://stub/login.schema.json") - c := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, reg) + c := testhelpers.NewHTTPClientWithArbitrarySessionCookie(t, context.Background(), reg) t.Run("method=GET", func(t *testing.T) { t.Skip("GET is currently not supported for this endpoint.") diff --git a/selfservice/strategy/profile/.snapshots/TestStrategyTraits-description=hydrate_the_proper_fields-type=api#01.json b/selfservice/strategy/profile/.snapshots/TestStrategyTraits-description=hydrate_the_proper_fields-type=spa.json similarity index 100% rename from selfservice/strategy/profile/.snapshots/TestStrategyTraits-description=hydrate_the_proper_fields-type=api#01.json rename to selfservice/strategy/profile/.snapshots/TestStrategyTraits-description=hydrate_the_proper_fields-type=spa.json diff --git a/selfservice/strategy/profile/strategy_test.go b/selfservice/strategy/profile/strategy_test.go index 7d0c831711c3..fa5b1652a130 100644 --- a/selfservice/strategy/profile/strategy_test.go +++ b/selfservice/strategy/profile/strategy_test.go @@ -92,10 +92,10 @@ func TestStrategyTraits(t *testing.T) { browserIdentity2 := &identity.Identity{ID: x.NewUUID(), Traits: identity.Traits(`{}`), State: identity.StateActive} apiIdentity2 := &identity.Identity{ID: x.NewUUID(), Traits: identity.Traits(`{}`), State: identity.StateActive} - browserUser1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, browserIdentity1) - browserUser2 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, browserIdentity2) - apiUser1 := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, apiIdentity1) - apiUser2 := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, apiIdentity2) + browserUser1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, browserIdentity1) + browserUser2 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, browserIdentity2) + apiUser1 := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, apiIdentity1) + apiUser2 := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, apiIdentity2) t.Run("description=not authorized to call endpoints without a session", func(t *testing.T) { setUnprivileged(t) @@ -210,7 +210,7 @@ func TestStrategyTraits(t *testing.T) { run(t, apiIdentity1, pr, settings.RouteInitAPIFlow) }) - t.Run("type=api", func(t *testing.T) { + t.Run("type=spa", func(t *testing.T) { pr, _, err := testhelpers.NewSDKCustomClient(publicTS, browserUser1).FrontendApi.CreateBrowserSettingsFlow(context.Background()).Execute() require.NoError(t, err) run(t, browserIdentity1, pr, settings.RouteInitBrowserFlow) @@ -449,15 +449,20 @@ func TestStrategyTraits(t *testing.T) { t.Run("type=api", func(t *testing.T) { actual := expectSuccess(t, true, false, apiUser1, payload("not-john-doe-api@mail.com")) check(t, actual) + assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual) }) t.Run("type=sqa", func(t *testing.T) { actual := expectSuccess(t, false, true, browserUser1, payload("not-john-doe-browser@mail.com")) check(t, actual) + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), ui.URL, "%s", actual) }) t.Run("type=browser", func(t *testing.T) { - check(t, expectSuccess(t, false, false, browserUser1, payload("not-john-doe-browser@mail.com"))) + actual := expectSuccess(t, false, false, browserUser1, payload("not-john-doe-browser@mail.com")) + check(t, actual) + assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual) }) }) @@ -612,7 +617,7 @@ func TestDisabledEndpoint(t *testing.T) { publicTS, _ := testhelpers.NewKratosServer(t, reg) browserIdentity1 := newIdentityWithPassword("john-browser@doe.com") - browserUser1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, browserIdentity1) + browserUser1 := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, context.Background(), reg, browserIdentity1) t.Run("case=should not submit when profile method is disabled", func(t *testing.T) { t.Run("method=GET", func(t *testing.T) { diff --git a/selfservice/strategy/totp/.snapshots/TestCompleteLogin-case=totp_payload_is_set_when_identity_has_totp.json b/selfservice/strategy/totp/.snapshots/TestCompleteLogin-case=totp_payload_is_set_when_identity_has_totp.json index afae3de49f05..23611d1c2255 100644 --- a/selfservice/strategy/totp/.snapshots/TestCompleteLogin-case=totp_payload_is_set_when_identity_has_totp.json +++ b/selfservice/strategy/totp/.snapshots/TestCompleteLogin-case=totp_payload_is_set_when_identity_has_totp.json @@ -15,6 +15,7 @@ { "attributes": { "disabled": false, + "maxlength": 6, "name": "totp_code", "node_type": "input", "required": true, diff --git a/selfservice/strategy/totp/generator.go b/selfservice/strategy/totp/generator.go index fe79d8991d0d..9846506f1671 100644 --- a/selfservice/strategy/totp/generator.go +++ b/selfservice/strategy/totp/generator.go @@ -25,6 +25,7 @@ import ( // So we need 160/8 = 20 key length. stdtotp.Generate uses the key // length for reading from crypto.Rand. const secretSize = 160 / 8 +const digits = otp.DigitsSix func NewKey(ctx context.Context, accountName string, d interface { config.Provider @@ -33,7 +34,7 @@ func NewKey(ctx context.Context, accountName string, d interface { Issuer: d.Config().TOTPIssuer(ctx), AccountName: accountName, SecretSize: secretSize, - Digits: otp.DigitsSix, + Digits: digits, Period: 30, }) if err != nil { diff --git a/selfservice/strategy/totp/login.go b/selfservice/strategy/totp/login.go index 2aaface8dc5c..7b4564cb165d 100644 --- a/selfservice/strategy/totp/login.go +++ b/selfservice/strategy/totp/login.go @@ -50,7 +50,7 @@ func (s *Strategy) PopulateLoginMethod(r *http.Request, requestedAAL identity.Au } sr.UI.SetCSRF(s.d.GenerateCSRFToken(r)) - sr.UI.SetNode(node.NewInputField("totp_code", "", node.TOTPGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoLoginTOTPLabel())) + sr.UI.SetNode(node.NewInputField("totp_code", "", node.TOTPGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute, node.WithMaxLengthInputAttribute(int(digits))).WithMetaLabel(text.NewInfoLoginTOTPLabel())) sr.UI.GetNodes().Append(node.NewInputField("method", s.ID(), node.TOTPGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLoginTOTP())) return nil diff --git a/selfservice/strategy/totp/login_test.go b/selfservice/strategy/totp/login_test.go index 6456ea7cc599..7a48424ab412 100644 --- a/selfservice/strategy/totp/login_test.go +++ b/selfservice/strategy/totp/login_test.go @@ -13,6 +13,8 @@ import ( "testing" "time" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/x/assertx" "github.com/gofrs/uuid" @@ -107,7 +109,7 @@ func TestCompleteLogin(t *testing.T) { t.Run("case=totp payload is set when identity has totp", func(t *testing.T) { id, _, _ := createIdentity(t, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ "0.attributes.value", @@ -117,7 +119,7 @@ func TestCompleteLogin(t *testing.T) { t.Run("case=totp payload is not set when identity has no totp", func(t *testing.T) { id := createIdentityWithoutTOTP(t, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) assertx.EqualAsJSON(t, nil, f.Ui.Nodes) }) @@ -126,7 +128,7 @@ func TestCompleteLogin(t *testing.T) { id, _, _ := createIdentity(t, reg) t.Run("type=api", func(t *testing.T) { - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) body, res := testhelpers.LoginMakeRequest(t, true, false, f, apiClient, "14=)=!(%)$/ZP()GHIÖ") @@ -136,7 +138,7 @@ func TestCompleteLogin(t *testing.T) { }) t.Run("type=browser", func(t *testing.T) { - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, false, false, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) body, res := testhelpers.LoginMakeRequest(t, false, false, f, browserClient, "14=)=!(%)$/ZP()GHIÖ") @@ -146,7 +148,7 @@ func TestCompleteLogin(t *testing.T) { }) t.Run("type=spa", func(t *testing.T) { - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, true, false, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) body, res := testhelpers.LoginMakeRequest(t, false, true, f, browserClient, "14=)=!(%)$/ZP()GHIÖ") @@ -157,7 +159,7 @@ func TestCompleteLogin(t *testing.T) { }) doAPIFlow := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) { - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) values.Set("method", "totp") @@ -167,7 +169,7 @@ func TestCompleteLogin(t *testing.T) { } doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity, returnTo string) (string, *http.Response) { - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) opts := []testhelpers.InitFlowWithOption{testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)} if len(returnTo) > 0 { @@ -333,23 +335,36 @@ func TestCompleteLogin(t *testing.T) { t.Run("type=api", func(t *testing.T) { body, res := doAPIFlow(t, payload, id) check(t, false, body, res) + assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body) }) t.Run("type=browser", func(t *testing.T) { body, res := doBrowserFlow(t, false, payload, id, "") check(t, true, body, res) + assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body) }) t.Run("type=browser set return_to", func(t *testing.T) { returnTo := "https://www.ory.sh" - _, res := doBrowserFlow(t, false, payload, id, returnTo) + body, res := doBrowserFlow(t, false, payload, id, returnTo) t.Log(res.Request.URL.String()) assert.Contains(t, res.Request.URL.String(), returnTo) + assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body) }) t.Run("type=spa", func(t *testing.T) { body, res := doBrowserFlow(t, true, payload, id, "") check(t, false, body, res) + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body) + assert.EqualValues(t, conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), gjson.Get(body, "continue_with.0.redirect_browser_to").String(), "%s", body) + }) + + t.Run("type=spa set return_to", func(t *testing.T) { + returnTo := "https://www.ory.sh" + body, res := doBrowserFlow(t, true, payload, id, returnTo) + check(t, false, body, res) + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body) + assert.EqualValues(t, returnTo, gjson.Get(body, "continue_with.0.redirect_browser_to").String(), "%s", body) }) }) diff --git a/selfservice/strategy/totp/settings_test.go b/selfservice/strategy/totp/settings_test.go index 0fd479f1b220..b44cc736a560 100644 --- a/selfservice/strategy/totp/settings_test.go +++ b/selfservice/strategy/totp/settings_test.go @@ -64,7 +64,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("case=device unlinking is available when identity has totp", func(t *testing.T) { id, _, _ := createIdentity(t, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ "0.attributes.value", @@ -76,7 +76,7 @@ func TestCompleteSettings(t *testing.T) { id.Credentials = nil require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(context.Background(), id)) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ "0.attributes.value", @@ -87,7 +87,7 @@ func TestCompleteSettings(t *testing.T) { }) doAPIFlow := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) { - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) values.Set("method", "totp") @@ -97,7 +97,7 @@ func TestCompleteSettings(t *testing.T) { } doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity) (string, *http.Response) { - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) values.Set("method", "totp") @@ -241,6 +241,7 @@ func TestCompleteSettings(t *testing.T) { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual) checkIdentity(t, id) + assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual) }) t.Run("type=spa", func(t *testing.T) { @@ -250,6 +251,9 @@ func TestCompleteSettings(t *testing.T) { assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow) assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual) checkIdentity(t, id) + + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), uiTS.URL, "%s", actual) }) t.Run("type=browser", func(t *testing.T) { @@ -259,6 +263,7 @@ func TestCompleteSettings(t *testing.T) { assert.Contains(t, res.Request.URL.String(), uiTS.URL) assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual) checkIdentity(t, id) + assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual) }) }) @@ -344,12 +349,19 @@ func TestCompleteSettings(t *testing.T) { checkIdentity(t, id, key) testhelpers.EnsureAAL(t, hc, publicTS, "aal2", string(identity.CredentialsTypeTOTP)) + + if isSPA { + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), uiTS.URL, "%s", actual) + } else { + assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual) + } } t.Run("type=api", func(t *testing.T) { id := createIdentityWithoutTOTP(t, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) run(t, true, false, id, apiClient, f) @@ -358,7 +370,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("type=spa", func(t *testing.T) { id := createIdentityWithoutTOTP(t, reg) - user := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + user := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, user, true, publicTS) run(t, false, true, id, user, f) @@ -367,7 +379,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("type=browser", func(t *testing.T) { id := createIdentityWithoutTOTP(t, reg) - user := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + user := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, user, false, publicTS) run(t, false, false, id, user, f) diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=mfa-case=webauthn_payload_is_set_when_identity_has_webauthn.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=mfa-case=webauthn_payload_is_set_when_identity_has_webauthn.json index ca960c98d683..b180cf04a403 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=mfa-case=webauthn_payload_is_set_when_identity_has_webauthn.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=mfa-case=webauthn_payload_is_set_when_identity_has_webauthn.json @@ -24,25 +24,6 @@ "meta": {}, "type": "input" }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, { "attributes": { "disabled": false, @@ -61,7 +42,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" @@ -70,5 +51,24 @@ "messages": [], "meta": {}, "type": "script" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_login_trigger", + "node_type": "input", + "onclickTrigger": "oryWebAuthnLogin", + "type": "button" + }, + "group": "webauthn", + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + }, + "type": "input" } ] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=browser.json index f4be195cdecf..399562e7015d 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=browser.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=browser.json @@ -37,7 +37,7 @@ "async": true, "referrerpolicy": "no-referrer", "crossorigin": "anonymous", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "type": "text/javascript", "node_type": "script" }, @@ -51,6 +51,7 @@ "name": "webauthn_login_trigger", "type": "button", "disabled": false, + "onclickTrigger": "oryWebAuthnLogin", "node_type": "input" }, "messages": [], diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=spa.json index f4be195cdecf..399562e7015d 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=spa.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=should_fail_if_webauthn_login_is_invalid-type=spa.json @@ -37,7 +37,7 @@ "async": true, "referrerpolicy": "no-referrer", "crossorigin": "anonymous", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "type": "text/javascript", "node_type": "script" }, @@ -51,6 +51,7 @@ "name": "webauthn_login_trigger", "type": "button", "disabled": false, + "onclickTrigger": "oryWebAuthnLogin", "node_type": "input" }, "messages": [], diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=webauthn_button_exists.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=webauthn_button_exists.json index 6668b171ed43..052fc466dc5b 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=webauthn_button_exists.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=passwordless-case=webauthn_button_exists.json @@ -46,7 +46,7 @@ "meta": { "label": { "id": 1010008, - "text": "Use security key", + "text": "Sign in with hardware key", "type": "info" } }, diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false-browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false-browser.json deleted file mode 100644 index 581bff275b17..000000000000 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false-browser.json +++ /dev/null @@ -1,75 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "node_type": "input", - "required": true, - "type": "hidden" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "identifier", - "node_type": "input", - "type": "hidden", - "value": "foo@bar.com" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login", - "node_type": "input", - "type": "hidden", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "async": true, - "crossorigin": "anonymous", - "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", - "node_type": "script", - "referrerpolicy": "no-referrer", - "type": "text/javascript" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "script" - } -] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false-spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false-spa.json deleted file mode 100644 index 581bff275b17..000000000000 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false-spa.json +++ /dev/null @@ -1,75 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "node_type": "input", - "required": true, - "type": "hidden" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "identifier", - "node_type": "input", - "type": "hidden", - "value": "foo@bar.com" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login", - "node_type": "input", - "type": "hidden", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "async": true, - "crossorigin": "anonymous", - "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", - "node_type": "script", - "referrerpolicy": "no-referrer", - "type": "text/javascript" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "script" - } -] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true#01-browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true#01-browser.json deleted file mode 100644 index 581bff275b17..000000000000 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true#01-browser.json +++ /dev/null @@ -1,75 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "node_type": "input", - "required": true, - "type": "hidden" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "identifier", - "node_type": "input", - "type": "hidden", - "value": "foo@bar.com" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login", - "node_type": "input", - "type": "hidden", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "async": true, - "crossorigin": "anonymous", - "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", - "node_type": "script", - "referrerpolicy": "no-referrer", - "type": "text/javascript" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "script" - } -] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true#01-spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true#01-spa.json deleted file mode 100644 index 581bff275b17..000000000000 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true#01-spa.json +++ /dev/null @@ -1,75 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "node_type": "input", - "required": true, - "type": "hidden" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "identifier", - "node_type": "input", - "type": "hidden", - "value": "foo@bar.com" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login", - "node_type": "input", - "type": "hidden", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "async": true, - "crossorigin": "anonymous", - "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", - "node_type": "script", - "referrerpolicy": "no-referrer", - "type": "text/javascript" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "script" - } -] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true#02-browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true#02-browser.json deleted file mode 100644 index 581bff275b17..000000000000 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true#02-browser.json +++ /dev/null @@ -1,75 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "node_type": "input", - "required": true, - "type": "hidden" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "identifier", - "node_type": "input", - "type": "hidden", - "value": "foo@bar.com" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login", - "node_type": "input", - "type": "hidden", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "async": true, - "crossorigin": "anonymous", - "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", - "node_type": "script", - "referrerpolicy": "no-referrer", - "type": "text/javascript" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "script" - } -] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true#02-spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true#02-spa.json deleted file mode 100644 index 581bff275b17..000000000000 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true#02-spa.json +++ /dev/null @@ -1,75 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "node_type": "input", - "required": true, - "type": "hidden" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "identifier", - "node_type": "input", - "type": "hidden", - "value": "foo@bar.com" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login", - "node_type": "input", - "type": "hidden", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "async": true, - "crossorigin": "anonymous", - "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", - "node_type": "script", - "referrerpolicy": "no-referrer", - "type": "text/javascript" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "script" - } -] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true-browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true-browser.json deleted file mode 100644 index 581bff275b17..000000000000 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true-browser.json +++ /dev/null @@ -1,75 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "node_type": "input", - "required": true, - "type": "hidden" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "identifier", - "node_type": "input", - "type": "hidden", - "value": "foo@bar.com" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login", - "node_type": "input", - "type": "hidden", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "async": true, - "crossorigin": "anonymous", - "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", - "node_type": "script", - "referrerpolicy": "no-referrer", - "type": "text/javascript" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "script" - } -] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true-spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true-spa.json deleted file mode 100644 index 581bff275b17..000000000000 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=true-spa.json +++ /dev/null @@ -1,75 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "node_type": "input", - "required": true, - "type": "hidden" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "identifier", - "node_type": "input", - "type": "hidden", - "value": "foo@bar.com" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login", - "node_type": "input", - "type": "hidden", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "async": true, - "crossorigin": "anonymous", - "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", - "node_type": "script", - "referrerpolicy": "no-referrer", - "type": "text/javascript" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "script" - } -] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false#02-spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=mfa_v0_credentials-browser.json similarity index 85% rename from selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false#02-spa.json rename to selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=mfa_v0_credentials-browser.json index 581bff275b17..5021f44d2f94 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false#02-spa.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=mfa_v0_credentials-browser.json @@ -25,25 +25,6 @@ "meta": {}, "type": "input" }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, { "attributes": { "disabled": false, @@ -62,7 +43,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" @@ -71,5 +52,24 @@ "messages": [], "meta": {}, "type": "script" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_login_trigger", + "node_type": "input", + "onclickTrigger": "oryWebAuthnLogin", + "type": "button" + }, + "group": "webauthn", + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + }, + "type": "input" } ] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false#01-browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=mfa_v0_credentials-spa.json similarity index 85% rename from selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false#01-browser.json rename to selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=mfa_v0_credentials-spa.json index 581bff275b17..5021f44d2f94 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false#01-browser.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=mfa_v0_credentials-spa.json @@ -25,25 +25,6 @@ "meta": {}, "type": "input" }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, { "attributes": { "disabled": false, @@ -62,7 +43,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" @@ -71,5 +52,24 @@ "messages": [], "meta": {}, "type": "script" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_login_trigger", + "node_type": "input", + "onclickTrigger": "oryWebAuthnLogin", + "type": "button" + }, + "group": "webauthn", + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + }, + "type": "input" } ] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false#01-spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=mfa_v1_credentials-browser.json similarity index 85% rename from selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false#01-spa.json rename to selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=mfa_v1_credentials-browser.json index 581bff275b17..5021f44d2f94 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false#01-spa.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=mfa_v1_credentials-browser.json @@ -25,25 +25,6 @@ "meta": {}, "type": "input" }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, { "attributes": { "disabled": false, @@ -62,7 +43,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" @@ -71,5 +52,24 @@ "messages": [], "meta": {}, "type": "script" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_login_trigger", + "node_type": "input", + "onclickTrigger": "oryWebAuthnLogin", + "type": "button" + }, + "group": "webauthn", + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + }, + "type": "input" } ] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false#02-browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=mfa_v1_credentials-spa.json similarity index 85% rename from selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false#02-browser.json rename to selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=mfa_v1_credentials-spa.json index 581bff275b17..5021f44d2f94 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-case=mfa_v0_credentials-passwordless_enabled=false#02-browser.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=mfa_v1_credentials-spa.json @@ -25,25 +25,6 @@ "meta": {}, "type": "input" }, - { - "attributes": { - "disabled": false, - "name": "webauthn_login_trigger", - "node_type": "input", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1010008, - "text": "Use security key", - "type": "info" - } - }, - "type": "input" - }, { "attributes": { "disabled": false, @@ -62,7 +43,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" @@ -71,5 +52,24 @@ "messages": [], "meta": {}, "type": "script" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_login_trigger", + "node_type": "input", + "onclickTrigger": "oryWebAuthnLogin", + "type": "button" + }, + "group": "webauthn", + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + }, + "type": "input" } ] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=passwordless_credentials-browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=passwordless_credentials-browser.json new file mode 100644 index 000000000000..d601ae14020f --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=passwordless_credentials-browser.json @@ -0,0 +1,15 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=passwordless_credentials-spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=passwordless_credentials-spa.json new file mode 100644 index 000000000000..d601ae14020f --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=false-case=passwordless_credentials-spa.json @@ -0,0 +1,15 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=mfa_v0_credentials-browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=mfa_v0_credentials-browser.json new file mode 100644 index 000000000000..d601ae14020f --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=mfa_v0_credentials-browser.json @@ -0,0 +1,15 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=mfa_v0_credentials-spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=mfa_v0_credentials-spa.json new file mode 100644 index 000000000000..d601ae14020f --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=mfa_v0_credentials-spa.json @@ -0,0 +1,15 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=mfa_v1_credentials-browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=mfa_v1_credentials-browser.json new file mode 100644 index 000000000000..d601ae14020f --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=mfa_v1_credentials-browser.json @@ -0,0 +1,15 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=mfa_v1_credentials-spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=mfa_v1_credentials-spa.json new file mode 100644 index 000000000000..d601ae14020f --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=mfa_v1_credentials-spa.json @@ -0,0 +1,15 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=passwordless_credentials-browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=passwordless_credentials-browser.json new file mode 100644 index 000000000000..5021f44d2f94 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=passwordless_credentials-browser.json @@ -0,0 +1,75 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "identifier", + "node_type": "input", + "type": "hidden", + "value": "foo@bar.com" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_login", + "node_type": "input", + "type": "hidden", + "value": "" + }, + "group": "webauthn", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "async": true, + "crossorigin": "anonymous", + "id": "webauthn_script", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", + "node_type": "script", + "referrerpolicy": "no-referrer", + "type": "text/javascript" + }, + "group": "webauthn", + "messages": [], + "meta": {}, + "type": "script" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_login_trigger", + "node_type": "input", + "onclickTrigger": "oryWebAuthnLogin", + "type": "button" + }, + "group": "webauthn", + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + }, + "type": "input" + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=passwordless_credentials-spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=passwordless_credentials-spa.json new file mode 100644 index 000000000000..5021f44d2f94 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteLogin-flow=refresh-case=passwordless-passwordless_enabled=true-case=passwordless_credentials-spa.json @@ -0,0 +1,75 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "identifier", + "node_type": "input", + "type": "hidden", + "value": "foo@bar.com" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_login", + "node_type": "input", + "type": "hidden", + "value": "" + }, + "group": "webauthn", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "async": true, + "crossorigin": "anonymous", + "id": "webauthn_script", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", + "node_type": "script", + "referrerpolicy": "no-referrer", + "type": "text/javascript" + }, + "group": "webauthn", + "messages": [], + "meta": {}, + "type": "script" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_login_trigger", + "node_type": "input", + "onclickTrigger": "oryWebAuthnLogin", + "type": "button" + }, + "group": "webauthn", + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + }, + "type": "input" + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=a_device_is_shown_which_can_be_unlinked.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=a_device_is_shown_which_can_be_unlinked.json index 0b1702c09413..f0edfe3c5966 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=a_device_is_shown_which_can_be_unlinked.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=a_device_is_shown_which_can_be_unlinked.json @@ -82,33 +82,33 @@ { "attributes": { "disabled": false, - "name": "webauthn_register_trigger", + "name": "webauthn_register", "node_type": "input", - "type": "button", + "type": "hidden", "value": "" }, "group": "webauthn", "messages": [], - "meta": { - "label": { - "id": 1050012, - "text": "Add security key", - "type": "info" - } - }, + "meta": {}, "type": "input" }, { "attributes": { "disabled": false, - "name": "webauthn_register", + "name": "webauthn_register_trigger", "node_type": "input", - "type": "hidden", - "value": "" + "onclickTrigger": "oryWebAuthnRegistration", + "type": "button" }, "group": "webauthn", "messages": [], - "meta": {}, + "meta": { + "label": { + "id": 1050012, + "text": "Add security key", + "type": "info" + } + }, "type": "input" }, { @@ -116,7 +116,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=fails_to_remove_security_key_if_it_is_passwordless_and_the_last_credential_available-type=browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=fails_to_remove_security_key_if_it_is_passwordless_and_the_last_credential_available-type=browser.json index 515658a3d64f..9bd36e752fd0 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=fails_to_remove_security_key_if_it_is_passwordless_and_the_last_credential_available-type=browser.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=fails_to_remove_security_key_if_it_is_passwordless_and_the_last_credential_available-type=browser.json @@ -5,9 +5,6 @@ "webauthn_register_displayname": [ "" ], - "webauthn_register_trigger": [ - "" - ], "webauthn_remove": [ "666f6f666f6f" ] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=fails_to_remove_security_key_if_it_is_passwordless_and_the_last_credential_available-type=spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=fails_to_remove_security_key_if_it_is_passwordless_and_the_last_credential_available-type=spa.json index 515658a3d64f..9bd36e752fd0 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=fails_to_remove_security_key_if_it_is_passwordless_and_the_last_credential_available-type=spa.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=fails_to_remove_security_key_if_it_is_passwordless_and_the_last_credential_available-type=spa.json @@ -5,9 +5,6 @@ "webauthn_register_displayname": [ "" ], - "webauthn_register_trigger": [ - "" - ], "webauthn_remove": [ "666f6f666f6f" ] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=one_activation_element_is_shown.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=one_activation_element_is_shown.json index b21fa4833028..c15a847d4703 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=one_activation_element_is_shown.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=one_activation_element_is_shown.json @@ -34,33 +34,33 @@ { "attributes": { "disabled": false, - "name": "webauthn_register_trigger", + "name": "webauthn_register", "node_type": "input", - "type": "button", + "type": "hidden", "value": "" }, "group": "webauthn", "messages": [], - "meta": { - "label": { - "id": 1050012, - "text": "Add security key", - "type": "info" - } - }, + "meta": {}, "type": "input" }, { "attributes": { "disabled": false, - "name": "webauthn_register", + "name": "webauthn_register_trigger", "node_type": "input", - "type": "hidden", - "value": "" + "onclickTrigger": "oryWebAuthnRegistration", + "type": "button" }, "group": "webauthn", "messages": [], - "meta": {}, + "meta": { + "label": { + "id": 1050012, + "text": "Add security key", + "type": "info" + } + }, "type": "input" }, { @@ -68,7 +68,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=possible_to_remove_webauthn_credential_if_it_is_MFA_at_all_times-type=browser.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=possible_to_remove_webauthn_credential_if_it_is_MFA_at_all_times-type=browser.json index 515658a3d64f..9bd36e752fd0 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=possible_to_remove_webauthn_credential_if_it_is_MFA_at_all_times-type=browser.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=possible_to_remove_webauthn_credential_if_it_is_MFA_at_all_times-type=browser.json @@ -5,9 +5,6 @@ "webauthn_register_displayname": [ "" ], - "webauthn_register_trigger": [ - "" - ], "webauthn_remove": [ "666f6f666f6f" ] diff --git a/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=possible_to_remove_webauthn_credential_if_it_is_MFA_at_all_times-type=spa.json b/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=possible_to_remove_webauthn_credential_if_it_is_MFA_at_all_times-type=spa.json index 515658a3d64f..9bd36e752fd0 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=possible_to_remove_webauthn_credential_if_it_is_MFA_at_all_times-type=spa.json +++ b/selfservice/strategy/webauthn/.snapshots/TestCompleteSettings-case=possible_to_remove_webauthn_credential_if_it_is_MFA_at_all_times-type=spa.json @@ -5,9 +5,6 @@ "webauthn_register_displayname": [ "" ], - "webauthn_register_trigger": [ - "" - ], "webauthn_remove": [ "666f6f666f6f" ] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor-case=mfa_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor-case=mfa_enabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor-case=mfa_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor-case=passwordless_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor-case=passwordless_enabled.json new file mode 100644 index 000000000000..ddd8316aa00f --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodFirstFactor-case=passwordless_enabled.json @@ -0,0 +1,54 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "text", + "required": true, + "autocomplete": "username webauthn", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070004, + "text": "ID", + "type": "info" + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "webauthn", + "attributes": { + "name": "method", + "type": "submit", + "value": "webauthn", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=mfa_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 000000000000..63ce82315a77 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentifier-case=passwordless_enabled-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1,34 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "webauthn", + "attributes": { + "name": "method", + "type": "submit", + "value": "webauthn", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_webauthn-case=mfa_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_webauthn-case=mfa_enabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_webauthn-case=mfa_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_webauthn-case=passwordless_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_webauthn-case=passwordless_enabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_does_not_have_a_webauthn-case=passwordless_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_webauthn-case=mfa_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_webauthn-case=mfa_enabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_webauthn-case=mfa_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_webauthn-case=passwordless_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_webauthn-case=passwordless_enabled.json new file mode 100644 index 000000000000..63ce82315a77 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_disabled-case=identity_has_webauthn-case=passwordless_enabled.json @@ -0,0 +1,34 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "webauthn", + "attributes": { + "name": "method", + "type": "submit", + "value": "webauthn", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled-case=mfa_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled-case=mfa_enabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled-case=mfa_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled-case=passwordless_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled-case=passwordless_enabled.json new file mode 100644 index 000000000000..63ce82315a77 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=WithIdentityHint-case=account_enumeration_mitigation_enabled-case=passwordless_enabled.json @@ -0,0 +1,34 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "webauthn", + "attributes": { + "name": "method", + "type": "submit", + "value": "webauthn", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=mfa_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_disabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_enabled.json new file mode 100644 index 000000000000..63ce82315a77 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstCredentials-case=no_options-case=passwordless_enabled-case=account_enumeration_mitigation_enabled.json @@ -0,0 +1,34 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "webauthn", + "attributes": { + "name": "method", + "type": "submit", + "value": "webauthn", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification-case=mfa_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification-case=mfa_enabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification-case=mfa_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification-case=passwordless_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification-case=passwordless_enabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodIdentifierFirstIdentification-case=passwordless_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=mfa_enabled_and_user_has_mfa_credentials.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=mfa_enabled_and_user_has_mfa_credentials.json new file mode 100644 index 000000000000..1be62bb13f42 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=mfa_enabled_and_user_has_mfa_credentials.json @@ -0,0 +1,74 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "hidden", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "script", + "group": "webauthn", + "attributes": { + "async": true, + "referrerpolicy": "no-referrer", + "crossorigin": "anonymous", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", + "type": "text/javascript", + "id": "webauthn_script", + "node_type": "script" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "webauthn", + "attributes": { + "name": "webauthn_login_trigger", + "type": "button", + "disabled": false, + "onclickTrigger": "oryWebAuthnLogin", + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + } + }, + { + "type": "input", + "group": "webauthn", + "attributes": { + "name": "webauthn_login", + "type": "hidden", + "value": "", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=mfa_enabled_but_user_has_passwordless_credentials.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=mfa_enabled_but_user_has_passwordless_credentials.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=mfa_enabled_but_user_has_passwordless_credentials.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=passwordless_enabled_and_user_has_passwordless_credentials.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=passwordless_enabled_and_user_has_passwordless_credentials.json new file mode 100644 index 000000000000..1be62bb13f42 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=passwordless_enabled_and_user_has_passwordless_credentials.json @@ -0,0 +1,74 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "hidden", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "script", + "group": "webauthn", + "attributes": { + "async": true, + "referrerpolicy": "no-referrer", + "crossorigin": "anonymous", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", + "type": "text/javascript", + "id": "webauthn_script", + "node_type": "script" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "webauthn", + "attributes": { + "name": "webauthn_login_trigger", + "type": "button", + "disabled": false, + "onclickTrigger": "oryWebAuthnLogin", + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + } + }, + { + "type": "input", + "group": "webauthn", + "attributes": { + "name": "webauthn_login", + "type": "hidden", + "value": "", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=passwordless_enabled_but_user_has_no_passwordless_credentials.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=passwordless_enabled_but_user_has_no_passwordless_credentials.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodRefresh-case=passwordless_enabled_but_user_has_no_passwordless_credentials.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=mfa_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=mfa_enabled.json new file mode 100644 index 000000000000..1be62bb13f42 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=mfa_enabled.json @@ -0,0 +1,74 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "hidden", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "script", + "group": "webauthn", + "attributes": { + "async": true, + "referrerpolicy": "no-referrer", + "crossorigin": "anonymous", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", + "type": "text/javascript", + "id": "webauthn_script", + "node_type": "script" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "webauthn", + "attributes": { + "name": "webauthn_login_trigger", + "type": "button", + "disabled": false, + "onclickTrigger": "oryWebAuthnLogin", + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010008, + "text": "Sign in with hardware key", + "type": "info" + } + } + }, + { + "type": "input", + "group": "webauthn", + "attributes": { + "name": "webauthn_login", + "type": "hidden", + "value": "", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=passwordless_enabled.json b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=passwordless_enabled.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestFormHydration-method=PopulateLoginMethodSecondFactor-case=passwordless_enabled.json @@ -0,0 +1 @@ +[] diff --git a/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-browser.json b/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-browser.json index 14a920d0a18d..20e3d3566fb0 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-browser.json +++ b/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-browser.json @@ -75,8 +75,8 @@ "disabled": false, "name": "webauthn_register_trigger", "node_type": "input", - "type": "button", - "value": "" + "onclickTrigger": "oryWebAuthnRegistration", + "type": "button" }, "group": "webauthn", "messages": [], @@ -94,7 +94,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" diff --git a/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-spa.json b/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-spa.json index 14a920d0a18d..20e3d3566fb0 100644 --- a/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-spa.json +++ b/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-spa.json @@ -75,8 +75,8 @@ "disabled": false, "name": "webauthn_register_trigger", "node_type": "input", - "type": "button", - "value": "" + "onclickTrigger": "oryWebAuthnRegistration", + "type": "button" }, "group": "webauthn", "messages": [], @@ -94,7 +94,7 @@ "async": true, "crossorigin": "anonymous", "id": "webauthn_script", - "integrity": "sha512-SSVrbpK6KOwN4xsH+nSyinjp4BOw8dsCU5dgegdpYUk0k7idTnQTd2JGVd+EJZV/TkdRaSKFJHRetpt5vydIZA==", + "integrity": "sha512-MDzBlwh32rr+eus2Yf1BetIj94m+ULLbewYDulbZjczycs81klNed+qQWG2yi2N03KV5uZlRJJtWdV2x9JNHzQ==", "node_type": "script", "referrerpolicy": "no-referrer", "type": "text/javascript" diff --git a/selfservice/strategy/webauthn/login.go b/selfservice/strategy/webauthn/login.go index 4c7dd23f09ea..fe98d1d88c55 100644 --- a/selfservice/strategy/webauthn/login.go +++ b/selfservice/strategy/webauthn/login.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "github.com/ory/kratos/selfservice/strategy/idfirst" + "github.com/ory/kratos/selfservice/flowhelpers" "github.com/ory/kratos/session" "github.com/ory/kratos/x/webauthnx" @@ -34,84 +36,14 @@ import ( "github.com/ory/x/decoderx" ) +var _ login.FormHydrator = new(Strategy) + func (s *Strategy) RegisterLoginRoutes(r *x.RouterPublic) { webauthnx.RegisterWebauthnRoute(r) } -func (s *Strategy) PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, sr *login.Flow) error { - if sr.Type != flow.TypeBrowser { - return nil - } - - if s.d.Config().WebAuthnForPasswordless(r.Context()) && (requestedAAL == identity.AuthenticatorAssuranceLevel1) { - if err := s.populateLoginMethodForPasswordless(r, sr); errors.Is(err, webauthnx.ErrNoCredentials) { - return nil - } else if err != nil { - return err - } - return nil - } else if sr.IsForced() { - if err := s.populateLoginMethodForPasswordless(r, sr); errors.Is(err, webauthnx.ErrNoCredentials) { - return nil - } else if err != nil { - return err - } - return nil - } else if !s.d.Config().WebAuthnForPasswordless(r.Context()) && (requestedAAL == identity.AuthenticatorAssuranceLevel2) { - // We have done proper validation before so this should never error - sess, err := s.d.SessionManager().FetchFromRequest(r.Context(), r) - if err != nil { - return err - } - - if err := s.populateLoginMethod(r, sr, sess.Identity, text.NewInfoSelfServiceLoginWebAuthn(), identity.AuthenticatorAssuranceLevel2); errors.Is(err, webauthnx.ErrNoCredentials) { - return nil - } else if err != nil { - return err - } - - return nil - } - - return nil -} - func (s *Strategy) populateLoginMethodForPasswordless(r *http.Request, sr *login.Flow) error { - if sr.IsForced() { - identifier, id, _ := flowhelpers.GuessForcedLoginIdentifier(r, s.d, sr, s.ID()) - if identifier == "" { - return nil - } - - if err := s.populateLoginMethod(r, sr, id, text.NewInfoSelfServiceLoginWebAuthn(), ""); errors.Is(err, webauthnx.ErrNoCredentials) { - return nil - } else if err != nil { - return err - } - - sr.UI.SetCSRF(s.d.GenerateCSRFToken(r)) - sr.UI.SetNode(node.NewInputField("identifier", identifier, node.DefaultGroup, node.InputAttributeTypeHidden)) - return nil - } - - ds, err := s.d.Config().DefaultIdentityTraitsSchemaURL(r.Context()) - if err != nil { - return err - } - identifierLabel, err := login.GetIdentifierLabelFromSchema(r.Context(), ds.String()) - if err != nil { - return err - } - sr.UI.SetCSRF(s.d.GenerateCSRFToken(r)) - sr.UI.SetNode(node.NewInputField( - "identifier", - "", - node.DefaultGroup, - node.InputAttributeTypeText, - node.WithRequiredInputAttribute, - func(attributes *node.InputAttributes) { attributes.Autocomplete = "username webauthn" }, - ).WithMetaLabel(identifierLabel)) sr.UI.GetNodes().Append(node.NewInputField("method", "webauthn", node.WebAuthnGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoSelfServiceLoginWebAuthn())) return nil } @@ -133,11 +65,7 @@ func (s *Strategy) populateLoginMethod(r *http.Request, sr *login.Flow, i *ident return errors.WithStack(err) } - webAuthCreds := conf.Credentials.ToWebAuthn() - if !sr.IsForced() { - webAuthCreds = conf.Credentials.ToWebAuthnFiltered(aal) - } - + webAuthCreds := conf.Credentials.ToWebAuthnFiltered(aal) if len(webAuthCreds) == 0 { // Identity has no webauthn return webauthnx.ErrNoCredentials @@ -245,7 +173,7 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, s.handleLoginError(r, f, err) } - if s.d.Config().WebAuthnForPasswordless(r.Context()) || f.IsForced() && f.RequestedAAL == identity.AuthenticatorAssuranceLevel1 { + if s.d.Config().WebAuthnForPasswordless(r.Context()) || f.IsRefresh() && f.RequestedAAL == identity.AuthenticatorAssuranceLevel1 { return s.loginPasswordless(w, r, f, &p) } @@ -337,7 +265,7 @@ func (s *Strategy) loginAuthenticate(_ http.ResponseWriter, r *http.Request, f * } webAuthCreds := o.Credentials.ToWebAuthnFiltered(aal) - if f.IsForced() { + if f.IsRefresh() { webAuthCreds = o.Credentials.ToWebAuthn() } @@ -365,3 +293,124 @@ func (s *Strategy) loginMultiFactor(w http.ResponseWriter, r *http.Request, f *l } return s.loginAuthenticate(w, r, f, identityID, p, identity.AuthenticatorAssuranceLevel2) } + +func (s *Strategy) populateLoginMethodRefresh(r *http.Request, sr *login.Flow) error { + if sr.Type != flow.TypeBrowser { + return nil + } + + identifier, id, _ := flowhelpers.GuessForcedLoginIdentifier(r, s.d, sr, s.ID()) + if identifier == "" { + return nil + } + + if err := s.populateLoginMethod(r, sr, id, text.NewInfoSelfServiceLoginWebAuthn(), sr.RequestedAAL); errors.Is(err, webauthnx.ErrNoCredentials) { + return nil + } else if err != nil { + return err + } + + sr.UI.SetCSRF(s.d.GenerateCSRFToken(r)) + sr.UI.SetNode(node.NewInputField("identifier", identifier, node.DefaultGroup, node.InputAttributeTypeHidden)) + return nil +} + +func (s *Strategy) PopulateLoginMethodFirstFactorRefresh(r *http.Request, sr *login.Flow) error { + return s.populateLoginMethodRefresh(r, sr) +} + +func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, sr *login.Flow) error { + return s.populateLoginMethodRefresh(r, sr) +} + +func (s *Strategy) PopulateLoginMethodFirstFactor(r *http.Request, sr *login.Flow) error { + if sr.Type != flow.TypeBrowser || !s.d.Config().WebAuthnForPasswordless(r.Context()) { + return nil + } + + ds, err := s.d.Config().DefaultIdentityTraitsSchemaURL(r.Context()) + if err != nil { + return err + } + + identifierLabel, err := login.GetIdentifierLabelFromSchema(r.Context(), ds.String()) + if err != nil { + return err + } + + sr.UI.SetNode(node.NewInputField( + "identifier", + "", + node.DefaultGroup, + node.InputAttributeTypeText, + node.WithRequiredInputAttribute, + func(attributes *node.InputAttributes) { attributes.Autocomplete = "username webauthn" }, + ).WithMetaLabel(identifierLabel)) + + if err := s.populateLoginMethodForPasswordless(r, sr); errors.Is(err, webauthnx.ErrNoCredentials) { + return nil + } else if err != nil { + return err + } + + return nil +} + +func (s *Strategy) PopulateLoginMethodSecondFactor(r *http.Request, sr *login.Flow) error { + if sr.Type != flow.TypeBrowser || s.d.Config().WebAuthnForPasswordless(r.Context()) { + return nil + } + + // We have done proper validation before so this should never error + sess, err := s.d.SessionManager().FetchFromRequest(r.Context(), r) + if err != nil { + return err + } + + if err := s.populateLoginMethod(r, sr, sess.Identity, text.NewInfoSelfServiceLoginWebAuthn(), identity.AuthenticatorAssuranceLevel2); errors.Is(err, webauthnx.ErrNoCredentials) { + return nil + } else if err != nil { + return err + } + + return nil +} + +func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *login.Flow, opts ...login.FormHydratorModifier) error { + if sr.Type != flow.TypeBrowser || !s.d.Config().WebAuthnForPasswordless(r.Context()) { + return errors.WithStack(idfirst.ErrNoCredentialsFound) + } + + o := login.NewFormHydratorOptions(opts) + + var count int + if o.IdentityHint != nil { + var err error + // If we have an identity hint we can perform identity credentials discovery and + // hide this credential if it should not be included. + if count, err = s.CountActiveFirstFactorCredentials(o.IdentityHint.Credentials); err != nil { + return err + } + } + + if count > 0 || s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + if err := s.populateLoginMethodForPasswordless(r, sr); errors.Is(err, webauthnx.ErrNoCredentials) { + if !s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) { + return errors.WithStack(idfirst.ErrNoCredentialsFound) + } + return nil + } else if err != nil { + return err + } + } + + if count == 0 { + return errors.WithStack(idfirst.ErrNoCredentialsFound) + } + + return nil +} + +func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, sr *login.Flow) error { + return nil +} diff --git a/selfservice/strategy/webauthn/login_test.go b/selfservice/strategy/webauthn/login_test.go index f5d332182163..f1323b9b6789 100644 --- a/selfservice/strategy/webauthn/login_test.go +++ b/selfservice/strategy/webauthn/login_test.go @@ -10,8 +10,12 @@ import ( "fmt" "io" "net/http" + "net/http/httptest" "net/url" "testing" + "time" + + "github.com/ory/kratos/selfservice/strategy/idfirst" "github.com/ory/x/jsonx" @@ -30,6 +34,7 @@ import ( "github.com/tidwall/gjson" "github.com/ory/kratos/driver/config" + configtesthelpers "github.com/ory/kratos/driver/config/testhelpers" "github.com/ory/kratos/identity" "github.com/ory/kratos/internal" "github.com/ory/kratos/internal/testhelpers" @@ -153,7 +158,7 @@ func TestCompleteLogin(t *testing.T) { } submitWebAuthnLogin := func(t *testing.T, isSPA bool, id *identity.Identity, contextFixture []byte, cb func(values url.Values), opts ...testhelpers.InitFlowWithOption) (string, *http.Response, *kratos.LoginFlow) { - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) return submitWebAuthnLoginWithClient(t, isSPA, id, contextFixture, browserClient, cb, opts...) } @@ -163,19 +168,30 @@ func TestCompleteLogin(t *testing.T) { conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, nil) }) - run := func(t *testing.T, id *identity.Identity, context, response []byte, isSPA bool, expectedAAL identity.AuthenticatorAssuranceLevel) { + run := func(t *testing.T, id *identity.Identity, context, response []byte, isSPA bool, expectedAAL identity.AuthenticatorAssuranceLevel, expectTriggers bool) { body, res, f := submitWebAuthnLogin(t, isSPA, id, context, func(values url.Values) { values.Set("identifier", loginFixtureSuccessEmail) values.Set(node.WebAuthnLogin, string(response)) - }, testhelpers.InitFlowWithRefresh()) + }, + testhelpers.InitFlowWithRefresh(), + testhelpers.InitFlowWithAAL(expectedAAL), + ) snapshotx.SnapshotTExcept(t, f.Ui.Nodes, []string{ "0.attributes.value", - "2.attributes.onclick", - "4.attributes.nonce", - "4.attributes.src", + "3.attributes.nonce", + "3.attributes.src", + "4.attributes.value", + "4.attributes.onclick", }) + nodes, err := json.Marshal(f.Ui.Nodes) require.NoError(t, err) + + if !expectTriggers { + assert.Falsef(t, gjson.GetBytes(nodes, "#(attributes.name==identifier)").Exists(), "%s", nodes) + return + } + assert.Equal(t, loginFixtureSuccessEmail, gjson.GetBytes(nodes, "#(attributes.name==identifier).attributes.value").String(), "%s", nodes) prefix := "" @@ -208,40 +224,44 @@ func TestCompleteLogin(t *testing.T) { } for _, tc := range []struct { - creds identity.Credentials - response []byte - context []byte - descript string + creds identity.Credentials + response []byte + context []byte + descript string + expectTriggers bool }{ { creds: identity.Credentials{ Config: loginFixtureSuccessV0Credentials, Version: 0, }, - context: loginFixtureSuccessV0Context, - response: loginFixtureSuccessV0Response, - descript: "mfa v0 credentials", + context: loginFixtureSuccessV0Context, + response: loginFixtureSuccessV0Response, + descript: "mfa v0 credentials", + expectTriggers: !e, }, { creds: identity.Credentials{ Config: loginFixtureSuccessV1Credentials, Version: 1, }, - context: loginFixtureSuccessV1Context, - response: loginFixtureSuccessV1Response, - descript: "mfa v1 credentials", + context: loginFixtureSuccessV1Context, + response: loginFixtureSuccessV1Response, + descript: "mfa v1 credentials", + expectTriggers: !e, }, { creds: identity.Credentials{ Config: loginFixtureSuccessV1PasswordlessCredentials, Version: 1, }, - context: loginFixtureSuccessV1PasswordlessContext, - response: loginFixtureSuccessV1PasswordlessResponse, - descript: "passwordless credentials", + context: loginFixtureSuccessV1PasswordlessContext, + response: loginFixtureSuccessV1PasswordlessResponse, + descript: "passwordless credentials", + expectTriggers: e, }, } { - t.Run(fmt.Sprintf("case=mfa v0 credentials/passwordless enabled=%v", e), func(t *testing.T) { + t.Run(fmt.Sprintf("passwordless enabled=%v/case=%s", e, tc.descript), func(t *testing.T) { id := createIdentityWithWebAuthn(t, tc.creds) for _, f := range []string{ @@ -249,7 +269,7 @@ func TestCompleteLogin(t *testing.T) { "spa", } { t.Run(f, func(t *testing.T) { - run(t, id, tc.context, tc.response, f == "spa", expectedAAL) + run(t, id, tc.context, tc.response, f == "spa", expectedAAL, tc.expectTriggers) }) } }) @@ -264,7 +284,7 @@ func TestCompleteLogin(t *testing.T) { for _, f := range []string{"browser", "spa"} { t.Run(f, func(t *testing.T) { id := identity.NewIdentity("") - client := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + client := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaBrowser(t, client, publicTS, true, f == "spa", false, false) snapshotx.SnapshotTExcept(t, f.Ui.Nodes, []string{ @@ -317,7 +337,7 @@ func TestCompleteLogin(t *testing.T) { }) t.Run("case=webauthn shows error if user tries to sign in but user has no webauth credentials set up", func(t *testing.T) { - id, subject := createIdentityAndReturnIdentifier(t, reg, nil) + id, subject := createIdentityAndReturnIdentifier(t, ctx, reg, nil) id.DeleteCredentialsType(identity.CredentialsTypeWebAuthn) require.NoError(t, reg.IdentityManager().Update(ctx, id, identity.ManagerAllowWriteProtectedTraits)) @@ -344,7 +364,7 @@ func TestCompleteLogin(t *testing.T) { }) t.Run("case=webauthn MFA credentials can not be used for passwordless login", func(t *testing.T) { - _, subject := createIdentityAndReturnIdentifier(t, reg, []byte(`{"credentials":[{"id":"Zm9vZm9v","is_passwordless":false}]}`)) + _, subject := createIdentityAndReturnIdentifier(t, ctx, reg, []byte(`{"credentials":[{"id":"Zm9vZm9v","is_passwordless":false}]}`)) payload := func(v url.Values) { v.Set("method", identity.CredentialsTypeWebAuthn.String()) @@ -369,7 +389,7 @@ func TestCompleteLogin(t *testing.T) { }) t.Run("case=should fail if webauthn login is invalid", func(t *testing.T) { - _, subject := createIdentityAndReturnIdentifier(t, reg, []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":true}]}`)) + _, subject := createIdentityAndReturnIdentifier(t, ctx, reg, []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":true}]}`)) doBrowserFlow := func(t *testing.T, spa bool, browserClient *http.Client, opts ...testhelpers.InitFlowWithOption) { f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, spa, false, false, opts...) @@ -446,6 +466,13 @@ func TestCompleteLogin(t *testing.T) { actualFlow, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(f.Id)) require.NoError(t, err) assert.Empty(t, gjson.GetBytes(actualFlow.InternalContext, flow.PrefixInternalContextKey(identity.CredentialsTypeWebAuthn, webauthn.InternalContextKeySessionData))) + + if spa { + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body) + assert.Contains(t, gjson.Get(body, "continue_with.0.redirect_browser_to").String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%s", body) + } else { + assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body) + } } t.Run("type=browser", func(t *testing.T) { @@ -460,25 +487,26 @@ func TestCompleteLogin(t *testing.T) { t.Run("flow=mfa", func(t *testing.T) { t.Run("case=webauthn payload is set when identity has webauthn", func(t *testing.T) { - id := createIdentity(t, reg) + id := createIdentity(t, ctx, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaBrowser(t, apiClient, publicTS, false, true, false, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) assert.Equal(t, gjson.GetBytes(id.Traits, "subject").String(), f.Ui.Nodes[1].Attributes.UiNodeInputAttributes.Value, jsonx.TestMarshalJSONString(t, f.Ui)) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ "0.attributes.value", "1.attributes.value", - "2.attributes.onclick", - "2.attributes.onload", - "4.attributes.src", - "4.attributes.nonce", + "3.attributes.src", + "3.attributes.nonce", + "4.attributes.onclick", + "4.attributes.onload", + "4.attributes.value", }) - ensureReplacement(t, "2", f.Ui, "allowCredentials") + ensureReplacement(t, "4", f.Ui, "allowCredentials") }) t.Run("case=webauthn payload is not set when identity has no webauthn", func(t *testing.T) { id := createIdentityWithoutWebAuthn(t, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaBrowser(t, apiClient, publicTS, false, true, false, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ @@ -487,23 +515,23 @@ func TestCompleteLogin(t *testing.T) { }) t.Run("case=webauthn payload is not set for API clients", func(t *testing.T) { - id := createIdentity(t, reg) + id := createIdentity(t, ctx, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) assertx.EqualAsJSON(t, nil, f.Ui.Nodes) }) doAPIFlowSignedIn := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) { - return doAPIFlow(t, v, testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id), testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) + return doAPIFlow(t, v, testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id), testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) } doBrowserFlowSignIn := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity) (string, *http.Response) { - return doBrowserFlow(t, spa, v, testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id), testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) + return doBrowserFlow(t, spa, v, testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id), testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)) } t.Run("case=should refuse to execute api flow", func(t *testing.T) { - id := createIdentity(t, reg) + id := createIdentity(t, ctx, reg) payload := func(v url.Values) { v.Set(node.WebAuthnLogin, "{}") } @@ -515,7 +543,7 @@ func TestCompleteLogin(t *testing.T) { }) t.Run("case=should fail if webauthn login is invalid", func(t *testing.T) { - id, sub := createIdentityAndReturnIdentifier(t, reg, nil) + id, sub := createIdentityAndReturnIdentifier(t, ctx, reg, nil) payload := func(v url.Values) { v.Set("identifier", sub) v.Set(node.WebAuthnLogin, string(loginFixtureSuccessResponseInvalid)) @@ -615,3 +643,284 @@ func TestCompleteLogin(t *testing.T) { }) }) } + +func TestFormHydration(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + + ctx = configtesthelpers.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".enabled", true) + ctx = configtesthelpers.WithConfigValue( + ctx, + config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config", + map[string]interface{}{ + "rp": map[string]interface{}{ + "display_name": "foo", + "id": "localhost", + "origins": []string{"http://localhost"}, + }, + }, + ) + ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://stub/login.schema.json") + + s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsTypeWebAuthn) + require.NoError(t, err) + fh, ok := s.(login.FormHydrator) + require.True(t, ok) + + toSnapshot := func(t *testing.T, f *login.Flow) { + t.Helper() + // The CSRF token has a unique value that messes with the snapshot - ignore it. + f.UI.Nodes.ResetNodes("csrf_token") + f.UI.Nodes.ResetNodes("identifier") + f.UI.Nodes.ResetNodes("webauthn_login_trigger") + snapshotx.SnapshotT(t, f.UI.Nodes, snapshotx.ExceptNestedKeys("onclick", "nonce", "src")) + } + + newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *login.Flow) { + r := httptest.NewRequest("GET", "/self-service/login/browser", nil) + r = r.WithContext(ctx) + t.Helper() + f, err := login.NewFlow(conf, time.Minute, "csrf_token", r, flow.TypeBrowser) + f.UI.Nodes = make(node.Nodes, 0) + require.NoError(t, err) + return r, f + } + + passwordlessEnabled := configtesthelpers.WithConfigValue(ctx, config.ViperKeyWebAuthnPasswordless, true) + mfaEnabled := configtesthelpers.WithConfigValue(ctx, config.ViperKeyWebAuthnPasswordless, false) + + t.Run("method=PopulateLoginMethodSecondFactor", func(t *testing.T) { + id := createIdentity(t, ctx, reg) + headers := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader() + t.Run("case=passwordless enabled", func(t *testing.T) { + r, f := newFlow(passwordlessEnabled, t) + + r.Header = headers + f.RequestedAAL = identity.AuthenticatorAssuranceLevel2 + + require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f)) + toSnapshot(t, f) + }) + + t.Run("case=mfa enabled", func(t *testing.T) { + r, f := newFlow(mfaEnabled, t) + + r.Header = headers + f.RequestedAAL = identity.AuthenticatorAssuranceLevel2 + + require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f)) + toSnapshot(t, f) + }) + }) + + t.Run("method=PopulateLoginMethodFirstFactor", func(t *testing.T) { + t.Run("case=passwordless enabled", func(t *testing.T) { + r, f := newFlow(passwordlessEnabled, t) + require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f)) + toSnapshot(t, f) + }) + + t.Run("case=mfa enabled", func(t *testing.T) { + r, f := newFlow(mfaEnabled, t) + require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f)) + toSnapshot(t, f) + }) + }) + + t.Run("method=PopulateLoginMethodRefresh", func(t *testing.T) { + t.Run("case=passwordless enabled but user has no passwordless credentials", func(t *testing.T) { + id := createIdentity(t, ctx, reg) + r, f := newFlow(passwordlessEnabled, t) + r.Header = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader() + f.Refresh = true + require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("case=passwordless enabled and user has passwordless credentials", func(t *testing.T) { + id, _ := createIdentityAndReturnIdentifier(t, ctx, reg, []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":true}]}`)) + r, f := newFlow(passwordlessEnabled, t) + r.Header = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader() + f.Refresh = true + require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("case=mfa enabled and user has mfa credentials", func(t *testing.T) { + id := createIdentity(t, ctx, reg) + r, f := newFlow(mfaEnabled, t) + r.Header = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader() + f.Refresh = true + f.RequestedAAL = identity.AuthenticatorAssuranceLevel2 + require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f)) + toSnapshot(t, f) + }) + + t.Run("case=mfa enabled but user has passwordless credentials", func(t *testing.T) { + id, _ := createIdentityAndReturnIdentifier(t, ctx, reg, []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":true}]}`)) + r, f := newFlow(mfaEnabled, t) + r.Header = testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader() + f.Refresh = true + f.RequestedAAL = identity.AuthenticatorAssuranceLevel2 + require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f)) + toSnapshot(t, f) + }) + }) + + t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) { + t.Run("case=no options", func(t *testing.T) { + t.Run("case=passwordless enabled", func(t *testing.T) { + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false), + t, + ) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true), + t, + ) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + }) + + t.Run("case=mfa enabled", func(t *testing.T) { + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false), + t, + ) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=account enumeration mitigation enabled", func(t *testing.T) { + r, f := newFlow( + configtesthelpers.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true), + t, + ) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + }) + }) + + t.Run("case=WithIdentifier", func(t *testing.T) { + t.Run("case=passwordless enabled", func(t *testing.T) { + 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) + passwordlessEnabled := configtesthelpers.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, true) + + id := identity.NewIdentity("test-provider") + t.Run("case=passwordless enabled", func(t *testing.T) { + r, f := newFlow(passwordlessEnabled, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=mfa enabled", func(t *testing.T) { + r, f := newFlow(mfaEnabled, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + }) + + t.Run("case=account enumeration mitigation disabled", func(t *testing.T) { + mfaEnabled := configtesthelpers.WithConfigValue(mfaEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false) + passwordlessEnabled := configtesthelpers.WithConfigValue(passwordlessEnabled, config.ViperKeySecurityAccountEnumerationMitigate, false) + + id, _ := createIdentityAndReturnIdentifier(t, ctx, reg, []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":true}]}`)) + + t.Run("case=identity has webauthn", func(t *testing.T) { + t.Run("case=passwordless enabled", func(t *testing.T) { + r, f := newFlow(passwordlessEnabled, t) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id))) + toSnapshot(t, f) + }) + + t.Run("case=mfa enabled", func(t *testing.T) { + r, f := newFlow(mfaEnabled, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + }) + + t.Run("case=identity does not have a webauthn", func(t *testing.T) { + t.Run("case=passwordless enabled", func(t *testing.T) { + id := identity.NewIdentity("default") + r, f := newFlow(passwordlessEnabled, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + + t.Run("case=mfa enabled", func(t *testing.T) { + id := identity.NewIdentity("default") + r, f := newFlow(mfaEnabled, t) + require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound) + toSnapshot(t, f) + }) + }) + }) + }) + }) + + t.Run("method=PopulateLoginMethodIdentifierFirstIdentification", func(t *testing.T) { + t.Run("case=passwordless enabled", func(t *testing.T) { + r, f := newFlow(passwordlessEnabled, t) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstIdentification(r, f)) + toSnapshot(t, f) + }) + + t.Run("case=mfa enabled", func(t *testing.T) { + r, f := newFlow(mfaEnabled, t) + require.NoError(t, fh.PopulateLoginMethodIdentifierFirstIdentification(r, f)) + toSnapshot(t, f) + }) + }) +} diff --git a/selfservice/strategy/webauthn/registration.go b/selfservice/strategy/webauthn/registration.go index 85cb3628e59d..e3ca6c9e5fd7 100644 --- a/selfservice/strategy/webauthn/registration.go +++ b/selfservice/strategy/webauthn/registration.go @@ -76,9 +76,10 @@ func (s *Strategy) handleRegistrationError(_ http.ResponseWriter, r *http.Reques // we only set the value and not the whole field because we want to keep types from the initial form generation f.UI.Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue()) } + + f.UI.Nodes.SetValueAttribute(node.WebAuthnRegisterDisplayName, p.RegisterDisplayName) } - f.UI.Nodes.SetValueAttribute(node.WebAuthnRegisterDisplayName, p.RegisterDisplayName) if f.Type == flow.TypeBrowser { f.UI.SetCSRF(s.d.GenerateCSRFToken(r)) } diff --git a/selfservice/strategy/webauthn/registration_test.go b/selfservice/strategy/webauthn/registration_test.go index c0503b151ed8..8dd3e38bd036 100644 --- a/selfservice/strategy/webauthn/registration_test.go +++ b/selfservice/strategy/webauthn/registration_test.go @@ -145,6 +145,7 @@ func TestRegistration(t *testing.T) { testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ "2.attributes.value", "5.attributes.onclick", + "5.attributes.value", "6.attributes.nonce", "6.attributes.src", }) @@ -367,6 +368,13 @@ func TestRegistration(t *testing.T) { i, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeWebAuthn, email) require.NoError(t, err) assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual) + + if f == "spa" { + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), redirNoSessionTS.URL+"/registration-return-ts", "%s", actual) + } else { + assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual) + } }) } }) diff --git a/selfservice/strategy/webauthn/settings_test.go b/selfservice/strategy/webauthn/settings_test.go index acf4fd357b1d..01ccdbf48578 100644 --- a/selfservice/strategy/webauthn/settings_test.go +++ b/selfservice/strategy/webauthn/settings_test.go @@ -53,19 +53,20 @@ var settingsFixtureSuccessInternalContext []byte const registerDisplayNameGJSONQuery = "ui.nodes.#(attributes.name==" + node.WebAuthnRegisterDisplayName + ")" func createIdentityWithoutWebAuthn(t *testing.T, reg driver.Registry) *identity.Identity { - id := createIdentity(t, reg) + id := createIdentity(t, ctx, reg) delete(id.Credentials, identity.CredentialsTypeWebAuthn) require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(context.Background(), id)) return id } -func createIdentityAndReturnIdentifier(t *testing.T, reg driver.Registry, conf []byte) (*identity.Identity, string) { +func createIdentityAndReturnIdentifier(t *testing.T, ctx context.Context, reg driver.Registry, conf []byte) (*identity.Identity, string) { identifier := x.NewUUID().String() + "@ory.sh" password := x.NewUUID().String() - p, err := reg.Hasher(ctx).Generate(context.Background(), []byte(password)) + p, err := reg.Hasher(ctx).Generate(ctx, []byte(password)) require.NoError(t, err) i := &identity.Identity{ - Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)), + SchemaID: "default", + Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)), VerifiableAddresses: []identity.VerifiableAddress{ { Value: identifier, @@ -77,7 +78,7 @@ func createIdentityAndReturnIdentifier(t *testing.T, reg driver.Registry, conf [ if conf == nil { conf = []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo"},{"id":"YmFyYmFy","display_name":"bar"}]}`) } - require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i)) + require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i)) i.Credentials = map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: { Type: identity.CredentialsTypePassword, @@ -90,12 +91,12 @@ func createIdentityAndReturnIdentifier(t *testing.T, reg driver.Registry, conf [ Config: conf, }, } - require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(context.Background(), i)) + require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(ctx, i)) return i, identifier } -func createIdentity(t *testing.T, reg driver.Registry) *identity.Identity { - id, _ := createIdentityAndReturnIdentifier(t, reg, nil) +func createIdentity(t *testing.T, ctx context.Context, reg driver.Registry) *identity.Identity { + id, _ := createIdentityAndReturnIdentifier(t, ctx, reg, nil) return id } @@ -136,48 +137,50 @@ func TestCompleteSettings(t *testing.T) { conf.MustSet(ctx, config.ViperKeySecretsDefault, []string{"not-a-secure-session-key"}) t.Run("case=a device is shown which can be unlinked", func(t *testing.T) { - id := createIdentity(t, reg) + id := createIdentity(t, ctx, reg) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, apiClient, true, publicTS) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ "0.attributes.value", - "4.attributes.onclick", + "5.attributes.onclick", + "5.attributes.value", "6.attributes.src", "6.attributes.nonce", }) - ensureReplacement(t, "4", f.Ui, "Ory Corp") + ensureReplacement(t, "5", f.Ui, "Ory Corp") }) t.Run("case=one activation element is shown", func(t *testing.T) { id := createIdentityWithoutWebAuthn(t, reg) require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(context.Background(), id)) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, apiClient, true, publicTS) testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ "0.attributes.value", - "2.attributes.onload", - "2.attributes.onclick", + "3.attributes.onload", + "3.attributes.onclick", + "3.attributes.value", "4.attributes.src", "4.attributes.nonce", }) - ensureReplacement(t, "2", f.Ui, "Ory Corp") + ensureReplacement(t, "3", f.Ui, "Ory Corp") }) t.Run("case=webauthn only works for browsers", func(t *testing.T) { id := createIdentityWithoutWebAuthn(t, reg) require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(context.Background(), id)) - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) assert.Empty(t, f.Ui.Nodes) }) doAPIFlow := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) { - apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, reg, id) + apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) v(values) @@ -186,7 +189,7 @@ func TestCompleteSettings(t *testing.T) { } doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity) (string, *http.Response) { - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, publicTS) values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) v(values) @@ -314,7 +317,7 @@ func TestCompleteSettings(t *testing.T) { var id identity.Identity require.NoError(t, json.Unmarshal(settingsFixtureSuccessIdentity, &id)) _ = reg.PrivilegedIdentityPool().DeleteIdentity(context.Background(), id.ID) - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, &id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, &id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, publicTS) // We inject the session to replay @@ -367,7 +370,7 @@ func TestCompleteSettings(t *testing.T) { }) run := func(t *testing.T, spa bool) { - id := createIdentity(t, reg) + id := createIdentity(t, ctx, reg) id.DeleteCredentialsType(identity.CredentialsTypePassword) conf := sqlxx.JSONRawMessage(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":true}]}`) id.UpsertCredentialsConfig(identity.CredentialsTypeWebAuthn, conf, 0) @@ -375,7 +378,7 @@ func TestCompleteSettings(t *testing.T) { body, res := doBrowserFlow(t, spa, func(v url.Values) { // The remove key should be empty - snapshotx.SnapshotTExcept(t, v, []string{"csrf_token"}) + snapshotx.SnapshotTExcept(t, v, []string{"csrf_token", "webauthn_register_trigger"}) v.Set(node.WebAuthnRemove, "666f6f666f6f") }, id) @@ -409,14 +412,17 @@ func TestCompleteSettings(t *testing.T) { t.Run("case=possible to remove webauthn credential if it is MFA at all times", func(t *testing.T) { run := func(t *testing.T, spa bool) { - id := createIdentity(t, reg) + id := createIdentity(t, ctx, reg) id.DeleteCredentialsType(identity.CredentialsTypePassword) id.UpsertCredentialsConfig(identity.CredentialsTypeWebAuthn, sqlxx.JSONRawMessage(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":false}]}`), 0) require.NoError(t, reg.IdentityManager().Update(ctx, id, identity.ManagerAllowWriteProtectedTraits)) body, res := doBrowserFlow(t, spa, func(v url.Values) { // The remove key should be set - snapshotx.SnapshotTExcept(t, v, []string{"csrf_token"}) + snapshotx.SnapshotTExcept(t, v, []string{ + "csrf_token", + "webauthn_register_trigger", + }) v.Set(node.WebAuthnRemove, "666f6f666f6f") }, id) @@ -446,7 +452,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("case=remove all security keys", func(t *testing.T) { run := func(t *testing.T, spa bool) { - id := createIdentity(t, reg) + id := createIdentity(t, ctx, reg) allCred, ok := id.GetCredentials(identity.CredentialsTypeWebAuthn) assert.True(t, ok) @@ -465,6 +471,13 @@ func TestCompleteSettings(t *testing.T) { assert.Contains(t, res.Request.URL.String(), uiTS.URL) } assert.EqualValues(t, flow.StateSuccess, gjson.Get(body, "state").String(), body) + + if spa { + assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body) + assert.Contains(t, gjson.Get(body, "continue_with.0.redirect_browser_to").String(), uiTS.URL, "%s", body) + } else { + assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body) + } } actual, err := reg.Persister().GetIdentityConfidential(context.Background(), id.ID) @@ -487,7 +500,7 @@ func TestCompleteSettings(t *testing.T) { t.Run("case=fails with browser submit register payload is invalid", func(t *testing.T) { run := func(t *testing.T, spa bool) { - id := createIdentity(t, reg) + id := createIdentity(t, ctx, reg) body, res := doBrowserFlow(t, spa, func(v url.Values) { v.Set(node.WebAuthnRemove, fmt.Sprintf("%x", []byte("foofoo"))) }, id) @@ -526,7 +539,7 @@ func TestCompleteSettings(t *testing.T) { var id identity.Identity require.NoError(t, json.Unmarshal(settingsFixtureSuccessIdentity, &id)) _ = reg.PrivilegedIdentityPool().DeleteIdentity(context.Background(), id.ID) - browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, reg, &id) + browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t, ctx, reg, &id) f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, isSPA, publicTS) // We inject the session to replay diff --git a/session/handler_test.go b/session/handler_test.go index 3c61b7764832..c11a8f4a548e 100644 --- a/session/handler_test.go +++ b/session/handler_test.go @@ -560,7 +560,7 @@ func TestHandlerAdminSessionManagement(t *testing.T) { }) t.Run("should redirect to public for whoami", func(t *testing.T) { - client := testhelpers.NewHTTPClientWithSessionToken(t, reg, s) + client := testhelpers.NewHTTPClientWithSessionToken(t, ctx, reg, s) client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } diff --git a/spec/api.json b/spec/api.json index 1bb1345dc25c..8eb594a4354e 100644 --- a/spec/api.json +++ b/spec/api.json @@ -465,6 +465,7 @@ "continueWith": { "discriminator": { "mapping": { + "redirect_browser_to": "#/components/schemas/continueWithRedirectBrowserTo", "set_ory_session_token": "#/components/schemas/continueWithSetOrySessionToken", "show_recovery_ui": "#/components/schemas/continueWithRecoveryUi", "show_settings_ui": "#/components/schemas/continueWithSettingsUi", @@ -484,6 +485,9 @@ }, { "$ref": "#/components/schemas/continueWithRecoveryUi" + }, + { + "$ref": "#/components/schemas/continueWithRedirectBrowserTo" } ] }, @@ -516,7 +520,7 @@ "type": "string" }, "url": { - "description": "The URL of the recovery flow", + "description": "The URL of the recovery flow\n\nIf this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows.", "type": "string" } }, @@ -525,6 +529,28 @@ ], "type": "object" }, + "continueWithRedirectBrowserTo": { + "description": "Indicates, that the UI flow could be continued by showing a recovery ui", + "properties": { + "action": { + "description": "Action will always be `redirect_browser_to`\nredirect_browser_to ContinueWithActionRedirectBrowserToString", + "enum": [ + "redirect_browser_to" + ], + "type": "string", + "x-go-enum-desc": "redirect_browser_to ContinueWithActionRedirectBrowserToString" + }, + "redirect_browser_to": { + "description": "The URL to redirect the browser to", + "type": "string" + } + }, + "required": [ + "action", + "redirect_browser_to" + ], + "type": "object" + }, "continueWithSetOrySessionToken": { "description": "Indicates that a session was issued, and the application should use this token for authenticated requests", "properties": { @@ -574,6 +600,10 @@ "description": "The ID of the settings flow", "format": "uuid", "type": "string" + }, + "url": { + "description": "The URL of the settings flow\n\nIf this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows.", + "type": "string" } }, "required": [ @@ -610,7 +640,7 @@ "type": "string" }, "url": { - "description": "The URL of the verification flow", + "description": "The URL of the verification flow\n\nIf this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows.", "type": "string" }, "verifiable_address": { @@ -2173,7 +2203,7 @@ "$ref": "#/components/schemas/uiNodeAttributes" }, "group": { - "description": "Group specifies which group (e.g. password authenticator) this node belongs to.\ndefault DefaultGroup\npassword PasswordGroup\noidc OpenIDConnectGroup\nprofile ProfileGroup\nlink LinkGroup\ncode CodeGroup\ntotp TOTPGroup\nlookup_secret LookupGroup\nwebauthn WebAuthnGroup\npasskey PasskeyGroup", + "description": "Group specifies which group (e.g. password authenticator) this node belongs to.\ndefault DefaultGroup\npassword PasswordGroup\noidc OpenIDConnectGroup\nprofile ProfileGroup\nlink LinkGroup\ncode CodeGroup\ntotp TOTPGroup\nlookup_secret LookupGroup\nwebauthn WebAuthnGroup\npasskey PasskeyGroup\nidentifier_first IdentifierFirstGroup", "enum": [ "default", "password", @@ -2184,10 +2214,11 @@ "totp", "lookup_secret", "webauthn", - "passkey" + "passkey", + "identifier_first" ], "type": "string", - "x-go-enum-desc": "default DefaultGroup\npassword PasswordGroup\noidc OpenIDConnectGroup\nprofile ProfileGroup\nlink LinkGroup\ncode CodeGroup\ntotp TOTPGroup\nlookup_secret LookupGroup\nwebauthn WebAuthnGroup\npasskey PasskeyGroup" + "x-go-enum-desc": "default DefaultGroup\npassword PasswordGroup\noidc OpenIDConnectGroup\nprofile ProfileGroup\nlink LinkGroup\ncode CodeGroup\ntotp TOTPGroup\nlookup_secret LookupGroup\nwebauthn WebAuthnGroup\npasskey PasskeyGroup\nidentifier_first IdentifierFirstGroup" }, "messages": { "$ref": "#/components/schemas/uiTexts" @@ -2349,6 +2380,11 @@ "label": { "$ref": "#/components/schemas/uiText" }, + "maxlength": { + "description": "MaxLength may contain the input's maximum length.", + "format": "int64", + "type": "integer" + }, "name": { "description": "The input's element name.", "type": "string" @@ -2366,13 +2402,39 @@ "x-go-enum-desc": "text Text\ninput Input\nimg Image\na Anchor\nscript Script" }, "onclick": { - "description": "OnClick may contain javascript which should be executed on click. This is primarily\nused for WebAuthn.", + "description": "OnClick may contain javascript which should be executed on click. This is primarily\nused for WebAuthn.\n\nDeprecated: Using OnClick requires the use of eval() which is a security risk. Use OnClickTrigger instead.", "type": "string" }, + "onclickTrigger": { + "description": "OnClickTrigger may contain a WebAuthn trigger which should be executed on click.\n\nThe trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login.\noryWebAuthnRegistration WebAuthnTriggersWebAuthnRegistration\noryWebAuthnLogin WebAuthnTriggersWebAuthnLogin\noryPasskeyLogin WebAuthnTriggersPasskeyLogin\noryPasskeyLoginAutocompleteInit WebAuthnTriggersPasskeyLoginAutocompleteInit\noryPasskeyRegistration WebAuthnTriggersPasskeyRegistration\noryPasskeySettingsRegistration WebAuthnTriggersPasskeySettingsRegistration", + "enum": [ + "oryWebAuthnRegistration", + "oryWebAuthnLogin", + "oryPasskeyLogin", + "oryPasskeyLoginAutocompleteInit", + "oryPasskeyRegistration", + "oryPasskeySettingsRegistration" + ], + "type": "string", + "x-go-enum-desc": "oryWebAuthnRegistration WebAuthnTriggersWebAuthnRegistration\noryWebAuthnLogin WebAuthnTriggersWebAuthnLogin\noryPasskeyLogin WebAuthnTriggersPasskeyLogin\noryPasskeyLoginAutocompleteInit WebAuthnTriggersPasskeyLoginAutocompleteInit\noryPasskeyRegistration WebAuthnTriggersPasskeyRegistration\noryPasskeySettingsRegistration WebAuthnTriggersPasskeySettingsRegistration" + }, "onload": { - "description": "OnLoad may contain javascript which should be executed on load. This is primarily\nused for WebAuthn.", + "description": "OnLoad may contain javascript which should be executed on load. This is primarily\nused for WebAuthn.\n\nDeprecated: Using OnLoad requires the use of eval() which is a security risk. Use OnLoadTrigger instead.", "type": "string" }, + "onloadTrigger": { + "description": "OnLoadTrigger may contain a WebAuthn trigger which should be executed on load.\n\nThe trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login.\noryWebAuthnRegistration WebAuthnTriggersWebAuthnRegistration\noryWebAuthnLogin WebAuthnTriggersWebAuthnLogin\noryPasskeyLogin WebAuthnTriggersPasskeyLogin\noryPasskeyLoginAutocompleteInit WebAuthnTriggersPasskeyLoginAutocompleteInit\noryPasskeyRegistration WebAuthnTriggersPasskeyRegistration\noryPasskeySettingsRegistration WebAuthnTriggersPasskeySettingsRegistration", + "enum": [ + "oryWebAuthnRegistration", + "oryWebAuthnLogin", + "oryPasskeyLogin", + "oryPasskeyLoginAutocompleteInit", + "oryPasskeyRegistration", + "oryPasskeySettingsRegistration" + ], + "type": "string", + "x-go-enum-desc": "oryWebAuthnRegistration WebAuthnTriggersWebAuthnRegistration\noryWebAuthnLogin WebAuthnTriggersWebAuthnLogin\noryPasskeyLogin WebAuthnTriggersPasskeyLogin\noryPasskeyLoginAutocompleteInit WebAuthnTriggersPasskeyLoginAutocompleteInit\noryPasskeyRegistration WebAuthnTriggersPasskeyRegistration\noryPasskeySettingsRegistration WebAuthnTriggersPasskeySettingsRegistration" + }, "pattern": { "description": "The input's pattern.", "type": "string" @@ -2601,6 +2663,7 @@ "discriminator": { "mapping": { "code": "#/components/schemas/updateLoginFlowWithCodeMethod", + "identifier_first": "#/components/schemas/updateLoginFlowWithIdentifierFirstMethod", "lookup_secret": "#/components/schemas/updateLoginFlowWithLookupSecretMethod", "oidc": "#/components/schemas/updateLoginFlowWithOidcMethod", "passkey": "#/components/schemas/updateLoginFlowWithPasskeyMethod", @@ -2631,6 +2694,9 @@ }, { "$ref": "#/components/schemas/updateLoginFlowWithPasskeyMethod" + }, + { + "$ref": "#/components/schemas/updateLoginFlowWithIdentifierFirstMethod" } ] }, @@ -2668,6 +2734,32 @@ ], "type": "object" }, + "updateLoginFlowWithIdentifierFirstMethod": { + "description": "Update Login Flow with Multi-Step Method", + "properties": { + "csrf_token": { + "description": "Sending the anti-csrf token is only required for browser login flows.", + "type": "string" + }, + "identifier": { + "description": "Identifier is the email or username of the user trying to log in.", + "type": "string" + }, + "method": { + "description": "Method should be set to \"password\" when logging in using the identifier and password strategy.", + "type": "string" + }, + "transient_payload": { + "description": "Transient data to pass along to any webhooks", + "type": "object" + } + }, + "required": [ + "method", + "identifier" + ], + "type": "object" + }, "updateLoginFlowWithLookupSecretMethod": { "description": "Update Login Flow with Lookup Secret Method", "properties": { @@ -2933,8 +3025,9 @@ "mapping": { "code": "#/components/schemas/updateRegistrationFlowWithCodeMethod", "oidc": "#/components/schemas/updateRegistrationFlowWithOidcMethod", - "passKey": "#/components/schemas/updateRegistrationFlowWithPasskeyMethod", + "passkey": "#/components/schemas/updateRegistrationFlowWithPasskeyMethod", "password": "#/components/schemas/updateRegistrationFlowWithPasswordMethod", + "profile": "#/components/schemas/updateRegistrationFlowWithProfileMethod", "webauthn": "#/components/schemas/updateRegistrationFlowWithWebAuthnMethod" }, "propertyName": "method" @@ -2954,6 +3047,9 @@ }, { "$ref": "#/components/schemas/updateRegistrationFlowWithPasskeyMethod" + }, + { + "$ref": "#/components/schemas/updateRegistrationFlowWithProfileMethod" } ] }, diff --git a/spec/swagger.json b/spec/swagger.json index e790c71fecb4..54ace8d9cabe 100755 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -3654,7 +3654,29 @@ "format": "uuid" }, "url": { - "description": "The URL of the recovery flow", + "description": "The URL of the recovery flow\n\nIf this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows.", + "type": "string" + } + } + }, + "continueWithRedirectBrowserTo": { + "description": "Indicates, that the UI flow could be continued by showing a recovery ui", + "type": "object", + "required": [ + "action", + "redirect_browser_to" + ], + "properties": { + "action": { + "description": "Action will always be `redirect_browser_to`\nredirect_browser_to ContinueWithActionRedirectBrowserToString", + "type": "string", + "enum": [ + "redirect_browser_to" + ], + "x-go-enum-desc": "redirect_browser_to ContinueWithActionRedirectBrowserToString" + }, + "redirect_browser_to": { + "description": "The URL to redirect the browser to", "type": "string" } } @@ -3712,6 +3734,10 @@ "description": "The ID of the settings flow", "type": "string", "format": "uuid" + }, + "url": { + "description": "The URL of the settings flow\n\nIf this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows.", + "type": "string" } } }, @@ -3749,7 +3775,7 @@ "format": "uuid" }, "url": { - "description": "The URL of the verification flow", + "description": "The URL of the verification flow\n\nIf this value is set, redirect the user's browser to this URL. This value is typically unset for native clients / API flows.", "type": "string" }, "verifiable_address": { @@ -5286,7 +5312,7 @@ "$ref": "#/definitions/uiNodeAttributes" }, "group": { - "description": "Group specifies which group (e.g. password authenticator) this node belongs to.\ndefault DefaultGroup\npassword PasswordGroup\noidc OpenIDConnectGroup\nprofile ProfileGroup\nlink LinkGroup\ncode CodeGroup\ntotp TOTPGroup\nlookup_secret LookupGroup\nwebauthn WebAuthnGroup\npasskey PasskeyGroup", + "description": "Group specifies which group (e.g. password authenticator) this node belongs to.\ndefault DefaultGroup\npassword PasswordGroup\noidc OpenIDConnectGroup\nprofile ProfileGroup\nlink LinkGroup\ncode CodeGroup\ntotp TOTPGroup\nlookup_secret LookupGroup\nwebauthn WebAuthnGroup\npasskey PasskeyGroup\nidentifier_first IdentifierFirstGroup", "type": "string", "enum": [ "default", @@ -5298,9 +5324,10 @@ "totp", "lookup_secret", "webauthn", - "passkey" + "passkey", + "identifier_first" ], - "x-go-enum-desc": "default DefaultGroup\npassword PasswordGroup\noidc OpenIDConnectGroup\nprofile ProfileGroup\nlink LinkGroup\ncode CodeGroup\ntotp TOTPGroup\nlookup_secret LookupGroup\nwebauthn WebAuthnGroup\npasskey PasskeyGroup" + "x-go-enum-desc": "default DefaultGroup\npassword PasswordGroup\noidc OpenIDConnectGroup\nprofile ProfileGroup\nlink LinkGroup\ncode CodeGroup\ntotp TOTPGroup\nlookup_secret LookupGroup\nwebauthn WebAuthnGroup\npasskey PasskeyGroup\nidentifier_first IdentifierFirstGroup" }, "messages": { "$ref": "#/definitions/uiTexts" @@ -5434,6 +5461,11 @@ "label": { "$ref": "#/definitions/uiText" }, + "maxlength": { + "description": "MaxLength may contain the input's maximum length.", + "type": "integer", + "format": "int64" + }, "name": { "description": "The input's element name.", "type": "string" @@ -5451,13 +5483,39 @@ "x-go-enum-desc": "text Text\ninput Input\nimg Image\na Anchor\nscript Script" }, "onclick": { - "description": "OnClick may contain javascript which should be executed on click. This is primarily\nused for WebAuthn.", + "description": "OnClick may contain javascript which should be executed on click. This is primarily\nused for WebAuthn.\n\nDeprecated: Using OnClick requires the use of eval() which is a security risk. Use OnClickTrigger instead.", "type": "string" }, + "onclickTrigger": { + "description": "OnClickTrigger may contain a WebAuthn trigger which should be executed on click.\n\nThe trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login.\noryWebAuthnRegistration WebAuthnTriggersWebAuthnRegistration\noryWebAuthnLogin WebAuthnTriggersWebAuthnLogin\noryPasskeyLogin WebAuthnTriggersPasskeyLogin\noryPasskeyLoginAutocompleteInit WebAuthnTriggersPasskeyLoginAutocompleteInit\noryPasskeyRegistration WebAuthnTriggersPasskeyRegistration\noryPasskeySettingsRegistration WebAuthnTriggersPasskeySettingsRegistration", + "type": "string", + "enum": [ + "oryWebAuthnRegistration", + "oryWebAuthnLogin", + "oryPasskeyLogin", + "oryPasskeyLoginAutocompleteInit", + "oryPasskeyRegistration", + "oryPasskeySettingsRegistration" + ], + "x-go-enum-desc": "oryWebAuthnRegistration WebAuthnTriggersWebAuthnRegistration\noryWebAuthnLogin WebAuthnTriggersWebAuthnLogin\noryPasskeyLogin WebAuthnTriggersPasskeyLogin\noryPasskeyLoginAutocompleteInit WebAuthnTriggersPasskeyLoginAutocompleteInit\noryPasskeyRegistration WebAuthnTriggersPasskeyRegistration\noryPasskeySettingsRegistration WebAuthnTriggersPasskeySettingsRegistration" + }, "onload": { - "description": "OnLoad may contain javascript which should be executed on load. This is primarily\nused for WebAuthn.", + "description": "OnLoad may contain javascript which should be executed on load. This is primarily\nused for WebAuthn.\n\nDeprecated: Using OnLoad requires the use of eval() which is a security risk. Use OnLoadTrigger instead.", "type": "string" }, + "onloadTrigger": { + "description": "OnLoadTrigger may contain a WebAuthn trigger which should be executed on load.\n\nThe trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login.\noryWebAuthnRegistration WebAuthnTriggersWebAuthnRegistration\noryWebAuthnLogin WebAuthnTriggersWebAuthnLogin\noryPasskeyLogin WebAuthnTriggersPasskeyLogin\noryPasskeyLoginAutocompleteInit WebAuthnTriggersPasskeyLoginAutocompleteInit\noryPasskeyRegistration WebAuthnTriggersPasskeyRegistration\noryPasskeySettingsRegistration WebAuthnTriggersPasskeySettingsRegistration", + "type": "string", + "enum": [ + "oryWebAuthnRegistration", + "oryWebAuthnLogin", + "oryPasskeyLogin", + "oryPasskeyLoginAutocompleteInit", + "oryPasskeyRegistration", + "oryPasskeySettingsRegistration" + ], + "x-go-enum-desc": "oryWebAuthnRegistration WebAuthnTriggersWebAuthnRegistration\noryWebAuthnLogin WebAuthnTriggersWebAuthnLogin\noryPasskeyLogin WebAuthnTriggersPasskeyLogin\noryPasskeyLoginAutocompleteInit WebAuthnTriggersPasskeyLoginAutocompleteInit\noryPasskeyRegistration WebAuthnTriggersPasskeyRegistration\noryPasskeySettingsRegistration WebAuthnTriggersPasskeySettingsRegistration" + }, "pattern": { "description": "The input's pattern.", "type": "string" @@ -5713,6 +5771,32 @@ } } }, + "updateLoginFlowWithIdentifierFirstMethod": { + "description": "Update Login Flow with Multi-Step Method", + "type": "object", + "required": [ + "method", + "identifier" + ], + "properties": { + "csrf_token": { + "description": "Sending the anti-csrf token is only required for browser login flows.", + "type": "string" + }, + "identifier": { + "description": "Identifier is the email or username of the user trying to log in.", + "type": "string" + }, + "method": { + "description": "Method should be set to \"password\" when logging in using the identifier and password strategy.", + "type": "string" + }, + "transient_payload": { + "description": "Transient data to pass along to any webhooks", + "type": "object" + } + } + }, "updateLoginFlowWithLookupSecretMethod": { "description": "Update Login Flow with Lookup Secret Method", "type": "object", diff --git a/test/e2e/cypress/integration/profiles/code/login/error.spec.ts b/test/e2e/cypress/integration/profiles/code/login/error.spec.ts index 477a149f9b26..3310a966627f 100644 --- a/test/e2e/cypress/integration/profiles/code/login/error.spec.ts +++ b/test/e2e/cypress/integration/profiles/code/login/error.spec.ts @@ -83,7 +83,7 @@ context("Login error messages with code method", () => { "An email containing a code has been sent to the email address you provided", ) - cy.get(Selectors[app]["code"]).type("invalid-code") + cy.get(Selectors[app]["code"]).type("123456") cy.submitCodeForm(app) cy.get('[data-testid="ui/message/4010008"]').should( @@ -113,7 +113,7 @@ context("Login error messages with code method", () => { .type(gen.email(), { force: true }) } - cy.get(Selectors[app]["code"]).type("invalid-code") + cy.get(Selectors[app]["code"]).type("123456") cy.submitCodeForm(app) if (app !== "express") { @@ -147,7 +147,7 @@ context("Login error messages with code method", () => { ) } - cy.get(Selectors[app]["code"]).type("invalid-code") + cy.get(Selectors[app]["code"]).type("123456") cy.removeAttribute([Selectors[app]["identity"]], "required") cy.get(Selectors[app]["identity"]).type("{selectall}{backspace}", { diff --git a/test/e2e/cypress/integration/profiles/code/registration/error.spec.ts b/test/e2e/cypress/integration/profiles/code/registration/error.spec.ts index ef435991f736..4570c1c5cc60 100644 --- a/test/e2e/cypress/integration/profiles/code/registration/error.spec.ts +++ b/test/e2e/cypress/integration/profiles/code/registration/error.spec.ts @@ -74,7 +74,7 @@ context("Registration error messages with code method", () => { "An email containing a code has been sent to the email address you provided", ) - cy.get(Selectors[app]["code"]).type("invalid-code") + cy.get(Selectors[app]["code"]).type("123456") cy.submitCodeForm(app) cy.get('[data-testid="ui/message/4040003"]').should( @@ -111,7 +111,7 @@ context("Registration error messages with code method", () => { .type("changed-email@email.com", { force: true }) } - cy.get(Selectors[app]["code"]).type("invalid-code") + cy.get(Selectors[app]["code"]).type("123456") cy.submitCodeForm(app) if (app !== "express") { @@ -174,7 +174,7 @@ context("Registration error messages with code method", () => { }) cy.removeAttribute([Selectors[app]["email"]], "required") } - cy.get(Selectors[app]["code"]).type("invalid-code") + cy.get(Selectors[app]["code"]).type("123456") cy.submitCodeForm(app) diff --git a/test/e2e/cypress/integration/profiles/mfa/code.spec.ts b/test/e2e/cypress/integration/profiles/mfa/code.spec.ts index 7961aa850de9..c7d29c0561c2 100644 --- a/test/e2e/cypress/integration/profiles/mfa/code.spec.ts +++ b/test/e2e/cypress/integration/profiles/mfa/code.spec.ts @@ -58,7 +58,7 @@ context("2FA code", () => { cy.get("input[name='code']").should("be.visible") cy.getLoginCodeFromEmail(email).then((code) => { cy.get("input[name='code']").type(code) - cy.contains("Submit").click() + cy.contains("Continue").click() }) cy.getSession({ @@ -88,10 +88,10 @@ context("2FA code", () => { cy.get("input[name='code']").should("be.visible") cy.get("input[name='code']").type("123456") - cy.contains("Submit").click() + cy.contains("Continue").click() cy.getLoginCodeFromEmail(email).then((code) => { cy.get("input[name='code']").type(code) - cy.contains("Submit").click() + cy.contains("Continue").click() }) cy.getSession({ diff --git a/test/e2e/cypress/support/commands.ts b/test/e2e/cypress/support/commands.ts index 0b4584646abc..89b5c7cb15c5 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" @@ -429,7 +429,7 @@ Cypress.Commands.add( f.group === "default" && "name" in f.attributes && f.attributes.name === "traits.email", - ).attributes.value, + )?.attributes.value, ).to.eq(email) return cy diff --git a/test/e2e/cypress/support/configHelpers.ts b/test/e2e/cypress/support/configHelpers.ts index c8ccf05b70d2..0fc72864294a 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 9cfeb083f12a..a6a7120937de 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 6042605a6887..dd9b96adae48 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 f7f7bd88539a..f6452591bda9 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 d4106cbb8aa1..05b04db6880a 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 71a67dfd8795..a3f81c73060d 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 000000000000..806f16466dc3 --- /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 871608bc204d..172c6d8ab849 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 000000000000..5d77b6de7b59 --- /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 3b1264e84c0a..b915dd4d937f 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 83b24587bd61..e9abcfd7f4f0 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 929b39b74573..da5fb76c13ba 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 000000000000..5732e5a34aca --- /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 000000000000..baae9b11834a --- /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", + Unified = "unified", +} + +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.Unified: + 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.Unified: + 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 000000000000..c4f1f35d0271 --- /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 b9249917b039..5e4f5d1e2e73 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 92a42432fc96..000000000000 --- 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 000000000000..b94dbf29669c --- /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 000000000000..cf0a19d23a87 --- /dev/null +++ b/test/e2e/playwright/tests/desktop/identifier_first/oidc.login.spec.ts @@ -0,0 +1,162 @@ +// 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) + }) + + // This is required, because OIDC issues a new session on refresh (TODO), and MySQL does not store sub second timestamps, so we need to wait a bit + await page.waitForTimeout(1000) + 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(newSession.authentication_methods).toHaveLength( + // initialSession.authentication_methods.length + 1, + // ) + expect(newSession.authenticated_at).not.toBe( + initialSession.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 000000000000..ecd4fe0ceaa5 --- /dev/null +++ b/test/e2e/playwright/tests/desktop/identifier_first/passkeys.login.spec.ts @@ -0,0 +1,238 @@ +// 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 page.waitForURL( + new RegExp(config.selfservice.default_browser_return_url), + ) + + 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 initialSession = 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(newSession.authentication_methods).toHaveLength( + initialSession.authentication_methods.length + 1, + ) + }) +}) 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 000000000000..982c2ead22d6 --- /dev/null +++ b/test/e2e/playwright/tests/desktop/identifier_first/password.login.spec.ts @@ -0,0 +1,219 @@ +// 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 initialSession = 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(newSession.authentication_methods).toHaveLength( + initialSession.authentication_methods.length + 1, + ) + }) +}) + +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 initialSession = 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(newSession.authentication_methods).toHaveLength( + initialSession.authentication_methods.length + 1, + ) + }) +}) 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 600a49a48ce1..327fbbf3e54a 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 629abd3c05bc..c065e0965443 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 000000000000..ddb3431774c0 --- /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/profiles/code/.kratos.yml b/test/e2e/profiles/code/.kratos.yml index 3e98857e1628..a98e4979e730 100644 --- a/test/e2e/profiles/code/.kratos.yml +++ b/test/e2e/profiles/code/.kratos.yml @@ -38,7 +38,6 @@ selfservice: enabled: true code: passwordless_enabled: true - passwordless_login_fallback_enabled: false enabled: true config: lifespan: 1h diff --git a/test/e2e/profiles/mobile/.kratos.yml b/test/e2e/profiles/mobile/.kratos.yml index c0a46e57c197..ab927737337d 100644 --- a/test/e2e/profiles/mobile/.kratos.yml +++ b/test/e2e/profiles/mobile/.kratos.yml @@ -19,7 +19,6 @@ selfservice: verification: enabled: false - methods: totp: enabled: true diff --git a/test/e2e/profiles/two-steps/.kratos.yml b/test/e2e/profiles/two-steps/.kratos.yml index d23dd0bce07c..f5271792c0fc 100644 --- a/test/e2e/profiles/two-steps/.kratos.yml +++ b/test/e2e/profiles/two-steps/.kratos.yml @@ -66,7 +66,6 @@ selfservice: code: enabled: true passwordless_enabled: true - passwordless_login_fallback_enabled: false config: lifespan: 1h diff --git a/test/e2e/render-kratos-config.sh b/test/e2e/render-kratos-config.sh index 5867904216e2..b1895ecd882c 100755 --- a/test/e2e/render-kratos-config.sh +++ b/test/e2e/render-kratos-config.sh @@ -10,8 +10,8 @@ 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 -make format +(cd $dir/../..; make format) diff --git a/test/e2e/cypress/support/config.d.ts b/test/e2e/shared/config.d.ts similarity index 89% rename from test/e2e/cypress/support/config.d.ts rename to test/e2e/shared/config.d.ts index c7e9742aed39..28a2168dc196 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 `unified` 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 = "unified" | "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. @@ -143,6 +143,16 @@ export type MinimumPasswordLength = number * If set to false the password validation does not check for similarity between the password and the user identifier. */ export type EnablePasswordIdentifierSimilarityCheck = boolean +/** + * If set to true will enable password migration. + */ +export type EnablePasswordMigration = boolean +/** + * Define which auth mechanism the Web-Hook should use + */ +export type AuthMechanisms = + | WebHookAuthApiKeyProperties + | WebHookAuthBasicAuthProperties export type EnablesTheTOTPMethod = boolean /** * The issuer (e.g. a domain name) will be shown in the TOTP app (e.g. Google Authenticator). It helps the user differentiate between different codes. @@ -178,6 +188,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 +225,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 +254,9 @@ export type Provider = | "dingtalk" | "patreon" | "linkedin" + | "linkedin_v2" | "lark" + | "x" export type OptionalStringWhichWillBeUsedWhenGeneratingLabelsForUIButtons = string /** @@ -262,6 +288,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. */ @@ -297,7 +327,7 @@ export type HTTPAddressOfAPIEndpoint = string /** * Define which auth mechanism to use for auth with the HTTP email provider */ -export type AuthMechanisms = +export type AuthMechanisms1 = | WebHookAuthApiKeyProperties | WebHookAuthBasicAuthProperties /** @@ -335,7 +365,7 @@ export type HTTPAddressOfAPIEndpoint1 = string /** * Define which auth mechanism to use for auth with the SMS provider */ -export type AuthMechanisms1 = +export type AuthMechanisms2 = | WebHookAuthApiKeyProperties | WebHookAuthBasicAuthProperties /** @@ -517,14 +547,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 +593,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 +609,7 @@ export interface OryKratosConfiguration2 { } } methods?: { + b2b?: SingleSignOnForB2B profile?: { enabled?: EnablesProfileManagementMethod } @@ -572,13 +617,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 +648,10 @@ export interface OryKratosConfiguration2 { enabled?: EnablesTheWebAuthnMethod config?: WebAuthnConfiguration } + passkey?: { + enabled?: EnablesThePasskeyMethod + config?: PasskeyConfiguration + } oidc?: SpecifyOpenIDConnectAndOAuth2Configuration } } @@ -701,6 +759,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 +788,7 @@ export interface OryKratosConfiguration2 { clients?: GlobalOutgoingNetworkSettings feature_flags?: FeatureFlags organizations?: Organizations + enterprise?: EnterpriseFeatures } export interface SelfServiceAfterSettings { default_browser_return_url?: RedirectBrowsersToSetURLPerDefault @@ -727,6 +796,7 @@ export interface SelfServiceAfterSettings { totp?: SelfServiceAfterSettingsAuthMethod oidc?: SelfServiceAfterSettingsAuthMethod webauthn?: SelfServiceAfterSettingsAuthMethod + passkey?: SelfServiceAfterSettingsAuthMethod lookup_secret?: SelfServiceAfterSettingsAuthMethod profile?: SelfServiceAfterSettingsMethod hooks?: SelfServiceHooks @@ -762,6 +832,7 @@ export interface SelfServiceAfterRegistration { default_browser_return_url?: RedirectBrowsersToSetURLPerDefault password?: SelfServiceAfterRegistrationMethod webauthn?: SelfServiceAfterRegistrationMethod + passkey?: SelfServiceAfterRegistrationMethod oidc?: SelfServiceAfterRegistrationMethod code?: SelfServiceAfterRegistrationMethod hooks?: SelfServiceHooks @@ -788,6 +859,7 @@ export interface SelfServiceAfterLogin { default_browser_return_url?: RedirectBrowsersToSetURLPerDefault password?: SelfServiceAfterDefaultLoginMethod webauthn?: SelfServiceAfterDefaultLoginMethod + passkey?: SelfServiceAfterDefaultLoginMethod oidc?: SelfServiceAfterOIDCLoginMethod code?: SelfServiceAfterDefaultLoginMethod totp?: SelfServiceAfterDefaultLoginMethod @@ -796,6 +868,8 @@ export interface SelfServiceAfterLogin { | SelfServiceWebHook | SelfServiceSessionRevokerHook | SelfServiceRequireVerifiedAddressHook + | SelfServiceVerificationHook + | SelfServiceShowVerificationUIHook | B2BSSOHook )[] } @@ -805,11 +879,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 +930,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 +957,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. */ @@ -876,6 +967,61 @@ export interface PasswordConfiguration { ignore_network_errors?: IgnoreLookupNetworkErrors min_password_length?: MinimumPasswordLength identifier_similarity_check_enabled?: EnablePasswordIdentifierSimilarityCheck + migrate_hook?: { + enabled?: EnablePasswordMigration + config?: { + /** + * The URL the password migration hook should call + */ + url?: string + /** + * The HTTP method to use (GET, POST, etc). + */ + method?: "POST" + /** + * The HTTP headers that must be applied to the password migration hook. + */ + headers?: { + [k: string]: string | undefined + } + /** + * Emit tracing events for this hook on delivery or error + */ + emit_analytics_event?: boolean + auth?: AuthMechanisms + additionalProperties?: false + } + } +} +export interface WebHookAuthApiKeyProperties { + type: "api_key" + config: { + /** + * The name of the api key + */ + name: string + /** + * The value of the api key + */ + value: string + /** + * How the api key should be transferred + */ + in: "header" | "cookie" + } +} +export interface WebHookAuthBasicAuthProperties { + type: "basic_auth" + config: { + /** + * user name for basic auth + */ + user: string + /** + * password for basic auth + */ + password: string + } } export interface TOTPConfiguration { issuer?: TOTPIssuer @@ -884,6 +1030,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 +1118,7 @@ export interface CourierConfiguration { login_code?: { valid?: { email: EmailCourierTemplate + sms?: SmsCourierTemplate } } } @@ -1043,44 +1199,14 @@ export interface HttpRequestConfig { * URI pointing to the jsonnet template used for payload generation. Only used for those HTTP methods, which support HTTP body payloads */ body?: string - auth?: AuthMechanisms + auth?: AuthMechanisms1 additionalProperties?: false } -export interface WebHookAuthApiKeyProperties { - type: "api_key" - config: { - /** - * The name of the api key - */ - name: string - /** - * The value of the api key - */ - value: string - /** - * How the api key should be transferred - */ - in: "header" | "cookie" - } -} -export interface WebHookAuthBasicAuthProperties { - type: "basic_auth" - config: { - /** - * user name for basic auth - */ - user: string - /** - * password for basic auth - */ - password: string - } -} /** * 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 @@ -1119,7 +1245,7 @@ export interface SMSSenderConfiguration { * URI pointing to the jsonnet template used for payload generation. Only used for those HTTP methods, which support HTTP body payloads */ body?: string - auth?: AuthMechanisms1 + auth?: AuthMechanisms2 additionalProperties?: false } } @@ -1375,5 +1501,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 } diff --git a/text/id.go b/text/id.go index a466caec0f8c..edff417a2738 100644 --- a/text/id.go +++ b/text/id.go @@ -31,6 +31,7 @@ const ( InfoSelfServiceLoginCodeMFA // 1010019 InfoSelfServiceLoginCodeMFAHint // 1010020 InfoSelfServiceLoginPasskey // 1010021 + InfoSelfServiceLoginPassword // 1010022 ) const ( @@ -86,21 +87,21 @@ const ( ) const ( - InfoNodeLabel ID = 1070000 + iota // 1070000 - InfoNodeLabelInputPassword // 1070001 - InfoNodeLabelGenerated // 1070002 - InfoNodeLabelSave // 1070003 - InfoNodeLabelID // 1070004 - InfoNodeLabelSubmit // 1070005 - InfoNodeLabelVerifyOTP // 1070006 - InfoNodeLabelEmail // 1070007 - InfoNodeLabelResendOTP // 1070008 - InfoNodeLabelContinue // 1070009 - InfoNodeLabelRecoveryCode // 1070010 - InfoNodeLabelVerificationCode // 1070011 - InfoNodeLabelRegistrationCode // 1070012 - InfoNodeLabelLoginCode // 1070013 - InfoNodeLabelLoginAndLinkCredential + InfoNodeLabel ID = 1070000 + iota // 1070000 + InfoNodeLabelInputPassword // 1070001 + InfoNodeLabelGenerated // 1070002 + InfoNodeLabelSave // 1070003 + InfoNodeLabelID // 1070004 + InfoNodeLabelSubmit // 1070005 + InfoNodeLabelVerifyOTP // 1070006 + InfoNodeLabelEmail // 1070007 + InfoNodeLabelResendOTP // 1070008 + InfoNodeLabelContinue // 1070009 + InfoNodeLabelRecoveryCode // 1070010 + InfoNodeLabelVerificationCode // 1070011 + InfoNodeLabelRegistrationCode // 1070012 + InfoNodeLabelLoginCode // 1070013 + InfoNodeLabelLoginAndLinkCredential // 1070014 ) const ( @@ -148,6 +149,7 @@ const ( ErrorValidationPasswordTooManyBreaches ErrorValidationNoCodeUser ErrorValidationTraitsMismatch + ErrorValidationAccountNotFound ) const ( diff --git a/text/message_login.go b/text/message_login.go index ec627458a028..9312a21e97a2 100644 --- a/text/message_login.go +++ b/text/message_login.go @@ -89,6 +89,14 @@ func NewInfoLoginTOTP() *Message { } } +func NewInfoLoginPassword() *Message { + return &Message{ + ID: InfoSelfServiceLoginPassword, + Text: "Sign in with password", + Type: Info, + } +} + func NewInfoLoginLookup() *Message { return &Message{ ID: InfoLoginLookup, @@ -182,7 +190,7 @@ func NewErrorValidationVerificationNoStrategyFound() *Message { func NewInfoSelfServiceLoginWebAuthn() *Message { return &Message{ ID: InfoSelfServiceLoginWebAuthn, - Text: "Use security key", + Text: "Sign in with hardware key", Type: Info, } } @@ -198,7 +206,7 @@ func NewInfoSelfServiceLoginPasskey() *Message { func NewInfoSelfServiceContinueLoginWebAuthn() *Message { return &Message{ ID: InfoSelfServiceLoginContinueWebAuthn, - Text: "Continue with security key", + Text: "Sign in with hardware key", Type: Info, } } @@ -239,7 +247,7 @@ func NewInfoSelfServiceLoginCode() *Message { return &Message{ ID: InfoSelfServiceLoginCode, Type: Info, - Text: "Sign in with code", + Text: "Send sign in code", } } diff --git a/text/message_validation.go b/text/message_validation.go index c10fddead805..2fd1e4c2d28d 100644 --- a/text/message_validation.go +++ b/text/message_validation.go @@ -257,6 +257,14 @@ func NewErrorValidationInvalidCredentials() *Message { } } +func NewErrorValidationAccountNotFound() *Message { + return &Message{ + ID: ErrorValidationAccountNotFound, + Text: "This account does not exist or has no login method configured.", + Type: Error, + } +} + func NewErrorValidationDuplicateCredentials() *Message { return &Message{ ID: ErrorValidationDuplicateCredentials, diff --git a/ui/node/attributes.go b/ui/node/attributes.go index 9611b5828dff..6ad55d46a0e2 100644 --- a/ui/node/attributes.go +++ b/ui/node/attributes.go @@ -3,7 +3,12 @@ package node -import "github.com/ory/kratos/text" +import ( + "fmt" + + "github.com/ory/kratos/text" + "github.com/ory/kratos/x/webauthnx/js" +) const ( InputAttributeTypeText UiNodeInputAttributeType = "text" @@ -53,6 +58,9 @@ type Attributes interface { // swagger:ignore GetNodeType() UiNodeType + + // swagger:ignore + Matches(other Attributes) bool } // InputAttributes represents the attributes of an input node @@ -91,12 +99,29 @@ type InputAttributes struct { // OnClick may contain javascript which should be executed on click. This is primarily // used for WebAuthn. + // + // Deprecated: Using OnClick requires the use of eval() which is a security risk. Use OnClickTrigger instead. OnClick string `json:"onclick,omitempty"` + // OnClickTrigger may contain a WebAuthn trigger which should be executed on click. + // + // The trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login. + OnClickTrigger js.WebAuthnTriggers `json:"onclickTrigger,omitempty"` + // OnLoad may contain javascript which should be executed on load. This is primarily // used for WebAuthn. + // + // Deprecated: Using OnLoad requires the use of eval() which is a security risk. Use OnLoadTrigger instead. OnLoad string `json:"onload,omitempty"` + // OnLoadTrigger may contain a WebAuthn trigger which should be executed on load. + // + // The trigger maps to a JavaScript function provided by Ory, which triggers actions such as PassKey registration or login. + OnLoadTrigger js.WebAuthnTriggers `json:"onloadTrigger,omitempty"` + + // MaxLength may contain the input's maximum length. + MaxLength int `json:"maxlength,omitempty"` + // NodeType represents this node's types. It is a mirror of `node.type` and // is primarily used to allow compatibility with OpenAPI 3.0. In this struct it technically always is "input". // @@ -267,6 +292,99 @@ func (a *ScriptAttributes) ID() string { return a.Identifier } +func (a *InputAttributes) Matches(other Attributes) bool { + ot, ok := other.(*InputAttributes) + if !ok { + return false + } + + if len(ot.ID()) > 0 && a.ID() != ot.ID() { + return false + } + + if len(ot.Type) > 0 && a.Type != ot.Type { + return false + } + + if ot.FieldValue != nil && fmt.Sprintf("%v", a.FieldValue) != fmt.Sprintf("%v", ot.FieldValue) { + return false + } + + if len(ot.Name) > 0 && a.Name != ot.Name { + return false + } + + return true +} + +func (a *ImageAttributes) Matches(other Attributes) bool { + ot, ok := other.(*ImageAttributes) + if !ok { + return false + } + + if len(ot.ID()) > 0 && a.ID() != ot.ID() { + return false + } + + if len(ot.Source) > 0 && a.Source != ot.Source { + return false + } + + return true +} + +func (a *AnchorAttributes) Matches(other Attributes) bool { + ot, ok := other.(*AnchorAttributes) + if !ok { + return false + } + + if len(ot.ID()) > 0 && a.ID() != ot.ID() { + return false + } + + if len(ot.HREF) > 0 && a.HREF != ot.HREF { + return false + } + + return true +} + +func (a *TextAttributes) Matches(other Attributes) bool { + ot, ok := other.(*TextAttributes) + if !ok { + return false + } + + if len(ot.ID()) > 0 && a.ID() != ot.ID() { + return false + } + + return true +} + +func (a *ScriptAttributes) Matches(other Attributes) bool { + ot, ok := other.(*ScriptAttributes) + if !ok { + return false + } + + if len(ot.ID()) > 0 && a.ID() != ot.ID() { + return false + } + + if ot.Type != "" && a.Type != ot.Type { + return false + } + + if ot.Source != "" && a.Source != ot.Source { + return false + } + + return true +} + func (a *InputAttributes) SetValue(value interface{}) { a.FieldValue = value } diff --git a/ui/node/attributes_input.go b/ui/node/attributes_input.go index b63ac9365a51..176c1a25b42e 100644 --- a/ui/node/attributes_input.go +++ b/ui/node/attributes_input.go @@ -38,6 +38,12 @@ func WithRequiredInputAttribute(a *InputAttributes) { a.Required = true } +func WithMaxLengthInputAttribute(maxLength int) func(a *InputAttributes) { + return func(a *InputAttributes) { + a.MaxLength = maxLength + } +} + func WithInputAttributes(f func(a *InputAttributes)) func(a *InputAttributes) { return func(a *InputAttributes) { f(a) diff --git a/ui/node/attributes_test.go b/ui/node/attributes_test.go index 218919e1145e..62c2316d3c9f 100644 --- a/ui/node/attributes_test.go +++ b/ui/node/attributes_test.go @@ -21,6 +21,70 @@ func TestIDs(t *testing.T) { assert.EqualValues(t, "foo", (&ScriptAttributes{Identifier: "foo"}).ID()) } +func TestMatchesAnchorAttributes(t *testing.T) { + assert.True(t, (&AnchorAttributes{Identifier: "foo"}).Matches(&AnchorAttributes{Identifier: "foo"})) + assert.True(t, (&AnchorAttributes{HREF: "bar"}).Matches(&AnchorAttributes{HREF: "bar"})) + assert.False(t, (&AnchorAttributes{HREF: "foo"}).Matches(&AnchorAttributes{HREF: "bar"})) + assert.False(t, (&AnchorAttributes{Identifier: "foo"}).Matches(&AnchorAttributes{HREF: "bar"})) + + assert.True(t, (&AnchorAttributes{Identifier: "foo", HREF: "bar"}).Matches(&AnchorAttributes{Identifier: "foo", HREF: "bar"})) + assert.False(t, (&AnchorAttributes{Identifier: "foo", HREF: "bar"}).Matches(&AnchorAttributes{Identifier: "foo", HREF: "baz"})) + assert.False(t, (&AnchorAttributes{Identifier: "foo", HREF: "bar"}).Matches(&AnchorAttributes{Identifier: "bar", HREF: "bar"})) + + assert.False(t, (&AnchorAttributes{Identifier: "foo"}).Matches(&TextAttributes{Identifier: "foo"})) +} + +func TestMatchesImageAttributes(t *testing.T) { + assert.True(t, (&ImageAttributes{Identifier: "foo"}).Matches(&ImageAttributes{Identifier: "foo"})) + assert.True(t, (&ImageAttributes{Source: "bar"}).Matches(&ImageAttributes{Source: "bar"})) + assert.False(t, (&ImageAttributes{Source: "foo"}).Matches(&ImageAttributes{Source: "bar"})) + assert.False(t, (&ImageAttributes{Identifier: "foo"}).Matches(&ImageAttributes{Source: "bar"})) + + assert.True(t, (&ImageAttributes{Identifier: "foo", Source: "bar"}).Matches(&ImageAttributes{Identifier: "foo", Source: "bar"})) + assert.False(t, (&ImageAttributes{Identifier: "foo", Source: "bar"}).Matches(&ImageAttributes{Identifier: "foo", Source: "baz"})) + assert.False(t, (&ImageAttributes{Identifier: "foo", Source: "bar"}).Matches(&ImageAttributes{Identifier: "bar", Source: "bar"})) + + assert.False(t, (&ImageAttributes{Identifier: "foo"}).Matches(&TextAttributes{Identifier: "foo"})) +} + +func TestMatchesInputAttributes(t *testing.T) { + // Test when other is not of type *InputAttributes + var attr Attributes = &ImageAttributes{} + inputAttr := &InputAttributes{Name: "foo"} + assert.False(t, inputAttr.Matches(attr)) + + // Test when ID is different + attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText} + inputAttr = &InputAttributes{Name: "bar", Type: InputAttributeTypeText} + assert.False(t, inputAttr.Matches(attr)) + + // Test when Type is different + attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText} + inputAttr = &InputAttributes{Name: "foo", Type: InputAttributeTypeNumber} + assert.False(t, inputAttr.Matches(attr)) + + // Test when FieldValue is different + attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText, FieldValue: "bar"} + inputAttr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText, FieldValue: "baz"} + assert.False(t, inputAttr.Matches(attr)) + + // Test when Name is different + attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText} + inputAttr = &InputAttributes{Name: "bar", Type: InputAttributeTypeText} + assert.False(t, inputAttr.Matches(attr)) + + // Test when all fields are the same + attr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText, FieldValue: "bar"} + inputAttr = &InputAttributes{Name: "foo", Type: InputAttributeTypeText, FieldValue: "bar"} + assert.True(t, inputAttr.Matches(attr)) +} + +func TestMatchesTextAttributes(t *testing.T) { + assert.True(t, (&TextAttributes{Identifier: "foo"}).Matches(&TextAttributes{Identifier: "foo"})) + assert.True(t, (&TextAttributes{Identifier: "foo"}).Matches(&TextAttributes{Identifier: "foo"})) + assert.False(t, (&TextAttributes{Identifier: "foo"}).Matches(&ImageAttributes{Identifier: "foo"})) +} + func TestNodeEncode(t *testing.T) { script := jsonx.TestMarshalJSONString(t, &Node{Attributes: &ScriptAttributes{}}) assert.EqualValues(t, Script, gjson.Get(script, "attributes.node_type").String()) diff --git a/ui/node/node.go b/ui/node/node.go index e08295b827f4..66e1b72fa8b6 100644 --- a/ui/node/node.go +++ b/ui/node/node.go @@ -39,16 +39,17 @@ func (t UiNodeType) String() string { type UiNodeGroup string const ( - DefaultGroup UiNodeGroup = "default" - PasswordGroup UiNodeGroup = "password" - OpenIDConnectGroup UiNodeGroup = "oidc" - ProfileGroup UiNodeGroup = "profile" - LinkGroup UiNodeGroup = "link" - CodeGroup UiNodeGroup = "code" - TOTPGroup UiNodeGroup = "totp" - LookupGroup UiNodeGroup = "lookup_secret" - WebAuthnGroup UiNodeGroup = "webauthn" - PasskeyGroup UiNodeGroup = "passkey" + DefaultGroup UiNodeGroup = "default" + PasswordGroup UiNodeGroup = "password" + OpenIDConnectGroup UiNodeGroup = "oidc" + ProfileGroup UiNodeGroup = "profile" + LinkGroup UiNodeGroup = "link" + CodeGroup UiNodeGroup = "code" + TOTPGroup UiNodeGroup = "totp" + LookupGroup UiNodeGroup = "lookup_secret" + WebAuthnGroup UiNodeGroup = "webauthn" + PasskeyGroup UiNodeGroup = "passkey" + IdentifierFirstGroup UiNodeGroup = "identifier_first" ) func (g UiNodeGroup) String() string { @@ -218,6 +219,7 @@ func SortUseOrder(keysInOrder []string) func(*sortOptions) { options.keysInOrder = keysInOrder } } + func SortUseOrderAppend(keysInOrder []string) func(*sortOptions) { return func(options *sortOptions) { options.keysInOrderAppend = keysInOrder @@ -353,6 +355,37 @@ func (n *Nodes) Append(node *Node) { *n = append(*n, node) } +func (n *Nodes) RemoveMatching(node *Node) { + if n == nil { + return + } + + var r Nodes + for k, v := range *n { + if !(*n)[k].Matches(node) { + r = append(r, v) + } + } + + *n = r +} + +func (n *Node) Matches(needle *Node) bool { + if len(needle.ID()) > 0 && n.ID() != needle.ID() { + return false + } + + if needle.Type != "" && n.Type != needle.Type { + return false + } + + if needle.Group != "" && n.Group != needle.Group { + return false + } + + return n.Attributes.Matches(needle.Attributes) +} + func (n *Node) UnmarshalJSON(data []byte) error { var attr Attributes switch t := gjson.GetBytes(data, "type").String(); UiNodeType(t) { diff --git a/ui/node/node_test.go b/ui/node/node_test.go index f8867b98c2a3..e8a85bc9cd9f 100644 --- a/ui/node/node_test.go +++ b/ui/node/node_test.go @@ -11,6 +11,8 @@ import ( "path/filepath" "testing" + "github.com/ory/kratos/text" + "github.com/ory/x/assertx" "github.com/ory/kratos/corpx" @@ -193,3 +195,64 @@ func TestNodeJSON(t *testing.T) { require.EqualError(t, json.NewDecoder(bytes.NewReader(json.RawMessage(`{"type": "foo"}`))).Decode(&n), "unexpected node type: foo") }) } + +func TestMatchesNode(t *testing.T) { + // Test when ID is different + node1 := &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}} + node2 := &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "bar"}} + assert.False(t, node1.Matches(node2)) + + // Test when Type is different + node1 = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}} + node2 = &node.Node{Type: node.Text, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}} + assert.False(t, node1.Matches(node2)) + + // Test when Group is different + node1 = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}} + node2 = &node.Node{Type: node.Input, Group: node.OpenIDConnectGroup, Attributes: &node.InputAttributes{Name: "foo"}} + assert.False(t, node1.Matches(node2)) + + // Test when all fields are the same + node1 = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}} + node2 = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}} + assert.True(t, node1.Matches(node2)) +} + +func TestRemoveMatchingNodes(t *testing.T) { + nodes := node.Nodes{ + &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "foo"}}, + &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "bar"}}, + &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "baz"}}, + } + + // Test when node to remove is present + nodeToRemove := &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "bar"}} + nodes.RemoveMatching(nodeToRemove) + assert.Len(t, nodes, 2) + for _, n := range nodes { + assert.NotEqual(t, nodeToRemove.ID(), n.ID()) + } + + // Test when node to remove is not present + nodeToRemove = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "qux"}} + nodes.RemoveMatching(nodeToRemove) + assert.Len(t, nodes, 2) // length should remain the same + + // Test when node to remove is present + nodeToRemove = &node.Node{Type: node.Input, Group: node.PasswordGroup, Attributes: &node.InputAttributes{Name: "baz"}} + ui := &container.Container{ + Nodes: nodes, + } + + ui.GetNodes().RemoveMatching(nodeToRemove) + assert.Len(t, *ui.GetNodes(), 1) + for _, n := range *ui.GetNodes() { + assert.NotEqual(t, "bar", n.ID()) + assert.NotEqual(t, "baz", n.ID()) + } + + ui.Nodes.Append(node.NewInputField("method", "foo", "bar", node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelContinue())) + assert.NotNil(t, ui.Nodes.Find("method")) + ui.GetNodes().RemoveMatching(node.NewInputField("method", "foo", "bar", node.InputAttributeTypeSubmit)) + assert.Nil(t, ui.Nodes.Find("method")) +} diff --git a/x/webauthnx/js/trigger.go b/x/webauthnx/js/trigger.go new file mode 100644 index 000000000000..7b236191ce8e --- /dev/null +++ b/x/webauthnx/js/trigger.go @@ -0,0 +1,22 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package js + +import "fmt" + +// swagger:enum WebAuthnTriggers +type WebAuthnTriggers string + +const ( + WebAuthnTriggersWebAuthnRegistration WebAuthnTriggers = "oryWebAuthnRegistration" + WebAuthnTriggersWebAuthnLogin WebAuthnTriggers = "oryWebAuthnLogin" + WebAuthnTriggersPasskeyLogin WebAuthnTriggers = "oryPasskeyLogin" + WebAuthnTriggersPasskeyLoginAutocompleteInit WebAuthnTriggers = "oryPasskeyLoginAutocompleteInit" + WebAuthnTriggersPasskeyRegistration WebAuthnTriggers = "oryPasskeyRegistration" + WebAuthnTriggersPasskeySettingsRegistration WebAuthnTriggers = "oryPasskeySettingsRegistration" +) + +func (r WebAuthnTriggers) String() string { + return fmt.Sprintf("window.%s", string(r)) +} diff --git a/x/webauthnx/js/trigger_test.go b/x/webauthnx/js/trigger_test.go new file mode 100644 index 000000000000..97f9dc00ee77 --- /dev/null +++ b/x/webauthnx/js/trigger_test.go @@ -0,0 +1,14 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package js + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestToString(t *testing.T) { + assert.Equal(t, "window.oryWebAuthnRegistration", WebAuthnTriggersWebAuthnRegistration.String()) +} diff --git a/x/webauthnx/js/webauthn.js b/x/webauthnx/js/webauthn.js index 61a7cb8f976d..638bd4ece082 100644 --- a/x/webauthnx/js/webauthn.js +++ b/x/webauthnx/js/webauthn.js @@ -32,7 +32,7 @@ } function __oryWebAuthnLogin( - opt, + options, resultQuerySelector = '*[name="webauthn_login"]', triggerQuerySelector = '*[name="webauthn_login_trigger"]', ) { @@ -40,6 +40,12 @@ alert("This browser does not support WebAuthn!") } + const triggerEl = document.querySelector(triggerQuerySelector) + let opt = options + if (!opt) { + opt = JSON.parse(triggerEl.value) + } + opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge) opt.publicKey.allowCredentials = opt.publicKey.allowCredentials.map( function (value) { @@ -71,7 +77,7 @@ }, }) - document.querySelector(triggerQuerySelector).closest("form").submit() + triggerEl.closest("form").submit() }) .catch((err) => { alert(err) @@ -79,7 +85,7 @@ } function __oryWebAuthnRegistration( - opt, + options, resultQuerySelector = '*[name="webauthn_register"]', triggerQuerySelector = '*[name="webauthn_register_trigger"]', ) { @@ -87,6 +93,12 @@ alert("This browser does not support WebAuthn!") } + const triggerEl = document.querySelector(triggerQuerySelector) + let opt = options + if (!opt) { + opt = JSON.parse(triggerEl.value) + } + opt.publicKey.user.id = __oryWebAuthnBufferDecode(opt.publicKey.user.id) opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge) @@ -118,14 +130,14 @@ }, }) - document.querySelector(triggerQuerySelector).closest("form").submit() + triggerEl.closest("form").submit() }) .catch((err) => { alert(err) }) } - window.__oryPasskeyLoginAutocompleteInit = async function () { + async function __oryPasskeyLoginAutocompleteInit () { const dataEl = document.getElementsByName("passkey_challenge")[0] const resultEl = document.getElementsByName("passkey_login")[0] const identifierEl = document.getElementsByName("identifier")[0] @@ -195,7 +207,7 @@ }) } - window.__oryPasskeyLogin = function () { + function __oryPasskeyLogin () { const dataEl = document.getElementsByName("passkey_challenge")[0] const resultEl = document.getElementsByName("passkey_login")[0] @@ -262,7 +274,7 @@ }) } - window.__oryPasskeyRegistration = function () { + function __oryPasskeyRegistration () { const dataEl = document.getElementsByName("passkey_create_data")[0] const resultEl = document.getElementsByName("passkey_register")[0] @@ -373,8 +385,21 @@ }) } + // Deprecated naming with underscores - kept for support with Ory Elements v0 window.__oryWebAuthnLogin = __oryWebAuthnLogin window.__oryWebAuthnRegistration = __oryWebAuthnRegistration window.__oryPasskeySettingsRegistration = __oryPasskeySettingsRegistration + window.__oryPasskeyLogin = __oryPasskeyLogin + window.__oryPasskeyRegistration = __oryPasskeyRegistration + window.__oryPasskeyLoginAutocompleteInit = __oryPasskeyLoginAutocompleteInit + + // Current naming - use with Ory Elements v1 + window.oryWebAuthnLogin = __oryWebAuthnLogin + window.oryWebAuthnRegistration = __oryWebAuthnRegistration + window.oryPasskeySettingsRegistration = __oryPasskeySettingsRegistration + window.oryPasskeyLogin = __oryPasskeyLogin + window.oryPasskeyRegistration = __oryPasskeyRegistration + window.oryPasskeyLoginAutocompleteInit = __oryPasskeyLoginAutocompleteInit + window.__oryWebAuthnInitialized = true })() diff --git a/x/webauthnx/nodes.go b/x/webauthnx/nodes.go index 76fac1c397cd..309f49f80e9d 100644 --- a/x/webauthnx/nodes.go +++ b/x/webauthnx/nodes.go @@ -10,6 +10,8 @@ import ( "fmt" "net/url" + "github.com/ory/kratos/x/webauthnx/js" + "github.com/ory/x/stringsx" "github.com/ory/x/urlx" @@ -21,7 +23,10 @@ import ( func NewWebAuthnConnectionTrigger(options string) *node.Node { return node.NewInputField(node.WebAuthnRegisterTrigger, "", node.WebAuthnGroup, node.InputAttributeTypeButton, node.WithInputAttributes(func(a *node.InputAttributes) { - a.OnClick = "window.__oryWebAuthnRegistration(" + options + ")" + //nolint:staticcheck + a.OnClick = fmt.Sprintf("%s(%s)", js.WebAuthnTriggersWebAuthnRegistration, options) + a.OnClickTrigger = js.WebAuthnTriggersWebAuthnRegistration + a.FieldValue = options })) } @@ -44,7 +49,10 @@ func NewWebAuthnConnectionInput() *node.Node { func NewWebAuthnLoginTrigger(options string) *node.Node { return node.NewInputField(node.WebAuthnLoginTrigger, "", node.WebAuthnGroup, node.InputAttributeTypeButton, node.WithInputAttributes(func(a *node.InputAttributes) { - a.OnClick = "window.__oryWebAuthnLogin(" + options + ")" + //nolint:staticcheck + a.OnClick = fmt.Sprintf("%s(%s)", js.WebAuthnTriggersWebAuthnLogin, options) + a.FieldValue = options + a.OnClickTrigger = js.WebAuthnTriggersWebAuthnLogin })) }