fix(19): IN-04 enforce single-use OIDC-link nonce to prevent state replay
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user