From ea380d6f68d9c457abe642280af3001da777ffe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 21:37:55 +0200 Subject: [PATCH] fix(auth): clear the in-memory query cache at every session boundary The root QueryClient is created once and survives every client-side navigation, so logout then login on the same browser left the previous account's non-user-scoped health-data queries in memory and the next account read them before any refetch landed. Wipe the entire in-memory cache on logout, on session expiry, and at the start of a fresh login, alongside the existing IndexedDB and service-worker cache wipe, so no session ever inherits another account's data. --- src/app/auth/login/page.tsx | 29 ++++++-- src/components/layout/auth-shell.tsx | 20 +++--- .../__tests__/use-auth-session-clear.test.ts | 71 +++++++++++++++++++ src/hooks/use-auth.ts | 42 ++++++++--- 4 files changed, 135 insertions(+), 27 deletions(-) create mode 100644 src/hooks/__tests__/use-auth-session-clear.test.ts diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index f0ddea12a..e0b1e170b 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -80,6 +80,18 @@ export default function LoginPage() { // can ride in parallel with the route transition + page-chunk // download instead of queueing behind the dashboard mount. function navigateAfterLogin() { + // Start the new session from an empty in-memory cache. The root + // QueryClient is created once and outlives every client-side navigation, + // so a previous user who closed the tab WITHOUT logging out (no logout + // fired) leaves their non-user-scoped health-data queries + // (`["measurements"]`, `["dashboard","snapshot"]`, …) sitting in memory. + // Clearing here — before any prefetch warms THIS user's data and before we + // navigate — guarantees a fresh login never inherits another account's + // cached entries. Logout / expiry clear the same cache from their own + // boundaries; this covers the no-logout path. `clear()` also drops the + // stale `["auth","me"]`, so the shell refetches it fresh on mount and the + // former explicit `invalidateQueries({ queryKey: auth() })` is redundant. + queryClient.clear(); const target = getRedirectTarget(); if (target === "/" && isDashboardSnapshotEnabled()) { prefetchDashboardSnapshot(queryClient); @@ -105,7 +117,8 @@ export default function LoginPage() { credential, }); - await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); + // `navigateAfterLogin` clears the in-memory cache (cross-user guard) and + // navigates; the shell refetches `["auth","me"]` fresh on mount. navigateAfterLogin(); } catch (err) { // Route rejections carry the envelope error verbatim; everything @@ -150,7 +163,8 @@ export default function LoginPage() { return; } - await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); + // `navigateAfterLogin` clears the in-memory cache (cross-user guard) and + // navigates; the shell refetches `["auth","me"]` fresh on mount. navigateAfterLogin(); } catch (err) { setError( @@ -193,7 +207,8 @@ export default function LoginPage() { challengeId, credential, }); - await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); + // `navigateAfterLogin` clears the in-memory cache (cross-user guard) + // and navigates; the shell refetches `["auth","me"]` fresh on mount. navigateAfterLogin(); } catch { // Autofill aborts (no passkey, user dismisses, password path taken) @@ -228,10 +243,10 @@ export default function LoginPage() { { - await queryClient.invalidateQueries({ - queryKey: queryKeys.auth(), - }); + onSuccess={() => { + // `navigateAfterLogin` clears the in-memory cache (cross-user + // guard) and navigates; the shell refetches `["auth","me"]` + // fresh on mount. navigateAfterLogin(); }} onCancel={() => { diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 7197b3e21..34f134aa7 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; @@ -8,8 +9,7 @@ import { MaintainershipBanner } from "@/components/i18n/maintainership-banner"; import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; import { LayoutCoachMount } from "@/components/insights/layout-coach-mount"; import { TourLauncher } from "@/components/onboarding/tour-launcher"; -import { useAuth } from "@/hooks/use-auth"; -import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; +import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; import { BottomNav } from "./bottom-nav"; @@ -52,6 +52,7 @@ export function AuthShell({ const { t } = useTranslations(); const pathname = usePathname(); const router = useRouter(); + const queryClient = useQueryClient(); const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p)); // v1.4.26 — `/privacy` is a long-form legal page that brings its own @@ -94,15 +95,16 @@ export function AuthShell({ useEffect(() => { if (!isLoading && !isAuthenticated && !isPublicPage) { // Session END (expired / invalidated cookie → `/api/auth/me` 401). Wipe - // every client-side cache that can hold the previous account's health - // data before bouncing to login, so it never leaks to the next account - // on a shared device. Logout does the same from `useLogout`; this covers - // the expiry path that logout never runs through. Best-effort; the - // redirect proceeds regardless. - void clearOfflineCachesForSessionEnd(); + // the in-memory query cache (the cross-user guard — the QueryClient + // outlives the navigation on this SPA and the health-data families are + // not user-scoped) plus the persisted IndexedDB + SW caches, so nothing + // leaks to the next account on a shared browser. Logout does the same + // from `useLogout`; this covers the expiry path logout never runs + // through. Best-effort; the redirect proceeds regardless. + clearCachesForSessionEnd(queryClient); router.replace("/auth/login"); } - }, [isLoading, isAuthenticated, isPublicPage, router]); + }, [isLoading, isAuthenticated, isPublicPage, router, queryClient]); // Redirect non-admins away from /admin useEffect(() => { diff --git a/src/hooks/__tests__/use-auth-session-clear.test.ts b/src/hooks/__tests__/use-auth-session-clear.test.ts new file mode 100644 index 000000000..1d86926b9 --- /dev/null +++ b/src/hooks/__tests__/use-auth-session-clear.test.ts @@ -0,0 +1,71 @@ +/** + * Cross-user in-memory cache leak (v1.25.1). + * + * The root QueryClient is created once and survives every client-side + * navigation, so logout→login on the same browser used to leave the previous + * account's NON-user-scoped health-data queries (`["measurements"]`, + * `["dashboard","snapshot"]`, …) sitting in memory — the next account read + * them before any refetch landed. `clearCachesForSessionEnd` drops the entire + * in-memory cache at the session boundary; this test pins that guarantee on a + * real QueryClient. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +import { queryKeys } from "@/lib/query-keys"; + +// The offline-cache wipe touches IndexedDB + the SW caches, neither of which +// exists in the node test env — mock it to a spy so we can assert it still +// runs alongside the in-memory clear without booting a browser. +const clearOfflineCachesForSessionEnd = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/pwa/query-persister", () => ({ + clearOfflineCachesForSessionEnd: () => clearOfflineCachesForSessionEnd(), +})); + +// `use-auth` pulls in the typed fetch wrapper, the router, and the i18n +// context at module load. None of them matter for the pure session-end +// helper, so stub them so the import is side-effect free in the test env. +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiFetchRaw: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); + +import { clearCachesForSessionEnd } from "../use-auth"; + +afterEach(() => { + clearOfflineCachesForSessionEnd.mockClear(); +}); + +describe("clearCachesForSessionEnd — cross-user in-memory wipe", () => { + it("drops every in-memory query, including non-auth health data, at the session boundary", () => { + const queryClient = new QueryClient(); + + // Seed the cache as a logged-in account would leave it: an auth/me entry + // plus a non-user-scoped health-data family. + queryClient.setQueryData(queryKeys.authMe(), { id: "account-a" }); + queryClient.setQueryData(["measurements"], [{ id: "m1", value: 72 }]); + queryClient.setQueryData(["dashboard", "snapshot"], { weightKg: 80 }); + + expect(queryClient.getQueryCache().getAll().length).toBeGreaterThan(0); + + clearCachesForSessionEnd(queryClient); + + // Nothing survives into the next session: the in-memory cache is empty, + // so the health-data entries can never render for the next account. + expect(queryClient.getQueryCache().getAll()).toHaveLength(0); + expect(queryClient.getQueryData(["measurements"])).toBeUndefined(); + expect(queryClient.getQueryData(["dashboard", "snapshot"])).toBeUndefined(); + expect(queryClient.getQueryData(queryKeys.authMe())).toBeUndefined(); + + // The persisted IndexedDB + SW caches are still wiped alongside the + // in-memory clear — the existing offline-leak guard stays intact. + expect(clearOfflineCachesForSessionEnd).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 98f6cc052..7330c70c1 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -1,6 +1,11 @@ "use client"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + useQuery, + useMutation, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { queryKeys } from "@/lib/query-keys"; @@ -231,6 +236,30 @@ export function useAuth() { }; } +/** + * Wipe every client-side cache at a session END so no session ever inherits + * another account's data on a shared browser. + * + * Primary cross-user guard: `queryClient.clear()` drops the entire IN-MEMORY + * query cache. On a long-lived SPA the root QueryClient instance outlives every + * client-side navigation, and the health-data families + * (`["measurements"]`, `["dashboard","snapshot"]`, `["labs", …]`, `["mood", …]`, + * `["insights", …]`) are NOT user-scoped — so without this wipe the next account + * that logs in on the same browser reads the previous account's cached entries + * before any refetch lands. `clear()` also drops `["auth","me"]`, superseding the + * former explicit `setQueryData(null)` + `invalidateQueries(["auth"])`. + * + * Then `clearOfflineCachesForSessionEnd()` wipes the persisted layers — the + * IndexedDB query snapshot, the SW offline read-data cache (`healthlog-data-*`), + * and the SW page cache (`healthlog-pages-*`, cached navigation HTML). The static + * cache (hashed chunks, icons) carries no PII and stays intact to avoid a + * needless re-download. Best-effort; never blocks the redirect. + */ +export function clearCachesForSessionEnd(queryClient: QueryClient): void { + queryClient.clear(); + void clearOfflineCachesForSessionEnd(); +} + export function useLogout() { const queryClient = useQueryClient(); const router = useRouter(); @@ -241,16 +270,7 @@ export function useLogout() { await apiFetchRaw("/api/auth/logout", { method: "POST" }); }, onSuccess: () => { - queryClient.setQueryData(queryKeys.authMe(), null); - queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); - // Defense-in-depth at the session boundary: wipe every client-side cache - // that can hold this account's health data — the IndexedDB query - // snapshot, the SW offline read-data cache (`healthlog-data-*`), and the - // SW page cache (`healthlog-pages-*`, cached navigation HTML) — so - // nothing survives a logout on a shared device. The static cache (hashed - // chunks, icons) carries no PII and is left intact to avoid a needless - // re-download. Best-effort; never blocks the redirect. - void clearOfflineCachesForSessionEnd(); + clearCachesForSessionEnd(queryClient); router.push("/auth/login"); }, // v1.16.4 — a network-failed logout used to do nothing at all (the