mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
feat: let the mobile app redeem a sign-in hand-off through any instance #1066
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof switchableSharedStore>;
|
||||
|
||||
/** Another replica: a fresh module copy without this process's own map. */
|
||||
async function replica() {
|
||||
vi.resetModules();
|
||||
delete (globalThis as Record<string, unknown>).__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 });
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
+119
-45
@@ -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<string> {
|
||||
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<boolean> {
|
||||
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<HandoffRedeemResult> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user