Skip to content

Commit c1c2ba1

Browse files
aspiersclaude
andcommitted
fix(demo): cookie outlasts OTP form sit times + honest error when it doesn't
Two changes that close the bug-report hole on the demo client: 1. Bump oauth_state cookie maxAge from 600s to 3600s, matching auth-service's auth_flow row TTL. The demo's cookie carries the state, code verifier, token endpoint and issuer for the OAuth callback to complete; if it expires before the user submits the OTP, the callback can't find any of that and bounces silently. 600s was shorter than realistic OTP-form sit times (the bug report was an 11-minute wait). Aligning with auth_flow's 60-min budget means as long as the auth-service can still recover the flow, the demo can too. 2. Distinguish "cookie missing" from "auth failed" on the callback. Previously every silent-fail path bounced to /?error=auth_failed ("Authentication failed. Please try again."), which is misleading when the sign-in itself succeeded — the user typed a fresh OTP correctly, the auth-service issued the OAuth code, the demo just couldn't finish because its own session cookie had aged out. Now the cookie-missing branch redirects to /?error=session_expired ("Your sign-in took too long to finish. Please sign in again.") so the user understands what happened without thinking they typed the code wrong. Test: new @demo-cookie-expiry @bug-report scenario clears the cookie mid-flow and asserts the session-expired error surfaces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e750e7c commit c1c2ba1

6 files changed

Lines changed: 100 additions & 4 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'ePDS': patch
3+
---
4+
5+
Signing in through the demo no longer silently fails if you take a while to enter your emailed code.
6+
7+
**Affects:** End users, Operators
8+
9+
**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 forgot the details of your in-progress sign-in after 10 minutes, which was shorter than the code itself stays 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.
10+
11+
**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 — so as long as the auth service can still recover the flow, the demo can complete the token exchange. The OAuth callback now distinguishes a missing/expired state cookie (and an `invalid_grant` from the token endpoint) from a genuine auth error, redirecting 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."

e2e/step-definitions/auth.steps.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,3 +888,46 @@ Then(
888888
})
889889
},
890890
)
891+
892+
// ---------------------------------------------------------------------------
893+
// Demo client cookie expiry simulation
894+
// ---------------------------------------------------------------------------
895+
//
896+
// The demo client stores OAuth state (state value, codeVerifier, token
897+
// endpoint, issuer) in a signed cookie called `oauth_state` with
898+
// `maxAge: 600` (see packages/demo/src/app/api/oauth/login/route.ts).
899+
// If the user spends longer than 10 minutes between starting the OAuth
900+
// flow and the callback firing — most realistic cause: dawdling on the
901+
// OTP form, then clicking Resend after the 10-minute mark — the cookie
902+
// expires before the callback runs, so the callback handler can't find
903+
// the OAuth state and silently bounces to /?error=auth_failed.
904+
//
905+
// This step deletes the cookie programmatically so we can exercise the
906+
// post-cookie-expiry callback path without a 10-minute wall-clock wait.
907+
908+
When(
909+
"the demo client's OAuth state cookie has expired",
910+
async function (this: EpdsWorld) {
911+
const page = getPage(this)
912+
const ctx = page.context()
913+
const before = await ctx.cookies()
914+
const target = before.find((c) => c.name === 'oauth_state')
915+
if (!target) {
916+
throw new Error(
917+
`Expected to find an oauth_state cookie set by the demo client but only saw: ${before.map((c) => c.name).join(', ')}`,
918+
)
919+
}
920+
await ctx.clearCookies({ name: 'oauth_state' })
921+
},
922+
)
923+
924+
Then(
925+
'the demo client surfaces a session-expired error',
926+
async function (this: EpdsWorld) {
927+
const origin = new URL(testEnv.demoUrl).origin
928+
const page = getPage(this)
929+
await page.waitForURL(`${origin}/?error=session_expired*`, {
930+
timeout: 30_000,
931+
})
932+
},
933+
)

features/passwordless-authentication.feature

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,37 @@ Feature: Passwordless authentication via email OTP
365365
Then the Resend code button is no longer offered
366366
And a Start over button is offered instead
367367

