Files
parking_solution/wiki/entities/local-jwt-auth.md
T
julian 8a8e74561d wiki: design the business layer (session, tariff, permit, vision, shift, ops)
Pivot from the hardware/integrity layer to the parking operation. All
wiki-only; no code yet. Core principle throughout: business entities are
projections over the signed append-only event log, never mutable tables.

New concepts: parking-session, tariff (composable/versioned, FX-ready),
shift (manned-only Z-report), capacity-occupancy, validation-discounts,
reporting-analytics, clock-integrity, ticket-encoding, anti-passback.
New entities: permit, opencv-anpr-service, blocklist.
Decisions: session-model, vision-service (host-side ANPR + vehicle
verification; scoped AGPL exception for the isolated service).

Updates: append-only-event-chain (new event types + vision witness),
local-jwt-auth (drop 8h expiry -> until logout; code change pending),
lpr-camera (host-side recognition supersedes edge-AI), standing-decisions
(AGPL exception), open-questions (+FX, +pay-station money corners, backup).

Deferred + flagged: intercom/help-call, receipts/refunds/change, FX engine,
lane topology (#1).
2026-06-15 17:41:38 +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). 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.

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.