Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/demo-cookie-outlasts-otp-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'ePDS': patch
---

Signing in through the demo no longer silently fails if you take a while to enter your emailed code.

**Affects:** End users, Operators

**End users:** If you requested a sign-in code, then took several minutes to fetch it from your email before entering it, the demo could fail at the last step with a generic "Authentication failed" message — even though your code was correct. That happened because the demo started its 10-minute sign-in timer before it sent the code, so it could forget the sign-in while a later-issued code was still valid. The demo now remembers your sign-in for up to an hour, and if it genuinely does time out you now see "Your sign-in took too long to finish. Please sign in again." instead of a message that made it look like you typed the code wrong.

**Operators:** The demo client's `oauth_state` cookie `maxAge` is raised from `600` (10 min) to `60 * 60` (1 hour), matching the auth service's `auth_flow` row TTL. Previously, the cookie's timer started when the OAuth flow began, while the OTP's 600-second validity started only when the code was issued, so the cookie could expire first. The OAuth callback now maps a missing/expired state cookie to `/?error=session_expired` rather than `/?error=auth_failed`; `session_expired` renders as "Your sign-in took too long to finish. Please sign in again."
9 changes: 9 additions & 0 deletions .changeset/keep-sign-in-alive-and-recover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Sign-in now stays active while you retrieve your emailed code and offers a clear way to restart if it times out.

**Affects:** End users

**End users:** Previously, taking more than about five minutes to enter a code could leave the surrounding sign-in expired even though the code was still valid. The code-entry page now keeps the sign-in active while it remains open. If browser suspension or a longer interruption still makes the sign-in unrecoverable, the page disables code entry and **Use different email**, hides **Resend code**, and shows **Start over**; clicking it returns to the app you came from so you can begin again.
87 changes: 87 additions & 0 deletions e2e/step-definitions/auth.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,3 +842,90 @@ Then(
}
},
)

// ---------------------------------------------------------------------------
// Resend-button visibility (fix for the "fresh OTP wasted on dead PAR" UX)
// ---------------------------------------------------------------------------
//
// The page never offers an action that cannot complete the flow. When the
// PAR is dead, the standalone Resend button is removed from view and a
// Start over button takes its place — clicking it bails to /auth/abort
// rather than issuing an OTP that would only fail downstream. The steps
// below trigger the page's reactive ping (via the visibilitychange
// handler that fires on tab-foreground) so it can observe the dead PAR
// and reconcile the UI without a 5-minute wall-clock wait.

When('the OTP form re-checks PAR liveness', async function (this: EpdsWorld) {
const page = getPage(this)
// Drive the page's reactive ping via a string-source script.
// Using page.evaluate(() => ...) inlines esbuild's __name helper,
// which then fails in Playwright's evaluation context with
// "ReferenceError: __name is not defined". Passing a string
// bypasses the bundler.
await page.evaluate(`(function () {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: function () { return 'visible' },
})
document.dispatchEvent(new Event('visibilitychange'))
})()`)
Comment thread
aspiers marked this conversation as resolved.
})

Then(
'the Resend code button is no longer offered',
async function (this: EpdsWorld) {
const page = getPage(this)
await expect(page.locator('#btn-resend')).toBeHidden({ timeout: 5_000 })
},
)

Then(
'a Start over button is offered instead',
async function (this: EpdsWorld) {
const page = getPage(this)
await expect(page.locator('#btn-start-over')).toBeVisible({
timeout: 5_000,
})
},
)

// ---------------------------------------------------------------------------
// Demo client cookie expiry simulation
// ---------------------------------------------------------------------------
//
// The demo client stores OAuth state (state value, codeVerifier, token
// endpoint, issuer) in a signed cookie called `oauth_state` with
// `maxAge: 60 * 60` (1 hour; see
// packages/demo/src/app/api/oauth/login/route.ts). If the cookie expires
// or is cleared before the callback runs, the callback handler can't find
// the OAuth state and redirects to /?error=session_expired.
//
// This step deletes the cookie programmatically so we can exercise the
// post-cookie-expiry callback path without a wall-clock wait.

When(
"the demo client's OAuth state cookie has expired",
async function (this: EpdsWorld) {
const page = getPage(this)
const ctx = page.context()
const before = await ctx.cookies()
const hasOAuthStateCookie = before.some((c) => c.name === 'oauth_state')
if (!hasOAuthStateCookie) {
throw new Error(
`Expected to find an oauth_state cookie set by the demo client but only saw: ${before.map((c) => c.name).join(', ')}`,
)
}
await ctx.clearCookies({ name: 'oauth_state' })
Comment thread
aspiers marked this conversation as resolved.
},
Comment thread
aspiers marked this conversation as resolved.
Comment thread
aspiers marked this conversation as resolved.
)

