fix(19): IN-04 enforce single-use OIDC-link nonce to prevent state replay

This commit is contained in:
Lucas Berger
2026-06-17 20:37:09 -04:00
parent f02521dd02
commit 2691dd0f95
3 changed files with 75 additions and 1 deletions
+12
View File
@@ -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