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:
+18
-18
@@ -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 }) });
|
||||
|
||||
@@ -56,27 +56,28 @@ export interface BackendCheck {
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Probe a candidate origin by hitting /api/version. That route is behind
|
||||
* requirePermission("site:read") (session cookie + site:read — see
|
||||
* apps/server/src/routes/site.ts), so a pre-login probe can never get a 2xx;
|
||||
* we're not checking "is this reachable and mine to use", only "is something
|
||||
* that speaks our Fastify auth protocol listening here" — a 401 (missing/bad
|
||||
* JWT) or 403 (valid session, wrong permission) from THIS specific route is
|
||||
* as strong a signal of that as a 200 would be, and both are expected outcomes
|
||||
* pre-login. Uses the same tauri-plugin-http path platformFetch does (raw
|
||||
* fetch from the webview can't reach an arbitrary LAN host — mixed content,
|
||||
* see origin.ts). */
|
||||
/** Probe a candidate origin via GET /health — the server's one unauthenticated
|
||||
* route (server.ts), which answers `{status:"ok", app:"parking-system"}`. We
|
||||
* require BOTH a 2xx and that `app` value: the previous probe hit an
|
||||
* auth-guarded route and accepted 401/403 as "ours", which any password-
|
||||
* protected service on the LAN would also have passed. Uses the same
|
||||
* tauri-plugin-http path platformFetch does (raw fetch from the webview can't
|
||||
* reach an arbitrary LAN host — mixed content, see origin.ts). */
|
||||
export async function testBackendUrl(url: string): Promise<BackendCheck> {
|
||||
const origin = url.replace(/\/$/, "");
|
||||
try {
|
||||
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
|
||||
const res = await tauriFetch(`${origin}/api/version`, {
|
||||
const res = await tauriFetch(`${origin}/health`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok && res.status !== 401 && res.status !== 403) {
|
||||
if (!res.ok) {
|
||||
return { ok: false, reason: "bad_response", detail: `HTTP ${res.status}` };
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as { app?: unknown } | null;
|
||||
if (body?.app !== "parking-system") {
|
||||
return { ok: false, reason: "bad_response", detail: "unexpected /health body" };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Desktop-only in-memory CSRF token stash.
|
||||
//
|
||||
// tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its OWN
|
||||
// cookie jar separate from the webview — document.cookie on tauri://localhost
|
||||
// never sees the parking_csrf cookie the server sets (open upstream bug,
|
||||
// tauri-apps/tauri#13045). The cookie IS still sent to the server by reqwest;
|
||||
// only the client-side READ is broken. So the server echoes the same value in
|
||||
// the login / me response body (sessionView's csrfToken, routes/auth.ts) and
|
||||
// the desktop client keeps it here, echoing THIS in X-CSRF-Token instead of
|
||||
// reading document.cookie.
|
||||
//
|
||||
// One module, no imports, so BOTH echo sites can share it without a cycle:
|
||||
// api.ts (sets it, uses it for apiFetch mutations) and logger.ts (uses it for
|
||||
// the /api/logs flush — which api.ts imports, so it can't import api.ts back).
|
||||
// Never persisted: a fresh launch re-learns it via login or /api/auth/me.
|
||||
|
||||
let token: string | null = null;
|
||||
|
||||
export function setDesktopCsrfToken(value: string | null): void {
|
||||
token = value;
|
||||
}
|
||||
|
||||
export function getDesktopCsrfToken(): string | null {
|
||||
return token;
|
||||
}
|
||||
@@ -14,7 +14,9 @@
|
||||
// high-signal sources (failed requests, uncaught errors) are always captured.
|
||||
|
||||
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
||||
import { getDesktopCsrfToken } from "./desktop-csrf.js";
|
||||
import { apiUrl, platformFetch } from "./origin.js";
|
||||
import { inTauri } from "./tauri-env.js";
|
||||
|
||||
const ENDPOINT = "/api/logs";
|
||||
const FLUSH_MS = 4000;
|
||||
@@ -75,7 +77,11 @@ async function flush(): Promise<void> {
|
||||
flushing = true;
|
||||
try {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
const csrf = readCookie(CSRF_COOKIE);
|
||||
// /api/logs is behind requireAuth → assertCsrf on POST. On desktop the
|
||||
// cookie is unreadable (see desktop-csrf.ts) — without this branch every
|
||||
// desktop flush 403'd and was dropped here, silently, by design (found
|
||||
// 2026-09-04: no desktop client log had EVER reached app_logs).
|
||||
const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE);
|
||||
if (csrf) headers[CSRF_HEADER] = csrf;
|
||||
await platformFetch(apiUrl(ENDPOINT), {
|
||||
method: "POST",
|
||||
|
||||
@@ -17,8 +17,16 @@
|
||||
// Browser build: plain pass-through to the real WebSocket (this file's
|
||||
// createPlatformSocket is only called from inside inTauri() callers).
|
||||
|
||||
import { fetchWsTicket } from "../api.js";
|
||||
import { logClient } from "./logger.js";
|
||||
import { inTauri } from "./tauri-env.js";
|
||||
|
||||
/** Rate-limit the "connect failed" log: use-live-feed reconnects every ≤10s
|
||||
* forever, and each attempt is a fresh adapter, so without this an outage
|
||||
* would write six near-identical app_logs rows a minute. */
|
||||
const CONNECT_FAIL_LOG_INTERVAL_MS = 60_000;
|
||||
let lastConnectFailLogAt = 0;
|
||||
|
||||
export interface PlatformSocket {
|
||||
onopen: (() => void) | null;
|
||||
onmessage: ((ev: { data: string }) => void) | null;
|
||||
@@ -64,13 +72,21 @@ class TauriSocketAdapter implements PlatformSocket {
|
||||
try {
|
||||
const { default: TauriWebSocket } = await import("@tauri-apps/plugin-websocket");
|
||||
if (this.#closed) return; // close() called before connect resolved
|
||||
// Runs on Tauri's native (Rust) side, NOT inside the webview page — there
|
||||
// is no page context to auto-attach an Origin header the way a real
|
||||
// browser WebSocket would. The server's anti-CSWSH check (routes/ws.ts)
|
||||
// rejects any handshake with a missing/mismatched Origin, so it must be
|
||||
// set explicitly here to match what WS_ALLOWED_ORIGINS expects
|
||||
// (tauri://localhost — see apps/server/.env.example).
|
||||
const conn = await TauriWebSocket.connect(url, { headers: { Origin: "tauri://localhost" } });
|
||||
// The native WS plugin is a bare tungstenite client: no page context AND
|
||||
// no cookie jar. Two consequences, both handled via explicit headers:
|
||||
// - Origin: nothing auto-attaches `Origin: tauri://localhost` the way a
|
||||
// browser WebSocket would, and routes/ws.ts's anti-CSWSH check rejects a
|
||||
// missing/mismatched Origin — so set it to match WS_ALLOWED_ORIGINS.
|
||||
// - Session: the HttpOnly JWT cookie lives in tauri-plugin-http's reqwest
|
||||
// jar and can't ride on this handshake, so jwtVerify() would 401 every
|
||||
// connect (the 2026-09-04 "reconnects every 10s forever" bug). Instead,
|
||||
// mint a single-use ticket over normal HTTP auth and present it in the
|
||||
// x-ws-ticket header (see routes/ws.ts).
|
||||
const ticket = await fetchWsTicket();
|
||||
if (this.#closed) return;
|
||||
const conn = await TauriWebSocket.connect(url, {
|
||||
headers: { Origin: "tauri://localhost", "x-ws-ticket": ticket },
|
||||
});
|
||||
if (this.#closed) {
|
||||
void conn.disconnect();
|
||||
return;
|
||||
@@ -87,7 +103,17 @@ class TauriSocketAdapter implements PlatformSocket {
|
||||
});
|
||||
this.onopen?.();
|
||||
} catch (err) {
|
||||
console.error("Tauri WebSocket connect failed:", url, err);
|
||||
// logClient, not console.error: console output only reaches app_logs at
|
||||
// debug/trace level, which is how the ticket-less 401 stayed invisible
|
||||
// for a full day. A closed-before-connect race isn't a failure.
|
||||
if (!this.#closed && Date.now() - lastConnectFailLogAt > CONNECT_FAIL_LOG_INTERVAL_MS) {
|
||||
lastConnectFailLogAt = Date.now();
|
||||
logClient({
|
||||
level: "error",
|
||||
message: `desktop live-feed connect failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
context: { kind: "desktop_ws_connect_failed", url },
|
||||
});
|
||||
}
|
||||
this.onerror?.();
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user