Cookie-based auth/authz with CSRF; remove auth bypass

Replace the dev-only token shim with real authentication.

Backend:
- @fastify/cookie; JWT carried in an HttpOnly + SameSite=Strict cookie
  (parking_token), read from the cookie not the Authorization header.
- Double-submit CSRF: readable parking_csrf cookie + X-CSRF-Token header, both
  cross-checked against a csrf claim baked into the JWT; enforced on mutations.
- Routes: POST /api/auth/login (bcrypt, constant-time-ish), POST logout,
  GET me. requireRole now verifies the cookie + CSRF + role.
- seed-admin script (pnpm --filter @parking/server seed-admin) for the first
  admin; no bootstrap endpoint.
- Removed SETUP_AUTH_BYPASS and catalog.authBypass entirely; setup endpoints
  use the cookie admin guard like everything else.

Frontend:
- apiFetch wrapper: credentials:'include' + X-CSRF-Token on mutations.
- Login form; App gates on /api/auth/me and only shows setup to admins; logout.
- Wizard token field removed (auth is the session cookie).

Deploy:
- deploy/nginx.conf: prod reverse proxy, SPA + /api same-origin, TLS, so the
  Secure cookies work. Dev stays same-origin via the Vite proxy.

Verified (curl + browser): wrong pass -> 401; login sets cookies; me -> admin;
assign without CSRF -> 403, with -> 201; no cookie -> 401; session persists
across reload. wiki/local-jwt-auth updated.
This commit is contained in:
2026-06-14 10:45:38 +02:00
parent 77606da2c9
commit 64d5e45f11
15 changed files with 490 additions and 138 deletions
+3 -49
View File
@@ -24,26 +24,15 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line));
// TEMPORARY hardware-bench escape hatch. When SETUP_AUTH_BYPASS=1, the setup
// endpoints skip the admin guard so devices can be discovered/assigned before
// the login flow exists. Remove once real admin login is wired.
//
// Hardened (flagged by security review): this can NEVER silently open auth in
// a deployable config. It is honoured ONLY when all hold, else the server
// FAILS CLOSED (throws) rather than running unauthenticated:
// (a) NODE_ENV !== 'production'
// (b) the listener is bound to loopback (HOST is 127.0.0.1 / ::1 / localhost)
// See server.ts TODO + wiki/concepts/first-run-setup.md.
const { guard: adminGuard, bypassed: authBypass } = resolveAdminGuard(app);
// Setup endpoints require an admin (cookie-based JWT — see ../auth.ts).
const adminGuard = requireRole("admin");
// Catalog of selectable drivers per category (no secrets — schema only).
// `discoverable` flags drivers that can scan the LAN (e.g. UHPPOTE).
// `authBypass` tells the UI the setup endpoints aren't requiring a token
// (testing only), so it can drop the admin-token requirement.
app.get("/api/setup/catalog", async () => {
const catalog = registry.catalog();
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
return { ...catalog, discoverable, authBypass };
return { ...catalog, discoverable };
});
// Scan the LAN for devices a driver can discover (UHPPOTE UDP broadcast, etc).
@@ -132,38 +121,3 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
},
);
}
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
/**
* Resolve the setup admin guard. Returns the real admin role guard unless the
* SETUP_AUTH_BYPASS escape hatch is both requested AND safe; if it's requested
* but unsafe, throws so the server fails closed instead of running open.
* `bypassed` is surfaced to the UI so it can drop the admin-token requirement.
*/
function resolveAdminGuard(app: FastifyInstance): {
guard: ReturnType<typeof requireRole>;
bypassed: boolean;
} {
if (process.env.SETUP_AUTH_BYPASS !== "1") {
return { guard: requireRole("admin"), bypassed: false };
}
const isProd = process.env.NODE_ENV === "production";
const host = process.env.HOST ?? "0.0.0.0";
const isLoopback = LOOPBACK_HOSTS.has(host);
if (isProd || !isLoopback) {
// Fail closed: never honour an auth bypass in production or on a non-loopback
// listener (that would expose unauthenticated setup endpoints on the network).
throw new Error(
`SETUP_AUTH_BYPASS refused: requires NODE_ENV!=production (is "${process.env.NODE_ENV ?? "undefined"}") ` +
`and a loopback HOST (is "${host}"). Set HOST=127.0.0.1 for local testing, or unset the bypass.`,
);
}
app.log.warn(
`⚠️ SETUP_AUTH_BYPASS=1 — /api/setup/* admin auth DISABLED on ${host} (testing only)`,
);
return { guard: async () => {}, bypassed: true };
}