diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6616545..2f198a8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -20,6 +20,7 @@ import { } from './auth/middleware.js'; import { devAuthBypass, devSessionCookieMiddleware } from './auth/devBypass.js'; import { localAuthMiddleware } from './auth/localAuthMiddleware.js'; +import { verifyLocalSessionCookie } from './auth/localSession.js'; import { persistSessionCookie } from './auth/persistSessionCookie.js'; import { startBrokerPoller } from './broker/poller.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. if (linkUserId !== null) { 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); if (auth) { const iss = (auth.iss as string | undefined) ?? ''; 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); // On success: user is now OIDC-only; normal redirect via callbackResponse proceeds. }