/** * linkNonceStore.ts — single-use nonce store for the OIDC-link state (IN-04). * * POST /api/me/link-oidc mints a signed `state` JWT carrying a random `nonce` "to prevent * replay". Previously the /callback handler never recorded or checked that nonce, so a * captured state JWT was fully replayable within its 10-minute signature window — the nonce * provided no actual protection (this underlies the BL-03 takeover concern). * * This in-memory store makes the nonce genuinely single-use: * - registerLinkNonce(nonce, expEpochSeconds): called when the state is issued. * - consumeLinkNonce(nonce): called on /callback; returns true exactly ONCE per nonce * (and only while unexpired), false on replay / unknown / expired. * * In-memory is sufficient for a single-process household deployment (same scope as the * loginAttempts limiter). Expired entries are swept opportunistically on each access so the * map stays bounded. If this app ever runs multi-process, move this to Redis. */ // nonce → expiry (epoch ms). Presence means "issued and not yet consumed". const issuedNonces = new Map(); function sweepExpired(now: number): void { for (const [nonce, expiresAt] of issuedNonces) { if (now >= expiresAt) issuedNonces.delete(nonce); } } /** * Record a freshly-issued link nonce as valid until expEpochSeconds (the state JWT's exp). */ export function registerLinkNonce(nonce: string, expEpochSeconds: number): void { const now = Date.now(); sweepExpired(now); issuedNonces.set(nonce, expEpochSeconds * 1000); } /** * Consume a link nonce. Returns true exactly once for a known, unexpired nonce; false for * any replay, unknown, or expired nonce. Single-use: the entry is deleted on first success. */ export function consumeLinkNonce(nonce: string): boolean { const now = Date.now(); sweepExpired(now); const expiresAt = issuedNonces.get(nonce); if (expiresAt === undefined || now >= expiresAt) return false; issuedNonces.delete(nonce); // single-use return true; } /** * Test-only: clear all issued nonces. */ export function _clearLinkNonces(): void { issuedNonces.clear(); }