CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
Lint (eslint --max-warnings 0): - index.ts: disable no-unsafe-argument on the type-only Context mismatch when delegating to the OIDC handler inside the local-session skip wrapper - localAuth.ts: handleLogout is sync (no await) — drop async (require-await) - devBypass.ts: disable detect-possible-timing-attacks on the public well-known dev-placeholder string compare (not a secret comparison) - remove dead code / unused bindings flagged by no-unused-vars: makeTestApp (localSession.test), makeUnauthContext + BrowserContext import (login.spec), unused memberId (admin.test), unused txSelectCount counter (me.test) - localAuthMiddleware.test / me.test: fix unused + reflow-detached eslint-disable directives Format: prettier --write across the 20 Phase-19 files that were never formatted. Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key regex hit on "credential atomically, 409-equivalent"). Neither is a real secret. Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks), PWA 266/266, API 452/452. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
/**
|
|
* 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-USERNAME in-memory rate-limiting: 5 failures → 429; 10 failures → 423 (T-19-11).
|
|
* - Lockout (423) auto-expires after LOCKOUT_TTL_MS (CR-04) so a single attacker cannot
|
|
* permanently deny login for the whole household, and no process restart is needed to
|
|
* recover. Admin password reset still clears it immediately (resetLoginAttempts).
|
|
*
|
|
* CR-04: the limiter is keyed on the submitted username, NOT the client IP. In this
|
|
* deployment all household traffic egresses the Pangolin tunnel with the same
|
|
* X-Forwarded-For first hop, so IP-keying made one bad actor (or one fat-fingered user)
|
|
* able to lock out every member, and X-Forwarded-For is attacker-spoofable. Username-keying
|
|
* scopes the lockout to the identity actually under attack and the TTL makes it self-healing.
|
|
*/
|
|
|
|
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-USERNAME rate-limiting state (in-memory Map — household scale, no Redis needed).
|
|
//
|
|
// State shape per username: { count, lockedUntil (epoch ms), lockedOut (bool), lockedAt (epoch ms) }
|
|
// count >= RATE_WINDOW_FAILURES AND Date.now() < lockedUntil → 429
|
|
// count >= LOCKOUT_FAILURES → 423 (until LOCKOUT_TTL_MS elapses OR admin reset)
|
|
// Success → delete entry (clears counter)
|
|
//
|
|
// RATE_WINDOW_FAILURES: 5 failures → 60s cooldown (429)
|
|
// LOCKOUT_FAILURES: 10 failures → account locked (423)
|
|
// LOCKOUT_TTL_MS: 15 min after which a 423 lockout auto-expires (CR-04)
|
|
//
|
|
// CR-04: keyed on the validated username (not the client IP) so a lockout is scoped to
|
|
// the identity under attack, and self-heals after LOCKOUT_TTL_MS without a restart.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const loginAttempts = new Map<
|
|
string,
|
|
{ count: number; lockedUntil: number; lockedOut: boolean; lockedAt: number }
|
|
>();
|
|
|
|
const RATE_WINDOW_FAILURES = 5;
|
|
const RATE_WINDOW_SECS = 60;
|
|
const LOCKOUT_FAILURES = 10;
|
|
const LOCKOUT_TTL_MS = 15 * 60 * 1000; // CR-04: 423 lockout auto-expires after 15 minutes
|
|
|
|
/**
|
|
* Immediately clear any rate-limit / lockout state for a username (CR-04).
|
|
*
|
|
* Called by the admin password-reset path so a reset is an instant unlock and the
|
|
* lockout is not "unrecoverable without a process restart". Safe to call for an
|
|
* unknown username (no-op). Normalizes the username the same way the login schema
|
|
* does (trim) so the key matches what the limiter stored.
|
|
*/
|
|
export function resetLoginAttempts(username: string): void {
|
|
loginAttempts.delete(username.trim());
|
|
}
|
|
|
|
/**
|
|
* IN-03: evict stale rate-limit entries to bound the in-memory map size.
|
|
*
|
|
* An entry is stale (and safe to drop) when it is neither inside an active rate-limit
|
|
* window nor inside an active lockout TTL:
|
|
* - locked-out entries expire LOCKOUT_TTL_MS after lockedAt
|
|
* - non-locked entries expire once their lockedUntil window has passed
|
|
*
|
|
* Called opportunistically at the top of each login request. Dropping an entry is
|
|
* behaviourally identical to the entry having naturally expired, so eviction never
|
|
* weakens the brute-force defense — it only reclaims memory for identities no longer
|
|
* under active rate-limiting.
|
|
*/
|
|
function evictStaleLoginAttempts(now: number): void {
|
|
for (const [k, v] of loginAttempts) {
|
|
const lockoutExpired = v.lockedOut ? now - v.lockedAt >= LOCKOUT_TTL_MS : true;
|
|
const windowExpired = now >= v.lockedUntil;
|
|
if (lockoutExpired && windowExpired) {
|
|
loginAttempts.delete(k);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pre-computed dummy hash used to run verifyPassword on unknown-username paths
|
|
// (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2).
|
|
// WR-03: hashPassword is now async. Kick off the computation once at module load and keep
|
|
// the PROMISE; the login handler awaits it. The value is never used for auth — only to make
|
|
// the unknown-username path perform the same scrypt work as the known-username path.
|
|
const dummyHashPromise: Promise<string> = hashPassword('dummy-constant-time-filler-xyzzy');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /local/login → POST /api/auth/local/login
|
|
// ---------------------------------------------------------------------------
|
|
|
|
localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook), async (c) => {
|
|
// CR-04: the rate-limit / lockout key is the validated, normalized username — NOT the
|
|
// client IP. zValidator has already run, so c.req.valid('json') is available here.
|
|
const { username, password } = c.req.valid('json');
|
|
const key = username; // loginSchema .trim()s the username; the map key matches resetLoginAttempts
|
|
|
|
const now = Date.now();
|
|
// IN-03: opportunistically reclaim memory from entries that are no longer actively
|
|
// rate-limited or locked out. Cheap at household scale; bounds the map under input churn.
|
|
evictStaleLoginAttempts(now);
|
|
const attempt = loginAttempts.get(key);
|
|
|
|
// 423: account locked (>= LOCKOUT_FAILURES failures). CR-04: the lockout auto-expires
|
|
// after LOCKOUT_TTL_MS so a single attacker cannot deny login indefinitely and no restart
|
|
// is required to recover. On expiry, drop the entry so the next attempt starts clean.
|
|
if (attempt?.lockedOut) {
|
|
if (now - attempt.lockedAt >= LOCKOUT_TTL_MS) {
|
|
loginAttempts.delete(key);
|
|
} else {
|
|
return c.json({ error: 'Account locked' }, 423);
|
|
}
|
|
}
|
|
|
|
// Re-read after a possible expiry-delete above.
|
|
const live = loginAttempts.get(key);
|
|
|
|
// 429: rate window — >= RATE_WINDOW_FAILURES failures within the cooldown window.
|
|
// Increment the counter even on 429 so continued brute-force accumulates toward lockout.
|
|
if (live && live.count >= RATE_WINDOW_FAILURES && now < live.lockedUntil) {
|
|
live.count += 1;
|
|
// WR-06: do NOT extend lockedUntil here. This request was itself REJECTED by the window;
|
|
// re-arming the cooldown on every blocked attempt let an attacker who keeps hammering the
|
|
// endpoint slide the window forward forever, so a legitimate user behind the same identity
|
|
// could never get back in even after pausing. The window stays anchored to when it was
|
|
// first armed (in the failure path below); it expires on schedule regardless of rejected
|
|
// traffic. The lockout (423) still triggers once the failure count crosses the threshold.
|
|
live.lockedOut = live.count >= LOCKOUT_FAILURES;
|
|
if (live.lockedOut && live.lockedAt === 0) live.lockedAt = now;
|
|
loginAttempts.set(key, live);
|
|
if (live.lockedOut) {
|
|
return c.json({ error: 'Account locked' }, 423);
|
|
}
|
|
return c.json({ error: 'Too many attempts' }, 429);
|
|
}
|
|
|
|
// 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.
|
|
// WR-03: verifyPassword is async (threadpool scrypt) — await it.
|
|
const valid = cred
|
|
? await verifyPassword(cred.passwordHash, password)
|
|
: await verifyPassword(await dummyHashPromise, password);
|
|
|
|
if (!valid || !cred) {
|
|
// Increment failure counter (keyed on username)
|
|
const cur = loginAttempts.get(key) ?? {
|
|
count: 0,
|
|
lockedUntil: 0,
|
|
lockedOut: false,
|
|
lockedAt: 0,
|
|
};
|
|
cur.count += 1;
|
|
cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
|
|
cur.lockedOut = cur.count >= LOCKOUT_FAILURES;
|
|
if (cur.lockedOut && cur.lockedAt === 0) cur.lockedAt = Date.now();
|
|
loginAttempts.set(key, 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(key);
|
|
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)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function handleLogout(c: Context) {
|
|
clearLocalSessionCookie(c);
|
|
return c.json({ ok: true }, 200);
|
|
}
|
|
|
|
localAuthRouter.post('/local/logout', handleLogout);
|
|
localAuthRouter.get('/local/logout', handleLogout);
|