--- type: entity tags: [parking, stack, auth, offline-first] sources: [parking-system-architecture] updated: 2026-06-15 --- # Local JWT Auth Authentication and authorization, kept **fully local** — a direct consequence of [[offline-first]] (an air-gapped park cannot reach an external identity provider; see [[logto-zitadel-oidc]] for the rejected alternative). (See [[parking-system-architecture]] §2.) - `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no insecure default. - **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15). Booth reality breaks any fixed clock: relief arrives late, fails to show, or one operator is forced to work two shifts in a row — a token that expired mid-duty would strand an active operator. So the login persists until logout; a **[[shift]] is a separate, explicit boundary**, not tied to token lifetime. (Superseded the earlier "8h expiry, bound to a shift" assumption.) > ⚠️ Code still mints an 8h-expiry token — this page records the decided design; the server > change (drop `expiresIn`, persist until logout) is pending. - A `users` table in [[sqlite]] holds **bcrypt** password hashes plus a **role** column. The first admin is seeded via `pnpm --filter @parking/server seed-admin` (no bootstrap endpoint). - Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier / readonly**. No Casbin or full RBAC engine needed at this scale. ## Cookie session (browser auth) The SPA never sees the JWT. Login (`POST /api/auth/login`) verifies bcrypt and sets two cookies: - **`parking_token`** — the JWT, **HttpOnly + SameSite=Strict** (+ `Secure` when `NODE_ENV=production`). JS can't read it; `@fastify/jwt` reads it from the cookie, not the `Authorization` header. - **`parking_csrf`** — a random token, **readable** by JS. The JWT also carries a matching `csrf` claim. On every mutation the SPA echoes the cookie in the **`X-CSRF-Token`** header; the guard requires header == cookie == the signed claim (**double-submit CSRF**). Safe reads are exempt. Routes: `login`, `logout` (clears cookies), `me` (bootstraps SPA session on load). The dev [[react-vite-spa|Vite]] proxy and the prod **nginx** reverse proxy keep the SPA and API **same-origin**, so the cookies work without CORS. (This replaced an earlier dev-only `SETUP_AUTH_BYPASS` shim, now removed.) > **Open decision:** moving from the symmetric secret to an **asymmetric key (RS256/EdDSA)** so > verifying hosts hold only a public key — [[open-questions]] #7. Relevant before any > multi-host/multi-lane deployment. Part of the [[technology-stack]]. License: MIT.