fix(oidc): make SSO callback idempotent + evict stale legacy service worker (#510)

OIDC SSO login intermittently ended on a 403 "Invalid or expired OIDC state
— possible CSRF attack" even though the login had already succeeded
server-side.

Root cause: the (now-removed) legacy vanilla-JS frontend registered a
`/sw.js` service worker that, with navigation preload enabled, double-fetched
the top-level navigation to `/api/auth/oidc/callback`. The OIDC `state` is
single-use, so the first callback consumed it and logged the user in while
the duplicate (~0.4s later) found the state gone and returned the 403 the
browser rendered.

Backend — idempotent callback: after a successful web login, remember
`state -> exchange_code` in a short-lived (120s) cache. A duplicate callback
whose state was already consumed now replays that same redirect instead of
403-ing, returning the cached result directly without re-running the IdP code
exchange (the authorization `code` is single-use too). Keyed by the
unguessable 32-byte state, so it adds no new attack surface and fixes the 403
for everyone — including browsers still running a stale legacy service worker.

Frontend — evict the stale worker: the current SvelteKit app registers no
service worker, so fresh clients can't double-fire. But a browser that
previously loaded the legacy frontend still has `/sw.js` registered and
controlling pages (and `/sw.js` now 404s, so vendor self-cleanup is
inconsistent). killLegacyServiceWorker() runs first in the root layout's
onMount: it surgically unregisters only `/sw.js` workers, drops only the
legacy `oxicloud-cache-*` caches, and reloads once (guarded).

Fixes #510.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Meier
2026-06-21 11:31:59 -05:00
parent 778d551090
commit f42756aa29
3 changed files with 64 additions and 7 deletions
@@ -0,0 +1,28 @@
export async function killLegacyServiceWorker(): Promise<void> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
try {
const registrations = await navigator.serviceWorker.getRegistrations();
const legacy = registrations.filter((r) => {
const url = r.active?.scriptURL ?? r.waiting?.scriptURL ?? r.installing?.scriptURL ?? '';
return url.endsWith('/sw.js');
});
if (legacy.length === 0) return;
await Promise.all(legacy.map((r) => r.unregister()));
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(
keys.filter((k) => k.startsWith('oxicloud-cache')).map((k) => caches.delete(k))
);
}
if (navigator.serviceWorker.controller && !sessionStorage.getItem('legacy-sw-killed')) {
sessionStorage.setItem('legacy-sw-killed', '1');
location.reload();
}
} catch {
/* best-effort cleanup */
}
}