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:
2026-06-15 19:15:53 +02:00
parent 2a36830880
commit a8c6d6e714
4 changed files with 30 additions and 14 deletions
+11 -5
View File
@@ -18,9 +18,15 @@ export const TOKEN_COOKIE = "parking_token";
export const CSRF_COOKIE = "parking_csrf";
export const CSRF_HEADER = "x-csrf-token";
/** Token lifetime, also used as the cookie maxAge. */
export const TOKEN_TTL = "8h";
export const TOKEN_TTL_SECONDS = 8 * 60 * 60;
// Session lifetime: the JWT has NO expiry — a login is valid until explicit
// logout. Booth reality breaks any fixed clock (relief late/absent, forced double
// 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.
@@ -55,7 +61,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
sameSite: "strict",
secure,
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).
reply.setCookie(CSRF_COOKIE, csrf, {
@@ -63,7 +69,7 @@ export function setAuthCookies(reply: FastifyReply, jwt: string, csrf: string):
sameSite: "strict",
secure,
path: "/",
maxAge: TOKEN_TTL_SECONDS,
maxAge: COOKIE_MAX_AGE_SECONDS,
});
}
+7 -5
View File
@@ -2,7 +2,6 @@ import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify";
import { eq, users, type Db } from "@parking/db";
import {
TOKEN_TTL,
clearAuthCookies,
newCsrfToken,
requireRole,
@@ -34,10 +33,13 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
const csrf = newCsrfToken();
const token = await reply.jwtSign(
{ sub: user.id, username: user.username, role: user.role, csrf },
{ expiresIn: TOKEN_TTL },
);
// No expiresIn: the token is valid until explicit logout (see auth.ts).
const token = await reply.jwtSign({
sub: user.id,
username: user.username,
role: user.role,
csrf,
});
setAuthCookies(reply, token, csrf);
return { id: user.id, username: user.username, role: user.role };
});
+9 -1
View File
@@ -8,12 +8,14 @@ import { deviceEvents } from "./device-events.js";
import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js";
import { ExitFlow } from "./exit-flow.js";
import { PayStation } from "./pay-station.js";
import { LaneMap } from "./lane-map.js";
import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js";
import { authRoutes } from "./routes/auth.js";
import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js";
import { payRoutes } from "./routes/pay.js";
import { printerRoutes } from "./routes/printers.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).
await app.register(jwt, {
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 },
});
@@ -101,6 +104,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
});
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) => {
// 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