Files
parking_solution/wiki/entities/local-jwt-auth.md
T
julian a8c6d6e714 auth: JWT valid until logout (drop 8h expiry)
Booth reality breaks a fixed clock (relief late/absent, forced double shifts),
and a shift is a separate explicit boundary. Drop expiresIn from the global jwt
config and from login; the token carries no exp. Cookie maxAge = 30 days so a
browser restart doesn't log out an active operator; logout still clears it.
2026-06-15 19:15:53 +02:00

2.8 KiB

type, tags, sources, updated
type tags sources updated
entity
parking
stack
auth
offline-first
parking-system-architecture
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, built). 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.) The JWT carries no exp; the cookie has a long fixed maxAge (30 days) so a browser restart doesn't log out an active operator, and logout clears it.
  • 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.

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 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.