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.
This commit is contained in:
+11
-5
@@ -18,9 +18,15 @@ export const TOKEN_COOKIE = "parking_token";
|
|||||||
export const CSRF_COOKIE = "parking_csrf";
|
export const CSRF_COOKIE = "parking_csrf";
|
||||||
export const CSRF_HEADER = "x-csrf-token";
|
export const CSRF_HEADER = "x-csrf-token";
|
||||||
|
|
||||||
/** Token lifetime, also used as the cookie maxAge. */
|
// Session lifetime: the JWT has NO expiry — a login is valid until explicit
|
||||||
export const TOKEN_TTL = "8h";
|
// logout. Booth reality breaks any fixed clock (relief late/absent, forced double
|
||||||
export const TOKEN_TTL_SECONDS = 8 * 60 * 60;
|
// shifts), and a shift is a separate explicit boundary, not the token's lifetime.
|
||||||
|
// See wiki/entities/local-jwt-auth.md + wiki/concepts/shift.md.
|
||||||
|
//
|
||||||
|
// The cookie still needs a maxAge so it survives a browser restart (a session
|
||||||
|
// cookie would log out an active operator on browser close — the opposite of
|
||||||
|
// "until logout"). Use a long fixed window; the server clears it on logout.
|
||||||
|
export const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the JWT signing secret, refusing to start without a strong one.
|
* Resolve the JWT signing secret, refusing to start without a strong one.
|
||||||
@@ -55,7 +61,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
|||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
secure,
|
secure,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: TOKEN_TTL_SECONDS,
|
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||||
});
|
});
|
||||||
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
|
// Readable by JS so the SPA can echo it back in the CSRF header (double-submit).
|
||||||
reply.setCookie(CSRF_COOKIE, csrf, {
|
reply.setCookie(CSRF_COOKIE, csrf, {
|
||||||
@@ -63,7 +69,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
|
|||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
secure,
|
secure,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: TOKEN_TTL_SECONDS,
|
maxAge: COOKIE_MAX_AGE_SECONDS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import bcrypt from "bcrypt";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, users, type Db } from "@parking/db";
|
import { eq, users, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
TOKEN_TTL,
|
|
||||||
clearAuthCookies,
|
clearAuthCookies,
|
||||||
newCsrfToken,
|
newCsrfToken,
|
||||||
requireRole,
|
requireRole,
|
||||||
@@ -34,10 +33,13 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const csrf = newCsrfToken();
|
const csrf = newCsrfToken();
|
||||||
const token = await reply.jwtSign(
|
// No expiresIn: the token is valid until explicit logout (see auth.ts).
|
||||||
{ sub: user.id, username: user.username, role: user.role, csrf },
|
const token = await reply.jwtSign({
|
||||||
{ expiresIn: TOKEN_TTL },
|
sub: user.id,
|
||||||
);
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
csrf,
|
||||||
|
});
|
||||||
setAuthCookies(reply, token, csrf);
|
setAuthCookies(reply, token, csrf);
|
||||||
return { id: user.id, username: user.username, role: user.role };
|
return { id: user.id, username: user.username, role: user.role };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { deviceEvents } from "./device-events.js";
|
|||||||
import { EntryFlow } from "./entry-flow.js";
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { PayStation } from "./pay-station.js";
|
||||||
import { LaneMap } from "./lane-map.js";
|
import { LaneMap } from "./lane-map.js";
|
||||||
import { PrinterMonitor } from "./printer-monitor.js";
|
import { PrinterMonitor } from "./printer-monitor.js";
|
||||||
import { buildSigner } from "./signer.js";
|
import { buildSigner } from "./signer.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
|
import { payRoutes } from "./routes/pay.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
import { setupRoutes } from "./routes/setup.js";
|
import { setupRoutes } from "./routes/setup.js";
|
||||||
|
|
||||||
@@ -41,7 +43,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// The token is carried in an HttpOnly cookie (not the Authorization header).
|
// The token is carried in an HttpOnly cookie (not the Authorization header).
|
||||||
await app.register(jwt, {
|
await app.register(jwt, {
|
||||||
secret: requireJwtSecret(),
|
secret: requireJwtSecret(),
|
||||||
sign: { expiresIn: "8h" }, // bound to a shift; minted tokens must expire
|
// No expiry: a login is valid until explicit logout — a shift is a separate
|
||||||
|
// boundary, not the token lifetime (see auth.ts + wiki/concepts/shift.md).
|
||||||
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,6 +104,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
});
|
});
|
||||||
app.addHook("onClose", async () => unsubscribeExit());
|
app.addHook("onClose", async () => unsubscribeExit());
|
||||||
|
|
||||||
|
// Pay station (pay-on-foot): quote an open session against the active tariff +
|
||||||
|
// take payment → signed `payment` event. See wiki/concepts/tariff.md.
|
||||||
|
const payStation = new PayStation(db, eventLog, app.log);
|
||||||
|
await payRoutes(app, payStation);
|
||||||
|
|
||||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||||
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
||||||
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ Authentication and authorization, kept **fully local** — a direct consequence
|
|||||||
- `@fastify/jwt` signs tokens with a **local secret** (symmetric HMAC). The server **refuses to
|
- `@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
|
start** without a strong `JWT_SECRET` (≥32 chars, no placeholder) — there is deliberately no
|
||||||
insecure default.
|
insecure default.
|
||||||
- **Session lifetime: valid until explicit logout — no time expiry** (decision 2026-06-15).
|
- **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
|
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
|
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**,
|
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.)
|
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
|
The JWT carries no `exp`; the cookie has a long fixed `maxAge` (30 days) so a browser restart
|
||||||
> change (drop `expiresIn`, persist until logout) is pending.
|
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
|
- 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).
|
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 /
|
- Authorization = a simple `preHandler` role guard per route: **admin / operator / cashier /
|
||||||
|
|||||||
Reference in New Issue
Block a user