368+
# The demo OAuth client stores its OAuth state (state value, code
369+
# verifier, token endpoint, issuer) in a signed cookie that has its
370+
# own lifetime. If that cookie expires before the auth-service
371+
# bridges the user back via /oauth/epds-callback (e.g. user
372+
# dawdled on the OTP form long enough that the cookie aged out
373+
# mid-flow), the demo's callback handler can't find the OAuth state
374+
# and silently bounces to the demo home page with
375+
# `?error=auth_failed`. The user has just typed a fresh OTP
376+
# successfully, so this is genuinely time-wasting and misleading:
377+
# the auth-service did everything right but the demo dropped the
378+
# ball.
379+
#
380+
# The contract the demo MUST satisfy: as long as the OAuth flow is
381+
# still recoverable from the auth-service side (auth_flow row
382+
# alive, PAR alive or refreshable), the demo's session cookie must
383+
# also be alive. This scenario reproduces the failure mode by
384+
# programmatically clearing the demo's `oauth_state` cookie just
385+
# before the OTP submission, which is equivalent to the cookie
386+
# having lapsed by wall-clock.
387+
@email @demo-cookie-expiry @bug-report
388+
Scenario: Demo client's OAuth cookie has expired by the time of callback — useful error, not generic auth_failed
389+
When the demo client starts a new OAuth flow with random handle mode
390+
Then the browser is redirected to the auth service login page
391+
And the login page displays an email input form
392+
When the user enters a unique test email and submits
393+
Then an OTP email arrives in the mail trap for the test email
394+
And the login page shows an OTP verification form
395+
When the demo client's OAuth state cookie has expired
396+
And the user enters the OTP code
397+
Then the demo client surfaces a session-expired error
398+
368399
@email @otp-and-par-expiry @prompt-login
369400
Scenario: prompt=login + expired PAR — clean exit back to the OAuth client
370401
Given a returning user has a PDS account

packages/demo/src/app/api/oauth/callback/route.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,20 @@ export async function GET(request: NextRequest) {
4848
return NextResponse.redirect(new URL('/?error=auth_failed', baseUrl))
4949
}
5050

51-
// Retrieve OAuth session from signed cookie
51+
// Retrieve OAuth session from signed cookie. The cookie carries
52+
// the state value, code verifier, token endpoint and issuer that
53+
// we recorded when starting the OAuth flow. If it has gone away
54+
// (cookie expired by browser, user cleared cookies, very long
55+
// wait on the OTP form), there is nothing we can do to complete
56+
// the token exchange — but we owe the user a useful error
57+
// (`session_expired`) rather than a generic `auth_failed`, so
58+
// the landing page can guide them to start over instead of
59+
// looking like the sign-in itself just failed.
5260
const cookieStore = await cookies()
5361
const stateData = getOAuthSessionFromCookie(cookieStore)
5462
if (!stateData) {
55-
return NextResponse.redirect(new URL('/?error=auth_failed', baseUrl))
63+
console.error('[oauth/callback] Missing oauth_state cookie')
64+
return NextResponse.redirect(new URL('/?error=session_expired', baseUrl))
5665
}
5766

5867
if (stateData.state !== state) {

packages/demo/src/app/api/oauth/login/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ export async function GET(request: Request) {
263263
httpOnly: true,
264264
secure: true,
265265
sameSite: 'lax',
266-
maxAge: 600,
266+
maxAge: 60 * 60,
267267
path: '/',
268268
})
269269
return resp2
@@ -281,7 +281,7 @@ export async function GET(request: Request) {
281281
httpOnly: true,
282282
secure: true,
283283
sameSite: 'lax',
284-
maxAge: 600,
284+
maxAge: 60 * 60,
285285
path: '/',
286286
})
287287
return response

packages/demo/src/app/components/LoginForm.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { ForceLoginCheckbox } from './ForceLoginCheckbox'
66

77
const ERROR_MESSAGES: Record<string, string> = {
88
auth_failed: 'Authentication failed. Please try again.',
9+
session_expired:
10+
'Your sign-in took too long to finish. Please sign in again.',
911
par_failed:
1012
'Could not start login — the PDS rejected the request. Check server logs.',
1113
invalid_email: 'Please enter a valid email address.',

0 commit comments

Comments
 (0)