From 2691dd0f95bd0fdab5ddcd0604213a1e75b7f4f2 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 20:37:09 -0400 Subject: [PATCH] fix(19): IN-04 enforce single-use OIDC-link nonce to prevent state replay --- apps/api/src/auth/linkNonceStore.ts | 55 +++++++++++++++++++++++++++++ apps/api/src/index.ts | 12 +++++++ apps/api/src/routes/me.ts | 9 ++++- 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/auth/linkNonceStore.ts diff --git a/apps/api/src/auth/linkNonceStore.ts b/apps/api/src/auth/linkNonceStore.ts new file mode 100644 index 0000000..a0cc2df --- /dev/null +++ b/apps/api/src/auth/linkNonceStore.ts @@ -0,0 +1,55 @@ +/** + * 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(); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2f198a8..d1b2891 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -21,6 +21,7 @@ import { import { devAuthBypass, devSessionCookieMiddleware } from './auth/devBypass.js'; import { localAuthMiddleware } from './auth/localAuthMiddleware.js'; import { verifyLocalSessionCookie } from './auth/localSession.js'; +import { consumeLinkNonce } from './auth/linkNonceStore.js'; import { persistSessionCookie } from './auth/persistSessionCookie.js'; import { startBrokerPoller } from './broker/poller.js'; import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js'; @@ -59,6 +60,7 @@ app.get('/callback', async (c) => { // Attempt to extract linkUserId from the signed state param BEFORE processOAuthCallback // consumes it. The state param may be our signed JWT (link mode) or a random string (normal). let linkUserId: number | null = null; + let linkNonce: string | null = null; const rawState = c.req.query('state'); if (rawState) { const secret = process.env.LOCAL_SESSION_SECRET; @@ -67,6 +69,7 @@ app.get('/callback', async (c) => { const payload = await Jwt.verify(rawState, secret, 'HS256'); if (typeof payload.linkUserId === 'number') { linkUserId = payload.linkUserId; + linkNonce = typeof payload.nonce === 'string' ? payload.nonce : null; } } catch { // Not our signed link state — normal OIDC callback, proceed normally. @@ -80,6 +83,15 @@ app.get('/callback', async (c) => { // Link mode: after session is established, bind the OIDC identity to the local user. if (linkUserId !== null) { try { + // IN-04: enforce SINGLE USE of the link nonce. The signed state JWT is otherwise + // replayable for its full 10-minute signature lifetime; consuming the nonce here means + // a captured state can be used at most once. A replay (already-consumed), unknown, or + // expired nonce is rejected before any binding occurs. + if (!linkNonce || !consumeLinkNonce(linkNonce)) { + console.warn('[callback] OIDC-link rejected: link nonce missing, replayed, or expired.'); + return c.redirect('/?error=oidc-link-conflict'); + } + // BL-03: cross-check that the local session completing this callback is the SAME // user the link flow was initiated for. The signed `state` JWT proves the state was // minted by POST /api/me/link-oidc, but NOT that the person finishing the OIDC login diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index f766aea..3cf8961 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -42,6 +42,7 @@ import { } from '../broker/credentialSync.js'; import { hashPassword, verifyPassword } from '../auth/localCredentials.js'; import { resolveOidcConfig, discoverAuthorizationEndpoint } from '../auth/oidcConfig.js'; +import { registerLinkNonce } from '../auth/linkNonceStore.js'; // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') import '../auth/devBypass.js'; @@ -319,12 +320,18 @@ meRouter.post('/link-oidc', async (c) => { // T-19-09: nonce prevents state replay attacks (each link attempt gets a fresh nonce) const nonce = randomBytes(16).toString('hex'); const now = Math.floor(Date.now() / 1000); + const exp = now + 600; // 10-minute window const signedState = await Jwt.sign( - { linkUserId: currentUserId, nonce, iat: now, exp: now + 600 }, // 10-minute window + { linkUserId: currentUserId, nonce, iat: now, exp }, secret, 'HS256', ); + // IN-04: record the nonce so /callback can enforce SINGLE USE. Without this the signed + // state JWT is fully replayable for its 10-minute signature lifetime and the nonce is + // decorative. registerLinkNonce keeps it valid only until the state's own exp. + registerLinkNonce(nonce, exp); + // Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button). // WR-04: resolve issuer/clientId/redirectUri from env-OR-app_config (single source of truth, // consistent with /api/auth/mode and the OIDC fallback middleware) so a wizard-configured