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
+4 -7
View File
@@ -13,11 +13,8 @@ JWT_SECRET=
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
# LOG_LEVEL=info
# DATABASE_URL=./parking.sqlite
# NODE_ENV=production # set in prod: makes auth cookies Secure (HTTPS-only)
# Testing-only ------------------------------------------------------------
# Bypass the admin auth on /api/setup/* so you can discover/assign devices
# before the login flow exists. HARDENED: only honoured when NODE_ENV is not
# "production" AND HOST is loopback (127.0.0.1 / ::1 / localhost); otherwise
# the server refuses to start. Never set this in production.
# SETUP_AUTH_BYPASS=1
# HOST=127.0.0.1
# First admin (seed once): pnpm --filter @parking/server seed-admin
# ADMIN_USER=admin
# ADMIN_PASS=
+2
View File
@@ -7,10 +7,12 @@
"build": "tsc -b",
"dev": "node --env-file-if-exists=.env --watch --experimental-strip-types src/index.ts",
"start": "node --env-file-if-exists=.env dist/index.js",
"seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "11.2.0",
"@fastify/jwt": "10.1.0",
"@fastify/static": "9.1.3",
+59
View File
@@ -0,0 +1,59 @@
// Seed the first admin user (run once at install).
//
// ADMIN_USER=admin ADMIN_PASS='strong-pass' \
// node --env-file-if-exists=.env apps/server/scripts/seed-admin.mjs
//
// Or interactively (prompts for a hidden password):
// node --env-file-if-exists=.env apps/server/scripts/seed-admin.mjs <username>
//
// Idempotent-ish: refuses to overwrite an existing user unless FORCE=1.
import { randomUUID } from "node:crypto";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const bcrypt = require("bcrypt");
const { createDb, users, eq } = require("@parking/db");
const username = process.env.ADMIN_USER ?? process.argv[2];
let password = process.env.ADMIN_PASS;
if (!username) {
console.error("usage: ADMIN_USER=.. ADMIN_PASS=.. seed-admin.mjs (or pass a username arg)");
process.exit(1);
}
if (!password) {
const rl = createInterface({ input: stdin, output: stdout });
password = (await rl.question(`Password for "${username}": `)).trim();
rl.close();
}
if (!password || password.length < 8) {
console.error("password must be at least 8 characters");
process.exit(1);
}
const db = createDb();
const existing = await db.select().from(users).where(eq(users.username, username)).get();
if (existing && process.env.FORCE !== "1") {
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
process.exit(1);
}
const passwordHash = await bcrypt.hash(password, 12);
if (existing) {
await db.update(users).set({ passwordHash, role: "admin" }).where(eq(users.id, existing.id));
console.log(`reset password for admin "${username}"`);
} else {
await db.insert(users).values({
id: randomUUID(),
username,
passwordHash,
role: "admin",
});
console.log(`created admin "${username}"`);
}
process.exit(0);
+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 });
}
+59
View File
@@ -0,0 +1,59 @@
import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify";
import { eq, users, type Db } from "@parking/db";
import {
TOKEN_TTL,
clearAuthCookies,
newCsrfToken,
requireRole,
setAuthCookies,
} from "../auth.js";
// Local auth: username + bcrypt password → signed JWT in an HttpOnly cookie.
// Fully offline; no external identity provider. See wiki/entities/local-jwt-auth.md.
interface LoginBody {
username: string;
password: string;
}
export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
app.post<{ Body: LoginBody }>("/api/auth/login", async (req, reply) => {
const { username, password } = req.body ?? {};
if (!username || !password) {
return reply.code(400).send({ error: "username and password required" });
}
const user = await db.select().from(users).where(eq(users.username, username)).get();
// Always run a bcrypt compare to avoid leaking which usernames exist (timing).
const hash = user?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
const ok = await bcrypt.compare(password, hash);
if (!user || !ok) {
return reply.code(401).send({ error: "invalid credentials" });
}
const csrf = newCsrfToken();
const token = await reply.jwtSign(
{ sub: user.id, username: user.username, role: user.role, csrf },
{ expiresIn: TOKEN_TTL },
);
setAuthCookies(reply, token, csrf);
return { id: user.id, username: user.username, role: user.role };
});
app.post("/api/auth/logout", async (_req, reply) => {
clearAuthCookies(reply);
return { ok: true };
});
// Who am I — used by the SPA to bootstrap session state on load.
app.get(
"/api/auth/me",
{ preHandler: requireRole("admin", "operator", "cashier", "readonly") },
async (req) => {
const { sub, username, role } = req.user;
return { id: sub, username, role };
},
);
}
+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 };
}
+11 -2
View File
@@ -1,7 +1,9 @@
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import Fastify, { type FastifyInstance } from "fastify";
import { createDb, type Db } from "@parking/db";
import { requireJwtSecret } from "./auth.js";
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
import { authRoutes } from "./routes/auth.js";
import { setupRoutes } from "./routes/setup.js";
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
@@ -19,22 +21,29 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
const db = opts.db ?? createDb();
await app.register(cookie);
// Local JWT signing with a local secret — no external identity provider.
// Fail fast rather than fall back to a known default: a booth machine started
// without a real secret would sign tokens anyone could forge (incl. an admin
// token), defeating the whole local-auth/anti-fraud model. No insecure default.
// The token is carried in an HttpOnly cookie (not the Authorization header).
await app.register(jwt, {
secret: requireJwtSecret(),
sign: { expiresIn: "8h" }, // bound to a shift; minted tokens must expire
cookie: { cookieName: TOKEN_COOKIE, signed: false },
});
app.get("/health", async () => ({ status: "ok" }));
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
await authRoutes(app, db);
// Device-agnostic setup: the admin selects devices per lane from the driver
// catalog at first-run. See wiki/concepts/first-run-setup.md.
await setupRoutes(app, db);
// TODO: device-driver runtime plugins, append-only event-log routes, login.
// TODO: device-driver runtime plugins, append-only event-log routes.
return app;
}