diff --git a/.changeset/friendlier-stale-link-errors.md b/.changeset/friendlier-stale-link-errors.md new file mode 100644 index 00000000..38992abe --- /dev/null +++ b/.changeset/friendlier-stale-link-errors.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +Stale sign-in and account-recovery links now explain how to restart. + +**Affects:** End users + +**End users:** Return to the app you were signing into and start again when either page reports that the old link no longer belongs to an active sign-in flow. diff --git a/e2e/step-definitions/account-recovery.steps.ts b/e2e/step-definitions/account-recovery.steps.ts index 870d4224..41ad6403 100644 --- a/e2e/step-definitions/account-recovery.steps.ts +++ b/e2e/step-definitions/account-recovery.steps.ts @@ -329,3 +329,39 @@ Then( await assertNoEmailFor(this.backupEmail) }, ) + +// --------------------------------------------------------------------------- +// Stale-recovery-link UX +// --------------------------------------------------------------------------- + +When( + 'the user navigates directly to the recovery page without an active sign-in', + async function (this: EpdsWorld) { + const page = getPage(this) + await page.goto(`${testEnv.authUrl}/auth/recover`) + }, +) + +Then( + 'the page explains that recovery has to start from the sign-in page', + async function (this: EpdsWorld) { + const page = getPage(this) + await expect(page.locator('body')).toContainText( + /recovery has to be started from the sign-in page/i, + { timeout: 10_000 }, + ) + }, +) + +Then( + 'the page does not mention the technical field name {string}', + async function (this: EpdsWorld, fieldName: string) { + const page = getPage(this) + const body = await page.locator('body').innerText() + if (body.toLowerCase().includes(fieldName.toLowerCase())) { + throw new Error( + `Expected the page to not surface the technical field name "${fieldName}", but its body text contained it. Page body: ${body}`, + ) + } + }, +) diff --git a/e2e/step-definitions/auth.steps.ts b/e2e/step-definitions/auth.steps.ts index 71abc40f..a75eb457 100644 --- a/e2e/step-definitions/auth.steps.ts +++ b/e2e/step-definitions/auth.steps.ts @@ -969,3 +969,26 @@ Then('the email input is empty and focused', async function (this: EpdsWorld) { await expect(input).toHaveValue('', { timeout: 5_000 }) await expect(input).toBeFocused({ timeout: 5_000 }) }) + +// --------------------------------------------------------------------------- +// Stale-authorization-link UX +// --------------------------------------------------------------------------- + +When( + 'the user navigates directly to the authorize page without an active sign-in', + async function (this: EpdsWorld) { + const page = getPage(this) + await page.goto(`${testEnv.authUrl}/oauth/authorize`) + }, +) + +Then( + 'the page explains that sign-in has to start from the app', + async function (this: EpdsWorld) { + const page = getPage(this) + await expect(page.locator('body')).toContainText( + /sign-in has to be started from the app/i, + { timeout: 10_000 }, + ) + }, +) diff --git a/features/account-recovery.feature b/features/account-recovery.feature index 4ded73d1..27fbdf24 100644 --- a/features/account-recovery.feature +++ b/features/account-recovery.feature @@ -39,6 +39,18 @@ Feature: Account recovery via backup emails Then the recovery OTP form is displayed And no email arrives for that non-existent address + # /auth/recover requires an active sign-in flow (it carries a + # request_uri that points at the upstream PAR). Hitting the URL + # directly — typically by following a stale link or pasting from + # somewhere — used to surface "Missing request_uri parameter", + # which leaks the internal OAuth field name and tells the user + # nothing actionable. + @stale-link + Scenario: Direct visit to recovery URL surfaces a friendly explanation, not a technical field name + When the user navigates directly to the recovery page without an active sign-in + Then the page explains that recovery has to start from the sign-in page + And the page does not mention the technical field name "request_uri" + # --- Backup email management --- Scenario: User removes a backup email diff --git a/features/passwordless-authentication.feature b/features/passwordless-authentication.feature index 09858b62..69f291ff 100644 --- a/features/passwordless-authentication.feature +++ b/features/passwordless-authentication.feature @@ -403,6 +403,14 @@ Feature: Passwordless authentication via email OTP When the user clicks "Use different email" Then the email input is empty and focused + # Direct visits to /oauth/authorize have no active request. Explain + # how to restart instead of exposing the internal request_uri field. + @stale-link + Scenario: Direct visit to /oauth/authorize surfaces a friendly explanation + When the user navigates directly to the authorize page without an active sign-in + Then the page explains that sign-in has to start from the app + And the page does not mention the technical field name "request_uri" + @email @demo-cookie-expiry @bug-report Scenario: Demo client's OAuth cookie has expired by the time of callback — useful error, not generic auth_failed When the demo client starts a new OAuth flow with random handle mode diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 5bfb41ba..1d783752 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -168,10 +168,19 @@ export function createLoginPageRouter(ctx: AuthServiceContext): Router { const clientId = req.query.client_id as string | undefined const loginHint = req.query.login_hint as string | undefined if (!requestUri) { + // The user landed here without an active sign-in flow — + // typically a stale link or direct visit. The technical + // "Missing request_uri parameter" tells them nothing + // actionable; surface the honest, useful message instead. res .status(400) .type('html') - .send(renderError('Missing request_uri parameter')) + .send( + renderError( + 'Sign-in has to be started from the app you are signing into. Please return to that app and try again.', + { title: 'No active sign-in' }, + ), + ) return } diff --git a/packages/auth-service/src/routes/recovery.ts b/packages/auth-service/src/routes/recovery.ts index 70504f41..8a5d0968 100644 --- a/packages/auth-service/src/routes/recovery.ts +++ b/packages/auth-service/src/routes/recovery.ts @@ -76,10 +76,19 @@ export function createRecoveryRouter( const requestUri = req.query.request_uri as string | undefined if (!requestUri) { + // The user landed here without an active sign-in flow — + // typically a stale link or direct visit. The technical + // "Missing request_uri parameter" tells them nothing + // actionable; surface the honest, useful message instead. res .status(400) .type('html') - .send(renderError('Missing request_uri parameter')) + .send( + renderError( + 'Account recovery has to be started from the sign-in page. Please sign in again from the app you came from.', + { title: 'No active sign-in' }, + ), + ) return }