Then(
'the demo client surfaces a session-expired error',
async function (this: EpdsWorld) {
const origin = new URL(testEnv.demoUrl).origin
const page = getPage(this)
await page.waitForURL(`${origin}/?error=session_expired*`, {
timeout: 30_000,
})
},
)
53 changes: 53 additions & 0 deletions features/passwordless-authentication.feature
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,59 @@ Feature: Passwordless authentication via email OTP
And the user requests a new OTP via the resend button
Then the browser lands back at the demo client with an auth error

# The page never offers actions that cannot complete the flow. When
# the upstream PAR has died (silent timeout, suspended tab,
# heartbeat throttling), the standalone Resend button is removed
# from view and replaced with a Start over button — so the user
# never wastes time typing a fresh OTP that could not have worked.
# This is the proactive complement to @resend-after-par-dead's
# reactive abort gate: rather than letting the click happen and
# bouncing it server-side, we surface only forward paths that can
# actually succeed.
@email @otp-and-par-expiry @resend-hidden-when-par-dead
Scenario: Resend button is hidden when the PAR has died — Start over is offered instead
When the demo client initiates an OAuth login
Then the browser is redirected to the auth service login page
And the login page displays an email input form
When the user enters a unique test email and submits
Then an OTP email arrives in the mail trap for the test email
And the login page shows an OTP verification form
When the PAR request_uri has expired before the bridge fires
And the OTP form re-checks PAR liveness
Then the Resend code button is no longer offered
And a Start over button is offered instead

# The demo OAuth client stores its OAuth state (state value, code
# verifier, token endpoint, issuer) in a signed cookie that has its
# own lifetime. If that cookie expires before the auth-service
# bridges the user back via /oauth/epds-callback (e.g. user
# dawdled on the OTP form long enough that the cookie aged out
# mid-flow), the demo's callback handler can't find the OAuth state
# and silently bounces to the demo home page with
# `?error=auth_failed`. The user has just typed a fresh OTP
# successfully, so this is genuinely time-wasting and misleading:
# the auth-service did everything right but the demo dropped the
# ball.
#
# The contract the demo MUST satisfy: as long as the OAuth flow is
# still recoverable from the auth-service side (auth_flow row
# alive, PAR alive or refreshable), the demo's session cookie must
# also be alive. This scenario reproduces the failure mode by
# programmatically clearing the demo's `oauth_state` cookie just
# before the OTP submission, which is equivalent to the cookie
# having lapsed by wall-clock.
@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
Then the browser is redirected to the auth service login page
And the login page displays an email input form
When the user enters a unique test email and submits
Then an OTP email arrives in the mail trap for the test email
And the login page shows an OTP verification form
When the demo client's OAuth state cookie has expired
And the user enters the OTP code
Then the demo client surfaces a session-expired error

@email @otp-and-par-expiry @prompt-login
Scenario: prompt=login + expired PAR — clean exit back to the OAuth client
Given a returning user has a PDS account
Expand Down
26 changes: 26 additions & 0 deletions packages/auth-service/src/__tests__/login-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,11 +674,37 @@ describe('renderLoginPage flow-aborted notice + reactive abort gates', () => {
const fnStart = html.indexOf('function showFlowAbortedNotice()')
const fnEnd = html.indexOf('function abortIfFlowDead', fnStart)
const fnBody = html.slice(fnStart, fnEnd)
expect(fnBody).toContain("startOverBtn.id = 'btn-start-over'")
expect(fnBody).toContain("startOverBtn.textContent = 'Start over'")
// No innerHTML — same XSS guard as the inline-action button.
expect(fnBody).not.toContain('innerHTML')
})

it('keeps the abort notice as the only Start over action', () => {
const html = renderDefault()
const noticeStart = html.indexOf('function showFlowAbortedNotice()')
const refreshStart = html.indexOf('function refreshResendVisibility()')
const noticeBody = html.slice(noticeStart, refreshStart)
const fnStart = html.indexOf('function refreshResendVisibility()')
const fnEnd = html.indexOf('function abortIfFlowDead', fnStart)
const fnBody = html.slice(fnStart, fnEnd)
const abortedGuard = fnBody.indexOf('if (flowAborted)')
const createAction = fnBody.indexOf("document.createElement('button')")

expect(noticeBody).toContain(
'standaloneStartOverBtn.parentNode.removeChild(standaloneStartOverBtn)',
)
expect(abortedGuard).toBeGreaterThan(0)
expect(fnBody.slice(abortedGuard, createAction)).toContain(
"startOverLink.className === 'btn-secondary'",
)
expect(fnBody.slice(abortedGuard, createAction)).toContain(
'startOverLink.parentNode.removeChild(startOverLink)',
)
expect(fnBody.slice(abortedGuard, createAction)).toContain('return;')
expect(createAction).toBeGreaterThan(abortedGuard)
})

it('triggers the proactive notice when the heartbeat reports a non-transient ok:false', () => {
const html = renderDefault()
// The pingHeartbeat handler must call showFlowAbortedNotice
Expand Down
Loading
Loading