fix(desktop): WS ticket auth for the live feed; desktop logs never reached the server
Build & push images / images (push) Successful in 2m51s
Release desktop / bundle (push) Successful in 41m19s

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:
2026-09-04 12:09:30 +02:00
parent 70e1e9939f
commit 8fa66c9911
11 changed files with 279 additions and 56 deletions
+18 -18
View File
@@ -5,18 +5,14 @@
// back in the X-CSRF-Token header (double-submit). See
// wiki/entities/local-jwt-auth.md.
//
// Desktop shell exception: tauri-plugin-http's fetch() runs through Rust's
// reqwest, which keeps its OWN cookie jar separate from the webview —
// document.cookie on the tauri://localhost page never sees a cookie set on a
// plugin-routed response (open upstream bug, tauri-apps/tauri#13045). The
// cookie itself IS still sent back to the server by reqwest on later
// requests (only the client-side *read* is broken), so the server also
// Desktop shell exception: document.cookie can't see the CSRF cookie there
// (reqwest's separate jar — see lib/desktop-csrf.ts), so the server also
// echoes the token in the login/me response BODY (sessionView's csrfToken —
// see routes/auth.ts) purely as a second channel for the desktop client to
// learn the value; desktopCsrfToken below stashes it in memory and
// setSessionUser() (called wherever a SessionUser is received) keeps it
// current. The browser path is untouched — it still reads document.cookie.
// see routes/auth.ts) and setSessionUser() (called wherever a SessionUser is
// received) stashes it via setDesktopCsrfToken(). The browser path is
// untouched — it still reads document.cookie.
import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js";
import { logFailedRequest } from "./lib/logger.js";
import { apiUrl, platformFetch } from "./lib/origin.js";
import { inTauri } from "./lib/tauri-env.js";
@@ -30,16 +26,11 @@ function readCookie(name: string): string | null {
return m ? decodeURIComponent(m[1]!) : null;
}
/** Desktop-only in-memory CSRF stash — see file header. Never persisted (a
* fresh app launch always logs in again, or bootstraps via /api/auth/me
* which re-supplies it). */
let desktopCsrfToken: string | null = null;
/** Update the desktop CSRF stash. Called wherever a SessionUser is received
* (login, fetchMe). No-op / cheap in the browser (the value just goes
* unused there — reads still come from document.cookie). */
function setSessionUser(user: SessionUser): void {
if (user.csrfToken) desktopCsrfToken = user.csrfToken;
if (user.csrfToken) setDesktopCsrfToken(user.csrfToken);
}
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
@@ -50,7 +41,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
headers.set("content-type", "application/json");
}
if (method !== "GET" && method !== "HEAD") {
const csrf = inTauri() ? desktopCsrfToken : readCookie(CSRF_COOKIE);
const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE);
if (csrf) headers.set(CSRF_HEADER, csrf);
}
const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" });
@@ -129,10 +120,19 @@ export async function login(username: string, password: string): Promise<Session
export async function logout(): Promise<{ ok: boolean }> {
const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
desktopCsrfToken = null;
setDesktopCsrfToken(null);
return res;
}
/** Desktop only: mint a single-use, short-lived ticket that authenticates the
* live-feed WebSocket handshake in place of the session cookie — the native WS
* plugin has no cookie jar, so the cookie can never ride along (see
* routes/ws.ts and lib/platform-ws.ts). Normal cookie + CSRF auth on the way in. */
export async function fetchWsTicket(): Promise<string> {
const { ticket } = await apiFetch<{ ticket: string }>("/api/ws/ticket", { method: "POST" });
return ticket;
}
/** Persist the current user's UI language preference (restored on next login). */
export function setLanguagePref(language: Lang): Promise<{ language: Lang }> {
return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) });