Phase 19: Local Auth (No-OIDC Mode) #23

Merged
luckberg merged 78 commits from gsd/phase-19-local-auth-no-oidc-mode into main 2026-06-18 06:25:00 -04:00
Showing only changes of commit 71537601ce - Show all commits
+23
View File
@@ -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.
} }