fix(19): BL-03 reject OIDC-link bind on empty iss/sub or mismatched local session (takeover guard)
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
} from './auth/middleware.js';
|
} from './auth/middleware.js';
|
||||||
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 { 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';
|
||||||
@@ -79,10 +80,32 @@ 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 {
|
||||||
|
// 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
|
||||||
|
// is that user. Without this check, an attacker who gets a victim to complete an OIDC
|
||||||
|
// login while replaying a still-valid (10-min) captured link state would bind the
|
||||||
|
// ATTACKER's OIDC identity onto the VICTIM's account (account takeover). Require the
|
||||||
|
// initiating local session to still be present and to match linkUserId.
|
||||||
|
const sessionUserId = await verifyLocalSessionCookie(c);
|
||||||
|
if (sessionUserId !== linkUserId) {
|
||||||
|
console.warn(
|
||||||
|
'[callback] OIDC-link rejected: local session does not match link state (possible replay).',
|
||||||
|
);
|
||||||
|
return c.redirect('/?error=oidc-link-conflict');
|
||||||
|
}
|
||||||
|
|
||||||
const auth = await getAuth(c);
|
const auth = await getAuth(c);
|
||||||
if (auth) {
|
if (auth) {
|
||||||
const iss = (auth.iss as string | undefined) ?? '';
|
const iss = (auth.iss as string | undefined) ?? '';
|
||||||
const sub = auth.sub ?? '';
|
const sub = auth.sub ?? '';
|
||||||
|
// BL-03: never bind on a blank/partial identity. linkOidcToUser writes oidc_iss/
|
||||||
|
// oidc_sub AND deletes the user's local_credentials — binding empty iss/sub would
|
||||||
|
// both corrupt identity and lock the user out of BOTH auth methods. Reject instead.
|
||||||
|
if (!iss || !sub) {
|
||||||
|
console.warn('[callback] OIDC-link rejected: empty iss/sub from getAuth.');
|
||||||
|
return c.redirect('/?error=oidc-link-conflict');
|
||||||
|
}
|
||||||
await linkOidcToUser(linkUserId, iss, sub);
|
await linkOidcToUser(linkUserId, iss, sub);
|
||||||
// On success: user is now OIDC-only; normal redirect via callbackResponse proceeds.
|
// On success: user is now OIDC-only; normal redirect via callbackResponse proceeds.
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user