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
+82 -29
View File
@@ -1,4 +1,78 @@
// Thin API client for the operator/admin UI.
//
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
// automatically (credentials: 'include'). For mutations we echo the readable
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
// wiki/entities/local-jwt-auth.md.
const CSRF_COOKIE = "parking_csrf";
const CSRF_HEADER = "X-CSRF-Token";
function readCookie(name: string): string | null {
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return m ? decodeURIComponent(m[1]!) : null;
}
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? "GET").toUpperCase();
const headers = new Headers(init.headers);
if (init.body && !headers.has("content-type")) {
headers.set("content-type", "application/json");
}
if (method !== "GET" && method !== "HEAD") {
const csrf = readCookie(CSRF_COOKIE);
if (csrf) headers.set(CSRF_HEADER, csrf);
}
const res = await fetch(path, { ...init, headers, credentials: "include" });
if (!res.ok) {
const msg = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(msg.error ?? `${path}: ${res.status}`, res.status);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
// --- Auth -----------------------------------------------------------------
export type Role = "admin" | "operator" | "cashier" | "readonly";
export interface SessionUser {
id: string;
username: string;
role: Role;
}
export function login(username: string, password: string): Promise<SessionUser> {
return apiFetch<SessionUser>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export function logout(): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/logout", { method: "POST" });
}
/** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> {
try {
return await apiFetch<SessionUser>("/api/auth/me");
} catch (e) {
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
throw e;
}
}
// --- Device setup ---------------------------------------------------------
export interface ConfigField {
key: string;
@@ -22,14 +96,10 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
/** Driver ids that support LAN discovery. */
discoverable: string[];
/** True when setup endpoints skip admin auth (testing only) — no token needed. */
authBypass: boolean;
};
export async function fetchCatalog(): Promise<Catalog> {
const res = await fetch("/api/setup/catalog");
if (!res.ok) throw new Error(`catalog: ${res.status}`);
return res.json() as Promise<Catalog>;
export function fetchCatalog(): Promise<Catalog> {
return apiFetch<Catalog>("/api/setup/catalog");
}
export interface DiscoveredDevice {
@@ -41,18 +111,10 @@ export interface DiscoveredDevice {
}
/** Scan the LAN for devices a driver can discover (e.g. UHPPOTE). Admin-only. */
export async function discoverDevices(
token: string,
driverId: string,
): Promise<DiscoveredDevice[]> {
const res = await fetch(`/api/setup/discover/${driverId}`, {
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) {
const msg = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(msg.error ?? `discover: ${res.status}`);
}
const body = (await res.json()) as { devices: DiscoveredDevice[] };
export async function discoverDevices(driverId: string): Promise<DiscoveredDevice[]> {
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
`/api/setup/discover/${driverId}`,
);
return body.devices;
}
@@ -63,15 +125,6 @@ export interface AssignBody {
config: Record<string, string | number | boolean>;
}
export async function assignDevice(token: string, body: AssignBody): Promise<unknown> {
const res = await fetch("/api/setup/assign", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify(body),
});
if (!res.ok) {
const msg = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(msg.error ?? `assign: ${res.status}`);
}
return res.json();
export function assignDevice(body: AssignBody): Promise<unknown> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
}