From 93eb67f99bb6a282c607426c88cba61b02579293 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 02:49:14 +0200 Subject: [PATCH] feat(dpop): add service worker to sign requests this will permit GET to resources and then to remove directly signing requests --- frontend/src/routes/+layout.svelte | 58 ++++++++++++++ frontend/src/service-worker.ts | 124 +++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 frontend/src/service-worker.ts diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 198dd264..e8d39ab5 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -82,6 +82,64 @@ await killLegacyServiceWorker(); + // Register the DPoP-signing Service Worker (see + // `src/service-worker.ts`). It attaches a DPoP proof to every + // same-origin `/api/*` request the browser initiates on its + // own — `` thumbnails, `` downloads, + // `EventSource` streams — replacing the server-side content + // allowlist (Gate C in `docs/plan/dpop.md`). + // + // `type: 'module'` matches SvelteKit's ESM output. Failure is + // swallowed to `console.debug`: unbound sessions and legacy + // browsers without SW support keep working; only bound sessions + // in `DPOP=required` mode see 401s on browser-direct URLs, + // which is the same failure mode as before this SW existed. + // + // AWAIT `navigator.serviceWorker.ready` before allowing the + // splash to lift on a first-ever visit. Without this, the SPA + // renders while the SW is still installing → thumbnails and + // downloads on that very first page load fire unsigned and + // 401. Second visit onward the SW is already installed, so + // `.ready` resolves in ~1ms — the wait is a one-shot cost per + // browser profile. + if ('serviceWorker' in navigator) { + void navigator.serviceWorker + .register('/service-worker.js', { type: 'module' }) + .catch((err) => console.debug('DPoP service worker registration failed', err)); + try { + await navigator.serviceWorker.ready; + } catch { + /* SW registration failed — unbound sessions still work; + bound sessions in `required` mode see 401s on browser- + direct URLs. Same fail-open shape as pre-SW. */ + } + // Soft-reload once if we landed uncontrolled. Covers two + // cases the SW itself can't fix: + // * First-ever visit — page loaded before any SW existed, + // so `controller` is null even after `.ready`. + // * Force-refresh (Cmd-Shift-R) — the browser deliberately + // delivers the top-level document with no SW controller; + // `clients.claim()` can't attach retroactively. + // A single reload brings the page under SW control. + // + // `sessionStorage` guard prevents an infinite loop when the + // reload doesn't fix it (SW registration is genuinely broken + // — bad URL, CSP block, quota). Cleared once `controller` is + // set so a LATER force-refresh in the same session can also + // self-heal (one reload per uncontrolled-landing). + const SW_RELOAD_KEY = 'sw-reload-once'; + if (!navigator.serviceWorker.controller) { + if (!sessionStorage.getItem(SW_RELOAD_KEY)) { + sessionStorage.setItem(SW_RELOAD_KEY, '1'); + location.reload(); + return; + } + /* reload already attempted this session — give up gracefully */ + } else { + sessionStorage.removeItem(SW_RELOAD_KEY); + } + } + // The instant HTML boot splash has done its job — the app is mounted, so // the route (login renders immediately; protected routes show their own // loading state) is already in the DOM behind it. diff --git a/frontend/src/service-worker.ts b/frontend/src/service-worker.ts new file mode 100644 index 00000000..8818d820 --- /dev/null +++ b/frontend/src/service-worker.ts @@ -0,0 +1,124 @@ +/// +/// + +/** + * DPoP-signing Service Worker (RFC 9449 companion to the page-context + * `apiFetch` interceptor). + * + * WHY this exists. Under `OXICLOUD_DPOP_MODE=required` the server rejects + * bound-session requests that carry no DPoP proof. The page-context + * `apiFetch` interceptor covers everything the SPA drives through + * `fetch()` — but the browser itself makes requests JS can never touch: + * `` (thumbnails, photo previews), `` / + * `` (file downloads / inline previews), and `EventSource` + * (admin log tail). Those had lived behind a middleware allowlist + * (Gate C) — this SW replaces that allowlist entirely by attaching a + * proof to every same-origin `/api/*` request the browser makes, + * regardless of who initiated it. + * + * Why NOT a Web Worker. Dedicated workers can't see network requests + * the page initiates. Only Service Workers register a `fetch` handler + * for their scope. This IS the browser-side hook for browser-driven + * requests. + * + * Shared state with the page. Both scopes share the same-origin + * IndexedDB (where the persistent P-256 keypair lives) and SubtleCrypto + * (also available in SW context). The nonce cache is per-scope — the + * page module holds its own in-memory nonce, the SW holds its own; on + * first request each scope pays a one-round-trip nonce challenge, then + * catches up via `DPoP-Nonce` response headers. + * + * Skip conditions: + * * cross-origin (privacy — never leak the user's keypair thumbprint + * to third parties); + * * anything outside `/api/*` (static assets don't hit the DPoP + * middleware, no need to burn crypto per request); + * * requests that already carry a `DPoP` header — the page context + * signed them via `apiFetch` (or the XHR upload path), don't + * double-sign; + * * proof unavailable (keypair inaccessible) — pass through + * unsigned so unbound sessions still function (fail-open contract, + * matches `docs/plan/dpop.md`). + */ + +import { buildDpopProof, isDpopNonceChallenge, updateNonceFromResponse } from '$lib/auth/dpop-proof'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-declarations +declare const self: ServiceWorkerGlobalScope; + +const ORIGIN = self.location.origin; + +// Fast-forward the SW lifecycle so open tabs pick up the new version +// on the next navigation without waiting for every existing tab to +// close (default lifecycle stalls activation until then). +self.addEventListener('install', () => { + void self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('fetch', (event) => { + const req = event.request; + const url = new URL(req.url); + // Same-origin only — never leak a DPoP proof (which carries the + // user's public-key JWK) to a third-party host. + if (url.origin !== ORIGIN) return; + // Only DPoP-protected paths need signing. Static assets, locales, + // vendors are outside the middleware and don't need the crypto tax. + if (!url.pathname.startsWith('/api/')) return; + // Don't double-sign — page-context `apiFetch`, `fetchMe`, `tryRefresh`, + // and `uploadFileWithProgress` already attach a proof themselves. + if (req.headers.has('DPoP')) return; + event.respondWith(signAndFetch(req)); +}); + +async function signAndFetch(req: Request): Promise { + const firstProof = await buildDpopProof(req.method, req.url).catch(() => null); + // No keypair (IndexedDB blocked, SubtleCrypto missing, etc.) — pass + // through unsigned. Unbound sessions still work; bound sessions in + // `required` mode will 401, matching the fail-open contract. + if (!firstProof) return fetch(req); + + // Body-preservation contract: `new Request(existing, ...)` transfers + // ownership of `existing.body` (a `ReadableStream` — read once). To + // keep a retry option open for POST / PUT bodies we tee ahead of the + // first attempt via `req.clone()`. Cheap on GET (no body), one + // stream tee on state-changing calls. + const retryReq = req.clone(); + const first = await fetch(signWith(req, firstProof)); + updateNonceFromResponse(first); + if (!isDpopNonceChallenge(first)) return first; + + // Fresh proof — the current call to `buildDpopProof` picks up the + // nonce we just harvested from `first`'s `DPoP-Nonce` header. + const secondProof = await buildDpopProof(retryReq.method, retryReq.url).catch(() => null); + if (!secondProof) return first; // couldn't sign — surface the challenge + return fetch(signWith(retryReq, secondProof)); +} + +/** + * Build the signed outbound `Request` from an intercepted one. + * + * `mode: 'same-origin'` is load-bearing. Browser-initiated `` + * / `` requests default to `mode: 'no-cors'`, and in no-cors + * mode the browser silently strips any header not on the CORS-safelist + * (`Accept`, `Accept-Language`, `Content-Language`, `Content-Type`) + * BEFORE sending — so `DPoP` would never reach the wire even though + * `Headers.set('DPoP', …)` succeeds in JS. `same-origin` (or `cors`) + * lets custom headers through. Legal for our targets: every path we + * intercept starts with `/api/` on the same origin as the SW itself. + */ +function signWith(req: Request, proof: string): Request { + return new Request(req, { + headers: withDpopHeader(req.headers, proof), + mode: 'same-origin' + }); +} + +function withDpopHeader(existing: Headers, proof: string): Headers { + const h = new Headers(existing); + h.set('DPoP', proof); + return h; +}