feat(19-03): implement localAuthMiddleware, GET /api/auth/mode, and pre-auth route mounts

- localAuthMiddleware: cookie→c.set('user') with Pitfall-1 guard (no-set on no-cookie path)
- authMode: GET /api/auth/mode pre-auth endpoint (localEnabled:true, oidcEnabled from env+config)
- localAuth: POST /api/auth/local/login (rate-limit + timing-safe), logout routes
- index.ts: mount authModeRouter + localAuthRouter pre-auth; localAuthMiddleware after devAuthBypass; OIDC guard wrapped skip-when-user-set
This commit is contained in:
Lucas Berger
2026-06-17 16:48:17 -04:00
parent ac32bd405f
commit be7a0aec90
4 changed files with 326 additions and 2 deletions
+96
View File
@@ -0,0 +1,96 @@
/**
* localAuthMiddleware.ts — local-session cookie → c.set('user') middleware (AUTH-LOCAL-04).
*
* Reads the `local-session` cookie, validates the JWT, fetches the users row,
* and populates `c.get('user')` with the same shape as devAuthBypass so that all
* downstream routes work unchanged.
*
* Mount in index.ts AFTER devAuthBypass() and BEFORE the OIDC guard:
* app.use('/api/*', devAuthBypass());
* app.use('/api/*', localAuthMiddleware()); ← HERE
* if (!devBypassActive) {
* app.use('/api/*', oidcAuthMiddleware()); ← skip if c.get('user') set
* }
*
* Security contract:
* - If c.get('user') is already set (devAuthBypass ran first): no-op passthrough.
* This is Test 4 — dev user is not overwritten.
* - If no 'local-session' cookie is present: pure passthrough WITHOUT calling
* c.set('user', undefined). The OIDC guard fires on falsy c.get('user') only when
* the value was never set — calling c.set('user', undefined) would suppress it.
* This is Pitfall-1 / Test 2.
* - If cookie is valid but the users row is gone: passthrough (no crash).
* This is Test 3.
*
* ContextVariableMap: the `user` shape is declared in devBypass.ts. Import it as a
* side-effect so c.set('user', ...) is typed correctly throughout this file.
*/
// Side-effect import: extends ContextVariableMap with the `user` key (Pitfall — shared shape).
import './devBypass.js';
import type { MiddlewareHandler } from 'hono';
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users } from '../db/schema.js';
import { verifyLocalSessionCookie } from './localSession.js';
/**
* Returns a Hono MiddlewareHandler that:
* 1. Checks if c.get('user') is already set — no-op if so (devAuthBypass-first).
* 2. Calls verifyLocalSessionCookie(c) — returns null if no cookie / invalid / expired.
* 3. On null: calls next() WITHOUT setting user (pure passthrough — OIDC guard can fire).
* 4. On valid userId: SELECTs the users row; if found, c.set('user', {...}); always next().
*/
export function localAuthMiddleware(): MiddlewareHandler {
return async (c, next) => {
// If a prior middleware (devAuthBypass) already set the user, do not overwrite.
if (c.get('user')) {
await next();
return;
}
// Verify the local-session JWT cookie — returns userId or null.
// verifyLocalSessionCookie returns null (never throws) on any error (Pitfall 9 guard).
const userId = await verifyLocalSessionCookie(c);
if (userId === null) {
// No valid local session — pass through WITHOUT setting c.get('user').
// CRITICAL: Do NOT call c.set('user', undefined) — that sets the key to undefined
// which is falsy but "set", breaking the OIDC guard's c.get('user') check (Pitfall 1).
await next();
return;
}
// Load the users row to populate the same shape as DEV_USER.
const [row] = await db
.select({
id: users.id,
oidcIss: users.oidcIss,
oidcSub: users.oidcSub,
displayName: users.displayName,
color: users.color,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!row) {
// userId from JWT but no users row (deleted user) — passthrough without setting user.
await next();
return;
}
// Populate c.get('user') with the same shape as DEV_USER (devBypass.ts ContextVariableMap).
// oidcIss/oidcSub: local users have nullable oidcIss/oidcSub — use fallback strings so the
// shape matches typeof DEV_USER (all required fields, no undefined in the value object).
c.set('user', {
id: row.id,
oidcIss: row.oidcIss ?? 'local',
oidcSub: row.oidcSub ?? String(row.id),
displayName: row.displayName ?? null,
color: row.color ?? '#4A90D9',
});
await next();
};
}