feat(desktop): runtime-configurable backend server address
The desktop shell is one generic .deb/.AppImage distributed via mca/public_releases, not built per-booth, but the backend origin was baked in at build time (VITE_API_BASE, hardcoded to http://127.0.0.1:3000) — the same installer could never point at a different appliance without a rebuild. Adds ConnectScreen (shown before Login in Tauri when no backend is saved), backed by tauri-plugin-store persisting the operator-entered URL across restarts. CSP's connect-src tightens to 'self' only — all backend traffic already routes through tauri-plugin-http/websocket, which run Rust-side and are outside connect-src's reach anyway — and the real access boundary moves to capabilities/default.json's http:default scope, wildcarded so an operator-chosen host is actually reachable. Adds a "Change server" control in Setup (desktop-only) to repoint an already-configured install. While tracing the desktop auth path for this: tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its own cookie jar separate from the webview, so document.cookie on tauri://localhost never sees the parking_csrf cookie the server sets (open upstream bug, tauri-apps/tauri#13045/#11518). This means the desktop app has likely been silently sending no CSRF header on every mutation since the shell was first built — pre-existing, independent of this change. Fixed by having sessionView() (routes/auth.ts) also echo the CSRF value in the login/me JSON body; the desktop client stashes it in memory and echoes that instead of reading document.cookie. assertCsrf() itself is untouched. Verified end-to-end against a real LAN-bound dev server: login returns a csrfToken matching the cookie, a mutation using the body-sourced token in X-CSRF-Token succeeds (200), and the same mutation without it still correctly 403s.
This commit is contained in:
+44
-9
@@ -1,12 +1,25 @@
|
||||
// Thin API client for the operator/admin UI.
|
||||
//
|
||||
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
|
||||
// automatically (credentials: 'include'). For mutations we echo the readable
|
||||
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
|
||||
// Auth is cookie-based: the JWT lives in an HttpOnly cookie sent automatically
|
||||
// (credentials: 'include'). For mutations we echo the readable CSRF cookie
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
import { logFailedRequest } from "./lib/logger.js";
|
||||
import { apiUrl, platformFetch } from "./lib/origin.js";
|
||||
import { inTauri } from "./lib/tauri-env.js";
|
||||
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
|
||||
|
||||
const CSRF_COOKIE = "parking_csrf";
|
||||
@@ -17,6 +30,18 @@ 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;
|
||||
}
|
||||
|
||||
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
|
||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const method = (init.method ?? "GET").toUpperCase();
|
||||
@@ -25,7 +50,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
const csrf = readCookie(CSRF_COOKIE);
|
||||
const csrf = inTauri() ? desktopCsrfToken : readCookie(CSRF_COOKIE);
|
||||
if (csrf) headers.set(CSRF_HEADER, csrf);
|
||||
}
|
||||
const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||
@@ -82,6 +107,10 @@ export interface SessionUser {
|
||||
fullName: string | null;
|
||||
/** Optional contact email (profile metadata); null if unset. */
|
||||
email: string | null;
|
||||
/** Desktop-only: the CSRF token also echoed via the (JS-unreadable, on
|
||||
* desktop) parking_csrf cookie — see the file header. Absent/unused in the
|
||||
* browser build, which reads the cookie directly instead. */
|
||||
csrfToken?: string;
|
||||
}
|
||||
|
||||
/** Does this session grant the permission? Central authz check for the SPA. */
|
||||
@@ -89,15 +118,19 @@ export function can(user: SessionUser | null, perm: Permission): boolean {
|
||||
return !!user && user.permissions.includes(perm);
|
||||
}
|
||||
|
||||
export function login(username: string, password: string): Promise<SessionUser> {
|
||||
return apiFetch<SessionUser>("/api/auth/login", {
|
||||
export async function login(username: string, password: string): Promise<SessionUser> {
|
||||
const user = await apiFetch<SessionUser>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
setSessionUser(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
export function logout(): Promise<{ ok: boolean }> {
|
||||
return apiFetch("/api/auth/logout", { method: "POST" });
|
||||
export async function logout(): Promise<{ ok: boolean }> {
|
||||
const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
|
||||
desktopCsrfToken = null;
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Persist the current user's UI language preference (restored on next login). */
|
||||
@@ -146,7 +179,9 @@ export function changeMyPassword(
|
||||
/** Returns the current user, or null if not authenticated. */
|
||||
export async function fetchMe(): Promise<SessionUser | null> {
|
||||
try {
|
||||
return await apiFetch<SessionUser>("/api/auth/me");
|
||||
const user = await apiFetch<SessionUser>("/api/auth/me");
|
||||
setSessionUser(user);
|
||||
return user;
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
||||
throw e;
|
||||
|
||||
Reference in New Issue
Block a user