Skip to content

Commit dcc9590

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 82c09af commit dcc9590

8 files changed

Lines changed: 166 additions & 5 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/__tests__/session.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {
55
createUserSessionCookie,
66
getUserSessionFromCookie,
77
getSessionFromCookie,
8+
resolveCallbackErrorCode,
89
OAUTH_COOKIE,
10+
OAUTH_COOKIE_MAX_AGE_SECONDS,
911
SESSION_COOKIE,
1012
} from '../lib/session'
1113
import type { OAuthSession, UserSession } from '../lib/session'
@@ -92,3 +94,27 @@ describe('getSessionFromCookie', () => {
9294
expect(getSessionFromCookie(store)).toEqual(sampleUserSession)
9395
})
9496
})
97+
98+
describe('OAUTH_COOKIE_MAX_AGE_SECONDS', () => {
99+
it('is one hour, long enough to outlast a realistic email-code wait', () => {
100+
expect(OAUTH_COOKIE_MAX_AGE_SECONDS).toBe(60 * 60)
101+
})
102+
103+
it('outlasts the previous 10-minute value that could expire mid-flow', () => {
104+
expect(OAUTH_COOKIE_MAX_AGE_SECONDS).toBeGreaterThan(600)
105+
})
106+
})
107+
108+
describe('resolveCallbackErrorCode', () => {
109+
it('maps a missing oauth_state cookie to session_expired', () => {
110+
expect(resolveCallbackErrorCode({ oauthCookiePresent: false })).toBe(
111+
'session_expired',
112+
)
113+
})
114+
115+
it('maps a present cookie (any other failure) to auth_failed', () => {
116+
expect(resolveCallbackErrorCode({ oauthCookiePresent: true })).toBe(
117+
'auth_failed',
118+
)
119+
})
120+
})

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { cookies } from 'next/headers'
2525
import {
2626
getOAuthSessionFromCookie,
2727
createUserSessionCookie,
28+
resolveCallbackErrorCode,
2829
OAUTH_COOKIE,
2930
} from '@/lib/session'
3031
import { sanitizeForLog } from '@/lib/validation'
@@ -48,11 +49,21 @@ export async function GET(request: NextRequest) {
4849
return NextResponse.redirect(new URL('/?error=auth_failed', baseUrl))
4950
}
5051

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

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

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ import {
2929
resolveDidToPds,
3030
discoverOAuthEndpoints,
3131
} from '@/lib/auth'
32-
import { createOAuthSessionCookie } from '@/lib/session'
32+
import {
33+
createOAuthSessionCookie,
34+
OAUTH_COOKIE_MAX_AGE_SECONDS,
35+
} from '@/lib/session'
3336
import { signClientAssertion } from '@/lib/client-jwk'
3437
import { validateEmail, validateHandle, sanitizeForLog } from '@/lib/validation'
3538

@@ -263,7 +266,7 @@ export async function GET(request: Request) {
263266
httpOnly: true,
264267
secure: true,
265268
sameSite: 'lax',
266-
maxAge: 600,
269+
maxAge: OAUTH_COOKIE_MAX_AGE_SECONDS,
267270
path: '/',
268271
})
269272
return resp2
@@ -281,7 +284,7 @@ export async function GET(request: Request) {
281284
httpOnly: true,
282285
secure: true,
283286
sameSite: 'lax',
284-
maxAge: 600,
287+
maxAge: OAUTH_COOKIE_MAX_AGE_SECONDS,
285288
path: '/',
286289
})
287290
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.',

packages/demo/src/lib/session.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,40 @@ function verifyPayload(signed: string): string | null {
7272

7373
const OAUTH_COOKIE = 'oauth_state'
7474

75+
/**
76+
* Lifetime of the `oauth_state` cookie, in seconds.
77+
*
78+
* The cookie carries everything the callback needs to finish the
79+
* token exchange (state, code verifier, token endpoint, issuer). It
80+
* must outlive a realistic sign-in: the user requests an email code,
81+
* fetches it from their inbox, and only then submits — a wait that
82+
* can run to several minutes. 600s (10 min) was shorter than that
83+
* and shorter than the code's own validity, so a slow-but-correct
84+
* sign-in could fail at the last step with nothing to complete
85+
* against. One hour matches the auth service's `auth_flow` row TTL:
86+
* as long as the auth service can still recover the flow, the demo
87+
* can too.
88+
*/
89+
export const OAUTH_COOKIE_MAX_AGE_SECONDS = 60 * 60
90+
91+
/**
92+
* Error code the OAuth callback landing page should show when the
93+
* flow can't be completed. Split out as a pure function so the
94+
* mapping is unit-testable without standing up a Next request.
95+
*
96+
* `session_expired` is reserved for the case where the sign-in
97+
* itself succeeded but *our* `oauth_state` cookie has gone away
98+
* (expired, cleared, or a wait longer than its lifetime) — there is
99+
* nothing to exchange the code against, but the user did nothing
100+
* wrong, so an honest "took too long" beats a generic "auth failed".
101+
* Every other failure maps to `auth_failed`.
102+
*/
103+
export function resolveCallbackErrorCode(reason: {
104+
oauthCookiePresent: boolean
105+
}): 'session_expired' | 'auth_failed' {
106+
return reason.oauthCookiePresent ? 'auth_failed' : 'session_expired'
107+
}
108+
75109
export function createOAuthSessionCookie(data: OAuthSession): {
76110
name: string
77111
value: string

0 commit comments

Comments
 (0)