From 80903a4ccb36cb3d25e9d3c1253e3968aa871a08 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:39:12 +0200 Subject: [PATCH] fix(client): revoke server session on user-initiated logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.logout() (POST /auth/logout) existed but was never called, leaving the bearer token valid server-side after a client-local logout. Add a small logout() helper that fires the revocation best-effort — fire-and-forget with its rejection swallowed — then runs clearAuth() synchronously, so a slow, offline, or rejecting server can never block or delay the local logout. Wire it into the settings Log Out button. Tests pin both paths: logout is called, and local logout still completes when the request rejects or never settles. Co-Authored-By: Claude Fable 5 --- Client/tauri-client/src/lib/logout.ts | 18 +++++++ Client/tauri-client/src/pages/MainPage.ts | 3 +- Client/tauri-client/tests/unit/logout.test.ts | 52 +++++++++++++++++++ docs/architecture/ux/connection-and-auth.md | 17 +++--- 4 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 Client/tauri-client/src/lib/logout.ts create mode 100644 Client/tauri-client/tests/unit/logout.test.ts diff --git a/Client/tauri-client/src/lib/logout.ts b/Client/tauri-client/src/lib/logout.ts new file mode 100644 index 00000000..dce1d66a --- /dev/null +++ b/Client/tauri-client/src/lib/logout.ts @@ -0,0 +1,18 @@ +/** + * User-initiated logout. + * + * Revokes the session server-side (`POST /auth/logout`) and tears down local + * auth state. The revocation is strictly best-effort: it is fire-and-forget and + * its failure is swallowed, so a slow, offline, or rejecting server can never + * block or delay the local logout — `clearAuth()` always runs synchronously. + */ + +import type { ApiClient } from "./api"; +import { clearAuth } from "@stores/auth.store"; + +export function logout(api: Pick): void { + // Best-effort server-side token revocation; never awaited, never allowed to + // throw. clearAuth() below is the authoritative local teardown. + void api.logout().catch(() => {}); + clearAuth(); +} diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index 76c39214..b7482123 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -15,6 +15,7 @@ import { createSettingsOverlay } from "@components/SettingsOverlay"; import { createToastContainer } from "@components/Toast"; import type { ToastContainer } from "@components/Toast"; import { initToast, teardownToast, showToast } from "@lib/toast"; +import { logout } from "@lib/logout"; import { authStore, clearAuth, updateUser } from "@stores/auth.store"; import { closeSettings, uiStore } from "@stores/ui.store"; import { updatePresence } from "@stores/members.store"; @@ -303,7 +304,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent { throw err; } }, - onLogout: () => clearAuth(), + onLogout: () => logout(api), onDeleteAccount: async (password) => { await api.deleteAccount(password); clearAuth(); diff --git a/Client/tauri-client/tests/unit/logout.test.ts b/Client/tauri-client/tests/unit/logout.test.ts new file mode 100644 index 00000000..08af648a --- /dev/null +++ b/Client/tauri-client/tests/unit/logout.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// clearAuth pulls in voice/livekit/notification teardown; stub it so this test +// stays a pure unit test of the best-effort revoke-then-teardown ordering. +const clearAuth = vi.fn(); +vi.mock("@stores/auth.store", () => ({ + clearAuth: () => clearAuth(), +})); + +import { logout } from "../../src/lib/logout"; + +function makeApi(logoutImpl: () => Promise): { logout: ReturnType } { + return { logout: vi.fn(logoutImpl) }; +} + +describe("logout", () => { + beforeEach(() => { + clearAuth.mockClear(); + }); + + it("calls POST /auth/logout and then clears local auth", () => { + const api = makeApi(() => Promise.resolve()); + + logout(api); + + expect(api.logout).toHaveBeenCalledTimes(1); + expect(clearAuth).toHaveBeenCalledTimes(1); + }); + + it("still logs out locally when the revocation request rejects", async () => { + const api = makeApi(() => Promise.reject(new Error("network down"))); + + // Must not throw despite the rejected request... + expect(() => logout(api)).not.toThrow(); + // ...and the local teardown must have happened regardless of the outcome. + expect(clearAuth).toHaveBeenCalledTimes(1); + + // Let the rejected promise settle: the swallowing .catch() must prevent an + // unhandled rejection and must not re-trigger teardown. + await Promise.resolve(); + expect(clearAuth).toHaveBeenCalledTimes(1); + }); + + it("does not await the request before local logout (best-effort, non-blocking)", () => { + // A request that never settles must not delay or block clearAuth. + const api = makeApi(() => new Promise(() => {})); + + logout(api); + + expect(clearAuth).toHaveBeenCalledTimes(1); + }); +}); diff --git a/docs/architecture/ux/connection-and-auth.md b/docs/architecture/ux/connection-and-auth.md index 5476bf54..0f4dd4d9 100644 --- a/docs/architecture/ux/connection-and-auth.md +++ b/docs/architecture/ux/connection-and-auth.md @@ -224,18 +224,19 @@ locks it. | Trigger | Target behavior | |---------|-----------------| -| User logout | `clearAuth()` → leave voice, disconnect WS, delete stored credential for the host, → connect page | +| User logout | best-effort `POST /auth/logout` (fire-and-forget) → `clearAuth()` → leave voice, disconnect WS, delete stored credential for the host, → connect page | | 401 anywhere | Same as logout, with "Your session expired — sign in again." | | WS `BANNED` | Transient-error → connect page, no reconnect | | Cert reject | Disconnect → connect page | -> **⚠ Current gap — server session not revoked on logout.** `api.logout()` -> (`POST /auth/logout`, `api.ts:211`) is defined but never called; logout is -> client-local only (`MainPage.ts:298` → `clearAuth()`), so the bearer token -> stays valid server-side until it expires. Target: user-initiated logout should -> `POST /auth/logout` (best-effort, before tearing down) so the session is -> actually revoked. The credential *is* deleted locally (`main.ts:491-515`), but -> the server token is not. +> **✓ Resolved 2026-07-20 — server session revoked on logout.** User-initiated +> logout now calls `api.logout()` (`POST /auth/logout`) via the `logout()` helper +> (`src/lib/logout.ts`), wired into the settings Log Out button +> (`MainPage.ts` → `logout(api)`). The revocation is strictly best-effort: +> fire-and-forget with its rejection swallowed, so a slow/offline/rejecting +> server never blocks or delays the local teardown — `clearAuth()` always runs +> synchronously. The credential is still deleted locally (`main.ts`), and the +> server token is now invalidated too. ---