From 983f8133865cdbaad0f98a2b9764403b7ca2204b Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Sun, 12 Nov 2023 13:44:50 +0100 Subject: [PATCH] chore: u --- driver/config/config.go | 6 +- embedx/config.schema.json | 2 +- ...e_correct_recovery_payloads-type=api.json} | 0 ...rrect_recovery_payloads-type=browser.json} | 0 ...e_correct_recovery_payloads-type=spa.json} | 0 ...y_payloads_after_submission-type=api.json} | 0 ...yloads_after_submission-type=browser.json} | 0 ...y_payloads_after_submission-type=spa.json} | 0 ...he_correct_recovery_payloads-type=api.json | 53 -- ...orrect_recovery_payloads-type=browser.json | 53 -- ...he_correct_recovery_payloads-type=spa.json | 53 -- ...ry_payloads_after_submission-type=api.json | 85 -- ...ayloads_after_submission-type=browser.json | 85 -- ...ry_payloads_after_submission-type=spa.json | 85 -- .../strategy/code/strategy_recovery.go | 9 +- .../strategy/code/strategy_recovery_test.go | 850 +++++++++++++++++- test/e2e/cypress/support/config.d.ts | 2 +- .../e2e/playwright/tests/app_recovery.spec.ts | 4 +- 18 files changed, 850 insertions(+), 437 deletions(-) rename selfservice/strategy/code/.snapshots/{TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=api.json => TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=api.json} (100%) rename selfservice/strategy/code/.snapshots/{TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=browser.json => TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=browser.json} (100%) rename selfservice/strategy/code/.snapshots/{TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=spa.json => TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=spa.json} (100%) rename selfservice/strategy/code/.snapshots/{TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json => TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json} (100%) rename selfservice/strategy/code/.snapshots/{TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json => TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json} (100%) rename selfservice/strategy/code/.snapshots/{TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json => TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json} (100%) delete mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=api.json delete mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=browser.json delete mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=spa.json delete mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json delete mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json delete mode 100644 selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json diff --git a/driver/config/config.go b/driver/config/config.go index c1a8496be879..2bc22a75bd22 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -111,7 +111,7 @@ const ( ViperKeySessionTokenizerTemplates = "session.whoami.tokenizer.templates" ViperKeySessionWhoAmIAAL = "session.whoami.required_aal" ViperKeySessionWhoAmICaching = "feature_flags.cacheable_sessions" - ViperKeyNewFlowTransitions = "feature_flags.new_flow_transitions" + ViperKeyUseContinueWithTransitions = "feature_flags.use_continue_with_transitions" ViperKeySessionRefreshMinTimeLeft = "session.earliest_possible_extend" ViperKeyCookieSameSite = "cookies.same_site" ViperKeyCookieDomain = "cookies.domain" @@ -1298,8 +1298,8 @@ func (p *Config) SessionWhoAmICaching(ctx context.Context) bool { return p.GetProvider(ctx).Bool(ViperKeySessionWhoAmICaching) } -func (p *Config) NewFlowTransitions(ctx context.Context) bool { - return p.GetProvider(ctx).Bool(ViperKeyNewFlowTransitions) +func (p *Config) UseContinueWithTransitions(ctx context.Context) bool { + return p.GetProvider(ctx).Bool(ViperKeyUseContinueWithTransitions) } func (p *Config) SessionRefreshMinTimeLeft(ctx context.Context) time.Duration { diff --git a/embedx/config.schema.json b/embedx/config.schema.json index 30d3c469fbc5..79c1ccf3ce96 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -2618,7 +2618,7 @@ "description": "If enabled allows Ory Sessions to be cached. Only effective in the Ory Network.", "default": false }, - "new_flow_transitions": { + "use_continue_with_transitions": { "type": "boolean", "title": "Enable new flow transitions using `continue_with` items", "description": "If enabled allows new flow transitions using `continue_with` items.", diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=api.json similarity index 100% rename from selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=api.json rename to selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=api.json diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=browser.json similarity index 100% rename from selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=browser.json rename to selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=browser.json diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=spa.json similarity index 100% rename from selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads-type=spa.json rename to selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads-type=spa.json diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json similarity index 100% rename from selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json rename to selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json similarity index 100% rename from selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json rename to selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json diff --git a/selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json similarity index 100% rename from selfservice/strategy/code/.snapshots/TestRecovery-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json rename to selfservice/strategy/code/.snapshots/TestRecovery_WithContinueWith-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=api.json deleted file mode 100644 index ec1092ad77a6..000000000000 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=api.json +++ /dev/null @@ -1,53 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "node_type": "input", - "required": true, - "type": "hidden" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "email", - "node_type": "input", - "required": true, - "type": "email" - }, - "group": "code", - "messages": [], - "meta": { - "label": { - "id": 1070007, - "text": "Email", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "method", - "node_type": "input", - "type": "submit", - "value": "code" - }, - "group": "code", - "messages": [], - "meta": { - "label": { - "id": 1070005, - "text": "Submit", - "type": "info" - } - }, - "type": "input" - } -] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=browser.json deleted file mode 100644 index ec1092ad77a6..000000000000 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=browser.json +++ /dev/null @@ -1,53 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "node_type": "input", - "required": true, - "type": "hidden" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "email", - "node_type": "input", - "required": true, - "type": "email" - }, - "group": "code", - "messages": [], - "meta": { - "label": { - "id": 1070007, - "text": "Email", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "method", - "node_type": "input", - "type": "submit", - "value": "code" - }, - "group": "code", - "messages": [], - "meta": { - "label": { - "id": 1070005, - "text": "Submit", - "type": "info" - } - }, - "type": "input" - } -] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=spa.json deleted file mode 100644 index ec1092ad77a6..000000000000 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads-type=spa.json +++ /dev/null @@ -1,53 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "node_type": "input", - "required": true, - "type": "hidden" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "email", - "node_type": "input", - "required": true, - "type": "email" - }, - "group": "code", - "messages": [], - "meta": { - "label": { - "id": 1070007, - "text": "Email", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "method", - "node_type": "input", - "type": "submit", - "value": "code" - }, - "group": "code", - "messages": [], - "meta": { - "label": { - "id": 1070005, - "text": "Submit", - "type": "info" - } - }, - "type": "input" - } -] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json deleted file mode 100644 index 36fe7b033ce7..000000000000 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=api.json +++ /dev/null @@ -1,85 +0,0 @@ -[ - { - "type": "input", - "group": "default", - "attributes": { - "name": "csrf_token", - "type": "hidden", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "code", - "type": "text", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070010, - "text": "Recovery code", - "type": "info" - } - } - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "hidden", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "submit", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070005, - "text": "Submit", - "type": "info" - } - } - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "email", - "type": "submit", - "value": "test@ory.sh", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070008, - "text": "Resend code", - "type": "info" - } - } - } -] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json deleted file mode 100644 index 36fe7b033ce7..000000000000 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=browser.json +++ /dev/null @@ -1,85 +0,0 @@ -[ - { - "type": "input", - "group": "default", - "attributes": { - "name": "csrf_token", - "type": "hidden", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "code", - "type": "text", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070010, - "text": "Recovery code", - "type": "info" - } - } - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "hidden", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "submit", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070005, - "text": "Submit", - "type": "info" - } - } - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "email", - "type": "submit", - "value": "test@ory.sh", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070008, - "text": "Resend code", - "type": "info" - } - } - } -] diff --git a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json b/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json deleted file mode 100644 index 36fe7b033ce7..000000000000 --- a/selfservice/strategy/code/.snapshots/TestRecovery_WithNewFlowTransitions-description=should_set_all_the_correct_recovery_payloads_after_submission-type=spa.json +++ /dev/null @@ -1,85 +0,0 @@ -[ - { - "type": "input", - "group": "default", - "attributes": { - "name": "csrf_token", - "type": "hidden", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "code", - "type": "text", - "required": true, - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070010, - "text": "Recovery code", - "type": "info" - } - } - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "hidden", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": {} - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "method", - "type": "submit", - "value": "code", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070005, - "text": "Submit", - "type": "info" - } - } - }, - { - "type": "input", - "group": "code", - "attributes": { - "name": "email", - "type": "submit", - "value": "test@ory.sh", - "disabled": false, - "node_type": "input" - }, - "messages": [], - "meta": { - "label": { - "id": 1070008, - "text": "Resend code", - "type": "info" - } - } - } -] diff --git a/selfservice/strategy/code/strategy_recovery.go b/selfservice/strategy/code/strategy_recovery.go index c82cd81b1b26..3d373329f028 100644 --- a/selfservice/strategy/code/strategy_recovery.go +++ b/selfservice/strategy/code/strategy_recovery.go @@ -218,13 +218,14 @@ func (s *Strategy) recoveryIssueSession(w http.ResponseWriter, r *http.Request, return s.retryRecoveryFlow(w, r, f.Type, RetryWithError(err)) } - if s.deps.Config().NewFlowTransitions(ctx) { + if s.deps.Config().UseContinueWithTransitions(ctx) { switch { case f.Type.IsAPI(): + fallthrough + case x.IsJSONRequest(r): f.ContinueWith = append(f.ContinueWith, flow.NewContinueWithSettingsUI(sf)) s.deps.Writer().Write(w, r, f) - case x.IsJSONRequest(r): - s.deps.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String())) + // s.deps.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String())) default: http.Redirect(w, r, sf.AppendTo(s.deps.Config().SelfServiceFlowSettingsUI(r.Context())).String(), http.StatusSeeOther) } @@ -325,7 +326,7 @@ func (s *Strategy) retryRecoveryFlow(w http.ResponseWriter, r *http.Request, ft return err } - if s.deps.Config().NewFlowTransitions(ctx) { + if s.deps.Config().UseContinueWithTransitions(ctx) { switch { case x.IsJSONRequest(r): rErr := new(herodot.DefaultError) diff --git a/selfservice/strategy/code/strategy_recovery_test.go b/selfservice/strategy/code/strategy_recovery_test.go index 7cb152c86492..91afe5c3e149 100644 --- a/selfservice/strategy/code/strategy_recovery_test.go +++ b/selfservice/strategy/code/strategy_recovery_test.go @@ -144,7 +144,818 @@ func TestRecovery(t *testing.T) { conf, reg := internal.NewFastRegistryWithMocks(t) testhelpers.StrategyEnable(t, conf, string(recovery.RecoveryStrategyCode), true) testhelpers.StrategyEnable(t, conf, string(recovery.RecoveryStrategyLink), false) - conf.MustSet(ctx, config.ViperKeyNewFlowTransitions, true) + + initViper(t, ctx, conf) + + _ = testhelpers.NewRecoveryUIFlowEchoServer(t, reg) + _ = testhelpers.NewLoginUIFlowEchoServer(t, reg) + _ = testhelpers.NewSettingsUIFlowEchoServer(t, reg) + _ = testhelpers.NewErrorTestServer(t, reg) + + public, _, _, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg) + + submitRecovery := func(t *testing.T, client *http.Client, flowType ClientType, values func(url.Values), code int) string { + isSPA := flowType == RecoveryClientTypeSPA + isAPI := flowType == RecoveryClientTypeAPI + if client == nil { + client = testhelpers.NewDebugClient(t) + if !isAPI { + client = testhelpers.NewClientWithCookies(t) + client.Transport = testhelpers.NewTransportWithLogger(http.DefaultTransport, t).RoundTripper + } + } + + expectedUrl := testhelpers.ExpectURL(isAPI || isSPA, public.URL+recovery.RouteSubmitFlow, conf.SelfServiceFlowRecoveryUI(ctx).String()) + return testhelpers.SubmitRecoveryForm(t, isAPI, isSPA, client, public, values, code, expectedUrl) + } + + submitRecoveryCode := func(t *testing.T, client *http.Client, flow string, flowType ClientType, recoveryCode string, statusCode int) string { + action := gjson.Get(flow, "ui.action").String() + assert.NotEmpty(t, action) + + values := withCSRFToken(t, flowType, flow, url.Values{ + "code": {recoveryCode}, + "method": {"code"}, + }) + + contentType := "application/json" + if flowType == RecoveryClientTypeBrowser { + contentType = "application/x-www-form-urlencoded" + } + + res, err := client.Post(action, contentType, bytes.NewBufferString(values)) + require.NoError(t, err) + assert.Equal(t, statusCode, res.StatusCode) + + return string(ioutilx.MustReadAll(res.Body)) + } + + resendRecoveryCode := func(t *testing.T, client *http.Client, flow string, flowType ClientType, statusCode int) string { + action := gjson.Get(flow, "ui.action").String() + assert.NotEmpty(t, action) + + email := gjson.Get(flow, "ui.nodes.#(attributes.name==email).attributes.value").String() + + values := withCSRFToken(t, flowType, flow, url.Values{ + "method": {"code"}, + "email": {email}, + }) + + contentType := "application/json" + if flowType == RecoveryClientTypeBrowser { + contentType = "application/x-www-form-urlencoded" + } + + res, err := client.Post(action, contentType, bytes.NewBufferString(values)) + require.NoError(t, err) + assert.Equal(t, statusCode, res.StatusCode) + + return string(ioutilx.MustReadAll(res.Body)) + } + + expectValidationError := func(t *testing.T, hc *http.Client, flowType ClientType, values func(url.Values)) string { + code := testhelpers.ExpectStatusCode(flowType == RecoveryClientTypeAPI || flowType == RecoveryClientTypeSPA, http.StatusBadRequest, http.StatusOK) + return submitRecovery(t, hc, flowType, values, code) + } + + expectSuccessfulRecovery := func(t *testing.T, hc *http.Client, flowType ClientType, values func(url.Values)) string { + code := testhelpers.ExpectStatusCode(flowType == RecoveryClientTypeAPI || flowType == RecoveryClientTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) + return submitRecovery(t, hc, flowType, values, code) + } + + ExpectVerfiableAddressStatus := func(t *testing.T, email string, status identity.VerifiableAddressStatus) { + addr, err := reg.IdentityPool(). + FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, email) + assert.NoError(t, err) + assert.Equal(t, status, addr.Status, "verifiable address %s was not %s. instead %", email, status, addr.Status) + } + + t.Run("description=should recover an account", func(t *testing.T) { + checkRecovery := func(t *testing.T, client *http.Client, flowType ClientType, recoveryEmail, recoverySubmissionResponse string) string { + ExpectVerfiableAddressStatus(t, recoveryEmail, identity.VerifiableAddressStatusPending) + + assert.EqualValues(t, node.CodeGroup, gjson.Get(recoverySubmissionResponse, "active").String(), "%s", recoverySubmissionResponse) + assert.True(t, gjson.Get(recoverySubmissionResponse, "ui.nodes.#(attributes.name==code)").Exists(), "%s", recoverySubmissionResponse) + assert.Len(t, gjson.Get(recoverySubmissionResponse, "ui.messages").Array(), 1, "%s", recoverySubmissionResponse) + assertx.EqualAsJSON(t, text.NewRecoveryEmailWithCodeSent(), json.RawMessage(gjson.Get(recoverySubmissionResponse, "ui.messages.0").Raw)) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + assert.Contains(t, message.Body, "please recover access to your account by entering the following code") + + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + assert.NotEmpty(t, recoveryCode) + + statusCode := testhelpers.ExpectStatusCode(flowType == RecoveryClientTypeAPI || flowType == RecoveryClientTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) + return submitRecoveryCode(t, client, recoverySubmissionResponse, flowType, recoveryCode, statusCode) + } + + t.Run("type=browser", func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + email := "recoverme1@ory.sh" + createIdentityToRecover(t, reg, email) + recoverySubmissionResponse := submitRecovery(t, client, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) + body := checkRecovery(t, client, RecoveryClientTypeBrowser, email, recoverySubmissionResponse) + + assert.Equal(t, text.NewRecoverySuccessful(time.Now().Add(time.Hour)).Text, + gjson.Get(body, "ui.messages.0.text").String()) + + res, err := client.Get(public.URL + session.RouteWhoami) + require.NoError(t, err) + body = string(x.MustReadAll(res.Body)) + require.NoError(t, res.Body.Close()) + assert.Equal(t, "code_recovery", gjson.Get(body, "authentication_methods.0.method").String(), "%s", body) + assert.Equal(t, "aal1", gjson.Get(body, "authenticator_assurance_level").String(), "%s", body) + }) + + t.Run("type=spa", func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + email := "recoverme3@ory.sh" + createIdentityToRecover(t, reg, email) + recoverySubmissionResponse := submitRecovery(t, client, RecoveryClientTypeSPA, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) + body := checkRecovery(t, client, RecoveryClientTypeSPA, email, recoverySubmissionResponse) + assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) + assert.Contains(t, gjson.Get(body, "redirect_browser_to").String(), "settings-ts?") + }) + + t.Run("type=api", func(t *testing.T) { + client := &http.Client{} + email := "recoverme4@ory.sh" + createIdentityToRecover(t, reg, email) + recoverySubmissionResponse := submitRecovery(t, client, RecoveryClientTypeAPI, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) + body := checkRecovery(t, client, RecoveryClientTypeAPI, email, recoverySubmissionResponse) + assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) + assert.Contains(t, gjson.Get(body, "redirect_browser_to").String(), "settings-ts?") + }) + + t.Run("description=should return browser to return url", func(t *testing.T) { + returnTo := public.URL + "/return-to" + conf.Set(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTo}) + for _, tc := range []struct { + desc string + returnTo string + f func(t *testing.T, client *http.Client, identity *identity.Identity) *kratos.RecoveryFlow + expectedAAL string + }{ + { + desc: "should use return_to from recovery flow", + returnTo: returnTo, + f: func(t *testing.T, client *http.Client, identity *identity.Identity) *kratos.RecoveryFlow { + return testhelpers.InitializeRecoveryFlowViaBrowser(t, client, false, public, url.Values{"return_to": []string{returnTo}}) + }, + }, + { + desc: "should use return_to from config", + returnTo: returnTo, + f: func(t *testing.T, client *http.Client, identity *identity.Identity) *kratos.RecoveryFlow { + conf.Set(ctx, config.ViperKeySelfServiceRecoveryBrowserDefaultReturnTo, returnTo) + t.Cleanup(func() { + conf.Set(ctx, config.ViperKeySelfServiceRecoveryBrowserDefaultReturnTo, "") + }) + return testhelpers.InitializeRecoveryFlowViaBrowser(t, client, false, public, nil) + }, + }, + { + desc: "no return to", + returnTo: "", + f: func(t *testing.T, client *http.Client, identity *identity.Identity) *kratos.RecoveryFlow { + return testhelpers.InitializeRecoveryFlowViaBrowser(t, client, false, public, nil) + }, + }, + { + desc: "should use return_to with an account that has 2fa enabled", + returnTo: returnTo, + f: func(t *testing.T, client *http.Client, id *identity.Identity) *kratos.RecoveryFlow { + conf.Set(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL) + conf.Set(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL) + conf.Set(ctx, config.ViperKeyWebAuthnRPDisplayName, "Kratos") + conf.Set(ctx, config.ViperKeyWebAuthnRPID, "ory.sh") + + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, identity.AuthenticatorAssuranceLevel1) + conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, identity.AuthenticatorAssuranceLevel1) + }) + testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeWebAuthn.String(), true) + + id.SetCredentials(identity.CredentialsTypeWebAuthn, identity.Credentials{ + Type: identity.CredentialsTypeWebAuthn, + Config: []byte(`{"credentials":[{"is_passwordless":false, "display_name":"test"}]}`), + Identifiers: []string{testhelpers.RandomEmail()}, + }) + + require.NoError(t, reg.IdentityManager().Update(ctx, id, identity.ManagerAllowWriteProtectedTraits)) + return testhelpers.InitializeRecoveryFlowViaBrowser(t, client, false, public, url.Values{"return_to": []string{returnTo}}) + }, + expectedAAL: "aal2", + }, + } { + t.Run(fmt.Sprintf("%s", tc.desc), func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + email := testhelpers.RandomEmail() + i := createIdentityToRecover(t, reg, email) + + client.Transport = testhelpers.NewTransportWithLogger(http.DefaultTransport, t).RoundTripper + f := tc.f(t, client, i) + + formPayload := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + formPayload.Set("email", email) + + body, res := testhelpers.RecoveryMakeRequest(t, false, f, client, formPayload.Encode()) + assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body) + expectedURL := testhelpers.ExpectURL(false, public.URL+recovery.RouteSubmitFlow, conf.SelfServiceFlowRecoveryUI(ctx).String()) + assert.Contains(t, res.Request.URL.String(), expectedURL, "%+v\n\t%s", res.Request, body) + + body = checkRecovery(t, client, RecoveryClientTypeBrowser, email, body) + + require.Equal(t, text.NewRecoverySuccessful(time.Now().Add(time.Hour)).Text, + gjson.Get(body, "ui.messages.0.text").String()) + + settingsId := gjson.Get(body, "id").String() + + sf, err := reg.SettingsFlowPersister().GetSettingsFlow(ctx, uuid.Must(uuid.FromString(settingsId))) + require.NoError(t, err) + + u, err := url.Parse(public.URL) + require.NoError(t, err) + require.Len(t, client.Jar.Cookies(u), 2) + found := false + for _, cookie := range client.Jar.Cookies(u) { + if cookie.Name == "ory_kratos_session" { + found = true + } + } + require.True(t, found) + + require.Equal(t, tc.returnTo, sf.ReturnTo) + res, err = client.Get(public.URL + session.RouteWhoami) + require.NoError(t, err) + body = string(x.MustReadAll(res.Body)) + require.NoError(t, res.Body.Close()) + + if tc.expectedAAL == "aal2" { + require.Equal(t, http.StatusForbidden, res.StatusCode) + require.Equalf(t, session.NewErrAALNotSatisfied("").Reason(), gjson.Get(body, "error.reason").String(), "%s", body) + require.Equalf(t, "session_aal2_required", gjson.Get(body, "error.id").String(), "%s", body) + } else { + assert.Equal(t, "code_recovery", gjson.Get(body, "authentication_methods.0.method").String(), "%s", body) + assert.Equal(t, "aal1", gjson.Get(body, "authenticator_assurance_level").String(), "%s", body) + } + }) + } + }) + }) + + t.Run("description=should set all the correct recovery payloads after submission", func(t *testing.T) { + body := expectSuccessfulRecovery(t, nil, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", "test@ory.sh") + }) + testhelpers.SnapshotTExcept(t, json.RawMessage(gjson.Get(body, "ui.nodes").String()), []string{"0.attributes.value"}) + }) + + t.Run("description=should set all the correct recovery payloads", func(t *testing.T) { + c := testhelpers.NewClientWithCookies(t) + rs := testhelpers.GetRecoveryFlow(t, c, public) + + testhelpers.SnapshotTExcept(t, rs.Ui.Nodes, []string{"0.attributes.value"}) + assert.EqualValues(t, public.URL+recovery.RouteSubmitFlow+"?flow="+rs.Id, rs.Ui.Action) + assert.Empty(t, rs.Ui.Messages) + }) + + t.Run("description=should require an email to be sent", func(t *testing.T) { + for _, flowType := range flowTypes { + t.Run("type="+string(flowType), func(t *testing.T) { + body := expectValidationError(t, nil, flowType, func(v url.Values) { + v.Del("email") + }) + assert.EqualValues(t, node.CodeGroup, gjson.Get(body, "active").String(), "%s", body) + assert.EqualValues(t, "Property email is missing.", + gjson.Get(body, "ui.nodes.#(attributes.name==email).messages.0.text").String(), + "%s", body) + }) + } + }) + + t.Run("description=should require a valid email to be sent", func(t *testing.T) { + for _, flowType := range flowTypes { + for _, email := range []string{"\\", "asdf", "...", "aiacobelli.sec@gmail.com,alejandro.iacobelli@mercadolibre.com"} { + t.Run("type="+string(flowType), func(t *testing.T) { + responseJSON := expectValidationError(t, nil, flowType, func(v url.Values) { + v.Set("email", email) + }) + activeMethod := gjson.Get(responseJSON, "active").String() + assert.EqualValues(t, node.CodeGroup, activeMethod, "expected method to be %s got %s", node.CodeGroup, activeMethod) + expectedMessage := fmt.Sprintf("%q is not valid \"email\"", email) + actualMessage := gjson.Get(responseJSON, "ui.nodes.#(attributes.name==email).messages.0.text").String() + assert.EqualValues(t, expectedMessage, actualMessage, "%s", responseJSON) + }) + } + } + }) + + t.Run("description=should try to submit the form while authenticated", func(t *testing.T) { + for _, flowType := range flowTypes { + t.Run("type="+string(flowType), func(t *testing.T) { + isSPA := flowType == "spa" + isAPI := flowType == "api" + client := testhelpers.NewDebugClient(t) + if !isAPI { + client = testhelpers.NewClientWithCookies(t) + client.Transport = testhelpers.NewTransportWithLogger(http.DefaultTransport, t).RoundTripper + } + + var f *kratos.RecoveryFlow + if isAPI { + f = testhelpers.InitializeRecoveryFlowViaAPI(t, client, public) + } else { + f = testhelpers.InitializeRecoveryFlowViaBrowser(t, client, isSPA, public, nil) + } + req := httptest.NewRequest("GET", "/sessions/whoami", nil) + + session, err := session.NewActiveSession( + req, + &identity.Identity{ID: x.NewUUID(), State: identity.StateActive}, + testhelpers.NewSessionLifespanProvider(time.Hour), + time.Now(), + identity.CredentialsTypePassword, + identity.AuthenticatorAssuranceLevel1, + ) + + require.NoError(t, err) + + // Add the authentication to the request + client.Transport = testhelpers.NewTransportWithLogger(testhelpers.NewAuthorizedTransport(t, reg, session), t).RoundTripper + + v := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + v.Set("email", "some-email@example.org") + v.Set("method", "code") + + body, res := testhelpers.RecoveryMakeRequest(t, isAPI || isSPA, f, client, testhelpers.EncodeFormAsJSON(t, isAPI || isSPA, v)) + + if isAPI || isSPA { + assert.EqualValues(t, http.StatusBadRequest, res.StatusCode, "%s", body) + assert.Contains(t, res.Request.URL.String(), recovery.RouteSubmitFlow, "%+v\n\t%s", res.Request, body) + assertx.EqualAsJSONExcept(t, recovery.ErrAlreadyLoggedIn, json.RawMessage(gjson.Get(body, "error").Raw), nil) + } else { + assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", body) + assert.Contains(t, res.Request.URL.String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%+v\n\t%s", res.Request, body) + } + }) + } + }) + + t.Run("description=should not be able to recover account that does not exist", func(t *testing.T) { + conf.Set(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, true) + + t.Cleanup(func() { + conf.Set(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, false) + }) + + check := func(t *testing.T, c *http.Client, flowType ClientType, email string) { + withValues := func(v url.Values) { + v.Set("email", email) + } + body := submitRecovery(t, c, flowType, withValues, http.StatusOK) + assert.EqualValues(t, node.CodeGroup, gjson.Get(body, "active").String(), "%s", body) + assert.Empty(t, gjson.Get(body, "ui.nodes.#(attributes.name==code).attributes.value").String(), "%s", body) + assertx.EqualAsJSON(t, text.NewRecoveryEmailWithCodeSent(), json.RawMessage(gjson.Get(body, "ui.messages.0").Raw)) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Account access attempted") + assert.Contains(t, message.Body, "If this was you, check if you signed up using a different address.") + } + + t.Run("type=browser", func(t *testing.T) { + email := "recover_browser@ory.sh" + c := browserHttpClient(t) + check(t, c, RecoveryClientTypeBrowser, email) + }) + + t.Run("type=spa", func(t *testing.T) { + email := "recover_spa@ory.sh" + c := spaHttpClient(t) + check(t, c, RecoveryClientTypeSPA, email) + }) + + t.Run("type=api", func(t *testing.T) { + email := "recover_api@ory.sh" + c := apiHttpClient(t) + check(t, c, RecoveryClientTypeAPI, email) + }) + }) + + t.Run("description=should not be able to recover an inactive account", func(t *testing.T) { + for _, flowType := range flowTypeCases { + t.Run("type="+string(flowType.ClientType), func(t *testing.T) { + email := "recoverinactive_" + string(flowType.ClientType) + "@ory.sh" + createIdentityToRecover(t, reg, email) + values := func(v url.Values) { + v.Set("email", email) + } + cl := testhelpers.NewClientWithCookies(t) + + body := submitRecovery(t, cl, flowType.ClientType, values, http.StatusOK) + addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, email) + assert.NoError(t, err) + + emailText := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, emailText, 1) + + // Deactivate the identity + require.NoError(t, reg.Persister().GetConnection(context.Background()).RawQuery("UPDATE identities SET state=? WHERE id = ?", identity.StateInactive, addr.IdentityID).Exec()) + + if flowType.ClientType == RecoveryClientTypeAPI || flowType.ClientType == RecoveryClientTypeSPA { + body = submitRecoveryCode(t, cl, body, flowType.ClientType, recoveryCode, http.StatusUnauthorized) + assertx.EqualAsJSON(t, session.ErrIdentityDisabled.WithDetail("identity_id", addr.IdentityID), json.RawMessage(gjson.Get(body, "error").Raw), "%s", body) + } else { + body = submitRecoveryCode(t, cl, body, flowType.ClientType, recoveryCode, http.StatusOK) + assertx.EqualAsJSON(t, session.ErrIdentityDisabled.WithDetail("identity_id", addr.IdentityID), json.RawMessage(body), "%s", body) + } + }) + } + }) + + t.Run("description=should recover and invalidate all other sessions if hook is set", func(t *testing.T) { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "revoke_active_sessions"}}) + t.Cleanup(func() { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil) + }) + + email := testhelpers.RandomEmail() + id := createIdentityToRecover(t, reg, email) + + req := httptest.NewRequest("GET", "/sessions/whoami", nil) + sess, err := session.NewActiveSession(req, id, conf, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1) + require.NoError(t, err) + require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), sess)) + + actualSession, err := reg.SessionPersister().GetSession(context.Background(), sess.ID, session.ExpandNothing) + require.NoError(t, err) + assert.True(t, actualSession.IsActive()) + + cl := testhelpers.NewClientWithCookies(t) + actual := expectSuccessfulRecovery(t, cl, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", email) + }) + message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + cl.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + action := gjson.Get(actual, "ui.action").String() + require.NotEmpty(t, action) + csrf_token := gjson.Get(actual, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String() + require.NotEmpty(t, csrf_token) + + submitRecoveryCode(t, cl, actual, RecoveryClientTypeBrowser, recoveryCode, http.StatusSeeOther) + + require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) + cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.Contains(t, cookies, "ory_kratos_session") + + actualSession, err = reg.SessionPersister().GetSession(context.Background(), sess.ID, session.ExpandNothing) + require.NoError(t, err) + assert.False(t, actualSession.IsActive()) + }) + + t.Run("description=should not be able to use an invalid code more than 5 times", func(t *testing.T) { + email := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, email) + c := testhelpers.NewClientWithCookies(t) + body := submitRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) + initialFlowId := gjson.Get(body, "id") + + for submitTry := 0; submitTry < 5; submitTry++ { + body := submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, "12312312", http.StatusOK) + + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + } + + // submit an invalid code for the 6th time + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, "12312312", http.StatusOK) + + require.Len(t, gjson.Get(body, "ui.messages").Array(), 1) + assert.Equal(t, "The request was submitted too often. Please request another code.", gjson.Get(body, "ui.messages.0.text").String()) + + // check that a new flow has been created + assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + + assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==email)").Exists()) + }) + + t.Run("description=should be able to recover after using invalid code", func(t *testing.T) { + for _, testCase := range flowTypeCases { + t.Run("type="+testCase.ClientType.String(), func(t *testing.T) { + c := testCase.GetClient(t) + recoveryEmail := testhelpers.RandomEmail() + _ = createIdentityToRecover(t, reg, recoveryEmail) + + actual := submitRecovery(t, c, testCase.ClientType, func(v url.Values) { + v.Set("email", recoveryEmail) + }, http.StatusOK) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + form := withCSRFToken(t, testCase.ClientType, actual, url.Values{ + "code": {"12312312"}, + }) + + action := gjson.Get(actual, "ui.action").String() + require.NotEmpty(t, action) + + res, err := c.Post(action, testCase.FormContentType, bytes.NewBufferString(form)) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + + flowId := gjson.Get(actual, "id").String() + require.NotEmpty(t, flowId) + + rs, res, err := testhelpers. + NewSDKCustomClient(public, c). + FrontendApi.GetRecoveryFlow(context.Background()). + Id(flowId). + Execute() + + require.NoError(t, err) + body := ioutilx.MustReadAll(res.Body) + require.NotEmpty(t, body) + + require.Len(t, rs.Ui.Messages, 1) + assert.Equal(t, "The recovery code is invalid or has already been used. Please try again.", rs.Ui.Messages[0].Text) + + form = withCSRFToken(t, testCase.ClientType, actual, url.Values{ + "code": {recoveryCode}, + }) + // Now submit the correct code + res, err = c.Post(action, testCase.FormContentType, bytes.NewBufferString(form)) + require.NoError(t, err) + if testCase.ClientType == RecoveryClientTypeBrowser { + assert.Equal(t, http.StatusOK, res.StatusCode) + + json := ioutilx.MustReadAll(res.Body) + + assert.Len(t, gjson.GetBytes(json, "ui.messages").Array(), 1) + assert.Contains(t, gjson.GetBytes(json, "ui.messages.0.text").String(), "You successfully recovered your account.") + } else if testCase.ClientType == RecoveryClientTypeSPA { + assert.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) + + json := ioutilx.MustReadAll(res.Body) + + assert.Equal(t, gjson.GetBytes(json, "error.id").String(), "browser_location_change_required") + assert.Contains(t, gjson.GetBytes(json, "redirect_browser_to").String(), "settings-ts?") + } + }) + } + }) + + t.Run("description=should not be able to use an invalid code", func(t *testing.T) { + email := "recoverme+invalid_code@ory.sh" + createIdentityToRecover(t, reg, email) + c := testhelpers.NewClientWithCookies(t) + + body := submitRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", email) + }, http.StatusOK) + + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, "12312312", http.StatusOK) + + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + }) + + t.Run("description=should not be able to submit recover address after flow expired", func(t *testing.T) { + recoveryEmail := "recoverme5@ory.sh" + createIdentityToRecover(t, reg, recoveryEmail) + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Millisecond*200) + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Minute) + }) + + c := testhelpers.NewClientWithCookies(t) + rs := testhelpers.GetRecoveryFlow(t, c, public) + + time.Sleep(time.Millisecond * 201) + + res, err := c.PostForm(rs.Ui.Action, url.Values{"email": {recoveryEmail}}) + require.NoError(t, err) + assert.EqualValues(t, http.StatusOK, res.StatusCode) + assert.NotContains(t, res.Request.URL.String(), "flow="+rs.Id) + assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String()) + + addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) + assert.NoError(t, err) + assert.False(t, addr.Verified) + assert.Nil(t, addr.VerifiedAt) + assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status) + }) + + t.Run("description=should not be able to submit code after flow expired", func(t *testing.T) { + recoveryEmail := "recoverme6@ory.sh" + createIdentityToRecover(t, reg, recoveryEmail) + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Millisecond*200) + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Minute) + }) + + c := testhelpers.NewClientWithCookies(t) + + body := expectSuccessfulRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + initialFlowId := gjson.Get(body, "id") + + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + assert.Contains(t, message.Body, "please recover access to your account by entering the following code") + + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + time.Sleep(time.Millisecond * 201) + + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, recoveryCode, http.StatusOK) + + assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + + testhelpers.AssertMessage(t, []byte(body), "The recovery flow expired 0.00 minutes ago, please try again.") + + addr, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.VerifiableAddressTypeEmail, recoveryEmail) + require.NoError(t, err) + assert.False(t, addr.Verified) + assert.Nil(t, addr.VerifiedAt) + assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status) + }) + + t.Run("description=should not break ui if empty code is submitted", func(t *testing.T) { + recoveryEmail := "recoverme7@ory.sh" + createIdentityToRecover(t, reg, recoveryEmail) + + c := testhelpers.NewClientWithCookies(t) + body := expectSuccessfulRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, "", http.StatusOK) + + assert.NotContains(t, gjson.Get(body, "ui.nodes").String(), "Property email is missing.") + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + }) + + t.Run("description=should be able to re-send the recovery code", func(t *testing.T) { + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) + + c := testhelpers.NewClientWithCookies(t) + body := expectSuccessfulRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + body = resendRecoveryCode(t, c, body, RecoveryClientTypeBrowser, http.StatusOK) + assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==code)").Exists()) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, recoveryCode, http.StatusOK) + }) + + t.Run("description=should not be able to use first code after re-sending email", func(t *testing.T) { + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) + + c := testhelpers.NewClientWithCookies(t) + body := expectSuccessfulRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + message1 := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode1 := testhelpers.CourierExpectCodeInMessage(t, message1, 1) + + body = resendRecoveryCode(t, c, body, RecoveryClientTypeBrowser, http.StatusOK) + assert.True(t, gjson.Get(body, "ui.nodes.#(attributes.name==code)").Exists()) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + message2 := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode2 := testhelpers.CourierExpectCodeInMessage(t, message2, 1) + + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, recoveryCode1, http.StatusOK) + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + + // For good measure, check that the second code works! + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, recoveryCode2, http.StatusOK) + testhelpers.AssertMessage(t, []byte(body), "You successfully recovered your account. Please change your password or set up an alternative login method (e.g. social sign in) within the next 60.00 minutes.") + }) + + t.Run("description=should not show outdated validation message if newer message appears #2799", func(t *testing.T) { + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) + + c := testhelpers.NewClientWithCookies(t) + body := expectSuccessfulRecovery(t, c, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + body = submitRecoveryCode(t, c, body, RecoveryClientTypeBrowser, "12312312", http.StatusOK) // Now send a wrong code that triggers "global" validation error + + assert.Empty(t, gjson.Get(body, "ui.nodes.#(attributes.name==code).messages").Array()) + testhelpers.AssertMessage(t, []byte(body), "The recovery code is invalid or has already been used. Please try again.") + }) + + t.Run("description=should recover if post recovery hook is successful", func(t *testing.T) { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}}) + t.Cleanup(func() { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), nil) + }) + + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) + + cl := testhelpers.NewClientWithCookies(t) + body := expectSuccessfulRecovery(t, cl, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + cl.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + body = submitRecoveryCode(t, cl, body, RecoveryClientTypeBrowser, recoveryCode, http.StatusSeeOther) + + require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) + cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.Contains(t, cookies, "ory_kratos_session") + }) + + t.Run("description=should not be able to recover if post recovery hook fails", func(t *testing.T) { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostRecoveryHook": "err"}`)}}) + t.Cleanup(func() { + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), nil) + }) + + recoveryEmail := testhelpers.RandomEmail() + createIdentityToRecover(t, reg, recoveryEmail) + + cl := testhelpers.NewClientWithCookies(t) + body := expectSuccessfulRecovery(t, cl, RecoveryClientTypeBrowser, func(v url.Values) { + v.Set("email", recoveryEmail) + }) + + message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account") + recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) + + action := gjson.Get(body, "ui.action").String() + require.NotEmpty(t, action) + assert.Equal(t, recoveryEmail, gjson.Get(body, "ui.nodes.#(attributes.name==email).attributes.value").String()) + + cl.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + initialFlowId := gjson.Get(body, "id") + body = submitRecoveryCode(t, cl, body, RecoveryClientTypeBrowser, recoveryCode, http.StatusSeeOther) + assert.NotEqual(t, gjson.Get(body, "id"), initialFlowId) + + require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 1) + cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.NotContains(t, cookies, "ory_kratos_session") + }) +} + +func TestRecovery_WithContinueWith(t *testing.T) { + ctx := context.Background() + conf, reg := internal.NewFastRegistryWithMocks(t) + testhelpers.StrategyEnable(t, conf, string(recovery.RecoveryStrategyCode), true) + testhelpers.StrategyEnable(t, conf, string(recovery.RecoveryStrategyLink), false) + conf.MustSet(ctx, config.ViperKeyUseContinueWithTransitions, true) initViper(t, ctx, conf) @@ -237,11 +1048,13 @@ func TestRecovery(t *testing.T) { assert.Contains(t, cookies, "ory_kratos_session") require.Contains(t, body, "You successfully recovered your account. Please change your password or set up an alternative login method (e.g. social sign in) within the next 60.00 minutes.") case RecoveryClientTypeSPA: - body = submitRecoveryCode(t, c, body, clientType, recoveryCode, http.StatusUnprocessableEntity) - assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) + body = submitRecoveryCode(t, c, body, clientType, recoveryCode, http.StatusOK) + // assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) require.Len(t, c.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) cookies := spew.Sdump(c.Jar.Cookies(urlx.ParseOrPanic(public.URL))) assert.Contains(t, cookies, "ory_kratos_session") + + require.NotEmpty(t, gjson.Get(body, "continue_with.#(action==show_settings_ui).flow").String(), "%s", body) case RecoveryClientTypeAPI: body = submitRecoveryCode(t, c, body, clientType, recoveryCode, http.StatusOK) require.NotEmpty(t, gjson.Get(body, "continue_with.#(action==show_settings_ui).flow").String(), "%s", body) @@ -264,8 +1077,8 @@ func TestRecovery(t *testing.T) { recoveryCode := testhelpers.CourierExpectCodeInMessage(t, message, 1) assert.NotEmpty(t, recoveryCode) - statusCode := testhelpers.ExpectStatusCode(flowType == RecoveryClientTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) - return submitRecoveryCode(t, client, recoverySubmissionResponse, flowType, recoveryCode, statusCode) + // statusCode := testhelpers.ExpectStatusCode(flowType == RecoveryClientTypeSPA, http.StatusUnprocessableEntity, http.StatusOK) + return submitRecoveryCode(t, client, recoverySubmissionResponse, flowType, recoveryCode, http.StatusOK) } t.Run("type=browser", func(t *testing.T) { @@ -296,8 +1109,10 @@ func TestRecovery(t *testing.T) { v.Set("email", email) }, http.StatusOK) body := checkRecovery(t, client, RecoveryClientTypeSPA, email, recoverySubmissionResponse) - assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String()) - assert.Contains(t, gjson.Get(body, "redirect_browser_to").String(), "settings-ts?") + assert.Equal(t, "passed_challenge", gjson.Get(body, "state").String()) + assert.Len(t, gjson.Get(body, "continue_with").Array(), 1) + sfId := gjson.Get(body, "continue_with.#(action==show_settings_ui).flow.id").String() + assert.NotEmpty(t, uuid.Must(uuid.FromString(sfId))) }) t.Run("type=api", func(t *testing.T) { @@ -729,20 +1544,31 @@ func TestRecovery(t *testing.T) { // Now submit the correct code res, err = c.Post(action, testCase.FormContentType, bytes.NewBufferString(form)) require.NoError(t, err) - if testCase.ClientType == RecoveryClientTypeBrowser { + switch testCase.ClientType { + case RecoveryClientTypeBrowser: assert.Equal(t, http.StatusOK, res.StatusCode) json := ioutilx.MustReadAll(res.Body) assert.Len(t, gjson.GetBytes(json, "ui.messages").Array(), 1) assert.Contains(t, gjson.GetBytes(json, "ui.messages.0.text").String(), "You successfully recovered your account.") - } else if testCase.ClientType == RecoveryClientTypeSPA { - assert.Equal(t, http.StatusUnprocessableEntity, res.StatusCode) + case RecoveryClientTypeSPA: + assert.Equal(t, http.StatusOK, res.StatusCode) json := ioutilx.MustReadAll(res.Body) - assert.Equal(t, gjson.GetBytes(json, "error.id").String(), "browser_location_change_required") - assert.Contains(t, gjson.GetBytes(json, "redirect_browser_to").String(), "settings-ts?") + require.Len(t, c.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2) + cookies := spew.Sdump(c.Jar.Cookies(urlx.ParseOrPanic(public.URL))) + assert.Contains(t, cookies, "ory_kratos_session") + + require.NotEmpty(t, gjson.GetBytes(json, "continue_with.#(action==show_settings_ui).flow").String(), "%s", json) + case RecoveryClientTypeAPI: + assert.Equal(t, http.StatusOK, res.StatusCode) + + json := ioutilx.MustReadAll(res.Body) + + require.NotEmpty(t, gjson.GetBytes(json, "continue_with.#(action==show_settings_ui).flow").String(), "%s", json) + require.NotEmpty(t, gjson.GetBytes(json, "continue_with.#(action==set_ory_session_token).ory_session_token").String(), "%s", json) } }) } diff --git a/test/e2e/cypress/support/config.d.ts b/test/e2e/cypress/support/config.d.ts index 5f73f6626e8e..060cb12822bf 100644 --- a/test/e2e/cypress/support/config.d.ts +++ b/test/e2e/cypress/support/config.d.ts @@ -1334,5 +1334,5 @@ export interface GlobalHTTPClientConfiguration { } export interface FeatureFlags { cacheable_sessions?: EnableOrySessionsCaching - new_flow_transitions?: EnableNewFlowTransitionsUsingContinueWithItems + use_continue_with_transitions?: EnableNewFlowTransitionsUsingContinueWithItems } diff --git a/test/e2e/playwright/tests/app_recovery.spec.ts b/test/e2e/playwright/tests/app_recovery.spec.ts index 1629b6c3923d..629abd3c05bc 100644 --- a/test/e2e/playwright/tests/app_recovery.spec.ts +++ b/test/e2e/playwright/tests/app_recovery.spec.ts @@ -23,7 +23,7 @@ test.describe("Recovery", () => { ...schemaConfig, }, feature_flags: { - new_flow_transitions: true, + use_continue_with_transitions: true, }, }, }) @@ -115,7 +115,7 @@ test.describe("Recovery", () => { }, }, feature_flags: { - new_flow_transitions: true, + use_continue_with_transitions: true, }, }, })