From 12a23bf779234e902c2dc379ae9898106eb6b9ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 14:03:46 +0100 Subject: [PATCH] fix(auth): leave for single sign-on even when the cache wipe hangs The sign-in page and the profile take-over page waited for the offline copies to be cleared before leaving for the identity provider, with no limit. WebKit can leave an IndexedDB open unsettled after the installed app returns from the background, and the button then did nothing. The wait is now capped at one and a half seconds. --- .../auth/login/__tests__/page.browser.test.tsx | 15 +++++++++++++++ src/app/auth/login/page.tsx | 14 ++++++++++++-- src/components/auth/claim-profile.tsx | 13 +++++++++++-- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/app/auth/login/__tests__/page.browser.test.tsx b/src/app/auth/login/__tests__/page.browser.test.tsx index c4b163fde..234cbcf7d 100644 --- a/src/app/auth/login/__tests__/page.browser.test.tsx +++ b/src/app/auth/login/__tests__/page.browser.test.tsx @@ -63,6 +63,21 @@ describe("sign-in page, single sign-on", () => { }); }); +describe("sign-in page, single sign-on with a stuck cache", () => { + it("still leaves for the identity provider when the wipe never settles", async () => { + // WebKit can leave an IndexedDB open hanging after the app returns from + // the background; the button did nothing then. + clearOffline.mockImplementationOnce(() => new Promise(() => {})); + const { screen } = await renderWithApp(, { user: null }); + await screen.getByRole("button", { name: "Example ID" }).click(); + + await expect + .poll(() => loadDocument.mock.calls.length, { timeout: 4000 }) + .toBe(1); + expect(clearOffline).toHaveBeenCalledTimes(1); + }); +}); + describe("sign-in page, password", () => { it("replaces itself with the page asked for", async () => { api.on("POST", "/api/auth/login", { data: { id: "signed-in" } }); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index bcc84657b..3d5b3e9ed 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -24,6 +24,7 @@ import { OIDC_MFA_COOKIE, OIDC_MFA_COOKIE_PATH } from "@/lib/auth/oidc-cookie"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; +import { settleWithin } from "@/lib/async/settle-within"; import { sanitizeSameOriginPath } from "@/lib/url-safety"; import { localizedSignInError } from "@/lib/api/localized-error"; import { formatDate } from "@/lib/format"; @@ -51,6 +52,9 @@ function deletionPendingMessage(t: Translate, purgeAt: unknown): string { : t("auth.deletionPendingNoDate"); } +/** How long the single-sign-on start waits for the offline copies to go. */ +const SSO_CLEANUP_WAIT_MS = 1_500; + /** The message for the `?error=` a sign-in redirect (the SSO callback) set. */ function signInRedirectError( searchParams: URLSearchParams, @@ -183,8 +187,14 @@ export default function LoginPage() { 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(); + // The session starts on the way back; the last account's copies go + // first. Bounded: WebKit can leave an IndexedDB open hanging after the + // app returns from the background, and the button must still work. + await settleWithin( + clearOfflineCachesForSessionEnd(), + SSO_CLEANUP_WAIT_MS, + undefined, + ); loadDocument(`/api/auth/oidc/login?${params}`); } diff --git a/src/components/auth/claim-profile.tsx b/src/components/auth/claim-profile.tsx index 540fa04ea..3ea40d5e6 100644 --- a/src/components/auth/claim-profile.tsx +++ b/src/components/auth/claim-profile.tsx @@ -23,12 +23,16 @@ import { showErrorToast } from "@/lib/errors/error-toast"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { loadDocument } from "@/lib/navigation/load-document"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; +import { settleWithin } from "@/lib/async/settle-within"; import { useClaimProfile, useClaimProfileSso, useProfileClaimPreview, } from "@/lib/queries/use-profile-claim"; +/** How long the single-sign-on claim waits for the offline copies to go. */ +const SSO_CLEANUP_WAIT_MS = 1_500; + /** * v1.42 (#959) — the person a managed profile describes takes it over as * their own account (`/auth/claim?token=hlp_…`). @@ -111,8 +115,13 @@ 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(); + // The session starts on the way back; the last account's copies + // go first, waited on no longer than on the sign-in page. + await settleWithin( + clearOfflineCachesForSessionEnd(), + SSO_CLEANUP_WAIT_MS, + undefined, + ); loadDocument(`${next.pathname}${next.search}`); }, },