diff --git a/apps/api/src/auth/localAuthMiddleware.ts b/apps/api/src/auth/localAuthMiddleware.ts new file mode 100644 index 0000000..f4eef0b --- /dev/null +++ b/apps/api/src/auth/localAuthMiddleware.ts @@ -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(); + }; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 214822f..6bd5dd3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -11,12 +11,15 @@ import { listsRouter, listItemsRouter } from './routes/lists.js'; import { pushRouter } from './routes/push.js'; import { adminRouter } from './routes/admin.js'; import { setupRouter } from './routes/setup.js'; +import { authModeRouter } from './routes/authMode.js'; +import { localAuthRouter } from './routes/localAuth.js'; import { oidcAuthMiddleware, processOAuthCallback, oidcConfigFallbackMiddleware, } from './auth/middleware.js'; import { devAuthBypass } from './auth/devBypass.js'; +import { localAuthMiddleware } from './auth/localAuthMiddleware.js'; import { persistSessionCookie } from './auth/persistSessionCookie.js'; import { startBrokerPoller } from './broker/poller.js'; import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js'; @@ -48,15 +51,26 @@ app.route('/health', healthRouter); // 423 lock after setup is complete (SETUP-04 / D-10). app.route('/api/setup', setupRouter); +// Phase 19 — pre-auth auth routes: GET /api/auth/mode and POST /api/auth/local/login, /logout. +// Mounted BEFORE devAuthBypass so they are reachable without a session (D-01 / AUTH-LOCAL-05). +app.route('/api/auth', authModeRouter); +app.route('/api/auth', localAuthRouter); + // Dev-auth bypass — no-op passthrough unless DEV_AUTH_BYPASS=true AND NODE_ENV!='production'. // When active, injects DEV_USER into the Hono context and the OIDC guard is NOT mounted. // Must be mounted BEFORE oidcAuthMiddleware (T-02-01 mitigation; see auth/devBypass.ts). app.use('/api/*', devAuthBypass()); +// Phase 19 — local-session middleware: sets c.get('user') from 'local-session' JWT cookie. +// No-op passthrough when no cookie is present — the OIDC guard fires for unauthenticated. +// Runs AFTER devAuthBypass (which may set c.get('user') first) and BEFORE the OIDC guard. +// The OIDC guard below is wrapped to skip when c.get('user') is already set (Pitfall 1 guard). +app.use('/api/*', localAuthMiddleware()); + // Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05). // Skipped entirely when devBypassActive so that local dev works without Authelia. // In production devBypassActive is always false — OIDC is unconditionally mounted. -// Unauthenticated requests receive a 302 redirect to Authelia's authorize endpoint. +// Unauthenticated requests receive a 302 redirect to the OIDC authorize endpoint. // OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct // redirect_uri (Pitfall 1). Set it to https://familysync.. if (!devBypassActive) { @@ -67,7 +81,16 @@ if (!devBypassActive) { // A2 CONFIRMED: oidcAuthMiddleware() reads process.env per-request (call time), so // injecting into process.env here is safe and effective (see auth/middleware.ts A2 note). app.use('/api/*', oidcConfigFallbackMiddleware); - app.use('/api/*', oidcAuthMiddleware()); + // Phase 19 / D-03: OIDC guard wrapped to skip when c.get('user') is already set. + // A valid local-session (or dev-bypass) user must NOT be 302-redirected to the OIDC + // provider — the skip-when-set wrapper is the coexistence seam (D-03 / RESEARCH Pitfall 1). + app.use('/api/*', async (c, next) => { + if (c.get('user')) { + await next(); + return; + } + await oidcAuthMiddleware()(c, next); + }); // Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02). app.use('/api/*', persistSessionCookie()); } diff --git a/apps/api/src/routes/authMode.ts b/apps/api/src/routes/authMode.ts new file mode 100644 index 0000000..a4dcadd --- /dev/null +++ b/apps/api/src/routes/authMode.ts @@ -0,0 +1,50 @@ +/** + * authMode.ts — GET /api/auth/mode pre-auth endpoint (AUTH-LOCAL-05). + * + * Returns { localEnabled: boolean, oidcEnabled: boolean } reflecting the current + * authentication configuration. This endpoint is intentionally reachable before + * authentication (same pre-auth pattern as GET /api/setup/status). + * + * Mount in index.ts BEFORE devAuthBypass and OIDC guard: + * app.route('/api/auth', authModeRouter); ← pre-auth + * + * Response contract: + * - localEnabled: always true — local auth is the default, always available (D-01). + * - oidcEnabled: true when OIDC_ISSUER env var is set, OR when app_config has + * an oidc_issuer row (supports wizard-configured OIDC before container restart). + * + * No auth gate, no isSetupLocked() check — the PWA fetches this on app load before + * knowing if the user is authenticated. + */ + +import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { appConfig } from '../db/schema.js'; + +export const authModeRouter = new Hono(); + +/** + * GET / (mounted as /api/auth, so effective path is GET /api/auth/mode) + * + * Checks OIDC_ISSUER env var first (process.env is cheapest); falls back to + * app_config DB row if env var is absent (wizard-configured OIDC). + */ +authModeRouter.get('/mode', async (c) => { + // localEnabled: always true (D-01) + // oidcEnabled: env var first, then app_config fallback + const issuerFromEnv = process.env.OIDC_ISSUER; + let oidcEnabled = Boolean(issuerFromEnv); + + if (!oidcEnabled) { + // Check app_config for oidc_issuer (wizard-written, pre-restart-fallback pattern from middleware.ts) + const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'oidc_issuer')) + .limit(1); + oidcEnabled = Boolean(row?.value); + } + + return c.json({ localEnabled: true, oidcEnabled }); +}); diff --git a/apps/api/src/routes/localAuth.ts b/apps/api/src/routes/localAuth.ts new file mode 100644 index 0000000..f3b3663 --- /dev/null +++ b/apps/api/src/routes/localAuth.ts @@ -0,0 +1,155 @@ +/** + * localAuth.ts — local authentication routes (AUTH-LOCAL-03, AUTH-LOCAL-06). + * + * Routes: + * POST /api/auth/local/login — rate-limited credential verify + session cookie issue + * POST /api/auth/local/logout — clear local-session cookie + * GET /api/auth/local/logout — alias for POST /logout (some browsers prefer GET) + * + * Mounted in index.ts as app.route('/api/auth', localAuthRouter) BEFORE any auth middleware. + * This means POST /api/auth/local/login is reachable without a session (pre-auth, per D-01). + * + * Security (T-19-11, T-19-12, T-19-14): + * - noEchoHook on login: Zod errors NEVER return received values (T-19-14 / Pitfall 7). + * - Dummy-hash timing defense: verifyPassword is always called, even for unknown usernames, + * to prevent timing-oracle username enumeration attacks (T-19-12 / RESEARCH Pitfall 2). + * - Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12). + * - Per-IP in-memory rate-limiting: 5 failures → 429; 10 failures → 423 (T-19-11). + * - Lockout (423) cleared only by admin password reset — no self-service unlock. + */ + +import { Hono } from 'hono'; +import type { Context } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { localCredentials } from '../db/schema.js'; +import { verifyPassword, hashPassword } from '../auth/localCredentials.js'; +import { issueLocalSessionCookie, clearLocalSessionCookie } from '../auth/localSession.js'; + +export const localAuthRouter = new Hono(); + +// --------------------------------------------------------------------------- +// noEchoHook — NEVER return Zod validation error details for the login route. +// Zod's error object contains issues[].received which may echo the submitted password. +// Always return { error: 'Invalid request' } 400, no other fields (T-19-14). +// --------------------------------------------------------------------------- + +const noEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) { + return c.json({ error: 'Invalid request' }, 400); + } +}; + +// --------------------------------------------------------------------------- +// Zod schema for login body +// --------------------------------------------------------------------------- + +const loginSchema = z.object({ + username: z.string().min(1).max(128).trim(), + password: z.string().min(1).max(1000), +}); + +// --------------------------------------------------------------------------- +// Per-IP rate-limiting state (in-memory Map — household scale, no Redis needed). +// +// State shape per IP: { count, lockedUntil (epoch ms), lockedOut (bool) } +// count >= RATE_WINDOW_FAILURES AND Date.now() < lockedUntil → 429 +// count >= LOCKOUT_FAILURES → 423 (permanent until admin reset) +// Success → delete entry (clears counter) +// +// RATE_WINDOW_FAILURES: 5 failures → 60s cooldown (429) +// LOCKOUT_FAILURES: 10 failures → account locked (423) +// --------------------------------------------------------------------------- + +export const loginAttempts = new Map(); + +const RATE_WINDOW_FAILURES = 5; +const RATE_WINDOW_SECS = 60; +const LOCKOUT_FAILURES = 10; + +// Pre-computed dummy hash used to run verifyPassword on unknown-username paths +// (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2). +// Computed once at module load time; the actual value is never used for auth. +const DUMMY_HASH = hashPassword('dummy-constant-time-filler-xyzzy'); + +// --------------------------------------------------------------------------- +// POST /local/login → POST /api/auth/local/login +// --------------------------------------------------------------------------- + +localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook), async (c) => { + // Derive client IP from Pangolin-set X-Forwarded-For header; fall back to host header. + const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() + ?? c.req.raw.headers.get('host') + ?? 'unknown'; + + const attempt = loginAttempts.get(ip); + + // 423: account locked (>= LOCKOUT_FAILURES total failures, admin must reset) + if (attempt?.lockedOut) { + return c.json({ error: 'Account locked' }, 423); + } + + // 429: rate window — >= RATE_WINDOW_FAILURES failures within the cooldown window + if (attempt && attempt.count >= RATE_WINDOW_FAILURES && Date.now() < attempt.lockedUntil) { + return c.json({ error: 'Too many attempts' }, 429); + } + + const { username, password } = c.req.valid('json'); + + // Look up local_credentials by username + let cred: { userId: number; passwordHash: string } | undefined; + try { + const [found] = await db + .select({ userId: localCredentials.userId, passwordHash: localCredentials.passwordHash }) + .from(localCredentials) + .where(eq(localCredentials.username, username)) + .limit(1); + cred = found; + } catch (err) { + console.error('[localAuth/POST /local/login] DB error:', err instanceof Error ? err.message : String(err)); + return c.json({ error: 'Service unavailable' }, 503); + } + + // ALWAYS run verifyPassword — even for unknown usernames — to prevent timing-oracle + // username enumeration (T-19-12 / RESEARCH Pitfall 2). Use a pre-computed dummy hash + // so the scrypt work is always performed regardless of whether username was found. + const valid = cred + ? verifyPassword(cred.passwordHash, password) + : verifyPassword(DUMMY_HASH, password); + + if (!valid || !cred) { + // Increment failure counter + const cur = loginAttempts.get(ip) ?? { count: 0, lockedUntil: 0, lockedOut: false }; + cur.count += 1; + cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000; + cur.lockedOut = cur.count >= LOCKOUT_FAILURES; + loginAttempts.set(ip, cur); + // Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12) + return c.json({ error: 'Invalid credentials' }, 401); + } + + // Success: clear failure counter, issue the local-session JWT cookie, return ok + loginAttempts.delete(ip); + try { + await issueLocalSessionCookie(c, cred.userId); + } catch (err) { + console.error('[localAuth/POST /local/login] Cookie issue error:', err instanceof Error ? err.message : String(err)); + return c.json({ error: 'Service unavailable' }, 503); + } + return c.json({ ok: true }, 200); +}); + +// --------------------------------------------------------------------------- +// POST /local/logout → POST /api/auth/local/logout +// GET /local/logout → GET /api/auth/local/logout (browser-redirect alias) +// --------------------------------------------------------------------------- + +async function handleLogout(c: Context) { + clearLocalSessionCookie(c); + return c.json({ ok: true }, 200); +} + +localAuthRouter.post('/local/logout', handleLogout); +localAuthRouter.get('/local/logout', handleLogout);