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
3 changed files with 75 additions and 1 deletions
Showing only changes of commit 2691dd0f95 - Show all commits
+55
View File
@@ -0,0 +1,55 @@
/**
* linkNonceStore.ts — single-use nonce store for the OIDC-link state (IN-04).
*
* POST /api/me/link-oidc mints a signed `state` JWT carrying a random `nonce` "to prevent
* replay". Previously the /callback handler never recorded or checked that nonce, so a
* captured state JWT was fully replayable within its 10-minute signature window — the nonce
* provided no actual protection (this underlies the BL-03 takeover concern).
*
* This in-memory store makes the nonce genuinely single-use:
* - registerLinkNonce(nonce, expEpochSeconds): called when the state is issued.
* - consumeLinkNonce(nonce): called on /callback; returns true exactly ONCE per nonce
* (and only while unexpired), false on replay / unknown / expired.
*
* In-memory is sufficient for a single-process household deployment (same scope as the
* loginAttempts limiter). Expired entries are swept opportunistically on each access so the
* map stays bounded. If this app ever runs multi-process, move this to Redis.
*/
// nonce → expiry (epoch ms). Presence means "issued and not yet consumed".
const issuedNonces = new Map<string, number>();
function sweepExpired(now: number): void {
for (const [nonce, expiresAt] of issuedNonces) {
if (now >= expiresAt) issuedNonces.delete(nonce);
}
}
/**
* Record a freshly-issued link nonce as valid until expEpochSeconds (the state JWT's exp).
*/
export function registerLinkNonce(nonce: string, expEpochSeconds: number): void {
const now = Date.now();
sweepExpired(now);
issuedNonces.set(nonce, expEpochSeconds * 1000);
}
/**
* Consume a link nonce. Returns true exactly once for a known, unexpired nonce; false for
* any replay, unknown, or expired nonce. Single-use: the entry is deleted on first success.
*/
export function consumeLinkNonce(nonce: string): boolean {
const now = Date.now();
sweepExpired(now);
const expiresAt = issuedNonces.get(nonce);
if (expiresAt === undefined || now >= expiresAt) return false;
issuedNonces.delete(nonce); // single-use
return true;
}
/**
* Test-only: clear all issued nonces.
*/
export function _clearLinkNonces(): void {
issuedNonces.clear();
}
+12
View File
@@ -21,6 +21,7 @@ import {
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 { verifyLocalSessionCookie } from './auth/localSession.js';
import { consumeLinkNonce } from './auth/linkNonceStore.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';
@@ -59,6 +60,7 @@ app.get('/callback', async (c) => {
// Attempt to extract linkUserId from the signed state param BEFORE processOAuthCallback // 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). // consumes it. The state param may be our signed JWT (link mode) or a random string (normal).
let linkUserId: number | null = null; let linkUserId: number | null = null;
let linkNonce: string | null = null;
const rawState = c.req.query('state'); const rawState = c.req.query('state');
if (rawState) { if (rawState) {
const secret = process.env.LOCAL_SESSION_SECRET; const secret = process.env.LOCAL_SESSION_SECRET;
@@ -67,6 +69,7 @@ app.get('/callback', async (c) => {
const payload = await Jwt.verify(rawState, secret, 'HS256'); const payload = await Jwt.verify(rawState, secret, 'HS256');
if (typeof payload.linkUserId === 'number') { if (typeof payload.linkUserId === 'number') {
linkUserId = payload.linkUserId; linkUserId = payload.linkUserId;
linkNonce = typeof payload.nonce === 'string' ? payload.nonce : null;
} }
} catch { } catch {
// Not our signed link state — normal OIDC callback, proceed normally. // 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. // Link mode: after session is established, bind the OIDC identity to the local user.
if (linkUserId !== null) { if (linkUserId !== null) {
try { 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 // 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 // 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 // minted by POST /api/me/link-oidc, but NOT that the person finishing the OIDC login
+8 -1
View File
@@ -42,6 +42,7 @@ import {
} from '../broker/credentialSync.js'; } from '../broker/credentialSync.js';
import { hashPassword, verifyPassword } from '../auth/localCredentials.js'; import { hashPassword, verifyPassword } from '../auth/localCredentials.js';
import { resolveOidcConfig, discoverAuthorizationEndpoint } from '../auth/oidcConfig.js'; import { resolveOidcConfig, discoverAuthorizationEndpoint } from '../auth/oidcConfig.js';
import { registerLinkNonce } from '../auth/linkNonceStore.js';
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'; import '../auth/devBypass.js';
@@ -319,12 +320,18 @@ meRouter.post('/link-oidc', async (c) => {
// T-19-09: nonce prevents state replay attacks (each link attempt gets a fresh nonce) // T-19-09: nonce prevents state replay attacks (each link attempt gets a fresh nonce)
const nonce = randomBytes(16).toString('hex'); const nonce = randomBytes(16).toString('hex');
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const exp = now + 600; // 10-minute window
const signedState = await Jwt.sign( const signedState = await Jwt.sign(
{ linkUserId: currentUserId, nonce, iat: now, exp: now + 600 }, // 10-minute window { linkUserId: currentUserId, nonce, iat: now, exp },
secret, secret,
'HS256', 'HS256',
); );
// IN-04: record the nonce so /callback can enforce SINGLE USE. Without this the signed
// state JWT is fully replayable for its 10-minute signature lifetime and the nonce is
// decorative. registerLinkNonce keeps it valid only until the state's own exp.
registerLinkNonce(nonce, exp);
// Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button). // Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button).
// WR-04: resolve issuer/clientId/redirectUri from env-OR-app_config (single source of truth, // WR-04: resolve issuer/clientId/redirectUri from env-OR-app_config (single source of truth,
// consistent with /api/auth/mode and the OIDC fallback middleware) so a wizard-configured // consistent with /api/auth/mode and the OIDC fallback middleware) so a wizard-configured