From ca5f2cf09c758c57d2e50584b45db14595951f3a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:19:36 +0100 Subject: [PATCH] feat: let the mobile app redeem a sign-in hand-off through any instance #1066 --- app/api/auth/handoff/confirm/route.ts | 2 +- app/api/auth/handoff/create/route.ts | 2 +- app/api/auth/handoff/redeem/route.ts | 4 +- app/api/auth/sso/complete/route.ts | 5 +- .../mobile-handoff-code-routes.test.ts | 10 +- lib/__tests__/shared-handoff-store.test.ts | 97 +++++++++++ lib/__tests__/sso-complete-route.test.ts | 4 +- lib/auth/handoff-store.ts | 164 +++++++++++++----- 8 files changed, 231 insertions(+), 57 deletions(-) create mode 100644 lib/__tests__/shared-handoff-store.test.ts diff --git a/app/api/auth/handoff/confirm/route.ts b/app/api/auth/handoff/confirm/route.ts index 8e550f7f4..caed06483 100644 --- a/app/api/auth/handoff/confirm/route.ts +++ b/app/api/auth/handoff/confirm/route.ts @@ -19,7 +19,7 @@ export async function POST(request: NextRequest) { try { const body = await request.json().catch(() => null); const code = body?.code; - if (!isHandoffCode(code) || !confirmHandoff(code)) { + if (!isHandoffCode(code) || !(await confirmHandoff(code))) { return NextResponse.json({ error: 'invalid_code' }, { status: 400, headers: NO_STORE }); } return NextResponse.json({ ok: true }, { headers: NO_STORE }); diff --git a/app/api/auth/handoff/create/route.ts b/app/api/auth/handoff/create/route.ts index e63ee0082..0523521be 100644 --- a/app/api/auth/handoff/create/route.ts +++ b/app/api/auth/handoff/create/route.ts @@ -47,7 +47,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'too_many_requests' }, { status: 429, headers: NO_STORE }); } - const code = stashHandoff( + const code = await stashHandoff( { flow: 'password', server_url: body.server_url, username: body.username, password: body.password }, { challenge, state }, ); diff --git a/app/api/auth/handoff/redeem/route.ts b/app/api/auth/handoff/redeem/route.ts index bf0adc04a..ce1856a68 100644 --- a/app/api/auth/handoff/redeem/route.ts +++ b/app/api/auth/handoff/redeem/route.ts @@ -40,11 +40,11 @@ export async function POST(request: NextRequest) { } if (!isHandoffCodeVerifier(verifier)) { // Still spend the code: a malformed verifier is a wrong one. - redeemHandoff(code, ''); + await redeemHandoff(code, ''); return NextResponse.json({ error: 'invalid_verifier' }, { status: 400, headers: NO_STORE }); } - const result = redeemHandoff(code, verifier); + const result = await redeemHandoff(code, verifier); if (!result.ok) { const error = result.reason === 'verifier_mismatch' ? 'invalid_verifier' : `${result.reason}_code`; logger.warn('Mobile hand-off redeem refused', { reason: result.reason }); diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts index 688e3db2d..dc2eb65a2 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -10,6 +10,7 @@ import { } from '@/lib/oauth/token-exchange'; import { buildRedeemBundle } from '@/lib/auth/pair-bundle'; import { isHandoffCodeChallenge, stashHandoff } from '@/lib/auth/handoff-store'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { getCookieOptions, getSignInCookieOptions } from '@/lib/oauth/cookie-config'; import { storeIdToken } from '@/lib/oauth/end-session'; @@ -167,7 +168,7 @@ export async function POST(request: NextRequest) { // only a one-time code for the link (lib/auth/handoff-store.ts). The // tokens never reach the page. The code starts working when the user // confirms the hand-off (/api/auth/handoff/confirm). - const handoffCode = stashHandoff(bundle, { + const handoffCode = await stashHandoff(bundle, { challenge: mobileCodeChallenge, state: mobileState ?? '', awaitConfirm: true, @@ -201,6 +202,8 @@ export async function POST(request: NextRequest) { } catch (error) { // Clean up pending cookie on any error cookieStore.delete(SSO_PENDING_COOKIE); + // The mobile hand-off can't be parked without the shared store. + if (isStoreUnavailable(error)) return storeUnavailableResponse(); logger.error('SSO complete error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 }); } diff --git a/lib/__tests__/mobile-handoff-code-routes.test.ts b/lib/__tests__/mobile-handoff-code-routes.test.ts index e49bde36e..db33472a5 100644 --- a/lib/__tests__/mobile-handoff-code-routes.test.ts +++ b/lib/__tests__/mobile-handoff-code-routes.test.ts @@ -179,7 +179,7 @@ describe('SSO hand-off', () => { it('redeems only after the user confirmed, for 60 seconds from then', async () => { vi.useFakeTimers({ toFake: ['Date'] }); const start = Date.now(); - const code = stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true }); + const code = await stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true }); // The user reads the confirmation for a few minutes. vi.setSystemTime(start + HANDOFF_CONFIRM_TTL_MS - 1000); @@ -194,7 +194,7 @@ describe('SSO hand-off', () => { }); it('refuses and spends a code the user has not confirmed', async () => { - const code = stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true }); + const code = await stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true }); const early = await redeem({ code, code_verifier: VERIFIER }); expect(early.status).toBe(400); expect(early.body).toEqual({ error: 'invalid_code' }); @@ -203,7 +203,7 @@ describe('SSO hand-off', () => { it('expires 60 seconds after the confirmation', async () => { vi.useFakeTimers({ toFake: ['Date'] }); - const code = stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true }); + const code = await stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true }); expect((await confirm({ code })).status).toBe(200); vi.setSystemTime(Date.now() + HANDOFF_TTL_MS + 1); const late = await redeem({ code, code_verifier: VERIFIER }); @@ -213,13 +213,13 @@ describe('SSO hand-off', () => { it('cannot be confirmed once the five minutes to confirm are over', async () => { vi.useFakeTimers({ toFake: ['Date'] }); - const code = stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true }); + const code = await stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true }); vi.setSystemTime(Date.now() + HANDOFF_CONFIRM_TTL_MS + 1); expect((await confirm({ code })).status).toBe(400); }); it('confirms a code once', async () => { - const code = stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true }); + const code = await stashHandoff(bundle, { challenge: CHALLENGE, state: 'm-2', awaitConfirm: true }); expect((await confirm({ code })).status).toBe(200); expect((await confirm({ code })).status).toBe(400); expect((await confirm({ code: 'nope' })).status).toBe(400); diff --git a/lib/__tests__/shared-handoff-store.test.ts b/lib/__tests__/shared-handoff-store.test.ts new file mode 100644 index 000000000..19e9a4409 --- /dev/null +++ b/lib/__tests__/shared-handoff-store.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node +import { createHash } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +import { getStore, setStoreForTests } from '@/lib/shared-store'; +import { resetLocalStoreForTests } from '@/lib/shared-store/local'; +import type { RedeemBundle } from '@/lib/auth/pair-bundle'; +import { switchableSharedStore } from './helpers/shared-store-down'; + +// The mobile hand-off parked its sign-in in one process's memory (#1066). +// The SSO hand-off is three requests from two clients (sso/complete parks, +// the page confirms, the app redeems), so behind a load balancer each could +// reach another replica and get invalid_code. + +const T0 = 1_800_000_000_000; +const VERIFIER = 'v'.repeat(43); +const CHALLENGE = createHash('sha256').update(VERIFIER, 'ascii').digest('base64url'); +const PASSWORD_BUNDLE: RedeemBundle = { flow: 'password', server_url: 'https://mail.example.org', username: 'alice@example.org', password: 'hunter2-secret' }; +let store: ReturnType; + +/** Another replica: a fresh module copy without this process's own map. */ +async function replica() { + vi.resetModules(); + delete (globalThis as Record).__bulwarkMobileHandoffStore; + return import('@/lib/auth/handoff-store'); +} + +beforeEach(() => { + vi.stubEnv('SESSION_SECRET', 'a-test-session-secret-of-at-least-32-chars'); + resetLocalStoreForTests(); + store = switchableSharedStore(); + setStoreForTests(store); +}); + +afterEach(() => { + setStoreForTests(null); + resetLocalStoreForTests(); + vi.unstubAllEnvs(); +}); + +describe('mobile hand-off with a shared store', () => { + it('redeems on another replica a code parked on this one, only once', async () => { + const browser = await replica(); + const code = await browser.stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's-1' }, T0); + + const apps = await Promise.all([replica(), replica(), replica()]); + const results = await Promise.all(apps.map((app) => app.redeemHandoff(code, VERIFIER, T0 + 1000))); + expect(results.filter((r) => r.ok)).toEqual([{ ok: true, bundle: PASSWORD_BUNDLE, state: 's-1' }]); + expect(results.filter((r) => !r.ok)).toEqual([{ ok: false, reason: 'invalid' }, { ok: false, reason: 'invalid' }]); + }); + + it('parks on one replica, confirms on a second and redeems on a third', async () => { + const code = await (await replica()).stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's-2', awaitConfirm: true }, T0); + expect(await (await replica()).confirmHandoff(code, T0 + 60_000)).toBe(true); + // Confirmed once only. + expect(await (await replica()).confirmHandoff(code, T0 + 60_001)).toBe(false); + // Its 60 seconds started at the confirmation. + expect(await (await replica()).redeemHandoff(code, VERIFIER, T0 + 110_000)).toMatchObject({ ok: true, state: 's-2' }); + }); + + it('spends an unconfirmed code on the first attempt, on any replica', async () => { + const code = await (await replica()).stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's', awaitConfirm: true }, T0); + expect(await (await replica()).redeemHandoff(code, VERIFIER, T0 + 1)).toEqual({ ok: false, reason: 'invalid' }); + expect(await (await replica()).confirmHandoff(code, T0 + 2)).toBe(false); + expect(await (await replica()).redeemHandoff(code, VERIFIER, T0 + 3)).toEqual({ ok: false, reason: 'invalid' }); + }); + + it('spends a code on a wrong verifier and tells a late attempt it expired', async () => { + const code = await (await replica()).stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's' }, T0); + expect(await (await replica()).redeemHandoff(code, 'w'.repeat(43), T0 + 1)).toEqual({ ok: false, reason: 'verifier_mismatch' }); + expect(await (await replica()).redeemHandoff(code, VERIFIER, T0 + 2)).toEqual({ ok: false, reason: 'invalid' }); + expect(await (await replica()).redeemHandoff(code, VERIFIER, T0 + 61_000)).toEqual({ ok: false, reason: 'expired' }); + }); + + it('keeps the sign-in sealed and the code hashed in the store', async () => { + const code = await (await replica()).stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's' }, T0); + const raw = await getStore().kv.get(`handoff:${createHash('sha256').update(code).digest('hex')}`); + expect(raw).toBeTruthy(); + expect(raw).not.toContain('hunter2-secret'); + expect(await getStore().kv.get(`handoff:${code}`)).toBeNull(); + }); + + it('parks and redeems nothing while the store is down', async () => { + const browser = await replica(); + const code = await browser.stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's' }, T0); + store.down = true; + await expect(browser.stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's' }, T0)).rejects.toThrow('down'); + await expect(browser.redeemHandoff(code, VERIFIER, T0 + 1)).rejects.toThrow('down'); + await expect(browser.confirmHandoff(code, T0 + 1)).rejects.toThrow('down'); + store.down = false; + expect(await (await replica()).redeemHandoff(code, VERIFIER, T0 + 2)).toMatchObject({ ok: true }); + }); +}); diff --git a/lib/__tests__/sso-complete-route.test.ts b/lib/__tests__/sso-complete-route.test.ts index e5da63b89..73090fb2f 100644 --- a/lib/__tests__/sso-complete-route.test.ts +++ b/lib/__tests__/sso-complete-route.test.ts @@ -174,8 +174,8 @@ describe('/api/auth/sso/complete for an app that sent a PKCE challenge', () => { expect(JSON.stringify(res.body)).not.toMatch(/"at"|"rt"/); expect(jar.has('jmap_rt')).toBe(false); - expect(confirmHandoff(res.body.mobile_handoff_code)).toBe(true); - const redeemed = redeemHandoff(res.body.mobile_handoff_code, VERIFIER); + expect(await confirmHandoff(res.body.mobile_handoff_code)).toBe(true); + const redeemed = await redeemHandoff(res.body.mobile_handoff_code, VERIFIER); expect(redeemed).toMatchObject({ ok: true, state: 'm-1', diff --git a/lib/auth/handoff-store.ts b/lib/auth/handoff-store.ts index a4aba3fef..0b92eaf94 100644 --- a/lib/auth/handoff-store.ts +++ b/lib/auth/handoff-store.ts @@ -2,6 +2,8 @@ import { createHash, randomBytes, timingSafeEqual } from 'crypto'; import type { RedeemBundle } from '@/lib/auth/pair-bundle'; import { isMobileCodeChallenge } from '@/lib/auth/mobile-handoff'; import { createFailureBudget, type BudgetRefund } from '@/lib/auth/verify-budget'; +import { decryptPayload, encryptPayload } from '@/lib/auth/crypto'; +import { getStore, isSharedMode } from '@/lib/shared-store'; // One-time codes for the browser sign-in hand-off to the mobile app // (lib/auth/mobile-handoff.ts, bulwarkmail.org/docs/development/mobile-handoff). The app opens the @@ -25,8 +27,12 @@ import { createFailureBudget, type BudgetRefund } from '@/lib/auth/verify-budget // Spent and expired records stay a while without their sign-in, so a late // redeem is told "expired" rather than "invalid". // -// Storage is an in-process Map pinned on globalThis, like the pairing store -// (lib/auth/pairing-store.ts), with the same single-instance assumption. +// One process keeps the codes in a Map pinned on globalThis, like the pairing +// store (lib/auth/pairing-store.ts). With a shared store (#1066) they live +// there instead, sealed with the session key and under the code's hash, so +// the page's confirm and the app's redeem may reach any replica: the SSO +// hand-off is three requests from two clients. While the store is down every +// function rejects with StoreUnavailableError; nothing is parked or redeemed. export const HANDOFF_TTL_MS = 60 * 1000; /** How long an SSO sign-in waits for the user to confirm the hand-off. */ @@ -35,6 +41,8 @@ const TOMBSTONE_MS = 10 * 60 * 1000; const CODE_BYTES = 32; // 256 bits /** Records at most; the oldest gives way when a new one comes in. */ const MAX_RECORDS = 2000; +/** The store's TTL only cleans up; every record carries its own expiry. */ +const CLEANUP_MS = 60 * 1000; /** RFC 7636 §4.1: 43 to 128 unreserved characters. */ const CODE_VERIFIER_RE = /^[A-Za-z0-9._~-]{43,128}$/; @@ -88,26 +96,13 @@ function s256(verifier: string): string { return createHash('sha256').update(verifier, 'ascii').digest('base64url'); } -/** - * Park a sign-in for the app; returns the code the browser hands over. With - * `awaitConfirm` the code only becomes redeemable through confirmHandoff. - */ -export function stashHandoff( +function newRecord( bundle: RedeemBundle, options: { challenge: string; state: string; awaitConfirm?: boolean }, - now = Date.now(), -): string { - sweep(now); - const codes = store(); - // Map keeps insertion order, so the first key is the oldest. - while (codes.size >= MAX_RECORDS) { - const oldest = codes.keys().next().value; - if (oldest === undefined) break; - codes.delete(oldest); - } - const code = randomBytes(CODE_BYTES).toString('hex'); + now: number, +): HandoffRecord { const expiresAt = now + (options.awaitConfirm ? HANDOFF_CONFIRM_TTL_MS : HANDOFF_TTL_MS); - codes.set(code, { + return { bundle, state: options.state, challenge: options.challenge, @@ -115,36 +110,17 @@ export function stashHandoff( issuedAt: now, expiresAt, forgetAt: expiresAt + TOMBSTONE_MS, - }); - return code; + }; } -/** - * The user confirmed the hand-off of a code parked with `awaitConfirm`: it - * can be redeemed for the next 60 seconds. False when there is nothing (left) - * to confirm. - */ -export function confirmHandoff(code: string, now = Date.now()): boolean { - sweep(now); - const record = store().get(code); - if (!record || !record.bundle || record.confirmed) return false; - record.confirmed = true; - record.expiresAt = now + HANDOFF_TTL_MS; - record.forgetAt = record.expiresAt + TOMBSTONE_MS; - return true; +/** The user confirmed: the code redeems for the next 60 seconds. */ +function confirmed(record: HandoffRecord, now: number): HandoffRecord { + const expiresAt = now + HANDOFF_TTL_MS; + return { ...record, confirmed: true, expiresAt, forgetAt: expiresAt + TOMBSTONE_MS }; } -/** - * Hand out the sign-in behind `code` when `verifier` matches its challenge. - * The code is spent by the attempt, whatever its outcome. `expires_in` of an - * OAuth bundle counts down from when it was parked. - */ -export function redeemHandoff(code: string, verifier: string, now = Date.now()): HandoffRedeemResult { - sweep(now); - const record = store().get(code); - if (!record) return { ok: false, reason: 'invalid' }; - const bundle = record.bundle; - record.bundle = null; +/** What a redeem attempt of `record` (spent by the attempt) gets. */ +function redeemOutcome(record: HandoffRecord, bundle: RedeemBundle | null, verifier: string, now: number): HandoffRedeemResult { if (!bundle) return { ok: false, reason: record.expiresAt <= now ? 'expired' : 'invalid' }; if (!record.confirmed) return { ok: false, reason: 'invalid' }; const expected = Buffer.from(record.challenge, 'ascii'); @@ -159,6 +135,104 @@ export function redeemHandoff(code: string, verifier: string, now = Date.now()): return { ok: true, bundle, state: record.state }; } +// ─── shared store ────────────────────────────────────────────────────────── + +const sharedKey = (code: string) => `handoff:${createHash('sha256').update(code).digest('hex')}`; + +function seal(record: HandoffRecord): string { + return encryptPayload({ r: record }, 'handoff-store'); +} + +/** The record, or null when missing, past its tombstone or unreadable. */ +function open(sealed: string | null, now: number): HandoffRecord | null { + if (sealed === null) return null; + const payload = decryptPayload(sealed, 'handoff-store'); + const record = payload && typeof payload.r === 'object' && payload.r !== null ? (payload.r as HandoffRecord) : null; + return record && record.forgetAt > now ? record : null; +} + +const ttl = (record: HandoffRecord, now: number) => Math.max(record.forgetAt - now, 0) + CLEANUP_MS; + +// ─── API ─────────────────────────────────────────────────────────────────── + +/** + * Park a sign-in for the app; returns the code the browser hands over. With + * `awaitConfirm` the code only becomes redeemable through confirmHandoff. + */ +export async function stashHandoff( + bundle: RedeemBundle, + options: { challenge: string; state: string; awaitConfirm?: boolean }, + now = Date.now(), +): Promise { + const code = randomBytes(CODE_BYTES).toString('hex'); + const record = newRecord(bundle, options, now); + if (isSharedMode()) { + // Each code is spent by a single redeem and dies on its own; the create + // budget bounds how many one client parks. + await getStore().kv.set(sharedKey(code), seal(record), { ttlMs: ttl(record, now) }); + return code; + } + sweep(now); + const codes = store(); + // Map keeps insertion order, so the first key is the oldest. + while (codes.size >= MAX_RECORDS) { + const oldest = codes.keys().next().value; + if (oldest === undefined) break; + codes.delete(oldest); + } + codes.set(code, record); + return code; +} + +/** + * The user confirmed the hand-off of a code parked with `awaitConfirm`: it + * can be redeemed for the next 60 seconds. False when there is nothing (left) + * to confirm. + */ +export async function confirmHandoff(code: string, now = Date.now()): Promise { + if (isSharedMode()) { + const result = { ok: false }; + await getStore().kv.update(sharedKey(code), (current) => { + result.ok = false; + const record = open(current, now); + if (!record || !record.bundle || record.expiresAt <= now || record.confirmed) return undefined; + result.ok = true; + return seal(confirmed(record, now)); + }, { ttlMs: HANDOFF_TTL_MS + TOMBSTONE_MS + CLEANUP_MS }); + return result.ok; + } + sweep(now); + const record = store().get(code); + if (!record || !record.bundle || record.confirmed) return false; + store().set(code, confirmed(record, now)); + return true; +} + +/** + * Hand out the sign-in behind `code` when `verifier` matches its challenge. + * The code is spent by the attempt, whatever its outcome. `expires_in` of an + * OAuth bundle counts down from when it was parked. + */ +export async function redeemHandoff(code: string, verifier: string, now = Date.now()): Promise { + if (isSharedMode()) { + const kv = getStore().kv; + // Taken in one step: of two attempts, only one sees the sign-in. + const record = open(await kv.take(sharedKey(code)), now); + if (!record) return { ok: false, reason: 'invalid' }; + const bundle = record.expiresAt > now ? record.bundle : null; + // A tombstone goes back, so a late attempt is told "expired". + const spent = { ...record, bundle: null }; + await kv.set(sharedKey(code), seal(spent), { ttlMs: ttl(spent, now), ifAbsent: true }); + return redeemOutcome(record, bundle, verifier, now); + } + sweep(now); + const record = store().get(code); + if (!record) return { ok: false, reason: 'invalid' }; + const bundle = record.bundle; + record.bundle = null; + return redeemOutcome(record, bundle, verifier, now); +} + /** Test hook: forget everything. */ export function resetHandoffStoreForTests(): void { store().clear();