Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/friendlier-stale-link-errors.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions e2e/step-definitions/account-recovery.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
)
}
},
)
23 changes: 23 additions & 0 deletions e2e/step-definitions/auth.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
)
},
)
12 changes: 12 additions & 0 deletions features/account-recovery.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions features/passwordless-authentication.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion packages/auth-service/src/routes/login-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
11 changes: 10 additions & 1 deletion packages/auth-service/src/routes/recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading