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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:10:38 +01:00
1 parent aae029d94e
commit 75b879279f
8 files changed
+378 -23

No files matched your search

@@ -0,0 +1,110 @@
/**
* Taking over a managed profile, driven by the claim page's buttons.
*
* Both ways in start a session for a different account than whatever this
* browser held before: the password claim stays in the single-page app and
* starts from an empty cache, the single-sign-on claim leaves for the
* identity provider and clears the offline copies before it goes.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import { queryKeys } from "@/lib/query-keys";
import {
stubFetch,
type FetchStub,
} 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 { ClaimProfile } from "../claim-profile";
const TOKEN = `hlp_${"c".repeat(64)}`;
let api: FetchStub;
beforeEach(() => {
window.history.replaceState(null, "", "/auth/claim");
api = stubFetch()
.on("GET", "/api/auth/me", { status: 401, error: "Unauthorized" })
.on("POST", "/api/auth/claim/preview", {
data: {
displayName: "Robin",
expiresAt: "2030-01-01T00:00:00.000Z",
guardians: [],
methods: ["password", "sso"],
ssoProviderName: "Example ID",
},
})
.on("POST", "/api/auth/claim", {
data: { userId: "claimed", username: "robin" },
})
.on("POST", "/api/auth/claim/sso", {
data: { redirectTo: "/api/auth/oidc/login?claim=1" },
});
});
afterEach(() => {
clearOffline.mockClear();
loadDocument.mockClear();
});
async function fill(selector: string, value: string): Promise<void> {
await expect.poll(() => document.querySelector(selector)).toBeTruthy();
await page.elementLocator(document.querySelector(selector)!).fill(value);
}
async function click(slot: string): Promise<void> {
await page
.elementLocator(document.querySelector(`[data-slot="${slot}"]`)!)
.click();
}
describe("ClaimProfile", () => {
it("starts the claimed account from an empty cache", async () => {
const { queryClient } = await renderWithApp(
<ClaimProfile token={TOKEN} />,
{
user: null,
},
);
queryClient.setQueryData(queryKeys.dashboardSnapshot(), { stale: true });
await fill("#claim-username", "robin");
await fill("#claim-email", "robin@example.com");
await fill("#claim-password", "a long enough passphrase");
await click("claim-submit");
await expect
.poll(() => window.location.pathname)
.toBe("/onboarding/handover");
expect(api.calls("POST", "/api/auth/claim")).toHaveLength(1);
expect(queryClient.getQueryData(queryKeys.dashboardSnapshot())).toBe(
undefined,
);
});
it("clears the offline copies before leaving for the identity provider", async () => {
await renderWithApp(<ClaimProfile token={TOKEN} />, { user: null });
await fill("#claim-username", "robin");
await click("claim-sso-continue");
await expect.poll(() => loadDocument.mock.calls.length).toBe(1);
expect(loadDocument.mock.calls[0][0]).toMatch(
/^\/api\/auth\/oidc\/login\?claim=1/,
);
expect(clearOffline).toHaveBeenCalledTimes(1);
expect(clearOffline.mock.invocationCallOrder[0]).toBeLessThan(
loadDocument.mock.invocationCallOrder[0],
);
});
});
+7 -7
View File
@@ -17,12 +17,12 @@ import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { loadDocument } from "@/lib/navigation/load-document";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import {
useClaimProfile,
useClaimProfileSso,
useProfileClaimPreview,
} from "@/lib/queries/use-profile-claim";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.42 (#959) — the person a managed profile describes takes it over as
@@ -97,7 +97,7 @@ export function ClaimProfile({
claimSso.mutate(
{ token, username },
{
onSuccess: ({ redirectTo }) => {
onSuccess: async ({ redirectTo }) => {
// Only the path and query of the server's answer, so a response
// could never send the browser off this origin.
const next = new URL(redirectTo, window.location.origin);
@@ -105,6 +105,8 @@ export function ClaimProfile({
next.searchParams.set("client_proto", clientTransport.protocol);
next.searchParams.set("client_host", clientTransport.host);
}
// The session starts on the way back; the last account's copies go first.
await clearOfflineCachesForSessionEnd();
loadDocument(`${next.pathname}${next.search}`);
},
},
@@ -117,11 +119,9 @@ export function ClaimProfile({
claim.mutate(
{ token, username, email, password },
{
onSuccess: async () => {
queryClient.removeQueries({
queryKey: queryKeys.profileClaimPreview(),
});
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
onSuccess: () => {
// As after any sign-in: nothing cached before belongs to this account.
queryClient.clear();
router.push("/onboarding/handover");
},
},
@@ -0,0 +1,127 @@
/**
* What the shell does once the server says there is no session.
*
* On an `/auth/*` page it clears the previous account's offline copies, once,
* so a session that ended without a sign-out (an expired cookie the edge sent
* straight to the sign-in page) leaves nothing on the device for the next
* account. It never clears on an answer that is not a verdict.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { authUser, renderWithApp } from "../../../../tests/browser/render";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
const clearOffline = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("@/lib/pwa/query-persister", () => ({
clearOfflineCachesForSessionEnd: clearOffline,
clearOfflineCachesForRecordSwitch: vi.fn(async () => {}),
}));
// Chrome that is not the subject.
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
vi.mock("../top-bar", () => ({ TopBar: () => null }));
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
AchievementUnlockNotifier: () => null,
}));
vi.mock("@/components/insights/layout-coach-fab", () => ({
LayoutCoachFab: () => null,
}));
vi.mock("@/components/insights/layout-coach-mount", () => ({
LayoutCoachMount: () => null,
}));
vi.mock("@/components/onboarding/tour-launcher", () => ({
TourLauncher: () => null,
}));
vi.mock("@/components/day/day-layer", () => ({ DayLayerMount: () => null }));
vi.mock("@/components/command-palette/command-palette.lazy", () => ({
CommandPaletteMount: () => null,
}));
vi.mock("../global-shortcuts", () => ({ GlobalShortcuts: () => null }));
import { AuthShell } from "../auth-shell";
const ME = "/api/auth/me";
function openAt(path: string): void {
window.history.replaceState(null, "", path);
}
async function mountShell(user: Parameters<typeof authUser>[0] | null) {
return renderWithApp(
<AuthShell>
<div data-slot="page" />
</AuthShell>,
{ user },
);
}
async function settle(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 150));
}
afterEach(() => {
clearOffline.mockClear();
});
describe("AuthShell on a sign-in page", () => {
it("clears the offline copies once the server says there is no session", async () => {
const fetchStub = stubFetch().on("GET", ME, {
status: 401,
error: "Unauthorized",
});
openAt("/auth/login");
const { queryClient } = await mountShell(null);
await expect.poll(() => clearOffline.mock.calls.length).toBe(1);
// A second answer of the same kind does not clear again.
await queryClient.refetchQueries();
await settle();
expect(fetchStub.calls("GET", ME).length).toBeGreaterThan(1);
expect(clearOffline).toHaveBeenCalledTimes(1);
});
it("does the same on the profile take-over page", async () => {
stubFetch().on("GET", ME, { status: 401, error: "Unauthorized" });
openAt("/auth/claim");
await mountShell(null);
await expect.poll(() => clearOffline.mock.calls.length).toBe(1);
});
it("leaves them alone for a signed-in visitor", async () => {
stubFetch();
openAt("/auth/register");
await mountShell({ id: "signed-in" });
await settle();
expect(clearOffline).not.toHaveBeenCalled();
});
it("leaves them alone when the answer carries no verdict", async () => {
// An offline launch or a 5xx says nothing about the session; clearing on
// it is what used to destroy the offline copies of a signed-in device.
const fetchStub = stubFetch().on("GET", ME, {
status: 503,
error: "Unavailable",
});
openAt("/auth/login");
await mountShell(null);
// The account query retries a 5xx once; wait for both answers.
await expect
.poll(() => fetchStub.calls("GET", ME).length, { timeout: 4000 })
.toBe(2);
await settle();
expect(clearOffline).not.toHaveBeenCalled();
});
it("does not clear on a public page outside the sign-in flow", async () => {
stubFetch().on("GET", ME, { status: 401, error: "Unauthorized" });
openAt("/about");
await mountShell(null);
await settle();
expect(clearOffline).not.toHaveBeenCalled();
});
});
+30 -3
View File
@@ -15,6 +15,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import { useTranslations } from "@/lib/i18n/context";
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
// The leaf module, not the `@/lib/record-settings` barrel. This shell wraps
// every authenticated route, and the barrel also re-exports the managed
// settings-patch schemas — so one destination predicate that reads a slug
@@ -77,6 +78,32 @@ const PUBLIC_PATHS = [
"/claim/",
];
/**
* A session that ended without passing through the shell's session-end wipe
* (an expired cookie the edge sent straight to the sign-in page, a sign-in
* page opened directly) left the previous account's offline copies on this
* device. Clear them on every `/auth/*` page once the server has said there is
* no session, before anyone signs in. Once per signed-out stretch, and never
* on an answer that is not a verdict (offline, 5xx).
*/
function useSignInPageOfflineWipe(pathname: string): void {
const { isAuthenticated, isAuthUnknown, isLoading } = useAuth();
const cleared = useRef(false);
useEffect(() => {
if (isAuthenticated) {
cleared.current = false;
} else if (
!isLoading &&
!isAuthUnknown &&
pathname.startsWith("/auth/") &&
!cleared.current
) {
cleared.current = true;
void clearOfflineCachesForSessionEnd();
}
}, [isLoading, isAuthenticated, isAuthUnknown, pathname]);
}
export function AuthShell({
children,
demoMode = false,
@@ -223,9 +250,8 @@ export function AuthShell({
// 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.
// Logout does the same from `useLogout`; this covers the expiry path it
// never runs through. Best-effort; the redirect proceeds regardless.
//
// `isAuthUnknown` = the auth probe failed WITHOUT a session verdict
// (offline relaunch, network blip, 5xx) and no prior-session marker
@@ -247,6 +273,7 @@ export function AuthShell({
router,
queryClient,
]);
useSignInPageOfflineWipe(pathname);
// Redirect non-admins away from /admin
useEffect(() => {