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
+73 -7
View File
@@ -1,15 +1,27 @@
import { randomBytes } from "node:crypto";
import type { FastifyReply, FastifyRequest } from "fastify";
import type { Role } from "@parking/shared";
// Local JWT auth helpers — fully local, no external identity provider
// (offline-first). See wiki/entities/local-jwt-auth.md.
// (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.
declare module "@fastify/jwt" {
interface FastifyJWT {
payload: { sub: string; username: string; role: Role };
user: { sub: string; username: string; role: Role };
payload: { sub: string; username: string; role: Role; csrf: string };
user: { sub: string; username: string; role: Role; csrf: string };
}
}
export const TOKEN_COOKIE = "parking_token";
export const CSRF_COOKIE = "parking_csrf";
export const CSRF_HEADER = "x-csrf-token";
/** Token lifetime, also used as the cookie maxAge. */
export const TOKEN_TTL = "8h";
export const TOKEN_TTL_SECONDS = 8 * 60 * 60;
/**
* Resolve the JWT signing secret, refusing to start without a strong one.
* There is deliberately no fallback default — a missing, short, or placeholder
@@ -26,13 +38,67 @@ export function requireJwtSecret(): string {
return secret;
}
/** Cookies are secure in production; relaxed for local http dev. */
function secureCookies(): boolean {
return process.env.NODE_ENV === "production";
}
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: TOKEN_TTL_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: TOKEN_TTL_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"]);
/**
* preHandler role guard. Authorization is a simple per-route role check — no
* Casbin/RBAC engine needed at this scale. See wiki/entities/local-jwt-auth.md.
* 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 });
}
}
/**
* preHandler role guard. Verifies the JWT (from the HttpOnly cookie), enforces
* CSRF on mutations, then checks the role. Authorization is a simple per-route
* role check — no Casbin/RBAC engine needed at this scale.
*/
export function requireRole(...allowed: Role[]) {
return async (req: { jwtVerify: () => Promise<void>; user?: { role: Role } }) => {
await req.jwtVerify();
return async (req: FastifyRequest, _reply: FastifyReply) => {
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
assertCsrf(req);
if (!req.user || !allowed.includes(req.user.role)) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
}