fix(19): IN-04 enforce single-use OIDC-link nonce to prevent state replay
This commit is contained in:
@@ -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<string, number>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { devAuthBypass, devSessionCookieMiddleware } from './auth/devBypass.js';
|
import { devAuthBypass, devSessionCookieMiddleware } from './auth/devBypass.js';
|
||||||
import { localAuthMiddleware } from './auth/localAuthMiddleware.js';
|
import { localAuthMiddleware } from './auth/localAuthMiddleware.js';
|
||||||
import { verifyLocalSessionCookie } from './auth/localSession.js';
|
import { verifyLocalSessionCookie } from './auth/localSession.js';
|
||||||
|
import { consumeLinkNonce } from './auth/linkNonceStore.js';
|
||||||
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
||||||
import { startBrokerPoller } from './broker/poller.js';
|
import { startBrokerPoller } from './broker/poller.js';
|
||||||
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.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
|
// 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).
|
// consumes it. The state param may be our signed JWT (link mode) or a random string (normal).
|
||||||
let linkUserId: number | null = null;
|
let linkUserId: number | null = null;
|
||||||
|
let linkNonce: string | null = null;
|
||||||
const rawState = c.req.query('state');
|
const rawState = c.req.query('state');
|
||||||
if (rawState) {
|
if (rawState) {
|
||||||
const secret = process.env.LOCAL_SESSION_SECRET;
|
const secret = process.env.LOCAL_SESSION_SECRET;
|
||||||
@@ -67,6 +69,7 @@ app.get('/callback', async (c) => {
|
|||||||
const payload = await Jwt.verify(rawState, secret, 'HS256');
|
const payload = await Jwt.verify(rawState, secret, 'HS256');
|
||||||
if (typeof payload.linkUserId === 'number') {
|
if (typeof payload.linkUserId === 'number') {
|
||||||
linkUserId = payload.linkUserId;
|
linkUserId = payload.linkUserId;
|
||||||
|
linkNonce = typeof payload.nonce === 'string' ? payload.nonce : null;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Not our signed link state — normal OIDC callback, proceed normally.
|
// 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.
|
// Link mode: after session is established, bind the OIDC identity to the local user.
|
||||||
if (linkUserId !== null) {
|
if (linkUserId !== null) {
|
||||||
try {
|
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
|
// 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
|
// 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
|
// minted by POST /api/me/link-oidc, but NOT that the person finishing the OIDC login
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
} from '../broker/credentialSync.js';
|
} from '../broker/credentialSync.js';
|
||||||
import { hashPassword, verifyPassword } from '../auth/localCredentials.js';
|
import { hashPassword, verifyPassword } from '../auth/localCredentials.js';
|
||||||
import { resolveOidcConfig, discoverAuthorizationEndpoint } from '../auth/oidcConfig.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')
|
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||||
import '../auth/devBypass.js';
|
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)
|
// T-19-09: nonce prevents state replay attacks (each link attempt gets a fresh nonce)
|
||||||
const nonce = randomBytes(16).toString('hex');
|
const nonce = randomBytes(16).toString('hex');
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const exp = now + 600; // 10-minute window
|
||||||
const signedState = await Jwt.sign(
|
const signedState = await Jwt.sign(
|
||||||
{ linkUserId: currentUserId, nonce, iat: now, exp: now + 600 }, // 10-minute window
|
{ linkUserId: currentUserId, nonce, iat: now, exp },
|
||||||
secret,
|
secret,
|
||||||
'HS256',
|
'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).
|
// 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,
|
// 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
|
// consistent with /api/auth/mode and the OIDC fallback middleware) so a wizard-configured
|
||||||
|
|||||||
Reference in New Issue
Block a user