From 75b879279f4cfe2f426affed6776981ca8b5362c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:10:38 +0100 Subject: [PATCH] fix(auth): leave nothing of the last account behind on any sign-in Only the password and passkey success path cleared the offline copies on this device. A session that ended by expiry reached the sign-in page without any wipe, and single sign-on and both profile take-over paths started the next account on top of the previous one's IndexedDB snapshot and worker caches. The shell now clears those copies on every /auth/* page once /api/auth/me answers 401 or 403: once per signed-out stretch, never on an answer without a verdict. Single sign-on and the take-over by single sign-on clear them before leaving for the identity provider, and registration and the password take-over start from an empty query cache like the sign-in page does. --- .../login/__tests__/page.browser.test.tsx | 52 +++++++ src/app/auth/login/page.tsx | 18 +-- .../register/__tests__/page.browser.test.tsx | 44 ++++++ src/app/auth/register/page.tsx | 3 +- .../__tests__/claim-profile.browser.test.tsx | 110 +++++++++++++++ src/components/auth/claim-profile.tsx | 14 +- .../auth-shell-session-end.browser.test.tsx | 127 ++++++++++++++++++ src/components/layout/auth-shell.tsx | 33 ++++- 8 files changed, 378 insertions(+), 23 deletions(-) create mode 100644 src/app/auth/login/__tests__/page.browser.test.tsx create mode 100644 src/app/auth/register/__tests__/page.browser.test.tsx create mode 100644 src/components/auth/__tests__/claim-profile.browser.test.tsx create mode 100644 src/components/layout/__tests__/auth-shell-session-end.browser.test.tsx diff --git a/src/app/auth/login/__tests__/page.browser.test.tsx b/src/app/auth/login/__tests__/page.browser.test.tsx new file mode 100644 index 000000000..e40943d97 --- /dev/null +++ b/src/app/auth/login/__tests__/page.browser.test.tsx @@ -0,0 +1,52 @@ +/** + * The sign-in page, driven by its buttons. + * + * Single sign-on leaves the page for the identity provider and the session + * starts on the way back, so the previous account's offline copies have to + * be gone before the page is left. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { stubFetch } from "../../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../../tests/browser/render"; + +const clearOffline = vi.hoisted(() => vi.fn(async () => {})); +const loadDocument = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/pwa/query-persister", () => ({ + clearOfflineCachesForSessionEnd: clearOffline, +})); +vi.mock("@/lib/navigation/load-document", () => ({ loadDocument })); + +import LoginPage from "../page"; + +beforeEach(() => { + stubFetch() + .on("GET", "/api/auth/registration-status", { + data: { registrationEnabled: false, sessionCookieSecure: false }, + }) + .on("GET", "/api/auth/oidc/status", { + data: { enabled: true, buttonLabel: "Example ID", only: false }, + }); +}); + +afterEach(() => { + clearOffline.mockClear(); + loadDocument.mockClear(); +}); + +describe("sign-in page, single sign-on", () => { + it("clears the offline copies before leaving for the identity provider", async () => { + const { screen } = await renderWithApp(, { user: null }); + await screen.getByRole("button", { name: "Example ID" }).click(); + + await expect.poll(() => loadDocument.mock.calls.length).toBe(1); + expect(loadDocument.mock.calls[0][0]).toMatch( + /^\/api\/auth\/oidc\/login\?/, + ); + expect(clearOffline).toHaveBeenCalledTimes(1); + expect(clearOffline.mock.invocationCallOrder[0]).toBeLessThan( + loadDocument.mock.invocationCallOrder[0], + ); + }); +}); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 49b44b786..7aa4db1ce 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -176,12 +176,14 @@ export default function LoginPage() { staleTime: 60 * 1000, }); - function handleOidcLogin() { + async function handleOidcLogin() { const params = new URLSearchParams({ next: getRedirectTarget() }); if (clientTransport) { params.set("client_proto", clientTransport.protocol); params.set("client_host", clientTransport.host); } + // The session starts on the way back; the last account's copies go first. + await clearOfflineCachesForSessionEnd(); loadDocument(`/api/auth/oidc/login?${params}`); } @@ -226,15 +228,9 @@ export default function LoginPage() { // stale `["auth","me"]`, so the shell refetches it fresh on mount and the // former explicit `invalidateQueries({ queryKey: auth() })` is redundant. queryClient.clear(); - // Belt-and-suspenders to match the session-END wipe (logout / 401-expiry): - // also drop the OFFLINE layers — the IndexedDB query snapshot and the SW - // data/page caches — so a previous account's persisted dashboard never - // lingers on disk for the new session. The restore is already per-account - // bound, but a fresh login on a shared browser should leave nothing of the - // prior account behind. Order is clear → prefetch → navigate: the wipe is - // started here (best-effort, like logout) before the prefetch warms THIS - // user's data, and the persister's 1 s debounce gives the single IDB delete - // ample headroom to land before the new snapshot is written. + // The offline layers too, as at every session end. Started before the + // prefetch; the persister's 1 s debounce lets the delete land before the + // new account's snapshot is written. void clearOfflineCachesForSessionEnd(); const target = getRedirectTarget(); if (target === "/" && isDashboardSnapshotEnabled()) { @@ -464,7 +460,7 @@ export default function LoginPage() { {oidcStatus?.enabled && (