From e750e7c805ab91089fa43274f9f355dae624e603 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 7 May 2026 03:36:25 +0000 Subject: [PATCH 1/2] fix(auth-service): hide Resend on OTP screen when sign-in cannot recover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the OTP screen always offered "Resend code", even when the upstream PAR row had silently lapsed (suspended tab, mobile background, heartbeat throttling). The user could click Resend, receive a fresh email, type the new code, and only then see "Sign in failed" — wasting their time on a code that could not have worked. The screen now never surfaces actions that cannot complete the flow: - Track lastSuccessfulHeartbeatAt; treat the PAR as dead once we cross upstream's 5 min AUTHORIZATION_INACTIVITY_TIMEOUT without a fresh ok ping (the upstream death point is exact — no margin needed). - Hide #btn-resend and surface a #btn-start-over (→ /auth/abort) the moment parLikelyDead() flips. Reconciled on every heartbeat tick (including transient ticks, so a stale-by-time case still hides the button) and on the visibilitychange event (so a backgrounded tab returning to focus reflects reality immediately). - Inline "Send a new code" action on the OTP-expired error now branches: parLikelyDead() → "Start over"; otherwise existing "Send a new code". This is the proactive UI complement to the existing reactive abort gate. Server-side enforcement of the same invariant on /email-otp/send- verification-otp and /sign-in/email-otp is a separate follow-up. Test: new @resend-hidden-when-par-dead scenario; full @otp-and-par-expiry / @par-heartbeat / @resend-after-par-dead / @otp-expiry suite still passes (7 scenarios, 78 steps). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...hide-resend-when-sign-in-cannot-recover.md | 13 +++ e2e/step-definitions/auth.steps.ts | 46 ++++++++ features/passwordless-authentication.feature | 22 ++++ .../auth-service/src/routes/login-page.ts | 105 ++++++++++++++++-- 4 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 .changeset/hide-resend-when-sign-in-cannot-recover.md diff --git a/.changeset/hide-resend-when-sign-in-cannot-recover.md b/.changeset/hide-resend-when-sign-in-cannot-recover.md new file mode 100644 index 00000000..3bc48815 --- /dev/null +++ b/.changeset/hide-resend-when-sign-in-cannot-recover.md @@ -0,0 +1,13 @@ +--- +'ePDS': patch +--- + +Sign-in no longer offers "Resend code" when the new code wouldn't have worked anyway. + +**Affects:** End users + +**End users:** Previously, if you sat on the email-code step long enough that the underlying sign-in had silently timed out (most often: leaving the tab in the background while reading email on your phone, or coming back after an interruption), the page would still show **Resend code**. Clicking it sent you a fresh email, but the moment you typed the new code you'd see "Sign in failed" — the code was issued for a sign-in that could no longer complete, so it never had a chance. + +The page now hides the Resend button as soon as it knows the sign-in can't be recovered, and shows **Start over** in its place. Clicking Start over takes you back to the app you came from to begin again, instead of letting you waste time on a code that couldn't work. + +If you're actively using the page (the tab in the foreground), nothing changes: Resend stays available and works the same way it always has. diff --git a/e2e/step-definitions/auth.steps.ts b/e2e/step-definitions/auth.steps.ts index 66c23365..792a1dc7 100644 --- a/e2e/step-definitions/auth.steps.ts +++ b/e2e/step-definitions/auth.steps.ts @@ -842,3 +842,49 @@ 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')) + })()`) +}) + +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, + }) + }, +) diff --git a/features/passwordless-authentication.feature b/features/passwordless-authentication.feature index b0692184..ee1888d6 100644 --- a/features/passwordless-authentication.feature +++ b/features/passwordless-authentication.feature @@ -343,6 +343,28 @@ 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 + @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 diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 302d8902..b4ba42b8 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -743,17 +743,38 @@ export function renderLoginPage(opts: { var heartbeatEnabled = ${JSON.stringify(opts.heartbeatEnabled)}; var heartbeatHandle = null; var heartbeatIntervalMs = 3 * 60 * 1000; + // Upstream's AUTHORIZATION_INACTIVITY_TIMEOUT — once this much + // wall-clock time has elapsed since our last successful PAR + // refresh, the upstream row is guaranteed to be dead. Used by + // parLikelyDead() to hide Resend before the user can click it. + var parInactivityTimeoutMs = 5 * 60 * 1000; + // Page load is the implicit first PAR refresh — atproto's + // PAR_EXPIRES_IN gives a fresh row 5 min on creation, and the + // user just hit /oauth/authorize seconds ago. Treat now as + // last-known-alive until the first ping confirms otherwise. + var lastSuccessfulHeartbeatAt = Date.now(); // Set to true the moment we know the flow can no longer // complete (PAR or auth_flow gone). Resend / Verify gates // check this so a click that races the proactive notice // still bails to /auth/abort instead of issuing a fresh OTP // that would only fail. var flowAborted = false; + // True iff we have proof the PAR is still alive (last ping + // was ok:true and was recent enough to fall inside the + // upstream inactivity window). Used to gate every "offer the + // user a Resend" decision so they only ever see actions that + // can actually complete the flow. + function parLikelyDead() { + if (flowAborted) return true; + return Date.now() - lastSuccessfulHeartbeatAt >= parInactivityTimeoutMs; + } function pingHeartbeat() { return fetch('/auth/ping', { credentials: 'include', cache: 'no-store' }) .then(function(r) { return r.json(); }) .then(function(body) { - if (body && body.ok === false && body.reason !== 'transient') { + if (body && body.ok === true) { + lastSuccessfulHeartbeatAt = Date.now(); + } else if (body && body.ok === false && body.reason !== 'transient') { // Auth flow / PAR genuinely dead — no point pinging again, // and no point letting the user keep typing. 'transient' // (5xx / network blip) does NOT stop the interval; the @@ -763,7 +784,13 @@ export function renderLoginPage(opts: { } return body; }) - .catch(function() { return null; /* network blip — caller may retry */ }); + .catch(function() { return null; /* network blip — caller may retry */ }) + .finally(function() { + // Always reconcile visibility — a 'transient' tick that + // pushes us past the inactivity window must hide Resend + // even though we never got a definitive 'par_expired'. + refreshResendVisibility(); + }); } function startHeartbeat() { if (!heartbeatEnabled) return; @@ -777,6 +804,16 @@ export function renderLoginPage(opts: { } } window.addEventListener('beforeunload', stopHeartbeat); + // When the tab returns to the foreground after being hidden, + // setInterval may have been throttled enough that PAR has + // silently lapsed. Re-ping immediately so the UI reflects + // reality before the user clicks anything. + document.addEventListener('visibilitychange', function() { + if (document.visibilityState === 'visible' && heartbeatEnabled) { + pingHeartbeat(); + refreshResendVisibility(); + } + }); // Show the proactive "this won't work — start over" notice when // the flow is unrecoverable. Disables the OTP boxes, the verify @@ -819,6 +856,41 @@ export function renderLoginPage(opts: { errorEl.appendChild(startOverBtn); } + /** + * Toggle the standalone Resend button between visible and + * hidden based on whether the PAR is still alive. The button + * is removed from view (display:none) rather than just + * disabled — a button the user cannot productively click + * shouldn't be on the page at all. When hidden, a "Start over" + * link is shown in its place so the user always has a forward + * path. Idempotent — safe to call from heartbeat ticks, + * visibility change handlers, and inline render paths. + */ + function refreshResendVisibility() { + var resendBtn = document.getElementById('btn-resend'); + var startOverLink = document.getElementById('btn-start-over'); + if (!resendBtn) return; + if (parLikelyDead()) { + resendBtn.style.display = 'none'; + if (!startOverLink) { + startOverLink = document.createElement('button'); + startOverLink.type = 'button'; + startOverLink.id = 'btn-start-over'; + startOverLink.className = 'btn-secondary'; + startOverLink.textContent = 'Start over'; + startOverLink.addEventListener('click', function() { + window.location.href = '/auth/abort'; + }); + resendBtn.parentNode.insertBefore(startOverLink, resendBtn); + } + } else { + resendBtn.style.display = ''; + if (startOverLink && startOverLink.parentNode) { + startOverLink.parentNode.removeChild(startOverLink); + } + } + } + /** * Reactive gate used by the Resend and Verify click handlers. * Pings /auth/ping synchronously; if the result indicates the @@ -983,6 +1055,7 @@ export function renderLoginPage(opts: { if (otpBoxes.length) otpBoxes[0].focus(); clearError(); startHeartbeat(); + refreshResendVisibility(); } function showEmailStep() { @@ -1104,15 +1177,21 @@ export function renderLoginPage(opts: { // expired") plus generic "expir"/"too long" variants. var isExpired = /expir|too long/i.test(result.error); if (isExpired) { - // The inline action triggers the same Resend handler, - // which itself runs abortIfFlowDead() before issuing - // a new code. So even if the PAR is dead the user - // gets the spec-compliant bounce rather than a fresh - // OTP that wouldn't work — no need to gate the - // action's visibility separately here. - showErrorWithAction(result.error, 'Send a new code', function() { - document.getElementById('btn-resend').click(); - }); + // Only offer "Send a new code" when the PAR is still + // alive. If it isn't, a fresh OTP would issue but + // never complete — wasting the user's time on a code + // that can't work. Show "Start over" instead so the + // only forward path we surface is one that will + // actually succeed. + if (parLikelyDead()) { + showErrorWithAction(result.error, 'Start over', function() { + window.location.href = '/auth/abort'; + }); + } else { + showErrorWithAction(result.error, 'Send a new code', function() { + document.getElementById('btn-resend').click(); + }); + } } else { showError(result.error); } @@ -1185,8 +1264,10 @@ export function renderLoginPage(opts: { }); } // OTP form is already visible server-side; showOtpStep() never - // ran, so kick off the heartbeat ourselves. + // ran, so kick off the heartbeat ourselves and reflect the + // current PAR-liveness state in the Resend button visibility. startHeartbeat(); + refreshResendVisibility(); } })(); From c4240cdfcaa48e128f6e2832aff1448df0bc5af3 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 7 May 2026 04:08:26 +0000 Subject: [PATCH 2/2] fix(demo): cookie outlasts OTP form sit times + honest error when it doesn't MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/demo-cookie-outlasts-otp-wait.md | 11 +++++ e2e/step-definitions/auth.steps.ts | 43 +++++++++++++++++++ features/passwordless-authentication.feature | 31 +++++++++++++ packages/demo/src/__tests__/session.test.ts | 26 +++++++++++ .../demo/src/app/api/oauth/callback/route.ts | 15 ++++++- .../demo/src/app/api/oauth/login/route.ts | 9 ++-- .../demo/src/app/components/LoginForm.tsx | 2 + packages/demo/src/lib/session.ts | 34 +++++++++++++++ 8 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 .changeset/demo-cookie-outlasts-otp-wait.md diff --git a/.changeset/demo-cookie-outlasts-otp-wait.md b/.changeset/demo-cookie-outlasts-otp-wait.md new file mode 100644 index 00000000..58234b61 --- /dev/null +++ b/.changeset/demo-cookie-outlasts-otp-wait.md @@ -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 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. + +**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." diff --git a/e2e/step-definitions/auth.steps.ts b/e2e/step-definitions/auth.steps.ts index 792a1dc7..23272bf1 100644 --- a/e2e/step-definitions/auth.steps.ts +++ b/e2e/step-definitions/auth.steps.ts @@ -888,3 +888,46 @@ Then( }) }, ) + +// --------------------------------------------------------------------------- +// 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: 600` (see packages/demo/src/app/api/oauth/login/route.ts). +// If the user spends longer than 10 minutes between starting the OAuth +// flow and the callback firing — most realistic cause: dawdling on the +// OTP form, then clicking Resend after the 10-minute mark — the cookie +// expires before the callback runs, so the callback handler can't find +// the OAuth state and silently bounces to /?error=auth_failed. +// +// This step deletes the cookie programmatically so we can exercise the +// post-cookie-expiry callback path without a 10-minute 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 target = before.find((c) => c.name === 'oauth_state') + if (!target) { + 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' }) + }, +) + +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, + }) + }, +) diff --git a/features/passwordless-authentication.feature b/features/passwordless-authentication.feature index ee1888d6..20edade8 100644 --- a/features/passwordless-authentication.feature +++ b/features/passwordless-authentication.feature @@ -365,6 +365,37 @@ Feature: Passwordless authentication via email OTP 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 diff --git a/packages/demo/src/__tests__/session.test.ts b/packages/demo/src/__tests__/session.test.ts index 12928ee1..930cd502 100644 --- a/packages/demo/src/__tests__/session.test.ts +++ b/packages/demo/src/__tests__/session.test.ts @@ -5,7 +5,9 @@ import { createUserSessionCookie, getUserSessionFromCookie, getSessionFromCookie, + resolveCallbackErrorCode, OAUTH_COOKIE, + OAUTH_COOKIE_MAX_AGE_SECONDS, SESSION_COOKIE, } from '../lib/session' import type { OAuthSession, UserSession } from '../lib/session' @@ -92,3 +94,27 @@ describe('getSessionFromCookie', () => { expect(getSessionFromCookie(store)).toEqual(sampleUserSession) }) }) + +describe('OAUTH_COOKIE_MAX_AGE_SECONDS', () => { + it('is one hour, long enough to outlast a realistic email-code wait', () => { + expect(OAUTH_COOKIE_MAX_AGE_SECONDS).toBe(60 * 60) + }) + + it('outlasts the previous 10-minute value that could expire mid-flow', () => { + expect(OAUTH_COOKIE_MAX_AGE_SECONDS).toBeGreaterThan(600) + }) +}) + +describe('resolveCallbackErrorCode', () => { + it('maps a missing oauth_state cookie to session_expired', () => { + expect(resolveCallbackErrorCode({ oauthCookiePresent: false })).toBe( + 'session_expired', + ) + }) + + it('maps a present cookie (any other failure) to auth_failed', () => { + expect(resolveCallbackErrorCode({ oauthCookiePresent: true })).toBe( + 'auth_failed', + ) + }) +}) diff --git a/packages/demo/src/app/api/oauth/callback/route.ts b/packages/demo/src/app/api/oauth/callback/route.ts index abaa5811..45f594d5 100644 --- a/packages/demo/src/app/api/oauth/callback/route.ts +++ b/packages/demo/src/app/api/oauth/callback/route.ts @@ -25,6 +25,7 @@ import { cookies } from 'next/headers' import { getOAuthSessionFromCookie, createUserSessionCookie, + resolveCallbackErrorCode, OAUTH_COOKIE, } from '@/lib/session' import { sanitizeForLog } from '@/lib/validation' @@ -48,11 +49,21 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL('/?error=auth_failed', baseUrl)) } - // Retrieve OAuth session from signed cookie + // Retrieve OAuth session from signed cookie. The cookie carries + // the state value, code verifier, token endpoint and issuer that + // we recorded when starting the OAuth flow. If it has gone away + // (cookie expired by browser, user cleared cookies, very long + // wait on the OTP form), there is nothing we can do to complete + // the token exchange — but we owe the user a useful error + // (`session_expired`) rather than a generic `auth_failed`, so + // the landing page can guide them to start over instead of + // looking like the sign-in itself just failed. const cookieStore = await cookies() const stateData = getOAuthSessionFromCookie(cookieStore) if (!stateData) { - return NextResponse.redirect(new URL('/?error=auth_failed', baseUrl)) + console.error('[oauth/callback] Missing oauth_state cookie') + const code = resolveCallbackErrorCode({ oauthCookiePresent: false }) + return NextResponse.redirect(new URL(`/?error=${code}`, baseUrl)) } if (stateData.state !== state) { diff --git a/packages/demo/src/app/api/oauth/login/route.ts b/packages/demo/src/app/api/oauth/login/route.ts index 30a855e0..4c7c805a 100644 --- a/packages/demo/src/app/api/oauth/login/route.ts +++ b/packages/demo/src/app/api/oauth/login/route.ts @@ -29,7 +29,10 @@ import { resolveDidToPds, discoverOAuthEndpoints, } from '@/lib/auth' -import { createOAuthSessionCookie } from '@/lib/session' +import { + createOAuthSessionCookie, + OAUTH_COOKIE_MAX_AGE_SECONDS, +} from '@/lib/session' import { signClientAssertion } from '@/lib/client-jwk' import { validateEmail, validateHandle, sanitizeForLog } from '@/lib/validation' @@ -263,7 +266,7 @@ export async function GET(request: Request) { httpOnly: true, secure: true, sameSite: 'lax', - maxAge: 600, + maxAge: OAUTH_COOKIE_MAX_AGE_SECONDS, path: '/', }) return resp2 @@ -281,7 +284,7 @@ export async function GET(request: Request) { httpOnly: true, secure: true, sameSite: 'lax', - maxAge: 600, + maxAge: OAUTH_COOKIE_MAX_AGE_SECONDS, path: '/', }) return response diff --git a/packages/demo/src/app/components/LoginForm.tsx b/packages/demo/src/app/components/LoginForm.tsx index 46e882c9..e13c1c67 100644 --- a/packages/demo/src/app/components/LoginForm.tsx +++ b/packages/demo/src/app/components/LoginForm.tsx @@ -6,6 +6,8 @@ import { ForceLoginCheckbox } from './ForceLoginCheckbox' const ERROR_MESSAGES: Record = { auth_failed: 'Authentication failed. Please try again.', + session_expired: + 'Your sign-in took too long to finish. Please sign in again.', par_failed: 'Could not start login — the PDS rejected the request. Check server logs.', invalid_email: 'Please enter a valid email address.', diff --git a/packages/demo/src/lib/session.ts b/packages/demo/src/lib/session.ts index 706b26e2..9772b831 100644 --- a/packages/demo/src/lib/session.ts +++ b/packages/demo/src/lib/session.ts @@ -72,6 +72,40 @@ function verifyPayload(signed: string): string | null { const OAUTH_COOKIE = 'oauth_state' +/** + * Lifetime of the `oauth_state` cookie, in seconds. + * + * The cookie carries everything the callback needs to finish the + * token exchange (state, code verifier, token endpoint, issuer). It + * must outlive a realistic sign-in: the user requests an email code, + * fetches it from their inbox, and only then submits — a wait that + * can run to several minutes. 600s (10 min) was shorter than that + * and shorter than the code's own validity, so a slow-but-correct + * sign-in could fail at the last step with nothing to complete + * against. One hour matches the auth service's `auth_flow` row TTL: + * as long as the auth service can still recover the flow, the demo + * can too. + */ +export const OAUTH_COOKIE_MAX_AGE_SECONDS = 60 * 60 + +/** + * Error code the OAuth callback landing page should show when the + * flow can't be completed. Split out as a pure function so the + * mapping is unit-testable without standing up a Next request. + * + * `session_expired` is reserved for the case where the sign-in + * itself succeeded but *our* `oauth_state` cookie has gone away + * (expired, cleared, or a wait longer than its lifetime) — there is + * nothing to exchange the code against, but the user did nothing + * wrong, so an honest "took too long" beats a generic "auth failed". + * Every other failure maps to `auth_failed`. + */ +export function resolveCallbackErrorCode(reason: { + oauthCookiePresent: boolean +}): 'session_expired' | 'auth_failed' { + return reason.oauthCookiePresent ? 'auth_failed' : 'session_expired' +} + export function createOAuthSessionCookie(data: OAuthSession): { name: string value: string