import { randomBytes } from "node:crypto"; import type { FastifyReply, FastifyRequest } from "fastify"; import { eq, rolePermissions, type Db } from "@parking/db"; import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared"; // Local JWT auth helpers — fully local, no external identity provider // (offline-first). The JWT is carried in an HttpOnly cookie (JS can't read it); // a separate readable CSRF cookie + matching header defends mutations // (double-submit). See wiki/entities/local-jwt-auth.md. // // Authorization is DYNAMIC RBAC: the token carries the user's `roleId`, and each // guarded route resolves that role's PERMISSION SET (cached in memory) and checks // the permission it requires. Editing a role takes effect on the next request — // no re-login, no token bloat, no stale perms. See @parking/shared PERMISSIONS. declare module "@fastify/jwt" { interface FastifyJWT { payload: { sub: string; username: string; roleId: string; csrf: string }; user: { sub: string; username: string; roleId: string; csrf: string }; } } export const TOKEN_COOKIE = "parking_token"; export const CSRF_COOKIE = "parking_csrf"; export const CSRF_HEADER = "x-csrf-token"; // Session lifetime: the JWT has NO expiry — a login is valid until explicit // logout. Booth reality breaks any fixed clock (relief late/absent, forced double // shifts), and a shift is a separate explicit boundary, not the token's lifetime. // See wiki/entities/local-jwt-auth.md + wiki/concepts/shift.md. // // The cookie still needs a maxAge so it survives a browser restart (a session // cookie would log out an active operator on browser close — the opposite of // "until logout"). Use a long fixed window; the server clears it on logout. export const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days /** * Resolve the JWT signing secret, refusing to start without a strong one. * There is deliberately no fallback default — a missing, short, or placeholder * secret throws so the server never runs with forgeable tokens. */ export function requireJwtSecret(): string { const secret = process.env.JWT_SECRET; if (!secret || secret.length < 32 || /change.?me|insecure|dev-only/i.test(secret)) { throw new Error( "JWT_SECRET must be set to a strong random value (>=32 chars). " + "Generate one with: openssl rand -hex 32", ); } return secret; } /** * Whether to set the `Secure` flag on the auth/CSRF cookies. FAIL-SAFE: default is * `true` (Secure) — a misconfigured/forgotten env can only ever make cookies MORE * restrictive, never silently drop the flag. * * The previous gate keyed off `NODE_ENV === "production"`, which meant an appliance * deployed without that var leaked cookies over plain HTTP. Now `Secure` is the * default and is dropped ONLY for an explicit, deliberate opt-out — `COOKIE_SECURE` * set to a falsey value (`0/false/no/off`), or the legacy `NODE_ENV !== production` * signal kept as a fallback so existing dev setups still work over http://localhost. * * The parking appliance often serves the SPA same-origin over the LAN with no TLS; * THAT box sets `COOKIE_SECURE=0` on purpose (a Secure cookie would never be sent * over its http origin and would lock operators out). Everything else stays secure. */ export function secureCookies(): boolean { const override = process.env.COOKIE_SECURE; if (override !== undefined) { return !/^(0|false|no|off)$/i.test(override.trim()); } // No explicit override: secure unless this is an obvious local-dev run. return process.env.NODE_ENV !== "development"; } export function newCsrfToken(): string { return randomBytes(32).toString("hex"); } /** Set the auth (HttpOnly) + CSRF (readable) cookies after a successful login. */ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string): void { const secure = secureCookies(); reply.setCookie(TOKEN_COOKIE, jwt, { httpOnly: true, sameSite: "strict", secure, path: "/", maxAge: COOKIE_MAX_AGE_SECONDS, }); // Readable by JS so the SPA can echo it back in the CSRF header (double-submit). reply.setCookie(CSRF_COOKIE, csrf, { httpOnly: false, sameSite: "strict", secure, path: "/", maxAge: COOKIE_MAX_AGE_SECONDS, }); } export function clearAuthCookies(reply: FastifyReply): void { reply.clearCookie(TOKEN_COOKIE, { path: "/" }); reply.clearCookie(CSRF_COOKIE, { path: "/" }); } const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]); /** * Double-submit CSRF check: the X-CSRF-Token header must match the CSRF cookie. * The CSRF token is bound into the JWT at login, so a stolen/forged cookie pair * still can't pass unless it matches the signed token. Only enforced on * state-changing methods (safe reads are exempt). */ function assertCsrf(req: FastifyRequest): void { if (!MUTATING.has(req.method)) return; const header = req.headers[CSRF_HEADER]; const cookie = req.cookies[CSRF_COOKIE]; const tokenCsrf = (req.user as { csrf?: string } | undefined)?.csrf; if (!header || !cookie || header !== cookie || (tokenCsrf && header !== tokenCsrf)) { throw Object.assign(new Error("invalid CSRF token"), { statusCode: 403 }); } } // --- Permission resolution + cache ------------------------------------------- // A role's permission set is read from `role_permissions` and cached in memory. // SQLite is single-writer/single-process here, so a module-level Map is a correct // cache: every role / role-permission mutation calls bumpPermsCache() to clear it, // and the next request re-reads. The built-in `admin` role always resolves to the // FULL permission set in code (never trusts the DB rows for it), so administration // can't be accidentally narrowed. const ADMIN_PERMS: ReadonlySet = new Set(PERMISSIONS); const permsCache = new Map>(); // The DB handle the permission resolver reads from. Set ONCE at startup via // initAuth() so route guards don't each have to thread `db` (several route // modules only receive a monitor/service, not the db). Single-process server. let authDb: Db | null = null; /** Wire the permission resolver to the app's DB. Call once in buildServer(). */ export function initAuth(db: Db): void { authDb = db; permsCache.clear(); } /** Clear the permission cache. Call after ANY write to roles / role_permissions * (or a user's roleId) so the change takes effect on the next request. */ export function bumpPermsCache(): void { permsCache.clear(); } /** The permission set for a role id, cached. `admin` is always the full set. */ export function permissionsFor(roleId: string): ReadonlySet { if (roleId === ADMIN_ROLE_ID) return ADMIN_PERMS; const hit = permsCache.get(roleId); if (hit) return hit; if (!authDb) throw new Error("auth not initialised (call initAuth)"); const rows = authDb .select({ permission: rolePermissions.permission }) .from(rolePermissions) .where(eq(rolePermissions.roleId, roleId)) .all(); const set = new Set(rows.map((r) => r.permission as Permission)); permsCache.set(roleId, set); return set; } /** True if the role grants every listed permission. */ export function roleHasPermissions( roleId: string, required: readonly Permission[], ): boolean { const granted = permissionsFor(roleId); return required.every((p) => granted.has(p)); } /** * preHandler permission guard. Verifies the JWT (from the HttpOnly cookie), * enforces CSRF on mutations, then requires the user's role to grant ALL of the * listed permissions. Authorization is a per-route permission check against the * dynamic, admin-composed role grid — no Casbin/RBAC engine needed at this scale. */ export function requirePermission(...required: Permission[]) { return async (req: FastifyRequest, _reply: FastifyReply) => { await req.jwtVerify(); // reads the token cookie (configured in server.ts) assertCsrf(req); if (!req.user || !roleHasPermissions(req.user.roleId, required)) { throw Object.assign(new Error("forbidden"), { statusCode: 403 }); } }; } /** * preHandler that requires a valid signed-in session but NO specific permission — * for "about me" routes (/me, change own language) every authenticated user may * call regardless of role. Still enforces CSRF on mutations. */ export async function requireAuth( req: FastifyRequest, _reply: FastifyReply, ): Promise { await req.jwtVerify(); assertCsrf(req); }