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 d4a8a65a13a3..e631ab4a813f 100644 --- a/internal/client-go/api_frontend.go +++ b/internal/client-go/api_frontend.go @@ -246,7 +246,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 @@ -777,8 +777,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 @@ -5013,8 +5013,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 d4a8a65a13a3..e631ab4a813f 100644 --- a/internal/httpclient/api_frontend.go +++ b/internal/httpclient/api_frontend.go @@ -246,7 +246,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 @@ -777,8 +777,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 @@ -5013,8 +5013,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 25d3a55b175b..6ed21970179e 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,25 +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( @@ -63,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.NewInfoNodeLabelVerifyOTP()), - ) - - 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 @@ -374,9 +179,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) @@ -391,7 +204,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) } if err := s.deps.RecoveryExecutor().PostRecoveryHook(w, r, f, sess); err != nil { @@ -405,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..ace184e62696 --- /dev/null +++ b/selfservice/strategy/code/strategy_recovery_admin.go @@ -0,0 +1,217 @@ +// 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 + } + + 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.NewInfoNodeLabelVerifyOTP()), + ) + + 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) +} 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 ab9c0a691209..10a440351db0 100644 --- a/selfservice/strategy/code/strategy_recovery_test.go +++ b/selfservice/strategy/code/strategy_recovery_test.go @@ -17,7 +17,6 @@ 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" @@ -29,8 +28,6 @@ import ( "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" @@ -45,7 +42,6 @@ import ( "github.com/ory/kratos/identity" "github.com/ory/kratos/internal" "github.com/ory/kratos/internal/testhelpers" - "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/recovery" "github.com/ory/kratos/selfservice/strategy/code" "github.com/ory/kratos/text" @@ -60,178 +56,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" @@ -340,6 +164,7 @@ func TestRecovery(t *testing.T) { } var 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) @@ -416,13 +241,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) @@ -442,7 +267,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) @@ -454,14 +279,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 3d694a3acc01..8f650a98b4c4 100755 --- a/spec/api.json +++ b/spec/api.json @@ -435,6 +435,7 @@ "discriminator": { "mapping": { "set_ory_session_token": "#/components/schemas/continueWithSetOrySessionToken", + "show_settings_ui": "#/components/schemas/continueWithSettingsUi", "show_verification_ui": "#/components/schemas/continueWithVerificationUi" }, "propertyName": "action" @@ -445,6 +446,9 @@ }, { "$ref": "#/components/schemas/continueWithSetOrySessionToken" + }, + { + "$ref": "#/components/schemas/continueWithSettingsUi" } ] }, @@ -452,13 +456,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", @@ -471,17 +476,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" @@ -1456,6 +1498,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", @@ -5161,7 +5209,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": [ { @@ -5261,7 +5309,7 @@ "description": "errorGeneric" } }, - "summary": "Complete Recovery Flow", + "summary": "Update Recovery Flow", "tags": [ "frontend" ] @@ -5269,7 +5317,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 142650176c4c..2002b7cdcafc 100755 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -1785,7 +1785,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" @@ -1800,7 +1800,7 @@ "tags": [ "frontend" ], - "summary": "Complete Recovery Flow", + "summary": "Update Recovery Flow", "operationId": "updateRecoveryFlow", "parameters": [ { @@ -1870,7 +1870,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" @@ -3407,13 +3407,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", @@ -3421,6 +3422,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", @@ -3430,13 +3467,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" @@ -4393,6 +4431,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 663b48e04de3..f1451f1b556e 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..12a8434deadd --- /dev/null +++ b/test/e2e/playwright/actions/mail.ts @@ -0,0 +1,21 @@ +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..ddc18288be2e --- /dev/null +++ b/test/e2e/playwright/fixtures/index.ts @@ -0,0 +1,54 @@ +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 8486dc910d42..91dc4c9d7c74 100644 --- a/test/e2e/playwright/kratos.base-config.json +++ b/test/e2e/playwright/kratos.base-config.json @@ -12,15 +12,8 @@ "base_url": "http://localhost:4455/", "cors": { "enabled": true, - "allowed_origins": [ - "http://localhost:3000", - "http://localhost:4457" - ], - "allowed_headers": [ - "Authorization", - "Content-Type", - "X-Session-Token" - ] + "allowed_origins": ["http://localhost:3000", "http://localhost:4457"], + "allowed_headers": ["Authorization", "Content-Type", "X-Session-Token"] } } }, @@ -29,12 +22,8 @@ "leak_sensitive_values": true }, "secrets": { - "cookie": [ - "PLEASE-CHANGE-ME-I-AM-VERY-INSECURE" - ], - "cipher": [ - "secret-thirty-two-character-long" - ] + "cookie": ["PLEASE-CHANGE-ME-I-AM-VERY-INSECURE"], + "cipher": ["secret-thirty-two-character-long"] }, "selfservice": { "default_browser_return_url": "http://localhost:4455/", @@ -69,9 +58,7 @@ "client_id": "client_id", "client_secret": "client_secret", "issuer_url": "http://localhost:4444/", - "scope": [ - "offline" - ], + "scope": ["offline"], "mapper_url": "file://test/e2e/profiles/oidc/hydra.jsonnet" } ] @@ -123,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..90f2cbc385aa --- /dev/null +++ b/test/e2e/playwright/lib/helper.ts @@ -0,0 +1,17 @@ +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 2617df23ae06..db009cdb9270 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..89e0826b548b --- /dev/null +++ b/test/e2e/playwright/tests/app_recovery.spec.ts @@ -0,0 +1,48 @@ +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