fix(desktop): WS ticket auth for the live feed; desktop logs never reached the server
The v0.1.4 Origin fix cleared only the first of two gates in /api/ws's preHandler. The second, req.jwtVerify(), reads the HttpOnly cookie — which tauri-plugin-websocket (a bare tungstenite client, no cookie jar) can never send. Every desktop handshake 401'd and use-live-feed reconnected every 10s (confirmed in the park-2 server log). - routes/ws.ts: POST /api/ws/ticket (cookie + CSRF auth) mints a 30s, single-use, in-memory ticket; the WS preHandler accepts it via an x-ws-ticket header after the Origin check, then the same report:read role check. Browser cookie path unchanged; JWT stays out of JS. - platform-ws.ts: fetch a ticket before connect, send it with the Origin header; connect failures now go through logClient (rate-limited). - logger.ts: flush read the CSRF token from document.cookie, null on desktop, so every desktop POST /api/logs 403'd and was dropped silently — no desktop client log had ever reached app_logs. Stash moved to a dependency-free lib/desktop-csrf.ts shared by api.ts and logger.ts. - backend-config.ts: ConnectScreen probe uses the unauthenticated /health (now also returns app: "parking-system") instead of accepting any 401. - README: local-AppImage release gate — tauri dev runs at http://localhost:5173, not tauri://localhost, so none of these origin-dependent bugs reproduce there. - wiki: new section + log entry; four citation corrections. Requires the server image with this commit deployed before the new desktop build connects (the ticket endpoint must exist). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -30,7 +30,7 @@ describe("health + login", () => {
|
||||
it("GET /health is open", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/health" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ status: "ok" });
|
||||
expect(res.json()).toEqual({ status: "ok", app: "parking-system" });
|
||||
});
|
||||
|
||||
it("login with bad credentials is rejected", async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
import { roleHasPermissions } from "../auth.js";
|
||||
import { requireAuth, roleHasPermissions } from "../auth.js";
|
||||
import {
|
||||
deviceEvents,
|
||||
type LaneStatusEvent,
|
||||
@@ -31,11 +32,57 @@ import { getOccupancy } from "../occupancy.js";
|
||||
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
|
||||
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
|
||||
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
|
||||
//
|
||||
// Desktop shell (Tauri) exception — the WS TICKET. The desktop app's HTTP goes
|
||||
// through tauri-plugin-http (reqwest, its own cookie jar) and its WebSocket
|
||||
// through tauri-plugin-websocket (bare tungstenite, NO cookie jar at all), so
|
||||
// the JWT cookie set at login can never ride on the WS handshake — jwtVerify()
|
||||
// would 401 every connect (found 2026-09-04: the desktop live feed reconnected
|
||||
// every 10s forever). The JWT is HttpOnly and must stay out of JS, so instead
|
||||
// the desktop client POSTs /api/ws/ticket (normal cookie + CSRF auth) to get a
|
||||
// single-use, 30-second random ticket bound to its user, and presents it in an
|
||||
// `x-ws-ticket` header on the handshake. A browser page cannot set custom
|
||||
// headers on a WebSocket, so this path is unreachable from a browser and adds
|
||||
// no CSWSH surface; the Origin allowlist still applies to both paths.
|
||||
|
||||
/** Permission required to watch the live feed (a read-only stream of ledger +
|
||||
* device status). Any role granted `report:read` may watch. */
|
||||
const WATCH_PERMISSION = "report:read" as const;
|
||||
|
||||
/** Handshake header carrying a desktop WS ticket (see file header). */
|
||||
const WS_TICKET_HEADER = "x-ws-ticket";
|
||||
/** A ticket is only good for the connect that immediately follows its issue. */
|
||||
const WS_TICKET_TTL_MS = 30_000;
|
||||
|
||||
interface WsTicket {
|
||||
sub: string;
|
||||
roleId: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** Outstanding tickets. Tiny (one per desktop connect attempt), in-memory only —
|
||||
* a server restart invalidates them, which is fine: the client just asks for
|
||||
* another on its next reconnect. */
|
||||
const tickets = new Map<string, WsTicket>();
|
||||
|
||||
function issueWsTicket(sub: string, roleId: string): string {
|
||||
const now = Date.now();
|
||||
for (const [key, t] of tickets) {
|
||||
if (t.expiresAt <= now) tickets.delete(key);
|
||||
}
|
||||
const ticket = randomBytes(32).toString("hex");
|
||||
tickets.set(ticket, { sub, roleId, expiresAt: now + WS_TICKET_TTL_MS });
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/** Single-use: the ticket is removed whether or not it turns out to be valid. */
|
||||
function consumeWsTicket(ticket: string): WsTicket | null {
|
||||
const t = tickets.get(ticket);
|
||||
if (!t) return null;
|
||||
tickets.delete(ticket);
|
||||
return t.expiresAt > Date.now() ? t : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
|
||||
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
|
||||
@@ -80,19 +127,43 @@ export async function wsRoutes(
|
||||
laneStatus: LaneStatus,
|
||||
lanePresence: LanePresence,
|
||||
): Promise<void> {
|
||||
// Desktop-only: mint a WS ticket for the signed-in session (see file header).
|
||||
// Ordinary cookie + CSRF auth — the desktop client CAN do that over HTTP (via
|
||||
// tauri-plugin-http), it just can't carry the cookie onto the WebSocket.
|
||||
app.post("/api/ws/ticket", { preHandler: requireAuth }, async (req) => ({
|
||||
ticket: issueWsTicket(req.user.sub, req.user.roleId),
|
||||
expiresInMs: WS_TICKET_TTL_MS,
|
||||
}));
|
||||
|
||||
app.get(
|
||||
"/api/ws",
|
||||
{
|
||||
websocket: true,
|
||||
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
|
||||
// role. Reject a cross/absent origin before touching the token, so a hijack
|
||||
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
|
||||
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN
|
||||
// session (JWT cookie, or a desktop WS ticket) THEN role. Reject a
|
||||
// cross/absent origin before touching either credential, so a hijack
|
||||
// attempt never reaches an authenticated socket.
|
||||
preHandler: async (req) => {
|
||||
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
||||
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
|
||||
}
|
||||
await req.jwtVerify();
|
||||
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) {
|
||||
const rawTicket = req.headers[WS_TICKET_HEADER];
|
||||
const ticket = Array.isArray(rawTicket) ? rawTicket[0] : rawTicket;
|
||||
let roleId: string;
|
||||
if (ticket !== undefined) {
|
||||
const t = consumeWsTicket(ticket);
|
||||
if (!t) {
|
||||
throw Object.assign(new Error("invalid or expired ws ticket"), { statusCode: 401 });
|
||||
}
|
||||
roleId = t.roleId;
|
||||
} else {
|
||||
await req.jwtVerify(); // reads the HttpOnly cookie (browser path)
|
||||
if (!req.user) {
|
||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
}
|
||||
roleId = req.user.roleId;
|
||||
}
|
||||
if (!roleHasPermissions(roleId, [WATCH_PERMISSION])) {
|
||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -106,7 +106,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
// Unauthenticated liveness probe. `app` lets a client (the desktop ConnectScreen
|
||||
// test — apps/web/src/lib/backend-config.ts) tell THIS server apart from any
|
||||
// other service that happens to answer on the address the operator typed.
|
||||
app.get("/health", async () => ({ status: "ok", app: "parking-system" }));
|
||||
|
||||
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
||||
await authRoutes(app, db);
|
||||
|
||||
Reference in New Issue
Block a user