diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 0ce5b276..12be86f5 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -456,7 +456,6 @@ export async function startOidcLink(): Promise { headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: '{}' }); -<<<<<<< HEAD if (!res.ok) { const { errorType, message } = await parseErrorBody(res); throw new ApiError(res.status, res.statusText, '/api/auth/oidc/link/start', errorType, message); @@ -515,8 +514,13 @@ export async function logout(): Promise { try { const { clearKeypair } = await import('$lib/auth/dpop'); const { clearNonce } = await import('$lib/auth/dpop-proof'); + const { broadcastSessionCleared } = await import('$lib/auth/session-broadcast'); await clearKeypair(); clearNonce(); + // Notify every OTHER tab of this origin that the session is + // gone — Gate 8 cross-tab UX. Tabs that were sitting idle + // don't have to wait for their next 401 to notice. + broadcastSessionCleared(); } catch (err) { console.debug('dpop: cleanup failed during logout', err); } diff --git a/frontend/src/lib/auth/session-broadcast.ts b/frontend/src/lib/auth/session-broadcast.ts new file mode 100644 index 00000000..37fb73a6 --- /dev/null +++ b/frontend/src/lib/auth/session-broadcast.ts @@ -0,0 +1,78 @@ +/** + * Cross-tab session invalidation via `BroadcastChannel` — the + * "logout on tab A, tab B knows immediately" wire. + * + * Why this exists: after a logout on Tab A, Tab B still holds an + * in-memory `CryptoKey` handle to the (now-cleared) DPoP keypair + * and a session cookie whose server-side row was just revoked. + * Without a cross-tab signal, Tab B doesn't notice until its next + * network request — at which point the server 401s and the SPA + * bounces to `/login`. That's correctness-safe (see + * `docs/plan/dpop.md` Gate 8), but UX-poor: an idle Tab B silently + * pretends to be logged in for as long as it stays idle. + * + * This module fires a `BroadcastChannel` message so every other + * tab of the same origin can react synchronously — reset its + * session store, redirect to `/login`, no visible drift. + * + * Scope: session **invalidation** only. Not for cross-tab login + * (a fresh sign-in on Tab B while Tab A sits on `/login`); that's + * a general auth-store consistency concern, not DPoP-specific, + * and can be layered on later using the same primitive if needed. + * + * Fail-open contract mirrors the rest of the DPoP stack: if + * `BroadcastChannel` is unavailable (very old Safari, restricted + * webviews), broadcast/subscribe are no-ops. Users lose the + * instant-redirect UX; the natural 401-on-next-request path + * kicks in as before. + */ + +const CHANNEL_NAME = 'oxicloud-session-cleared'; + +/** + * Post a "session cleared" event to every other tab of this + * origin. The current tab does NOT receive its own message — + * `BroadcastChannel` skips the sender by design. + * + * Called from `logout()` after the server round trip completes + * (success or failure — user intent is what matters). Non-fatal + * on failure so the logout flow always finishes. + */ +export function broadcastSessionCleared(): void { + try { + const ch = new BroadcastChannel(CHANNEL_NAME); + ch.postMessage({ kind: 'session_cleared', at: Date.now() }); + ch.close(); + } catch (err) { + console.debug('session-broadcast: postMessage failed', err); + } +} + +/** + * Subscribe to cross-tab session-cleared events. Wire this once + * from the root layout's `onMount`; the callback should reset + * the SPA's session store and navigate to `/login`. + * + * Returns a cleanup function that closes the channel — call it + * from the layout's `onDestroy` so hot-reload during dev doesn't + * leak listeners. + * + * Errors during subscription are swallowed to a no-op: same + * degradation posture as the rest of the DPoP stack. + */ +export function onSessionCleared(callback: () => void): () => void { + try { + const ch = new BroadcastChannel(CHANNEL_NAME); + ch.onmessage = () => { + try { + callback(); + } catch (err) { + console.debug('session-broadcast: callback threw', err); + } + }; + return () => ch.close(); + } catch (err) { + console.debug('session-broadcast: subscribe failed', err); + return () => {}; + } +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0c847322..198dd264 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -9,6 +9,7 @@ import DialogHost from '$lib/components/DialogHost.svelte'; import Toaster from '$lib/components/Toaster.svelte'; import { setPasswordChangeRequiredHandler } from '$lib/api/client'; + import { onSessionCleared } from '$lib/auth/session-broadcast'; import { session } from '$lib/stores/session.svelte'; import { ui } from '$lib/stores/ui.svelte'; import { hashUrlToPath } from '$lib/utils/hashRedirect'; @@ -61,6 +62,24 @@ }); onMount(async () => { + // Cross-tab logout — when ANOTHER tab logs out, wipe our + // session store and bounce to /login synchronously. Without + // this the natural 401-on-next-request path still catches + // it, just with visible delay for an idle tab. See + // `docs/plan/dpop.md` Gate 8. + // + // The cleanup closure returned by `onSessionCleared` is + // intentionally not wired to `onDestroy` — the root layout + // only unmounts on hot-reload, and a leaked BroadcastChannel + // there is far cheaper than the risk of missing an + // invalidation event during teardown. + onSessionCleared(() => { + session.reset(); + // `replaceState: true` so the back button doesn't return + // the user to the now-dead protected page they were on. + void goto(resolve('/login'), { replaceState: true }); + }); + await killLegacyServiceWorker(); // The instant HTML boot splash has done its job — the app is mounted, so diff --git a/frontend/vitest-setup.ts b/frontend/vitest-setup.ts index 6e6c29d9..6f4ac0df 100644 --- a/frontend/vitest-setup.ts +++ b/frontend/vitest-setup.ts @@ -54,3 +54,149 @@ for (const name of ['localStorage', 'sessionStorage']) { g[name] = new MemoryStorage() as unknown as Storage; } } + +// jsdom has no IndexedDB. `$lib/auth/dpop` uses it as a single-entry +// key/value store for the browser DPoP keypair; without a working +// backing store every test that touches login / fetch prints a +// fail-open `console.debug` on stdout. Rather than pull in +// `fake-indexeddb` for one call-site, provide a minimal in-memory +// shim that covers exactly the API surface `dpop.ts` uses: +// +// indexedDB.open(name) → IDBOpenDBRequest +// .onupgradeneeded / .onsuccess / .onerror → callback slots +// .result → { objectStoreNames.contains, +// createObjectStore, transaction, close } +// store.get(key) / .put(value, key) / .delete(key) +// tx.oncomplete / .onerror +// +// Tests that WANT to exercise DPoP semantics still mock the module +// (see `src/lib/auth/dpop-proof.test.ts`). This shim is for the +// login-path traversals that were noisy without it. +if (!g.indexedDB) { + type Store = Map; + type Db = { + stores: Map; + objectStoreNames: { contains: (n: string) => boolean }; + createObjectStore: (n: string) => void; + transaction: (n: string, mode: 'readonly' | 'readwrite') => FakeTx; + close: () => void; + }; + type FakeReq = { + result: T | undefined; + error: unknown; + onsuccess: ((this: unknown, ev: Event) => void) | null; + onerror: ((this: unknown, ev: Event) => void) | null; + }; + type FakeTx = { + objectStore: (n: string) => FakeStore; + oncomplete: ((this: unknown, ev: Event) => void) | null; + onerror: ((this: unknown, ev: Event) => void) | null; + _done: () => void; + }; + type FakeStore = { + get: (key: string) => FakeReq; + put: (value: unknown, key: string) => FakeReq; + delete: (key: string) => FakeReq; + }; + + // Per-database persistence: opening the same name again gives you + // back your previously-created stores + entries, so the module's + // "read a value someone else wrote" flow works across + // open→close→open cycles within one test. + const databases = new Map>(); + + function makeStore(map: Store, tx: FakeTx): FakeStore { + const microDone = () => queueMicrotask(() => tx._done()); + return { + get(key: string): FakeReq { + const req: FakeReq = { + result: map.get(key), + error: undefined, + onsuccess: null, + onerror: null + }; + queueMicrotask(() => req.onsuccess?.call(req, new Event('success'))); + microDone(); + return req; + }, + put(value: unknown, key: string): FakeReq { + map.set(key, value); + const req: FakeReq = { + result: undefined, + error: undefined, + onsuccess: null, + onerror: null + }; + queueMicrotask(() => req.onsuccess?.call(req, new Event('success'))); + microDone(); + return req; + }, + delete(key: string): FakeReq { + map.delete(key); + const req: FakeReq = { + result: undefined, + error: undefined, + onsuccess: null, + onerror: null + }; + queueMicrotask(() => req.onsuccess?.call(req, new Event('success'))); + microDone(); + return req; + } + }; + } + + function makeDb(name: string): Db { + let stores = databases.get(name); + if (!stores) { + stores = new Map(); + databases.set(name, stores); + } + return { + stores, + objectStoreNames: { contains: (n: string) => stores!.has(n) }, + createObjectStore(n: string): void { + if (!stores!.has(n)) stores!.set(n, new Map()); + }, + transaction(n: string, _mode: 'readonly' | 'readwrite'): FakeTx { + const store = stores!.get(n); + if (!store) throw new Error(`fake-idb: store '${n}' not found`); + const tx: FakeTx = { + objectStore: () => makeStore(store, tx), + oncomplete: null, + onerror: null, + _done: () => tx.oncomplete?.call(tx, new Event('complete')) + }; + return tx; + }, + close(): void { + /* no-op — databases map keeps state across close */ + } + }; + } + + g.indexedDB = { + open(name: string) { + const req: FakeReq & { + onupgradeneeded: ((this: unknown, ev: Event) => void) | null; + } = { + result: undefined, + error: undefined, + onsuccess: null, + onerror: null, + onupgradeneeded: null + }; + queueMicrotask(() => { + const db = makeDb(name); + req.result = db; + // Fire upgradeneeded on FIRST open per database, so the + // module can call createObjectStore('keypair') exactly + // like the real API expects. + const stores = databases.get(name)!; + if (stores.size === 0) req.onupgradeneeded?.call(req, new Event('upgradeneeded')); + req.onsuccess?.call(req, new Event('success')); + }); + return req; + } + }; +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 727561f6..f11a1547 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -66,13 +66,14 @@ export default defineConfig({ // Verbose startup so a CI webServer-readiness timeout shows where the // server stalls (DB connect, migrations, bind) instead of nothing. RUST_LOG: 'info,oxicloud=debug,sqlx=warn,tower_http=info', - // OPAQUE substrate is off for the E2E suite — Hurl exercises it via - // `tests/common/server.env`; the SPA-facing coverage suite doesn't - // need the boot-time init nor the ~200 KiB WASM client. Blanking - // the inherited commonEnv values takes the DI factory's - // `effective_mode == Off` short-circuit. - OXICLOUD_AUTH_OPAQUE_MODE: 'off', - OXICLOUD_AUTH_OPAQUE_SERVER_SETUP: '', + // OPAQUE + DPoP are inherited from `../common/server.env`: + // OXICLOUD_AUTH_OPAQUE_MODE=migrate (Phase 2 silent-migration + // on first legacy login, Phase 4 refusal thereafter) + // OXICLOUD_DPOP_MODE=required (verify every proof; unbound + // sessions still exempt per Gate 5 design) + // Testing under the production shape catches breakage where the + // SPA's fetch interceptor or the migration hook regresses in + // ways that only surface in a real browser + real crypto. }, }, }); diff --git a/tests/e2e/scenarios/helpers.ts b/tests/e2e/scenarios/helpers.ts index c2924e86..c3703b8e 100644 --- a/tests/e2e/scenarios/helpers.ts +++ b/tests/e2e/scenarios/helpers.ts @@ -98,22 +98,61 @@ export async function seedAdmin(baseURL: string, admin = TEST_ADMIN): Promise { - const res = await page.request.post('/api/auth/login', { - data: { username: admin.username, password: admin.password }, - }); - if (!res.ok()) { - throw new Error(`apiLogin failed: ${res.status()} ${await res.text()}`); + // Idempotence check — many specs' beforeEach + test body both call + // apiLogin; the old bare-POST version was a no-op on a live + // session, and callers depend on that. Under UI-driven login, + // navigating to /login when already authenticated triggers the + // SPA's layout guard to redirect away → the login form never + // renders → the fill() below times out. Probe /api/auth/me FIRST: + // 2xx means we're already signed in as SOMEONE. If that's the + // right admin, no-op; otherwise fall through to a fresh login. + const probe = await page.request.get('/api/auth/me').catch(() => null); + if (probe?.ok()) { + const body = (await probe.json().catch(() => ({}))) as { username?: string }; + if (body.username === admin.username) return; } + + await page.goto('/login'); + await page.locator('[data-testid="login-username-input"]').fill(admin.username); + await page.locator('[data-testid="login-password-input"]').fill(admin.password); + await page.locator('[data-testid="login-submit-btn"]').click(); + // Post-login the SPA's `goto(redirectTarget)` sends the user + // to `/files` (default) or a `?redirect=` target. Match the + // default with a glob — the same shape `uiLogin` uses in + // `spa/coverage-helpers.ts` and that Playwright handles well + // under SvelteKit's client-side navigation. The 15s ceiling + // covers the OPAQUE-post-migration path: WASM load + KE1 + + // KE3 + Argon2id. + await page.waitForURL('**/files**', { timeout: 15_000 }); } /** diff --git a/tests/e2e/spa/dpop-multi-tab.spec.ts b/tests/e2e/spa/dpop-multi-tab.spec.ts new file mode 100644 index 00000000..096f1262 --- /dev/null +++ b/tests/e2e/spa/dpop-multi-tab.spec.ts @@ -0,0 +1,99 @@ +import { test, expect, uiLogin } from './coverage-helpers'; + +/** + * SPA · DPoP multi-tab coverage — Gate 8 follow-up. + * + * IndexedDB, cookies, and `BroadcastChannel` are shared across every + * tab of a single Playwright `BrowserContext`. That's the correct + * shape for testing the multi-tab DPoP invariants: + * + * * shared keypair — a second tab opened after login already sees + * the first tab's persisted keypair via IndexedDB, so both tabs + * sign requests with the same JWK thumbprint (`dpop_jkt`) → + * server accepts both under a single bound session. + * * `BroadcastChannel('oxicloud-session-cleared')` — logout on + * one tab must cause the other tab's root layout to reset the + * session store and redirect to `/login` synchronously, without + * waiting for a network round trip to 401. See + * `frontend/src/lib/auth/session-broadcast.ts`. + * + * Runs under `OXICLOUD_AUTH_OPAQUE_MODE=migrate` + + * `OXICLOUD_DPOP_MODE=required` inherited from + * `tests/common/server.env` — so the actual OPAQUE login handshake + * fires (WASM client → KE1 → KE3) and every subsequent request + * carries a DPoP proof the middleware verifies. + */ +test.describe('SPA · DPoP multi-tab', () => { + test('a second tab shares the first tab\'s DPoP keypair (IndexedDB)', async ({ context }) => { + const tabA = await context.newPage(); + await uiLogin(tabA); + // Sanity: tab A landed on an authenticated view. + await expect(tabA.getByTestId('appshell-logo-link')).toBeVisible(); + + // Second tab in the same context — cookies + IndexedDB shared. + const tabB = await context.newPage(); + // Deep-link straight into an authenticated route. If the session + // cookie is shared (it is — cookies are per-context) AND the + // DPoP keypair is shared (it is — IndexedDB is per-origin per- + // context), tab B loads without redirecting to /login. + await tabB.goto('/files'); + await expect(tabB.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 }); + + // Both tabs' auth store agrees on the same user id — proves the + // shared cookie + shared keypair combination actually authorised + // an API call under DPoP=required against a bound session. + const [uidA, uidB] = await Promise.all([ + tabA.evaluate(async () => { + const res = await fetch('/api/auth/me', { credentials: 'same-origin' }); + return res.ok ? ((await res.json()) as { id: string }).id : null; + }), + tabB.evaluate(async () => { + const res = await fetch('/api/auth/me', { credentials: 'same-origin' }); + return res.ok ? ((await res.json()) as { id: string }).id : null; + }) + ]); + expect(uidA).not.toBeNull(); + expect(uidB).toBe(uidA); + }); + + test('logging out on one tab redirects the other via BroadcastChannel', async ({ context }) => { + const tabA = await context.newPage(); + await uiLogin(tabA); + + const tabB = await context.newPage(); + await tabB.goto('/files'); + await expect(tabB.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 }); + + // Log out from tab A. Bypass the user-menu UI (which drifts as + // the shell markup evolves) — call `/api/auth/logout` directly + // then post to the BroadcastChannel by hand. Same shape as + // `endpoints/auth.ts::logout()` — the two side-effects the SPA + // does after a successful server logout are (a) wipe DPoP + // state (moot here since tab A is about to close/redirect) and + // (b) broadcast, which is exactly what we simulate. + await tabA.evaluate(async () => { + const csrf = + document.cookie + .split(';') + .map((c) => c.trim()) + .find((c) => c.startsWith('oxicloud_csrf=')) + ?.slice('oxicloud_csrf='.length) ?? ''; + const res = await fetch('/api/auth/logout', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrf }, + body: '{}' + }); + if (!res.ok) throw new Error(`logout returned ${res.status}`); + new BroadcastChannel('oxicloud-session-cleared').postMessage({ + kind: 'session_cleared', + at: Date.now() + }); + }); + + // Tab B should navigate to /login on its own. No API call + // needed — the BroadcastChannel handler in the root layout + // does session.reset() + goto('/login'). + await tabB.waitForURL('**/login**', { timeout: 5_000 }); + }); +}); diff --git a/tests/e2e/spa/favorites.spec.ts b/tests/e2e/spa/favorites.spec.ts index 165fd0f1..9206b24e 100644 --- a/tests/e2e/spa/favorites.spec.ts +++ b/tests/e2e/spa/favorites.spec.ts @@ -21,8 +21,19 @@ test('favorite, view, and unfavorite a folder', async ({ page }) => { await page.goto('/files'); await expect(page.getByTestId(name)).toBeVisible({ timeout: 15_000 }); await page.getByTestId(name).click({ button: 'right' }); + // The context-menu `favorite` click is fire-and-forget in the SPA + // (closeContext() runs before the POST) — the test's next + // navigation can race the write. Wait for the actual POST to + // land before going to /favorites so the list-fetch there sees + // the new row committed. The batch test doesn't need this because + // it queues 2 POSTs sequentially, which naturally gives the first + // one time to commit. + const favorited = page.waitForResponse( + (r) => r.url().includes('/api/favorites') && r.request().method() === 'POST' && r.ok() + ); await page.getByTestId('files-ctx-favorite-item').click(); await expect(page.getByTestId('files-context-menu')).toHaveCount(0); + await favorited; await page.goto('/favorites'); const row = page.getByTestId(name);