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:
@@ -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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user