mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
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],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user