From bc0f0d0025d610103916c6e951698a0d634f6261 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Tue, 22 Aug 2023 13:50:13 +0200 Subject: [PATCH 01/25] feat: add support for recovery on native flows Co-authored-by: Jonas Hungershausen --- .schema/openapi/patches/schema.yaml | 2 + .../kratos/email-password/kratos.yml | 1 + internal/client-go/.openapi-generator/FILES | 4 + internal/client-go/README.md | 4 +- internal/client-go/api_frontend.go | 12 +- internal/client-go/model_continue_with.go | 30 +++ ...del_continue_with_set_ory_session_token.go | 2 +- .../model_continue_with_settings_ui.go | 137 +++++++++++ .../model_continue_with_settings_ui_flow.go | 108 +++++++++ .../model_continue_with_verification_ui.go | 2 +- internal/client-go/model_recovery_flow.go | 38 ++- internal/httpclient/.openapi-generator/FILES | 4 + internal/httpclient/README.md | 4 +- internal/httpclient/api_frontend.go | 12 +- internal/httpclient/model_continue_with.go | 30 +++ ...del_continue_with_set_ory_session_token.go | 2 +- .../model_continue_with_settings_ui.go | 137 +++++++++++ .../model_continue_with_settings_ui_flow.go | 108 +++++++++ .../model_continue_with_verification_ui.go | 2 +- internal/httpclient/model_recovery_flow.go | 38 ++- selfservice/flow/continue_with.go | 34 +++ selfservice/flow/recovery/flow.go | 2 + selfservice/flow/recovery/handler.go | 20 +- selfservice/strategy/code/strategy.go | 1 + .../strategy/code/strategy_recovery.go | 218 ++---------------- .../strategy/code/strategy_recovery_admin.go | 216 +++++++++++++++++ .../code/strategy_recovery_admin_test.go | 204 ++++++++++++++++ .../strategy/code/strategy_recovery_test.go | 218 ++---------------- spec/api.json | 66 +++++- spec/swagger.json | 62 ++++- test/e2e/package-lock.json | 123 +++++++++- test/e2e/package.json | 6 + test/e2e/playwright.config.ts | 13 +- test/e2e/playwright/actions/mail.ts | 24 ++ test/e2e/playwright/fixtures/index.ts | 57 +++++ test/e2e/playwright/kratos.base-config.json | 2 +- test/e2e/playwright/lib/helper.ts | 20 ++ test/e2e/playwright/setup/default_config.ts | 3 +- .../e2e/playwright/tests/app_recovery.spec.ts | 51 ++++ 39 files changed, 1565 insertions(+), 452 deletions(-) create mode 100644 internal/client-go/model_continue_with_settings_ui.go create mode 100644 internal/client-go/model_continue_with_settings_ui_flow.go create mode 100644 internal/httpclient/model_continue_with_settings_ui.go create mode 100644 internal/httpclient/model_continue_with_settings_ui_flow.go create mode 100644 selfservice/strategy/code/strategy_recovery_admin.go create mode 100644 selfservice/strategy/code/strategy_recovery_admin_test.go create mode 100644 test/e2e/playwright/actions/mail.ts create mode 100644 test/e2e/playwright/fixtures/index.ts create mode 100644 test/e2e/playwright/lib/helper.ts create mode 100644 test/e2e/playwright/tests/app_recovery.spec.ts diff --git a/.schema/openapi/patches/schema.yaml b/.schema/openapi/patches/schema.yaml index bae28a0872db..4797e4fa61bf 100644 --- a/.schema/openapi/patches/schema.yaml +++ b/.schema/openapi/patches/schema.yaml @@ -41,9 +41,11 @@ mapping: show_verification_ui: "#/components/schemas/continueWithVerificationUi" set_ory_session_token: "#/components/schemas/continueWithSetOrySessionToken" + show_settings_ui: "#/components/schemas/continueWithSettingsUi" - op: add path: /components/schemas/continueWith/oneOf value: - "$ref": "#/components/schemas/continueWithVerificationUi" - "$ref": "#/components/schemas/continueWithSetOrySessionToken" + - "$ref": "#/components/schemas/continueWithSettingsUi" diff --git a/contrib/quickstart/kratos/email-password/kratos.yml b/contrib/quickstart/kratos/email-password/kratos.yml index 4ff9fb805917..0c18cb00b31d 100644 --- a/contrib/quickstart/kratos/email-password/kratos.yml +++ b/contrib/quickstart/kratos/email-password/kratos.yml @@ -14,6 +14,7 @@ selfservice: default_browser_return_url: http://127.0.0.1:4455/ allowed_return_urls: - http://127.0.0.1:4455 + - http://localhost:4457/Callback methods: password: diff --git a/internal/client-go/.openapi-generator/FILES b/internal/client-go/.openapi-generator/FILES index f7968b85c90e..1c96366dc98f 100644 --- a/internal/client-go/.openapi-generator/FILES +++ b/internal/client-go/.openapi-generator/FILES @@ -12,6 +12,8 @@ docs/AuthenticatorAssuranceLevel.md docs/BatchPatchIdentitiesResponse.md docs/ContinueWith.md docs/ContinueWithSetOrySessionToken.md +docs/ContinueWithSettingsUi.md +docs/ContinueWithSettingsUiFlow.md docs/ContinueWithVerificationUi.md docs/ContinueWithVerificationUiFlow.md docs/CourierApi.md @@ -125,6 +127,8 @@ model_authenticator_assurance_level.go model_batch_patch_identities_response.go model_continue_with.go model_continue_with_set_ory_session_token.go +model_continue_with_settings_ui.go +model_continue_with_settings_ui_flow.go model_continue_with_verification_ui.go model_continue_with_verification_ui_flow.go model_courier_message_status.go diff --git a/internal/client-go/README.md b/internal/client-go/README.md index cb48b260e91f..3436adecf422 100644 --- a/internal/client-go/README.md +++ b/internal/client-go/README.md @@ -107,7 +107,7 @@ Class | Method | HTTP request | Description *FrontendApi* | [**ToSession**](docs/FrontendApi.md#tosession) | **Get** /sessions/whoami | Check Who the Current HTTP Session Belongs To *FrontendApi* | [**UpdateLoginFlow**](docs/FrontendApi.md#updateloginflow) | **Post** /self-service/login | Submit a Login Flow *FrontendApi* | [**UpdateLogoutFlow**](docs/FrontendApi.md#updatelogoutflow) | **Get** /self-service/logout | Update Logout Flow -*FrontendApi* | [**UpdateRecoveryFlow**](docs/FrontendApi.md#updaterecoveryflow) | **Post** /self-service/recovery | Complete Recovery Flow +*FrontendApi* | [**UpdateRecoveryFlow**](docs/FrontendApi.md#updaterecoveryflow) | **Post** /self-service/recovery | Update Recovery Flow *FrontendApi* | [**UpdateRegistrationFlow**](docs/FrontendApi.md#updateregistrationflow) | **Post** /self-service/registration | Update Registration Flow *FrontendApi* | [**UpdateSettingsFlow**](docs/FrontendApi.md#updatesettingsflow) | **Post** /self-service/settings | Complete Settings Flow *FrontendApi* | [**UpdateVerificationFlow**](docs/FrontendApi.md#updateverificationflow) | **Post** /self-service/verification | Complete Verification Flow @@ -140,6 +140,8 @@ Class | Method | HTTP request | Description - [BatchPatchIdentitiesResponse](docs/BatchPatchIdentitiesResponse.md) - [ContinueWith](docs/ContinueWith.md) - [ContinueWithSetOrySessionToken](docs/ContinueWithSetOrySessionToken.md) + - [ContinueWithSettingsUi](docs/ContinueWithSettingsUi.md) + - [ContinueWithSettingsUiFlow](docs/ContinueWithSettingsUiFlow.md) - [ContinueWithVerificationUi](docs/ContinueWithVerificationUi.md) - [ContinueWithVerificationUiFlow](docs/ContinueWithVerificationUiFlow.md) - [CourierMessageStatus](docs/CourierMessageStatus.md) diff --git a/internal/client-go/api_frontend.go b/internal/client-go/api_frontend.go index ac8cadd1a4b3..bc2bf65b7e6e 100644 --- a/internal/client-go/api_frontend.go +++ b/internal/client-go/api_frontend.go @@ -240,7 +240,7 @@ type FrontendApi interface { If a valid provided session cookie or session token is provided, a 400 Bad Request error. - To fetch an existing recovery flow call `/self-service/recovery/flows?flow=`. + If you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint. You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make @@ -765,8 +765,8 @@ type FrontendApi interface { UpdateLogoutFlowExecute(r FrontendApiApiUpdateLogoutFlowRequest) (*http.Response, error) /* - * UpdateRecoveryFlow Complete Recovery Flow - * Use this endpoint to complete a recovery flow. This endpoint + * UpdateRecoveryFlow Update Recovery Flow + * Use this endpoint to update a recovery flow. This endpoint behaves differently for API and browser flows and has several states: `choose_method` expects `flow` (in the URL query) and `email` (in the body) to be sent @@ -2049,7 +2049,7 @@ func (r FrontendApiApiCreateNativeRecoveryFlowRequest) Execute() (*RecoveryFlow, If a valid provided session cookie or session token is provided, a 400 Bad Request error. -To fetch an existing recovery flow call `/self-service/recovery/flows?flow=`. +If you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint. You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make @@ -5016,8 +5016,8 @@ func (r FrontendApiApiUpdateRecoveryFlowRequest) Execute() (*RecoveryFlow, *http } /* - - UpdateRecoveryFlow Complete Recovery Flow - - Use this endpoint to complete a recovery flow. This endpoint + - UpdateRecoveryFlow Update Recovery Flow + - Use this endpoint to update a recovery flow. This endpoint behaves differently for API and browser flows and has several states: diff --git a/internal/client-go/model_continue_with.go b/internal/client-go/model_continue_with.go index 2cd5dd77542c..a1e844ca053a 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 { ContinueWithSetOrySessionToken *ContinueWithSetOrySessionToken + ContinueWithSettingsUi *ContinueWithSettingsUi ContinueWithVerificationUi *ContinueWithVerificationUi } @@ -29,6 +30,13 @@ func ContinueWithSetOrySessionTokenAsContinueWith(v *ContinueWithSetOrySessionTo } } +// ContinueWithSettingsUiAsContinueWith is a convenience function that returns ContinueWithSettingsUi wrapped in ContinueWith +func ContinueWithSettingsUiAsContinueWith(v *ContinueWithSettingsUi) ContinueWith { + return ContinueWith{ + ContinueWithSettingsUi: v, + } +} + // ContinueWithVerificationUiAsContinueWith is a convenience function that returns ContinueWithVerificationUi wrapped in ContinueWith func ContinueWithVerificationUiAsContinueWith(v *ContinueWithVerificationUi) ContinueWith { return ContinueWith{ @@ -53,6 +61,19 @@ func (dst *ContinueWith) UnmarshalJSON(data []byte) error { dst.ContinueWithSetOrySessionToken = nil } + // try to unmarshal data into ContinueWithSettingsUi + err = newStrictDecoder(data).Decode(&dst.ContinueWithSettingsUi) + if err == nil { + jsonContinueWithSettingsUi, _ := json.Marshal(dst.ContinueWithSettingsUi) + if string(jsonContinueWithSettingsUi) == "{}" { // empty struct + dst.ContinueWithSettingsUi = nil + } else { + match++ + } + } else { + dst.ContinueWithSettingsUi = nil + } + // try to unmarshal data into ContinueWithVerificationUi err = newStrictDecoder(data).Decode(&dst.ContinueWithVerificationUi) if err == nil { @@ -69,6 +90,7 @@ func (dst *ContinueWith) UnmarshalJSON(data []byte) error { if match > 1 { // more than 1 match // reset to nil dst.ContinueWithSetOrySessionToken = nil + dst.ContinueWithSettingsUi = nil dst.ContinueWithVerificationUi = nil return fmt.Errorf("Data matches more than one schema in oneOf(ContinueWith)") @@ -85,6 +107,10 @@ func (src ContinueWith) MarshalJSON() ([]byte, error) { return json.Marshal(&src.ContinueWithSetOrySessionToken) } + if src.ContinueWithSettingsUi != nil { + return json.Marshal(&src.ContinueWithSettingsUi) + } + if src.ContinueWithVerificationUi != nil { return json.Marshal(&src.ContinueWithVerificationUi) } @@ -101,6 +127,10 @@ func (obj *ContinueWith) GetActualInstance() interface{} { return obj.ContinueWithSetOrySessionToken } + if obj.ContinueWithSettingsUi != nil { + return obj.ContinueWithSettingsUi + } + if obj.ContinueWithVerificationUi != nil { return obj.ContinueWithVerificationUi } diff --git a/internal/client-go/model_continue_with_set_ory_session_token.go b/internal/client-go/model_continue_with_set_ory_session_token.go index 641718339f88..3f7179d6e7b8 100644 --- a/internal/client-go/model_continue_with_set_ory_session_token.go +++ b/internal/client-go/model_continue_with_set_ory_session_token.go @@ -17,7 +17,7 @@ import ( // ContinueWithSetOrySessionToken Indicates that a session was issued, and the application should use this token for authenticated requests type ContinueWithSetOrySessionToken struct { - // Action will always be `set_ory_session_token` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI + // Action will always be `set_ory_session_token` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI show_settings_ui ContinueWithActionShowSettingsUI Action string `json:"action"` // Token is the token of the session OrySessionToken string `json:"ory_session_token"` diff --git a/internal/client-go/model_continue_with_settings_ui.go b/internal/client-go/model_continue_with_settings_ui.go new file mode 100644 index 000000000000..5b161e013522 --- /dev/null +++ b/internal/client-go/model_continue_with_settings_ui.go @@ -0,0 +1,137 @@ +/* + * 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" +) + +// ContinueWithSettingsUi Indicates, that the UI flow could be continued by showing a settings ui +type ContinueWithSettingsUi struct { + // Action will always be `show_settings_ui` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI show_settings_ui ContinueWithActionShowSettingsUI + Action string `json:"action"` + Flow ContinueWithSettingsUiFlow `json:"flow"` +} + +// NewContinueWithSettingsUi instantiates a new ContinueWithSettingsUi 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 NewContinueWithSettingsUi(action string, flow ContinueWithSettingsUiFlow) *ContinueWithSettingsUi { + this := ContinueWithSettingsUi{} + this.Action = action + this.Flow = flow + return &this +} + +// NewContinueWithSettingsUiWithDefaults instantiates a new ContinueWithSettingsUi 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 NewContinueWithSettingsUiWithDefaults() *ContinueWithSettingsUi { + this := ContinueWithSettingsUi{} + return &this +} + +// GetAction returns the Action field value +func (o *ContinueWithSettingsUi) 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 *ContinueWithSettingsUi) GetActionOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Action, true +} + +// SetAction sets field value +func (o *ContinueWithSettingsUi) SetAction(v string) { + o.Action = v +} + +// GetFlow returns the Flow field value +func (o *ContinueWithSettingsUi) GetFlow() ContinueWithSettingsUiFlow { + if o == nil { + var ret ContinueWithSettingsUiFlow + return ret + } + + return o.Flow +} + +// GetFlowOk returns a tuple with the Flow field value +// and a boolean to check if the value has been set. +func (o *ContinueWithSettingsUi) GetFlowOk() (*ContinueWithSettingsUiFlow, bool) { + if o == nil { + return nil, false + } + return &o.Flow, true +} + +// SetFlow sets field value +func (o *ContinueWithSettingsUi) SetFlow(v ContinueWithSettingsUiFlow) { + o.Flow = v +} + +func (o ContinueWithSettingsUi) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if true { + toSerialize["action"] = o.Action + } + if true { + toSerialize["flow"] = o.Flow + } + return json.Marshal(toSerialize) +} + +type NullableContinueWithSettingsUi struct { + value *ContinueWithSettingsUi + isSet bool +} + +func (v NullableContinueWithSettingsUi) Get() *ContinueWithSettingsUi { + return v.value +} + +func (v *NullableContinueWithSettingsUi) Set(val *ContinueWithSettingsUi) { + v.value = val + v.isSet = true +} + +func (v NullableContinueWithSettingsUi) IsSet() bool { + return v.isSet +} + +func (v *NullableContinueWithSettingsUi) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableContinueWithSettingsUi(val *ContinueWithSettingsUi) *NullableContinueWithSettingsUi { + return &NullableContinueWithSettingsUi{value: val, isSet: true} +} + +func (v NullableContinueWithSettingsUi) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableContinueWithSettingsUi) 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 new file mode 100644 index 000000000000..4ccaf74ef1b8 --- /dev/null +++ b/internal/client-go/model_continue_with_settings_ui_flow.go @@ -0,0 +1,108 @@ +/* + * 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" +) + +// ContinueWithSettingsUiFlow struct for ContinueWithSettingsUiFlow +type ContinueWithSettingsUiFlow struct { + // The ID of the settings flow + Id string `json:"id"` +} + +// NewContinueWithSettingsUiFlow instantiates a new ContinueWithSettingsUiFlow 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 NewContinueWithSettingsUiFlow(id string) *ContinueWithSettingsUiFlow { + this := ContinueWithSettingsUiFlow{} + this.Id = id + return &this +} + +// NewContinueWithSettingsUiFlowWithDefaults instantiates a new ContinueWithSettingsUiFlow 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 NewContinueWithSettingsUiFlowWithDefaults() *ContinueWithSettingsUiFlow { + this := ContinueWithSettingsUiFlow{} + return &this +} + +// GetId returns the Id field value +func (o *ContinueWithSettingsUiFlow) GetId() string { + if o == nil { + var ret string + return ret + } + + return o.Id +} + +// GetIdOk returns a tuple with the Id field value +// and a boolean to check if the value has been set. +func (o *ContinueWithSettingsUiFlow) GetIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Id, true +} + +// SetId sets field value +func (o *ContinueWithSettingsUiFlow) SetId(v string) { + o.Id = v +} + +func (o ContinueWithSettingsUiFlow) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if true { + toSerialize["id"] = o.Id + } + return json.Marshal(toSerialize) +} + +type NullableContinueWithSettingsUiFlow struct { + value *ContinueWithSettingsUiFlow + isSet bool +} + +func (v NullableContinueWithSettingsUiFlow) Get() *ContinueWithSettingsUiFlow { + return v.value +} + +func (v *NullableContinueWithSettingsUiFlow) Set(val *ContinueWithSettingsUiFlow) { + v.value = val + v.isSet = true +} + +func (v NullableContinueWithSettingsUiFlow) IsSet() bool { + return v.isSet +} + +func (v *NullableContinueWithSettingsUiFlow) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableContinueWithSettingsUiFlow(val *ContinueWithSettingsUiFlow) *NullableContinueWithSettingsUiFlow { + return &NullableContinueWithSettingsUiFlow{value: val, isSet: true} +} + +func (v NullableContinueWithSettingsUiFlow) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableContinueWithSettingsUiFlow) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/internal/client-go/model_continue_with_verification_ui.go b/internal/client-go/model_continue_with_verification_ui.go index 987c32d18f2e..2aeaea48fbf4 100644 --- a/internal/client-go/model_continue_with_verification_ui.go +++ b/internal/client-go/model_continue_with_verification_ui.go @@ -17,7 +17,7 @@ import ( // ContinueWithVerificationUi Indicates, that the UI flow could be continued by showing a verification ui type ContinueWithVerificationUi struct { - // Action will always be `show_verification_ui` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI + // Action will always be `show_verification_ui` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI show_settings_ui ContinueWithActionShowSettingsUI Action string `json:"action"` Flow ContinueWithVerificationUiFlow `json:"flow"` } diff --git a/internal/client-go/model_recovery_flow.go b/internal/client-go/model_recovery_flow.go index 6ae19ebd60e6..f7a0cd2d0988 100644 --- a/internal/client-go/model_recovery_flow.go +++ b/internal/client-go/model_recovery_flow.go @@ -19,7 +19,8 @@ import ( // RecoveryFlow This request is used when an identity wants to recover their account. We recommend reading the [Account Recovery Documentation](../self-service/flows/password-reset-account-recovery) type RecoveryFlow struct { // Active, if set, contains the recovery method that is being used. It is initially not set. - Active *string `json:"active,omitempty"` + Active *string `json:"active,omitempty"` + ContinueWith []ContinueWith `json:"continue_with,omitempty"` // ExpiresAt is the time (UTC) when the request expires. If the user still wishes to update the setting, a new request has to be initiated. ExpiresAt time.Time `json:"expires_at"` // ID represents the request's unique ID. When performing the recovery flow, this represents the id in the recovery ui's query parameter: http://?request= @@ -92,6 +93,38 @@ func (o *RecoveryFlow) SetActive(v string) { o.Active = &v } +// GetContinueWith returns the ContinueWith field value if set, zero value otherwise. +func (o *RecoveryFlow) GetContinueWith() []ContinueWith { + if o == nil || o.ContinueWith == nil { + var ret []ContinueWith + return ret + } + return o.ContinueWith +} + +// GetContinueWithOk returns a tuple with the ContinueWith field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RecoveryFlow) GetContinueWithOk() ([]ContinueWith, bool) { + if o == nil || o.ContinueWith == nil { + return nil, false + } + return o.ContinueWith, true +} + +// HasContinueWith returns a boolean if a field has been set. +func (o *RecoveryFlow) HasContinueWith() bool { + if o != nil && o.ContinueWith != nil { + return true + } + + return false +} + +// SetContinueWith gets a reference to the given []ContinueWith and assigns it to the ContinueWith field. +func (o *RecoveryFlow) SetContinueWith(v []ContinueWith) { + o.ContinueWith = v +} + // GetExpiresAt returns the ExpiresAt field value func (o *RecoveryFlow) GetExpiresAt() time.Time { if o == nil { @@ -297,6 +330,9 @@ func (o RecoveryFlow) MarshalJSON() ([]byte, error) { if o.Active != nil { toSerialize["active"] = o.Active } + if o.ContinueWith != nil { + toSerialize["continue_with"] = o.ContinueWith + } if true { toSerialize["expires_at"] = o.ExpiresAt } diff --git a/internal/httpclient/.openapi-generator/FILES b/internal/httpclient/.openapi-generator/FILES index af0c731e5f92..90e76171fd37 100644 --- a/internal/httpclient/.openapi-generator/FILES +++ b/internal/httpclient/.openapi-generator/FILES @@ -13,6 +13,8 @@ docs/AuthenticatorAssuranceLevel.md docs/BatchPatchIdentitiesResponse.md docs/ContinueWith.md docs/ContinueWithSetOrySessionToken.md +docs/ContinueWithSettingsUi.md +docs/ContinueWithSettingsUiFlow.md docs/ContinueWithVerificationUi.md docs/ContinueWithVerificationUiFlow.md docs/CourierApi.md @@ -126,6 +128,8 @@ model_authenticator_assurance_level.go model_batch_patch_identities_response.go model_continue_with.go model_continue_with_set_ory_session_token.go +model_continue_with_settings_ui.go +model_continue_with_settings_ui_flow.go model_continue_with_verification_ui.go model_continue_with_verification_ui_flow.go model_courier_message_status.go diff --git a/internal/httpclient/README.md b/internal/httpclient/README.md index cb48b260e91f..3436adecf422 100644 --- a/internal/httpclient/README.md +++ b/internal/httpclient/README.md @@ -107,7 +107,7 @@ Class | Method | HTTP request | Description *FrontendApi* | [**ToSession**](docs/FrontendApi.md#tosession) | **Get** /sessions/whoami | Check Who the Current HTTP Session Belongs To *FrontendApi* | [**UpdateLoginFlow**](docs/FrontendApi.md#updateloginflow) | **Post** /self-service/login | Submit a Login Flow *FrontendApi* | [**UpdateLogoutFlow**](docs/FrontendApi.md#updatelogoutflow) | **Get** /self-service/logout | Update Logout Flow -*FrontendApi* | [**UpdateRecoveryFlow**](docs/FrontendApi.md#updaterecoveryflow) | **Post** /self-service/recovery | Complete Recovery Flow +*FrontendApi* | [**UpdateRecoveryFlow**](docs/FrontendApi.md#updaterecoveryflow) | **Post** /self-service/recovery | Update Recovery Flow *FrontendApi* | [**UpdateRegistrationFlow**](docs/FrontendApi.md#updateregistrationflow) | **Post** /self-service/registration | Update Registration Flow *FrontendApi* | [**UpdateSettingsFlow**](docs/FrontendApi.md#updatesettingsflow) | **Post** /self-service/settings | Complete Settings Flow *FrontendApi* | [**UpdateVerificationFlow**](docs/FrontendApi.md#updateverificationflow) | **Post** /self-service/verification | Complete Verification Flow @@ -140,6 +140,8 @@ Class | Method | HTTP request | Description - [BatchPatchIdentitiesResponse](docs/BatchPatchIdentitiesResponse.md) - [ContinueWith](docs/ContinueWith.md) - [ContinueWithSetOrySessionToken](docs/ContinueWithSetOrySessionToken.md) + - [ContinueWithSettingsUi](docs/ContinueWithSettingsUi.md) + - [ContinueWithSettingsUiFlow](docs/ContinueWithSettingsUiFlow.md) - [ContinueWithVerificationUi](docs/ContinueWithVerificationUi.md) - [ContinueWithVerificationUiFlow](docs/ContinueWithVerificationUiFlow.md) - [CourierMessageStatus](docs/CourierMessageStatus.md) diff --git a/internal/httpclient/api_frontend.go b/internal/httpclient/api_frontend.go index ac8cadd1a4b3..bc2bf65b7e6e 100644 --- a/internal/httpclient/api_frontend.go +++ b/internal/httpclient/api_frontend.go @@ -240,7 +240,7 @@ type FrontendApi interface { If a valid provided session cookie or session token is provided, a 400 Bad Request error. - To fetch an existing recovery flow call `/self-service/recovery/flows?flow=`. + If you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint. You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make @@ -765,8 +765,8 @@ type FrontendApi interface { UpdateLogoutFlowExecute(r FrontendApiApiUpdateLogoutFlowRequest) (*http.Response, error) /* - * UpdateRecoveryFlow Complete Recovery Flow - * Use this endpoint to complete a recovery flow. This endpoint + * UpdateRecoveryFlow Update Recovery Flow + * Use this endpoint to update a recovery flow. This endpoint behaves differently for API and browser flows and has several states: `choose_method` expects `flow` (in the URL query) and `email` (in the body) to be sent @@ -2049,7 +2049,7 @@ func (r FrontendApiApiCreateNativeRecoveryFlowRequest) Execute() (*RecoveryFlow, If a valid provided session cookie or session token is provided, a 400 Bad Request error. -To fetch an existing recovery flow call `/self-service/recovery/flows?flow=`. +If you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint. You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make @@ -5016,8 +5016,8 @@ func (r FrontendApiApiUpdateRecoveryFlowRequest) Execute() (*RecoveryFlow, *http } /* - - UpdateRecoveryFlow Complete Recovery Flow - - Use this endpoint to complete a recovery flow. This endpoint + - UpdateRecoveryFlow Update Recovery Flow + - Use this endpoint to update a recovery flow. This endpoint behaves differently for API and browser flows and has several states: diff --git a/internal/httpclient/model_continue_with.go b/internal/httpclient/model_continue_with.go index 2cd5dd77542c..a1e844ca053a 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 { ContinueWithSetOrySessionToken *ContinueWithSetOrySessionToken + ContinueWithSettingsUi *ContinueWithSettingsUi ContinueWithVerificationUi *ContinueWithVerificationUi } @@ -29,6 +30,13 @@ func ContinueWithSetOrySessionTokenAsContinueWith(v *ContinueWithSetOrySessionTo } } +// ContinueWithSettingsUiAsContinueWith is a convenience function that returns ContinueWithSettingsUi wrapped in ContinueWith +func ContinueWithSettingsUiAsContinueWith(v *ContinueWithSettingsUi) ContinueWith { + return ContinueWith{ + ContinueWithSettingsUi: v, + } +} + // ContinueWithVerificationUiAsContinueWith is a convenience function that returns ContinueWithVerificationUi wrapped in ContinueWith func ContinueWithVerificationUiAsContinueWith(v *ContinueWithVerificationUi) ContinueWith { return ContinueWith{ @@ -53,6 +61,19 @@ func (dst *ContinueWith) UnmarshalJSON(data []byte) error { dst.ContinueWithSetOrySessionToken = nil } + // try to unmarshal data into ContinueWithSettingsUi + err = newStrictDecoder(data).Decode(&dst.ContinueWithSettingsUi) + if err == nil { + jsonContinueWithSettingsUi, _ := json.Marshal(dst.ContinueWithSettingsUi) + if string(jsonContinueWithSettingsUi) == "{}" { // empty struct + dst.ContinueWithSettingsUi = nil + } else { + match++ + } + } else { + dst.ContinueWithSettingsUi = nil + } + // try to unmarshal data into ContinueWithVerificationUi err = newStrictDecoder(data).Decode(&dst.ContinueWithVerificationUi) if err == nil { @@ -69,6 +90,7 @@ func (dst *ContinueWith) UnmarshalJSON(data []byte) error { if match > 1 { // more than 1 match // reset to nil dst.ContinueWithSetOrySessionToken = nil + dst.ContinueWithSettingsUi = nil dst.ContinueWithVerificationUi = nil return fmt.Errorf("Data matches more than one schema in oneOf(ContinueWith)") @@ -85,6 +107,10 @@ func (src ContinueWith) MarshalJSON() ([]byte, error) { return json.Marshal(&src.ContinueWithSetOrySessionToken) } + if src.ContinueWithSettingsUi != nil { + return json.Marshal(&src.ContinueWithSettingsUi) + } + if src.ContinueWithVerificationUi != nil { return json.Marshal(&src.ContinueWithVerificationUi) } @@ -101,6 +127,10 @@ func (obj *ContinueWith) GetActualInstance() interface{} { return obj.ContinueWithSetOrySessionToken } + if obj.ContinueWithSettingsUi != nil { + return obj.ContinueWithSettingsUi + } + if obj.ContinueWithVerificationUi != nil { return obj.ContinueWithVerificationUi } diff --git a/internal/httpclient/model_continue_with_set_ory_session_token.go b/internal/httpclient/model_continue_with_set_ory_session_token.go index 641718339f88..3f7179d6e7b8 100644 --- a/internal/httpclient/model_continue_with_set_ory_session_token.go +++ b/internal/httpclient/model_continue_with_set_ory_session_token.go @@ -17,7 +17,7 @@ import ( // ContinueWithSetOrySessionToken Indicates that a session was issued, and the application should use this token for authenticated requests type ContinueWithSetOrySessionToken struct { - // Action will always be `set_ory_session_token` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI + // Action will always be `set_ory_session_token` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI show_settings_ui ContinueWithActionShowSettingsUI Action string `json:"action"` // Token is the token of the session OrySessionToken string `json:"ory_session_token"` diff --git a/internal/httpclient/model_continue_with_settings_ui.go b/internal/httpclient/model_continue_with_settings_ui.go new file mode 100644 index 000000000000..5b161e013522 --- /dev/null +++ b/internal/httpclient/model_continue_with_settings_ui.go @@ -0,0 +1,137 @@ +/* + * 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" +) + +// ContinueWithSettingsUi Indicates, that the UI flow could be continued by showing a settings ui +type ContinueWithSettingsUi struct { + // Action will always be `show_settings_ui` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI show_settings_ui ContinueWithActionShowSettingsUI + Action string `json:"action"` + Flow ContinueWithSettingsUiFlow `json:"flow"` +} + +// NewContinueWithSettingsUi instantiates a new ContinueWithSettingsUi 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 NewContinueWithSettingsUi(action string, flow ContinueWithSettingsUiFlow) *ContinueWithSettingsUi { + this := ContinueWithSettingsUi{} + this.Action = action + this.Flow = flow + return &this +} + +// NewContinueWithSettingsUiWithDefaults instantiates a new ContinueWithSettingsUi 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 NewContinueWithSettingsUiWithDefaults() *ContinueWithSettingsUi { + this := ContinueWithSettingsUi{} + return &this +} + +// GetAction returns the Action field value +func (o *ContinueWithSettingsUi) 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 *ContinueWithSettingsUi) GetActionOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Action, true +} + +// SetAction sets field value +func (o *ContinueWithSettingsUi) SetAction(v string) { + o.Action = v +} + +// GetFlow returns the Flow field value +func (o *ContinueWithSettingsUi) GetFlow() ContinueWithSettingsUiFlow { + if o == nil { + var ret ContinueWithSettingsUiFlow + return ret + } + + return o.Flow +} + +// GetFlowOk returns a tuple with the Flow field value +// and a boolean to check if the value has been set. +func (o *ContinueWithSettingsUi) GetFlowOk() (*ContinueWithSettingsUiFlow, bool) { + if o == nil { + return nil, false + } + return &o.Flow, true +} + +// SetFlow sets field value +func (o *ContinueWithSettingsUi) SetFlow(v ContinueWithSettingsUiFlow) { + o.Flow = v +} + +func (o ContinueWithSettingsUi) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if true { + toSerialize["action"] = o.Action + } + if true { + toSerialize["flow"] = o.Flow + } + return json.Marshal(toSerialize) +} + +type NullableContinueWithSettingsUi struct { + value *ContinueWithSettingsUi + isSet bool +} + +func (v NullableContinueWithSettingsUi) Get() *ContinueWithSettingsUi { + return v.value +} + +func (v *NullableContinueWithSettingsUi) Set(val *ContinueWithSettingsUi) { + v.value = val + v.isSet = true +} + +func (v NullableContinueWithSettingsUi) IsSet() bool { + return v.isSet +} + +func (v *NullableContinueWithSettingsUi) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableContinueWithSettingsUi(val *ContinueWithSettingsUi) *NullableContinueWithSettingsUi { + return &NullableContinueWithSettingsUi{value: val, isSet: true} +} + +func (v NullableContinueWithSettingsUi) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableContinueWithSettingsUi) 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 new file mode 100644 index 000000000000..4ccaf74ef1b8 --- /dev/null +++ b/internal/httpclient/model_continue_with_settings_ui_flow.go @@ -0,0 +1,108 @@ +/* + * 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" +) + +// ContinueWithSettingsUiFlow struct for ContinueWithSettingsUiFlow +type ContinueWithSettingsUiFlow struct { + // The ID of the settings flow + Id string `json:"id"` +} + +// NewContinueWithSettingsUiFlow instantiates a new ContinueWithSettingsUiFlow 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 NewContinueWithSettingsUiFlow(id string) *ContinueWithSettingsUiFlow { + this := ContinueWithSettingsUiFlow{} + this.Id = id + return &this +} + +// NewContinueWithSettingsUiFlowWithDefaults instantiates a new ContinueWithSettingsUiFlow 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 NewContinueWithSettingsUiFlowWithDefaults() *ContinueWithSettingsUiFlow { + this := ContinueWithSettingsUiFlow{} + return &this +} + +// GetId returns the Id field value +func (o *ContinueWithSettingsUiFlow) GetId() string { + if o == nil { + var ret string + return ret + } + + return o.Id +} + +// GetIdOk returns a tuple with the Id field value +// and a boolean to check if the value has been set. +func (o *ContinueWithSettingsUiFlow) GetIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Id, true +} + +// SetId sets field value +func (o *ContinueWithSettingsUiFlow) SetId(v string) { + o.Id = v +} + +func (o ContinueWithSettingsUiFlow) MarshalJSON() ([]byte, error) { + toSerialize := map[string]interface{}{} + if true { + toSerialize["id"] = o.Id + } + return json.Marshal(toSerialize) +} + +type NullableContinueWithSettingsUiFlow struct { + value *ContinueWithSettingsUiFlow + isSet bool +} + +func (v NullableContinueWithSettingsUiFlow) Get() *ContinueWithSettingsUiFlow { + return v.value +} + +func (v *NullableContinueWithSettingsUiFlow) Set(val *ContinueWithSettingsUiFlow) { + v.value = val + v.isSet = true +} + +func (v NullableContinueWithSettingsUiFlow) IsSet() bool { + return v.isSet +} + +func (v *NullableContinueWithSettingsUiFlow) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableContinueWithSettingsUiFlow(val *ContinueWithSettingsUiFlow) *NullableContinueWithSettingsUiFlow { + return &NullableContinueWithSettingsUiFlow{value: val, isSet: true} +} + +func (v NullableContinueWithSettingsUiFlow) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableContinueWithSettingsUiFlow) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/internal/httpclient/model_continue_with_verification_ui.go b/internal/httpclient/model_continue_with_verification_ui.go index 987c32d18f2e..2aeaea48fbf4 100644 --- a/internal/httpclient/model_continue_with_verification_ui.go +++ b/internal/httpclient/model_continue_with_verification_ui.go @@ -17,7 +17,7 @@ import ( // ContinueWithVerificationUi Indicates, that the UI flow could be continued by showing a verification ui type ContinueWithVerificationUi struct { - // Action will always be `show_verification_ui` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI + // Action will always be `show_verification_ui` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI show_settings_ui ContinueWithActionShowSettingsUI Action string `json:"action"` Flow ContinueWithVerificationUiFlow `json:"flow"` } diff --git a/internal/httpclient/model_recovery_flow.go b/internal/httpclient/model_recovery_flow.go index 6ae19ebd60e6..f7a0cd2d0988 100644 --- a/internal/httpclient/model_recovery_flow.go +++ b/internal/httpclient/model_recovery_flow.go @@ -19,7 +19,8 @@ import ( // RecoveryFlow This request is used when an identity wants to recover their account. We recommend reading the [Account Recovery Documentation](../self-service/flows/password-reset-account-recovery) type RecoveryFlow struct { // Active, if set, contains the recovery method that is being used. It is initially not set. - Active *string `json:"active,omitempty"` + Active *string `json:"active,omitempty"` + ContinueWith []ContinueWith `json:"continue_with,omitempty"` // ExpiresAt is the time (UTC) when the request expires. If the user still wishes to update the setting, a new request has to be initiated. ExpiresAt time.Time `json:"expires_at"` // ID represents the request's unique ID. When performing the recovery flow, this represents the id in the recovery ui's query parameter: http://?request= @@ -92,6 +93,38 @@ func (o *RecoveryFlow) SetActive(v string) { o.Active = &v } +// GetContinueWith returns the ContinueWith field value if set, zero value otherwise. +func (o *RecoveryFlow) GetContinueWith() []ContinueWith { + if o == nil || o.ContinueWith == nil { + var ret []ContinueWith + return ret + } + return o.ContinueWith +} + +// GetContinueWithOk returns a tuple with the ContinueWith field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RecoveryFlow) GetContinueWithOk() ([]ContinueWith, bool) { + if o == nil || o.ContinueWith == nil { + return nil, false + } + return o.ContinueWith, true +} + +// HasContinueWith returns a boolean if a field has been set. +func (o *RecoveryFlow) HasContinueWith() bool { + if o != nil && o.ContinueWith != nil { + return true + } + + return false +} + +// SetContinueWith gets a reference to the given []ContinueWith and assigns it to the ContinueWith field. +func (o *RecoveryFlow) SetContinueWith(v []ContinueWith) { + o.ContinueWith = v +} + // GetExpiresAt returns the ExpiresAt field value func (o *RecoveryFlow) GetExpiresAt() time.Time { if o == nil { @@ -297,6 +330,9 @@ func (o RecoveryFlow) MarshalJSON() ([]byte, error) { if o.Active != nil { toSerialize["active"] = o.Active } + if o.ContinueWith != nil { + toSerialize["continue_with"] = o.ContinueWith + } if true { toSerialize["expires_at"] = o.ExpiresAt } diff --git a/selfservice/flow/continue_with.go b/selfservice/flow/continue_with.go index 0b22b8b8ecb3..4b82f984a866 100644 --- a/selfservice/flow/continue_with.go +++ b/selfservice/flow/continue_with.go @@ -21,6 +21,7 @@ type ContinueWithAction string const ( ContinueWithActionSetOrySessionToken ContinueWithAction = "set_ory_session_token" ContinueWithActionShowVerificationUI ContinueWithAction = "show_verification_ui" + ContinueWithActionShowSettingsUI ContinueWithAction = "show_settings_ui" ) var _ ContinueWith = new(ContinueWithSetToken) @@ -106,3 +107,36 @@ type FlowWithContinueWith interface { AddContinueWith(ContinueWith) ContinueWith() []ContinueWith } + +var _ ContinueWith = new(ContinueWithSettingsUI) + +// Indicates, that the UI flow could be continued by showing a settings ui +// +// swagger:model continueWithSettingsUi +type ContinueWithSettingsUI struct { + // Action will always be `show_settings_ui` + // + // required: true + Action ContinueWithAction `json:"action"` + // Flow contains the ID of the verification flow + // + // required: true + Flow ContinueWithSettingsUIFlow `json:"flow"` +} + +// swagger:model continueWithSettingsUiFlow +type ContinueWithSettingsUIFlow struct { + // The ID of the settings flow + // + // required: true + ID uuid.UUID `json:"id"` +} + +func NewContinueWithSettingsUI(f Flow) *ContinueWithSettingsUI { + return &ContinueWithSettingsUI{ + Action: ContinueWithActionShowSettingsUI, + Flow: ContinueWithSettingsUIFlow{ + ID: f.GetID(), + }, + } +} diff --git a/selfservice/flow/recovery/flow.go b/selfservice/flow/recovery/flow.go index 42770783fb42..92aa18cb7300 100644 --- a/selfservice/flow/recovery/flow.go +++ b/selfservice/flow/recovery/flow.go @@ -100,6 +100,8 @@ type Flow struct { // This is needed, because we can not enforce these measures, if the flow has been initialized by someone else than // the user. DangerousSkipCSRFCheck bool `json:"-" faker:"-" db:"skip_csrf_check"` + + ContinueWith []flow.ContinueWith `json:"continue_with,omitempty" faker:"-" db:"-"` } func NewFlow(conf *config.Config, exp time.Duration, csrf string, r *http.Request, strategy Strategy, ft flow.Type) (*Flow, error) { diff --git a/selfservice/flow/recovery/handler.go b/selfservice/flow/recovery/handler.go index 24aed7695bba..64477d58349a 100644 --- a/selfservice/flow/recovery/handler.go +++ b/selfservice/flow/recovery/handler.go @@ -103,7 +103,7 @@ func (h *Handler) RegisterAdminRoutes(admin *x.RouterAdmin) { // // If a valid provided session cookie or session token is provided, a 400 Bad Request error. // -// To fetch an existing recovery flow call `/self-service/recovery/flows?flow=`. +// If you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint. // // You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server // Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make @@ -116,9 +116,9 @@ func (h *Handler) RegisterAdminRoutes(admin *x.RouterAdmin) { // Schemes: http, https // // Responses: -// 200: recoveryFlow -// 400: errorGeneric -// default: errorGeneric +// 200: recoveryFlow +// 400: errorGeneric +// default: errorGeneric func (h *Handler) createNativeRecoveryFlow(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { if !h.d.Config().SelfServiceFlowRecoveryEnabled(r.Context()) { h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Recovery is not allowed because it was disabled."))) @@ -130,23 +130,23 @@ func (h *Handler) createNativeRecoveryFlow(w http.ResponseWriter, r *http.Reques return } - req, err := NewFlow(h.d.Config(), h.d.Config().SelfServiceFlowRecoveryRequestLifespan(r.Context()), h.d.GenerateCSRFToken(r), r, activeRecoveryStrategy, flow.TypeAPI) + f, err := NewFlow(h.d.Config(), h.d.Config().SelfServiceFlowRecoveryRequestLifespan(r.Context()), h.d.GenerateCSRFToken(r), r, activeRecoveryStrategy, flow.TypeAPI) if err != nil { h.d.Writer().WriteError(w, r, err) return } - if err := h.d.RecoveryExecutor().PreRecoveryHook(w, r, req); err != nil { + if err := h.d.RecoveryExecutor().PreRecoveryHook(w, r, f); err != nil { h.d.Writer().WriteError(w, r, err) return } - if err := h.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), req); err != nil { + if err := h.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), f); err != nil { h.d.Writer().WriteError(w, r, err) return } - h.d.Writer().Write(w, r, req) + h.d.Writer().Write(w, r, f) } // Create Browser Recovery Flow Parameters @@ -365,9 +365,9 @@ type updateRecoveryFlowBody struct{} // swagger:route POST /self-service/recovery frontend updateRecoveryFlow // -// # Complete Recovery Flow +// # Update Recovery Flow // -// Use this endpoint to complete a recovery flow. This endpoint +// Use this endpoint to update a recovery flow. This endpoint // behaves differently for API and browser flows and has several states: // // - `choose_method` expects `flow` (in the URL query) and `email` (in the body) to be sent diff --git a/selfservice/strategy/code/strategy.go b/selfservice/strategy/code/strategy.go index b35d8bda30ff..90c97d7a7111 100644 --- a/selfservice/strategy/code/strategy.go +++ b/selfservice/strategy/code/strategy.go @@ -70,6 +70,7 @@ type ( SenderProvider schema.IdentityTraitsProvider + session.PersistenceProvider } Strategy struct { diff --git a/selfservice/strategy/code/strategy_recovery.go b/selfservice/strategy/code/strategy_recovery.go index d06c5e2c9009..cd504c53016e 100644 --- a/selfservice/strategy/code/strategy_recovery.go +++ b/selfservice/strategy/code/strategy_recovery.go @@ -9,12 +9,9 @@ import ( "time" "github.com/gofrs/uuid" - "github.com/julienschmidt/httprouter" "github.com/pkg/errors" - "github.com/ory/herodot" "github.com/ory/x/decoderx" - "github.com/ory/x/sqlcon" "github.com/ory/x/sqlxx" "github.com/ory/x/urlx" @@ -22,7 +19,6 @@ import ( "github.com/ory/kratos/schema" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/recovery" - "github.com/ory/kratos/selfservice/strategy" "github.com/ory/kratos/session" "github.com/ory/kratos/text" "github.com/ory/kratos/ui/container" @@ -30,24 +26,10 @@ import ( "github.com/ory/kratos/x" ) -const ( - RouteAdminCreateRecoveryCode = "/recovery/code" -) - func (s *Strategy) RecoveryStrategyID() string { return string(recovery.RecoveryStrategyCode) } -func (s *Strategy) RegisterPublicRecoveryRoutes(public *x.RouterPublic) { - s.deps.CSRFHandler().IgnorePath(RouteAdminCreateRecoveryCode) - public.POST(RouteAdminCreateRecoveryCode, x.RedirectToAdminRoute(s.deps)) -} - -func (s *Strategy) RegisterAdminRecoveryRoutes(admin *x.RouterAdmin) { - wrappedCreateRecoveryCode := strategy.IsDisabled(s.deps, s.RecoveryStrategyID(), s.createRecoveryCodeForIdentity) - admin.POST(RouteAdminCreateRecoveryCode, wrappedCreateRecoveryCode) -} - func (s *Strategy) PopulateRecoveryMethod(r *http.Request, f *recovery.Flow) error { f.UI.SetCSRF(s.deps.GenerateCSRFToken(r)) f.UI.GetNodes().Upsert( @@ -62,182 +44,6 @@ func (s *Strategy) PopulateRecoveryMethod(r *http.Request, f *recovery.Flow) err return nil } -// Create Recovery Code for Identity Parameters -// -// swagger:parameters createRecoveryCodeForIdentity -// -//nolint:deadcode,unused -//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions -type createRecoveryCodeForIdentity struct { - // in: body - Body createRecoveryCodeForIdentityBody -} - -// Create Recovery Code for Identity Request Body -// -// swagger:model createRecoveryCodeForIdentityBody -type createRecoveryCodeForIdentityBody struct { - // Identity to Recover - // - // The identity's ID you wish to recover. - // - // required: true - IdentityID uuid.UUID `json:"identity_id"` - - // Code Expires In - // - // The recovery code will expire after that amount of time has passed. Defaults to the configuration value of - // `selfservice.methods.code.config.lifespan`. - // - // - // pattern: ^([0-9]+(ns|us|ms|s|m|h))*$ - // example: - // - 1h - // - 1m - // - 1s - ExpiresIn string `json:"expires_in"` -} - -// Recovery Code for Identity -// -// Used when an administrator creates a recovery code for an identity. -// -// swagger:model recoveryCodeForIdentity -// -//nolint:deadcode,unused -//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions -type recoveryCodeForIdentity struct { - // RecoveryLink with flow - // - // This link opens the recovery UI with an empty `code` field. - // - // required: true - // format: uri - RecoveryLink string `json:"recovery_link"` - - // RecoveryCode is the code that can be used to recover the account - // - // required: true - RecoveryCode string `json:"recovery_code"` - - // Expires At is the timestamp of when the recovery flow expires - // - // The timestamp when the recovery link expires. - ExpiresAt time.Time `json:"expires_at"` -} - -// swagger:route POST /admin/recovery/code identity createRecoveryCodeForIdentity -// -// # Create a Recovery Code -// -// This endpoint creates a recovery code which should be given to the user in order for them to recover -// (or activate) their account. -// -// Consumes: -// - application/json -// -// Produces: -// - application/json -// -// Schemes: http, https -// -// Security: -// oryAccessToken: -// -// Responses: -// 201: recoveryCodeForIdentity -// 400: errorGeneric -// 404: errorGeneric -// default: errorGeneric -func (s *Strategy) createRecoveryCodeForIdentity(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - var p createRecoveryCodeForIdentityBody - if err := s.dx.Decode(r, &p, decoderx.HTTPJSONDecoder()); err != nil { - s.deps.Writer().WriteError(w, r, err) - return - } - - ctx := r.Context() - config := s.deps.Config() - - expiresIn := config.SelfServiceCodeMethodLifespan(ctx) - if len(p.ExpiresIn) > 0 { - // If an expiration of the code was supplied use it instead of the default duration - var err error - expiresIn, err = time.ParseDuration(p.ExpiresIn) - if err != nil { - s.deps.Writer().WriteError(w, r, errors.WithStack(herodot. - ErrBadRequest. - WithReasonf(`Unable to parse "expires_in" whose format should match "[0-9]+(ns|us|ms|s|m|h)" but did not: %s`, p.ExpiresIn))) - return - } - } - - if time.Now().Add(expiresIn).Before(time.Now()) { - s.deps.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Value from "expires_in" must result to a future time: %s`, p.ExpiresIn))) - return - } - - flow, err := recovery.NewFlow(config, expiresIn, s.deps.GenerateCSRFToken(r), r, s, flow.TypeBrowser) - if err != nil { - s.deps.Writer().WriteError(w, r, err) - return - } - flow.DangerousSkipCSRFCheck = true - flow.State = recovery.StateEmailSent - flow.UI.Nodes = node.Nodes{} - flow.UI.Nodes.Append(node.NewInputField("code", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). - WithMetaLabel(text.NewInfoNodeLabelRecoveryCode()), - ) - - flow.UI.Nodes. - Append(node.NewInputField("method", s.RecoveryStrategyID(), node.CodeGroup, node.InputAttributeTypeSubmit). - WithMetaLabel(text.NewInfoNodeLabelSubmit())) - - if err := s.deps.RecoveryFlowPersister().CreateRecoveryFlow(ctx, flow); err != nil { - s.deps.Writer().WriteError(w, r, err) - return - } - - id, err := s.deps.IdentityPool().GetIdentity(ctx, p.IdentityID, identity.ExpandDefault) - if notFoundErr := sqlcon.ErrNoRows; errors.As(err, ¬FoundErr) { - s.deps.Writer().WriteError(w, r, notFoundErr.WithReasonf("could not find identity")) - return - } else if err != nil { - s.deps.Writer().WriteError(w, r, err) - return - } - - rawCode := GenerateCode() - - if _, err := s.deps.RecoveryCodePersister().CreateRecoveryCode(ctx, &CreateRecoveryCodeParams{ - RawCode: rawCode, - CodeType: RecoveryCodeTypeAdmin, - ExpiresIn: expiresIn, - FlowID: flow.ID, - IdentityID: id.ID, - }); err != nil { - s.deps.Writer().WriteError(w, r, err) - return - } - - s.deps.Audit(). - WithField("identity_id", id.ID). - WithSensitiveField("recovery_code", rawCode). - Info("A recovery code has been created.") - - body := &recoveryCodeForIdentity{ - ExpiresAt: flow.ExpiresAt.UTC(), - RecoveryLink: urlx.CopyWithQuery( - s.deps.Config().SelfServiceFlowRecoveryUI(ctx), - url.Values{ - "flow": {flow.ID.String()}, - }).String(), - RecoveryCode: rawCode, - } - - s.deps.Writer().WriteCode(w, r, http.StatusCreated, body, herodot.UnescapedHTML) -} - // Update Recovery Flow with Code Method // // swagger:model updateRecoveryFlowWithCodeMethod @@ -377,9 +183,17 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, return s.retryRecoveryFlowWithError(w, r, f.Type, err) } - // TODO: How does this work with Mobile? - if err := s.deps.SessionManager().UpsertAndIssueCookie(ctx, w, r, sess); err != nil { - return s.retryRecoveryFlowWithError(w, r, f.Type, err) + switch { + case f.Type == flow.TypeBrowser: + // TODO: How does this work with Mobile? + if err := s.deps.SessionManager().UpsertAndIssueCookie(ctx, w, r, sess); err != nil { + return s.retryRecoveryFlowWithError(w, r, f.Type, err) + } + case f.Type == flow.TypeAPI: + if err := s.deps.SessionPersister().UpsertSession(r.Context(), sess); err != nil { + return s.retryRecoveryFlowWithError(w, r, f.Type, err) + } + f.ContinueWith = append(f.ContinueWith, flow.NewContinueWithSetToken(sess.Token)) } sf, err := s.deps.SettingsHandler().NewFlow(w, r, sess.Identity, f.Type) @@ -394,7 +208,7 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, } sf.RequestURL, err = x.TakeOverReturnToParameter(f.RequestURL, sf.RequestURL, returnTo) if err != nil { - return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err) + return s.retryRecoveryFlowWithError(w, r, f.Type, err) } config := s.deps.Config() @@ -404,9 +218,13 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, return s.retryRecoveryFlowWithError(w, r, f.Type, err) } - if x.IsJSONRequest(r) { + switch { + case f.Type.IsAPI(): + f.ContinueWith = append(f.ContinueWith, flow.NewContinueWithSettingsUI(sf)) + s.deps.Writer().Write(w, r, f) + case x.IsJSONRequest(r): s.deps.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String())) - } else { + default: http.Redirect(w, r, sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String(), http.StatusSeeOther) } diff --git a/selfservice/strategy/code/strategy_recovery_admin.go b/selfservice/strategy/code/strategy_recovery_admin.go new file mode 100644 index 000000000000..a88b8b14b5b0 --- /dev/null +++ b/selfservice/strategy/code/strategy_recovery_admin.go @@ -0,0 +1,216 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package code + +import ( + "net/http" + "net/url" + "time" + + "github.com/gofrs/uuid" + "github.com/julienschmidt/httprouter" + "github.com/pkg/errors" + + "github.com/ory/herodot" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/recovery" + "github.com/ory/kratos/selfservice/strategy" + "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" + "github.com/ory/x/urlx" +) + +const ( + RouteAdminCreateRecoveryCode = "/recovery/code" +) + +func (s *Strategy) RegisterPublicRecoveryRoutes(public *x.RouterPublic) { + s.deps.CSRFHandler().IgnorePath(RouteAdminCreateRecoveryCode) + public.POST(RouteAdminCreateRecoveryCode, x.RedirectToAdminRoute(s.deps)) +} + +func (s *Strategy) RegisterAdminRecoveryRoutes(admin *x.RouterAdmin) { + wrappedCreateRecoveryCode := strategy.IsDisabled(s.deps, s.RecoveryStrategyID(), s.createRecoveryCodeForIdentity) + admin.POST(RouteAdminCreateRecoveryCode, wrappedCreateRecoveryCode) +} + +// Create Recovery Code for Identity Parameters +// +// swagger:parameters createRecoveryCodeForIdentity +// +//nolint:deadcode,unused +//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions +type createRecoveryCodeForIdentity struct { + // in: body + Body createRecoveryCodeForIdentityBody +} + +// Create Recovery Code for Identity Request Body +// +// swagger:model createRecoveryCodeForIdentityBody +type createRecoveryCodeForIdentityBody struct { + // Identity to Recover + // + // The identity's ID you wish to recover. + // + // required: true + IdentityID uuid.UUID `json:"identity_id"` + + // Code Expires In + // + // The recovery code will expire after that amount of time has passed. Defaults to the configuration value of + // `selfservice.methods.code.config.lifespan`. + // + // + // pattern: ^([0-9]+(ns|us|ms|s|m|h))*$ + // example: + // - 1h + // - 1m + // - 1s + ExpiresIn string `json:"expires_in"` +} + +// Recovery Code for Identity +// +// Used when an administrator creates a recovery code for an identity. +// +// swagger:model recoveryCodeForIdentity +// +//nolint:deadcode,unused +//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions +type recoveryCodeForIdentity struct { + // RecoveryLink with flow + // + // This link opens the recovery UI with an empty `code` field. + // + // required: true + // format: uri + RecoveryLink string `json:"recovery_link"` + + // RecoveryCode is the code that can be used to recover the account + // + // required: true + RecoveryCode string `json:"recovery_code"` + + // Expires At is the timestamp of when the recovery flow expires + // + // The timestamp when the recovery link expires. + ExpiresAt time.Time `json:"expires_at"` +} + +// swagger:route POST /admin/recovery/code identity createRecoveryCodeForIdentity +// +// # Create a Recovery Code +// +// This endpoint creates a recovery code which should be given to the user in order for them to recover +// (or activate) their account. +// +// Consumes: +// - application/json +// +// Produces: +// - application/json +// +// Schemes: http, https +// +// Security: +// oryAccessToken: +// +// Responses: +// 201: recoveryCodeForIdentity +// 400: errorGeneric +// 404: errorGeneric +// default: errorGeneric +func (s *Strategy) createRecoveryCodeForIdentity(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + var p createRecoveryCodeForIdentityBody + if err := s.dx.Decode(r, &p, decoderx.HTTPJSONDecoder()); err != nil { + s.deps.Writer().WriteError(w, r, err) + return + } + + ctx := r.Context() + config := s.deps.Config() + + expiresIn := config.SelfServiceCodeMethodLifespan(ctx) + if len(p.ExpiresIn) > 0 { + // If an expiration of the code was supplied use it instead of the default duration + var err error + expiresIn, err = time.ParseDuration(p.ExpiresIn) + if err != nil { + s.deps.Writer().WriteError(w, r, errors.WithStack(herodot. + ErrBadRequest. + WithReasonf(`Unable to parse "expires_in" whose format should match "[0-9]+(ns|us|ms|s|m|h)" but did not: %s`, p.ExpiresIn))) + return + } + } + + if time.Now().Add(expiresIn).Before(time.Now()) { + s.deps.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Value from "expires_in" must result to a future time: %s`, p.ExpiresIn))) + return + } + + recoveryFlow, err := recovery.NewFlow(config, expiresIn, s.deps.GenerateCSRFToken(r), r, s, flow.TypeBrowser) + if err != nil { + s.deps.Writer().WriteError(w, r, err) + return + } + recoveryFlow.DangerousSkipCSRFCheck = true + recoveryFlow.State = recovery.StateEmailSent + recoveryFlow.UI.Nodes = node.Nodes{} + recoveryFlow.UI.Nodes.Append(node.NewInputField("code", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). + WithMetaLabel(text.NewInfoNodeLabelRecoveryCode()), + ) + + recoveryFlow.UI.Nodes. + Append(node.NewInputField("method", s.RecoveryStrategyID(), node.CodeGroup, node.InputAttributeTypeSubmit). + WithMetaLabel(text.NewInfoNodeLabelSubmit())) + + if err := s.deps.RecoveryFlowPersister().CreateRecoveryFlow(ctx, recoveryFlow); err != nil { + s.deps.Writer().WriteError(w, r, err) + return + } + + id, err := s.deps.IdentityPool().GetIdentity(ctx, p.IdentityID, identity.ExpandDefault) + if notFoundErr := sqlcon.ErrNoRows; errors.As(err, ¬FoundErr) { + s.deps.Writer().WriteError(w, r, notFoundErr.WithReasonf("could not find identity")) + return + } else if err != nil { + s.deps.Writer().WriteError(w, r, err) + return + } + + rawCode := GenerateCode() + + if _, err := s.deps.RecoveryCodePersister().CreateRecoveryCode(ctx, &CreateRecoveryCodeParams{ + RawCode: rawCode, + CodeType: RecoveryCodeTypeAdmin, + ExpiresIn: expiresIn, + FlowID: recoveryFlow.ID, + IdentityID: id.ID, + }); err != nil { + s.deps.Writer().WriteError(w, r, err) + return + } + + s.deps.Audit(). + WithField("identity_id", id.ID). + WithSensitiveField("recovery_code", rawCode). + Info("A recovery code has been created.") + + body := &recoveryCodeForIdentity{ + ExpiresAt: recoveryFlow.ExpiresAt.UTC(), + RecoveryLink: urlx.CopyWithQuery( + s.deps.Config().SelfServiceFlowRecoveryUI(ctx), + url.Values{ + "flow": {recoveryFlow.ID.String()}, + }).String(), + RecoveryCode: rawCode, + } + + s.deps.Writer().WriteCode(w, r, http.StatusCreated, body, herodot.UnescapedHTML) +} diff --git a/selfservice/strategy/code/strategy_recovery_admin_test.go b/selfservice/strategy/code/strategy_recovery_admin_test.go new file mode 100644 index 000000000000..aed7bbcbaf43 --- /dev/null +++ b/selfservice/strategy/code/strategy_recovery_admin_test.go @@ -0,0 +1,204 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package code_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" + kratos "github.com/ory/kratos/internal/httpclient" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/recovery" + "github.com/ory/kratos/selfservice/strategy/code" + "github.com/ory/kratos/x" + "github.com/ory/x/ioutilx" + "github.com/ory/x/pointerx" + "github.com/ory/x/snapshotx" +) + +func TestAdminStrategy(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + initViper(t, ctx, conf) + + _ = testhelpers.NewRecoveryUIFlowEchoServer(t, reg) + _ = testhelpers.NewSettingsUIFlowEchoServer(t, reg) + _ = testhelpers.NewLoginUIFlowEchoServer(t, reg) + _ = testhelpers.NewErrorTestServer(t, reg) + + publicTS, adminTS := testhelpers.NewKratosServer(t, reg) + adminSDK := testhelpers.NewSDKClient(adminTS) + + createCode := func(id string, expiresIn *string) (*kratos.RecoveryCodeForIdentity, *http.Response, error) { + return adminSDK.IdentityApi. + CreateRecoveryCodeForIdentity(context.Background()). + CreateRecoveryCodeForIdentityBody( + kratos.CreateRecoveryCodeForIdentityBody{ + IdentityId: id, + ExpiresIn: expiresIn, + }).Execute() + } + + t.Run("no panic on empty body #1384", func(t *testing.T) { + ctx := context.Background() + s, err := reg.RecoveryStrategies(ctx).Strategy("code") + require.NoError(t, err) + w := httptest.NewRecorder() + r := &http.Request{URL: new(url.URL)} + f, err := recovery.NewFlow(reg.Config(), time.Minute, "", r, s, flow.TypeBrowser) + require.NoError(t, err) + require.NotPanics(t, func() { + require.Error(t, s.(*code.Strategy).HandleRecoveryError(w, r, f, nil, errors.New("test"))) + }) + }) + + t.Run("description=should not be able to recover an account that does not exist", func(t *testing.T) { + _, _, err := createCode(x.NewUUID().String(), nil) + + require.IsType(t, err, new(kratos.GenericOpenAPIError), "%T", err) + snapshotx.SnapshotT(t, err.(*kratos.GenericOpenAPIError).Model()) + }) + + t.Run("description=should fail on malformed expiry time", func(t *testing.T) { + _, _, err := createCode(x.NewUUID().String(), pointerx.String("not-a-valid-value")) + require.IsType(t, err, new(kratos.GenericOpenAPIError), "%T", err) + snapshotx.SnapshotT(t, err.(*kratos.GenericOpenAPIError).Model()) + }) + + t.Run("description=should fail on negative expiry time", func(t *testing.T) { + _, _, err := createCode(x.NewUUID().String(), pointerx.String("-1h")) + require.IsType(t, err, new(kratos.GenericOpenAPIError), "%T", err) + snapshotx.SnapshotT(t, err.(*kratos.GenericOpenAPIError).Model()) + }) + + submitRecoveryLink := func(t *testing.T, link string, code string) []byte { + t.Helper() + res, err := publicTS.Client().Get(link) + require.NoError(t, err) + body := ioutilx.MustReadAll(res.Body) + + action := gjson.GetBytes(body, "ui.action").String() + require.NotEmpty(t, action) + + res, err = publicTS.Client().PostForm(action, url.Values{ + "code": {code}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + + return ioutilx.MustReadAll(res.Body) + } + + t.Run("description=should create code without email", func(t *testing.T) { + id := identity.Identity{Traits: identity.Traits(`{}`)} + + require.NoError(t, reg.IdentityManager().Create(context.Background(), + &id, identity.ManagerAllowWriteProtectedTraits)) + + code, _, err := createCode(id.ID.String(), nil) + require.NoError(t, err) + + require.NotEmpty(t, code.RecoveryLink) + require.Contains(t, code.RecoveryLink, "flow=") + require.NotContains(t, code.RecoveryLink, "code=") + require.NotEmpty(t, code.RecoveryCode) + require.True(t, code.ExpiresAt.Before(time.Now().Add(conf.SelfServiceFlowRecoveryRequestLifespan(ctx)))) + + body := submitRecoveryLink(t, code.RecoveryLink, code.RecoveryCode) + testhelpers.AssertMessage(t, body, "You successfully recovered your account. Please change your password or set up an alternative login method (e.g. social sign in) within the next 60.00 minutes.") + }) + + t.Run("description=should not be able to recover with expired code", func(t *testing.T) { + recoveryEmail := "recover.expired@ory.sh" + id := identity.Identity{Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, recoveryEmail))} + + require.NoError(t, reg.IdentityManager().Create(context.Background(), + &id, identity.ManagerAllowWriteProtectedTraits)) + + code, _, err := createCode(id.ID.String(), pointerx.String("100ms")) + require.NoError(t, err) + + time.Sleep(time.Millisecond * 100) + require.NotEmpty(t, code.RecoveryLink) + require.True(t, code.ExpiresAt.Before(time.Now().Add(conf.SelfServiceFlowRecoveryRequestLifespan(ctx)))) + + body := submitRecoveryLink(t, code.RecoveryLink, code.RecoveryCode) + testhelpers.AssertMessage(t, body, "The recovery flow expired 0.00 minutes ago, please try again.") + + // The recovery address should not be verified if the flow was initiated by the admins + addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) + assert.NoError(t, err) + assert.False(t, addr.Verified) + assert.Nil(t, addr.VerifiedAt) + assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status) + }) + + t.Run("description=should create a valid recovery link and set the expiry time as well and recover the account", func(t *testing.T) { + recoveryEmail := "recoverme@ory.sh" + id := identity.Identity{Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, recoveryEmail))} + + require.NoError(t, reg.IdentityManager().Create(context.Background(), + &id, identity.ManagerAllowWriteProtectedTraits)) + + code, _, err := createCode(id.ID.String(), nil) + require.NoError(t, err) + + require.NotEmpty(t, code.RecoveryLink) + require.True(t, code.ExpiresAt.Before(time.Now().Add(conf.SelfServiceFlowRecoveryRequestLifespan(ctx)+time.Second))) + + body := submitRecoveryLink(t, code.RecoveryLink, code.RecoveryCode) + + testhelpers.AssertMessage(t, body, "You successfully recovered your account. Please change your password or set up an alternative login method (e.g. social sign in) within the next 60.00 minutes.") + + addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) + assert.NoError(t, err) + assert.False(t, addr.Verified) + assert.Nil(t, addr.VerifiedAt) + assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status) + }) + + t.Run("case=should not be able to use code from different flow", func(t *testing.T) { + email := testhelpers.RandomEmail() + i := createIdentityToRecover(t, reg, email) + + c1, _, err := createCode(i.ID.String(), pointerx.String("1h")) + require.NoError(t, err) + c2, _, err := createCode(i.ID.String(), pointerx.String("1h")) + require.NoError(t, err) + code2 := c2.RecoveryCode + require.NotEmpty(t, code2) + + body := submitRecoveryLink(t, c1.RecoveryLink, c2.RecoveryCode) + + testhelpers.AssertMessage(t, body, "The recovery code is invalid or has already been used. Please try again.") + }) + + t.Run("case=form should not contain email field when creating recovery code", func(t *testing.T) { + email := testhelpers.RandomEmail() + i := createIdentityToRecover(t, reg, email) + + c1, _, err := createCode(i.ID.String(), pointerx.String("1h")) + require.NoError(t, err) + + res, err := http.Get(c1.RecoveryLink) + require.NoError(t, err) + body := ioutilx.MustReadAll(res.Body) + + snapshotx.SnapshotT(t, json.RawMessage(gjson.GetBytes(body, "ui.nodes").String())) + }) +} diff --git a/selfservice/strategy/code/strategy_recovery_test.go b/selfservice/strategy/code/strategy_recovery_test.go index 2adbf97213d4..b9a13c42ca47 100644 --- a/selfservice/strategy/code/strategy_recovery_test.go +++ b/selfservice/strategy/code/strategy_recovery_test.go @@ -17,39 +17,27 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/gofrs/uuid" - errors "github.com/pkg/errors" - - "github.com/ory/kratos/driver" - "github.com/ory/kratos/session" - - "github.com/ory/kratos/ui/node" - - kratos "github.com/ory/kratos/internal/httpclient" - - "github.com/ory/kratos/corpx" - - "github.com/ory/x/ioutilx" - "github.com/ory/x/pointerx" - "github.com/ory/x/snapshotx" - "github.com/ory/x/urlx" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" - "github.com/ory/x/sqlxx" - - "github.com/ory/x/assertx" - + "github.com/ory/kratos/corpx" + "github.com/ory/kratos/driver" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/internal" + kratos "github.com/ory/kratos/internal/httpclient" "github.com/ory/kratos/internal/testhelpers" - "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/recovery" "github.com/ory/kratos/selfservice/strategy/code" + "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/assertx" + "github.com/ory/x/ioutilx" + "github.com/ory/x/sqlxx" + "github.com/ory/x/urlx" ) func init() { @@ -60,178 +48,6 @@ func extractCsrfToken(body []byte) string { return gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() } -func TestAdminStrategy(t *testing.T) { - ctx := context.Background() - conf, reg := internal.NewFastRegistryWithMocks(t) - initViper(t, ctx, conf) - - _ = testhelpers.NewRecoveryUIFlowEchoServer(t, reg) - _ = testhelpers.NewSettingsUIFlowEchoServer(t, reg) - _ = testhelpers.NewLoginUIFlowEchoServer(t, reg) - _ = testhelpers.NewErrorTestServer(t, reg) - - publicTS, adminTS := testhelpers.NewKratosServer(t, reg) - adminSDK := testhelpers.NewSDKClient(adminTS) - - createCode := func(id string, expiresIn *string) (*kratos.RecoveryCodeForIdentity, *http.Response, error) { - return adminSDK.IdentityApi. - CreateRecoveryCodeForIdentity(context.Background()). - CreateRecoveryCodeForIdentityBody( - kratos.CreateRecoveryCodeForIdentityBody{ - IdentityId: id, - ExpiresIn: expiresIn, - }).Execute() - } - - t.Run("no panic on empty body #1384", func(t *testing.T) { - ctx := context.Background() - s, err := reg.RecoveryStrategies(ctx).Strategy("code") - require.NoError(t, err) - w := httptest.NewRecorder() - r := &http.Request{URL: new(url.URL)} - f, err := recovery.NewFlow(reg.Config(), time.Minute, "", r, s, flow.TypeBrowser) - require.NoError(t, err) - require.NotPanics(t, func() { - require.Error(t, s.(*code.Strategy).HandleRecoveryError(w, r, f, nil, errors.New("test"))) - }) - }) - - t.Run("description=should not be able to recover an account that does not exist", func(t *testing.T) { - _, _, err := createCode(x.NewUUID().String(), nil) - - require.IsType(t, err, new(kratos.GenericOpenAPIError), "%T", err) - snapshotx.SnapshotT(t, err.(*kratos.GenericOpenAPIError).Model()) - }) - - t.Run("description=should fail on malformed expiry time", func(t *testing.T) { - _, _, err := createCode(x.NewUUID().String(), pointerx.String("not-a-valid-value")) - require.IsType(t, err, new(kratos.GenericOpenAPIError), "%T", err) - snapshotx.SnapshotT(t, err.(*kratos.GenericOpenAPIError).Model()) - }) - - t.Run("description=should fail on negative expiry time", func(t *testing.T) { - _, _, err := createCode(x.NewUUID().String(), pointerx.String("-1h")) - require.IsType(t, err, new(kratos.GenericOpenAPIError), "%T", err) - snapshotx.SnapshotT(t, err.(*kratos.GenericOpenAPIError).Model()) - }) - - submitRecoveryLink := func(t *testing.T, link string, code string) []byte { - t.Helper() - res, err := publicTS.Client().Get(link) - require.NoError(t, err) - body := ioutilx.MustReadAll(res.Body) - - action := gjson.GetBytes(body, "ui.action").String() - require.NotEmpty(t, action) - - res, err = publicTS.Client().PostForm(action, url.Values{ - "code": {code}, - }) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, res.StatusCode) - - return ioutilx.MustReadAll(res.Body) - } - - t.Run("description=should create code without email", func(t *testing.T) { - id := identity.Identity{Traits: identity.Traits(`{}`)} - - require.NoError(t, reg.IdentityManager().Create(context.Background(), - &id, identity.ManagerAllowWriteProtectedTraits)) - - code, _, err := createCode(id.ID.String(), nil) - require.NoError(t, err) - - require.NotEmpty(t, code.RecoveryLink) - require.Contains(t, code.RecoveryLink, "flow=") - require.NotContains(t, code.RecoveryLink, "code=") - require.NotEmpty(t, code.RecoveryCode) - require.True(t, code.ExpiresAt.Before(time.Now().Add(conf.SelfServiceFlowRecoveryRequestLifespan(ctx)))) - - body := submitRecoveryLink(t, code.RecoveryLink, code.RecoveryCode) - testhelpers.AssertMessage(t, body, "You successfully recovered your account. Please change your password or set up an alternative login method (e.g. social sign in) within the next 60.00 minutes.") - }) - - t.Run("description=should not be able to recover with expired code", func(t *testing.T) { - recoveryEmail := "recover.expired@ory.sh" - id := identity.Identity{Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, recoveryEmail))} - - require.NoError(t, reg.IdentityManager().Create(context.Background(), - &id, identity.ManagerAllowWriteProtectedTraits)) - - code, _, err := createCode(id.ID.String(), pointerx.String("100ms")) - require.NoError(t, err) - - time.Sleep(time.Millisecond * 100) - require.NotEmpty(t, code.RecoveryLink) - require.True(t, code.ExpiresAt.Before(time.Now().Add(conf.SelfServiceFlowRecoveryRequestLifespan(ctx)))) - - body := submitRecoveryLink(t, code.RecoveryLink, code.RecoveryCode) - testhelpers.AssertMessage(t, body, "The recovery flow expired 0.00 minutes ago, please try again.") - - // The recovery address should not be verified if the flow was initiated by the admins - addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) - assert.NoError(t, err) - assert.False(t, addr.Verified) - assert.Nil(t, addr.VerifiedAt) - assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status) - }) - - t.Run("description=should create a valid recovery link and set the expiry time as well and recover the account", func(t *testing.T) { - recoveryEmail := "recoverme@ory.sh" - id := identity.Identity{Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, recoveryEmail))} - - require.NoError(t, reg.IdentityManager().Create(context.Background(), - &id, identity.ManagerAllowWriteProtectedTraits)) - - code, _, err := createCode(id.ID.String(), nil) - require.NoError(t, err) - - require.NotEmpty(t, code.RecoveryLink) - require.True(t, code.ExpiresAt.Before(time.Now().Add(conf.SelfServiceFlowRecoveryRequestLifespan(ctx)+time.Second))) - - body := submitRecoveryLink(t, code.RecoveryLink, code.RecoveryCode) - - testhelpers.AssertMessage(t, body, "You successfully recovered your account. Please change your password or set up an alternative login method (e.g. social sign in) within the next 60.00 minutes.") - - addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) - assert.NoError(t, err) - assert.False(t, addr.Verified) - assert.Nil(t, addr.VerifiedAt) - assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status) - }) - - t.Run("case=should not be able to use code from different flow", func(t *testing.T) { - email := testhelpers.RandomEmail() - i := createIdentityToRecover(t, reg, email) - - c1, _, err := createCode(i.ID.String(), pointerx.String("1h")) - require.NoError(t, err) - c2, _, err := createCode(i.ID.String(), pointerx.String("1h")) - require.NoError(t, err) - code2 := c2.RecoveryCode - require.NotEmpty(t, code2) - - body := submitRecoveryLink(t, c1.RecoveryLink, c2.RecoveryCode) - - testhelpers.AssertMessage(t, body, "The recovery code is invalid or has already been used. Please try again.") - }) - - t.Run("case=form should not contain email field when creating recovery code", func(t *testing.T) { - email := testhelpers.RandomEmail() - i := createIdentityToRecover(t, reg, email) - - c1, _, err := createCode(i.ID.String(), pointerx.String("1h")) - require.NoError(t, err) - - res, err := http.Get(c1.RecoveryLink) - require.NoError(t, err) - body := ioutilx.MustReadAll(res.Body) - - snapshotx.SnapshotT(t, json.RawMessage(gjson.GetBytes(body, "ui.nodes").String())) - }) -} - const ( RecoveryFlowTypeBrowser string = "browser" RecoveryFlowTypeSPA string = "spa" @@ -343,6 +159,7 @@ func TestRecovery(t *testing.T) { } submitRecoveryCode := func(t *testing.T, client *http.Client, flow string, flowType string, recoveryCode string, statusCode int) string { + t.Helper() action := gjson.Get(flow, "ui.action").String() assert.NotEmpty(t, action) @@ -418,13 +235,13 @@ func TestRecovery(t *testing.T) { recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, recoveryCode) - statusCode := testhelpers.ExpectStatusCode(flowType == RecoveryFlowTypeAPI || flowType == RecoveryFlowTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) + statusCode := testhelpers.ExpectStatusCode(flowType == RecoveryFlowTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) return submitRecoveryCode(t, client, recoverySubmissionResponse, flowType, recoveryCode, statusCode) } t.Run("type=browser", func(t *testing.T) { client := testhelpers.NewClientWithCookies(t) - email := "recoverme1@ory.sh" + email := testhelpers.RandomEmail() createIdentityToRecover(t, reg, email) recoverySubmissionResponse := submitRecovery(t, client, RecoveryFlowTypeBrowser, func(v url.Values) { v.Set("email", email) @@ -444,7 +261,7 @@ func TestRecovery(t *testing.T) { t.Run("type=spa", func(t *testing.T) { client := testhelpers.NewClientWithCookies(t) - email := "recoverme3@ory.sh" + email := testhelpers.RandomEmail() createIdentityToRecover(t, reg, email) recoverySubmissionResponse := submitRecovery(t, client, RecoveryFlowTypeSPA, func(v url.Values) { v.Set("email", email) @@ -456,14 +273,17 @@ func TestRecovery(t *testing.T) { t.Run("type=api", func(t *testing.T) { client := &http.Client{} - email := "recoverme4@ory.sh" + email := testhelpers.RandomEmail() createIdentityToRecover(t, reg, email) recoverySubmissionResponse := submitRecovery(t, client, RecoveryFlowTypeAPI, func(v url.Values) { v.Set("email", email) }, http.StatusOK) body := checkRecovery(t, client, RecoveryFlowTypeAPI, email, recoverySubmissionResponse) - assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) - assert.Contains(t, gjson.Get(body, "redirect_browser_to").String(), "settings-ts?") + assert.Equal(t, "passed_challenge", gjson.Get(body, "state").String()) + assert.Len(t, gjson.Get(body, "continue_with").Array(), 2) + assert.NotEmpty(t, gjson.Get(body, "continue_with.#(action==set_ory_session_token).ory_session_token").String()) + sfId := gjson.Get(body, "continue_with.#(action==show_settings_ui).flow.id").String() + assert.NotEmpty(t, uuid.Must(uuid.FromString(sfId))) }) t.Run("description=should return browser to return url", func(t *testing.T) { diff --git a/spec/api.json b/spec/api.json index d1973e17f7fd..9049e51f1da1 100755 --- a/spec/api.json +++ b/spec/api.json @@ -458,6 +458,7 @@ "discriminator": { "mapping": { "set_ory_session_token": "#/components/schemas/continueWithSetOrySessionToken", + "show_settings_ui": "#/components/schemas/continueWithSettingsUi", "show_verification_ui": "#/components/schemas/continueWithVerificationUi" }, "propertyName": "action" @@ -468,6 +469,9 @@ }, { "$ref": "#/components/schemas/continueWithSetOrySessionToken" + }, + { + "$ref": "#/components/schemas/continueWithSettingsUi" } ] }, @@ -475,13 +479,14 @@ "description": "Indicates that a session was issued, and the application should use this token for authenticated requests", "properties": { "action": { - "description": "Action will always be `set_ory_session_token`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI", + "description": "Action will always be `set_ory_session_token`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI\nshow_settings_ui ContinueWithActionShowSettingsUI", "enum": [ "set_ory_session_token", - "show_verification_ui" + "show_verification_ui", + "show_settings_ui" ], "type": "string", - "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI" + "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI\nshow_settings_ui ContinueWithActionShowSettingsUI" }, "ory_session_token": { "description": "Token is the token of the session", @@ -494,17 +499,54 @@ ], "type": "object" }, + "continueWithSettingsUi": { + "description": "Indicates, that the UI flow could be continued by showing a settings ui", + "properties": { + "action": { + "description": "Action will always be `show_settings_ui`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI\nshow_settings_ui ContinueWithActionShowSettingsUI", + "enum": [ + "set_ory_session_token", + "show_verification_ui", + "show_settings_ui" + ], + "type": "string", + "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI\nshow_settings_ui ContinueWithActionShowSettingsUI" + }, + "flow": { + "$ref": "#/components/schemas/continueWithSettingsUiFlow" + } + }, + "required": [ + "action", + "flow" + ], + "type": "object" + }, + "continueWithSettingsUiFlow": { + "properties": { + "id": { + "description": "The ID of the settings flow", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "continueWithVerificationUi": { "description": "Indicates, that the UI flow could be continued by showing a verification ui", "properties": { "action": { - "description": "Action will always be `show_verification_ui`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI", + "description": "Action will always be `show_verification_ui`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI\nshow_settings_ui ContinueWithActionShowSettingsUI", "enum": [ "set_ory_session_token", - "show_verification_ui" + "show_verification_ui", + "show_settings_ui" ], "type": "string", - "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI" + "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI\nshow_settings_ui ContinueWithActionShowSettingsUI" }, "flow": { "$ref": "#/components/schemas/continueWithVerificationUiFlow" @@ -1480,6 +1522,12 @@ "description": "Active, if set, contains the recovery method that is being used. It is initially\nnot set.", "type": "string" }, + "continue_with": { + "items": { + "$ref": "#/components/schemas/continueWith" + }, + "type": "array" + }, "expires_at": { "description": "ExpiresAt is the time (UTC) when the request expires. If the user still wishes to update the setting,\na new request has to be initiated.", "format": "date-time", @@ -5215,7 +5263,7 @@ }, "/self-service/recovery": { "post": { - "description": "Use this endpoint to complete a recovery flow. This endpoint\nbehaves differently for API and browser flows and has several states:\n\n`choose_method` expects `flow` (in the URL query) and `email` (in the body) to be sent\nand works with API- and Browser-initiated flows.\nFor API clients and Browser clients with HTTP Header `Accept: application/json` it either returns a HTTP 200 OK when the form is valid and HTTP 400 OK when the form is invalid.\nand a HTTP 303 See Other redirect with a fresh recovery flow if the flow was otherwise invalid (e.g. expired).\nFor Browser clients without HTTP Header `Accept` or with `Accept: text/*` it returns a HTTP 303 See Other redirect to the Recovery UI URL with the Recovery Flow ID appended.\n`sent_email` is the success state after `choose_method` for the `link` method and allows the user to request another recovery email. It\nworks for both API and Browser-initiated flows and returns the same responses as the flow in `choose_method` state.\n`passed_challenge` expects a `token` to be sent in the URL query and given the nature of the flow (\"sending a recovery link\")\ndoes not have any API capabilities. The server responds with a HTTP 303 See Other redirect either to the Settings UI URL\n(if the link was valid) and instructs the user to update their password, or a redirect to the Recover UI URL with\na new Recovery Flow ID which contains an error message that the recovery link was invalid.\n\nMore information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).", + "description": "Use this endpoint to update a recovery flow. This endpoint\nbehaves differently for API and browser flows and has several states:\n\n`choose_method` expects `flow` (in the URL query) and `email` (in the body) to be sent\nand works with API- and Browser-initiated flows.\nFor API clients and Browser clients with HTTP Header `Accept: application/json` it either returns a HTTP 200 OK when the form is valid and HTTP 400 OK when the form is invalid.\nand a HTTP 303 See Other redirect with a fresh recovery flow if the flow was otherwise invalid (e.g. expired).\nFor Browser clients without HTTP Header `Accept` or with `Accept: text/*` it returns a HTTP 303 See Other redirect to the Recovery UI URL with the Recovery Flow ID appended.\n`sent_email` is the success state after `choose_method` for the `link` method and allows the user to request another recovery email. It\nworks for both API and Browser-initiated flows and returns the same responses as the flow in `choose_method` state.\n`passed_challenge` expects a `token` to be sent in the URL query and given the nature of the flow (\"sending a recovery link\")\ndoes not have any API capabilities. The server responds with a HTTP 303 See Other redirect either to the Settings UI URL\n(if the link was valid) and instructs the user to update their password, or a redirect to the Recover UI URL with\na new Recovery Flow ID which contains an error message that the recovery link was invalid.\n\nMore information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).", "operationId": "updateRecoveryFlow", "parameters": [ { @@ -5315,7 +5363,7 @@ "description": "errorGeneric" } }, - "summary": "Complete Recovery Flow", + "summary": "Update Recovery Flow", "tags": [ "frontend" ] @@ -5323,7 +5371,7 @@ }, "/self-service/recovery/api": { "get": { - "description": "This endpoint initiates a recovery flow for API clients such as mobile devices, smart TVs, and so on.\n\nIf a valid provided session cookie or session token is provided, a 400 Bad Request error.\n\nTo fetch an existing recovery flow call `/self-service/recovery/flows?flow=\u003cflow_id\u003e`.\n\nYou MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server\nPages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make\nyou vulnerable to a variety of CSRF attacks.\n\nThis endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).\n\nMore information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).", + "description": "This endpoint initiates a recovery flow for API clients such as mobile devices, smart TVs, and so on.\n\nIf a valid provided session cookie or session token is provided, a 400 Bad Request error.\n\nIf you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint.\n\nYou MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server\nPages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make\nyou vulnerable to a variety of CSRF attacks.\n\nThis endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).\n\nMore information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).", "operationId": "createNativeRecoveryFlow", "responses": { "200": { diff --git a/spec/swagger.json b/spec/swagger.json index 01ad8fb3bf27..0b077c61b657 100755 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -1794,7 +1794,7 @@ }, "/self-service/recovery": { "post": { - "description": "Use this endpoint to complete a recovery flow. This endpoint\nbehaves differently for API and browser flows and has several states:\n\n`choose_method` expects `flow` (in the URL query) and `email` (in the body) to be sent\nand works with API- and Browser-initiated flows.\nFor API clients and Browser clients with HTTP Header `Accept: application/json` it either returns a HTTP 200 OK when the form is valid and HTTP 400 OK when the form is invalid.\nand a HTTP 303 See Other redirect with a fresh recovery flow if the flow was otherwise invalid (e.g. expired).\nFor Browser clients without HTTP Header `Accept` or with `Accept: text/*` it returns a HTTP 303 See Other redirect to the Recovery UI URL with the Recovery Flow ID appended.\n`sent_email` is the success state after `choose_method` for the `link` method and allows the user to request another recovery email. It\nworks for both API and Browser-initiated flows and returns the same responses as the flow in `choose_method` state.\n`passed_challenge` expects a `token` to be sent in the URL query and given the nature of the flow (\"sending a recovery link\")\ndoes not have any API capabilities. The server responds with a HTTP 303 See Other redirect either to the Settings UI URL\n(if the link was valid) and instructs the user to update their password, or a redirect to the Recover UI URL with\na new Recovery Flow ID which contains an error message that the recovery link was invalid.\n\nMore information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).", + "description": "Use this endpoint to update a recovery flow. This endpoint\nbehaves differently for API and browser flows and has several states:\n\n`choose_method` expects `flow` (in the URL query) and `email` (in the body) to be sent\nand works with API- and Browser-initiated flows.\nFor API clients and Browser clients with HTTP Header `Accept: application/json` it either returns a HTTP 200 OK when the form is valid and HTTP 400 OK when the form is invalid.\nand a HTTP 303 See Other redirect with a fresh recovery flow if the flow was otherwise invalid (e.g. expired).\nFor Browser clients without HTTP Header `Accept` or with `Accept: text/*` it returns a HTTP 303 See Other redirect to the Recovery UI URL with the Recovery Flow ID appended.\n`sent_email` is the success state after `choose_method` for the `link` method and allows the user to request another recovery email. It\nworks for both API and Browser-initiated flows and returns the same responses as the flow in `choose_method` state.\n`passed_challenge` expects a `token` to be sent in the URL query and given the nature of the flow (\"sending a recovery link\")\ndoes not have any API capabilities. The server responds with a HTTP 303 See Other redirect either to the Settings UI URL\n(if the link was valid) and instructs the user to update their password, or a redirect to the Recover UI URL with\na new Recovery Flow ID which contains an error message that the recovery link was invalid.\n\nMore information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).", "consumes": [ "application/json", "application/x-www-form-urlencoded" @@ -1809,7 +1809,7 @@ "tags": [ "frontend" ], - "summary": "Complete Recovery Flow", + "summary": "Update Recovery Flow", "operationId": "updateRecoveryFlow", "parameters": [ { @@ -1879,7 +1879,7 @@ }, "/self-service/recovery/api": { "get": { - "description": "This endpoint initiates a recovery flow for API clients such as mobile devices, smart TVs, and so on.\n\nIf a valid provided session cookie or session token is provided, a 400 Bad Request error.\n\nTo fetch an existing recovery flow call `/self-service/recovery/flows?flow=\u003cflow_id\u003e`.\n\nYou MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server\nPages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make\nyou vulnerable to a variety of CSRF attacks.\n\nThis endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).\n\nMore information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).", + "description": "This endpoint initiates a recovery flow for API clients such as mobile devices, smart TVs, and so on.\n\nIf a valid provided session cookie or session token is provided, a 400 Bad Request error.\n\nIf you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint.\n\nYou MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server\nPages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make\nyou vulnerable to a variety of CSRF attacks.\n\nThis endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).\n\nMore information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).", "schemes": [ "http", "https" @@ -3426,13 +3426,14 @@ ], "properties": { "action": { - "description": "Action will always be `set_ory_session_token`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI", + "description": "Action will always be `set_ory_session_token`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI\nshow_settings_ui ContinueWithActionShowSettingsUI", "type": "string", "enum": [ "set_ory_session_token", - "show_verification_ui" + "show_verification_ui", + "show_settings_ui" ], - "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI" + "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI\nshow_settings_ui ContinueWithActionShowSettingsUI" }, "ory_session_token": { "description": "Token is the token of the session", @@ -3440,6 +3441,42 @@ } } }, + "continueWithSettingsUi": { + "description": "Indicates, that the UI flow could be continued by showing a settings ui", + "type": "object", + "required": [ + "action", + "flow" + ], + "properties": { + "action": { + "description": "Action will always be `show_settings_ui`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI\nshow_settings_ui ContinueWithActionShowSettingsUI", + "type": "string", + "enum": [ + "set_ory_session_token", + "show_verification_ui", + "show_settings_ui" + ], + "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI\nshow_settings_ui ContinueWithActionShowSettingsUI" + }, + "flow": { + "$ref": "#/definitions/continueWithSettingsUiFlow" + } + } + }, + "continueWithSettingsUiFlow": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "The ID of the settings flow", + "type": "string", + "format": "uuid" + } + } + }, "continueWithVerificationUi": { "description": "Indicates, that the UI flow could be continued by showing a verification ui", "type": "object", @@ -3449,13 +3486,14 @@ ], "properties": { "action": { - "description": "Action will always be `show_verification_ui`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI", + "description": "Action will always be `show_verification_ui`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI\nshow_settings_ui ContinueWithActionShowSettingsUI", "type": "string", "enum": [ "set_ory_session_token", - "show_verification_ui" + "show_verification_ui", + "show_settings_ui" ], - "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI" + "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI\nshow_settings_ui ContinueWithActionShowSettingsUI" }, "flow": { "$ref": "#/definitions/continueWithVerificationUiFlow" @@ -4413,6 +4451,12 @@ "description": "Active, if set, contains the recovery method that is being used. It is initially\nnot set.", "type": "string" }, + "continue_with": { + "type": "array", + "items": { + "$ref": "#/definitions/continueWith" + } + }, "expires_at": { "description": "ExpiresAt is the time (UTC) when the request expires. If the user still wishes to update the setting,\na new request has to be initiated.", "type": "string", diff --git a/test/e2e/package-lock.json b/test/e2e/package-lock.json index 0cd1fcf6a2a5..3ef671258154 100644 --- a/test/e2e/package-lock.json +++ b/test/e2e/package-lock.json @@ -7,9 +7,15 @@ "": { "name": "@ory/kratos-e2e-suite", "version": "0.0.1", + "dependencies": { + "@faker-js/faker": "^7.6.0", + "async-retry": "^1.3.3", + "mailhog": "^4.16.0" + }, "devDependencies": { "@ory/kratos-client": "0.0.0-next.8d3b018594f7", "@playwright/test": "^1.32.3", + "@types/async-retry": "^1.4.5", "@types/node": "^16.9.6", "@types/yamljs": "^0.2.31", "chrome-remote-interface": "0.31.2", @@ -90,6 +96,15 @@ "ms": "^2.1.1" } }, + "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==", + "engines": { + "node": ">=14.0.0", + "npm": ">=6.0.0" + } + }, "node_modules/@hapi/hoek": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.1.tgz", @@ -231,6 +246,15 @@ "node": ">=10" } }, + "node_modules/@types/async-retry": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/async-retry/-/async-retry-1.4.5.tgz", + "integrity": "sha512-YrdjSD+yQv7h6d5Ip+PMxh3H6ZxKyQk0Ts+PvaNRInxneG9PFVZjFg77ILAN+N6qYf7g4giSJ1l+ZjQ1zeegvA==", + "dev": true, + "dependencies": { + "@types/retry": "*" + } + }, "node_modules/@types/cacheable-request": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz", @@ -307,6 +331,12 @@ "@types/node": "*" } }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true + }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", @@ -464,6 +494,14 @@ "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==", "dev": true }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "dependencies": { + "retry": "0.13.1" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1510,6 +1548,18 @@ "node": ">=8.12.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1941,6 +1991,17 @@ "es5-ext": "~0.10.2" } }, + "node_modules/mailhog": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/mailhog/-/mailhog-4.16.0.tgz", + "integrity": "sha512-wXrGik+0MaAy4dbYTImxa8niX9a4aRpZTzC/b1GzCvQs09khhs0aKZgHjgScakI4Y18WInDvvF48hhEz9ifN4g==", + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.6" + } + }, "node_modules/memoizee": { "version": "0.4.15", "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", @@ -2320,6 +2381,14 @@ "node": ">=8" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "engines": { + "node": ">= 4" + } + }, "node_modules/rfdc": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", @@ -2374,7 +2443,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "devOptional": true }, "node_modules/semver": { "version": "7.3.5", @@ -2873,6 +2942,11 @@ } } }, + "@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==" + }, "@hapi/hoek": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.1.tgz", @@ -2997,6 +3071,15 @@ "defer-to-connect": "^2.0.0" } }, + "@types/async-retry": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/async-retry/-/async-retry-1.4.5.tgz", + "integrity": "sha512-YrdjSD+yQv7h6d5Ip+PMxh3H6ZxKyQk0Ts+PvaNRInxneG9PFVZjFg77ILAN+N6qYf7g4giSJ1l+ZjQ1zeegvA==", + "dev": true, + "requires": { + "@types/retry": "*" + } + }, "@types/cacheable-request": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz", @@ -3073,6 +3156,12 @@ "@types/node": "*" } }, + "@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true + }, "@types/sinonjs__fake-timers": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", @@ -3189,6 +3278,14 @@ "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==", "dev": true }, + "async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "requires": { + "retry": "0.13.1" + } + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3987,6 +4084,15 @@ "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", "dev": true }, + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4314,6 +4420,14 @@ "es5-ext": "~0.10.2" } }, + "mailhog": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/mailhog/-/mailhog-4.16.0.tgz", + "integrity": "sha512-wXrGik+0MaAy4dbYTImxa8niX9a4aRpZTzC/b1GzCvQs09khhs0aKZgHjgScakI4Y18WInDvvF48hhEz9ifN4g==", + "requires": { + "iconv-lite": "^0.6" + } + }, "memoizee": { "version": "0.4.15", "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", @@ -4600,6 +4714,11 @@ "signal-exit": "^3.0.2" } }, + "retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" + }, "rfdc": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", @@ -4634,7 +4753,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "devOptional": true }, "semver": { "version": "7.3.5", diff --git a/test/e2e/package.json b/test/e2e/package.json index c3204c330060..908a9255af18 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -13,6 +13,7 @@ "devDependencies": { "@ory/kratos-client": "0.0.0-next.8d3b018594f7", "@playwright/test": "^1.32.3", + "@types/async-retry": "^1.4.5", "@types/node": "^16.9.6", "@types/yamljs": "^0.2.31", "chrome-remote-interface": "0.31.2", @@ -25,5 +26,10 @@ "typescript": "^4.7.4", "wait-on": "5.3.0", "yamljs": "^0.3.0" + }, + "dependencies": { + "@faker-js/faker": "^7.6.0", + "async-retry": "^1.3.3", + "mailhog": "^4.16.0" } } diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts index 677c0a3c2d46..84590aa4550a 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -43,9 +43,20 @@ export default defineConfig({ cwd: "../..", url: "http://localhost:4433/health/ready", reuseExistingServer: false, - env: { DSN: dbToDsn() }, + env: { + DSN: dbToDsn(), + COURIER_SMTP_CONNECTION_URI: + "smtp://localhost:8026/?disable_starttls=true", + }, timeout: 5 * 60 * 1000, // 5 minutes }, + { + command: + "make .bin/MailHog && .bin/MailHog -smtp-bind-addr=localhost:8026", + cwd: "../..", + reuseExistingServer: true, + url: "http://localhost:8025/", + }, ], }) diff --git a/test/e2e/playwright/actions/mail.ts b/test/e2e/playwright/actions/mail.ts new file mode 100644 index 000000000000..871608bc204d --- /dev/null +++ b/test/e2e/playwright/actions/mail.ts @@ -0,0 +1,24 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import mailhog from "mailhog" +import retry from "async-retry" + +const mh = mailhog({ + basePath: "http://localhost:8025/api", +}) + +export function search(...props: Parameters) { + return retry( + async () => { + const res = await mh.search(...props) + if (res.total === 0) { + throw new Error("no emails found") + } + return res.items + }, + { + retries: 3, + }, + ) +} diff --git a/test/e2e/playwright/fixtures/index.ts b/test/e2e/playwright/fixtures/index.ts new file mode 100644 index 000000000000..5d4a4cabdf05 --- /dev/null +++ b/test/e2e/playwright/fixtures/index.ts @@ -0,0 +1,57 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { Identity } from "@ory/kratos-client" +import { test as base, expect } from "@playwright/test" +import { OryKratosConfiguration } from "../../cypress/support/config" +import { merge } from "lodash" +import { default_config } from "../setup/default_config" +import { writeFile } from "fs/promises" +import { faker } from "@faker-js/faker" + +type TestFixtures = { + identity: Identity + configOverride: Partial + config: void +} + +type WorkerFixtures = {} + +export const test = base.extend({ + configOverride: {}, + config: [ + async ({ request, configOverride }, use) => { + const configToWrite = merge(default_config, configOverride) + + const resp = await request.get("http://localhost:4434/health/config") + + const configRevision = await resp.body() + + await writeFile( + "playwright/kratos.config.json", + JSON.stringify(configToWrite), + ) + await expect(async () => { + const resp = await request.get("http://localhost:4434/health/config") + const updatedRevision = await resp.body() + expect(updatedRevision).not.toBe(configRevision) + }).toPass() + + await use() + }, + { auto: true }, + ], + identity: async ({ request }, use) => { + const resp = await request.post("http://localhost:4434/admin/identities", { + data: { + schema_id: "email", + traits: { + email: faker.internet.email(undefined, undefined, "ory.sh"), + website: faker.internet.url(), + }, + }, + }) + expect(resp.status()).toBe(201) + await use(await resp.json()) + }, +}) diff --git a/test/e2e/playwright/kratos.base-config.json b/test/e2e/playwright/kratos.base-config.json index 675f1a94862d..83b24587bd61 100644 --- a/test/e2e/playwright/kratos.base-config.json +++ b/test/e2e/playwright/kratos.base-config.json @@ -110,7 +110,7 @@ }, "courier": { "smtp": { - "connection_uri": "smtps://test:test@localhost:1025/?skip_ssl_verify=true" + "connection_uri": "smtps://test:test@localhost:8026/?skip_ssl_verify=true" } } } diff --git a/test/e2e/playwright/lib/helper.ts b/test/e2e/playwright/lib/helper.ts new file mode 100644 index 000000000000..929b39b74573 --- /dev/null +++ b/test/e2e/playwright/lib/helper.ts @@ -0,0 +1,20 @@ +// Copyright © 2023 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { Message } from "mailhog" + +export const codeRegex = /(\d{6})/ + +/** + * Extracts the recovery or verification code from a mail + * + * @param mail the mail to extract the code from + * @returns the code or null if no code was found + */ +export function extractCode(mail: Message) { + const result = codeRegex.exec(mail.html || mail.text) + if (result != null && result.length > 0) { + return result[0] + } + return null +} diff --git a/test/e2e/playwright/setup/default_config.ts b/test/e2e/playwright/setup/default_config.ts index 6cb3466b479c..04db2bf9c741 100644 --- a/test/e2e/playwright/setup/default_config.ts +++ b/test/e2e/playwright/setup/default_config.ts @@ -112,6 +112,7 @@ export const default_config: OryKratosConfiguration = { ui_url: "http://localhost:4455/verify", }, recovery: { + enabled: true, ui_url: "http://localhost:4455/recovery", }, }, @@ -119,7 +120,7 @@ export const default_config: OryKratosConfiguration = { courier: { smtp: { - connection_uri: "smtps://test:test@localhost:1025/?skip_ssl_verify=true", + connection_uri: "smtp://localhost:8026/?disable_starttls=true", }, }, } diff --git a/test/e2e/playwright/tests/app_recovery.spec.ts b/test/e2e/playwright/tests/app_recovery.spec.ts new file mode 100644 index 000000000000..fea58ac0e637 --- /dev/null +++ b/test/e2e/playwright/tests/app_recovery.spec.ts @@ -0,0 +1,51 @@ +// Copyright © 2023 Ory Corp +// 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" + +test.use({ + configOverride: { + identity: { + default_schema_id: "email", + schemas: [ + { + id: "email", + url: "file://test/e2e/profiles/email/identity.traits.schema.json", + }, + ], + }, + }, +}) + +test("recovery works", async ({ page, identity }) => { + await page.goto("/Recovery") + + const emailInput = page.getByTestId("email") + await emailInput.waitFor() + + await emailInput.fill(identity.traits.email) + + await page.getByTestId("submit-form").click() + + await page.getByTestId("ui/message/1060003").waitFor() + + const mails = await search(identity.traits.email, "to") + expect(mails).toHaveLength(1) + + const code = extractCode(mails[0]) + + const codeInput = page.getByTestId("code") + await codeInput.fill(code) + + await page.getByTestId("field/method/code").getByTestId("submit-form").click() + + await page.getByTestId("ui/message/1060001").waitFor() +}) + +// TODO: add test for +// - recovery with a not registered email +// - recovery with a not verified email +// - recovery brute force From 77b7c72f2e504e4808d1442c3d1018f8386b1333 Mon Sep 17 00:00:00 2001 From: Henning Perl Date: Wed, 23 Aug 2023 14:09:04 +0200 Subject: [PATCH 02/25] test: add e2e tests for recovery Co-authored-by: Jonas Hungershausen --- .../kratos/email-password/kratos.yml | 2 +- selfservice/flow/recovery/flow.go | 1 + .../strategy/code/strategy_recovery.go | 1 - test/e2e/playwright.config.ts | 2 +- .../e2e/playwright/tests/app_recovery.spec.ts | 106 +++++++++++++----- 5 files changed, 78 insertions(+), 34 deletions(-) diff --git a/contrib/quickstart/kratos/email-password/kratos.yml b/contrib/quickstart/kratos/email-password/kratos.yml index 0c18cb00b31d..1b939ac7f394 100644 --- a/contrib/quickstart/kratos/email-password/kratos.yml +++ b/contrib/quickstart/kratos/email-password/kratos.yml @@ -14,7 +14,7 @@ selfservice: default_browser_return_url: http://127.0.0.1:4455/ allowed_return_urls: - http://127.0.0.1:4455 - - http://localhost:4457/Callback + - http://localhost:19006/Callback methods: password: diff --git a/selfservice/flow/recovery/flow.go b/selfservice/flow/recovery/flow.go index 92aa18cb7300..0eb420bd5981 100644 --- a/selfservice/flow/recovery/flow.go +++ b/selfservice/flow/recovery/flow.go @@ -101,6 +101,7 @@ type Flow struct { // the user. DangerousSkipCSRFCheck bool `json:"-" faker:"-" db:"skip_csrf_check"` + // Contains possible actions that could follow this flow ContinueWith []flow.ContinueWith `json:"continue_with,omitempty" faker:"-" db:"-"` } diff --git a/selfservice/strategy/code/strategy_recovery.go b/selfservice/strategy/code/strategy_recovery.go index cd504c53016e..54acf7148315 100644 --- a/selfservice/strategy/code/strategy_recovery.go +++ b/selfservice/strategy/code/strategy_recovery.go @@ -185,7 +185,6 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, switch { case f.Type == flow.TypeBrowser: - // TODO: How does this work with Mobile? if err := s.deps.SessionManager().UpsertAndIssueCookie(ctx, w, r, sess); err != nil { return s.retryRecoveryFlowWithError(w, r, f.Type, err) } diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts index 84590aa4550a..71a67dfd8795 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -54,7 +54,7 @@ export default defineConfig({ command: "make .bin/MailHog && .bin/MailHog -smtp-bind-addr=localhost:8026", cwd: "../..", - reuseExistingServer: true, + reuseExistingServer: false, url: "http://localhost:8025/", }, ], diff --git a/test/e2e/playwright/tests/app_recovery.spec.ts b/test/e2e/playwright/tests/app_recovery.spec.ts index fea58ac0e637..82b671ce53c0 100644 --- a/test/e2e/playwright/tests/app_recovery.spec.ts +++ b/test/e2e/playwright/tests/app_recovery.spec.ts @@ -6,46 +6,90 @@ import { test } from "../fixtures" import { search } from "../actions/mail" import { extractCode } from "../lib/helper" -test.use({ - configOverride: { - identity: { - default_schema_id: "email", - schemas: [ - { - id: "email", - url: "file://test/e2e/profiles/email/identity.traits.schema.json", - }, - ], +test.describe.configure({ mode: "parallel" }) +test.describe("Recovery", () => { + test.use({ + configOverride: { + identity: { + default_schema_id: "email", + schemas: [ + { + id: "email", + url: "file://test/e2e/profiles/email/identity.traits.schema.json", + }, + ], + }, }, - }, -}) + }) -test("recovery works", async ({ page, identity }) => { - await page.goto("/Recovery") + test("succeeds with a valid email address", async ({ page, identity }) => { + await page.goto("/Recovery") - const emailInput = page.getByTestId("email") - await emailInput.waitFor() + await page.getByTestId("email").fill(identity.traits.email) + await page.getByTestId("submit-form").click() + await expect(page.getByTestId("ui/message/1060003")).toBeVisible() - await emailInput.fill(identity.traits.email) + const mails = await search(identity.traits.email, "to") + expect(mails).toHaveLength(1) - await page.getByTestId("submit-form").click() + const code = extractCode(mails[0]) + const wrongCode = "0" + code - await page.getByTestId("ui/message/1060003").waitFor() + await test.step("enter wrong code", async () => { + await page.getByTestId("code").fill(wrongCode) + await page.getByText("Submit").click() + await expect(page.getByTestId("ui/message/4060006")).toBeVisible() + }) - const mails = await search(identity.traits.email, "to") - expect(mails).toHaveLength(1) + await test.step("enter correct code", async () => { + await page.getByTestId("code").fill(code) + await page.getByText("Submit").click() + await page.waitForURL(/Settings/) + await expect(page.getByTestId("ui/message/1060001").first()).toBeVisible() + }) + }) - const code = extractCode(mails[0]) + test("wrong email address does not get sent", async ({ page, identity }) => { + await page.goto("/Recovery") - const codeInput = page.getByTestId("code") - await codeInput.fill(code) + const wrongEmailAddress = "wrong-" + identity.traits.email + await page.getByTestId("email").fill(wrongEmailAddress) + await page.getByTestId("submit-form").click() + await expect(page.getByTestId("ui/message/1060003")).toBeVisible() - await page.getByTestId("field/method/code").getByTestId("submit-form").click() + try { + await search(identity.traits.email, "to") + expect(false).toBeTruthy() + } catch (e) { + // this is expected + } + }) - await page.getByTestId("ui/message/1060001").waitFor() -}) + test("fails with an invalid code", async ({ page, identity }) => { + await page.goto("/Recovery") + + await page.getByTestId("email").fill(identity.traits.email) + await page.getByTestId("submit-form").click() + await page.getByTestId("ui/message/1060003").isVisible() + + const mails = await search(identity.traits.email, "to") + expect(mails).toHaveLength(1) -// TODO: add test for -// - recovery with a not registered email -// - recovery with a not verified email -// - recovery brute force + const code = extractCode(mails[0]) + const wrongCode = "0" + code + + await test.step("enter wrong repeatetly", async () => { + for (let i = 0; i < 10; i++) { + await page.getByTestId("code").fill(wrongCode) + await page.getByText("Submit").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").click() + await expect(page.getByTestId("ui/message/4060006")).toBeVisible() + }) + }) +}) From 105271b918bdab5cc7ffa8a002b50060b84bd7db Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Sun, 3 Sep 2023 12:14:51 +0200 Subject: [PATCH 03/25] chore: u --- selfservice/flow/continue_with.go | 47 ++++ selfservice/flow/error.go | 5 + selfservice/flow/recovery/error.go | 39 +-- selfservice/flow/recovery/flow.go | 2 +- .../strategy/code/strategy_recovery.go | 110 ++++---- .../strategy/code/strategy_recovery_admin.go | 2 +- .../strategy/code/strategy_recovery_test.go | 266 +++++++++++------- test/e2e/playwright/fixtures/index.ts | 9 +- .../e2e/playwright/tests/app_recovery.spec.ts | 54 +++- 9 files changed, 351 insertions(+), 183 deletions(-) diff --git a/selfservice/flow/continue_with.go b/selfservice/flow/continue_with.go index 4b82f984a866..96ab76835746 100644 --- a/selfservice/flow/continue_with.go +++ b/selfservice/flow/continue_with.go @@ -4,6 +4,7 @@ package flow import ( + "github.com/ory/herodot" "net/url" "github.com/gofrs/uuid" @@ -22,6 +23,7 @@ const ( ContinueWithActionSetOrySessionToken ContinueWithAction = "set_ory_session_token" ContinueWithActionShowVerificationUI ContinueWithAction = "show_verification_ui" ContinueWithActionShowSettingsUI ContinueWithAction = "show_settings_ui" + ContinueWithActionShowRecoveryUI ContinueWithAction = "show_recovery_ui" ) var _ ContinueWith = new(ContinueWithSetToken) @@ -140,3 +142,48 @@ func NewContinueWithSettingsUI(f Flow) *ContinueWithSettingsUI { }, } } + +// Indicates, that the UI flow could be continued by showing a recovery ui +// +// swagger:model continueWithRecoveryUi +type ContinueWithRecoveryUI struct { + // Action will always be `show_recovery_ui` + // + // required: true + Action ContinueWithAction `json:"action"` + // Flow contains the ID of the recovery flow + // + // required: true + Flow ContinueWithRecoveryUIFlow `json:"flow"` +} + +// swagger:model continueWithRecoveryUiFlow +type ContinueWithRecoveryUIFlow struct { + // The ID of the recovery flow + // + // required: true + ID uuid.UUID `json:"id"` + + // The URL of the recovery flow + // + // required: false + URL string `json:"url,omitempty"` +} + +func NewContinueWithRecoveryUI(f Flow) *ContinueWithRecoveryUI { + return &ContinueWithRecoveryUI{ + Action: ContinueWithActionShowRecoveryUI, + Flow: ContinueWithRecoveryUIFlow{ + ID: f.GetID(), + }, + } +} + +func ErrorWithContinueWith(err *herodot.DefaultError, continueWith ...ContinueWith) *herodot.DefaultError { + // todo: check if the map already exists + err.DetailsField = map[string]interface{}{ + "continue_with": continueWith, + } + + return err +} diff --git a/selfservice/flow/error.go b/selfservice/flow/error.go index 4e169e3d9d69..0967c133dd46 100644 --- a/selfservice/flow/error.go +++ b/selfservice/flow/error.go @@ -114,6 +114,11 @@ type ExpiredError struct { flow Flow } +func (e *ExpiredError) WithContinueWith(continueWith ...ContinueWith) *ExpiredError { + e.DefaultError = ErrorWithContinueWith(e.DefaultError, continueWith...) + return e +} + func (e *ExpiredError) WithFlow(flow Flow) *ExpiredError { e.FlowID = flow.GetID() e.flow = flow diff --git a/selfservice/flow/recovery/error.go b/selfservice/flow/recovery/error.go index defc22719412..e992f6efd1fb 100644 --- a/selfservice/flow/recovery/error.go +++ b/selfservice/flow/recovery/error.go @@ -62,23 +62,23 @@ func (s *ErrorHandler) WriteFlowError( r *http.Request, f *Flow, group node.UiNodeGroup, - err error, + recoveryErr error, ) { s.d.Audit(). - WithError(err). + WithError(recoveryErr). WithRequest(r). WithField("recovery_flow", f). Info("Encountered self-service recovery error.") if f == nil { trace.SpanFromContext(r.Context()).AddEvent(events.NewRecoveryFailed(r.Context(), "", "")) - s.forward(w, r, nil, err) + s.forward(w, r, nil, recoveryErr) return } trace.SpanFromContext(r.Context()).AddEvent(events.NewRecoveryFailed(r.Context(), string(f.Type), f.Active.String())) - if e := new(flow.ExpiredError); errors.As(err, &e) { + if e := new(flow.ExpiredError); errors.As(recoveryErr, &e) { strategy, err := s.d.RecoveryStrategies(r.Context()).Strategy(f.Active.String()) if err != nil { strategy, err = s.d.GetActiveRecoveryStrategy(r.Context()) @@ -89,33 +89,36 @@ func (s *ErrorHandler) WriteFlowError( } } // create new flow because the old one is not valid - a, err := FromOldFlow(s.d.Config(), s.d.Config().SelfServiceFlowRecoveryRequestLifespan(r.Context()), s.d.GenerateCSRFToken(r), r, strategy, *f) + newFlow, err := FromOldFlow(s.d.Config(), s.d.Config().SelfServiceFlowRecoveryRequestLifespan(r.Context()), s.d.GenerateCSRFToken(r), r, strategy, *f) if err != nil { // failed to create a new session and redirect to it, handle that error as a new one s.WriteFlowError(w, r, f, group, err) return } - a.UI.Messages.Add(text.NewErrorValidationRecoveryFlowExpired(e.ExpiredAt)) - if err := s.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), a); err != nil { - s.forward(w, r, a, err) + newFlow.UI.Messages.Add(text.NewErrorValidationRecoveryFlowExpired(e.ExpiredAt)) + if err := s.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), newFlow); err != nil { + s.forward(w, r, newFlow, err) return } - // We need to use the new flow, as that flow will be a browser flow. Bug fix for: - // - // https://github.com/ory/kratos/issues/2049!! - if a.Type == flow.TypeAPI || x.IsJSONRequest(r) { - http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()), - RouteGetFlow), url.Values{"id": {a.ID.String()}}).String(), http.StatusSeeOther) - } else { - http.Redirect(w, r, a.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther) + switch { + case newFlow.Type.IsAPI(): + e.FlowID = newFlow.ID + s.d.Writer().WriteError(w, r, e.WithContinueWith(flow.NewContinueWithRecoveryUI(f))) + case x.IsJSONRequest(r): + http.Redirect(w, r, urlx.CopyWithQuery( + urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()), RouteGetFlow), + url.Values{"id": {newFlow.ID.String()}}, + ).String(), http.StatusSeeOther) + default: + http.Redirect(w, r, newFlow.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther) } return } f.UI.ResetMessages() - if err := f.UI.ParseError(group, err); err != nil { + if err := f.UI.ParseError(group, recoveryErr); err != nil { s.forward(w, r, f, err) return } @@ -136,7 +139,7 @@ func (s *ErrorHandler) WriteFlowError( s.forward(w, r, updatedFlow, innerErr) } - s.d.Writer().WriteCode(w, r, x.RecoverStatusCode(err, http.StatusBadRequest), updatedFlow) + s.d.Writer().WriteCode(w, r, x.RecoverStatusCode(recoveryErr, http.StatusBadRequest), updatedFlow) } func (s *ErrorHandler) forward(w http.ResponseWriter, r *http.Request, rr *Flow, err error) { diff --git a/selfservice/flow/recovery/flow.go b/selfservice/flow/recovery/flow.go index 843c2a4d61e5..d5aed79ae0f3 100644 --- a/selfservice/flow/recovery/flow.go +++ b/selfservice/flow/recovery/flow.go @@ -150,7 +150,7 @@ func NewFlow(conf *config.Config, exp time.Duration, csrf string, r *http.Reques func FromOldFlow(conf *config.Config, exp time.Duration, csrf string, r *http.Request, strategy Strategy, of Flow) (*Flow, error) { f := of.Type // Using the same flow in the recovery/verification context can lead to using API flow in a verification/recovery email - if of.Type == flow.TypeAPI { + if of.Type == flow.TypeAPI && of.Active.String() == string(RecoveryStrategyLink) { f = flow.TypeBrowser } nf, err := NewFlow(conf, exp, csrf, r, strategy, f) diff --git a/selfservice/strategy/code/strategy_recovery.go b/selfservice/strategy/code/strategy_recovery.go index 5c20f390b8fb..b0eff420e11a 100644 --- a/selfservice/strategy/code/strategy_recovery.go +++ b/selfservice/strategy/code/strategy_recovery.go @@ -4,6 +4,7 @@ package code import ( + "github.com/ory/herodot" "net/http" "net/url" "time" @@ -108,7 +109,7 @@ func (s *Strategy) Recover(w http.ResponseWriter, r *http.Request, f *recovery.F } else if err := flow.EnsureCSRF(s.deps, r, f.Type, s.deps.Config().DisableAPIFlowEnforcement(ctx), s.deps.GenerateCSRFToken, body.CSRFToken); err != nil { // If a CSRF violation occurs the flow is most likely FUBAR, as the user either lost the CSRF token, or an attack occured. // In this case, we just issue a new flow and "abandon" the old flow. - return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err) + return s.retryRecoveryFlow(w, r, flow.TypeBrowser, RetryWithError(err)) } sID := s.RecoveryStrategyID() @@ -153,9 +154,9 @@ func (s *Strategy) Recover(w http.ResponseWriter, r *http.Request, f *recovery.F return s.recoveryHandleFormSubmission(w, r, recoveryFlow, body) case flow.StatePassedChallenge: // was already handled, do not allow retry - return s.retryRecoveryFlowWithMessage(w, r, recoveryFlow.Type, text.NewErrorValidationRecoveryRetrySuccess()) + return s.retryRecoveryFlow(w, r, recoveryFlow.Type, RetryWithMessage(text.NewErrorValidationRecoveryRetrySuccess())) default: - return s.retryRecoveryFlowWithMessage(w, r, recoveryFlow.Type, text.NewErrorValidationRecoveryStateFailure()) + return s.retryRecoveryFlow(w, r, recoveryFlow.Type, RetryWithMessage(text.NewErrorValidationRecoveryStateFailure())) } } @@ -170,34 +171,34 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, Valid: true, } if err := s.deps.RecoveryFlowPersister().UpdateRecoveryFlow(ctx, f); err != nil { - return s.retryRecoveryFlowWithError(w, r, f.Type, err) + return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } sess, err := session.NewActiveSession(r, id, s.deps.Config(), time.Now().UTC(), identity.CredentialsTypeRecoveryCode, identity.AuthenticatorAssuranceLevel1) if err != nil { - return s.retryRecoveryFlowWithError(w, r, f.Type, err) + return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } if err := s.deps.RecoveryExecutor().PostRecoveryHook(w, r, f, sess); err != nil { - return s.retryRecoveryFlowWithError(w, r, f.Type, err) + return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } - switch { - case f.Type == flow.TypeBrowser: + switch f.Type { + case flow.TypeBrowser: if err := s.deps.SessionManager().UpsertAndIssueCookie(ctx, w, r, sess); err != nil { - return s.retryRecoveryFlowWithError(w, r, f.Type, err) + return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } - case f.Type == flow.TypeAPI: + case flow.TypeAPI: if err := s.deps.SessionPersister().UpsertSession(r.Context(), sess); err != nil { - return s.retryRecoveryFlowWithError(w, r, f.Type, err) + return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } f.ContinueWith = append(f.ContinueWith, flow.NewContinueWithSetToken(sess.Token)) } sf, err := s.deps.SettingsHandler().NewFlow(w, r, sess.Identity, f.Type) if err != nil { - return s.retryRecoveryFlowWithError(w, r, f.Type, err) + return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } returnToURL := s.deps.Config().SelfServiceFlowRecoveryReturnTo(r.Context(), nil) @@ -207,14 +208,14 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, } sf.RequestURL, err = x.TakeOverReturnToParameter(f.RequestURL, sf.RequestURL, returnTo) if err != nil { - return s.retryRecoveryFlowWithError(w, r, f.Type, err) + return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } config := s.deps.Config() sf.UI.Messages.Set(text.NewRecoverySuccessful(time.Now().Add(config.SelfServiceFlowSettingsPrivilegedSessionMaxAge(ctx)))) if err := s.deps.SettingsFlowPersister().UpdateSettingsFlow(r.Context(), sf); err != nil { - return s.retryRecoveryFlowWithError(w, r, f.Type, err) + return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } switch { @@ -237,13 +238,13 @@ func (s *Strategy) recoveryUseCode(w http.ResponseWriter, r *http.Request, body f.UI.Messages.Clear() f.UI.Messages.Add(text.NewErrorValidationRecoveryCodeInvalidOrAlreadyUsed()) if err := s.deps.RecoveryFlowPersister().UpdateRecoveryFlow(ctx, f); err != nil { - return s.retryRecoveryFlowWithError(w, r, f.Type, err) + return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } // No error return nil } else if err != nil { - return s.retryRecoveryFlowWithError(w, r, f.Type, err) + return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } recovered, err := s.deps.IdentityPool().GetIdentity(ctx, code.IdentityID, identity.ExpandDefault) @@ -261,63 +262,74 @@ func (s *Strategy) recoveryUseCode(w http.ResponseWriter, r *http.Request, body return s.recoveryIssueSession(w, r, f, recovered) } -func (s *Strategy) retryRecoveryFlowWithMessage(w http.ResponseWriter, r *http.Request, ft flow.Type, message *text.Message) error { - s.deps.Logger(). - WithRequest(r). - WithField("message", message). - Debug("A recovery flow is being retried because a validation error occurred.") +type retry struct { + err error + message *text.Message +} - ctx := r.Context() - config := s.deps.Config() +type RetryOption func(*retry) - f, err := recovery.NewFlow(config, config.SelfServiceFlowRecoveryRequestLifespan(ctx), s.deps.CSRFHandler().RegenerateToken(w, r), r, s, ft) - if err != nil { - return err +func RetryWithError(err error) RetryOption { + return func(r *retry) { + r.err = err } +} - f.UI.Messages.Add(message) - if err := s.deps.RecoveryFlowPersister().CreateRecoveryFlow(ctx, f); err != nil { - return err +func RetryWithMessage(msg *text.Message) RetryOption { + return func(r *retry) { + r.message = msg } +} - if x.IsJSONRequest(r) { - http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(config.SelfPublicURL(ctx), - recovery.RouteGetFlow), url.Values{"id": {f.ID.String()}}).String(), http.StatusSeeOther) - } else { - http.Redirect(w, r, f.AppendTo(config.SelfServiceFlowRecoveryUI(ctx)).String(), http.StatusSeeOther) - } +func (s *Strategy) retryRecoveryFlow(w http.ResponseWriter, r *http.Request, ft flow.Type, opts ...RetryOption) error { + retryOptions := retry{} - return errors.WithStack(flow.ErrCompletedByStrategy) -} + for _, o := range opts { + o(&retryOptions) + } -func (s *Strategy) retryRecoveryFlowWithError(w http.ResponseWriter, r *http.Request, ft flow.Type, recErr error) error { s.deps.Logger(). WithRequest(r). - WithError(recErr). + WithField("message", retryOptions.message). + WithError(retryOptions.err). Debug("A recovery flow is being retried because a validation error occurred.") ctx := r.Context() config := s.deps.Config() - if expired := new(flow.ExpiredError); errors.As(recErr, &expired) { - return s.retryRecoveryFlowWithMessage(w, r, ft, text.NewErrorValidationRecoveryFlowExpired(expired.ExpiredAt)) - } - f, err := recovery.NewFlow(config, config.SelfServiceFlowRecoveryRequestLifespan(ctx), s.deps.CSRFHandler().RegenerateToken(w, r), r, s, ft) if err != nil { return err } - if err := f.UI.ParseError(node.CodeGroup, recErr); err != nil { - return err + + if retryOptions.message != nil { + f.UI.Messages.Add(retryOptions.message) + } + + if retryOptions.err != nil { + if expired := new(flow.ExpiredError); errors.As(retryOptions.err, &expired) { + f.UI.Messages.Add(text.NewErrorValidationRecoveryFlowExpired(expired.ExpiredAt)) + } else if err := f.UI.ParseError(node.CodeGroup, retryOptions.err); err != nil { + return err + } } if err := s.deps.RecoveryFlowPersister().CreateRecoveryFlow(ctx, f); err != nil { return err } - if x.IsJSONRequest(r) { - http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(config.SelfPublicURL(ctx), - recovery.RouteGetFlow), url.Values{"id": {f.ID.String()}}).String(), http.StatusSeeOther) - } else { + switch { + case f.Type.IsAPI(): + rErr := new(herodot.DefaultError) + if !errors.As(retryOptions.err, &rErr) { + rErr = rErr.WithError(retryOptions.err.Error()) + } + s.deps.Writer().WriteError(w, r, flow.ErrorWithContinueWith(rErr, flow.NewContinueWithRecoveryUI(f))) + case x.IsJSONRequest(r): + http.Redirect(w, r, urlx.CopyWithQuery( + urlx.AppendPaths(config.SelfPublicURL(ctx), recovery.RouteGetFlow), + url.Values{"id": {f.ID.String()}}, + ).String(), http.StatusSeeOther) + default: http.Redirect(w, r, f.AppendTo(config.SelfServiceFlowRecoveryUI(ctx)).String(), http.StatusSeeOther) } diff --git a/selfservice/strategy/code/strategy_recovery_admin.go b/selfservice/strategy/code/strategy_recovery_admin.go index a88b8b14b5b0..5880cb533383 100644 --- a/selfservice/strategy/code/strategy_recovery_admin.go +++ b/selfservice/strategy/code/strategy_recovery_admin.go @@ -160,7 +160,7 @@ func (s *Strategy) createRecoveryCodeForIdentity(w http.ResponseWriter, r *http. return } recoveryFlow.DangerousSkipCSRFCheck = true - recoveryFlow.State = recovery.StateEmailSent + recoveryFlow.State = flow.StateEmailSent recoveryFlow.UI.Nodes = node.Nodes{} recoveryFlow.UI.Nodes.Append(node.NewInputField("code", nil, node.CodeGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute). WithMetaLabel(text.NewInfoNodeLabelRecoveryCode()), diff --git a/selfservice/strategy/code/strategy_recovery_test.go b/selfservice/strategy/code/strategy_recovery_test.go index ce5686dfad27..11b04340b202 100644 --- a/selfservice/strategy/code/strategy_recovery_test.go +++ b/selfservice/strategy/code/strategy_recovery_test.go @@ -213,7 +213,7 @@ func TestRecovery(t *testing.T) { return submitRecovery(t, hc, flowType, values, code) } - ExpectVerfiableAddressStatus := func(t *testing.T, email string, status identity.VerifiableAddressStatus) { + expectVerfiableAddressStatus := func(t *testing.T, email string, status identity.VerifiableAddressStatus) { addr, err := reg.IdentityPool(). FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, email) assert.NoError(t, err) @@ -222,7 +222,7 @@ func TestRecovery(t *testing.T) { t.Run("description=should recover an account", func(t *testing.T) { checkRecovery := func(t *testing.T, client *http.Client, flowType, recoveryEmail, recoverySubmissionResponse string) string { - ExpectVerfiableAddressStatus(t, recoveryEmail, identity.VerifiableAddressStatusPending) + expectVerfiableAddressStatus(t, recoveryEmail, identity.VerifiableAddressStatusPending) assert.EqualValues(t, node.CodeGroup, gjson.Get(recoverySubmissionResponse, "active").String(), "%s", recoverySubmissionResponse) assert.True(t, gjson.Get(recoverySubmissionResponse, "ui.nodes.#(attributes.name==code)").Exists(), "%s", recoverySubmissionResponse) @@ -508,36 +508,23 @@ func TestRecovery(t *testing.T) { conf.Set(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, false) }) - check := func(t *testing.T, c *http.Client, flowType, email string) { - withValues := func(v url.Values) { - v.Set("email", email) - } - body := submitRecovery(t, c, flowType, withValues, http.StatusOK) - assert.EqualValues(t, node.CodeGroup, gjson.Get(body, "active").String(), "%s", body) - assert.Empty(t, gjson.Get(body, "ui.nodes.#(attributes.name==code).attributes.value").String(), "%s", body) - assertx.EqualAsJSON(t, text.NewRecoveryEmailWithCodeSent(), json.RawMessage(gjson.Get(body, "ui.messages.0").Raw)) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.FlowType, func(t *testing.T) { + email := x.NewUUID().String() + "@ory.sh" + c := testCase.GetClient(t) + withValues := func(v url.Values) { + v.Set("email", email) + } + body := submitRecovery(t, c, testCase.FlowType, withValues, http.StatusOK) + assert.EqualValues(t, node.CodeGroup, gjson.Get(body, "active").String(), "%s", body) + assert.Empty(t, gjson.Get(body, "ui.nodes.#(attributes.name==code).attributes.value").String(), "%s", body) + assertx.EqualAsJSON(t, text.NewRecoveryEmailWithCodeSent(), json.RawMessage(gjson.Get(body, "ui.messages.0").Raw)) - message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Account access attempted") - assert.Contains(t, message.Body, "If this was you, check if you signed up using a different address.") + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Account access attempted") + assert.Contains(t, message.Body, "If this was you, check if you signed up using a different address.") + }) } - t.Run("type=browser", func(t *testing.T) { - email := "recover_browser@ory.sh" - c := browserHttpClient(t) - check(t, c, RecoveryFlowTypeBrowser, email) - }) - - t.Run("type=spa", func(t *testing.T) { - email := "recover_spa@ory.sh" - c := spaHttpClient(t) - check(t, c, RecoveryFlowTypeSPA, email) - }) - - t.Run("type=api", func(t *testing.T) { - email := "recover_api@ory.sh" - c := apiHttpClient(t) - check(t, c, RecoveryFlowTypeAPI, email) - }) }) t.Run("description=should not be able to recover an inactive account", func(t *testing.T) { @@ -560,10 +547,13 @@ func TestRecovery(t *testing.T) { // Deactivate the identity require.NoError(t, reg.Persister().GetConnection(context.Background()).RawQuery("UPDATE identities SET state=? WHERE id = ?", identity.StateInactive, addr.IdentityID).Exec()) - if flowType.FlowType == RecoveryFlowTypeAPI || flowType.FlowType == RecoveryFlowTypeSPA { + switch flowType.FlowType { + case RecoveryFlowTypeAPI: + fallthrough + case RecoveryFlowTypeSPA: body = submitRecoveryCode(t, cl, body, flowType.FlowType, recoveryCode, http.StatusUnauthorized) assertx.EqualAsJSON(t, session.ErrIdentityDisabled.WithDetail("identity_id", addr.IdentityID), json.RawMessage(gjson.Get(body, "error").Raw), "%s", body) - } else { + default: body = submitRecoveryCode(t, cl, body, flowType.FlowType, recoveryCode, http.StatusOK) assertx.EqualAsJSON(t, session.ErrIdentityDisabled.WithDetail("identity_id", addr.IdentityID), json.RawMessage(body), "%s", body) } @@ -617,30 +607,57 @@ func TestRecovery(t *testing.T) { }) t.Run("description=should not be able to use an invalid code more than 5 times", func(t *testing.T) { - email := testhelpers.RandomEmail() - createIdentityToRecover(t, reg, email) - c := testhelpers.NewClientWithCookies(t) - body := submitRecovery(t, c, RecoveryFlowTypeBrowser, func(v url.Values) { - v.Set("email", email) - }, http.StatusOK) - initialFlowId := gjson.Get(body, "id") - - for submitTry := 0; submitTry < 5; submitTry++ { - body := submitRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, "12312312", http.StatusOK) - - testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") - } + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.FlowType, func(t *testing.T) { + email := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, email) + c := testCase.GetClient(t) + body := submitRecovery(t, c, testCase.FlowType, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) - // submit an invalid code for the 6th time - body = submitRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, "12312312", http.StatusOK) + initialFlowId := gjson.Get(body, "id") - require.Len(t, gjson.Get(body, "ui.messages").Array(), 1) - assert.Equal(t, "The request was submitted too often. Please request another code.", gjson.Get(body, "ui.messages.0.text").String()) + for submitTry := 0; submitTry < 5; submitTry++ { + body := submitRecoveryCode(t, c, body, testCase.FlowType, "12312312", http.StatusOK) - // check that a new flow has been created - assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + } - assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==email)").Exists()) + switch testCase.FlowType { + case RecoveryFlowTypeBrowser: + fallthrough + case RecoveryFlowTypeSPA: + // submit an invalid code for the 6th time + body = submitRecoveryCode(t, c, body, testCase.FlowType, "12312312", http.StatusOK) + + require.Len(t, gjson.Get(body, "ui.messages").Array(), 1, "%s", body) + assert.Equal(t, "The request was submitted too often. Please request another code.", gjson.Get(body, "ui.messages.0.text").String()) + + // check that a new flow has been created + assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + + assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==email)").Exists()) + case RecoveryFlowTypeAPI: + // submit an invalid code for the 6th time + body = submitRecoveryCode(t, c, body, testCase.FlowType, "12312312", http.StatusBadRequest) + + assert.Equal(t, "Bad Request", gjson.Get(body, "error.status").String(), "%s", body) + assert.Equal(t, "The request was submitted too often. Please request another code.", gjson.Get(body, "error.reason").String(), "%s", body) + continueWith := gjson.Get(body, "error.details.continue_with").Array() + assert.Len(t, continueWith, 1, "%s", body) + assert.Equal(t, "show_recovery_ui", continueWith[0].Get("action").String(), "%s", body) + flowId := continueWith[0].Get("flow.id").String() + assert.NotEmpty(t, flowId, "%s", body) + require.NotEqual(t, flowId, initialFlowId, "%s", body) + + rf, _, err := testhelpers.NewSDKClient(public).FrontendApi.GetRecoveryFlow(context.Background()).Id(flowId).Execute() + require.NoError(t, err) + assert.Len(t, rf.Ui.Messages, 1, "%+v", rf) + assert.Equal(t, "The request was submitted too often. Please request another code.", rf.Ui.Messages[0].GetText()) + } + }) + } }) t.Run("description=should be able to recover after using invalid code", func(t *testing.T) { @@ -710,46 +727,75 @@ func TestRecovery(t *testing.T) { }) t.Run("description=should not be able to use an invalid code", func(t *testing.T) { - email := "recoverme+invalid_code@ory.sh" - createIdentityToRecover(t, reg, email) - c := testhelpers.NewClientWithCookies(t) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.FlowType, func(t *testing.T) { + email := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, email) + c := testCase.GetClient(t) - body := submitRecovery(t, c, RecoveryFlowTypeBrowser, func(v url.Values) { - v.Set("email", email) - }, http.StatusOK) + body := submitRecovery(t, c, testCase.FlowType, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) - body = submitRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, "12312312", http.StatusOK) + body = submitRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, "12312312", http.StatusOK) - testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + }) + } }) t.Run("description=should not be able to submit recover address after flow expired", func(t *testing.T) { - recoveryEmail := "recoverme5@ory.sh" - createIdentityToRecover(t, reg, recoveryEmail) - conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Millisecond*200) - t.Cleanup(func() { - conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Minute) - }) - - c := testhelpers.NewClientWithCookies(t) - rs := testhelpers.GetRecoveryFlow(t, c, public) - - time.Sleep(time.Millisecond * 201) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.FlowType, func(t *testing.T) { + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Millisecond*10) + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Minute) + }) - res, err := c.PostForm(rs.Ui.Action, url.Values{"email": {recoveryEmail}}) - require.NoError(t, err) - assert.EqualValues(t, http.StatusOK, res.StatusCode) - assert.NotContains(t, res.Request.URL.String(), "flow="+rs.Id) - assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String()) + c := testCase.GetClient(t) + var rs *kratos.RecoveryFlow + var res *http.Response + var err error + switch testCase.FlowType { + case RecoveryFlowTypeBrowser: + fallthrough + case RecoveryFlowTypeSPA: + rs = testhelpers.GetRecoveryFlow(t, c, public) + time.Sleep(time.Millisecond * 11) + res, err = c.PostForm(rs.Ui.Action, url.Values{"email": {recoveryEmail}, "method": {"code"}}) + require.NoError(t, err) + assert.EqualValues(t, http.StatusOK, res.StatusCode) + assert.NotContains(t, res.Request.URL.String(), "flow="+rs.Id) + assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String()) + case RecoveryFlowTypeAPI: + rs = testhelpers.InitializeRecoveryFlowViaAPI(t, c, public) + time.Sleep(time.Millisecond * 11) + form := testhelpers.EncodeFormAsJSON(t, true, url.Values{"email": {recoveryEmail}, "method": {"code"}}) + res, err = c.Post(rs.Ui.Action, "application/json", bytes.NewBufferString(form)) + require.NoError(t, err) + body := ioutilx.MustReadAll(res.Body) + assert.Equal(t, http.StatusGone, res.StatusCode, "%s", body) + assert.Equal(t, "self_service_flow_expired", gjson.GetBytes(body, "error.id").String(), "%s", body) + continueWith := gjson.GetBytes(body, "error.details.continue_with").Array() + assert.Len(t, continueWith, 1, "%s", body) + assert.Equal(t, "show_recovery_ui", continueWith[0].Get("action").String(), "%s", continueWith) + flowId := continueWith[0].Get("flow.id").String() + require.NotEmpty(t, flowId, "%s", body) + } - addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) - assert.NoError(t, err) - assert.False(t, addr.Verified) - assert.Nil(t, addr.VerifiedAt) - assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status) + addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) + assert.NoError(t, err) + assert.False(t, addr.Verified) + assert.Nil(t, addr.VerifiedAt) + assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status) + }) + } }) t.Run("description=should not be able to submit code after flow expired", func(t *testing.T) { + // TODO: combine with test above? recoveryEmail := "recoverme6@ory.sh" createIdentityToRecover(t, reg, recoveryEmail) conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Millisecond*200) @@ -786,44 +832,52 @@ func TestRecovery(t *testing.T) { }) t.Run("description=should not break ui if empty code is submitted", func(t *testing.T) { - recoveryEmail := "recoverme7@ory.sh" - createIdentityToRecover(t, reg, recoveryEmail) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.FlowType, func(t *testing.T) { + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) - c := testhelpers.NewClientWithCookies(t) - body := expectSuccessfulRecovery(t, c, RecoveryFlowTypeBrowser, func(v url.Values) { - v.Set("email", recoveryEmail) - }) + c := testCase.GetClient(t) + body := submitRecovery(t, c, testCase.FlowType, func(v url.Values) { + v.Set("email", recoveryEmail) + }, http.StatusOK) - action := gjson.Get(body, "ui.action").String() - require.NotEmpty(t, action) + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) - body = submitRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, "", http.StatusOK) + body = submitRecoveryCode(t, c, body, testCase.FlowType, "", http.StatusOK) - assert.NotContains(t, gjson.Get(body, "ui.nodes").String(), "Property email is missing.") - testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + assert.NotContains(t, gjson.Get(body, "ui.nodes").String(), "Property email is missing.") + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + }) + } }) t.Run("description=should be able to re-send the recovery code", func(t *testing.T) { - recoveryEmail := testhelpers.RandomEmail() - createIdentityToRecover(t, reg, recoveryEmail) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.FlowType, func(t *testing.T) { + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) - c := testhelpers.NewClientWithCookies(t) - body := expectSuccessfulRecovery(t, c, RecoveryFlowTypeBrowser, func(v url.Values) { - v.Set("email", recoveryEmail) - }) + c := testCase.GetClient(t) + body := submitRecovery(t, c, testCase.FlowType, func(v url.Values) { + v.Set("email", recoveryEmail) + }, http.StatusOK) - action := gjson.Get(body, "ui.action").String() - require.NotEmpty(t, action) - assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) - body = resendRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, http.StatusOK) - assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==code)").Exists()) - assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + body = resendRecoveryCode(t, c, body, testCase.FlowType, http.StatusOK) + assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==code)").Exists()) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) - message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") - recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) - submitRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, recoveryCode, http.StatusOK) + submitRecoveryCode(t, c, body, testCase.FlowType, recoveryCode, http.StatusOK) + }) + } }) t.Run("description=should not be able to use first code after re-sending email", func(t *testing.T) { diff --git a/test/e2e/playwright/fixtures/index.ts b/test/e2e/playwright/fixtures/index.ts index 5d4a4cabdf05..3b1264e84c0a 100644 --- a/test/e2e/playwright/fixtures/index.ts +++ b/test/e2e/playwright/fixtures/index.ts @@ -9,9 +9,16 @@ import { default_config } from "../setup/default_config" import { writeFile } from "fs/promises" import { faker } from "@faker-js/faker" +// from https://stackoverflow.com/questions/61132262/typescript-deep-partial +type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial + } + : T + type TestFixtures = { identity: Identity - configOverride: Partial + configOverride: DeepPartial config: void } diff --git a/test/e2e/playwright/tests/app_recovery.spec.ts b/test/e2e/playwright/tests/app_recovery.spec.ts index 82b671ce53c0..242d78099dac 100644 --- a/test/e2e/playwright/tests/app_recovery.spec.ts +++ b/test/e2e/playwright/tests/app_recovery.spec.ts @@ -6,18 +6,22 @@ import { test } from "../fixtures" import { search } from "../actions/mail" import { extractCode } from "../lib/helper" +const schemaConfig = { + default_schema_id: "email", + schemas: [ + { + id: "email", + url: "file://test/e2e/profiles/email/identity.traits.schema.json", + }, + ], +} + test.describe.configure({ mode: "parallel" }) test.describe("Recovery", () => { test.use({ configOverride: { identity: { - default_schema_id: "email", - schemas: [ - { - id: "email", - url: "file://test/e2e/profiles/email/identity.traits.schema.json", - }, - ], + ...schemaConfig, }, }, }) @@ -92,4 +96,40 @@ test.describe("Recovery", () => { await expect(page.getByTestId("ui/message/4060006")).toBeVisible() }) }) + + test.describe("with short code expiration", () => { + test.use({ + configOverride: { + identity: { + ...schemaConfig, + }, + selfservice: { + methods: { + code: { + config: { + lifespan: "1s", + }, + }, + }, + }, + }, + }) + + test("fails with an expired code", async ({ page, identity }) => { + await page.goto("/Recovery") + + await page.getByTestId("email").fill(identity.traits.email) + await page.getByTestId("submit-form").click() + await page.getByTestId("ui/message/1060003").isVisible() + + const mails = await search(identity.traits.email, "to") + expect(mails).toHaveLength(1) + + const code = extractCode(mails[0]) + + await page.getByTestId("code").fill(code) + await page.getByText("Submit").click() + await expect(page.getByTestId("ui/message/4060006")).toBeVisible() + }) + }) }) From 9871f22af5f7400ab3caa13947927e959aa71a4f Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Fri, 8 Sep 2023 17:10:11 +0200 Subject: [PATCH 04/25] chore: more cleanup --- .../testhelpers/selfservice_verification.go | 35 ++- selfservice/flow/continue_with.go | 3 +- ...he_correct_recovery_payloads-type=api.json | 53 +++++ ...orrect_recovery_payloads-type=browser.json | 53 +++++ ...he_correct_recovery_payloads-type=spa.json | 53 +++++ ...ry_payloads_after_submission-type=api.json | 85 +++++++ ...ayloads_after_submission-type=browser.json | 85 +++++++ ...ry_payloads_after_submission-type=spa.json | 85 +++++++ .../strategy/code/strategy_recovery.go | 3 +- .../strategy/code/strategy_recovery_test.go | 218 ++++++++++-------- 10 files changed, 575 insertions(+), 98 deletions(-) create mode 100644 selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=api.json create mode 100644 selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=browser.json create mode 100644 selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=spa.json create mode 100644 selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json create mode 100644 selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json create mode 100644 selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json diff --git a/internal/testhelpers/selfservice_verification.go b/internal/testhelpers/selfservice_verification.go index c1b84f3d5430..1deaafa6c215 100644 --- a/internal/testhelpers/selfservice_verification.go +++ b/internal/testhelpers/selfservice_verification.go @@ -14,6 +14,7 @@ import ( "time" kratos "github.com/ory/kratos/internal/httpclient" + "github.com/tidwall/gjson" "github.com/ory/x/ioutilx" @@ -39,25 +40,47 @@ func NewRecoveryUIFlowEchoServer(t *testing.T, reg driver.Registry) *httptest.Se return ts } -func GetRecoveryFlow(t *testing.T, client *http.Client, ts *httptest.Server) *kratos.RecoveryFlow { +func GetRecoveryFlowForType(t *testing.T, client *http.Client, ts *httptest.Server, _type flow.Type) *kratos.RecoveryFlow { publicClient := NewSDKCustomClient(ts, client) - res, err := client.Get(ts.URL + recovery.RouteInitBrowserFlow) + var url string + switch _type { + case flow.TypeBrowser: + url = ts.URL + recovery.RouteInitBrowserFlow + case flow.TypeAPI: + url = ts.URL + recovery.RouteInitAPIFlow + default: + panic("unknown type") + } + + res, err := client.Get(url) require.NoError(t, err) - require.NoError(t, res.Body.Close()) - flowID := res.Request.URL.Query().Get("flow") - assert.NotEmpty(t, flowID, "expected to receive a flow id, got none") + var flowID string + + switch _type { + case flow.TypeBrowser: + flowID = res.Request.URL.Query().Get("flow") + case flow.TypeAPI: + flowID = gjson.GetBytes(ioutilx.MustReadAll(res.Body), "id").String() + default: + panic("unknown type") + } + require.NotEmpty(t, flowID, "expected to receive a flow id, got none. %s", ioutilx.MustReadAll(res.Body)) rs, _, err := publicClient.FrontendApi.GetRecoveryFlow(context.Background()). Id(flowID). Execute() - assert.NotEmpty(t, rs.Active) require.NoError(t, err, "expected no error when fetching recovery flow: %s", err) + assert.NotEmpty(t, rs.Active) return rs } +func GetRecoveryFlow(t *testing.T, client *http.Client, ts *httptest.Server) *kratos.RecoveryFlow { + return GetRecoveryFlowForType(t, client, ts, flow.TypeBrowser) +} + func InitializeRecoveryFlowViaBrowser(t *testing.T, client *http.Client, isSPA bool, ts *httptest.Server, values url.Values) *kratos.RecoveryFlow { publicClient := NewSDKCustomClient(ts, client) diff --git a/selfservice/flow/continue_with.go b/selfservice/flow/continue_with.go index 96ab76835746..fced37d8b137 100644 --- a/selfservice/flow/continue_with.go +++ b/selfservice/flow/continue_with.go @@ -4,9 +4,10 @@ package flow import ( - "github.com/ory/herodot" "net/url" + "github.com/ory/herodot" + "github.com/gofrs/uuid" "github.com/ory/x/urlx" diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=api.json new file mode 100644 index 000000000000..ec1092ad77a6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=api.json @@ -0,0 +1,53 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "email", + "node_type": "input", + "required": true, + "type": "email" + }, + "group": "code", + "messages": [], + "meta": { + "label": { + "id": 1070007, + "text": "Email", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "method", + "node_type": "input", + "type": "submit", + "value": "code" + }, + "group": "code", + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + }, + "type": "input" + } +] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=browser.json new file mode 100644 index 000000000000..ec1092ad77a6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=browser.json @@ -0,0 +1,53 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "email", + "node_type": "input", + "required": true, + "type": "email" + }, + "group": "code", + "messages": [], + "meta": { + "label": { + "id": 1070007, + "text": "Email", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "method", + "node_type": "input", + "type": "submit", + "value": "code" + }, + "group": "code", + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + }, + "type": "input" + } +] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=spa.json new file mode 100644 index 000000000000..ec1092ad77a6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=spa.json @@ -0,0 +1,53 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "email", + "node_type": "input", + "required": true, + "type": "email" + }, + "group": "code", + "messages": [], + "meta": { + "label": { + "id": 1070007, + "text": "Email", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "method", + "node_type": "input", + "type": "submit", + "value": "code" + }, + "group": "code", + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + }, + "type": "input" + } +] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json new file mode 100644 index 000000000000..36fe7b033ce7 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json @@ -0,0 +1,85 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "code", + "type": "text", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070010, + "text": "Recovery code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "hidden", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "email", + "type": "submit", + "value": "test@ory.sh", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070008, + "text": "Resend code", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json new file mode 100644 index 000000000000..36fe7b033ce7 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json @@ -0,0 +1,85 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "code", + "type": "text", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070010, + "text": "Recovery code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "hidden", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "email", + "type": "submit", + "value": "test@ory.sh", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070008, + "text": "Resend code", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json new file mode 100644 index 000000000000..36fe7b033ce7 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json @@ -0,0 +1,85 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "code", + "type": "text", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070010, + "text": "Recovery code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "hidden", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "email", + "type": "submit", + "value": "test@ory.sh", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070008, + "text": "Resend code", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/code/strategy_recovery.go b/selfservice/strategy/code/strategy_recovery.go index b0eff420e11a..59812553bb88 100644 --- a/selfservice/strategy/code/strategy_recovery.go +++ b/selfservice/strategy/code/strategy_recovery.go @@ -4,11 +4,12 @@ package code import ( - "github.com/ory/herodot" "net/http" "net/url" "time" + "github.com/ory/herodot" + "github.com/gofrs/uuid" "github.com/pkg/errors" diff --git a/selfservice/strategy/code/strategy_recovery_test.go b/selfservice/strategy/code/strategy_recovery_test.go index 11b04340b202..9d0166905935 100644 --- a/selfservice/strategy/code/strategy_recovery_test.go +++ b/selfservice/strategy/code/strategy_recovery_test.go @@ -28,6 +28,7 @@ import ( "github.com/ory/kratos/internal" kratos "github.com/ory/kratos/internal/httpclient" "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/recovery" "github.com/ory/kratos/selfservice/strategy/code" "github.com/ory/kratos/session" @@ -69,24 +70,29 @@ func browserHttpClient(t *testing.T) *http.Client { var flowTypes = []string{RecoveryFlowTypeBrowser, RecoveryFlowTypeAPI, RecoveryFlowTypeSPA} var flowTypeCases = []struct { - FlowType string + FlowType flow.Type + AppType string GetClient func(*testing.T) *http.Client FormContentType string }{ { - FlowType: RecoveryFlowTypeBrowser, + FlowType: flow.TypeBrowser, + AppType: RecoveryFlowTypeBrowser, GetClient: testhelpers.NewClientWithCookies, FormContentType: "application/x-www-form-urlencoded", }, { - FlowType: RecoveryFlowTypeAPI, + + FlowType: flow.TypeAPI, + AppType: RecoveryFlowTypeAPI, GetClient: func(_ *testing.T) *http.Client { return &http.Client{} }, FormContentType: "application/json", }, { - FlowType: RecoveryFlowTypeSPA, + FlowType: flow.TypeBrowser, + AppType: RecoveryFlowTypeSPA, GetClient: testhelpers.NewClientWithCookies, FormContentType: "application/json", }, @@ -128,6 +134,15 @@ func createIdentityToRecover(t *testing.T, reg *driver.RegistryDefault, email st return id } +func RunFlowTypes(t *testing.T, tf func(t *testing.T)) { + + for _, flowType := range flowTypes { + t.Run("type="+flowType, func(t *testing.T) { + tf(t) + }) + } +} + func TestRecovery(t *testing.T) { ctx := context.Background() conf, reg := internal.NewFastRegistryWithMocks(t) @@ -404,19 +419,27 @@ func TestRecovery(t *testing.T) { }) t.Run("description=should set all the correct recovery payloads after submission", func(t *testing.T) { - body := expectSuccessfulRecovery(t, nil, RecoveryFlowTypeBrowser, func(v url.Values) { - v.Set("email", "test@ory.sh") - }) - testhelpers.SnapshotTExcept(t, json.RawMessage(gjson.Get(body, "ui.nodes").String()), []string{"0.attributes.value"}) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.AppType, func(t *testing.T) { + body := submitRecovery(t, testCase.GetClient(t), testCase.AppType, func(v url.Values) { + v.Set("email", "test@ory.sh") + }, http.StatusOK) + testhelpers.SnapshotTExcept(t, json.RawMessage(gjson.Get(body, "ui.nodes").String()), []string{"0.attributes.value"}) + }) + } }) t.Run("description=should set all the correct recovery payloads", func(t *testing.T) { - c := testhelpers.NewClientWithCookies(t) - rs := testhelpers.GetRecoveryFlow(t, c, public) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.AppType, func(t *testing.T) { + c := testCase.GetClient(t) + rs := testhelpers.GetRecoveryFlowForType(t, c, public, testCase.FlowType) - testhelpers.SnapshotTExcept(t, rs.Ui.Nodes, []string{"0.attributes.value"}) - assert.EqualValues(t, public.URL+recovery.RouteSubmitFlow+"?flow="+rs.Id, rs.Ui.Action) - assert.Empty(t, rs.Ui.Messages) + testhelpers.SnapshotTExcept(t, rs.Ui.Nodes, []string{"0.attributes.value"}) + assert.EqualValues(t, public.URL+recovery.RouteSubmitFlow+"?flow="+rs.Id, rs.Ui.Action) + assert.Empty(t, rs.Ui.Messages) + }) + } }) t.Run("description=should require an email to be sent", func(t *testing.T) { @@ -435,8 +458,8 @@ func TestRecovery(t *testing.T) { t.Run("description=should require a valid email to be sent", func(t *testing.T) { for _, flowType := range flowTypes { - for _, email := range []string{"\\", "asdf", "...", "aiacobelli.sec@gmail.com,alejandro.iacobelli@mercadolibre.com"} { - t.Run("type="+flowType, func(t *testing.T) { + t.Run("type="+flowType, func(t *testing.T) { + for _, email := range []string{"\\", "asdf", "...", "aiacobelli.sec@gmail.com,alejandro.iacobelli@mercadolibre.com"} { responseJSON := expectValidationError(t, nil, flowType, func(v url.Values) { v.Set("email", email) }) @@ -445,21 +468,17 @@ func TestRecovery(t *testing.T) { expectedMessage := fmt.Sprintf("%q is not valid \"email\"", email) actualMessage := gjson.Get(responseJSON, "ui.nodes.#(attributes.name==email).messages.0.text").String() assert.EqualValues(t, expectedMessage, actualMessage, "%s", responseJSON) - }) - } + } + }) } }) t.Run("description=should try to submit the form while authenticated", func(t *testing.T) { - for _, flowType := range flowTypes { - t.Run("type="+flowType, func(t *testing.T) { - isSPA := flowType == "spa" - isAPI := flowType == "api" - client := testhelpers.NewDebugClient(t) - if !isAPI { - client = testhelpers.NewClientWithCookies(t) - client.Transport = testhelpers.NewTransportWithLogger(http.DefaultTransport, t).RoundTripper - } + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.AppType, func(t *testing.T) { + isSPA := testCase.AppType == "spa" + isAPI := testCase.AppType == "api" + client := testCase.GetClient(t) var f *kratos.RecoveryFlow if isAPI { @@ -509,13 +528,13 @@ func TestRecovery(t *testing.T) { }) for _, testCase := range flowTypeCases { - t.Run("type="+testCase.FlowType, func(t *testing.T) { + t.Run("type="+testCase.AppType, func(t *testing.T) { email := x.NewUUID().String() + "@ory.sh" c := testCase.GetClient(t) withValues := func(v url.Values) { v.Set("email", email) } - body := submitRecovery(t, c, testCase.FlowType, withValues, http.StatusOK) + body := submitRecovery(t, c, testCase.AppType, withValues, http.StatusOK) assert.EqualValues(t, node.CodeGroup, gjson.Get(body, "active").String(), "%s", body) assert.Empty(t, gjson.Get(body, "ui.nodes.#(attributes.name==code).attributes.value").String(), "%s", body) assertx.EqualAsJSON(t, text.NewRecoveryEmailWithCodeSent(), json.RawMessage(gjson.Get(body, "ui.messages.0").Raw)) @@ -524,20 +543,19 @@ func TestRecovery(t *testing.T) { assert.Contains(t, message.Body, "If this was you, check if you signed up using a different address.") }) } - }) t.Run("description=should not be able to recover an inactive account", func(t *testing.T) { - for _, flowType := range flowTypeCases { - t.Run("type="+flowType.FlowType, func(t *testing.T) { - email := "recoverinactive_" + flowType.FlowType + "@ory.sh" + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.AppType, func(t *testing.T) { + email := "recoverinactive_" + testCase.AppType + "@ory.sh" createIdentityToRecover(t, reg, email) values := func(v url.Values) { v.Set("email", email) } cl := testhelpers.NewClientWithCookies(t) - body := submitRecovery(t, cl, flowType.FlowType, values, http.StatusOK) + body := submitRecovery(t, cl, testCase.AppType, values, http.StatusOK) addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, email) assert.NoError(t, err) @@ -547,14 +565,14 @@ func TestRecovery(t *testing.T) { // Deactivate the identity require.NoError(t, reg.Persister().GetConnection(context.Background()).RawQuery("UPDATE identities SET state=? WHERE id = ?", identity.StateInactive, addr.IdentityID).Exec()) - switch flowType.FlowType { + switch testCase.AppType { case RecoveryFlowTypeAPI: fallthrough case RecoveryFlowTypeSPA: - body = submitRecoveryCode(t, cl, body, flowType.FlowType, recoveryCode, http.StatusUnauthorized) + body = submitRecoveryCode(t, cl, body, testCase.AppType, recoveryCode, http.StatusUnauthorized) assertx.EqualAsJSON(t, session.ErrIdentityDisabled.WithDetail("identity_id", addr.IdentityID), json.RawMessage(gjson.Get(body, "error").Raw), "%s", body) default: - body = submitRecoveryCode(t, cl, body, flowType.FlowType, recoveryCode, http.StatusOK) + body = submitRecoveryCode(t, cl, body, testCase.AppType, recoveryCode, http.StatusOK) assertx.EqualAsJSON(t, session.ErrIdentityDisabled.WithDetail("identity_id", addr.IdentityID), json.RawMessage(body), "%s", body) } }) @@ -567,69 +585,89 @@ func TestRecovery(t *testing.T) { conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil) }) - email := testhelpers.RandomEmail() - id := createIdentityToRecover(t, reg, email) - - req := httptest.NewRequest("GET", "/sessions/whoami", nil) - sess, err := session.NewActiveSession(req, id, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) - require.NoError(t, err) - require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), sess)) - - actualSession, err := reg.SessionPersister().GetSession(context.Background(), sess.ID, session.ExpandNothing) - require.NoError(t, err) - assert.True(t, actualSession.IsActive()) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.AppType, func(t *testing.T) { + email := testhelpers.RandomEmail() + id := createIdentityToRecover(t, reg, email) - cl := testhelpers.NewClientWithCookies(t) - actual := expectSuccessfulRecovery(t, cl, RecoveryFlowTypeBrowser, func(v url.Values) { - v.Set("email", email) - }) - message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Recover access to your account") - recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + sess, err := session.NewActiveSession(httptest.NewRequest("GET", "/sessions/whoami", nil), id, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) + require.NoError(t, err) + require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess)) - cl.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } + actualSession, err := reg.SessionPersister().GetSession(ctx, sess.ID, session.ExpandNothing) + require.NoError(t, err) + assert.True(t, actualSession.IsActive()) - action := gjson.Get(actual, "ui.action").String() - require.NotEmpty(t, action) - csrf_token := gjson.Get(actual, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() - require.NotEmpty(t, csrf_token) + cl := testCase.GetClient(t) + actual := submitRecovery(t, cl, testCase.AppType, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) - submitRecoveryCode(t, cl, actual, RecoveryFlowTypeBrowser, recoveryCode, http.StatusSeeOther) + cl.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } - require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) - cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) - assert.Contains(t, cookies, "ory_kratos_session") + switch testCase.AppType { + case RecoveryFlowTypeBrowser: + action := gjson.Get(actual, "ui.action").String() + require.NotEmpty(t, action) + csrf_token := gjson.Get(actual, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() + require.NotEmpty(t, csrf_token, "%s", actual) + + submitRecoveryCode(t, cl, actual, testCase.AppType, recoveryCode, http.StatusSeeOther) + require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) + cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.Contains(t, cookies, "ory_kratos_session") + // TODO + case RecoveryFlowTypeSPA: + action := gjson.Get(actual, "ui.action").String() + require.NotEmpty(t, action) + csrf_token := gjson.Get(actual, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() + require.NotEmpty(t, csrf_token, "%s", actual) + + submitRecoveryCode(t, cl, actual, testCase.AppType, recoveryCode, 422) + require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) + cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.Contains(t, cookies, "ory_kratos_session") + // TODO + case RecoveryFlowTypeAPI: + x := submitRecoveryCode(t, cl, actual, testCase.AppType, recoveryCode, 200) + t.Logf("%s", x) + } - actualSession, err = reg.SessionPersister().GetSession(context.Background(), sess.ID, session.ExpandNothing) - require.NoError(t, err) - assert.False(t, actualSession.IsActive()) + actualSession, err = reg.SessionPersister().GetSession(ctx, sess.ID, session.ExpandNothing) + require.NoError(t, err) + assert.False(t, actualSession.IsActive()) + }) + } }) t.Run("description=should not be able to use an invalid code more than 5 times", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.FlowType, func(t *testing.T) { + t.Run("type="+testCase.AppType, func(t *testing.T) { email := testhelpers.RandomEmail() createIdentityToRecover(t, reg, email) c := testCase.GetClient(t) - body := submitRecovery(t, c, testCase.FlowType, func(v url.Values) { + body := submitRecovery(t, c, testCase.AppType, func(v url.Values) { v.Set("email", email) }, http.StatusOK) initialFlowId := gjson.Get(body, "id") for submitTry := 0; submitTry < 5; submitTry++ { - body := submitRecoveryCode(t, c, body, testCase.FlowType, "12312312", http.StatusOK) + body := submitRecoveryCode(t, c, body, testCase.AppType, "12312312", http.StatusOK) testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") } - switch testCase.FlowType { + switch testCase.AppType { case RecoveryFlowTypeBrowser: fallthrough case RecoveryFlowTypeSPA: // submit an invalid code for the 6th time - body = submitRecoveryCode(t, c, body, testCase.FlowType, "12312312", http.StatusOK) + body = submitRecoveryCode(t, c, body, testCase.AppType, "12312312", http.StatusOK) require.Len(t, gjson.Get(body, "ui.messages").Array(), 1, "%s", body) assert.Equal(t, "The request was submitted too often. Please request another code.", gjson.Get(body, "ui.messages.0.text").String()) @@ -640,7 +678,7 @@ func TestRecovery(t *testing.T) { assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==email)").Exists()) case RecoveryFlowTypeAPI: // submit an invalid code for the 6th time - body = submitRecoveryCode(t, c, body, testCase.FlowType, "12312312", http.StatusBadRequest) + body = submitRecoveryCode(t, c, body, testCase.AppType, "12312312", http.StatusBadRequest) assert.Equal(t, "Bad Request", gjson.Get(body, "error.status").String(), "%s", body) assert.Equal(t, "The request was submitted too often. Please request another code.", gjson.Get(body, "error.reason").String(), "%s", body) @@ -662,19 +700,19 @@ func TestRecovery(t *testing.T) { t.Run("description=should be able to recover after using invalid code", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.FlowType, func(t *testing.T) { + t.Run("type="+testCase.AppType, func(t *testing.T) { c := testCase.GetClient(t) recoveryEmail := testhelpers.RandomEmail() _ = createIdentityToRecover(t, reg, recoveryEmail) - actual := submitRecovery(t, c, testCase.FlowType, func(v url.Values) { + actual := submitRecovery(t, c, testCase.AppType, func(v url.Values) { v.Set("email", recoveryEmail) }, http.StatusOK) message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) - form := withCSRFToken(t, testCase.FlowType, actual, url.Values{ + form := withCSRFToken(t, testCase.AppType, actual, url.Values{ "code": {"12312312"}, }) @@ -701,20 +739,20 @@ func TestRecovery(t *testing.T) { require.Len(t, rs.Ui.Messages, 1) assert.Equal(t, "The recovery code is invalid or has already been used. Please try again.", rs.Ui.Messages[0].Text) - form = withCSRFToken(t, testCase.FlowType, actual, url.Values{ + form = withCSRFToken(t, testCase.AppType, actual, url.Values{ "code": {recoveryCode}, }) // Now submit the correct code res, err = c.Post(action, testCase.FormContentType, bytes.NewBufferString(form)) require.NoError(t, err) - if testCase.FlowType == RecoveryFlowTypeBrowser { + if testCase.AppType == RecoveryFlowTypeBrowser { assert.Equal(t, http.StatusOK, res.StatusCode) json := ioutilx.MustReadAll(res.Body) assert.Len(t, gjson.GetBytes(json, "ui.messages").Array(), 1) assert.Contains(t, gjson.GetBytes(json, "ui.messages.0.text").String(), "You successfully recovered your account.") - } else if testCase.FlowType == RecoveryFlowTypeSPA { + } else if testCase.AppType == RecoveryFlowTypeSPA { assert.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) json := ioutilx.MustReadAll(res.Body) @@ -728,12 +766,12 @@ func TestRecovery(t *testing.T) { t.Run("description=should not be able to use an invalid code", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.FlowType, func(t *testing.T) { + t.Run("type="+testCase.AppType, func(t *testing.T) { email := testhelpers.RandomEmail() createIdentityToRecover(t, reg, email) c := testCase.GetClient(t) - body := submitRecovery(t, c, testCase.FlowType, func(v url.Values) { + body := submitRecovery(t, c, testCase.AppType, func(v url.Values) { v.Set("email", email) }, http.StatusOK) @@ -746,7 +784,7 @@ func TestRecovery(t *testing.T) { t.Run("description=should not be able to submit recover address after flow expired", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.FlowType, func(t *testing.T) { + t.Run("type="+testCase.AppType, func(t *testing.T) { recoveryEmail := testhelpers.RandomEmail() createIdentityToRecover(t, reg, recoveryEmail) conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Millisecond*10) @@ -758,7 +796,7 @@ func TestRecovery(t *testing.T) { var rs *kratos.RecoveryFlow var res *http.Response var err error - switch testCase.FlowType { + switch testCase.AppType { case RecoveryFlowTypeBrowser: fallthrough case RecoveryFlowTypeSPA: @@ -833,19 +871,19 @@ func TestRecovery(t *testing.T) { t.Run("description=should not break ui if empty code is submitted", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.FlowType, func(t *testing.T) { + t.Run("type="+testCase.AppType, func(t *testing.T) { recoveryEmail := testhelpers.RandomEmail() createIdentityToRecover(t, reg, recoveryEmail) c := testCase.GetClient(t) - body := submitRecovery(t, c, testCase.FlowType, func(v url.Values) { + body := submitRecovery(t, c, testCase.AppType, func(v url.Values) { v.Set("email", recoveryEmail) }, http.StatusOK) action := gjson.Get(body, "ui.action").String() require.NotEmpty(t, action) - body = submitRecoveryCode(t, c, body, testCase.FlowType, "", http.StatusOK) + body = submitRecoveryCode(t, c, body, testCase.AppType, "", http.StatusOK) assert.NotContains(t, gjson.Get(body, "ui.nodes").String(), "Property email is missing.") testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") @@ -855,12 +893,12 @@ func TestRecovery(t *testing.T) { t.Run("description=should be able to re-send the recovery code", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.FlowType, func(t *testing.T) { + t.Run("type="+testCase.AppType, func(t *testing.T) { recoveryEmail := testhelpers.RandomEmail() createIdentityToRecover(t, reg, recoveryEmail) c := testCase.GetClient(t) - body := submitRecovery(t, c, testCase.FlowType, func(v url.Values) { + body := submitRecovery(t, c, testCase.AppType, func(v url.Values) { v.Set("email", recoveryEmail) }, http.StatusOK) @@ -868,14 +906,14 @@ func TestRecovery(t *testing.T) { require.NotEmpty(t, action) assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) - body = resendRecoveryCode(t, c, body, testCase.FlowType, http.StatusOK) + body = resendRecoveryCode(t, c, body, testCase.AppType, http.StatusOK) assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==code)").Exists()) assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) - submitRecoveryCode(t, c, body, testCase.FlowType, recoveryCode, http.StatusOK) + submitRecoveryCode(t, c, body, testCase.AppType, recoveryCode, http.StatusOK) }) } }) From a77ae9d8ec8f338c28cde5878d185279a4de10a7 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Fri, 15 Sep 2023 18:30:17 +0200 Subject: [PATCH 05/25] chore: finalize go tests --- .../strategy/code/strategy_recovery_test.go | 480 +++++++++--------- .../code/strategy_verification_test.go | 8 +- 2 files changed, 250 insertions(+), 238 deletions(-) diff --git a/selfservice/strategy/code/strategy_recovery_test.go b/selfservice/strategy/code/strategy_recovery_test.go index 9d0166905935..a57950318b59 100644 --- a/selfservice/strategy/code/strategy_recovery_test.go +++ b/selfservice/strategy/code/strategy_recovery_test.go @@ -49,12 +49,18 @@ func extractCsrfToken(body []byte) string { return gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() } +type ClientType string + const ( - RecoveryFlowTypeBrowser string = "browser" - RecoveryFlowTypeSPA string = "spa" - RecoveryFlowTypeAPI string = "api" + RecoveryClientTypeBrowser ClientType = "browser" + RecoveryClientTypeSPA ClientType = "spa" + RecoveryClientTypeAPI ClientType = "api" ) +func (c ClientType) String() string { + return string(c) +} + func apiHttpClient(t *testing.T) *http.Client { return &http.Client{} } @@ -67,24 +73,24 @@ func browserHttpClient(t *testing.T) *http.Client { return testhelpers.NewClientWithCookies(t) } -var flowTypes = []string{RecoveryFlowTypeBrowser, RecoveryFlowTypeAPI, RecoveryFlowTypeSPA} +var flowTypes = []ClientType{RecoveryClientTypeBrowser, RecoveryClientTypeAPI, RecoveryClientTypeSPA} var flowTypeCases = []struct { FlowType flow.Type - AppType string + ClientType ClientType GetClient func(*testing.T) *http.Client FormContentType string }{ { FlowType: flow.TypeBrowser, - AppType: RecoveryFlowTypeBrowser, + ClientType: RecoveryClientTypeBrowser, GetClient: testhelpers.NewClientWithCookies, FormContentType: "application/x-www-form-urlencoded", }, { - FlowType: flow.TypeAPI, - AppType: RecoveryFlowTypeAPI, + FlowType: flow.TypeAPI, + ClientType: RecoveryClientTypeAPI, GetClient: func(_ *testing.T) *http.Client { return &http.Client{} }, @@ -92,19 +98,19 @@ var flowTypeCases = []struct { }, { FlowType: flow.TypeBrowser, - AppType: RecoveryFlowTypeSPA, + ClientType: RecoveryClientTypeSPA, GetClient: testhelpers.NewClientWithCookies, FormContentType: "application/json", }, } -func withCSRFToken(t *testing.T, flowType, body string, v url.Values) string { +func withCSRFToken(t *testing.T, clientType ClientType, body string, v url.Values) string { t.Helper() csrfToken := gjson.Get(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() - if csrfToken != "" && flowType != RecoveryFlowTypeAPI { + if csrfToken != "" && clientType != RecoveryClientTypeAPI { v.Set("csrf_token", csrfToken) } - if flowType == RecoveryFlowTypeBrowser { + if clientType == RecoveryClientTypeBrowser { return v.Encode() } return testhelpers.EncodeFormAsJSON(t, true, v) @@ -134,15 +140,6 @@ func createIdentityToRecover(t *testing.T, reg *driver.RegistryDefault, email st return id } -func RunFlowTypes(t *testing.T, tf func(t *testing.T)) { - - for _, flowType := range flowTypes { - t.Run("type="+flowType, func(t *testing.T) { - tf(t) - }) - } -} - func TestRecovery(t *testing.T) { ctx := context.Background() conf, reg := internal.NewFastRegistryWithMocks(t) @@ -158,9 +155,9 @@ func TestRecovery(t *testing.T) { public, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg) - submitRecovery := func(t *testing.T, client *http.Client, flowType string, values func(url.Values), code int) string { - isSPA := flowType == RecoveryFlowTypeSPA - isAPI := flowType == RecoveryFlowTypeAPI + submitRecoveryForm := func(t *testing.T, client *http.Client, clientType ClientType, values func(url.Values), code int) string { + isSPA := clientType == RecoveryClientTypeSPA + isAPI := clientType == RecoveryClientTypeAPI if client == nil { client = testhelpers.NewDebugClient(t) if !isAPI { @@ -173,7 +170,7 @@ func TestRecovery(t *testing.T) { return testhelpers.SubmitRecoveryForm(t, isAPI, isSPA, client, public, values, code, expectedUrl) } - submitRecoveryCode := func(t *testing.T, client *http.Client, flow string, flowType string, recoveryCode string, statusCode int) string { + submitRecoveryCode := func(t *testing.T, client *http.Client, flow string, flowType ClientType, recoveryCode string, statusCode int) string { t.Helper() action := gjson.Get(flow, "ui.action").String() assert.NotEmpty(t, action) @@ -184,7 +181,7 @@ func TestRecovery(t *testing.T) { }) contentType := "application/json" - if flowType == RecoveryFlowTypeBrowser { + if flowType == RecoveryClientTypeBrowser { contentType = "application/x-www-form-urlencoded" } @@ -195,7 +192,7 @@ func TestRecovery(t *testing.T) { return string(ioutilx.MustReadAll(res.Body)) } - resendRecoveryCode := func(t *testing.T, client *http.Client, flow string, flowType string, statusCode int) string { + resendRecoveryCode := func(t *testing.T, client *http.Client, flow string, flowType ClientType, statusCode int) string { action := gjson.Get(flow, "ui.action").String() assert.NotEmpty(t, action) @@ -207,7 +204,7 @@ func TestRecovery(t *testing.T) { }) contentType := "application/json" - if flowType == RecoveryFlowTypeBrowser { + if flowType == RecoveryClientTypeBrowser { contentType = "application/x-www-form-urlencoded" } @@ -218,14 +215,9 @@ func TestRecovery(t *testing.T) { return string(ioutilx.MustReadAll(res.Body)) } - expectValidationError := func(t *testing.T, hc *http.Client, flowType string, values func(url.Values)) string { - code := testhelpers.ExpectStatusCode(flowType == RecoveryFlowTypeAPI || flowType == RecoveryFlowTypeSPA, http.StatusBadRequest, http.StatusOK) - return submitRecovery(t, hc, flowType, values, code) - } - - expectSuccessfulRecovery := func(t *testing.T, hc *http.Client, flowType string, values func(url.Values)) string { - code := testhelpers.ExpectStatusCode(flowType == RecoveryFlowTypeAPI || flowType == RecoveryFlowTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) - return submitRecovery(t, hc, flowType, values, code) + expectValidationError := func(t *testing.T, hc *http.Client, flowType ClientType, values func(url.Values)) string { + code := testhelpers.ExpectStatusCode(flowType == RecoveryClientTypeAPI || flowType == RecoveryClientTypeSPA, http.StatusBadRequest, http.StatusOK) + return submitRecoveryForm(t, hc, flowType, values, code) } expectVerfiableAddressStatus := func(t *testing.T, email string, status identity.VerifiableAddressStatus) { @@ -235,8 +227,30 @@ func TestRecovery(t *testing.T) { assert.Equal(t, status, addr.Status, "verifiable address %s was not %s. instead %", email, status, addr.Status) } + submitCodeAndExpectRedirectToSettings := func(t *testing.T, c *http.Client, clientType ClientType, recoveryCode, body string) { + t.Helper() + switch clientType { + case RecoveryClientTypeBrowser: + body = submitRecoveryCode(t, c, body, clientType, recoveryCode, http.StatusOK) + require.Len(t, c.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) + cookies := spew.Sdump(c.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.Contains(t, cookies, "ory_kratos_session") + require.Contains(t, body, "You successfully recovered your account. Please change your password or set up an alternative login method (e.g. social sign in) within the next 60.00 minutes.") + case RecoveryClientTypeSPA: + body = submitRecoveryCode(t, c, body, clientType, recoveryCode, http.StatusUnprocessableEntity) + assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) + require.Len(t, c.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) + cookies := spew.Sdump(c.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.Contains(t, cookies, "ory_kratos_session") + case RecoveryClientTypeAPI: + body = submitRecoveryCode(t, c, body, clientType, recoveryCode, http.StatusOK) + require.NotEmpty(t, gjson.Get(body, "continue_with.#(action==show_settings_ui).flow").String(), "%s", body) + require.NotEmpty(t, gjson.Get(body, "continue_with.#(action==set_ory_session_token).ory_session_token").String(), "%s", body) + } + } + t.Run("description=should recover an account", func(t *testing.T) { - checkRecovery := func(t *testing.T, client *http.Client, flowType, recoveryEmail, recoverySubmissionResponse string) string { + checkRecovery := func(t *testing.T, client *http.Client, flowType ClientType, recoveryEmail, recoverySubmissionResponse string) string { expectVerfiableAddressStatus(t, recoveryEmail, identity.VerifiableAddressStatusPending) assert.EqualValues(t, node.CodeGroup, gjson.Get(recoverySubmissionResponse, "active").String(), "%s", recoverySubmissionResponse) @@ -250,7 +264,7 @@ func TestRecovery(t *testing.T) { recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, recoveryCode) - statusCode := testhelpers.ExpectStatusCode(flowType == RecoveryFlowTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) + statusCode := testhelpers.ExpectStatusCode(flowType == RecoveryClientTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) return submitRecoveryCode(t, client, recoverySubmissionResponse, flowType, recoveryCode, statusCode) } @@ -258,10 +272,10 @@ func TestRecovery(t *testing.T) { client := testhelpers.NewClientWithCookies(t) email := testhelpers.RandomEmail() createIdentityToRecover(t, reg, email) - recoverySubmissionResponse := submitRecovery(t, client, RecoveryFlowTypeBrowser, func(v url.Values) { + recoverySubmissionResponse := submitRecoveryForm(t, client, RecoveryClientTypeBrowser, func(v url.Values) { v.Set("email", email) }, http.StatusOK) - body := checkRecovery(t, client, RecoveryFlowTypeBrowser, email, recoverySubmissionResponse) + body := checkRecovery(t, client, RecoveryClientTypeBrowser, email, recoverySubmissionResponse) assert.Equal(t, text.NewRecoverySuccessful(time.Now().Add(time.Hour)).Text, gjson.Get(body, "ui.messages.0.text").String()) @@ -278,10 +292,10 @@ func TestRecovery(t *testing.T) { client := testhelpers.NewClientWithCookies(t) email := testhelpers.RandomEmail() createIdentityToRecover(t, reg, email) - recoverySubmissionResponse := submitRecovery(t, client, RecoveryFlowTypeSPA, func(v url.Values) { + recoverySubmissionResponse := submitRecoveryForm(t, client, RecoveryClientTypeSPA, func(v url.Values) { v.Set("email", email) }, http.StatusOK) - body := checkRecovery(t, client, RecoveryFlowTypeSPA, email, recoverySubmissionResponse) + body := checkRecovery(t, client, RecoveryClientTypeSPA, email, recoverySubmissionResponse) assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) assert.Contains(t, gjson.Get(body, "redirect_browser_to").String(), "settings-ts?") }) @@ -290,10 +304,10 @@ func TestRecovery(t *testing.T) { client := &http.Client{} email := testhelpers.RandomEmail() createIdentityToRecover(t, reg, email) - recoverySubmissionResponse := submitRecovery(t, client, RecoveryFlowTypeAPI, func(v url.Values) { + recoverySubmissionResponse := submitRecoveryForm(t, client, RecoveryClientTypeAPI, func(v url.Values) { v.Set("email", email) }, http.StatusOK) - body := checkRecovery(t, client, RecoveryFlowTypeAPI, email, recoverySubmissionResponse) + body := checkRecovery(t, client, RecoveryClientTypeAPI, email, recoverySubmissionResponse) assert.Equal(t, "passed_challenge", gjson.Get(body, "state").String()) assert.Len(t, gjson.Get(body, "continue_with").Array(), 2) assert.NotEmpty(t, gjson.Get(body, "continue_with.#(action==set_ory_session_token).ory_session_token").String()) @@ -378,7 +392,7 @@ func TestRecovery(t *testing.T) { expectedURL := testhelpers.ExpectURL(false, public.URL+recovery.RouteSubmitFlow, conf.SelfServiceFlowRecoveryUI(ctx).String()) assert.Contains(t, res.Request.URL.String(), expectedURL, "%+v\n\t%s", res.Request, body) - body = checkRecovery(t, client, RecoveryFlowTypeBrowser, email, body) + body = checkRecovery(t, client, RecoveryClientTypeBrowser, email, body) require.Equal(t, text.NewRecoverySuccessful(time.Now().Add(time.Hour)).Text, gjson.Get(body, "ui.messages.0.text").String()) @@ -420,8 +434,8 @@ func TestRecovery(t *testing.T) { t.Run("description=should set all the correct recovery payloads after submission", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.AppType, func(t *testing.T) { - body := submitRecovery(t, testCase.GetClient(t), testCase.AppType, func(v url.Values) { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { + body := submitRecoveryForm(t, testCase.GetClient(t), testCase.ClientType, func(v url.Values) { v.Set("email", "test@ory.sh") }, http.StatusOK) testhelpers.SnapshotTExcept(t, json.RawMessage(gjson.Get(body, "ui.nodes").String()), []string{"0.attributes.value"}) @@ -431,7 +445,7 @@ func TestRecovery(t *testing.T) { t.Run("description=should set all the correct recovery payloads", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.AppType, func(t *testing.T) { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { c := testCase.GetClient(t) rs := testhelpers.GetRecoveryFlowForType(t, c, public, testCase.FlowType) @@ -444,7 +458,7 @@ func TestRecovery(t *testing.T) { t.Run("description=should require an email to be sent", func(t *testing.T) { for _, flowType := range flowTypes { - t.Run("type="+flowType, func(t *testing.T) { + t.Run("type="+flowType.String(), func(t *testing.T) { body := expectValidationError(t, nil, flowType, func(v url.Values) { v.Del("email") }) @@ -458,7 +472,7 @@ func TestRecovery(t *testing.T) { t.Run("description=should require a valid email to be sent", func(t *testing.T) { for _, flowType := range flowTypes { - t.Run("type="+flowType, func(t *testing.T) { + t.Run("type="+flowType.String(), func(t *testing.T) { for _, email := range []string{"\\", "asdf", "...", "aiacobelli.sec@gmail.com,alejandro.iacobelli@mercadolibre.com"} { responseJSON := expectValidationError(t, nil, flowType, func(v url.Values) { v.Set("email", email) @@ -475,9 +489,9 @@ func TestRecovery(t *testing.T) { t.Run("description=should try to submit the form while authenticated", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.AppType, func(t *testing.T) { - isSPA := testCase.AppType == "spa" - isAPI := testCase.AppType == "api" + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { + isSPA := testCase.ClientType == "spa" + isAPI := testCase.ClientType == "api" client := testCase.GetClient(t) var f *kratos.RecoveryFlow @@ -528,13 +542,13 @@ func TestRecovery(t *testing.T) { }) for _, testCase := range flowTypeCases { - t.Run("type="+testCase.AppType, func(t *testing.T) { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { email := x.NewUUID().String() + "@ory.sh" c := testCase.GetClient(t) withValues := func(v url.Values) { v.Set("email", email) } - body := submitRecovery(t, c, testCase.AppType, withValues, http.StatusOK) + body := submitRecoveryForm(t, c, testCase.ClientType, withValues, http.StatusOK) assert.EqualValues(t, node.CodeGroup, gjson.Get(body, "active").String(), "%s", body) assert.Empty(t, gjson.Get(body, "ui.nodes.#(attributes.name==code).attributes.value").String(), "%s", body) assertx.EqualAsJSON(t, text.NewRecoveryEmailWithCodeSent(), json.RawMessage(gjson.Get(body, "ui.messages.0").Raw)) @@ -547,15 +561,15 @@ func TestRecovery(t *testing.T) { t.Run("description=should not be able to recover an inactive account", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.AppType, func(t *testing.T) { - email := "recoverinactive_" + testCase.AppType + "@ory.sh" + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { + email := "recoverinactive_" + testCase.ClientType.String() + "@ory.sh" createIdentityToRecover(t, reg, email) values := func(v url.Values) { v.Set("email", email) } cl := testhelpers.NewClientWithCookies(t) - body := submitRecovery(t, cl, testCase.AppType, values, http.StatusOK) + body := submitRecoveryForm(t, cl, testCase.ClientType, values, http.StatusOK) addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, email) assert.NoError(t, err) @@ -565,14 +579,14 @@ func TestRecovery(t *testing.T) { // Deactivate the identity require.NoError(t, reg.Persister().GetConnection(context.Background()).RawQuery("UPDATE identities SET state=? WHERE id = ?", identity.StateInactive, addr.IdentityID).Exec()) - switch testCase.AppType { - case RecoveryFlowTypeAPI: + switch testCase.ClientType { + case RecoveryClientTypeAPI: fallthrough - case RecoveryFlowTypeSPA: - body = submitRecoveryCode(t, cl, body, testCase.AppType, recoveryCode, http.StatusUnauthorized) + case RecoveryClientTypeSPA: + body = submitRecoveryCode(t, cl, body, testCase.ClientType, recoveryCode, http.StatusUnauthorized) assertx.EqualAsJSON(t, session.ErrIdentityDisabled.WithDetail("identity_id", addr.IdentityID), json.RawMessage(gjson.Get(body, "error").Raw), "%s", body) default: - body = submitRecoveryCode(t, cl, body, testCase.AppType, recoveryCode, http.StatusOK) + body = submitRecoveryCode(t, cl, body, testCase.ClientType, recoveryCode, http.StatusOK) assertx.EqualAsJSON(t, session.ErrIdentityDisabled.WithDetail("identity_id", addr.IdentityID), json.RawMessage(body), "%s", body) } }) @@ -586,88 +600,58 @@ func TestRecovery(t *testing.T) { }) for _, testCase := range flowTypeCases { - t.Run("type="+testCase.AppType, func(t *testing.T) { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { email := testhelpers.RandomEmail() id := createIdentityToRecover(t, reg, email) - sess, err := session.NewActiveSession(httptest.NewRequest("GET", "/sessions/whoami", nil), id, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) + otherSession, err := session.NewActiveSession(httptest.NewRequest("GET", "/sessions/whoami", nil), id, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) require.NoError(t, err) - require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess)) + require.NoError(t, reg.SessionPersister().UpsertSession(ctx, otherSession)) - actualSession, err := reg.SessionPersister().GetSession(ctx, sess.ID, session.ExpandNothing) + refetchedOtherSession, err := reg.SessionPersister().GetSession(ctx, otherSession.ID, session.ExpandNothing) require.NoError(t, err) - assert.True(t, actualSession.IsActive()) + assert.True(t, refetchedOtherSession.IsActive()) cl := testCase.GetClient(t) - actual := submitRecovery(t, cl, testCase.AppType, func(v url.Values) { + actual := submitRecoveryForm(t, cl, testCase.ClientType, func(v url.Values) { v.Set("email", email) }, http.StatusOK) message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Recover access to your account") recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) - cl.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - - switch testCase.AppType { - case RecoveryFlowTypeBrowser: - action := gjson.Get(actual, "ui.action").String() - require.NotEmpty(t, action) - csrf_token := gjson.Get(actual, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() - require.NotEmpty(t, csrf_token, "%s", actual) - - submitRecoveryCode(t, cl, actual, testCase.AppType, recoveryCode, http.StatusSeeOther) - require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) - cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) - assert.Contains(t, cookies, "ory_kratos_session") - // TODO - case RecoveryFlowTypeSPA: - action := gjson.Get(actual, "ui.action").String() - require.NotEmpty(t, action) - csrf_token := gjson.Get(actual, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() - require.NotEmpty(t, csrf_token, "%s", actual) - - submitRecoveryCode(t, cl, actual, testCase.AppType, recoveryCode, 422) - require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) - cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) - assert.Contains(t, cookies, "ory_kratos_session") - // TODO - case RecoveryFlowTypeAPI: - x := submitRecoveryCode(t, cl, actual, testCase.AppType, recoveryCode, 200) - t.Logf("%s", x) - } + submitCodeAndExpectRedirectToSettings(t, cl, testCase.ClientType, recoveryCode, actual) - actualSession, err = reg.SessionPersister().GetSession(ctx, sess.ID, session.ExpandNothing) + refetchedOtherSession, err = reg.SessionPersister().GetSession(ctx, otherSession.ID, session.ExpandNothing) require.NoError(t, err) - assert.False(t, actualSession.IsActive()) + assert.False(t, refetchedOtherSession.IsActive()) }) } }) t.Run("description=should not be able to use an invalid code more than 5 times", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.AppType, func(t *testing.T) { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { email := testhelpers.RandomEmail() createIdentityToRecover(t, reg, email) c := testCase.GetClient(t) - body := submitRecovery(t, c, testCase.AppType, func(v url.Values) { + body := submitRecoveryForm(t, c, testCase.ClientType, func(v url.Values) { v.Set("email", email) }, http.StatusOK) initialFlowId := gjson.Get(body, "id") for submitTry := 0; submitTry < 5; submitTry++ { - body := submitRecoveryCode(t, c, body, testCase.AppType, "12312312", http.StatusOK) + body := submitRecoveryCode(t, c, body, testCase.ClientType, "12312312", http.StatusOK) testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") } - switch testCase.AppType { - case RecoveryFlowTypeBrowser: + switch testCase.ClientType { + case RecoveryClientTypeBrowser: fallthrough - case RecoveryFlowTypeSPA: + case RecoveryClientTypeSPA: // submit an invalid code for the 6th time - body = submitRecoveryCode(t, c, body, testCase.AppType, "12312312", http.StatusOK) + body = submitRecoveryCode(t, c, body, testCase.ClientType, "12312312", http.StatusOK) require.Len(t, gjson.Get(body, "ui.messages").Array(), 1, "%s", body) assert.Equal(t, "The request was submitted too often. Please request another code.", gjson.Get(body, "ui.messages.0.text").String()) @@ -676,9 +660,9 @@ func TestRecovery(t *testing.T) { assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==email)").Exists()) - case RecoveryFlowTypeAPI: + case RecoveryClientTypeAPI: // submit an invalid code for the 6th time - body = submitRecoveryCode(t, c, body, testCase.AppType, "12312312", http.StatusBadRequest) + body = submitRecoveryCode(t, c, body, testCase.ClientType, "12312312", http.StatusBadRequest) assert.Equal(t, "Bad Request", gjson.Get(body, "error.status").String(), "%s", body) assert.Equal(t, "The request was submitted too often. Please request another code.", gjson.Get(body, "error.reason").String(), "%s", body) @@ -700,19 +684,19 @@ func TestRecovery(t *testing.T) { t.Run("description=should be able to recover after using invalid code", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.AppType, func(t *testing.T) { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { c := testCase.GetClient(t) recoveryEmail := testhelpers.RandomEmail() _ = createIdentityToRecover(t, reg, recoveryEmail) - actual := submitRecovery(t, c, testCase.AppType, func(v url.Values) { + actual := submitRecoveryForm(t, c, testCase.ClientType, func(v url.Values) { v.Set("email", recoveryEmail) }, http.StatusOK) message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) - form := withCSRFToken(t, testCase.AppType, actual, url.Values{ + form := withCSRFToken(t, testCase.ClientType, actual, url.Values{ "code": {"12312312"}, }) @@ -739,20 +723,20 @@ func TestRecovery(t *testing.T) { require.Len(t, rs.Ui.Messages, 1) assert.Equal(t, "The recovery code is invalid or has already been used. Please try again.", rs.Ui.Messages[0].Text) - form = withCSRFToken(t, testCase.AppType, actual, url.Values{ + form = withCSRFToken(t, testCase.ClientType, actual, url.Values{ "code": {recoveryCode}, }) // Now submit the correct code res, err = c.Post(action, testCase.FormContentType, bytes.NewBufferString(form)) require.NoError(t, err) - if testCase.AppType == RecoveryFlowTypeBrowser { + if testCase.ClientType == RecoveryClientTypeBrowser { assert.Equal(t, http.StatusOK, res.StatusCode) json := ioutilx.MustReadAll(res.Body) assert.Len(t, gjson.GetBytes(json, "ui.messages").Array(), 1) assert.Contains(t, gjson.GetBytes(json, "ui.messages.0.text").String(), "You successfully recovered your account.") - } else if testCase.AppType == RecoveryFlowTypeSPA { + } else if testCase.ClientType == RecoveryClientTypeSPA { assert.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) json := ioutilx.MustReadAll(res.Body) @@ -766,16 +750,16 @@ func TestRecovery(t *testing.T) { t.Run("description=should not be able to use an invalid code", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.AppType, func(t *testing.T) { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { email := testhelpers.RandomEmail() createIdentityToRecover(t, reg, email) c := testCase.GetClient(t) - body := submitRecovery(t, c, testCase.AppType, func(v url.Values) { + body := submitRecoveryForm(t, c, testCase.ClientType, func(v url.Values) { v.Set("email", email) }, http.StatusOK) - body = submitRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, "12312312", http.StatusOK) + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, "12312312", http.StatusOK) testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") }) @@ -784,7 +768,7 @@ func TestRecovery(t *testing.T) { t.Run("description=should not be able to submit recover address after flow expired", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.AppType, func(t *testing.T) { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { recoveryEmail := testhelpers.RandomEmail() createIdentityToRecover(t, reg, recoveryEmail) conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Millisecond*10) @@ -796,10 +780,10 @@ func TestRecovery(t *testing.T) { var rs *kratos.RecoveryFlow var res *http.Response var err error - switch testCase.AppType { - case RecoveryFlowTypeBrowser: + switch testCase.ClientType { + case RecoveryClientTypeBrowser: fallthrough - case RecoveryFlowTypeSPA: + case RecoveryClientTypeSPA: rs = testhelpers.GetRecoveryFlow(t, c, public) time.Sleep(time.Millisecond * 11) res, err = c.PostForm(rs.Ui.Action, url.Values{"email": {recoveryEmail}, "method": {"code"}}) @@ -807,7 +791,7 @@ func TestRecovery(t *testing.T) { assert.EqualValues(t, http.StatusOK, res.StatusCode) assert.NotContains(t, res.Request.URL.String(), "flow="+rs.Id) assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String()) - case RecoveryFlowTypeAPI: + case RecoveryClientTypeAPI: rs = testhelpers.InitializeRecoveryFlowViaAPI(t, c, public) time.Sleep(time.Millisecond * 11) form := testhelpers.EncodeFormAsJSON(t, true, url.Values{"email": {recoveryEmail}, "method": {"code"}}) @@ -833,57 +817,65 @@ func TestRecovery(t *testing.T) { }) t.Run("description=should not be able to submit code after flow expired", func(t *testing.T) { - // TODO: combine with test above? - recoveryEmail := "recoverme6@ory.sh" - createIdentityToRecover(t, reg, recoveryEmail) conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Millisecond*200) t.Cleanup(func() { conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Minute) }) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) - c := testhelpers.NewClientWithCookies(t) - - body := expectSuccessfulRecovery(t, c, RecoveryFlowTypeBrowser, func(v url.Values) { - v.Set("email", recoveryEmail) - }) + c := testhelpers.NewClientWithCookies(t) - initialFlowId := gjson.Get(body, "id") + body := submitRecoveryForm(t, c, testCase.ClientType, func(v url.Values) { + v.Set("email", recoveryEmail) + }, http.StatusOK) - message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") - assert.Contains(t, message.Body, "please recover access to your account by entering the following code") + initialFlowId := gjson.Get(body, "id") - recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + assert.Contains(t, message.Body, "please recover access to your account by entering the following code") - time.Sleep(time.Millisecond * 201) + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) - body = submitRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, recoveryCode, http.StatusOK) + time.Sleep(time.Millisecond * 201) - assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + if testCase.FlowType == "browser" { + body = submitRecoveryCode(t, c, body, testCase.ClientType, recoveryCode, http.StatusOK) + assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) - testhelpers.AssertMessage(t, []byte(body), "The recovery flow expired 0.00 minutes ago, please try again.") + testhelpers.AssertMessage(t, []byte(body), "The recovery flow expired 0.00 minutes ago, please try again.") + } else { + body = submitRecoveryCode(t, c, body, testCase.ClientType, recoveryCode, http.StatusGone) + assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + assert.Equal(t, "self_service_flow_expired", gjson.Get(body, "error.id").String()) + } - addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) - require.NoError(t, err) - assert.False(t, addr.Verified) - assert.Nil(t, addr.VerifiedAt) - assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status) + addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) + require.NoError(t, err) + assert.False(t, addr.Verified) + assert.Nil(t, addr.VerifiedAt) + assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status) + }) + } }) t.Run("description=should not break ui if empty code is submitted", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.AppType, func(t *testing.T) { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { recoveryEmail := testhelpers.RandomEmail() createIdentityToRecover(t, reg, recoveryEmail) c := testCase.GetClient(t) - body := submitRecovery(t, c, testCase.AppType, func(v url.Values) { + body := submitRecoveryForm(t, c, testCase.ClientType, func(v url.Values) { v.Set("email", recoveryEmail) }, http.StatusOK) action := gjson.Get(body, "ui.action").String() require.NotEmpty(t, action) - body = submitRecoveryCode(t, c, body, testCase.AppType, "", http.StatusOK) + body = submitRecoveryCode(t, c, body, testCase.ClientType, "", http.StatusOK) assert.NotContains(t, gjson.Get(body, "ui.nodes").String(), "Property email is missing.") testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") @@ -891,14 +883,14 @@ func TestRecovery(t *testing.T) { } }) - t.Run("description=should be able to re-send the recovery code", func(t *testing.T) { + t.Run("description=should be able to resend the recovery code", func(t *testing.T) { for _, testCase := range flowTypeCases { - t.Run("type="+testCase.AppType, func(t *testing.T) { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { recoveryEmail := testhelpers.RandomEmail() createIdentityToRecover(t, reg, recoveryEmail) c := testCase.GetClient(t) - body := submitRecovery(t, c, testCase.AppType, func(v url.Values) { + body := submitRecoveryForm(t, c, testCase.ClientType, func(v url.Values) { v.Set("email", recoveryEmail) }, http.StatusOK) @@ -906,132 +898,152 @@ func TestRecovery(t *testing.T) { require.NotEmpty(t, action) assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) - body = resendRecoveryCode(t, c, body, testCase.AppType, http.StatusOK) + body = resendRecoveryCode(t, c, body, testCase.ClientType, http.StatusOK) assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==code)").Exists()) assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) - submitRecoveryCode(t, c, body, testCase.AppType, recoveryCode, http.StatusOK) + submitCodeAndExpectRedirectToSettings(t, c, testCase.ClientType, recoveryCode, body) }) } }) t.Run("description=should not be able to use first code after re-sending email", func(t *testing.T) { - recoveryEmail := testhelpers.RandomEmail() - createIdentityToRecover(t, reg, recoveryEmail) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) - c := testhelpers.NewClientWithCookies(t) - body := expectSuccessfulRecovery(t, c, RecoveryFlowTypeBrowser, func(v url.Values) { - v.Set("email", recoveryEmail) - }) + c := testCase.GetClient(t) + body := submitRecoveryForm(t, c, testCase.ClientType, func(v url.Values) { + v.Set("email", recoveryEmail) + }, http.StatusOK) - action := gjson.Get(body, "ui.action").String() - require.NotEmpty(t, action) - assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) - message1 := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") - recoveryCode1 := testhelpers.CourierExpectCodeInMessage(t, message1, 1) + message1 := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode1 := testhelpers.CourierExpectCodeInMessage(t, message1, 1) - body = resendRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, http.StatusOK) - assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==code)").Exists()) - assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + body = resendRecoveryCode(t, c, body, testCase.ClientType, http.StatusOK) + assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==code)").Exists()) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) - message2 := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") - recoveryCode2 := testhelpers.CourierExpectCodeInMessage(t, message2, 1) + message2 := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode2 := testhelpers.CourierExpectCodeInMessage(t, message2, 1) - body = submitRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, recoveryCode1, http.StatusOK) - testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + body = submitRecoveryCode(t, c, body, testCase.ClientType, recoveryCode1, http.StatusOK) + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") - // For good measure, check that the second code works! - body = submitRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, recoveryCode2, http.StatusOK) - testhelpers.AssertMessage(t, []byte(body), "You successfully recovered your account. Please change your password or set up an alternative login method (e.g. social sign in) within the next 60.00 minutes.") + submitCodeAndExpectRedirectToSettings(t, c, testCase.ClientType, recoveryCode2, body) + }) + } }) t.Run("description=should not show outdated validation message if newer message appears #2799", func(t *testing.T) { - recoveryEmail := testhelpers.RandomEmail() - createIdentityToRecover(t, reg, recoveryEmail) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) - c := testhelpers.NewClientWithCookies(t) - body := expectSuccessfulRecovery(t, c, RecoveryFlowTypeBrowser, func(v url.Values) { - v.Set("email", recoveryEmail) - }) + c := testCase.GetClient(t) + body := submitRecoveryForm(t, c, testCase.ClientType, func(v url.Values) { + v.Set("email", recoveryEmail) + }, http.StatusOK) - action := gjson.Get(body, "ui.action").String() - require.NotEmpty(t, action) - assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) - body = submitRecoveryCode(t, c, body, RecoveryFlowTypeBrowser, "12312312", http.StatusOK) // Now send a wrong code that triggers "global" validation error + body = submitRecoveryCode(t, c, body, testCase.ClientType, "12312312", http.StatusOK) // Now send a wrong code that triggers "global" validation error - assert.Empty(t, gjson.Get(body, "ui.nodes.#(attributes.name==code).messages").Array()) - testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + assert.Empty(t, gjson.Get(body, "ui.nodes.#(attributes.name==code).messages").Array()) + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + }) + } }) t.Run("description=should recover if post recovery hook is successful", func(t *testing.T) { - conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}}) - t.Cleanup(func() { - conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), nil) - }) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}}) + t.Cleanup(func() { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), nil) + }) - recoveryEmail := testhelpers.RandomEmail() - createIdentityToRecover(t, reg, recoveryEmail) + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) - cl := testhelpers.NewClientWithCookies(t) - body := expectSuccessfulRecovery(t, cl, RecoveryFlowTypeBrowser, func(v url.Values) { - v.Set("email", recoveryEmail) - }) + cl := testCase.GetClient(t) + body := submitRecoveryForm(t, cl, testCase.ClientType, func(v url.Values) { + v.Set("email", recoveryEmail) + }, http.StatusOK) - message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") - recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) - action := gjson.Get(body, "ui.action").String() - require.NotEmpty(t, action) - assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) - cl.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse + submitCodeAndExpectRedirectToSettings(t, cl, testCase.ClientType, recoveryCode, body) + }) } - - body = submitRecoveryCode(t, cl, body, RecoveryFlowTypeBrowser, recoveryCode, http.StatusSeeOther) - - require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) - cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) - assert.Contains(t, cookies, "ory_kratos_session") }) t.Run("description=should not be able to recover if post recovery hook fails", func(t *testing.T) { - conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostRecoveryHook": "err"}`)}}) - t.Cleanup(func() { - conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), nil) - }) + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostRecoveryHook": "err"}`)}}) + t.Cleanup(func() { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), nil) + }) - recoveryEmail := testhelpers.RandomEmail() - createIdentityToRecover(t, reg, recoveryEmail) + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) - cl := testhelpers.NewClientWithCookies(t) - body := expectSuccessfulRecovery(t, cl, RecoveryFlowTypeBrowser, func(v url.Values) { - v.Set("email", recoveryEmail) - }) + cl := testhelpers.NewClientWithCookies(t) + body := submitRecoveryForm(t, cl, testCase.ClientType, func(v url.Values) { + v.Set("email", recoveryEmail) + }, http.StatusOK) - message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") - recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) - action := gjson.Get(body, "ui.action").String() - require.NotEmpty(t, action) - assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) - cl.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } + cl.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } - initialFlowId := gjson.Get(body, "id") - body = submitRecoveryCode(t, cl, body, RecoveryFlowTypeBrowser, recoveryCode, http.StatusSeeOther) - assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + initialFlowId := gjson.Get(body, "id") + switch testCase.ClientType { + case RecoveryClientTypeBrowser: + body = submitRecoveryCode(t, cl, body, testCase.ClientType, recoveryCode, http.StatusSeeOther) + assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) - require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 1) - cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) - assert.NotContains(t, cookies, "ory_kratos_session") + require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 1) + cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.NotContains(t, cookies, "ory_kratos_session") + case RecoveryClientTypeSPA: + body = submitRecoveryCode(t, cl, body, testCase.ClientType, recoveryCode, http.StatusBadRequest) + assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + + require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 1) + cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.NotContains(t, cookies, "ory_kratos_session") + case RecoveryClientTypeAPI: + body = submitRecoveryCode(t, cl, body, testCase.ClientType, recoveryCode, http.StatusBadRequest) + assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + require.Equal(t, "err", gjson.Get(body, "error.message").String(), "%s", body) + } + }) + } }) } diff --git a/selfservice/strategy/code/strategy_verification_test.go b/selfservice/strategy/code/strategy_verification_test.go index 9be8cd08145d..cdbfedc6069d 100644 --- a/selfservice/strategy/code/strategy_verification_test.go +++ b/selfservice/strategy/code/strategy_verification_test.go @@ -458,7 +458,7 @@ func TestVerification(t *testing.T) { assert.Equal(t, text.ErrIDSelfServiceFlowReplaced, gjson.GetBytes(f2, "error.id").String()) }) - resendVerificationCode := func(t *testing.T, client *http.Client, flow string, flowType string, statusCode int) string { + resendVerificationCode := func(t *testing.T, client *http.Client, flow string, flowType ClientType, statusCode int) string { action := gjson.Get(flow, "ui.action").String() assert.NotEmpty(t, action) @@ -470,7 +470,7 @@ func TestVerification(t *testing.T) { }) contentType := "application/json" - if flowType == RecoveryFlowTypeBrowser { + if flowType == RecoveryClientTypeBrowser { contentType = "application/x-www-form-urlencoded" } @@ -490,7 +490,7 @@ func TestVerification(t *testing.T) { _ = testhelpers.CourierExpectCodeInMessage(t, message, 1) c := testhelpers.NewClientWithCookies(t) - body = resendVerificationCode(t, c, body, RecoveryFlowTypeBrowser, http.StatusOK) + body = resendVerificationCode(t, c, body, RecoveryClientTypeBrowser, http.StatusOK) assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==code)").Exists()) assert.Equal(t, verificationEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) @@ -510,7 +510,7 @@ func TestVerification(t *testing.T) { firstCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) c := testhelpers.NewClientWithCookies(t) - body = resendVerificationCode(t, c, body, RecoveryFlowTypeBrowser, http.StatusOK) + body = resendVerificationCode(t, c, body, RecoveryClientTypeBrowser, http.StatusOK) assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==code)").Exists()) assert.Equal(t, verificationEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) From 575beb2b742a6ecbddbc640eaf4d0bcb21f2db9b Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Fri, 15 Sep 2023 18:50:56 +0200 Subject: [PATCH 06/25] chore: format --- .github/workflows/format.yml | 2 +- internal/testhelpers/selfservice_verification.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index a7a720ebc0a7..b7bee5b53ec8 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -2,7 +2,7 @@ name: Format on: pull_request: - push: + merge_group: jobs: format: diff --git a/internal/testhelpers/selfservice_verification.go b/internal/testhelpers/selfservice_verification.go index 1deaafa6c215..7a3abfa34b7e 100644 --- a/internal/testhelpers/selfservice_verification.go +++ b/internal/testhelpers/selfservice_verification.go @@ -13,9 +13,10 @@ import ( "testing" "time" - kratos "github.com/ory/kratos/internal/httpclient" "github.com/tidwall/gjson" + kratos "github.com/ory/kratos/internal/httpclient" + "github.com/ory/x/ioutilx" "github.com/stretchr/testify/assert" From fa326d9448880d584289f93e4840099e1ad46aee Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Tue, 19 Sep 2023 15:36:26 +0200 Subject: [PATCH 07/25] fix: wrong continue_with enum declaration --- ...del_continue_with_set_ory_session_token.go | 2 +- .../model_continue_with_verification_ui.go | 2 +- ...del_continue_with_set_ory_session_token.go | 2 +- .../model_continue_with_verification_ui.go | 2 +- selfservice/flow/continue_with.go | 33 +++++++++++-------- spec/api.json | 12 +++---- spec/swagger.json | 12 +++---- 7 files changed, 34 insertions(+), 31 deletions(-) diff --git a/internal/client-go/model_continue_with_set_ory_session_token.go b/internal/client-go/model_continue_with_set_ory_session_token.go index 641718339f88..e091665d0d00 100644 --- a/internal/client-go/model_continue_with_set_ory_session_token.go +++ b/internal/client-go/model_continue_with_set_ory_session_token.go @@ -17,7 +17,7 @@ import ( // ContinueWithSetOrySessionToken Indicates that a session was issued, and the application should use this token for authenticated requests type ContinueWithSetOrySessionToken struct { - // Action will always be `set_ory_session_token` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI + // Action will always be `set_ory_session_token` set_ory_session_token ContinueWithActionSetOrySessionTokenString Action string `json:"action"` // Token is the token of the session OrySessionToken string `json:"ory_session_token"` diff --git a/internal/client-go/model_continue_with_verification_ui.go b/internal/client-go/model_continue_with_verification_ui.go index 987c32d18f2e..38ca91116469 100644 --- a/internal/client-go/model_continue_with_verification_ui.go +++ b/internal/client-go/model_continue_with_verification_ui.go @@ -17,7 +17,7 @@ import ( // ContinueWithVerificationUi Indicates, that the UI flow could be continued by showing a verification ui type ContinueWithVerificationUi struct { - // Action will always be `show_verification_ui` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI + // Action will always be `show_verification_ui` show_verification_ui ContinueWithActionShowVerificationUIString Action string `json:"action"` Flow ContinueWithVerificationUiFlow `json:"flow"` } diff --git a/internal/httpclient/model_continue_with_set_ory_session_token.go b/internal/httpclient/model_continue_with_set_ory_session_token.go index 641718339f88..e091665d0d00 100644 --- a/internal/httpclient/model_continue_with_set_ory_session_token.go +++ b/internal/httpclient/model_continue_with_set_ory_session_token.go @@ -17,7 +17,7 @@ import ( // ContinueWithSetOrySessionToken Indicates that a session was issued, and the application should use this token for authenticated requests type ContinueWithSetOrySessionToken struct { - // Action will always be `set_ory_session_token` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI + // Action will always be `set_ory_session_token` set_ory_session_token ContinueWithActionSetOrySessionTokenString Action string `json:"action"` // Token is the token of the session OrySessionToken string `json:"ory_session_token"` diff --git a/internal/httpclient/model_continue_with_verification_ui.go b/internal/httpclient/model_continue_with_verification_ui.go index 987c32d18f2e..38ca91116469 100644 --- a/internal/httpclient/model_continue_with_verification_ui.go +++ b/internal/httpclient/model_continue_with_verification_ui.go @@ -17,7 +17,7 @@ import ( // ContinueWithVerificationUi Indicates, that the UI flow could be continued by showing a verification ui type ContinueWithVerificationUi struct { - // Action will always be `show_verification_ui` set_ory_session_token ContinueWithActionSetOrySessionToken show_verification_ui ContinueWithActionShowVerificationUI + // Action will always be `show_verification_ui` show_verification_ui ContinueWithActionShowVerificationUIString Action string `json:"action"` Flow ContinueWithVerificationUiFlow `json:"flow"` } diff --git a/selfservice/flow/continue_with.go b/selfservice/flow/continue_with.go index 0b22b8b8ecb3..5d6eb6ff1619 100644 --- a/selfservice/flow/continue_with.go +++ b/selfservice/flow/continue_with.go @@ -14,24 +14,23 @@ import ( // swagger:model continueWith type ContinueWith any -// swagger:enum ContinueWithAction -type ContinueWithAction string +// swagger:enum ContinueWithActionSetOrySessionToken +type ContinueWithActionSetOrySessionToken string // #nosec G101 -- only a key constant const ( - ContinueWithActionSetOrySessionToken ContinueWithAction = "set_ory_session_token" - ContinueWithActionShowVerificationUI ContinueWithAction = "show_verification_ui" + ContinueWithActionSetOrySessionTokenString ContinueWithActionSetOrySessionToken = "set_ory_session_token" ) -var _ ContinueWith = new(ContinueWithSetToken) +var _ ContinueWith = new(ContinueWithSetOrySessionToken) // Indicates that a session was issued, and the application should use this token for authenticated requests // swagger:model continueWithSetOrySessionToken -type ContinueWithSetToken struct { +type ContinueWithSetOrySessionToken struct { // Action will always be `set_ory_session_token` // // required: true - Action ContinueWithAction `json:"action"` + Action ContinueWithActionSetOrySessionToken `json:"action"` // Token is the token of the session // @@ -39,17 +38,25 @@ type ContinueWithSetToken struct { OrySessionToken string `json:"ory_session_token"` } -func (ContinueWithSetToken) AppendTo(url.Values) url.Values { +func (ContinueWithSetOrySessionToken) AppendTo(url.Values) url.Values { return nil } -func NewContinueWithSetToken(t string) *ContinueWithSetToken { - return &ContinueWithSetToken{ - Action: ContinueWithActionSetOrySessionToken, +func NewContinueWithSetToken(t string) *ContinueWithSetOrySessionToken { + return &ContinueWithSetOrySessionToken{ + Action: ContinueWithActionSetOrySessionTokenString, OrySessionToken: t, } } +// swagger:enum ContinueWithActionShowVerificationUI +type ContinueWithActionShowVerificationUI string + +// #nosec G101 -- only a key constant +const ( + ContinueWithActionShowVerificationUIString ContinueWithActionShowVerificationUI = "show_verification_ui" +) + var _ ContinueWith = new(ContinueWithVerificationUI) // Indicates, that the UI flow could be continued by showing a verification ui @@ -59,7 +66,7 @@ type ContinueWithVerificationUI struct { // Action will always be `show_verification_ui` // // required: true - Action ContinueWithAction `json:"action"` + Action ContinueWithActionShowVerificationUI `json:"action"` // Flow contains the ID of the verification flow // // required: true @@ -86,7 +93,7 @@ type ContinueWithVerificationUIFlow struct { func NewContinueWithVerificationUI(f Flow, address, url string) *ContinueWithVerificationUI { return &ContinueWithVerificationUI{ - Action: ContinueWithActionShowVerificationUI, + Action: ContinueWithActionShowVerificationUIString, Flow: ContinueWithVerificationUIFlow{ ID: f.GetID(), VerifiableAddress: address, diff --git a/spec/api.json b/spec/api.json index 06cd193d9edc..d163cd2d65f6 100644 --- a/spec/api.json +++ b/spec/api.json @@ -478,13 +478,12 @@ "description": "Indicates that a session was issued, and the application should use this token for authenticated requests", "properties": { "action": { - "description": "Action will always be `set_ory_session_token`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI", + "description": "Action will always be `set_ory_session_token`\nset_ory_session_token ContinueWithActionSetOrySessionTokenString", "enum": [ - "set_ory_session_token", - "show_verification_ui" + "set_ory_session_token" ], "type": "string", - "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI" + "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionTokenString" }, "ory_session_token": { "description": "Token is the token of the session", @@ -501,13 +500,12 @@ "description": "Indicates, that the UI flow could be continued by showing a verification ui", "properties": { "action": { - "description": "Action will always be `show_verification_ui`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI", + "description": "Action will always be `show_verification_ui`\nshow_verification_ui ContinueWithActionShowVerificationUIString", "enum": [ - "set_ory_session_token", "show_verification_ui" ], "type": "string", - "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI" + "x-go-enum-desc": "show_verification_ui ContinueWithActionShowVerificationUIString" }, "flow": { "$ref": "#/components/schemas/continueWithVerificationUiFlow" diff --git a/spec/swagger.json b/spec/swagger.json index 7d4321cf0b78..df1b67be4013 100755 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -3519,13 +3519,12 @@ ], "properties": { "action": { - "description": "Action will always be `set_ory_session_token`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI", + "description": "Action will always be `set_ory_session_token`\nset_ory_session_token ContinueWithActionSetOrySessionTokenString", "type": "string", "enum": [ - "set_ory_session_token", - "show_verification_ui" + "set_ory_session_token" ], - "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI" + "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionTokenString" }, "ory_session_token": { "description": "Token is the token of the session", @@ -3542,13 +3541,12 @@ ], "properties": { "action": { - "description": "Action will always be `show_verification_ui`\nset_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI", + "description": "Action will always be `show_verification_ui`\nshow_verification_ui ContinueWithActionShowVerificationUIString", "type": "string", "enum": [ - "set_ory_session_token", "show_verification_ui" ], - "x-go-enum-desc": "set_ory_session_token ContinueWithActionSetOrySessionToken\nshow_verification_ui ContinueWithActionShowVerificationUI" + "x-go-enum-desc": "show_verification_ui ContinueWithActionShowVerificationUIString" }, "flow": { "$ref": "#/definitions/continueWithVerificationUiFlow" From 5a9e53cc901f93735ccfefee39d4d6d5d4517281 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Tue, 19 Sep 2023 16:12:21 +0200 Subject: [PATCH 08/25] chore: compile --- selfservice/hook/session_issuer_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/selfservice/hook/session_issuer_test.go b/selfservice/hook/session_issuer_test.go index db62ab2d643d..c58faec0fb2f 100644 --- a/selfservice/hook/session_issuer_test.go +++ b/selfservice/hook/session_issuer_test.go @@ -76,8 +76,8 @@ func TestSessionIssuer(t *testing.T) { require.Len(t, f.ContinueWithItems, 1) st := f.ContinueWithItems[0] - require.IsType(t, &flow.ContinueWithSetToken{}, st) - assert.NotEmpty(t, st.(*flow.ContinueWithSetToken).OrySessionToken) + require.IsType(t, &flow.ContinueWithSetOrySessionToken{}, st) + assert.NotEmpty(t, st.(*flow.ContinueWithSetOrySessionToken).OrySessionToken) got, err := reg.SessionPersister().GetSession(context.Background(), s.ID, session.ExpandNothing) require.NoError(t, err) From 35e99fd62b633425213240188b0f12b62ae9633e Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Thu, 21 Sep 2023 15:10:25 +0200 Subject: [PATCH 09/25] chore: fix tests --- .github/workflows/ci.yaml | 1 + .schema/openapi/patches/schema.yaml | 2 + .../kratos/email-password/kratos.yml | 1 + internal/client-go/model_continue_with.go | 30 +++++++++++++ internal/httpclient/model_continue_with.go | 30 +++++++++++++ selfservice/flow/continue_with.go | 5 ++- selfservice/flow/error.go | 4 ++ selfservice/flow/recovery/error.go | 2 +- selfservice/flow/recovery/error_test.go | 24 +++++++--- selfservice/flow/recovery/flow_test.go | 45 +++++++++++++------ selfservice/flow/recovery/handler.go | 6 +-- .../strategy/code/strategy_recovery.go | 3 +- spec/api.json | 4 ++ .../e2e/playwright/tests/app_recovery.spec.ts | 10 ++--- 14 files changed, 134 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2e0dc2ca047f..f88471bf2392 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -177,6 +177,7 @@ jobs: uses: actions/checkout@v3 with: repository: ory/kratos-selfservice-ui-react-native + ref: jonas-jonas/improveRecovery path: react-native-ui - run: | cd react-native-ui diff --git a/.schema/openapi/patches/schema.yaml b/.schema/openapi/patches/schema.yaml index 4797e4fa61bf..206aceb2708e 100644 --- a/.schema/openapi/patches/schema.yaml +++ b/.schema/openapi/patches/schema.yaml @@ -42,6 +42,7 @@ show_verification_ui: "#/components/schemas/continueWithVerificationUi" set_ory_session_token: "#/components/schemas/continueWithSetOrySessionToken" show_settings_ui: "#/components/schemas/continueWithSettingsUi" + show_recovery_ui: "#/components/schemas/continueWithRecoveryUi" - op: add path: /components/schemas/continueWith/oneOf @@ -49,3 +50,4 @@ - "$ref": "#/components/schemas/continueWithVerificationUi" - "$ref": "#/components/schemas/continueWithSetOrySessionToken" - "$ref": "#/components/schemas/continueWithSettingsUi" + - "$ref": "#/components/schemas/continueWithRecoveryUi" diff --git a/contrib/quickstart/kratos/email-password/kratos.yml b/contrib/quickstart/kratos/email-password/kratos.yml index 1b939ac7f394..935727cc2bad 100644 --- a/contrib/quickstart/kratos/email-password/kratos.yml +++ b/contrib/quickstart/kratos/email-password/kratos.yml @@ -15,6 +15,7 @@ selfservice: allowed_return_urls: - http://127.0.0.1:4455 - http://localhost:19006/Callback + - exp://localhost:8081/--/Callback methods: password: diff --git a/internal/client-go/model_continue_with.go b/internal/client-go/model_continue_with.go index a1e844ca053a..ee84e74692fb 100644 --- a/internal/client-go/model_continue_with.go +++ b/internal/client-go/model_continue_with.go @@ -18,11 +18,19 @@ import ( // ContinueWith - struct for ContinueWith type ContinueWith struct { + ContinueWithRecoveryUi *ContinueWithRecoveryUi ContinueWithSetOrySessionToken *ContinueWithSetOrySessionToken ContinueWithSettingsUi *ContinueWithSettingsUi ContinueWithVerificationUi *ContinueWithVerificationUi } +// ContinueWithRecoveryUiAsContinueWith is a convenience function that returns ContinueWithRecoveryUi wrapped in ContinueWith +func ContinueWithRecoveryUiAsContinueWith(v *ContinueWithRecoveryUi) ContinueWith { + return ContinueWith{ + ContinueWithRecoveryUi: v, + } +} + // ContinueWithSetOrySessionTokenAsContinueWith is a convenience function that returns ContinueWithSetOrySessionToken wrapped in ContinueWith func ContinueWithSetOrySessionTokenAsContinueWith(v *ContinueWithSetOrySessionToken) ContinueWith { return ContinueWith{ @@ -48,6 +56,19 @@ func ContinueWithVerificationUiAsContinueWith(v *ContinueWithVerificationUi) Con func (dst *ContinueWith) UnmarshalJSON(data []byte) error { var err error match := 0 + // try to unmarshal data into ContinueWithRecoveryUi + err = newStrictDecoder(data).Decode(&dst.ContinueWithRecoveryUi) + if err == nil { + jsonContinueWithRecoveryUi, _ := json.Marshal(dst.ContinueWithRecoveryUi) + if string(jsonContinueWithRecoveryUi) == "{}" { // empty struct + dst.ContinueWithRecoveryUi = nil + } else { + match++ + } + } else { + dst.ContinueWithRecoveryUi = nil + } + // try to unmarshal data into ContinueWithSetOrySessionToken err = newStrictDecoder(data).Decode(&dst.ContinueWithSetOrySessionToken) if err == nil { @@ -89,6 +110,7 @@ func (dst *ContinueWith) UnmarshalJSON(data []byte) error { if match > 1 { // more than 1 match // reset to nil + dst.ContinueWithRecoveryUi = nil dst.ContinueWithSetOrySessionToken = nil dst.ContinueWithSettingsUi = nil dst.ContinueWithVerificationUi = nil @@ -103,6 +125,10 @@ func (dst *ContinueWith) UnmarshalJSON(data []byte) error { // Marshal data from the first non-nil pointers in the struct to JSON func (src ContinueWith) MarshalJSON() ([]byte, error) { + if src.ContinueWithRecoveryUi != nil { + return json.Marshal(&src.ContinueWithRecoveryUi) + } + if src.ContinueWithSetOrySessionToken != nil { return json.Marshal(&src.ContinueWithSetOrySessionToken) } @@ -123,6 +149,10 @@ func (obj *ContinueWith) GetActualInstance() interface{} { if obj == nil { return nil } + if obj.ContinueWithRecoveryUi != nil { + return obj.ContinueWithRecoveryUi + } + if obj.ContinueWithSetOrySessionToken != nil { return obj.ContinueWithSetOrySessionToken } diff --git a/internal/httpclient/model_continue_with.go b/internal/httpclient/model_continue_with.go index a1e844ca053a..ee84e74692fb 100644 --- a/internal/httpclient/model_continue_with.go +++ b/internal/httpclient/model_continue_with.go @@ -18,11 +18,19 @@ import ( // ContinueWith - struct for ContinueWith type ContinueWith struct { + ContinueWithRecoveryUi *ContinueWithRecoveryUi ContinueWithSetOrySessionToken *ContinueWithSetOrySessionToken ContinueWithSettingsUi *ContinueWithSettingsUi ContinueWithVerificationUi *ContinueWithVerificationUi } +// ContinueWithRecoveryUiAsContinueWith is a convenience function that returns ContinueWithRecoveryUi wrapped in ContinueWith +func ContinueWithRecoveryUiAsContinueWith(v *ContinueWithRecoveryUi) ContinueWith { + return ContinueWith{ + ContinueWithRecoveryUi: v, + } +} + // ContinueWithSetOrySessionTokenAsContinueWith is a convenience function that returns ContinueWithSetOrySessionToken wrapped in ContinueWith func ContinueWithSetOrySessionTokenAsContinueWith(v *ContinueWithSetOrySessionToken) ContinueWith { return ContinueWith{ @@ -48,6 +56,19 @@ func ContinueWithVerificationUiAsContinueWith(v *ContinueWithVerificationUi) Con func (dst *ContinueWith) UnmarshalJSON(data []byte) error { var err error match := 0 + // try to unmarshal data into ContinueWithRecoveryUi + err = newStrictDecoder(data).Decode(&dst.ContinueWithRecoveryUi) + if err == nil { + jsonContinueWithRecoveryUi, _ := json.Marshal(dst.ContinueWithRecoveryUi) + if string(jsonContinueWithRecoveryUi) == "{}" { // empty struct + dst.ContinueWithRecoveryUi = nil + } else { + match++ + } + } else { + dst.ContinueWithRecoveryUi = nil + } + // try to unmarshal data into ContinueWithSetOrySessionToken err = newStrictDecoder(data).Decode(&dst.ContinueWithSetOrySessionToken) if err == nil { @@ -89,6 +110,7 @@ func (dst *ContinueWith) UnmarshalJSON(data []byte) error { if match > 1 { // more than 1 match // reset to nil + dst.ContinueWithRecoveryUi = nil dst.ContinueWithSetOrySessionToken = nil dst.ContinueWithSettingsUi = nil dst.ContinueWithVerificationUi = nil @@ -103,6 +125,10 @@ func (dst *ContinueWith) UnmarshalJSON(data []byte) error { // Marshal data from the first non-nil pointers in the struct to JSON func (src ContinueWith) MarshalJSON() ([]byte, error) { + if src.ContinueWithRecoveryUi != nil { + return json.Marshal(&src.ContinueWithRecoveryUi) + } + if src.ContinueWithSetOrySessionToken != nil { return json.Marshal(&src.ContinueWithSetOrySessionToken) } @@ -123,6 +149,10 @@ func (obj *ContinueWith) GetActualInstance() interface{} { if obj == nil { return nil } + if obj.ContinueWithRecoveryUi != nil { + return obj.ContinueWithRecoveryUi + } + if obj.ContinueWithSetOrySessionToken != nil { return obj.ContinueWithSetOrySessionToken } diff --git a/selfservice/flow/continue_with.go b/selfservice/flow/continue_with.go index 26790143e81e..06c6cb912ee5 100644 --- a/selfservice/flow/continue_with.go +++ b/selfservice/flow/continue_with.go @@ -203,9 +203,10 @@ func NewContinueWithRecoveryUI(f Flow) *ContinueWithRecoveryUI { func ErrorWithContinueWith(err *herodot.DefaultError, continueWith ...ContinueWith) *herodot.DefaultError { // todo: check if the map already exists - err.DetailsField = map[string]interface{}{ - "continue_with": continueWith, + if err.DetailsField == nil { + err.DetailsField = map[string]interface{}{} } + err.DetailsField["continue_with"] = continueWith return err } diff --git a/selfservice/flow/error.go b/selfservice/flow/error.go index 0967c133dd46..d666822fa5c0 100644 --- a/selfservice/flow/error.go +++ b/selfservice/flow/error.go @@ -114,6 +114,10 @@ type ExpiredError struct { flow Flow } +func (e *ExpiredError) Unwrap() error { + return e.DefaultError +} + func (e *ExpiredError) WithContinueWith(continueWith ...ContinueWith) *ExpiredError { e.DefaultError = ErrorWithContinueWith(e.DefaultError, continueWith...) return e diff --git a/selfservice/flow/recovery/error.go b/selfservice/flow/recovery/error.go index e992f6efd1fb..db158ce73e00 100644 --- a/selfservice/flow/recovery/error.go +++ b/selfservice/flow/recovery/error.go @@ -105,7 +105,7 @@ func (s *ErrorHandler) WriteFlowError( switch { case newFlow.Type.IsAPI(): e.FlowID = newFlow.ID - s.d.Writer().WriteError(w, r, e.WithContinueWith(flow.NewContinueWithRecoveryUI(f))) + s.d.Writer().WriteError(w, r, e.WithContinueWith(flow.NewContinueWithRecoveryUI(newFlow))) case x.IsJSONRequest(r): http.Redirect(w, r, urlx.CopyWithQuery( urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()), RouteGetFlow), diff --git a/selfservice/flow/recovery/error_test.go b/selfservice/flow/recovery/error_test.go index ec7af27676d5..2fdc2a10bd07 100644 --- a/selfservice/flow/recovery/error_test.go +++ b/selfservice/flow/recovery/error_test.go @@ -12,6 +12,7 @@ import ( "github.com/gofrs/uuid" + "github.com/ory/x/ioutilx" "github.com/ory/x/jsonx" "github.com/ory/x/snapshotx" @@ -133,20 +134,29 @@ func TestHandleError(t *testing.T) { t.Run("case=expired error", func(t *testing.T) { t.Cleanup(reset) - recoveryFlow = newFlow(t, time.Minute, flow.TypeAPI) + recoveryFlow = newFlow(t, time.Minute, tc.t) flowError = flow.NewFlowExpiredError(anHourAgo) methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) require.NoError(t, err) - defer res.Body.Close() - require.Contains(t, res.Request.URL.String(), public.URL+recovery.RouteGetFlow) - require.Equal(t, http.StatusOK, res.StatusCode, "%+v", res.Request) - - body, err := io.ReadAll(res.Body) - require.NoError(t, err) + body := ioutilx.MustReadAll(res.Body) + switch tc.t { + case flow.TypeAPI: + require.Equal(t, http.StatusGone, res.StatusCode, "%s", body) + require.Len(t, gjson.GetBytes(body, "error.details.continue_with").Array(), 1, "%s", body) + require.Equal(t, "show_recovery_ui", gjson.GetBytes(body, "error.details.continue_with.0.action").String(), "%s", body) + id := gjson.GetBytes(body, "error.details.continue_with.0.flow.id").String() + res, err = ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, public.URL+recovery.RouteGetFlow+"?id="+id)) + require.NoError(t, err) + body = ioutilx.MustReadAll(res.Body) + case flow.TypeBrowser: + require.Contains(t, res.Request.URL.String(), public.URL+recovery.RouteGetFlow, "%s", body) + require.Equal(t, http.StatusOK, res.StatusCode, "%+v", res.Request) + } assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), string(body)) assert.NotEqual(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String()) + }) t.Run("case=validation error", func(t *testing.T) { diff --git a/selfservice/flow/recovery/flow_test.go b/selfservice/flow/recovery/flow_test.go index cab497c1b9a2..2f78a90b2f97 100644 --- a/selfservice/flow/recovery/flow_test.go +++ b/selfservice/flow/recovery/flow_test.go @@ -24,6 +24,8 @@ import ( "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/recovery" + "github.com/ory/kratos/selfservice/strategy/code" + "github.com/ory/kratos/selfservice/strategy/link" ) func TestFlow(t *testing.T) { @@ -92,20 +94,37 @@ func TestFlowEncodeJSON(t *testing.T) { func TestFromOldFlow(t *testing.T) { ctx := context.Background() - conf := internal.NewConfigurationWithDefaults(t) + conf, reg := internal.NewVeryFastRegistryWithoutDB(t) r := http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=" + urlx.AppendPaths(conf.SelfPublicURL(ctx), "/self-service/login/browser").String()}, Host: "ory.sh"} - for _, ft := range []flow.Type{ - flow.TypeAPI, - flow.TypeBrowser, - } { - t.Run(fmt.Sprintf("case=original flow is %s", ft), func(t *testing.T) { - f, err := recovery.NewFlow(conf, 0, "csrf", &r, nil, ft) - require.NoError(t, err) - nF, err := recovery.FromOldFlow(conf, time.Duration(time.Hour), f.CSRFToken, &r, nil, *f) - require.NoError(t, err) - require.Equal(t, flow.TypeBrowser, nF.Type) - }) - } + t.Run("strategy=code", func(t *testing.T) { + for _, ft := range []flow.Type{ + flow.TypeAPI, + flow.TypeBrowser, + } { + t.Run(fmt.Sprintf("case=original flow is %s", ft), func(t *testing.T) { + f, err := recovery.NewFlow(conf, 0, "csrf", &r, code.NewStrategy(reg), ft) + require.NoError(t, err) + nF, err := recovery.FromOldFlow(conf, time.Duration(time.Hour), f.CSRFToken, &r, nil, *f) + require.NoError(t, err) + require.Equal(t, ft, nF.Type) + }) + } + }) + + t.Run("strategy=link", func(t *testing.T) { + for _, ft := range []flow.Type{ + flow.TypeAPI, + flow.TypeBrowser, + } { + t.Run(fmt.Sprintf("case=original flow is %s", ft), func(t *testing.T) { + f, err := recovery.NewFlow(conf, 0, "csrf", &r, link.NewStrategy(reg), ft) + require.NoError(t, err) + nF, err := recovery.FromOldFlow(conf, time.Duration(time.Hour), f.CSRFToken, &r, nil, *f) + require.NoError(t, err) + require.Equal(t, flow.TypeBrowser, nF.Type) + }) + } + }) } func TestFlowDontOverrideReturnTo(t *testing.T) { diff --git a/selfservice/flow/recovery/handler.go b/selfservice/flow/recovery/handler.go index d3db1b2a7200..a10aa3dfff41 100644 --- a/selfservice/flow/recovery/handler.go +++ b/selfservice/flow/recovery/handler.go @@ -305,9 +305,9 @@ func (h *Handler) getRecoveryFlow(w http.ResponseWriter, r *http.Request, _ http WithDetail("return_to", f.ReturnTo))) return } - h.d.Writer().WriteError(w, r, errors.WithStack(x.ErrGone. - WithReason("The recovery flow has expired. Call the recovery flow init API endpoint to initialize a new recovery flow."). - WithDetail("api", urlx.AppendPaths(h.d.Config().SelfPublicURL(r.Context()), RouteInitAPIFlow).String()))) + + h.d.Writer().WriteError(w, r, flow.NewFlowExpiredError(f.ExpiresAt). + WithDetail("api", urlx.AppendPaths(h.d.Config().SelfPublicURL(r.Context()), RouteInitAPIFlow).String())) return } diff --git a/selfservice/strategy/code/strategy_recovery.go b/selfservice/strategy/code/strategy_recovery.go index 59812553bb88..f09e6adb4477 100644 --- a/selfservice/strategy/code/strategy_recovery.go +++ b/selfservice/strategy/code/strategy_recovery.go @@ -8,11 +8,10 @@ import ( "net/url" "time" - "github.com/ory/herodot" - "github.com/gofrs/uuid" "github.com/pkg/errors" + "github.com/ory/herodot" "github.com/ory/x/decoderx" "github.com/ory/x/sqlxx" "github.com/ory/x/urlx" diff --git a/spec/api.json b/spec/api.json index 0d74122ef6a6..cc7aa6ea52c7 100644 --- a/spec/api.json +++ b/spec/api.json @@ -461,6 +461,7 @@ "discriminator": { "mapping": { "set_ory_session_token": "#/components/schemas/continueWithSetOrySessionToken", + "show_recovery_ui": "#/components/schemas/continueWithRecoveryUi", "show_settings_ui": "#/components/schemas/continueWithSettingsUi", "show_verification_ui": "#/components/schemas/continueWithVerificationUi" }, @@ -475,6 +476,9 @@ }, { "$ref": "#/components/schemas/continueWithSettingsUi" + }, + { + "$ref": "#/components/schemas/continueWithRecoveryUi" } ] }, diff --git a/test/e2e/playwright/tests/app_recovery.spec.ts b/test/e2e/playwright/tests/app_recovery.spec.ts index 242d78099dac..33eb24373db8 100644 --- a/test/e2e/playwright/tests/app_recovery.spec.ts +++ b/test/e2e/playwright/tests/app_recovery.spec.ts @@ -85,14 +85,14 @@ test.describe("Recovery", () => { await test.step("enter wrong repeatetly", async () => { for (let i = 0; i < 10; i++) { await page.getByTestId("code").fill(wrongCode) - await page.getByText("Submit").click() + await page.getByText("Submit", { 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").click() + await page.getByText("Submit", { exact: true }).click() await expect(page.getByTestId("ui/message/4060006")).toBeVisible() }) }) @@ -107,7 +107,7 @@ test.describe("Recovery", () => { methods: { code: { config: { - lifespan: "1s", + lifespan: "1ms", }, }, }, @@ -128,8 +128,8 @@ test.describe("Recovery", () => { const code = extractCode(mails[0]) await page.getByTestId("code").fill(code) - await page.getByText("Submit").click() - await expect(page.getByTestId("ui/message/4060006")).toBeVisible() + await page.getByText("Submit", { exact: true }).click() + await expect(page.getByTestId("ui/message/4060005")).toBeVisible() }) }) }) From 615673c374883f34c367eb38c4a26356482b3753 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Thu, 21 Sep 2023 16:31:12 +0200 Subject: [PATCH 10/25] chore: fix ci --- .github/workflows/ci.yaml | 8 + .../profiles/mobile/mfa/totp.spec.ts | 2 +- test/e2e/cypress/support/commands.ts | 4 - test/e2e/package-lock.json | 512 +++++++++++++----- test/e2e/package.json | 12 +- test/e2e/run.sh | 18 +- 6 files changed, 395 insertions(+), 161 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f88471bf2392..28df2c476312 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -249,6 +249,7 @@ jobs: TEST_DATABASE_MYSQL: "mysql://root:test@(localhost:3306)/mysql?parseTime=true&multiStatements=true" TEST_DATABASE_COCKROACHDB: "cockroach://root@localhost:26257/defaultdb?sslmode=disable" strategy: + fail-fast: false matrix: database: ["postgres", "cockroach", "sqlite", "mysql"] steps: @@ -326,6 +327,13 @@ jobs: with: name: logs path: test/e2e/*.e2e.log + - if: failure() + uses: actions/upload-artifact@v2 + with: + name: playwright-test-results-${{ github.sha }} + path: | + test/e2epw/test-results/ + test/e2epw/playwright-report/ docs-cli: runs-on: ubuntu-latest diff --git a/test/e2e/cypress/integration/profiles/mobile/mfa/totp.spec.ts b/test/e2e/cypress/integration/profiles/mobile/mfa/totp.spec.ts index 0c733c5cdf54..3b86c3890b67 100644 --- a/test/e2e/cypress/integration/profiles/mobile/mfa/totp.spec.ts +++ b/test/e2e/cypress/integration/profiles/mobile/mfa/totp.spec.ts @@ -1,8 +1,8 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { APP_URL, gen, MOBILE_URL, website } from "../../../../helpers" import { authenticator } from "otplib" +import { gen, MOBILE_URL, website } from "../../../../helpers" context("Mobile Profile", () => { describe("TOTP 2FA Flow", () => { diff --git a/test/e2e/cypress/support/commands.ts b/test/e2e/cypress/support/commands.ts index b7ccff939add..7c1574322ca2 100644 --- a/test/e2e/cypress/support/commands.ts +++ b/test/e2e/cypress/support/commands.ts @@ -1299,10 +1299,6 @@ Cypress.Commands.add( }, ) -Cypress.Commands.add("clearAllCookies", () => { - cy.clearCookies({ domain: null }) -}) - Cypress.Commands.add("submitPasswordForm", () => { cy.get('[name="method"][value="password"]').click() cy.get('[name="method"][value="password"]:disabled').should("not.exist") diff --git a/test/e2e/package-lock.json b/test/e2e/package-lock.json index bd64424f8d00..110bb667e86c 100644 --- a/test/e2e/package-lock.json +++ b/test/e2e/package-lock.json @@ -19,7 +19,7 @@ "@types/node": "^16.9.6", "@types/yamljs": "^0.2.31", "chrome-remote-interface": "0.31.2", - "cypress": "^11.2.0", + "cypress": "^12.17.0", "dayjs": "^1.10.4", "dotenv": "^16.0.3", "got": "^11.8.2", @@ -49,9 +49,9 @@ } }, "node_modules/@cypress/request": { - "version": "2.88.10", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.10.tgz", - "integrity": "sha512-Zp7F+R93N0yZyG34GutyTNr+okam7s/Fzc1+i3kcqOP8vk6OuajuE9qZJ6Rs+10/1JFtXFYMdyarnU1rZuJesg==", + "version": "2.88.12", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz", + "integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==", "dev": true, "dependencies": { "aws-sign2": "~0.7.0", @@ -67,9 +67,9 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "~6.5.2", + "qs": "~6.10.3", "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", + "tough-cookie": "^4.1.3", "tunnel-agent": "^0.6.0", "uuid": "^8.3.2" }, @@ -311,9 +311,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "16.11.26", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz", - "integrity": "sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ==", + "version": "16.18.53", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.53.tgz", + "integrity": "sha512-vVmHeo4tpF8zsknALU90Hh24VueYdu45ZlXzYWFbom61YR4avJqTFDC3QlWzjuTdAv6/3xHaxiO9NrtVZXrkmw==", "dev": true }, "node_modules/@types/prettier": { @@ -473,7 +473,7 @@ "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "dev": true, "engines": { "node": ">=0.8" @@ -520,16 +520,16 @@ "node_modules/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", "dev": true, "engines": { "node": "*" } }, "node_modules/aws4": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", - "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", + "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", "dev": true }, "node_modules/axios": { @@ -570,7 +570,7 @@ "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", "dev": true, "dependencies": { "tweetnacl": "^0.14.3" @@ -667,6 +667,19 @@ "node": ">=6" } }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-me-maybe": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", @@ -676,7 +689,7 @@ "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "dev": true }, "node_modules/chalk": { @@ -882,7 +895,7 @@ "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "dev": true }, "node_modules/cross-spawn": { @@ -900,15 +913,15 @@ } }, "node_modules/cypress": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-11.2.0.tgz", - "integrity": "sha512-u61UGwtu7lpsNWLUma/FKNOsrjcI6wleNmda/TyKHe0dOBcVjbCPlp1N6uwFZ0doXev7f/91YDpU9bqDCFeBLA==", + "version": "12.17.4", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.17.4.tgz", + "integrity": "sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ==", "dev": true, "hasInstallScript": true, "dependencies": { - "@cypress/request": "^2.88.10", + "@cypress/request": "2.88.12", "@cypress/xvfb": "^1.2.4", - "@types/node": "^14.14.31", + "@types/node": "^16.18.39", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", "arch": "^2.2.0", @@ -920,10 +933,10 @@ "check-more-types": "^2.24.0", "cli-cursor": "^3.1.0", "cli-table3": "~0.6.1", - "commander": "^5.1.0", + "commander": "^6.2.1", "common-tags": "^1.8.0", "dayjs": "^1.10.4", - "debug": "^4.3.2", + "debug": "^4.3.4", "enquirer": "^2.3.6", "eventemitter2": "6.4.7", "execa": "4.1.0", @@ -938,12 +951,13 @@ "listr2": "^3.8.3", "lodash": "^4.17.21", "log-symbols": "^4.0.0", - "minimist": "^1.2.6", + "minimist": "^1.2.8", "ospath": "^1.2.2", "pretty-bytes": "^5.6.0", + "process": "^0.11.10", "proxy-from-env": "1.0.0", "request-progress": "^3.0.0", - "semver": "^7.3.2", + "semver": "^7.5.3", "supports-color": "^8.1.1", "tmp": "~0.2.1", "untildify": "^4.0.0", @@ -953,19 +967,13 @@ "cypress": "bin/cypress" }, "engines": { - "node": ">=12.0.0" + "node": "^14.0.0 || ^16.0.0 || >=18.0.0" } }, - "node_modules/cypress/node_modules/@types/node": { - "version": "14.18.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.12.tgz", - "integrity": "sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A==", - "dev": true - }, "node_modules/cypress/node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", "dev": true, "engines": { "node": ">= 6" @@ -984,7 +992,7 @@ "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", "dev": true, "dependencies": { "assert-plus": "^1.0.0" @@ -1000,9 +1008,9 @@ "dev": true }, "node_modules/debug": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", - "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, "dependencies": { "ms": "2.1.2" @@ -1073,7 +1081,7 @@ "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", "dev": true, "dependencies": { "jsbn": "~0.1.0", @@ -1259,7 +1267,7 @@ "node_modules/extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", "dev": true, "engines": [ "node >=0.6.0" @@ -1312,7 +1320,7 @@ "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", "dev": true, "engines": { "node": "*" @@ -1367,6 +1375,27 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "node_modules/get-intrinsic": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-stdin": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", @@ -1406,7 +1435,7 @@ "node_modules/getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", "dev": true, "dependencies": { "assert-plus": "^1.0.0" @@ -1497,6 +1526,18 @@ "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==", "dev": true }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1506,6 +1547,30 @@ "node": ">=8" } }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", @@ -1702,7 +1767,7 @@ "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", "dev": true }, "node_modules/is-unicode-supported": { @@ -1726,7 +1791,7 @@ "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "dev": true }, "node_modules/joi": { @@ -1763,7 +1828,7 @@ "node_modules/jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "dev": true }, "node_modules/json-buffer": { @@ -1809,7 +1874,7 @@ "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "dev": true }, "node_modules/jsonfile": { @@ -2152,6 +2217,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2244,7 +2318,7 @@ "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "dev": true }, "node_modules/pify": { @@ -2295,6 +2369,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/proxy-from-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", @@ -2302,9 +2385,9 @@ "dev": true }, "node_modules/psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", "dev": true }, "node_modules/pump": { @@ -2318,23 +2401,35 @@ } }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "dev": true, "engines": { "node": ">=6" } }, "node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz", + "integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==", "dev": true, + "dependencies": { + "side-channel": "^1.0.4" + }, "engines": { "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -2356,6 +2451,12 @@ "throttleit": "^1.0.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -2449,9 +2550,9 @@ "devOptional": true }, "node_modules/semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -2484,6 +2585,20 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -2650,16 +2765,27 @@ } }, "node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", "dev": true, "dependencies": { - "psl": "^1.1.28", - "punycode": "^2.1.1" + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" }, "engines": { - "node": ">=0.8" + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" } }, "node_modules/tslib": { @@ -2671,7 +2797,7 @@ "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, "dependencies": { "safe-buffer": "^5.0.1" @@ -2683,7 +2809,7 @@ "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "dev": true }, "node_modules/type": { @@ -2735,6 +2861,16 @@ "node": ">=8" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -2747,7 +2883,7 @@ "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", "dev": true, "engines": [ "node >=0.6.0" @@ -2905,9 +3041,9 @@ } }, "@cypress/request": { - "version": "2.88.10", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.10.tgz", - "integrity": "sha512-Zp7F+R93N0yZyG34GutyTNr+okam7s/Fzc1+i3kcqOP8vk6OuajuE9qZJ6Rs+10/1JFtXFYMdyarnU1rZuJesg==", + "version": "2.88.12", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz", + "integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==", "dev": true, "requires": { "aws-sign2": "~0.7.0", @@ -2923,9 +3059,9 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "~6.5.2", + "qs": "~6.10.3", "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", + "tough-cookie": "^4.1.3", "tunnel-agent": "^0.6.0", "uuid": "^8.3.2" } @@ -3145,9 +3281,9 @@ "dev": true }, "@types/node": { - "version": "16.11.26", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz", - "integrity": "sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ==", + "version": "16.18.53", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.53.tgz", + "integrity": "sha512-vVmHeo4tpF8zsknALU90Hh24VueYdu45ZlXzYWFbom61YR4avJqTFDC3QlWzjuTdAv6/3xHaxiO9NrtVZXrkmw==", "dev": true }, "@types/prettier": { @@ -3272,7 +3408,7 @@ "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "dev": true }, "astral-regex": { @@ -3310,13 +3446,13 @@ "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", "dev": true }, "aws4": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", - "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", + "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", "dev": true }, "axios": { @@ -3343,7 +3479,7 @@ "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", "dev": true, "requires": { "tweetnacl": "^0.14.3" @@ -3414,6 +3550,16 @@ "integrity": "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==", "dev": true }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, "call-me-maybe": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", @@ -3423,7 +3569,7 @@ "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "dev": true }, "chalk": { @@ -3584,7 +3730,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "dev": true }, "cross-spawn": { @@ -3599,14 +3745,14 @@ } }, "cypress": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-11.2.0.tgz", - "integrity": "sha512-u61UGwtu7lpsNWLUma/FKNOsrjcI6wleNmda/TyKHe0dOBcVjbCPlp1N6uwFZ0doXev7f/91YDpU9bqDCFeBLA==", + "version": "12.17.4", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.17.4.tgz", + "integrity": "sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ==", "dev": true, "requires": { - "@cypress/request": "^2.88.10", + "@cypress/request": "2.88.12", "@cypress/xvfb": "^1.2.4", - "@types/node": "^14.14.31", + "@types/node": "^16.18.39", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", "arch": "^2.2.0", @@ -3618,10 +3764,10 @@ "check-more-types": "^2.24.0", "cli-cursor": "^3.1.0", "cli-table3": "~0.6.1", - "commander": "^5.1.0", + "commander": "^6.2.1", "common-tags": "^1.8.0", "dayjs": "^1.10.4", - "debug": "^4.3.2", + "debug": "^4.3.4", "enquirer": "^2.3.6", "eventemitter2": "6.4.7", "execa": "4.1.0", @@ -3636,28 +3782,23 @@ "listr2": "^3.8.3", "lodash": "^4.17.21", "log-symbols": "^4.0.0", - "minimist": "^1.2.6", + "minimist": "^1.2.8", "ospath": "^1.2.2", "pretty-bytes": "^5.6.0", + "process": "^0.11.10", "proxy-from-env": "1.0.0", "request-progress": "^3.0.0", - "semver": "^7.3.2", + "semver": "^7.5.3", "supports-color": "^8.1.1", "tmp": "~0.2.1", "untildify": "^4.0.0", "yauzl": "^2.10.0" }, "dependencies": { - "@types/node": { - "version": "14.18.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.12.tgz", - "integrity": "sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A==", - "dev": true - }, "commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", "dev": true } } @@ -3675,7 +3816,7 @@ "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", "dev": true, "requires": { "assert-plus": "^1.0.0" @@ -3688,9 +3829,9 @@ "dev": true }, "debug": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", - "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, "requires": { "ms": "2.1.2" @@ -3734,7 +3875,7 @@ "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", "dev": true, "requires": { "jsbn": "~0.1.0", @@ -3895,7 +4036,7 @@ "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", "dev": true }, "fd-slicer": { @@ -3925,7 +4066,7 @@ "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", "dev": true }, "form-data": { @@ -3964,6 +4105,24 @@ "dev": true, "optional": true }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "get-intrinsic": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3" + } + }, "get-stdin": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", @@ -3991,7 +4150,7 @@ "getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", "dev": true, "requires": { "assert-plus": "^1.0.0" @@ -4054,12 +4213,33 @@ "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==", "dev": true }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true + }, "http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", @@ -4197,7 +4377,7 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", "dev": true }, "is-unicode-supported": { @@ -4215,7 +4395,7 @@ "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "dev": true }, "joi": { @@ -4251,7 +4431,7 @@ "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "dev": true }, "json-buffer": { @@ -4291,7 +4471,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "dev": true }, "jsonfile": { @@ -4551,6 +4731,12 @@ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true }, + "object-inspect": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "dev": true + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4622,7 +4808,7 @@ "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "dev": true }, "pify": { @@ -4649,6 +4835,12 @@ "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", "dev": true }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true + }, "proxy-from-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", @@ -4656,9 +4848,9 @@ "dev": true }, "psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", "dev": true }, "pump": { @@ -4672,15 +4864,24 @@ } }, "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "dev": true }, "qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz", + "integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } + }, + "querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "dev": true }, "quick-lru": { @@ -4698,6 +4899,12 @@ "throttleit": "^1.0.0" } }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -4765,9 +4972,9 @@ "devOptional": true }, "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -4788,6 +4995,17 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, "signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -4919,13 +5137,23 @@ } }, "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", "dev": true, "requires": { - "psl": "^1.1.28", - "punycode": "^2.1.1" + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "dependencies": { + "universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true + } } }, "tslib": { @@ -4937,7 +5165,7 @@ "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, "requires": { "safe-buffer": "^5.0.1" @@ -4946,7 +5174,7 @@ "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "dev": true }, "type": { @@ -4979,6 +5207,16 @@ "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", "dev": true }, + "url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "requires": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -4988,7 +5226,7 @@ "verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", "dev": true, "requires": { "assert-plus": "^1.0.0", diff --git a/test/e2e/package.json b/test/e2e/package.json index 0a7e182f3985..4d826d41d07c 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -10,6 +10,11 @@ "text-run": "exit 0", "wait-on": "wait-on" }, + "dependencies": { + "@faker-js/faker": "^7.6.0", + "async-retry": "^1.3.3", + "mailhog": "^4.16.0" + }, "devDependencies": { "@ory/kratos-client": "0.0.0-next.8d3b018594f7", "@playwright/test": "^1.32.3", @@ -17,7 +22,7 @@ "@types/node": "^16.9.6", "@types/yamljs": "^0.2.31", "chrome-remote-interface": "0.31.2", - "cypress": "^11.2.0", + "cypress": "^12.17.0", "dayjs": "^1.10.4", "dotenv": "^16.0.3", "got": "^11.8.2", @@ -26,10 +31,5 @@ "typescript": "^4.7.4", "wait-on": "7.0.1", "yamljs": "^0.3.0" - }, - "dependencies": { - "@faker-js/faker": "^7.6.0", - "async-retry": "^1.3.3", - "mailhog": "^4.16.0" } } diff --git a/test/e2e/run.sh b/test/e2e/run.sh index 6dba7949c779..9cbbf457a0cc 100755 --- a/test/e2e/run.sh +++ b/test/e2e/run.sh @@ -201,19 +201,11 @@ prepare() { PORT=4746 HYDRA_ADMIN_URL=http://localhost:4745 ./hydra-kratos-login-consent >"${base}/test/e2e/hydra-kratos-ui.e2e.log" 2>&1 & ) - if [ -z ${NODE_UI_PATH+x} ]; then - ( - cd "$node_ui_dir" - PORT=4456 SECURITY_MODE=cookie npm run serve \ - >"${base}/test/e2e/ui-node.e2e.log" 2>&1 & - ) - else - ( - cd "$node_ui_dir" - PORT=4456 SECURITY_MODE=cookie npm run start \ - >"${base}/test/e2e/ui-node.e2e.log" 2>&1 & - ) - fi + ( + cd "$node_ui_dir" + PORT=4456 SECURITY_MODE=cookie npm run start \ + >"${base}/test/e2e/ui-node.e2e.log" 2>&1 & + ) if [ -z ${REACT_UI_PATH+x} ]; then ( From dd3c1571adca674bb1fba8b28df1d1a45cfe4916 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Thu, 21 Sep 2023 16:34:09 +0200 Subject: [PATCH 11/25] chore: pw --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 28df2c476312..0df419ac6ae7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -332,8 +332,8 @@ jobs: with: name: playwright-test-results-${{ github.sha }} path: | - test/e2epw/test-results/ - test/e2epw/playwright-report/ + test/e2e/test-results/ + test/e2e/playwright-report/ docs-cli: runs-on: ubuntu-latest From 9fff46f8d5aea9276ff275fe6a197b67b2e10d7e Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Mon, 25 Sep 2023 17:11:58 +0200 Subject: [PATCH 12/25] chore: fix ci --- .github/workflows/ci.yaml | 1 + test/e2e/cypress/support/commands.ts | 4 +++ test/e2e/package-lock.json | 33 +++++++++---------- test/e2e/package.json | 2 +- test/e2e/playwright.config.ts | 10 ++++++ .../e2e/playwright/tests/app_recovery.spec.ts | 1 - 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fcb374e5359d..5b38eaf3f854 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -281,6 +281,7 @@ jobs: uses: actions/checkout@v3 with: repository: ory/kratos-selfservice-ui-react-native + ref: jonas-jonas/improveRecovery path: react-native-ui - run: | cd react-native-ui diff --git a/test/e2e/cypress/support/commands.ts b/test/e2e/cypress/support/commands.ts index 6fc7f0947d96..1db5571f2900 100644 --- a/test/e2e/cypress/support/commands.ts +++ b/test/e2e/cypress/support/commands.ts @@ -1299,6 +1299,10 @@ Cypress.Commands.add( }, ) +Cypress.Commands.add("clearAllCookies", () => { + cy.clearCookies({ domain: null }) +}) + Cypress.Commands.add("submitPasswordForm", () => { cy.get('[name="method"][value="password"]').click() cy.get('[name="method"][value="password"]:disabled').should("not.exist") diff --git a/test/e2e/package-lock.json b/test/e2e/package-lock.json index 61ade06e3a10..e127c165a2b3 100644 --- a/test/e2e/package-lock.json +++ b/test/e2e/package-lock.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@ory/kratos-client": "0.0.0-next.8d3b018594f7", - "@playwright/test": "1.32.3", + "@playwright/test": "1.34.0", "@types/async-retry": "1.4.5", "@types/node": "16.9.6", "@types/yamljs": "0.2.31", @@ -184,13 +184,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.32.3", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.3.tgz", - "integrity": "sha512-BvWNvK0RfBriindxhLVabi8BRe3X0J9EVjKlcmhxjg4giWBD/xleLcg2dz7Tx0agu28rczjNIPQWznwzDwVsZQ==", + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.34.0.tgz", + "integrity": "sha512-GIALJVODOIrMflLV54H3Cow635OfrTwOu24ZTDyKC66uchtFX2NcCRq83cLdakMjZKYK78lODNLQSYBj2OgaTw==", "dev": true, "dependencies": { "@types/node": "*", - "playwright-core": "1.32.3" + "playwright-core": "1.34.0" }, "bin": { "playwright": "cli.js" @@ -2337,13 +2337,10 @@ } }, "node_modules/playwright-core": { - "version": "1.32.3", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.3.tgz", - "integrity": "sha512-SB+cdrnu74ZIn5Ogh/8278ngEh9NEEV0vR4sJFmK04h2iZpybfbqBY0bX6+BLYWVdV12JLLI+JEFtSnYgR+mWg==", + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.34.0.tgz", + "integrity": "sha512-fMUY1+iR6kYbJF/EsOOqzBA99ZHXbw9sYPNjwA4X/oV0hVF/1aGlWYBGPVUEqxBkGANDKMziYoOdKGU5DIP5Gg==", "dev": true, - "bin": { - "playwright": "cli.js" - }, "engines": { "node": ">=14" } @@ -3176,14 +3173,14 @@ } }, "@playwright/test": { - "version": "1.32.3", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.3.tgz", - "integrity": "sha512-BvWNvK0RfBriindxhLVabi8BRe3X0J9EVjKlcmhxjg4giWBD/xleLcg2dz7Tx0agu28rczjNIPQWznwzDwVsZQ==", + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.34.0.tgz", + "integrity": "sha512-GIALJVODOIrMflLV54H3Cow635OfrTwOu24ZTDyKC66uchtFX2NcCRq83cLdakMjZKYK78lODNLQSYBj2OgaTw==", "dev": true, "requires": { "@types/node": "*", "fsevents": "2.3.2", - "playwright-core": "1.32.3" + "playwright-core": "1.34.0" } }, "@sideway/address": { @@ -4829,9 +4826,9 @@ "dev": true }, "playwright-core": { - "version": "1.32.3", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.3.tgz", - "integrity": "sha512-SB+cdrnu74ZIn5Ogh/8278ngEh9NEEV0vR4sJFmK04h2iZpybfbqBY0bX6+BLYWVdV12JLLI+JEFtSnYgR+mWg==", + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.34.0.tgz", + "integrity": "sha512-fMUY1+iR6kYbJF/EsOOqzBA99ZHXbw9sYPNjwA4X/oV0hVF/1aGlWYBGPVUEqxBkGANDKMziYoOdKGU5DIP5Gg==", "dev": true }, "prettier": { diff --git a/test/e2e/package.json b/test/e2e/package.json index cc9f26b053de..3ad3d3fbed36 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -17,7 +17,7 @@ }, "devDependencies": { "@ory/kratos-client": "0.0.0-next.8d3b018594f7", - "@playwright/test": "1.32.3", + "@playwright/test": "1.34.0", "@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..ff856410bf6c 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -50,6 +50,16 @@ export default defineConfig({ }, timeout: 5 * 60 * 1000, // 5 minutes }, + // { + // command: ["npm ci", "npm run web"].join(" && "), + // cwd: process.env.RN_UI_PATH, + // url: "http://localhost:19006", + // reuseExistingServer: true, + // env: { + // KRATOS_URL: "http://localhost:4433", + // CI: "1", + // }, + // }, { command: "make .bin/MailHog && .bin/MailHog -smtp-bind-addr=localhost:8026", diff --git a/test/e2e/playwright/tests/app_recovery.spec.ts b/test/e2e/playwright/tests/app_recovery.spec.ts index 33eb24373db8..3832aaf334c5 100644 --- a/test/e2e/playwright/tests/app_recovery.spec.ts +++ b/test/e2e/playwright/tests/app_recovery.spec.ts @@ -16,7 +16,6 @@ const schemaConfig = { ], } -test.describe.configure({ mode: "parallel" }) test.describe("Recovery", () => { test.use({ configOverride: { From 948ca24294c7569503812ca2791b8558b436c28f Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Tue, 26 Sep 2023 10:37:42 +0200 Subject: [PATCH 13/25] chore: cr --- selfservice/flow/continue_with.go | 2 +- test/e2e/package-lock.json | 6 +++--- test/e2e/package.json | 6 +++--- test/e2e/playwright.config.ts | 10 ---------- 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/selfservice/flow/continue_with.go b/selfservice/flow/continue_with.go index 06c6cb912ee5..0b42a05f87be 100644 --- a/selfservice/flow/continue_with.go +++ b/selfservice/flow/continue_with.go @@ -27,6 +27,7 @@ const ( var _ ContinueWith = new(ContinueWithSetOrySessionToken) // Indicates that a session was issued, and the application should use this token for authenticated requests +// // swagger:model continueWithSetOrySessionToken type ContinueWithSetOrySessionToken struct { // Action will always be `set_ory_session_token` @@ -202,7 +203,6 @@ func NewContinueWithRecoveryUI(f Flow) *ContinueWithRecoveryUI { } func ErrorWithContinueWith(err *herodot.DefaultError, continueWith ...ContinueWith) *herodot.DefaultError { - // todo: check if the map already exists if err.DetailsField == nil { err.DetailsField = map[string]interface{}{} } diff --git a/test/e2e/package-lock.json b/test/e2e/package-lock.json index e127c165a2b3..5df47741d1ed 100644 --- a/test/e2e/package-lock.json +++ b/test/e2e/package-lock.json @@ -8,9 +8,9 @@ "name": "@ory/kratos-e2e-suite", "version": "0.0.1", "dependencies": { - "@faker-js/faker": "^7.6.0", - "async-retry": "^1.3.3", - "mailhog": "^4.16.0" + "@faker-js/faker": "7.6.0", + "async-retry": "1.3.3", + "mailhog": "4.16.0" }, "devDependencies": { "@ory/kratos-client": "0.0.0-next.8d3b018594f7", diff --git a/test/e2e/package.json b/test/e2e/package.json index 3ad3d3fbed36..d4106cbb8aa1 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -11,9 +11,9 @@ "wait-on": "wait-on" }, "dependencies": { - "@faker-js/faker": "^7.6.0", - "async-retry": "^1.3.3", - "mailhog": "^4.16.0" + "@faker-js/faker": "7.6.0", + "async-retry": "1.3.3", + "mailhog": "4.16.0" }, "devDependencies": { "@ory/kratos-client": "0.0.0-next.8d3b018594f7", diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts index ff856410bf6c..71a67dfd8795 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -50,16 +50,6 @@ export default defineConfig({ }, timeout: 5 * 60 * 1000, // 5 minutes }, - // { - // command: ["npm ci", "npm run web"].join(" && "), - // cwd: process.env.RN_UI_PATH, - // url: "http://localhost:19006", - // reuseExistingServer: true, - // env: { - // KRATOS_URL: "http://localhost:4433", - // CI: "1", - // }, - // }, { command: "make .bin/MailHog && .bin/MailHog -smtp-bind-addr=localhost:8026", From bffde859c89308c4b43d1f635c66cc89e0e7e512 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Wed, 27 Sep 2023 09:25:27 +0200 Subject: [PATCH 14/25] chore: fix indentation --- selfservice/flow/recovery/handler.go | 6 +++--- selfservice/strategy/code/strategy_recovery_admin.go | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/selfservice/flow/recovery/handler.go b/selfservice/flow/recovery/handler.go index a10aa3dfff41..504da60f6152 100644 --- a/selfservice/flow/recovery/handler.go +++ b/selfservice/flow/recovery/handler.go @@ -116,9 +116,9 @@ func (h *Handler) RegisterAdminRoutes(admin *x.RouterAdmin) { // Schemes: http, https // // Responses: -// 200: recoveryFlow -// 400: errorGeneric -// default: errorGeneric +// 200: recoveryFlow +// 400: errorGeneric +// default: errorGeneric func (h *Handler) createNativeRecoveryFlow(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { if !h.d.Config().SelfServiceFlowRecoveryEnabled(r.Context()) { h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Recovery is not allowed because it was disabled."))) diff --git a/selfservice/strategy/code/strategy_recovery_admin.go b/selfservice/strategy/code/strategy_recovery_admin.go index 5880cb533383..65d3ace2f1d4 100644 --- a/selfservice/strategy/code/strategy_recovery_admin.go +++ b/selfservice/strategy/code/strategy_recovery_admin.go @@ -122,10 +122,10 @@ type recoveryCodeForIdentity struct { // oryAccessToken: // // Responses: -// 201: recoveryCodeForIdentity -// 400: errorGeneric -// 404: errorGeneric -// default: errorGeneric +// 201: recoveryCodeForIdentity +// 400: errorGeneric +// 404: errorGeneric +// default: errorGeneric func (s *Strategy) createRecoveryCodeForIdentity(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { var p createRecoveryCodeForIdentityBody if err := s.dx.Decode(r, &p, decoderx.HTTPJSONDecoder()); err != nil { From 5a16b17c6f9f95bb7a42c263a1dc6c8fd914f1d1 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Thu, 28 Sep 2023 09:19:34 +0200 Subject: [PATCH 15/25] Update test/e2e/playwright/tests/app_recovery.spec.ts Co-authored-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- test/e2e/playwright/tests/app_recovery.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/playwright/tests/app_recovery.spec.ts b/test/e2e/playwright/tests/app_recovery.spec.ts index 3832aaf334c5..d2b5908fc522 100644 --- a/test/e2e/playwright/tests/app_recovery.spec.ts +++ b/test/e2e/playwright/tests/app_recovery.spec.ts @@ -81,7 +81,7 @@ test.describe("Recovery", () => { const code = extractCode(mails[0]) const wrongCode = "0" + code - await test.step("enter wrong repeatetly", async () => { + 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() From 058dd0104e34e9b50b2711b6f595bd6173da8f72 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Thu, 28 Sep 2023 09:19:42 +0200 Subject: [PATCH 16/25] Update selfservice/flow/recovery/handler.go Co-authored-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- selfservice/flow/recovery/handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/selfservice/flow/recovery/handler.go b/selfservice/flow/recovery/handler.go index 504da60f6152..2a4c7f77dd01 100644 --- a/selfservice/flow/recovery/handler.go +++ b/selfservice/flow/recovery/handler.go @@ -103,7 +103,7 @@ func (h *Handler) RegisterAdminRoutes(admin *x.RouterAdmin) { // // If a valid provided session cookie or session token is provided, a 400 Bad Request error. // -// If you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint. +// On an existing recovery flow, use the `getRecoveryFlow` API endpoint. // // You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server // Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make From c7921a33c978458aaa778d91cde6b897fb4b05b5 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Tue, 17 Oct 2023 11:39:23 +0200 Subject: [PATCH 17/25] chore: add feature flag --- .schemastore/config.schema.json | 495 ++++-------------- driver/config/config.go | 9 +- ...ry_payloads_after_submission-type=api.json | 1 + ...ayloads_after_submission-type=browser.json | 1 + ...ry_payloads_after_submission-type=spa.json | 1 + ...he_correct_recovery_payloads-type=api.json | 53 ++ ...orrect_recovery_payloads-type=browser.json | 53 ++ ...he_correct_recovery_payloads-type=spa.json | 53 ++ ...ry_payloads_after_submission-type=api.json | 85 +++ ...ayloads_after_submission-type=browser.json | 85 +++ ...ry_payloads_after_submission-type=spa.json | 85 +++ .../strategy/code/strategy_recovery.go | 54 +- .../strategy/code/strategy_recovery_test.go | 12 +- 13 files changed, 577 insertions(+), 410 deletions(-) create mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=api.json create mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=browser.json create mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=spa.json create mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json create mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json create mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json diff --git a/.schemastore/config.schema.json b/.schemastore/config.schema.json index 5ab6aaa60eff..486866add76c 100644 --- a/.schemastore/config.schema.json +++ b/.schemastore/config.schema.json @@ -43,10 +43,7 @@ "description": "Ory Kratos redirects to this URL per default on completion of self-service flows and other browser interaction. Read this [article for more information on browser redirects](https://www.ory.sh/kratos/docs/concepts/browser-redirect-flow-completion).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/dashboard", - "/dashboard" - ] + "examples": ["https://my-app.com/dashboard", "/dashboard"] }, "selfServiceSessionRevokerHook": { "type": "object", @@ -56,9 +53,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "selfServiceSessionIssuerHook": { "type": "object", @@ -68,9 +63,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "selfServiceRequireVerifiedAddressHook": { "type": "object", @@ -80,9 +73,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "selfServiceShowVerificationUIHook": { "type": "object", @@ -92,9 +83,7 @@ } }, "additionalProperties": false, - "required": [ - "hook" - ] + "required": ["hook"] }, "webHookAuthBasicAuthProperties": { "properties": { @@ -114,17 +103,11 @@ } }, "additionalProperties": false, - "required": [ - "user", - "password" - ] + "required": ["user", "password"] } }, "additionalProperties": false, - "required": [ - "type", - "config" - ] + "required": ["type", "config"] }, "httpRequestConfig": { "type": "object", @@ -132,9 +115,7 @@ "url": { "title": "HTTP address of API endpoint", "description": "This URL will be used to send the emails to.", - "examples": [ - "https://example.com/api/v1/email" - ], + "examples": ["https://example.com/api/v1/email"], "type": "string", "pattern": "^https?://" }, @@ -199,25 +180,15 @@ "in": { "type": "string", "description": "How the api key should be transferred", - "enum": [ - "header", - "cookie" - ] + "enum": ["header", "cookie"] } }, "additionalProperties": false, - "required": [ - "name", - "value", - "in" - ] + "required": ["name", "value", "in"] } }, "additionalProperties": false, - "required": [ - "type", - "config" - ] + "required": ["type", "config"] }, "selfServiceWebHook": { "type": "object", @@ -256,10 +227,7 @@ "const": true } }, - "required": [ - "ignore", - "parse" - ] + "required": ["ignore", "parse"] } }, "url": { @@ -320,46 +288,30 @@ "response": { "properties": { "ignore": { - "enum": [ - true - ] + "enum": [true] } }, - "required": [ - "ignore" - ] + "required": ["ignore"] } }, - "required": [ - "response" - ] + "required": ["response"] } }, { "properties": { "can_interrupt": { - "enum": [ - false - ] + "enum": [false] } }, - "require": [ - "can_interrupt" - ] + "require": ["can_interrupt"] } ], "additionalProperties": false, - "required": [ - "url", - "method" - ] + "required": ["url", "method"] } }, "additionalProperties": false, - "required": [ - "hook", - "config" - ] + "required": ["hook", "config"] }, "OIDCClaims": { "title": "OpenID Connect claims", @@ -392,9 +344,7 @@ "essential": true }, "acr": { - "values": [ - "urn:mace:incommon:iap:silver" - ] + "values": ["urn:mace:incommon:iap:silver"] } } } @@ -442,9 +392,7 @@ "properties": { "id": { "type": "string", - "examples": [ - "google" - ] + "examples": ["google"] }, "provider": { "title": "Provider", @@ -471,9 +419,7 @@ "linkedin", "lark" ], - "examples": [ - "google" - ] + "examples": ["google"] }, "label": { "title": "Optional string which will be used when generating labels for UI buttons.", @@ -488,23 +434,17 @@ "issuer_url": { "type": "string", "format": "uri", - "examples": [ - "https://accounts.google.com" - ] + "examples": ["https://accounts.google.com"] }, "auth_url": { "type": "string", "format": "uri", - "examples": [ - "https://accounts.google.com/o/oauth2/v2/auth" - ] + "examples": ["https://accounts.google.com/o/oauth2/v2/auth"] }, "token_url": { "type": "string", "format": "uri", - "examples": [ - "https://www.googleapis.com/oauth2/v4/token" - ] + "examples": ["https://www.googleapis.com/oauth2/v4/token"] }, "mapper_url": { "title": "Jsonnet Mapper URL", @@ -521,10 +461,7 @@ "type": "array", "items": { "type": "string", - "examples": [ - "offline_access", - "profile" - ] + "examples": ["offline_access", "profile"] } }, "microsoft_tenant": { @@ -543,30 +480,21 @@ "title": "Microsoft subject source", "description": "Controls which source the subject identifier is taken from by microsoft provider. If set to `userinfo` (the default) then the identifier is taken from the `sub` field of OIDC ID token or data received from `/userinfo` standard OIDC endpoint. If set to `me` then the `id` field of data structure received from `https://graph.microsoft.com/v1.0/me` is taken as an identifier.", "type": "string", - "enum": [ - "userinfo", - "me" - ], + "enum": ["userinfo", "me"], "default": "userinfo", - "examples": [ - "userinfo" - ] + "examples": ["userinfo"] }, "apple_team_id": { "title": "Apple Developer Team ID", "description": "Apple Developer Team ID needed for generating a JWT token for client secret", "type": "string", - "examples": [ - "KP76DQS54M" - ] + "examples": ["KP76DQS54M"] }, "apple_private_key_id": { "title": "Apple Private Key Identifier", "description": "Sign In with Apple Private Key Identifier needed for generating a JWT token for client secret", "type": "string", - "examples": [ - "UX56C66723" - ] + "examples": ["UX56C66723"] }, "apple_private_key": { "title": "Apple Private Key", @@ -581,12 +509,7 @@ } }, "additionalProperties": false, - "required": [ - "id", - "provider", - "client_id", - "mapper_url" - ], + "required": ["id", "provider", "client_id", "mapper_url"], "allOf": [ { "if": { @@ -595,23 +518,17 @@ "const": "microsoft" } }, - "required": [ - "provider" - ] + "required": ["provider"] }, "then": { - "required": [ - "microsoft_tenant" - ] + "required": ["microsoft_tenant"] }, "else": { "not": { "properties": { "microsoft_tenant": {} }, - "required": [ - "microsoft_tenant" - ] + "required": ["microsoft_tenant"] } } }, @@ -622,9 +539,7 @@ "const": "apple" } }, - "required": [ - "provider" - ] + "required": ["provider"] }, "then": { "not": { @@ -634,9 +549,7 @@ "minLength": 1 } }, - "required": [ - "client_secret" - ] + "required": ["client_secret"] }, "required": [ "apple_private_key_id", @@ -645,9 +558,7 @@ ] }, "else": { - "required": [ - "client_secret" - ], + "required": ["client_secret"], "allOf": [ { "not": { @@ -657,9 +568,7 @@ "minLength": 1 } }, - "required": [ - "apple_team_id" - ] + "required": ["apple_team_id"] } }, { @@ -670,9 +579,7 @@ "minLength": 1 } }, - "required": [ - "apple_private_key_id" - ] + "required": ["apple_private_key_id"] } }, { @@ -683,9 +590,7 @@ "minLength": 1 } }, - "required": [ - "apple_private_key" - ] + "required": ["apple_private_key"] } } ] @@ -826,10 +731,7 @@ "title": "Required Authenticator Assurance Level", "description": "Sets what Authenticator Assurance Level (used for 2FA) is required to access this feature. If set to `highest_available` then this endpoint requires the highest AAL the identity has set up. If set to `aal1` then the identity can access this feature without 2FA.", "type": "string", - "enum": [ - "aal1", - "highest_available" - ], + "enum": ["aal1", "highest_available"], "default": "highest_available" }, "selfServiceAfterSettings": { @@ -983,9 +885,7 @@ "path": { "title": "Path to PEM-encoded Fle", "type": "string", - "examples": [ - "path/to/file.pem" - ] + "examples": ["path/to/file.pem"] }, "base64": { "title": "Base64 Encoded Inline", @@ -1033,9 +933,7 @@ "$ref": "#/definitions/emailCourierTemplate" } }, - "required": [ - "email" - ] + "required": ["email"] }, "valid": { "additionalProperties": false, @@ -1045,9 +943,7 @@ "$ref": "#/definitions/emailCourierTemplate" } }, - "required": [ - "email" - ] + "required": ["email"] } } }, @@ -1097,9 +993,7 @@ "selfservice": { "type": "object", "additionalProperties": false, - "required": [ - "default_browser_return_url" - ], + "required": ["default_browser_return_url"], "properties": { "default_browser_return_url": { "$ref": "#/definitions/defaultReturnTo" @@ -1133,30 +1027,20 @@ "description": "URL where the Settings UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/user/settings" - ], + "examples": ["https://my-app.com/user/settings"], "default": "https://www.ory.sh/kratos/docs/fallback/settings" }, "lifespan": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "privileged_session_max_age": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "required_aal": { "$ref": "#/definitions/featureRequiredAal" @@ -1199,20 +1083,14 @@ "description": "URL where the Registration UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/signup" - ], + "examples": ["https://my-app.com/signup"], "default": "https://www.ory.sh/kratos/docs/fallback/registration" }, "lifespan": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "before": { "$ref": "#/definitions/selfServiceBeforeRegistration" @@ -1231,20 +1109,14 @@ "description": "URL where the Login UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/login" - ], + "examples": ["https://my-app.com/login"], "default": "https://www.ory.sh/kratos/docs/fallback/login" }, "lifespan": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "before": { "$ref": "#/definitions/selfServiceBeforeLogin" @@ -1270,9 +1142,7 @@ "description": "URL where the Ory Verify UI is hosted. This is the page where users activate and / or verify their email or telephone number. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/verify" - ], + "examples": ["https://my-app.com/verify"], "default": "https://www.ory.sh/kratos/docs/fallback/verification" }, "after": { @@ -1284,11 +1154,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "before": { "$ref": "#/definitions/selfServiceBeforeVerification" @@ -1297,10 +1163,7 @@ "title": "Verification Strategy", "description": "The strategy to use for verification requests", "type": "string", - "enum": [ - "link", - "code" - ], + "enum": ["link", "code"], "default": "code" }, "notify_unknown_recipients": { @@ -1327,9 +1190,7 @@ "description": "URL where the Ory Recovery UI is hosted. This is the page where users request and complete account recovery. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/verify" - ], + "examples": ["https://my-app.com/verify"], "default": "https://www.ory.sh/kratos/docs/fallback/recovery" }, "after": { @@ -1341,11 +1202,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "before": { "$ref": "#/definitions/selfServiceBeforeRecovery" @@ -1354,10 +1211,7 @@ "title": "Recovery Strategy", "description": "The strategy to use for recovery requests", "type": "string", - "enum": [ - "link", - "code" - ], + "enum": ["link", "code"], "default": "code" }, "notify_unknown_recipients": { @@ -1377,9 +1231,7 @@ "description": "URL where the Ory Kratos Error UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": [ - "https://my-app.com/kratos-error" - ], + "examples": ["https://my-app.com/kratos-error"], "default": "https://www.ory.sh/kratos/docs/fallback/error" } } @@ -1418,20 +1270,14 @@ "base_url": { "title": "Override the base URL which should be used as the base for recovery and verification links.", "type": "string", - "examples": [ - "https://my-app.com" - ] + "examples": ["https://my-app.com"] }, "lifespan": { "title": "How long a link is valid for", "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] } } } @@ -1456,11 +1302,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] } } } @@ -1578,44 +1420,33 @@ }, "rp": { "title": "Relying Party (RP) Config", - "required": [ - "id", - "display_name" - ], + "required": ["id", "display_name"], "properties": { "display_name": { "type": "string", "title": "Relying Party Display Name", "description": "An name to help the user identify this RP.", - "examples": [ - "Ory Foundation" - ] + "examples": ["Ory Foundation"] }, "id": { "type": "string", "title": "Relying Party Identifier", "description": "The id must be a subset of the domain currently in the browser.", - "examples": [ - "ory.sh" - ] + "examples": ["ory.sh"] }, "origin": { "type": "string", "title": "Relying Party Origin", "description": "An explicit RP origin. If left empty, this defaults to `id`.", "format": "uri", - "examples": [ - "https://www.ory.sh/login" - ] + "examples": ["https://www.ory.sh/login"] }, "icon": { "type": "string", "title": "Relying Party Icon", "description": "An icon to help the user identify this RP.", "format": "uri", - "examples": [ - "https://www.ory.sh/an-icon.png" - ] + "examples": ["https://www.ory.sh/an-icon.png"] } }, "type": "object" @@ -1630,14 +1461,10 @@ "const": true } }, - "required": [ - "enabled" - ] + "required": ["enabled"] }, "then": { - "required": [ - "config" - ] + "required": ["config"] } }, "oidc": { @@ -1660,9 +1487,7 @@ "title": "Base URL for OAuth2 Redirect URIs", "description": "Can be used to modify the base URL for OAuth2 Redirect URLs. If unset, the Public Base URL will be used.", "format": "uri", - "examples": [ - "https://auth.myexample.org/" - ] + "examples": ["https://auth.myexample.org/"] }, "providers": { "title": "OpenID Connect and OAuth2 Providers", @@ -1761,27 +1586,19 @@ "type": "string", "title": "Override message templates", "description": "You can override certain or all message templates by pointing this key to the path where the templates are located.", - "examples": [ - "/conf/courier-templates" - ] + "examples": ["/conf/courier-templates"] }, "message_retries": { "description": "Defines the maximum number of times the sending of a message is retried after it failed before it is marked as abandoned", "type": "integer", "default": 5, - "examples": [ - 10, - 60 - ] + "examples": [10, 60] }, "delivery_strategy": { "title": "Delivery Strategy", "description": "Defines how emails will be sent, either through SMTP (default) or HTTP.", "type": "string", - "enum": [ - "smtp", - "http" - ], + "enum": ["smtp", "http"], "default": "smtp" }, "http": { @@ -1838,9 +1655,7 @@ "title": "SMTP Sender Name", "description": "The recipient of an email will see this as the sender name.", "type": "string", - "examples": [ - "Bob" - ] + "examples": ["Bob"] }, "headers": { "title": "SMTP Headers", @@ -1864,9 +1679,7 @@ "default": "localhost" } }, - "required": [ - "connection_uri" - ], + "required": ["connection_uri"], "additionalProperties": false }, "sms": { @@ -1891,9 +1704,7 @@ "url": { "title": "HTTP address of API endpoint", "description": "This URL will be used to connect to the SMS provider.", - "examples": [ - "https://api.twillio.com/sms/send" - ], + "examples": ["https://api.twillio.com/sms/send"], "type": "string", "pattern": "^https?:\\/\\/.*" }, @@ -1935,10 +1746,7 @@ }, "additionalProperties": false }, - "required": [ - "url", - "method" - ], + "required": ["url", "method"], "additionalProperties": false } }, @@ -2006,9 +1814,7 @@ "description": "The URL where the admin endpoint is exposed at.", "type": "string", "format": "uri", - "examples": [ - "https://kratos.private-network:4434/" - ] + "examples": ["https://kratos.private-network:4434/"] }, "host": { "title": "Admin Host", @@ -2022,9 +1828,7 @@ "type": "integer", "minimum": 1, "maximum": 65535, - "examples": [ - 4434 - ], + "examples": [4434], "default": 4434 }, "socket": { @@ -2083,9 +1887,7 @@ ] }, "uniqueItems": true, - "default": [ - "*" - ], + "default": ["*"], "examples": [ [ "https://example.com", @@ -2097,13 +1899,7 @@ "allowed_methods": { "type": "array", "description": "A list of HTTP methods the user agent is allowed to use with cross-domain requests.", - "default": [ - "POST", - "GET", - "PUT", - "PATCH", - "DELETE" - ], + "default": ["POST", "GET", "PUT", "PATCH", "DELETE"], "items": { "type": "string", "enum": [ @@ -2134,9 +1930,7 @@ "exposed_headers": { "type": "array", "description": "Sets which headers are safe to expose to the API of a CORS API specification.", - "default": [ - "Content-Type" - ], + "default": ["Content-Type"], "items": { "type": "string" } @@ -2179,9 +1973,7 @@ "type": "integer", "minimum": 1, "maximum": 65535, - "examples": [ - 4433 - ], + "examples": [4433], "default": 4433 }, "socket": { @@ -2231,10 +2023,7 @@ "format": { "description": "The log format can either be text or JSON.", "type": "string", - "enum": [ - "json", - "text" - ] + "enum": ["json", "text"] } }, "additionalProperties": false @@ -2275,9 +2064,7 @@ "id": { "title": "The schema's ID.", "type": "string", - "examples": [ - "employee" - ] + "examples": ["employee"] }, "url": { "type": "string", @@ -2291,16 +2078,11 @@ ] } }, - "required": [ - "id", - "url" - ] + "required": ["id", "url"] } } }, - "required": [ - "schemas" - ], + "required": ["schemas"], "additionalProperties": false }, "secrets": { @@ -2349,10 +2131,7 @@ "description": "One of the values: argon2, bcrypt.\nAny other hashes will be migrated to the set algorithm once an identity authenticates using their password.", "type": "string", "default": "bcrypt", - "enum": [ - "argon2", - "bcrypt" - ] + "enum": ["argon2", "bcrypt"] }, "argon2": { "title": "Configuration for the Argon2id hasher.", @@ -2408,9 +2187,7 @@ "title": "Configuration for the Bcrypt hasher. Minimum is 4 when --dev flag is used and 12 otherwise.", "type": "object", "additionalProperties": false, - "required": [ - "cost" - ], + "required": ["cost"], "properties": { "cost": { "type": "integer", @@ -2432,11 +2209,7 @@ "description": "One of the values: noop, aes, xchacha20-poly1305", "type": "string", "default": "noop", - "enum": [ - "noop", - "aes", - "xchacha20-poly1305" - ] + "enum": ["noop", "aes", "xchacha20-poly1305"] } } }, @@ -2460,11 +2233,7 @@ "title": "HTTP Cookie Same Site Configuration", "description": "Sets the session and CSRF cookie SameSite.", "type": "string", - "enum": [ - "Strict", - "Lax", - "None" - ], + "enum": ["Strict", "Lax", "None"], "default": "Lax" } }, @@ -2491,11 +2260,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "24h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] }, "cookie": { "type": "object", @@ -2526,11 +2291,7 @@ "title": "Session Cookie SameSite Configuration", "description": "Sets the session cookie SameSite. Overrides `cookies.same_site`.", "type": "string", - "enum": [ - "Strict", - "Lax", - "None" - ] + "enum": ["Strict", "Lax", "None"] } }, "additionalProperties": false @@ -2541,11 +2302,7 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "24h", - "examples": [ - "1h", - "1m", - "1s" - ] + "examples": ["1h", "1m", "1s"] } } }, @@ -2554,9 +2311,7 @@ "description": "SemVer according to https://semver.org/ prefixed with `v` as in our releases.", "type": "string", "pattern": "^(v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?)|$", - "examples": [ - "v0.5.0-alpha.1" - ] + "examples": ["v0.5.0-alpha.1"] }, "dev": { "type": "boolean" @@ -2580,9 +2335,7 @@ "type": "integer", "minimum": 0, "maximum": 65535, - "examples": [ - 4434 - ], + "examples": [4434], "default": 0 }, "config": { @@ -2630,6 +2383,12 @@ "title": "Enable Ory Sessions caching", "description": "If enabled allows Ory Sessions to be cached. Only effective in the Ory Network.", "default": false + }, + "new_flow_transitions": { + "type": "boolean", + "title": "Enable new flow transitions using `continue_with` items", + "description": "If enabled allows new flow transitions using `continue_with` items.", + "default": false } }, "additionalProperties": false @@ -2651,14 +2410,10 @@ "const": true } }, - "required": [ - "enabled" - ] + "required": ["enabled"] } }, - "required": [ - "verification" - ] + "required": ["verification"] }, { "properties": { @@ -2668,31 +2423,21 @@ "const": true } }, - "required": [ - "enabled" - ] + "required": ["enabled"] } }, - "required": [ - "recovery" - ] + "required": ["recovery"] } ] } }, - "required": [ - "flows" - ] + "required": ["flows"] } }, - "required": [ - "selfservice" - ] + "required": ["selfservice"] }, "then": { - "required": [ - "courier" - ] + "required": ["courier"] } }, { @@ -2711,33 +2456,21 @@ ] } }, - "required": [ - "algorithm" - ] + "required": ["algorithm"] } }, - "required": [ - "ciphers" - ] + "required": ["ciphers"] }, "then": { - "required": [ - "secrets" - ], + "required": ["secrets"], "properties": { "secrets": { - "required": [ - "cipher" - ] + "required": ["cipher"] } } } } ], - "required": [ - "identity", - "dsn", - "selfservice" - ], + "required": ["identity", "dsn", "selfservice"], "additionalProperties": false } diff --git a/driver/config/config.go b/driver/config/config.go index 4fa461dfbc0e..0e49b97490a6 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -111,6 +111,7 @@ const ( ViperKeySessionTokenizerTemplates = "session.whoami.tokenizer.templates" ViperKeySessionWhoAmIAAL = "session.whoami.required_aal" ViperKeySessionWhoAmICaching = "feature_flags.cacheable_sessions" + ViperKeyNewFlowTransitions = "feature_flags.new_flow_transitions" ViperKeySessionRefreshMinTimeLeft = "session.earliest_possible_extend" ViperKeyCookieSameSite = "cookies.same_site" ViperKeyCookieDomain = "cookies.domain" @@ -591,7 +592,7 @@ func (p *Config) PublicSocketPermission(ctx context.Context) *configx.UnixPermis return &configx.UnixPermission{ Owner: pp.String(ViperKeyPublicSocketOwner), Group: pp.String(ViperKeyPublicSocketGroup), - Mode: os.FileMode(pp.IntF(ViperKeyPublicSocketMode, 0755)), + Mode: os.FileMode(pp.IntF(ViperKeyPublicSocketMode, 0o755)), } } @@ -600,7 +601,7 @@ func (p *Config) AdminSocketPermission(ctx context.Context) *configx.UnixPermiss return &configx.UnixPermission{ Owner: pp.String(ViperKeyAdminSocketOwner), Group: pp.String(ViperKeyAdminSocketGroup), - Mode: os.FileMode(pp.IntF(ViperKeyAdminSocketMode, 0755)), + Mode: os.FileMode(pp.IntF(ViperKeyAdminSocketMode, 0o755)), } } @@ -1299,6 +1300,10 @@ func (p *Config) SessionWhoAmICaching(ctx context.Context) bool { return p.GetProvider(ctx).Bool(ViperKeySessionWhoAmICaching) } +func (p *Config) NewFlowTransitions(ctx context.Context) bool { + return p.GetProvider(ctx).Bool(ViperKeyNewFlowTransitions) +} + func (p *Config) SessionRefreshMinTimeLeft(ctx context.Context) time.Duration { return p.GetProvider(ctx).DurationF(ViperKeySessionRefreshMinTimeLeft, p.SessionLifespan(ctx)) } diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json index 36fe7b033ce7..dbf1dcd2cbb7 100644 --- a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json +++ b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json @@ -19,6 +19,7 @@ "name": "code", "type": "text", "required": true, + "pattern": "[0-9]+", "disabled": false, "node_type": "input" }, diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json index 36fe7b033ce7..dbf1dcd2cbb7 100644 --- a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json +++ b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json @@ -19,6 +19,7 @@ "name": "code", "type": "text", "required": true, + "pattern": "[0-9]+", "disabled": false, "node_type": "input" }, diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json index 36fe7b033ce7..dbf1dcd2cbb7 100644 --- a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json +++ b/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json @@ -19,6 +19,7 @@ "name": "code", "type": "text", "required": true, + "pattern": "[0-9]+", "disabled": false, "node_type": "input" }, diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=api.json new file mode 100644 index 000000000000..ec1092ad77a6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=api.json @@ -0,0 +1,53 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "email", + "node_type": "input", + "required": true, + "type": "email" + }, + "group": "code", + "messages": [], + "meta": { + "label": { + "id": 1070007, + "text": "Email", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "method", + "node_type": "input", + "type": "submit", + "value": "code" + }, + "group": "code", + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + }, + "type": "input" + } +] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=browser.json new file mode 100644 index 000000000000..ec1092ad77a6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=browser.json @@ -0,0 +1,53 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "email", + "node_type": "input", + "required": true, + "type": "email" + }, + "group": "code", + "messages": [], + "meta": { + "label": { + "id": 1070007, + "text": "Email", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "method", + "node_type": "input", + "type": "submit", + "value": "code" + }, + "group": "code", + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + }, + "type": "input" + } +] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=spa.json new file mode 100644 index 000000000000..ec1092ad77a6 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=spa.json @@ -0,0 +1,53 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "email", + "node_type": "input", + "required": true, + "type": "email" + }, + "group": "code", + "messages": [], + "meta": { + "label": { + "id": 1070007, + "text": "Email", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "method", + "node_type": "input", + "type": "submit", + "value": "code" + }, + "group": "code", + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + }, + "type": "input" + } +] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json new file mode 100644 index 000000000000..36fe7b033ce7 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json @@ -0,0 +1,85 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "code", + "type": "text", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070010, + "text": "Recovery code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "hidden", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "email", + "type": "submit", + "value": "test@ory.sh", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070008, + "text": "Resend code", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json new file mode 100644 index 000000000000..36fe7b033ce7 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json @@ -0,0 +1,85 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "code", + "type": "text", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070010, + "text": "Recovery code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "hidden", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "email", + "type": "submit", + "value": "test@ory.sh", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070008, + "text": "Resend code", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json new file mode 100644 index 000000000000..36fe7b033ce7 --- /dev/null +++ b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json @@ -0,0 +1,85 @@ +[ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "code", + "type": "text", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070010, + "text": "Recovery code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "hidden", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "email", + "type": "submit", + "value": "test@ory.sh", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070008, + "text": "Resend code", + "type": "info" + } + } + } +] diff --git a/selfservice/strategy/code/strategy_recovery.go b/selfservice/strategy/code/strategy_recovery.go index 2fcd13dd01a3..c82cd81b1b26 100644 --- a/selfservice/strategy/code/strategy_recovery.go +++ b/selfservice/strategy/code/strategy_recovery.go @@ -218,14 +218,22 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } - switch { - case f.Type.IsAPI(): - f.ContinueWith = append(f.ContinueWith, flow.NewContinueWithSettingsUI(sf)) - s.deps.Writer().Write(w, r, f) - case x.IsJSONRequest(r): - s.deps.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String())) - default: - http.Redirect(w, r, sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String(), http.StatusSeeOther) + if s.deps.Config().NewFlowTransitions(ctx) { + switch { + case f.Type.IsAPI(): + f.ContinueWith = append(f.ContinueWith, flow.NewContinueWithSettingsUI(sf)) + s.deps.Writer().Write(w, r, f) + case x.IsJSONRequest(r): + s.deps.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String())) + default: + http.Redirect(w, r, sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String(), http.StatusSeeOther) + } + } else { + if x.IsJSONRequest(r) { + s.deps.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String())) + } else { + http.Redirect(w, r, sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String(), http.StatusSeeOther) + } } return errors.WithStack(flow.ErrCompletedByStrategy) @@ -317,20 +325,24 @@ func (s *Strategy) retryRecoveryFlow(w http.ResponseWriter, r *http.Request, ft return err } - switch { - case f.Type.IsAPI(): - rErr := new(herodot.DefaultError) - if !errors.As(retryOptions.err, &rErr) { - rErr = rErr.WithError(retryOptions.err.Error()) + if s.deps.Config().NewFlowTransitions(ctx) { + switch { + case x.IsJSONRequest(r): + rErr := new(herodot.DefaultError) + if !errors.As(retryOptions.err, &rErr) { + rErr = rErr.WithError(retryOptions.err.Error()) + } + s.deps.Writer().WriteError(w, r, flow.ErrorWithContinueWith(rErr, flow.NewContinueWithRecoveryUI(f))) + default: + http.Redirect(w, r, f.AppendTo(config.SelfServiceFlowRecoveryUI(ctx)).String(), http.StatusSeeOther) + } + } else { + if x.IsJSONRequest(r) { + http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(config.SelfPublicURL(ctx), + recovery.RouteGetFlow), url.Values{"id": {f.ID.String()}}).String(), http.StatusSeeOther) + } else { + http.Redirect(w, r, f.AppendTo(config.SelfServiceFlowRecoveryUI(ctx)).String(), http.StatusSeeOther) } - s.deps.Writer().WriteError(w, r, flow.ErrorWithContinueWith(rErr, flow.NewContinueWithRecoveryUI(f))) - case x.IsJSONRequest(r): - http.Redirect(w, r, urlx.CopyWithQuery( - urlx.AppendPaths(config.SelfPublicURL(ctx), recovery.RouteGetFlow), - url.Values{"id": {f.ID.String()}}, - ).String(), http.StatusSeeOther) - default: - http.Redirect(w, r, f.AppendTo(config.SelfServiceFlowRecoveryUI(ctx)).String(), http.StatusSeeOther) } return errors.WithStack(flow.ErrCompletedByStrategy) diff --git a/selfservice/strategy/code/strategy_recovery_test.go b/selfservice/strategy/code/strategy_recovery_test.go index a57950318b59..7cb152c86492 100644 --- a/selfservice/strategy/code/strategy_recovery_test.go +++ b/selfservice/strategy/code/strategy_recovery_test.go @@ -88,7 +88,6 @@ var flowTypeCases = []struct { FormContentType: "application/x-www-form-urlencoded", }, { - FlowType: flow.TypeAPI, ClientType: RecoveryClientTypeAPI, GetClient: func(_ *testing.T) *http.Client { @@ -145,6 +144,7 @@ func TestRecovery(t *testing.T) { conf, reg := internal.NewFastRegistryWithMocks(t) testhelpers.StrategyEnable(t, conf, string(recovery.RecoveryStrategyCode), true) testhelpers.StrategyEnable(t, conf, string(recovery.RecoveryStrategyLink), false) + conf.MustSet(ctx, config.ViperKeyNewFlowTransitions, true) initViper(t, ctx, conf) @@ -648,8 +648,6 @@ func TestRecovery(t *testing.T) { switch testCase.ClientType { case RecoveryClientTypeBrowser: - fallthrough - case RecoveryClientTypeSPA: // submit an invalid code for the 6th time body = submitRecoveryCode(t, c, body, testCase.ClientType, "12312312", http.StatusOK) @@ -660,6 +658,8 @@ func TestRecovery(t *testing.T) { assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==email)").Exists()) + case RecoveryClientTypeSPA: + fallthrough case RecoveryClientTypeAPI: // submit an invalid code for the 6th time body = submitRecoveryCode(t, c, body, testCase.ClientType, "12312312", http.StatusBadRequest) @@ -673,10 +673,10 @@ func TestRecovery(t *testing.T) { assert.NotEmpty(t, flowId, "%s", body) require.NotEqual(t, flowId, initialFlowId, "%s", body) - rf, _, err := testhelpers.NewSDKClient(public).FrontendApi.GetRecoveryFlow(context.Background()).Id(flowId).Execute() + flow, err := reg.Persister().GetRecoveryFlow(ctx, uuid.Must(uuid.FromString(flowId))) require.NoError(t, err) - assert.Len(t, rf.Ui.Messages, 1, "%+v", rf) - assert.Equal(t, "The request was submitted too often. Please request another code.", rf.Ui.Messages[0].GetText()) + assert.Len(t, flow.UI.Messages, 1, "%+v", flow) + assert.Equal(t, "The request was submitted too often. Please request another code.", flow.UI.Messages[0].Text) } }) } From ee44f938979ebb2aa1bafcd956c7671273aa6eb2 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Tue, 17 Oct 2023 17:02:54 +0200 Subject: [PATCH 18/25] chore: fix tests --- .schemastore/config.schema.json | 495 ++++++++++++++---- embedx/config.schema.json | 11 +- .../profiles/code/login/error.spec.ts | 15 +- test/e2e/cypress/support/config.d.ts | 159 ++++-- .../e2e/playwright/tests/app_recovery.spec.ts | 8 +- test/e2e/render-kratos-config.sh | 8 +- 6 files changed, 528 insertions(+), 168 deletions(-) diff --git a/.schemastore/config.schema.json b/.schemastore/config.schema.json index 486866add76c..5ab6aaa60eff 100644 --- a/.schemastore/config.schema.json +++ b/.schemastore/config.schema.json @@ -43,7 +43,10 @@ "description": "Ory Kratos redirects to this URL per default on completion of self-service flows and other browser interaction. Read this [article for more information on browser redirects](https://www.ory.sh/kratos/docs/concepts/browser-redirect-flow-completion).", "type": "string", "format": "uri-reference", - "examples": ["https://my-app.com/dashboard", "/dashboard"] + "examples": [ + "https://my-app.com/dashboard", + "/dashboard" + ] }, "selfServiceSessionRevokerHook": { "type": "object", @@ -53,7 +56,9 @@ } }, "additionalProperties": false, - "required": ["hook"] + "required": [ + "hook" + ] }, "selfServiceSessionIssuerHook": { "type": "object", @@ -63,7 +68,9 @@ } }, "additionalProperties": false, - "required": ["hook"] + "required": [ + "hook" + ] }, "selfServiceRequireVerifiedAddressHook": { "type": "object", @@ -73,7 +80,9 @@ } }, "additionalProperties": false, - "required": ["hook"] + "required": [ + "hook" + ] }, "selfServiceShowVerificationUIHook": { "type": "object", @@ -83,7 +92,9 @@ } }, "additionalProperties": false, - "required": ["hook"] + "required": [ + "hook" + ] }, "webHookAuthBasicAuthProperties": { "properties": { @@ -103,11 +114,17 @@ } }, "additionalProperties": false, - "required": ["user", "password"] + "required": [ + "user", + "password" + ] } }, "additionalProperties": false, - "required": ["type", "config"] + "required": [ + "type", + "config" + ] }, "httpRequestConfig": { "type": "object", @@ -115,7 +132,9 @@ "url": { "title": "HTTP address of API endpoint", "description": "This URL will be used to send the emails to.", - "examples": ["https://example.com/api/v1/email"], + "examples": [ + "https://example.com/api/v1/email" + ], "type": "string", "pattern": "^https?://" }, @@ -180,15 +199,25 @@ "in": { "type": "string", "description": "How the api key should be transferred", - "enum": ["header", "cookie"] + "enum": [ + "header", + "cookie" + ] } }, "additionalProperties": false, - "required": ["name", "value", "in"] + "required": [ + "name", + "value", + "in" + ] } }, "additionalProperties": false, - "required": ["type", "config"] + "required": [ + "type", + "config" + ] }, "selfServiceWebHook": { "type": "object", @@ -227,7 +256,10 @@ "const": true } }, - "required": ["ignore", "parse"] + "required": [ + "ignore", + "parse" + ] } }, "url": { @@ -288,30 +320,46 @@ "response": { "properties": { "ignore": { - "enum": [true] + "enum": [ + true + ] } }, - "required": ["ignore"] + "required": [ + "ignore" + ] } }, - "required": ["response"] + "required": [ + "response" + ] } }, { "properties": { "can_interrupt": { - "enum": [false] + "enum": [ + false + ] } }, - "require": ["can_interrupt"] + "require": [ + "can_interrupt" + ] } ], "additionalProperties": false, - "required": ["url", "method"] + "required": [ + "url", + "method" + ] } }, "additionalProperties": false, - "required": ["hook", "config"] + "required": [ + "hook", + "config" + ] }, "OIDCClaims": { "title": "OpenID Connect claims", @@ -344,7 +392,9 @@ "essential": true }, "acr": { - "values": ["urn:mace:incommon:iap:silver"] + "values": [ + "urn:mace:incommon:iap:silver" + ] } } } @@ -392,7 +442,9 @@ "properties": { "id": { "type": "string", - "examples": ["google"] + "examples": [ + "google" + ] }, "provider": { "title": "Provider", @@ -419,7 +471,9 @@ "linkedin", "lark" ], - "examples": ["google"] + "examples": [ + "google" + ] }, "label": { "title": "Optional string which will be used when generating labels for UI buttons.", @@ -434,17 +488,23 @@ "issuer_url": { "type": "string", "format": "uri", - "examples": ["https://accounts.google.com"] + "examples": [ + "https://accounts.google.com" + ] }, "auth_url": { "type": "string", "format": "uri", - "examples": ["https://accounts.google.com/o/oauth2/v2/auth"] + "examples": [ + "https://accounts.google.com/o/oauth2/v2/auth" + ] }, "token_url": { "type": "string", "format": "uri", - "examples": ["https://www.googleapis.com/oauth2/v4/token"] + "examples": [ + "https://www.googleapis.com/oauth2/v4/token" + ] }, "mapper_url": { "title": "Jsonnet Mapper URL", @@ -461,7 +521,10 @@ "type": "array", "items": { "type": "string", - "examples": ["offline_access", "profile"] + "examples": [ + "offline_access", + "profile" + ] } }, "microsoft_tenant": { @@ -480,21 +543,30 @@ "title": "Microsoft subject source", "description": "Controls which source the subject identifier is taken from by microsoft provider. If set to `userinfo` (the default) then the identifier is taken from the `sub` field of OIDC ID token or data received from `/userinfo` standard OIDC endpoint. If set to `me` then the `id` field of data structure received from `https://graph.microsoft.com/v1.0/me` is taken as an identifier.", "type": "string", - "enum": ["userinfo", "me"], + "enum": [ + "userinfo", + "me" + ], "default": "userinfo", - "examples": ["userinfo"] + "examples": [ + "userinfo" + ] }, "apple_team_id": { "title": "Apple Developer Team ID", "description": "Apple Developer Team ID needed for generating a JWT token for client secret", "type": "string", - "examples": ["KP76DQS54M"] + "examples": [ + "KP76DQS54M" + ] }, "apple_private_key_id": { "title": "Apple Private Key Identifier", "description": "Sign In with Apple Private Key Identifier needed for generating a JWT token for client secret", "type": "string", - "examples": ["UX56C66723"] + "examples": [ + "UX56C66723" + ] }, "apple_private_key": { "title": "Apple Private Key", @@ -509,7 +581,12 @@ } }, "additionalProperties": false, - "required": ["id", "provider", "client_id", "mapper_url"], + "required": [ + "id", + "provider", + "client_id", + "mapper_url" + ], "allOf": [ { "if": { @@ -518,17 +595,23 @@ "const": "microsoft" } }, - "required": ["provider"] + "required": [ + "provider" + ] }, "then": { - "required": ["microsoft_tenant"] + "required": [ + "microsoft_tenant" + ] }, "else": { "not": { "properties": { "microsoft_tenant": {} }, - "required": ["microsoft_tenant"] + "required": [ + "microsoft_tenant" + ] } } }, @@ -539,7 +622,9 @@ "const": "apple" } }, - "required": ["provider"] + "required": [ + "provider" + ] }, "then": { "not": { @@ -549,7 +634,9 @@ "minLength": 1 } }, - "required": ["client_secret"] + "required": [ + "client_secret" + ] }, "required": [ "apple_private_key_id", @@ -558,7 +645,9 @@ ] }, "else": { - "required": ["client_secret"], + "required": [ + "client_secret" + ], "allOf": [ { "not": { @@ -568,7 +657,9 @@ "minLength": 1 } }, - "required": ["apple_team_id"] + "required": [ + "apple_team_id" + ] } }, { @@ -579,7 +670,9 @@ "minLength": 1 } }, - "required": ["apple_private_key_id"] + "required": [ + "apple_private_key_id" + ] } }, { @@ -590,7 +683,9 @@ "minLength": 1 } }, - "required": ["apple_private_key"] + "required": [ + "apple_private_key" + ] } } ] @@ -731,7 +826,10 @@ "title": "Required Authenticator Assurance Level", "description": "Sets what Authenticator Assurance Level (used for 2FA) is required to access this feature. If set to `highest_available` then this endpoint requires the highest AAL the identity has set up. If set to `aal1` then the identity can access this feature without 2FA.", "type": "string", - "enum": ["aal1", "highest_available"], + "enum": [ + "aal1", + "highest_available" + ], "default": "highest_available" }, "selfServiceAfterSettings": { @@ -885,7 +983,9 @@ "path": { "title": "Path to PEM-encoded Fle", "type": "string", - "examples": ["path/to/file.pem"] + "examples": [ + "path/to/file.pem" + ] }, "base64": { "title": "Base64 Encoded Inline", @@ -933,7 +1033,9 @@ "$ref": "#/definitions/emailCourierTemplate" } }, - "required": ["email"] + "required": [ + "email" + ] }, "valid": { "additionalProperties": false, @@ -943,7 +1045,9 @@ "$ref": "#/definitions/emailCourierTemplate" } }, - "required": ["email"] + "required": [ + "email" + ] } } }, @@ -993,7 +1097,9 @@ "selfservice": { "type": "object", "additionalProperties": false, - "required": ["default_browser_return_url"], + "required": [ + "default_browser_return_url" + ], "properties": { "default_browser_return_url": { "$ref": "#/definitions/defaultReturnTo" @@ -1027,20 +1133,30 @@ "description": "URL where the Settings UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": ["https://my-app.com/user/settings"], + "examples": [ + "https://my-app.com/user/settings" + ], "default": "https://www.ory.sh/kratos/docs/fallback/settings" }, "lifespan": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": ["1h", "1m", "1s"] + "examples": [ + "1h", + "1m", + "1s" + ] }, "privileged_session_max_age": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": ["1h", "1m", "1s"] + "examples": [ + "1h", + "1m", + "1s" + ] }, "required_aal": { "$ref": "#/definitions/featureRequiredAal" @@ -1083,14 +1199,20 @@ "description": "URL where the Registration UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": ["https://my-app.com/signup"], + "examples": [ + "https://my-app.com/signup" + ], "default": "https://www.ory.sh/kratos/docs/fallback/registration" }, "lifespan": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": ["1h", "1m", "1s"] + "examples": [ + "1h", + "1m", + "1s" + ] }, "before": { "$ref": "#/definitions/selfServiceBeforeRegistration" @@ -1109,14 +1231,20 @@ "description": "URL where the Login UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": ["https://my-app.com/login"], + "examples": [ + "https://my-app.com/login" + ], "default": "https://www.ory.sh/kratos/docs/fallback/login" }, "lifespan": { "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": ["1h", "1m", "1s"] + "examples": [ + "1h", + "1m", + "1s" + ] }, "before": { "$ref": "#/definitions/selfServiceBeforeLogin" @@ -1142,7 +1270,9 @@ "description": "URL where the Ory Verify UI is hosted. This is the page where users activate and / or verify their email or telephone number. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": ["https://my-app.com/verify"], + "examples": [ + "https://my-app.com/verify" + ], "default": "https://www.ory.sh/kratos/docs/fallback/verification" }, "after": { @@ -1154,7 +1284,11 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": ["1h", "1m", "1s"] + "examples": [ + "1h", + "1m", + "1s" + ] }, "before": { "$ref": "#/definitions/selfServiceBeforeVerification" @@ -1163,7 +1297,10 @@ "title": "Verification Strategy", "description": "The strategy to use for verification requests", "type": "string", - "enum": ["link", "code"], + "enum": [ + "link", + "code" + ], "default": "code" }, "notify_unknown_recipients": { @@ -1190,7 +1327,9 @@ "description": "URL where the Ory Recovery UI is hosted. This is the page where users request and complete account recovery. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": ["https://my-app.com/verify"], + "examples": [ + "https://my-app.com/verify" + ], "default": "https://www.ory.sh/kratos/docs/fallback/recovery" }, "after": { @@ -1202,7 +1341,11 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": ["1h", "1m", "1s"] + "examples": [ + "1h", + "1m", + "1s" + ] }, "before": { "$ref": "#/definitions/selfServiceBeforeRecovery" @@ -1211,7 +1354,10 @@ "title": "Recovery Strategy", "description": "The strategy to use for recovery requests", "type": "string", - "enum": ["link", "code"], + "enum": [ + "link", + "code" + ], "default": "code" }, "notify_unknown_recipients": { @@ -1231,7 +1377,9 @@ "description": "URL where the Ory Kratos Error UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node).", "type": "string", "format": "uri-reference", - "examples": ["https://my-app.com/kratos-error"], + "examples": [ + "https://my-app.com/kratos-error" + ], "default": "https://www.ory.sh/kratos/docs/fallback/error" } } @@ -1270,14 +1418,20 @@ "base_url": { "title": "Override the base URL which should be used as the base for recovery and verification links.", "type": "string", - "examples": ["https://my-app.com"] + "examples": [ + "https://my-app.com" + ] }, "lifespan": { "title": "How long a link is valid for", "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": ["1h", "1m", "1s"] + "examples": [ + "1h", + "1m", + "1s" + ] } } } @@ -1302,7 +1456,11 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "1h", - "examples": ["1h", "1m", "1s"] + "examples": [ + "1h", + "1m", + "1s" + ] } } } @@ -1420,33 +1578,44 @@ }, "rp": { "title": "Relying Party (RP) Config", - "required": ["id", "display_name"], + "required": [ + "id", + "display_name" + ], "properties": { "display_name": { "type": "string", "title": "Relying Party Display Name", "description": "An name to help the user identify this RP.", - "examples": ["Ory Foundation"] + "examples": [ + "Ory Foundation" + ] }, "id": { "type": "string", "title": "Relying Party Identifier", "description": "The id must be a subset of the domain currently in the browser.", - "examples": ["ory.sh"] + "examples": [ + "ory.sh" + ] }, "origin": { "type": "string", "title": "Relying Party Origin", "description": "An explicit RP origin. If left empty, this defaults to `id`.", "format": "uri", - "examples": ["https://www.ory.sh/login"] + "examples": [ + "https://www.ory.sh/login" + ] }, "icon": { "type": "string", "title": "Relying Party Icon", "description": "An icon to help the user identify this RP.", "format": "uri", - "examples": ["https://www.ory.sh/an-icon.png"] + "examples": [ + "https://www.ory.sh/an-icon.png" + ] } }, "type": "object" @@ -1461,10 +1630,14 @@ "const": true } }, - "required": ["enabled"] + "required": [ + "enabled" + ] }, "then": { - "required": ["config"] + "required": [ + "config" + ] } }, "oidc": { @@ -1487,7 +1660,9 @@ "title": "Base URL for OAuth2 Redirect URIs", "description": "Can be used to modify the base URL for OAuth2 Redirect URLs. If unset, the Public Base URL will be used.", "format": "uri", - "examples": ["https://auth.myexample.org/"] + "examples": [ + "https://auth.myexample.org/" + ] }, "providers": { "title": "OpenID Connect and OAuth2 Providers", @@ -1586,19 +1761,27 @@ "type": "string", "title": "Override message templates", "description": "You can override certain or all message templates by pointing this key to the path where the templates are located.", - "examples": ["/conf/courier-templates"] + "examples": [ + "/conf/courier-templates" + ] }, "message_retries": { "description": "Defines the maximum number of times the sending of a message is retried after it failed before it is marked as abandoned", "type": "integer", "default": 5, - "examples": [10, 60] + "examples": [ + 10, + 60 + ] }, "delivery_strategy": { "title": "Delivery Strategy", "description": "Defines how emails will be sent, either through SMTP (default) or HTTP.", "type": "string", - "enum": ["smtp", "http"], + "enum": [ + "smtp", + "http" + ], "default": "smtp" }, "http": { @@ -1655,7 +1838,9 @@ "title": "SMTP Sender Name", "description": "The recipient of an email will see this as the sender name.", "type": "string", - "examples": ["Bob"] + "examples": [ + "Bob" + ] }, "headers": { "title": "SMTP Headers", @@ -1679,7 +1864,9 @@ "default": "localhost" } }, - "required": ["connection_uri"], + "required": [ + "connection_uri" + ], "additionalProperties": false }, "sms": { @@ -1704,7 +1891,9 @@ "url": { "title": "HTTP address of API endpoint", "description": "This URL will be used to connect to the SMS provider.", - "examples": ["https://api.twillio.com/sms/send"], + "examples": [ + "https://api.twillio.com/sms/send" + ], "type": "string", "pattern": "^https?:\\/\\/.*" }, @@ -1746,7 +1935,10 @@ }, "additionalProperties": false }, - "required": ["url", "method"], + "required": [ + "url", + "method" + ], "additionalProperties": false } }, @@ -1814,7 +2006,9 @@ "description": "The URL where the admin endpoint is exposed at.", "type": "string", "format": "uri", - "examples": ["https://kratos.private-network:4434/"] + "examples": [ + "https://kratos.private-network:4434/" + ] }, "host": { "title": "Admin Host", @@ -1828,7 +2022,9 @@ "type": "integer", "minimum": 1, "maximum": 65535, - "examples": [4434], + "examples": [ + 4434 + ], "default": 4434 }, "socket": { @@ -1887,7 +2083,9 @@ ] }, "uniqueItems": true, - "default": ["*"], + "default": [ + "*" + ], "examples": [ [ "https://example.com", @@ -1899,7 +2097,13 @@ "allowed_methods": { "type": "array", "description": "A list of HTTP methods the user agent is allowed to use with cross-domain requests.", - "default": ["POST", "GET", "PUT", "PATCH", "DELETE"], + "default": [ + "POST", + "GET", + "PUT", + "PATCH", + "DELETE" + ], "items": { "type": "string", "enum": [ @@ -1930,7 +2134,9 @@ "exposed_headers": { "type": "array", "description": "Sets which headers are safe to expose to the API of a CORS API specification.", - "default": ["Content-Type"], + "default": [ + "Content-Type" + ], "items": { "type": "string" } @@ -1973,7 +2179,9 @@ "type": "integer", "minimum": 1, "maximum": 65535, - "examples": [4433], + "examples": [ + 4433 + ], "default": 4433 }, "socket": { @@ -2023,7 +2231,10 @@ "format": { "description": "The log format can either be text or JSON.", "type": "string", - "enum": ["json", "text"] + "enum": [ + "json", + "text" + ] } }, "additionalProperties": false @@ -2064,7 +2275,9 @@ "id": { "title": "The schema's ID.", "type": "string", - "examples": ["employee"] + "examples": [ + "employee" + ] }, "url": { "type": "string", @@ -2078,11 +2291,16 @@ ] } }, - "required": ["id", "url"] + "required": [ + "id", + "url" + ] } } }, - "required": ["schemas"], + "required": [ + "schemas" + ], "additionalProperties": false }, "secrets": { @@ -2131,7 +2349,10 @@ "description": "One of the values: argon2, bcrypt.\nAny other hashes will be migrated to the set algorithm once an identity authenticates using their password.", "type": "string", "default": "bcrypt", - "enum": ["argon2", "bcrypt"] + "enum": [ + "argon2", + "bcrypt" + ] }, "argon2": { "title": "Configuration for the Argon2id hasher.", @@ -2187,7 +2408,9 @@ "title": "Configuration for the Bcrypt hasher. Minimum is 4 when --dev flag is used and 12 otherwise.", "type": "object", "additionalProperties": false, - "required": ["cost"], + "required": [ + "cost" + ], "properties": { "cost": { "type": "integer", @@ -2209,7 +2432,11 @@ "description": "One of the values: noop, aes, xchacha20-poly1305", "type": "string", "default": "noop", - "enum": ["noop", "aes", "xchacha20-poly1305"] + "enum": [ + "noop", + "aes", + "xchacha20-poly1305" + ] } } }, @@ -2233,7 +2460,11 @@ "title": "HTTP Cookie Same Site Configuration", "description": "Sets the session and CSRF cookie SameSite.", "type": "string", - "enum": ["Strict", "Lax", "None"], + "enum": [ + "Strict", + "Lax", + "None" + ], "default": "Lax" } }, @@ -2260,7 +2491,11 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "24h", - "examples": ["1h", "1m", "1s"] + "examples": [ + "1h", + "1m", + "1s" + ] }, "cookie": { "type": "object", @@ -2291,7 +2526,11 @@ "title": "Session Cookie SameSite Configuration", "description": "Sets the session cookie SameSite. Overrides `cookies.same_site`.", "type": "string", - "enum": ["Strict", "Lax", "None"] + "enum": [ + "Strict", + "Lax", + "None" + ] } }, "additionalProperties": false @@ -2302,7 +2541,11 @@ "type": "string", "pattern": "^([0-9]+(ns|us|ms|s|m|h))+$", "default": "24h", - "examples": ["1h", "1m", "1s"] + "examples": [ + "1h", + "1m", + "1s" + ] } } }, @@ -2311,7 +2554,9 @@ "description": "SemVer according to https://semver.org/ prefixed with `v` as in our releases.", "type": "string", "pattern": "^(v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?)|$", - "examples": ["v0.5.0-alpha.1"] + "examples": [ + "v0.5.0-alpha.1" + ] }, "dev": { "type": "boolean" @@ -2335,7 +2580,9 @@ "type": "integer", "minimum": 0, "maximum": 65535, - "examples": [4434], + "examples": [ + 4434 + ], "default": 0 }, "config": { @@ -2383,12 +2630,6 @@ "title": "Enable Ory Sessions caching", "description": "If enabled allows Ory Sessions to be cached. Only effective in the Ory Network.", "default": false - }, - "new_flow_transitions": { - "type": "boolean", - "title": "Enable new flow transitions using `continue_with` items", - "description": "If enabled allows new flow transitions using `continue_with` items.", - "default": false } }, "additionalProperties": false @@ -2410,10 +2651,14 @@ "const": true } }, - "required": ["enabled"] + "required": [ + "enabled" + ] } }, - "required": ["verification"] + "required": [ + "verification" + ] }, { "properties": { @@ -2423,21 +2668,31 @@ "const": true } }, - "required": ["enabled"] + "required": [ + "enabled" + ] } }, - "required": ["recovery"] + "required": [ + "recovery" + ] } ] } }, - "required": ["flows"] + "required": [ + "flows" + ] } }, - "required": ["selfservice"] + "required": [ + "selfservice" + ] }, "then": { - "required": ["courier"] + "required": [ + "courier" + ] } }, { @@ -2456,21 +2711,33 @@ ] } }, - "required": ["algorithm"] + "required": [ + "algorithm" + ] } }, - "required": ["ciphers"] + "required": [ + "ciphers" + ] }, "then": { - "required": ["secrets"], + "required": [ + "secrets" + ], "properties": { "secrets": { - "required": ["cipher"] + "required": [ + "cipher" + ] } } } } ], - "required": ["identity", "dsn", "selfservice"], + "required": [ + "identity", + "dsn", + "selfservice" + ], "additionalProperties": false } diff --git a/embedx/config.schema.json b/embedx/config.schema.json index 15b6c8c38b4a..4607fa28b0b5 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -1982,10 +1982,7 @@ "type": "string", "title": "Default Read Consistency Level", "description": "The default consistency level to use when reading from the database. Defaults to `strong` to not break existing API contracts. Only set this to `eventual` if you can accept that other read APIs will suddenly return eventually consistent results. It is only effective in Ory Network.", - "enum": [ - "strong", - "eventual" - ], + "enum": ["strong", "eventual"], "default": "strong" } } @@ -2617,6 +2614,12 @@ "title": "Enable Ory Sessions caching", "description": "If enabled allows Ory Sessions to be cached. Only effective in the Ory Network.", "default": false + }, + "new_flow_transitions": { + "type": "boolean", + "title": "Enable new flow transitions using `continue_with` items", + "description": "If enabled allows new flow transitions using `continue_with` items.", + "default": false } }, "additionalProperties": false 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 4541d92eca49..477a149f9b26 100644 --- a/test/e2e/cypress/integration/profiles/code/login/error.spec.ts +++ b/test/e2e/cypress/integration/profiles/code/login/error.spec.ts @@ -1,7 +1,7 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { appPrefix, APP_URL, gen, MOBILE_URL } from "../../../../helpers" +import { MOBILE_URL, gen } from "../../../../helpers" import { routes as express } from "../../../../helpers/express" import { routes as react } from "../../../../helpers/react" @@ -40,14 +40,13 @@ context("Login error messages with code method", () => { } before(() => { - cy.useConfigProfile(profile) - cy.deleteMail() if (app !== "mobile") { cy.proxy(app) } }) beforeEach(() => { + cy.useConfigProfile(profile) cy.deleteMail() cy.clearAllCookies() @@ -207,16 +206,6 @@ context("Login error messages with code method", () => { } cy.noSession() - - cy.updateConfigFile((config) => { - config.selfservice.methods.code = { - passwordless_enabled: true, - config: { - lifespan: "1h", - }, - } - return config - }) }) }) }) diff --git a/test/e2e/cypress/support/config.d.ts b/test/e2e/cypress/support/config.d.ts index 790ef1e41ba9..5f73f6626e8e 100644 --- a/test/e2e/cypress/support/config.d.ts +++ b/test/e2e/cypress/support/config.d.ts @@ -40,11 +40,15 @@ export type WebHookConfiguration = can_interrupt?: false [k: string]: unknown | undefined } -export type SelfServiceHooks = SelfServiceWebHook[] +export type SelfServiceHooks = (SelfServiceWebHook | B2BSSOHook)[] /** * If set to true will enable [User Registration](https://www.ory.sh/kratos/docs/self-service/flows/user-registration/). */ export type EnableUserRegistration = boolean +/** + * When registration fails because an account with the given credentials or addresses previously signed up, provide login hints about available methods to sign in to the user. + */ +export type ProvideLoginHintsOnFailedRegistration = boolean /** * URL where the Registration UI is hosted. Check the [reference implementation](https://github.com/ory/kratos-selfservice-ui-node). */ @@ -106,8 +110,11 @@ export type EnablesLinkMethod = boolean export type OverrideTheBaseURLWhichShouldBeUsedAsTheBaseForRecoveryAndVerificationLinks = string export type HowLongALinkIsValidFor = string -export type EnablesLoginWithCodeMethod = boolean -export type EnablesRegistrationWithCodeMethod = boolean +export type EnablesLoginAndRegistrationWithTheCodeMethod = 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 @@ -146,22 +153,30 @@ export type EnablesTheWebAuthnMethod = boolean * If enabled will have the effect that WebAuthn is used for passwordless flows (as a first factor) and not for multi-factor set ups. With this set to true, users will see an option to sign up with WebAuthn on the registration screen. */ export type UseForPasswordlessFlows = boolean -/** - * An 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 -/** - * An explicit RP origin. If left empty, this defaults to `id`. - */ -export type RelyingPartyOrigin = string -/** - * An icon to help the user identify this RP. - */ -export type RelyingPartyIcon = string +export type RelyingPartyRPConfig = + | { + origin?: { + [k: string]: unknown | undefined + } + origins?: { + [k: string]: unknown | undefined + } + [k: string]: unknown | undefined + } + | { + origin: string + origins?: { + [k: string]: unknown | undefined + } + [k: string]: unknown | undefined + } + | { + origin?: { + [k: string]: unknown | undefined + } + origins: string[] + [k: string]: unknown | undefined + } 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. @@ -184,6 +199,7 @@ export type SelfServiceOIDCProvider = SelfServiceOIDCProvider1 & { apple_private_key_id?: ApplePrivateKeyIdentifier apple_private_key?: ApplePrivateKey requested_claims?: OpenIDConnectClaims + organization_id?: OrganizationID } export type SelfServiceOIDCProvider1 = { [k: string]: unknown | undefined @@ -239,6 +255,10 @@ export type ApplePrivateKeyIdentifier = string * Sign In with Apple Private Key needed for generating a JWT token for client secret */ export type ApplePrivateKey = string +/** + * The ID of the organization that this provider belongs to. Only effective in the Ory Network. + */ +export type OrganizationID = string /** * A list and configuration of OAuth2 and OpenID Connect providers Ory Kratos should integrate with. */ @@ -323,6 +343,10 @@ export type OAuth20ProviderURL = string * Override the return_to query parameter with the OAuth2 provider request URL when perfoming an OAuth2 login flow. */ export type PersistOAuth2RequestBetweenFlows = boolean +/** + * The default consistency level to use when reading from the database. Defaults to `strong` to not break existing API contracts. Only set this to `eventual` if you can accept that other read APIs will suddenly return eventually consistent results. It is only effective in Ory Network. + */ +export type DefaultReadConsistencyLevel = "strong" | "eventual" /** * Disable request logging for /health/alive and /health/ready endpoints */ @@ -431,6 +455,9 @@ export type HTTPCookiePath = string * Sets the session and CSRF cookie SameSite. */ export type HTTPCookieSameSiteConfiguration = "Strict" | "Lax" | "None" +export type TokenTimeToLive = string +export type JsonNetMapperURL = string +export type JSONWebKeySetURL = string /** * Defines how long a session is active. Once that lifespan has been reached, the user needs to sign in again. */ @@ -479,6 +506,14 @@ export type AddExemptURLsToPrivateIPRanges = string[] * If enabled allows Ory Sessions to be cached. Only effective in the Ory Network. */ export type EnableOrySessionsCaching = boolean +/** + * 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. + */ +export type Organizations = unknown[] export interface OryKratosConfiguration2 { selfservice: { @@ -500,11 +535,11 @@ export interface OryKratosConfiguration2 { } registration?: { enabled?: EnableUserRegistration + login_hints?: ProvideLoginHintsOnFailedRegistration ui_url?: RegistrationUIURL lifespan?: string before?: SelfServiceBeforeRegistration after?: SelfServiceAfterRegistration - login_hints?: boolean } login?: { ui_url?: LoginUIURL @@ -527,7 +562,8 @@ export interface OryKratosConfiguration2 { config?: LinkConfiguration } code?: { - passwordless_enabled?: boolean + passwordless_enabled?: EnablesLoginAndRegistrationWithTheCodeMethod + passwordless_login_fallback_enabled?: PasswordlessLoginFallbackEnabled enabled?: EnablesCodeMethod config?: CodeConfiguration } @@ -553,6 +589,7 @@ export interface OryKratosConfiguration2 { dsn: DataSourceName courier?: CourierConfiguration oauth2_provider?: OAuth2ProviderConfiguration + preview?: ConfigurePreviewFeatures serve?: { admin?: { request_log?: { @@ -670,14 +707,19 @@ export interface OryKratosConfiguration2 { config?: string[] clients?: GlobalOutgoingNetworkSettings feature_flags?: FeatureFlags + organizations?: Organizations } export interface SelfServiceAfterSettings { default_browser_return_url?: RedirectBrowsersToSetURLPerDefault - password?: SelfServiceAfterSettingsMethod + password?: SelfServiceAfterSettingsAuthMethod + totp?: SelfServiceAfterSettingsAuthMethod + oidc?: SelfServiceAfterSettingsAuthMethod + webauthn?: SelfServiceAfterSettingsAuthMethod + lookup_secret?: SelfServiceAfterSettingsAuthMethod profile?: SelfServiceAfterSettingsMethod hooks?: SelfServiceHooks } -export interface SelfServiceAfterSettingsMethod { +export interface SelfServiceAfterSettingsAuthMethod { default_browser_return_url?: RedirectBrowsersToSetURLPerDefault hooks?: (SelfServiceWebHook | SelfServiceSessionRevokerHook)[] } @@ -685,6 +727,19 @@ export interface SelfServiceWebHook { hook: "web_hook" config: WebHookConfiguration } +export interface SelfServiceSessionRevokerHook { + hook: "revoke_active_sessions" +} +export interface SelfServiceAfterSettingsMethod { + default_browser_return_url?: RedirectBrowsersToSetURLPerDefault + hooks?: SelfServiceWebHook[] +} +export interface B2BSSOHook { + hook: "b2b_sso" + config: { + [k: string]: unknown | undefined + } +} export interface SelfServiceBeforeSettings { hooks?: SelfServiceHooks } @@ -705,6 +760,7 @@ export interface SelfServiceAfterRegistrationMethod { | SelfServiceSessionIssuerHook | SelfServiceWebHook | SelfServiceShowVerificationUIHook + | B2BSSOHook )[] } export interface SelfServiceSessionIssuerHook { @@ -722,10 +778,13 @@ export interface SelfServiceAfterLogin { webauthn?: SelfServiceAfterDefaultLoginMethod oidc?: SelfServiceAfterOIDCLoginMethod code?: SelfServiceAfterDefaultLoginMethod + totp?: SelfServiceAfterDefaultLoginMethod + lookup_secret?: SelfServiceAfterDefaultLoginMethod hooks?: ( | SelfServiceWebHook | SelfServiceSessionRevokerHook | SelfServiceRequireVerifiedAddressHook + | B2BSSOHook )[] } export interface SelfServiceAfterDefaultLoginMethod { @@ -736,9 +795,6 @@ export interface SelfServiceAfterDefaultLoginMethod { | SelfServiceWebHook )[] } -export interface SelfServiceSessionRevokerHook { - hook: "revoke_active_sessions" -} export interface SelfServiceRequireVerifiedAddressHook { hook: "require_verified_address" } @@ -748,6 +804,7 @@ export interface SelfServiceAfterOIDCLoginMethod { | SelfServiceSessionRevokerHook | SelfServiceWebHook | SelfServiceRequireVerifiedAddressHook + | B2BSSOHook )[] } export interface EmailAndPhoneVerificationAndAccountActivationConfiguration { @@ -815,13 +872,6 @@ export interface WebAuthnConfiguration { passwordless?: UseForPasswordlessFlows rp?: RelyingPartyRPConfig } -export interface RelyingPartyRPConfig { - display_name: RelyingPartyDisplayName - id: RelyingPartyIdentifier - origin?: RelyingPartyOrigin - icon?: RelyingPartyIcon - [k: string]: unknown | undefined -} export interface SpecifyOpenIDConnectAndOAuth2Configuration { enabled?: EnablesOpenIDConnectMethod config?: { @@ -893,6 +943,16 @@ export interface CourierConfiguration { recovery_code?: CourierTemplates verification?: CourierTemplates verification_code?: CourierTemplates + registration_code?: { + valid?: { + email: EmailCourierTemplate + } + } + login_code?: { + valid?: { + email: EmailCourierTemplate + } + } } template_override_path?: OverrideMessageTemplates /** @@ -1038,6 +1098,10 @@ export interface OAuth2ProviderConfiguration { export interface HTTPRequestHeaders { [k: string]: string | undefined } +export interface ConfigurePreviewFeatures { + default_read_consistency_level?: DefaultReadConsistencyLevel + [k: string]: unknown | undefined +} /** * Sets the permissions of the unix socket */ @@ -1078,6 +1142,10 @@ export interface OryTracingConfig { * Specifies the service name to use on the tracer. */ service_name?: string + /** + * Specifies the deployment environment to use on the tracer. + */ + deployment_environment?: string providers?: { /** * Configures the jaeger tracing backend. @@ -1141,6 +1209,7 @@ export interface OryTracingConfig { */ sampling_ratio?: number } + authorization_header?: string } } } @@ -1224,6 +1293,29 @@ export interface HTTPCookieConfiguration { */ export interface WhoAmIToSessionSettings { required_aal?: RequiredAuthenticatorAssuranceLevel + tokenizer?: TokenizerConfiguration +} +/** + * Configure the tokenizer, responsible for converting a session into a token format such as JWT. + */ +export interface TokenizerConfiguration { + templates?: TokenizerTemplates + [k: string]: unknown | undefined +} +/** + * A list of different templates that govern how a session is converted to a token format. + */ +export interface TokenizerTemplates { + /** + * This interface was referenced by `TokenizerTemplates`'s JSON-Schema definition + * via the `patternProperty` "[a-zA-Z0-9-_.]+". + */ + [k: string]: { + ttl?: TokenTimeToLive + claims_mapper_url?: JsonNetMapperURL + jwks_url: JSONWebKeySetURL + [k: string]: unknown | undefined + } } /** * Configure how outgoing network calls behave. @@ -1242,4 +1334,5 @@ export interface GlobalHTTPClientConfiguration { } export interface FeatureFlags { cacheable_sessions?: EnableOrySessionsCaching + new_flow_transitions?: EnableNewFlowTransitionsUsingContinueWithItems } diff --git a/test/e2e/playwright/tests/app_recovery.spec.ts b/test/e2e/playwright/tests/app_recovery.spec.ts index d2b5908fc522..1629b6c3923d 100644 --- a/test/e2e/playwright/tests/app_recovery.spec.ts +++ b/test/e2e/playwright/tests/app_recovery.spec.ts @@ -22,6 +22,9 @@ test.describe("Recovery", () => { identity: { ...schemaConfig, }, + feature_flags: { + new_flow_transitions: true, + }, }, }) @@ -111,6 +114,9 @@ test.describe("Recovery", () => { }, }, }, + feature_flags: { + new_flow_transitions: true, + }, }, }) @@ -128,7 +134,7 @@ test.describe("Recovery", () => { await page.getByTestId("code").fill(code) await page.getByText("Submit", { exact: true }).click() - await expect(page.getByTestId("ui/message/4060005")).toBeVisible() + await expect(page.getByTestId("email")).toBeVisible() }) }) }) diff --git a/test/e2e/render-kratos-config.sh b/test/e2e/render-kratos-config.sh index 45506efd0380..5867904216e2 100755 --- a/test/e2e/render-kratos-config.sh +++ b/test/e2e/render-kratos-config.sh @@ -8,8 +8,10 @@ dir=$(realpath $(dirname "${BASH_SOURCE[0]}")) 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 > .tracing-config.schema.json +curl -s https://raw.githubusercontent.com/ory/x/$ory_x_version/otelx/config.schema.json > $dir/.tracing-config.schema.json -sed "s!ory://tracing-config!.tracing-config.schema.json!g;" ../../embedx/config.schema.json | npx json2ts --strictIndexSignatures > 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/cypress/support/config.d.ts) -rm .tracing-config.schema.json +rm $dir/.tracing-config.schema.json + +make format From 7c5deba0fc1ea636fa2bd9f03074b2f0c6371e5d Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Thu, 19 Oct 2023 13:25:46 +0200 Subject: [PATCH 19/25] chore: u --- selfservice/strategy/code/strategy_recovery_admin.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/selfservice/strategy/code/strategy_recovery_admin.go b/selfservice/strategy/code/strategy_recovery_admin.go index 65d3ace2f1d4..8964682afe30 100644 --- a/selfservice/strategy/code/strategy_recovery_admin.go +++ b/selfservice/strategy/code/strategy_recovery_admin.go @@ -99,7 +99,7 @@ type recoveryCodeForIdentity struct { // Expires At is the timestamp of when the recovery flow expires // - // The timestamp when the recovery link expires. + // The timestamp when the recovery code expires. ExpiresAt time.Time `json:"expires_at"` } @@ -119,7 +119,7 @@ type recoveryCodeForIdentity struct { // Schemes: http, https // // Security: -// oryAccessToken: +// oryAccessToken: // // Responses: // 201: recoveryCodeForIdentity From 983f8133865cdbaad0f98a2b9764403b7ca2204b Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Sun, 12 Nov 2023 13:44:50 +0100 Subject: [PATCH 20/25] chore: u --- driver/config/config.go | 6 +- embedx/config.schema.json | 2 +- ...e_correct_recovery_payloads-type=api.json} | 0 ...rrect_recovery_payloads-type=browser.json} | 0 ...e_correct_recovery_payloads-type=spa.json} | 0 ...y_payloads_after_submission-type=api.json} | 0 ...yloads_after_submission-type=browser.json} | 0 ...y_payloads_after_submission-type=spa.json} | 0 ...he_correct_recovery_payloads-type=api.json | 53 -- ...orrect_recovery_payloads-type=browser.json | 53 -- ...he_correct_recovery_payloads-type=spa.json | 53 -- ...ry_payloads_after_submission-type=api.json | 85 -- ...ayloads_after_submission-type=browser.json | 85 -- ...ry_payloads_after_submission-type=spa.json | 85 -- .../strategy/code/strategy_recovery.go | 9 +- .../strategy/code/strategy_recovery_test.go | 850 +++++++++++++++++- test/e2e/cypress/support/config.d.ts | 2 +- .../e2e/playwright/tests/app_recovery.spec.ts | 4 +- 18 files changed, 850 insertions(+), 437 deletions(-) rename selfservice/strategy/code/.snapshots/{TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=api.json => TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=api.json} (100%) rename selfservice/strategy/code/.snapshots/{TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=browser.json => TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=browser.json} (100%) rename selfservice/strategy/code/.snapshots/{TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=spa.json => TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=spa.json} (100%) rename selfservice/strategy/code/.snapshots/{TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json => TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json} (100%) rename selfservice/strategy/code/.snapshots/{TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json => TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json} (100%) rename selfservice/strategy/code/.snapshots/{TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json => TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json} (100%) delete mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=api.json delete mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=browser.json delete mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=spa.json delete mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json delete mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json delete mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json diff --git a/driver/config/config.go b/driver/config/config.go index c1a8496be879..2bc22a75bd22 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -111,7 +111,7 @@ const ( ViperKeySessionTokenizerTemplates = "session.whoami.tokenizer.templates" ViperKeySessionWhoAmIAAL = "session.whoami.required_aal" ViperKeySessionWhoAmICaching = "feature_flags.cacheable_sessions" - ViperKeyNewFlowTransitions = "feature_flags.new_flow_transitions" + ViperKeyUseContinueWithTransitions = "feature_flags.use_continue_with_transitions" ViperKeySessionRefreshMinTimeLeft = "session.earliest_possible_extend" ViperKeyCookieSameSite = "cookies.same_site" ViperKeyCookieDomain = "cookies.domain" @@ -1298,8 +1298,8 @@ func (p *Config) SessionWhoAmICaching(ctx context.Context) bool { return p.GetProvider(ctx).Bool(ViperKeySessionWhoAmICaching) } -func (p *Config) NewFlowTransitions(ctx context.Context) bool { - return p.GetProvider(ctx).Bool(ViperKeyNewFlowTransitions) +func (p *Config) UseContinueWithTransitions(ctx context.Context) bool { + return p.GetProvider(ctx).Bool(ViperKeyUseContinueWithTransitions) } func (p *Config) SessionRefreshMinTimeLeft(ctx context.Context) time.Duration { diff --git a/embedx/config.schema.json b/embedx/config.schema.json index 30d3c469fbc5..79c1ccf3ce96 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -2618,7 +2618,7 @@ "description": "If enabled allows Ory Sessions to be cached. Only effective in the Ory Network.", "default": false }, - "new_flow_transitions": { + "use_continue_with_transitions": { "type": "boolean", "title": "Enable new flow transitions using `continue_with` items", "description": "If enabled allows new flow transitions using `continue_with` items.", diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-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 similarity index 100% rename from selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=api.json rename to selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=api.json diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-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 similarity index 100% rename from selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=browser.json rename to selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=browser.json diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-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 similarity index 100% rename from selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=spa.json rename to selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=spa.json diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-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 similarity index 100% rename from selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json rename to selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-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 similarity index 100% rename from selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json rename to selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-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 similarity index 100% rename from selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json rename to selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=api.json deleted file mode 100644 index ec1092ad77a6..000000000000 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=api.json +++ /dev/null @@ -1,53 +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": "email", - "node_type": "input", - "required": true, - "type": "email" - }, - "group": "code", - "messages": [], - "meta": { - "label": { - "id": 1070007, - "text": "Email", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "method", - "node_type": "input", - "type": "submit", - "value": "code" - }, - "group": "code", - "messages": [], - "meta": { - "label": { - "id": 1070005, - "text": "Submit", - "type": "info" - } - }, - "type": "input" - } -] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=browser.json deleted file mode 100644 index ec1092ad77a6..000000000000 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=browser.json +++ /dev/null @@ -1,53 +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": "email", - "node_type": "input", - "required": true, - "type": "email" - }, - "group": "code", - "messages": [], - "meta": { - "label": { - "id": 1070007, - "text": "Email", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "method", - "node_type": "input", - "type": "submit", - "value": "code" - }, - "group": "code", - "messages": [], - "meta": { - "label": { - "id": 1070005, - "text": "Submit", - "type": "info" - } - }, - "type": "input" - } -] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=spa.json deleted file mode 100644 index ec1092ad77a6..000000000000 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=spa.json +++ /dev/null @@ -1,53 +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": "email", - "node_type": "input", - "required": true, - "type": "email" - }, - "group": "code", - "messages": [], - "meta": { - "label": { - "id": 1070007, - "text": "Email", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "method", - "node_type": "input", - "type": "submit", - "value": "code" - }, - "group": "code", - "messages": [], - "meta": { - "label": { - "id": 1070005, - "text": "Submit", - "type": "info" - } - }, - "type": "input" - } -] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json deleted file mode 100644 index 36fe7b033ce7..000000000000 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json +++ /dev/null @@ -1,85 +0,0 @@ -[ - { - "type": "input", - "group": "default", - "attributes": { - "name": "csrf_token", - "type": "hidden", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "code", - "type": "text", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070010, - "text": "Recovery code", - "type": "info" - } - } - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "hidden", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "submit", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070005, - "text": "Submit", - "type": "info" - } - } - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "email", - "type": "submit", - "value": "test@ory.sh", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070008, - "text": "Resend code", - "type": "info" - } - } - } -] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json deleted file mode 100644 index 36fe7b033ce7..000000000000 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json +++ /dev/null @@ -1,85 +0,0 @@ -[ - { - "type": "input", - "group": "default", - "attributes": { - "name": "csrf_token", - "type": "hidden", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "code", - "type": "text", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070010, - "text": "Recovery code", - "type": "info" - } - } - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "hidden", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "submit", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070005, - "text": "Submit", - "type": "info" - } - } - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "email", - "type": "submit", - "value": "test@ory.sh", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070008, - "text": "Resend code", - "type": "info" - } - } - } -] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json deleted file mode 100644 index 36fe7b033ce7..000000000000 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json +++ /dev/null @@ -1,85 +0,0 @@ -[ - { - "type": "input", - "group": "default", - "attributes": { - "name": "csrf_token", - "type": "hidden", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "code", - "type": "text", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070010, - "text": "Recovery code", - "type": "info" - } - } - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "hidden", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "submit", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070005, - "text": "Submit", - "type": "info" - } - } - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "email", - "type": "submit", - "value": "test@ory.sh", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070008, - "text": "Resend code", - "type": "info" - } - } - } -] diff --git a/selfservice/strategy/code/strategy_recovery.go b/selfservice/strategy/code/strategy_recovery.go index c82cd81b1b26..3d373329f028 100644 --- a/selfservice/strategy/code/strategy_recovery.go +++ b/selfservice/strategy/code/strategy_recovery.go @@ -218,13 +218,14 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } - if s.deps.Config().NewFlowTransitions(ctx) { + if s.deps.Config().UseContinueWithTransitions(ctx) { switch { case f.Type.IsAPI(): + fallthrough + case x.IsJSONRequest(r): f.ContinueWith = append(f.ContinueWith, flow.NewContinueWithSettingsUI(sf)) s.deps.Writer().Write(w, r, f) - case x.IsJSONRequest(r): - s.deps.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String())) + // s.deps.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String())) default: http.Redirect(w, r, sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String(), http.StatusSeeOther) } @@ -325,7 +326,7 @@ func (s *Strategy) retryRecoveryFlow(w http.ResponseWriter, r *http.Request, ft return err } - if s.deps.Config().NewFlowTransitions(ctx) { + if s.deps.Config().UseContinueWithTransitions(ctx) { switch { case x.IsJSONRequest(r): rErr := new(herodot.DefaultError) diff --git a/selfservice/strategy/code/strategy_recovery_test.go b/selfservice/strategy/code/strategy_recovery_test.go index 7cb152c86492..91afe5c3e149 100644 --- a/selfservice/strategy/code/strategy_recovery_test.go +++ b/selfservice/strategy/code/strategy_recovery_test.go @@ -144,7 +144,818 @@ func TestRecovery(t *testing.T) { conf, reg := internal.NewFastRegistryWithMocks(t) testhelpers.StrategyEnable(t, conf, string(recovery.RecoveryStrategyCode), true) testhelpers.StrategyEnable(t, conf, string(recovery.RecoveryStrategyLink), false) - conf.MustSet(ctx, config.ViperKeyNewFlowTransitions, true) + + initViper(t, ctx, conf) + + _ = testhelpers.NewRecoveryUIFlowEchoServer(t, reg) + _ = testhelpers.NewLoginUIFlowEchoServer(t, reg) + _ = testhelpers.NewSettingsUIFlowEchoServer(t, reg) + _ = testhelpers.NewErrorTestServer(t, reg) + + public, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg) + + submitRecovery := func(t *testing.T, client *http.Client, flowType ClientType, values func(url.Values), code int) string { + isSPA := flowType == RecoveryClientTypeSPA + isAPI := flowType == RecoveryClientTypeAPI + if client == nil { + client = testhelpers.NewDebugClient(t) + if !isAPI { + client = testhelpers.NewClientWithCookies(t) + client.Transport = testhelpers.NewTransportWithLogger(http.DefaultTransport, t).RoundTripper + } + } + + expectedUrl := testhelpers.ExpectURL(isAPI || isSPA, public.URL+recovery.RouteSubmitFlow, conf.SelfServiceFlowRecoveryUI(ctx).String()) + return testhelpers.SubmitRecoveryForm(t, isAPI, isSPA, client, public, values, code, expectedUrl) + } + + submitRecoveryCode := func(t *testing.T, client *http.Client, flow string, flowType ClientType, recoveryCode string, statusCode int) string { + action := gjson.Get(flow, "ui.action").String() + assert.NotEmpty(t, action) + + values := withCSRFToken(t, flowType, flow, url.Values{ + "code": {recoveryCode}, + "method": {"code"}, + }) + + contentType := "application/json" + if flowType == RecoveryClientTypeBrowser { + contentType = "application/x-www-form-urlencoded" + } + + res, err := client.Post(action, contentType, bytes.NewBufferString(values)) + require.NoError(t, err) + assert.Equal(t, statusCode, res.StatusCode) + + return string(ioutilx.MustReadAll(res.Body)) + } + + resendRecoveryCode := func(t *testing.T, client *http.Client, flow string, flowType ClientType, statusCode int) string { + action := gjson.Get(flow, "ui.action").String() + assert.NotEmpty(t, action) + + email := gjson.Get(flow, "ui.nodes.#(attributes.name==email).attributes.value").String() + + values := withCSRFToken(t, flowType, flow, url.Values{ + "method": {"code"}, + "email": {email}, + }) + + contentType := "application/json" + if flowType == RecoveryClientTypeBrowser { + contentType = "application/x-www-form-urlencoded" + } + + res, err := client.Post(action, contentType, bytes.NewBufferString(values)) + require.NoError(t, err) + assert.Equal(t, statusCode, res.StatusCode) + + return string(ioutilx.MustReadAll(res.Body)) + } + + expectValidationError := func(t *testing.T, hc *http.Client, flowType ClientType, values func(url.Values)) string { + code := testhelpers.ExpectStatusCode(flowType == RecoveryClientTypeAPI || flowType == RecoveryClientTypeSPA, http.StatusBadRequest, http.StatusOK) + return submitRecovery(t, hc, flowType, values, code) + } + + expectSuccessfulRecovery := func(t *testing.T, hc *http.Client, flowType ClientType, values func(url.Values)) string { + code := testhelpers.ExpectStatusCode(flowType == RecoveryClientTypeAPI || flowType == RecoveryClientTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) + return submitRecovery(t, hc, flowType, values, code) + } + + ExpectVerfiableAddressStatus := func(t *testing.T, email string, status identity.VerifiableAddressStatus) { + addr, err := reg.IdentityPool(). + FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, email) + assert.NoError(t, err) + assert.Equal(t, status, addr.Status, "verifiable address %s was not %s. instead %", email, status, addr.Status) + } + + t.Run("description=should recover an account", func(t *testing.T) { + checkRecovery := func(t *testing.T, client *http.Client, flowType ClientType, recoveryEmail, recoverySubmissionResponse string) string { + ExpectVerfiableAddressStatus(t, recoveryEmail, identity.VerifiableAddressStatusPending) + + assert.EqualValues(t, node.CodeGroup, gjson.Get(recoverySubmissionResponse, "active").String(), "%s", recoverySubmissionResponse) + assert.True(t, gjson.Get(recoverySubmissionResponse, "ui.nodes.#(attributes.name==code)").Exists(), "%s", recoverySubmissionResponse) + assert.Len(t, gjson.Get(recoverySubmissionResponse, "ui.messages").Array(), 1, "%s", recoverySubmissionResponse) + assertx.EqualAsJSON(t, text.NewRecoveryEmailWithCodeSent(), json.RawMessage(gjson.Get(recoverySubmissionResponse, "ui.messages.0").Raw)) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + assert.Contains(t, message.Body, "please recover access to your account by entering the following code") + + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + assert.NotEmpty(t, recoveryCode) + + statusCode := testhelpers.ExpectStatusCode(flowType == RecoveryClientTypeAPI || flowType == RecoveryClientTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) + return submitRecoveryCode(t, client, recoverySubmissionResponse, flowType, recoveryCode, statusCode) + } + + t.Run("type=browser", func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + email := "recoverme1@ory.sh" + createIdentityToRecover(t, reg, email) + recoverySubmissionResponse := submitRecovery(t, client, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) + body := checkRecovery(t, client, RecoveryClientTypeBrowser, email, recoverySubmissionResponse) + + assert.Equal(t, text.NewRecoverySuccessful(time.Now().Add(time.Hour)).Text, + gjson.Get(body, "ui.messages.0.text").String()) + + res, err := client.Get(public.URL + session.RouteWhoami) + require.NoError(t, err) + body = string(x.MustReadAll(res.Body)) + require.NoError(t, res.Body.Close()) + assert.Equal(t, "code_recovery", gjson.Get(body, "authentication_methods.0.method").String(), "%s", body) + assert.Equal(t, "aal1", gjson.Get(body, "authenticator_assurance_level").String(), "%s", body) + }) + + t.Run("type=spa", func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + email := "recoverme3@ory.sh" + createIdentityToRecover(t, reg, email) + recoverySubmissionResponse := submitRecovery(t, client, RecoveryClientTypeSPA, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) + body := checkRecovery(t, client, RecoveryClientTypeSPA, email, recoverySubmissionResponse) + assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) + assert.Contains(t, gjson.Get(body, "redirect_browser_to").String(), "settings-ts?") + }) + + t.Run("type=api", func(t *testing.T) { + client := &http.Client{} + email := "recoverme4@ory.sh" + createIdentityToRecover(t, reg, email) + recoverySubmissionResponse := submitRecovery(t, client, RecoveryClientTypeAPI, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) + body := checkRecovery(t, client, RecoveryClientTypeAPI, email, recoverySubmissionResponse) + assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) + assert.Contains(t, gjson.Get(body, "redirect_browser_to").String(), "settings-ts?") + }) + + t.Run("description=should return browser to return url", func(t *testing.T) { + returnTo := public.URL + "/return-to" + conf.Set(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTo}) + for _, tc := range []struct { + desc string + returnTo string + f func(t *testing.T, client *http.Client, identity *identity.Identity) *kratos.RecoveryFlow + expectedAAL string + }{ + { + desc: "should use return_to from recovery flow", + returnTo: returnTo, + f: func(t *testing.T, client *http.Client, identity *identity.Identity) *kratos.RecoveryFlow { + return testhelpers.InitializeRecoveryFlowViaBrowser(t, client, false, public, url.Values{"return_to": []string{returnTo}}) + }, + }, + { + desc: "should use return_to from config", + returnTo: returnTo, + f: func(t *testing.T, client *http.Client, identity *identity.Identity) *kratos.RecoveryFlow { + conf.Set(ctx, config.ViperKeySelfServiceRecoveryBrowserDefaultReturnTo, returnTo) + t.Cleanup(func() { + conf.Set(ctx, config.ViperKeySelfServiceRecoveryBrowserDefaultReturnTo, "") + }) + return testhelpers.InitializeRecoveryFlowViaBrowser(t, client, false, public, nil) + }, + }, + { + desc: "no return to", + returnTo: "", + f: func(t *testing.T, client *http.Client, identity *identity.Identity) *kratos.RecoveryFlow { + return testhelpers.InitializeRecoveryFlowViaBrowser(t, client, false, public, nil) + }, + }, + { + desc: "should use return_to with an account that has 2fa enabled", + returnTo: returnTo, + f: func(t *testing.T, client *http.Client, id *identity.Identity) *kratos.RecoveryFlow { + conf.Set(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL) + conf.Set(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL) + conf.Set(ctx, config.ViperKeyWebAuthnRPDisplayName, "Kratos") + conf.Set(ctx, config.ViperKeyWebAuthnRPID, "ory.sh") + + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, identity.AuthenticatorAssuranceLevel1) + conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, identity.AuthenticatorAssuranceLevel1) + }) + testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeWebAuthn.String(), true) + + id.SetCredentials(identity.CredentialsTypeWebAuthn, identity.Credentials{ + Type: identity.CredentialsTypeWebAuthn, + Config: []byte(`{"credentials":[{"is_passwordless":false, "display_name":"test"}]}`), + Identifiers: []string{testhelpers.RandomEmail()}, + }) + + require.NoError(t, reg.IdentityManager().Update(ctx, id, identity.ManagerAllowWriteProtectedTraits)) + return testhelpers.InitializeRecoveryFlowViaBrowser(t, client, false, public, url.Values{"return_to": []string{returnTo}}) + }, + expectedAAL: "aal2", + }, + } { + t.Run(fmt.Sprintf("%s", tc.desc), func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + email := testhelpers.RandomEmail() + i := createIdentityToRecover(t, reg, email) + + client.Transport = testhelpers.NewTransportWithLogger(http.DefaultTransport, t).RoundTripper + f := tc.f(t, client, i) + + formPayload := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + formPayload.Set("email", email) + + body, res := testhelpers.RecoveryMakeRequest(t, false, f, client, formPayload.Encode()) + assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body) + expectedURL := testhelpers.ExpectURL(false, public.URL+recovery.RouteSubmitFlow, conf.SelfServiceFlowRecoveryUI(ctx).String()) + assert.Contains(t, res.Request.URL.String(), expectedURL, "%+v\n\t%s", res.Request, body) + + body = checkRecovery(t, client, RecoveryClientTypeBrowser, email, body) + + require.Equal(t, text.NewRecoverySuccessful(time.Now().Add(time.Hour)).Text, + gjson.Get(body, "ui.messages.0.text").String()) + + settingsId := gjson.Get(body, "id").String() + + sf, err := reg.SettingsFlowPersister().GetSettingsFlow(ctx, uuid.Must(uuid.FromString(settingsId))) + require.NoError(t, err) + + u, err := url.Parse(public.URL) + require.NoError(t, err) + require.Len(t, client.Jar.Cookies(u), 2) + found := false + for _, cookie := range client.Jar.Cookies(u) { + if cookie.Name == "ory_kratos_session" { + found = true + } + } + require.True(t, found) + + require.Equal(t, tc.returnTo, sf.ReturnTo) + res, err = client.Get(public.URL + session.RouteWhoami) + require.NoError(t, err) + body = string(x.MustReadAll(res.Body)) + require.NoError(t, res.Body.Close()) + + if tc.expectedAAL == "aal2" { + require.Equal(t, http.StatusForbidden, res.StatusCode) + require.Equalf(t, session.NewErrAALNotSatisfied("").Reason(), gjson.Get(body, "error.reason").String(), "%s", body) + require.Equalf(t, "session_aal2_required", gjson.Get(body, "error.id").String(), "%s", body) + } else { + assert.Equal(t, "code_recovery", gjson.Get(body, "authentication_methods.0.method").String(), "%s", body) + assert.Equal(t, "aal1", gjson.Get(body, "authenticator_assurance_level").String(), "%s", body) + } + }) + } + }) + }) + + t.Run("description=should set all the correct recovery payloads after submission", func(t *testing.T) { + body := expectSuccessfulRecovery(t, nil, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", "test@ory.sh") + }) + testhelpers.SnapshotTExcept(t, json.RawMessage(gjson.Get(body, "ui.nodes").String()), []string{"0.attributes.value"}) + }) + + t.Run("description=should set all the correct recovery payloads", func(t *testing.T) { + c := testhelpers.NewClientWithCookies(t) + rs := testhelpers.GetRecoveryFlow(t, c, public) + + testhelpers.SnapshotTExcept(t, rs.Ui.Nodes, []string{"0.attributes.value"}) + assert.EqualValues(t, public.URL+recovery.RouteSubmitFlow+"?flow="+rs.Id, rs.Ui.Action) + assert.Empty(t, rs.Ui.Messages) + }) + + t.Run("description=should require an email to be sent", func(t *testing.T) { + for _, flowType := range flowTypes { + t.Run("type="+string(flowType), func(t *testing.T) { + body := expectValidationError(t, nil, flowType, func(v url.Values) { + v.Del("email") + }) + assert.EqualValues(t, node.CodeGroup, gjson.Get(body, "active").String(), "%s", body) + assert.EqualValues(t, "Property email is missing.", + gjson.Get(body, "ui.nodes.#(attributes.name==email).messages.0.text").String(), + "%s", body) + }) + } + }) + + t.Run("description=should require a valid email to be sent", func(t *testing.T) { + for _, flowType := range flowTypes { + for _, email := range []string{"\\", "asdf", "...", "aiacobelli.sec@gmail.com,alejandro.iacobelli@mercadolibre.com"} { + t.Run("type="+string(flowType), func(t *testing.T) { + responseJSON := expectValidationError(t, nil, flowType, func(v url.Values) { + v.Set("email", email) + }) + activeMethod := gjson.Get(responseJSON, "active").String() + assert.EqualValues(t, node.CodeGroup, activeMethod, "expected method to be %s got %s", node.CodeGroup, activeMethod) + expectedMessage := fmt.Sprintf("%q is not valid \"email\"", email) + actualMessage := gjson.Get(responseJSON, "ui.nodes.#(attributes.name==email).messages.0.text").String() + assert.EqualValues(t, expectedMessage, actualMessage, "%s", responseJSON) + }) + } + } + }) + + t.Run("description=should try to submit the form while authenticated", func(t *testing.T) { + for _, flowType := range flowTypes { + t.Run("type="+string(flowType), func(t *testing.T) { + isSPA := flowType == "spa" + isAPI := flowType == "api" + client := testhelpers.NewDebugClient(t) + if !isAPI { + client = testhelpers.NewClientWithCookies(t) + client.Transport = testhelpers.NewTransportWithLogger(http.DefaultTransport, t).RoundTripper + } + + var f *kratos.RecoveryFlow + if isAPI { + f = testhelpers.InitializeRecoveryFlowViaAPI(t, client, public) + } else { + f = testhelpers.InitializeRecoveryFlowViaBrowser(t, client, isSPA, public, nil) + } + req := httptest.NewRequest("GET", "/sessions/whoami", nil) + + session, err := session.NewActiveSession( + req, + &identity.Identity{ID: x.NewUUID(), State: identity.StateActive}, + testhelpers.NewSessionLifespanProvider(time.Hour), + time.Now(), + identity.CredentialsTypePassword, + identity.AuthenticatorAssuranceLevel1, + ) + + require.NoError(t, err) + + // Add the authentication to the request + client.Transport = testhelpers.NewTransportWithLogger(testhelpers.NewAuthorizedTransport(t, reg, session), t).RoundTripper + + v := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + v.Set("email", "some-email@example.org") + v.Set("method", "code") + + body, res := testhelpers.RecoveryMakeRequest(t, isAPI || isSPA, f, client, testhelpers.EncodeFormAsJSON(t, isAPI || isSPA, v)) + + if isAPI || isSPA { + assert.EqualValues(t, http.StatusBadRequest, res.StatusCode, "%s", body) + assert.Contains(t, res.Request.URL.String(), recovery.RouteSubmitFlow, "%+v\n\t%s", res.Request, body) + assertx.EqualAsJSONExcept(t, recovery.ErrAlreadyLoggedIn, json.RawMessage(gjson.Get(body, "error").Raw), nil) + } else { + assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body) + assert.Contains(t, res.Request.URL.String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%+v\n\t%s", res.Request, body) + } + }) + } + }) + + t.Run("description=should not be able to recover account that does not exist", func(t *testing.T) { + conf.Set(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, true) + + t.Cleanup(func() { + conf.Set(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, false) + }) + + check := func(t *testing.T, c *http.Client, flowType ClientType, email string) { + withValues := func(v url.Values) { + v.Set("email", email) + } + body := submitRecovery(t, c, flowType, withValues, http.StatusOK) + assert.EqualValues(t, node.CodeGroup, gjson.Get(body, "active").String(), "%s", body) + assert.Empty(t, gjson.Get(body, "ui.nodes.#(attributes.name==code).attributes.value").String(), "%s", body) + assertx.EqualAsJSON(t, text.NewRecoveryEmailWithCodeSent(), json.RawMessage(gjson.Get(body, "ui.messages.0").Raw)) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Account access attempted") + assert.Contains(t, message.Body, "If this was you, check if you signed up using a different address.") + } + + t.Run("type=browser", func(t *testing.T) { + email := "recover_browser@ory.sh" + c := browserHttpClient(t) + check(t, c, RecoveryClientTypeBrowser, email) + }) + + t.Run("type=spa", func(t *testing.T) { + email := "recover_spa@ory.sh" + c := spaHttpClient(t) + check(t, c, RecoveryClientTypeSPA, email) + }) + + t.Run("type=api", func(t *testing.T) { + email := "recover_api@ory.sh" + c := apiHttpClient(t) + check(t, c, RecoveryClientTypeAPI, email) + }) + }) + + t.Run("description=should not be able to recover an inactive account", func(t *testing.T) { + for _, flowType := range flowTypeCases { + t.Run("type="+string(flowType.ClientType), func(t *testing.T) { + email := "recoverinactive_" + string(flowType.ClientType) + "@ory.sh" + createIdentityToRecover(t, reg, email) + values := func(v url.Values) { + v.Set("email", email) + } + cl := testhelpers.NewClientWithCookies(t) + + body := submitRecovery(t, cl, flowType.ClientType, values, http.StatusOK) + addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, email) + assert.NoError(t, err) + + emailText := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, emailText, 1) + + // Deactivate the identity + require.NoError(t, reg.Persister().GetConnection(context.Background()).RawQuery("UPDATE identities SET state=? WHERE id = ?", identity.StateInactive, addr.IdentityID).Exec()) + + if flowType.ClientType == RecoveryClientTypeAPI || flowType.ClientType == RecoveryClientTypeSPA { + body = submitRecoveryCode(t, cl, body, flowType.ClientType, recoveryCode, http.StatusUnauthorized) + assertx.EqualAsJSON(t, session.ErrIdentityDisabled.WithDetail("identity_id", addr.IdentityID), json.RawMessage(gjson.Get(body, "error").Raw), "%s", body) + } else { + body = submitRecoveryCode(t, cl, body, flowType.ClientType, recoveryCode, http.StatusOK) + assertx.EqualAsJSON(t, session.ErrIdentityDisabled.WithDetail("identity_id", addr.IdentityID), json.RawMessage(body), "%s", body) + } + }) + } + }) + + t.Run("description=should recover and invalidate all other sessions if hook is set", func(t *testing.T) { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "revoke_active_sessions"}}) + t.Cleanup(func() { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil) + }) + + email := testhelpers.RandomEmail() + id := createIdentityToRecover(t, reg, email) + + req := httptest.NewRequest("GET", "/sessions/whoami", nil) + sess, err := session.NewActiveSession(req, id, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) + require.NoError(t, err) + require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), sess)) + + actualSession, err := reg.SessionPersister().GetSession(context.Background(), sess.ID, session.ExpandNothing) + require.NoError(t, err) + assert.True(t, actualSession.IsActive()) + + cl := testhelpers.NewClientWithCookies(t) + actual := expectSuccessfulRecovery(t, cl, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", email) + }) + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + cl.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + action := gjson.Get(actual, "ui.action").String() + require.NotEmpty(t, action) + csrf_token := gjson.Get(actual, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() + require.NotEmpty(t, csrf_token) + + submitRecoveryCode(t, cl, actual, RecoveryClientTypeBrowser, recoveryCode, http.StatusSeeOther) + + require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) + cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.Contains(t, cookies, "ory_kratos_session") + + actualSession, err = reg.SessionPersister().GetSession(context.Background(), sess.ID, session.ExpandNothing) + require.NoError(t, err) + assert.False(t, actualSession.IsActive()) + }) + + t.Run("description=should not be able to use an invalid code more than 5 times", func(t *testing.T) { + email := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, email) + c := testhelpers.NewClientWithCookies(t) + body := submitRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) + initialFlowId := gjson.Get(body, "id") + + for submitTry := 0; submitTry < 5; submitTry++ { + body := submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, "12312312", http.StatusOK) + + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + } + + // submit an invalid code for the 6th time + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, "12312312", http.StatusOK) + + require.Len(t, gjson.Get(body, "ui.messages").Array(), 1) + assert.Equal(t, "The request was submitted too often. Please request another code.", gjson.Get(body, "ui.messages.0.text").String()) + + // check that a new flow has been created + assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + + assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==email)").Exists()) + }) + + t.Run("description=should be able to recover after using invalid code", func(t *testing.T) { + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { + c := testCase.GetClient(t) + recoveryEmail := testhelpers.RandomEmail() + _ = createIdentityToRecover(t, reg, recoveryEmail) + + actual := submitRecovery(t, c, testCase.ClientType, func(v url.Values) { + v.Set("email", recoveryEmail) + }, http.StatusOK) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + form := withCSRFToken(t, testCase.ClientType, actual, url.Values{ + "code": {"12312312"}, + }) + + action := gjson.Get(actual, "ui.action").String() + require.NotEmpty(t, action) + + res, err := c.Post(action, testCase.FormContentType, bytes.NewBufferString(form)) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + + flowId := gjson.Get(actual, "id").String() + require.NotEmpty(t, flowId) + + rs, res, err := testhelpers. + NewSDKCustomClient(public, c). + FrontendApi.GetRecoveryFlow(context.Background()). + Id(flowId). + Execute() + + require.NoError(t, err) + body := ioutilx.MustReadAll(res.Body) + require.NotEmpty(t, body) + + require.Len(t, rs.Ui.Messages, 1) + assert.Equal(t, "The recovery code is invalid or has already been used. Please try again.", rs.Ui.Messages[0].Text) + + form = withCSRFToken(t, testCase.ClientType, actual, url.Values{ + "code": {recoveryCode}, + }) + // Now submit the correct code + res, err = c.Post(action, testCase.FormContentType, bytes.NewBufferString(form)) + require.NoError(t, err) + if testCase.ClientType == RecoveryClientTypeBrowser { + assert.Equal(t, http.StatusOK, res.StatusCode) + + json := ioutilx.MustReadAll(res.Body) + + assert.Len(t, gjson.GetBytes(json, "ui.messages").Array(), 1) + assert.Contains(t, gjson.GetBytes(json, "ui.messages.0.text").String(), "You successfully recovered your account.") + } else if testCase.ClientType == RecoveryClientTypeSPA { + assert.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) + + json := ioutilx.MustReadAll(res.Body) + + assert.Equal(t, gjson.GetBytes(json, "error.id").String(), "browser_location_change_required") + assert.Contains(t, gjson.GetBytes(json, "redirect_browser_to").String(), "settings-ts?") + } + }) + } + }) + + t.Run("description=should not be able to use an invalid code", func(t *testing.T) { + email := "recoverme+invalid_code@ory.sh" + createIdentityToRecover(t, reg, email) + c := testhelpers.NewClientWithCookies(t) + + body := submitRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) + + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, "12312312", http.StatusOK) + + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + }) + + t.Run("description=should not be able to submit recover address after flow expired", func(t *testing.T) { + recoveryEmail := "recoverme5@ory.sh" + createIdentityToRecover(t, reg, recoveryEmail) + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Millisecond*200) + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Minute) + }) + + c := testhelpers.NewClientWithCookies(t) + rs := testhelpers.GetRecoveryFlow(t, c, public) + + time.Sleep(time.Millisecond * 201) + + res, err := c.PostForm(rs.Ui.Action, url.Values{"email": {recoveryEmail}}) + require.NoError(t, err) + assert.EqualValues(t, http.StatusOK, res.StatusCode) + assert.NotContains(t, res.Request.URL.String(), "flow="+rs.Id) + assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String()) + + addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) + assert.NoError(t, err) + assert.False(t, addr.Verified) + assert.Nil(t, addr.VerifiedAt) + assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status) + }) + + t.Run("description=should not be able to submit code after flow expired", func(t *testing.T) { + recoveryEmail := "recoverme6@ory.sh" + createIdentityToRecover(t, reg, recoveryEmail) + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Millisecond*200) + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Minute) + }) + + c := testhelpers.NewClientWithCookies(t) + + body := expectSuccessfulRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + initialFlowId := gjson.Get(body, "id") + + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + assert.Contains(t, message.Body, "please recover access to your account by entering the following code") + + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + time.Sleep(time.Millisecond * 201) + + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, recoveryCode, http.StatusOK) + + assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + + testhelpers.AssertMessage(t, []byte(body), "The recovery flow expired 0.00 minutes ago, please try again.") + + addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) + require.NoError(t, err) + assert.False(t, addr.Verified) + assert.Nil(t, addr.VerifiedAt) + assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status) + }) + + t.Run("description=should not break ui if empty code is submitted", func(t *testing.T) { + recoveryEmail := "recoverme7@ory.sh" + createIdentityToRecover(t, reg, recoveryEmail) + + c := testhelpers.NewClientWithCookies(t) + body := expectSuccessfulRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, "", http.StatusOK) + + assert.NotContains(t, gjson.Get(body, "ui.nodes").String(), "Property email is missing.") + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + }) + + t.Run("description=should be able to re-send the recovery code", func(t *testing.T) { + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) + + c := testhelpers.NewClientWithCookies(t) + body := expectSuccessfulRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + body = resendRecoveryCode(t, c, body, RecoveryClientTypeBrowser, http.StatusOK) + assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==code)").Exists()) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, recoveryCode, http.StatusOK) + }) + + t.Run("description=should not be able to use first code after re-sending email", func(t *testing.T) { + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) + + c := testhelpers.NewClientWithCookies(t) + body := expectSuccessfulRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + message1 := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode1 := testhelpers.CourierExpectCodeInMessage(t, message1, 1) + + body = resendRecoveryCode(t, c, body, RecoveryClientTypeBrowser, http.StatusOK) + assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==code)").Exists()) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + message2 := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode2 := testhelpers.CourierExpectCodeInMessage(t, message2, 1) + + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, recoveryCode1, http.StatusOK) + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + + // For good measure, check that the second code works! + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, recoveryCode2, http.StatusOK) + testhelpers.AssertMessage(t, []byte(body), "You successfully recovered your account. Please change your password or set up an alternative login method (e.g. social sign in) within the next 60.00 minutes.") + }) + + t.Run("description=should not show outdated validation message if newer message appears #2799", func(t *testing.T) { + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) + + c := testhelpers.NewClientWithCookies(t) + body := expectSuccessfulRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, "12312312", http.StatusOK) // Now send a wrong code that triggers "global" validation error + + assert.Empty(t, gjson.Get(body, "ui.nodes.#(attributes.name==code).messages").Array()) + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + }) + + t.Run("description=should recover if post recovery hook is successful", func(t *testing.T) { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}}) + t.Cleanup(func() { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), nil) + }) + + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) + + cl := testhelpers.NewClientWithCookies(t) + body := expectSuccessfulRecovery(t, cl, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + cl.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + body = submitRecoveryCode(t, cl, body, RecoveryClientTypeBrowser, recoveryCode, http.StatusSeeOther) + + require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) + cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.Contains(t, cookies, "ory_kratos_session") + }) + + t.Run("description=should not be able to recover if post recovery hook fails", func(t *testing.T) { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostRecoveryHook": "err"}`)}}) + t.Cleanup(func() { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), nil) + }) + + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) + + cl := testhelpers.NewClientWithCookies(t) + body := expectSuccessfulRecovery(t, cl, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + cl.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + initialFlowId := gjson.Get(body, "id") + body = submitRecoveryCode(t, cl, body, RecoveryClientTypeBrowser, recoveryCode, http.StatusSeeOther) + assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + + require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 1) + cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.NotContains(t, cookies, "ory_kratos_session") + }) +} + +func TestRecovery_WithContinueWith(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + testhelpers.StrategyEnable(t, conf, string(recovery.RecoveryStrategyCode), true) + testhelpers.StrategyEnable(t, conf, string(recovery.RecoveryStrategyLink), false) + conf.MustSet(ctx, config.ViperKeyUseContinueWithTransitions, true) initViper(t, ctx, conf) @@ -237,11 +1048,13 @@ func TestRecovery(t *testing.T) { assert.Contains(t, cookies, "ory_kratos_session") require.Contains(t, body, "You successfully recovered your account. Please change your password or set up an alternative login method (e.g. social sign in) within the next 60.00 minutes.") case RecoveryClientTypeSPA: - body = submitRecoveryCode(t, c, body, clientType, recoveryCode, http.StatusUnprocessableEntity) - assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) + body = submitRecoveryCode(t, c, body, clientType, recoveryCode, http.StatusOK) + // assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) require.Len(t, c.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) cookies := spew.Sdump(c.Jar.Cookies(urlx.ParseOrPanic(public.URL))) assert.Contains(t, cookies, "ory_kratos_session") + + require.NotEmpty(t, gjson.Get(body, "continue_with.#(action==show_settings_ui).flow").String(), "%s", body) case RecoveryClientTypeAPI: body = submitRecoveryCode(t, c, body, clientType, recoveryCode, http.StatusOK) require.NotEmpty(t, gjson.Get(body, "continue_with.#(action==show_settings_ui).flow").String(), "%s", body) @@ -264,8 +1077,8 @@ func TestRecovery(t *testing.T) { recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, recoveryCode) - statusCode := testhelpers.ExpectStatusCode(flowType == RecoveryClientTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) - return submitRecoveryCode(t, client, recoverySubmissionResponse, flowType, recoveryCode, statusCode) + // statusCode := testhelpers.ExpectStatusCode(flowType == RecoveryClientTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) + return submitRecoveryCode(t, client, recoverySubmissionResponse, flowType, recoveryCode, http.StatusOK) } t.Run("type=browser", func(t *testing.T) { @@ -296,8 +1109,10 @@ func TestRecovery(t *testing.T) { v.Set("email", email) }, http.StatusOK) body := checkRecovery(t, client, RecoveryClientTypeSPA, email, recoverySubmissionResponse) - assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) - assert.Contains(t, gjson.Get(body, "redirect_browser_to").String(), "settings-ts?") + assert.Equal(t, "passed_challenge", gjson.Get(body, "state").String()) + assert.Len(t, gjson.Get(body, "continue_with").Array(), 1) + sfId := gjson.Get(body, "continue_with.#(action==show_settings_ui).flow.id").String() + assert.NotEmpty(t, uuid.Must(uuid.FromString(sfId))) }) t.Run("type=api", func(t *testing.T) { @@ -729,20 +1544,31 @@ func TestRecovery(t *testing.T) { // Now submit the correct code res, err = c.Post(action, testCase.FormContentType, bytes.NewBufferString(form)) require.NoError(t, err) - if testCase.ClientType == RecoveryClientTypeBrowser { + switch testCase.ClientType { + case RecoveryClientTypeBrowser: assert.Equal(t, http.StatusOK, res.StatusCode) json := ioutilx.MustReadAll(res.Body) assert.Len(t, gjson.GetBytes(json, "ui.messages").Array(), 1) assert.Contains(t, gjson.GetBytes(json, "ui.messages.0.text").String(), "You successfully recovered your account.") - } else if testCase.ClientType == RecoveryClientTypeSPA { - assert.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) + case RecoveryClientTypeSPA: + assert.Equal(t, http.StatusOK, res.StatusCode) json := ioutilx.MustReadAll(res.Body) - assert.Equal(t, gjson.GetBytes(json, "error.id").String(), "browser_location_change_required") - assert.Contains(t, gjson.GetBytes(json, "redirect_browser_to").String(), "settings-ts?") + require.Len(t, c.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) + cookies := spew.Sdump(c.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.Contains(t, cookies, "ory_kratos_session") + + require.NotEmpty(t, gjson.GetBytes(json, "continue_with.#(action==show_settings_ui).flow").String(), "%s", json) + case RecoveryClientTypeAPI: + assert.Equal(t, http.StatusOK, res.StatusCode) + + json := ioutilx.MustReadAll(res.Body) + + require.NotEmpty(t, gjson.GetBytes(json, "continue_with.#(action==show_settings_ui).flow").String(), "%s", json) + require.NotEmpty(t, gjson.GetBytes(json, "continue_with.#(action==set_ory_session_token).ory_session_token").String(), "%s", json) } }) } diff --git a/test/e2e/cypress/support/config.d.ts b/test/e2e/cypress/support/config.d.ts index 5f73f6626e8e..060cb12822bf 100644 --- a/test/e2e/cypress/support/config.d.ts +++ b/test/e2e/cypress/support/config.d.ts @@ -1334,5 +1334,5 @@ export interface GlobalHTTPClientConfiguration { } export interface FeatureFlags { cacheable_sessions?: EnableOrySessionsCaching - new_flow_transitions?: EnableNewFlowTransitionsUsingContinueWithItems + use_continue_with_transitions?: EnableNewFlowTransitionsUsingContinueWithItems } diff --git a/test/e2e/playwright/tests/app_recovery.spec.ts b/test/e2e/playwright/tests/app_recovery.spec.ts index 1629b6c3923d..629abd3c05bc 100644 --- a/test/e2e/playwright/tests/app_recovery.spec.ts +++ b/test/e2e/playwright/tests/app_recovery.spec.ts @@ -23,7 +23,7 @@ test.describe("Recovery", () => { ...schemaConfig, }, feature_flags: { - new_flow_transitions: true, + use_continue_with_transitions: true, }, }, }) @@ -115,7 +115,7 @@ test.describe("Recovery", () => { }, }, feature_flags: { - new_flow_transitions: true, + use_continue_with_transitions: true, }, }, }) From 274c0d0f255a8a8215fb09a7bc50181be2ca786c Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Sun, 12 Nov 2023 13:50:06 +0100 Subject: [PATCH 21/25] chore: y --- selfservice/strategy/code/strategy_recovery.go | 1 - 1 file changed, 1 deletion(-) diff --git a/selfservice/strategy/code/strategy_recovery.go b/selfservice/strategy/code/strategy_recovery.go index 3d373329f028..ebf328895582 100644 --- a/selfservice/strategy/code/strategy_recovery.go +++ b/selfservice/strategy/code/strategy_recovery.go @@ -225,7 +225,6 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, case x.IsJSONRequest(r): f.ContinueWith = append(f.ContinueWith, flow.NewContinueWithSettingsUI(sf)) s.deps.Writer().Write(w, r, f) - // s.deps.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String())) default: http.Redirect(w, r, sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String(), http.StatusSeeOther) } From 9f84d59c0175b933b7cdc3c6c3a6773e9fde43f5 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Mon, 13 Nov 2023 12:14:47 +0100 Subject: [PATCH 22/25] chore: sdk --- internal/client-go/api_frontend.go | 4 ++-- internal/client-go/model_recovery_code_for_identity.go | 2 +- internal/httpclient/api_frontend.go | 4 ++-- internal/httpclient/model_recovery_code_for_identity.go | 2 +- spec/api.json | 4 ++-- spec/swagger.json | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/client-go/api_frontend.go b/internal/client-go/api_frontend.go index 651fe1e274a4..ab8c45632935 100644 --- a/internal/client-go/api_frontend.go +++ b/internal/client-go/api_frontend.go @@ -240,7 +240,7 @@ type FrontendApi interface { If a valid provided session cookie or session token is provided, a 400 Bad Request error. - If you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint. + On an existing recovery flow, use the `getRecoveryFlow` API endpoint. You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make @@ -2075,7 +2075,7 @@ func (r FrontendApiApiCreateNativeRecoveryFlowRequest) Execute() (*RecoveryFlow, If a valid provided session cookie or session token is provided, a 400 Bad Request error. -If you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint. +On an existing recovery flow, use the `getRecoveryFlow` API endpoint. You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make diff --git a/internal/client-go/model_recovery_code_for_identity.go b/internal/client-go/model_recovery_code_for_identity.go index 437877b15b94..a5027e7c882e 100644 --- a/internal/client-go/model_recovery_code_for_identity.go +++ b/internal/client-go/model_recovery_code_for_identity.go @@ -18,7 +18,7 @@ import ( // RecoveryCodeForIdentity Used when an administrator creates a recovery code for an identity. type RecoveryCodeForIdentity struct { - // Expires At is the timestamp of when the recovery flow expires The timestamp when the recovery link expires. + // Expires At is the timestamp of when the recovery flow expires The timestamp when the recovery code expires. ExpiresAt *time.Time `json:"expires_at,omitempty"` // RecoveryCode is the code that can be used to recover the account RecoveryCode string `json:"recovery_code"` diff --git a/internal/httpclient/api_frontend.go b/internal/httpclient/api_frontend.go index 651fe1e274a4..ab8c45632935 100644 --- a/internal/httpclient/api_frontend.go +++ b/internal/httpclient/api_frontend.go @@ -240,7 +240,7 @@ type FrontendApi interface { If a valid provided session cookie or session token is provided, a 400 Bad Request error. - If you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint. + On an existing recovery flow, use the `getRecoveryFlow` API endpoint. You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make @@ -2075,7 +2075,7 @@ func (r FrontendApiApiCreateNativeRecoveryFlowRequest) Execute() (*RecoveryFlow, If a valid provided session cookie or session token is provided, a 400 Bad Request error. -If you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint. +On an existing recovery flow, use the `getRecoveryFlow` API endpoint. You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make diff --git a/internal/httpclient/model_recovery_code_for_identity.go b/internal/httpclient/model_recovery_code_for_identity.go index 437877b15b94..a5027e7c882e 100644 --- a/internal/httpclient/model_recovery_code_for_identity.go +++ b/internal/httpclient/model_recovery_code_for_identity.go @@ -18,7 +18,7 @@ import ( // RecoveryCodeForIdentity Used when an administrator creates a recovery code for an identity. type RecoveryCodeForIdentity struct { - // Expires At is the timestamp of when the recovery flow expires The timestamp when the recovery link expires. + // Expires At is the timestamp of when the recovery flow expires The timestamp when the recovery code expires. ExpiresAt *time.Time `json:"expires_at,omitempty"` // RecoveryCode is the code that can be used to recover the account RecoveryCode string `json:"recovery_code"` diff --git a/spec/api.json b/spec/api.json index bd4272096aa7..07d583a5f48c 100644 --- a/spec/api.json +++ b/spec/api.json @@ -1567,7 +1567,7 @@ "description": "Used when an administrator creates a recovery code for an identity.", "properties": { "expires_at": { - "description": "Expires At is the timestamp of when the recovery flow expires\n\nThe timestamp when the recovery link expires.", + "description": "Expires At is the timestamp of when the recovery flow expires\n\nThe timestamp when the recovery code expires.", "format": "date-time", "type": "string" }, @@ -5650,7 +5650,7 @@ }, "/self-service/recovery/api": { "get": { - "description": "This endpoint initiates a recovery flow for API clients such as mobile devices, smart TVs, and so on.\n\nIf a valid provided session cookie or session token is provided, a 400 Bad Request error.\n\nIf you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint.\n\nYou MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server\nPages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make\nyou vulnerable to a variety of CSRF attacks.\n\nThis endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).\n\nMore information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).", + "description": "This endpoint initiates a recovery flow for API clients such as mobile devices, smart TVs, and so on.\n\nIf a valid provided session cookie or session token is provided, a 400 Bad Request error.\n\nOn an existing recovery flow, use the `getRecoveryFlow` API endpoint.\n\nYou MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server\nPages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make\nyou vulnerable to a variety of CSRF attacks.\n\nThis endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).\n\nMore information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).", "operationId": "createNativeRecoveryFlow", "responses": { "200": { diff --git a/spec/swagger.json b/spec/swagger.json index 1f3f614c1ce8..e1f094467c66 100755 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -1957,7 +1957,7 @@ }, "/self-service/recovery/api": { "get": { - "description": "This endpoint initiates a recovery flow for API clients such as mobile devices, smart TVs, and so on.\n\nIf a valid provided session cookie or session token is provided, a 400 Bad Request error.\n\nIf you already created a recovery, fetch the flow's information using the getRecoveryFlow API endpoint.\n\nYou MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server\nPages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make\nyou vulnerable to a variety of CSRF attacks.\n\nThis endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).\n\nMore information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).", + "description": "This endpoint initiates a recovery flow for API clients such as mobile devices, smart TVs, and so on.\n\nIf a valid provided session cookie or session token is provided, a 400 Bad Request error.\n\nOn an existing recovery flow, use the `getRecoveryFlow` API endpoint.\n\nYou MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server\nPages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make\nyou vulnerable to a variety of CSRF attacks.\n\nThis endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).\n\nMore information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).", "schemes": [ "http", "https" @@ -4613,7 +4613,7 @@ ], "properties": { "expires_at": { - "description": "Expires At is the timestamp of when the recovery flow expires\n\nThe timestamp when the recovery link expires.", + "description": "Expires At is the timestamp of when the recovery flow expires\n\nThe timestamp when the recovery code expires.", "type": "string", "format": "date-time" }, From 2c03b5ac5e58db5498db3cb79096a250aee9a385 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Tue, 14 Nov 2023 10:25:26 +0100 Subject: [PATCH 23/25] chore: revert workflow changes --- .github/workflows/ci.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a2672f4e86ed..43129b0991ce 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -175,7 +175,6 @@ jobs: uses: actions/checkout@v3 with: repository: ory/kratos-selfservice-ui-react-native - ref: jonas-jonas/improveRecovery path: react-native-ui - run: | cd react-native-ui @@ -281,7 +280,6 @@ jobs: uses: actions/checkout@v3 with: repository: ory/kratos-selfservice-ui-react-native - ref: jonas-jonas/improveRecovery path: react-native-ui - run: | cd react-native-ui From d99d7c4aee420e30154988e3ac3143b7235d6584 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Tue, 14 Nov 2023 10:25:41 +0100 Subject: [PATCH 24/25] chore: guard error changes behind feature flag --- ...=fails_if_active_strategy_is_disabled.json | 73 +++++ ..._if_recovery_strategy_id_is_not_valid.json | 6 + ...=fails_if_active_strategy_is_disabled.json | 73 +++++ selfservice/flow/recovery/error.go | 38 ++- selfservice/flow/recovery/error_test.go | 262 +++++++++++++++++- 5 files changed, 435 insertions(+), 17 deletions(-) create mode 100644 selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=api-case=fails_if_active_strategy_is_disabled.json create mode 100644 selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=browser-case=fails_to_retry_flow_if_recovery_strategy_id_is_not_valid.json create mode 100644 selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=spa-case=fails_if_active_strategy_is_disabled.json 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 new file mode 100644 index 000000000000..d6c78d8b8358 --- /dev/null +++ b/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=api-case=fails_if_active_strategy_is_disabled.json @@ -0,0 +1,73 @@ +{ + "type": "api", + "request_url": "http:///", + "active": "link", + "ui": { + "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": "code", + "attributes": { + "name": "email", + "type": "email", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070007, + "text": "Email", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + } + } + ], + "messages": [ + { + "id": 4000001, + "text": "The active recovery strategy code is not enabled. Please enable it in the configuration.", + "type": "error", + "context": { + "reason": "The active recovery strategy code is not enabled. Please enable it in the configuration." + } + } + ] + }, + "state": "choose_method" +} + diff --git a/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=browser-case=fails_to_retry_flow_if_recovery_strategy_id_is_not_valid.json b/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=browser-case=fails_to_retry_flow_if_recovery_strategy_id_is_not_valid.json new file mode 100644 index 000000000000..bc2b0b525548 --- /dev/null +++ b/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=browser-case=fails_to_retry_flow_if_recovery_strategy_id_is_not_valid.json @@ -0,0 +1,6 @@ +{ + "code": 400, + "message": "The request was malformed or contained invalid parameters", + "reason": "The active recovery strategy not-valid is not enabled. Please enable it in the configuration.", + "status": "Bad Request" +} 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 new file mode 100644 index 000000000000..7a17de3f9374 --- /dev/null +++ b/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=spa-case=fails_if_active_strategy_is_disabled.json @@ -0,0 +1,73 @@ +{ + "type": "browser", + "request_url": "http:///", + "active": "link", + "ui": { + "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": "code", + "attributes": { + "name": "email", + "type": "email", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070007, + "text": "Email", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1070005, + "text": "Submit", + "type": "info" + } + } + } + ], + "messages": [ + { + "id": 4000001, + "text": "The active recovery strategy code is not enabled. Please enable it in the configuration.", + "type": "error", + "context": { + "reason": "The active recovery strategy code is not enabled. Please enable it in the configuration." + } + } + ] + }, + "state": "choose_method" +} + diff --git a/selfservice/flow/recovery/error.go b/selfservice/flow/recovery/error.go index db158ce73e00..9f93af941447 100644 --- a/selfservice/flow/recovery/error.go +++ b/selfservice/flow/recovery/error.go @@ -78,7 +78,7 @@ func (s *ErrorHandler) WriteFlowError( trace.SpanFromContext(r.Context()).AddEvent(events.NewRecoveryFailed(r.Context(), string(f.Type), f.Active.String())) - if e := new(flow.ExpiredError); errors.As(recoveryErr, &e) { + if expiredError := new(flow.ExpiredError); errors.As(recoveryErr, &expiredError) { strategy, err := s.d.RecoveryStrategies(r.Context()).Strategy(f.Active.String()) if err != nil { strategy, err = s.d.GetActiveRecoveryStrategy(r.Context()) @@ -96,23 +96,35 @@ func (s *ErrorHandler) WriteFlowError( return } - newFlow.UI.Messages.Add(text.NewErrorValidationRecoveryFlowExpired(e.ExpiredAt)) + newFlow.UI.Messages.Add(text.NewErrorValidationRecoveryFlowExpired(expiredError.ExpiredAt)) if err := s.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), newFlow); err != nil { s.forward(w, r, newFlow, err) return } - switch { - case newFlow.Type.IsAPI(): - e.FlowID = newFlow.ID - s.d.Writer().WriteError(w, r, e.WithContinueWith(flow.NewContinueWithRecoveryUI(newFlow))) - case x.IsJSONRequest(r): - http.Redirect(w, r, urlx.CopyWithQuery( - urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()), RouteGetFlow), - url.Values{"id": {newFlow.ID.String()}}, - ).String(), http.StatusSeeOther) - default: - http.Redirect(w, r, newFlow.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther) + if s.d.Config().UseContinueWithTransitions(r.Context()) { + switch { + case newFlow.Type.IsAPI(): + expiredError.FlowID = newFlow.ID + s.d.Writer().WriteError(w, r, expiredError.WithContinueWith(flow.NewContinueWithRecoveryUI(newFlow))) + case x.IsJSONRequest(r): + http.Redirect(w, r, urlx.CopyWithQuery( + urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()), RouteGetFlow), + url.Values{"id": {newFlow.ID.String()}}, + ).String(), http.StatusSeeOther) + default: + http.Redirect(w, r, newFlow.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther) + } + } else { + // We need to use the new flow, as that flow will be a browser flow. Bug fix for: + // + // https://github.com/ory/kratos/issues/2049!! + if newFlow.Type == flow.TypeAPI || x.IsJSONRequest(r) { + http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()), + RouteGetFlow), url.Values{"id": {newFlow.ID.String()}}).String(), http.StatusSeeOther) + } else { + http.Redirect(w, r, newFlow.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther) + } } return } diff --git a/selfservice/flow/recovery/error_test.go b/selfservice/flow/recovery/error_test.go index 2fdc2a10bd07..432d149a04f1 100644 --- a/selfservice/flow/recovery/error_test.go +++ b/selfservice/flow/recovery/error_test.go @@ -123,6 +123,264 @@ func TestHandleError(t *testing.T) { assert.Contains(t, string(body), "system error") }) + for _, tc := range []struct { + n string + t flow.Type + }{ + {"api", flow.TypeAPI}, + {"spa", flow.TypeBrowser}, + } { + t.Run("flow="+tc.n, func(t *testing.T) { + t.Run("case=expired error", func(t *testing.T) { + t.Cleanup(reset) + + recoveryFlow = newFlow(t, time.Minute, tc.t) + flowError = flow.NewFlowExpiredError(anHourAgo) + methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) + + res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) + require.NoError(t, err) + defer res.Body.Close() + require.Contains(t, res.Request.URL.String(), public.URL+recovery.RouteGetFlow) + require.Equal(t, http.StatusOK, res.StatusCode, "%+v", res.Request) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), string(body)) + assert.NotEqual(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String()) + }) + + t.Run("case=validation error", func(t *testing.T) { + t.Cleanup(reset) + + recoveryFlow = newFlow(t, time.Minute, tc.t) + flowError = schema.NewInvalidCredentialsError() + methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) + + res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), "%s", body) + assert.Equal(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String()) + }) + + t.Run("case=generic error", func(t *testing.T) { + t.Cleanup(reset) + + recoveryFlow = newFlow(t, time.Minute, tc.t) + flowError = herodot.ErrInternalServerError.WithReason("system error") + methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) + + res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusInternalServerError, res.StatusCode) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + assert.JSONEq(t, x.MustEncodeJSON(t, flowError), gjson.GetBytes(body, "error").Raw) + }) + + t.Run("case=fails if active strategy is disabled", func(t *testing.T) { + c, reg := internal.NewVeryFastRegistryWithoutDB(t) + c.Set(context.Background(), "selfservice.methods.code.enabled", false) + c.Set(context.Background(), config.ViperKeySelfServiceRecoveryUse, "code") + _, err := reg.GetActiveRecoveryStrategy(context.Background()) + recoveryFlow = newFlow(t, time.Minute, tc.t) + flowError = err + methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) + + res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + + snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("id", "expires_at", "issued_at", "ui.action", "ui.nodes.0.attributes.value")) + }) + }) + } + + t.Run("flow=browser", func(t *testing.T) { + expectRecoveryUI := func(t *testing.T) (*recovery.Flow, *http.Response) { + res, err := ts.Client().Get(ts.URL + "/error") + require.NoError(t, err) + defer res.Body.Close() + assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String()+"?flow=") + + rf, err := reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow"))) + require.NoError(t, err) + return rf, res + } + + t.Run("case=expired error", func(t *testing.T) { + t.Cleanup(reset) + + recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser} + flowError = flow.NewFlowExpiredError(anHourAgo) + methodName = node.LinkGroup + + lf, _ := expectRecoveryUI(t) + require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf)) + assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID)) + }) + + t.Run("case=validation error", func(t *testing.T) { + t.Cleanup(reset) + + recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser) + flowError = schema.NewInvalidCredentialsError() + methodName = node.LinkGroup + + lf, _ := expectRecoveryUI(t) + require.NotEmpty(t, lf.UI, x.MustEncodeJSON(t, lf)) + require.Len(t, lf.UI.Messages, 1, x.MustEncodeJSON(t, lf)) + assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(lf.UI.Messages[0].ID), x.MustEncodeJSON(t, lf)) + }) + + t.Run("case=generic error", func(t *testing.T) { + t.Cleanup(reset) + + recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser) + flowError = herodot.ErrInternalServerError.WithReason("system error") + methodName = node.LinkGroup + + sse, _ := expectErrorUI(t) + assertx.EqualAsJSON(t, flowError, sse) + }) + + t.Run("case=new flow uses strategy of old flow", func(t *testing.T) { + t.Cleanup(reset) + + recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "code"} + flowError = flow.NewFlowExpiredError(anHourAgo) + + lf, _ := expectRecoveryUI(t) + require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf)) + assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID)) + assert.Equal(t, recoveryFlow.Active.String(), lf.Active.String()) + }) + + t.Run("case=new flow uses current strategy if strategy of old flow does not exist", func(t *testing.T) { + t.Cleanup(reset) + + recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "not-valid"} + flowError = flow.NewFlowExpiredError(anHourAgo) + + lf, _ := expectRecoveryUI(t) + require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf)) + assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID)) + assert.Equal(t, "code", lf.Active.String()) + }) + + t.Run("case=fails to retry flow if recovery strategy id is not valid", func(t *testing.T) { + t.Cleanup(func() { + reset() + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "code") + }) + + recoveryFlow = newFlow(t, 0, flow.TypeBrowser) + recoveryFlow.Active = "not-valid" + flowError = flow.NewFlowExpiredError(anHourAgo) + + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "not-valid") + sse, _ := expectErrorUI(t) + snapshotx.SnapshotT(t, sse) + }) + }) +} + +func TestHandleError_WithContinueWith(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + conf.MustSet(ctx, config.ViperKeyUseContinueWithTransitions, true) + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryEnabled, true) + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "code") + + public, _ := testhelpers.NewKratosServer(t, reg) + + router := httprouter.New() + ts := httptest.NewServer(router) + t.Cleanup(ts.Close) + + testhelpers.NewRecoveryUIFlowEchoServer(t, reg) + testhelpers.NewErrorTestServer(t, reg) + + h := reg.RecoveryFlowErrorHandler() + sdk := testhelpers.NewSDKClient(public) + + var recoveryFlow *recovery.Flow + var flowError error + var methodName node.UiNodeGroup + router.GET("/error", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + h.WriteFlowError(w, r, recoveryFlow, methodName, flowError) + }) + + reset := func() { + recoveryFlow = nil + flowError = nil + methodName = "" + } + + newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type) *recovery.Flow { + req := &http.Request{URL: urlx.ParseOrPanic("/")} + s, err := reg.GetActiveRecoveryStrategy(context.Background()) + require.NoError(t, err) + f, err := recovery.NewFlow(conf, ttl, x.FakeCSRFToken, req, s, ft) + require.NoError(t, err) + require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(context.Background(), f)) + f, err = reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), f.ID) + require.NoError(t, err) + return f + } + + expectErrorUI := func(t *testing.T) (map[string]interface{}, *http.Response) { + res, err := ts.Client().Get(ts.URL + "/error") + require.NoError(t, err) + defer res.Body.Close() + require.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=") + + sse, _, err := sdk.FrontendApi.GetFlowError(context.Background()).Id(res.Request.URL.Query().Get("id")).Execute() + require.NoError(t, err) + + return sse.Error, nil + } + + anHourAgo := time.Now().Add(-time.Hour) + + t.Run("case=error with nil flow defaults to error ui redirect", func(t *testing.T) { + t.Cleanup(reset) + + flowError = herodot.ErrInternalServerError.WithReason("system error") + methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) + + sse, _ := expectErrorUI(t) + assertx.EqualAsJSON(t, flowError, sse) + }) + + t.Run("case=error with nil flow detects application/json", func(t *testing.T) { + t.Cleanup(reset) + + flowError = herodot.ErrInternalServerError.WithReason("system error") + methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink) + + res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error")) + require.NoError(t, err) + defer res.Body.Close() + assert.Contains(t, res.Header.Get("Content-Type"), "application/json") + assert.NotContains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=") + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "system error") + }) + for _, tc := range []struct { n string t flow.Type @@ -156,7 +414,6 @@ func TestHandleError(t *testing.T) { } assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), string(body)) assert.NotEqual(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String()) - }) t.Run("case=validation error", func(t *testing.T) { @@ -265,7 +522,6 @@ func TestHandleError(t *testing.T) { }) t.Run("case=new flow uses strategy of old flow", func(t *testing.T) { - t.Cleanup(reset) recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "code"} @@ -278,7 +534,6 @@ func TestHandleError(t *testing.T) { }) t.Run("case=new flow uses current strategy if strategy of old flow does not exist", func(t *testing.T) { - t.Cleanup(reset) recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "not-valid"} @@ -291,7 +546,6 @@ func TestHandleError(t *testing.T) { }) t.Run("case=fails to retry flow if recovery strategy id is not valid", func(t *testing.T) { - t.Cleanup(func() { reset() conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "code") From 2a0b92a57fae2b2db5c61082a7a8cb0d848b0930 Mon Sep 17 00:00:00 2001 From: aeneasr <3372410+aeneasr@users.noreply.github.com> Date: Tue, 14 Nov 2023 11:03:52 +0100 Subject: [PATCH 25/25] chore: synchronize workspaces --- driver/registry_default_recovery.go | 2 +- internal/testhelpers/selfservice_verification.go | 13 +++++++------ ...i-case=fails_if_active_strategy_is_disabled.json | 4 ++-- ...y_flow_if_recovery_strategy_id_is_not_valid.json | 2 +- ...a-case=fails_if_active_strategy_is_disabled.json | 4 ++-- ...i-case=fails_if_active_strategy_is_disabled.json | 4 ++-- ...y_flow_if_recovery_strategy_id_is_not_valid.json | 2 +- ...a-case=fails_if_active_strategy_is_disabled.json | 4 ++-- 8 files changed, 18 insertions(+), 17 deletions(-) diff --git a/driver/registry_default_recovery.go b/driver/registry_default_recovery.go index b16b0b99a412..04cf24857eba 100644 --- a/driver/registry_default_recovery.go +++ b/driver/registry_default_recovery.go @@ -48,7 +48,7 @@ func (m *RegistryDefault) GetActiveRecoveryStrategy(ctx context.Context) (recove s, err := m.RecoveryStrategies(ctx).Strategy(as) if err != nil { return nil, errors.WithStack(herodot.ErrBadRequest. - WithReasonf("The active recovery strategy %s is not enabled. Please enable it in the configuration.", as)) + WithReasonf("You attempted recovery using %s, which is not enabled or does not exist. An administrator needs to enable this recovery method.", as)) } return s, nil } diff --git a/internal/testhelpers/selfservice_verification.go b/internal/testhelpers/selfservice_verification.go index 3675bc8e6891..92bc5b191d43 100644 --- a/internal/testhelpers/selfservice_verification.go +++ b/internal/testhelpers/selfservice_verification.go @@ -41,31 +41,32 @@ func NewRecoveryUIFlowEchoServer(t *testing.T, reg driver.Registry) *httptest.Se return ts } -func GetRecoveryFlowForType(t *testing.T, client *http.Client, ts *httptest.Server, _type flow.Type) *kratos.RecoveryFlow { +func GetRecoveryFlowForType(t *testing.T, client *http.Client, ts *httptest.Server, ft flow.Type) *kratos.RecoveryFlow { publicClient := NewSDKCustomClient(ts, client) var url string - switch _type { + switch ft { case flow.TypeBrowser: url = ts.URL + recovery.RouteInitBrowserFlow case flow.TypeAPI: url = ts.URL + recovery.RouteInitAPIFlow default: - panic("unknown type") + t.Errorf("unknown type: %s", ft) + t.FailNow() } res, err := client.Get(url) require.NoError(t, err) var flowID string - - switch _type { + switch ft { case flow.TypeBrowser: flowID = res.Request.URL.Query().Get("flow") case flow.TypeAPI: flowID = gjson.GetBytes(ioutilx.MustReadAll(res.Body), "id").String() default: - panic("unknown type") + t.Errorf("unknown type: %s", ft) + t.FailNow() } require.NotEmpty(t, flowID, "expected to receive a flow id, got none. %s", ioutilx.MustReadAll(res.Body)) 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 d6c78d8b8358..f4c0270da2dc 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 @@ -60,10 +60,10 @@ "messages": [ { "id": 4000001, - "text": "The active recovery strategy code is not enabled. Please enable it in the configuration.", + "text": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method.", "type": "error", "context": { - "reason": "The active recovery strategy code is not enabled. Please enable it in the configuration." + "reason": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method." } } ] diff --git a/selfservice/flow/recovery/.snapshots/TestHandleError-flow=browser-case=fails_to_retry_flow_if_recovery_strategy_id_is_not_valid.json b/selfservice/flow/recovery/.snapshots/TestHandleError-flow=browser-case=fails_to_retry_flow_if_recovery_strategy_id_is_not_valid.json index bc2b0b525548..a9d446e87ff3 100644 --- a/selfservice/flow/recovery/.snapshots/TestHandleError-flow=browser-case=fails_to_retry_flow_if_recovery_strategy_id_is_not_valid.json +++ b/selfservice/flow/recovery/.snapshots/TestHandleError-flow=browser-case=fails_to_retry_flow_if_recovery_strategy_id_is_not_valid.json @@ -1,6 +1,6 @@ { "code": 400, "message": "The request was malformed or contained invalid parameters", - "reason": "The active recovery strategy not-valid is not enabled. Please enable it in the configuration.", + "reason": "You attempted recovery using not-valid, which is not enabled or does not exist. An administrator needs to enable this recovery method.", "status": "Bad Request" } 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 7a17de3f9374..56782eed4571 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 @@ -60,10 +60,10 @@ "messages": [ { "id": 4000001, - "text": "The active recovery strategy code is not enabled. Please enable it in the configuration.", + "text": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method.", "type": "error", "context": { - "reason": "The active recovery strategy code is not enabled. Please enable it in the configuration." + "reason": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method." } } ] 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 d6c78d8b8358..f4c0270da2dc 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 @@ -60,10 +60,10 @@ "messages": [ { "id": 4000001, - "text": "The active recovery strategy code is not enabled. Please enable it in the configuration.", + "text": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method.", "type": "error", "context": { - "reason": "The active recovery strategy code is not enabled. Please enable it in the configuration." + "reason": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method." } } ] diff --git a/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=browser-case=fails_to_retry_flow_if_recovery_strategy_id_is_not_valid.json b/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=browser-case=fails_to_retry_flow_if_recovery_strategy_id_is_not_valid.json index bc2b0b525548..a9d446e87ff3 100644 --- a/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=browser-case=fails_to_retry_flow_if_recovery_strategy_id_is_not_valid.json +++ b/selfservice/flow/recovery/.snapshots/TestHandleError_WithContinueWith-flow=browser-case=fails_to_retry_flow_if_recovery_strategy_id_is_not_valid.json @@ -1,6 +1,6 @@ { "code": 400, "message": "The request was malformed or contained invalid parameters", - "reason": "The active recovery strategy not-valid is not enabled. Please enable it in the configuration.", + "reason": "You attempted recovery using not-valid, which is not enabled or does not exist. An administrator needs to enable this recovery method.", "status": "Bad Request" } 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 7a17de3f9374..56782eed4571 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 @@ -60,10 +60,10 @@ "messages": [ { "id": 4000001, - "text": "The active recovery strategy code is not enabled. Please enable it in the configuration.", + "text": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method.", "type": "error", "context": { - "reason": "The active recovery strategy code is not enabled. Please enable it in the configuration." + "reason": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method." } } ]