feat(dpop): add service worker to sign requests
this will permit GET to resources and then to remove directly signing requests
This commit is contained in:
@@ -82,6 +82,64 @@
|
|||||||
|
|
||||||
await killLegacyServiceWorker();
|
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 — `<img src>` thumbnails, `<a href download>` 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 instant HTML boot splash has done its job — the app is mounted, so
|
||||||
// the route (login renders immediately; protected routes show their own
|
// the route (login renders immediately; protected routes show their own
|
||||||
// loading state) is already in the DOM behind it.
|
// loading state) is already in the DOM behind it.
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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:
|
||||||
|
* `<img src>` (thumbnails, photo previews), `<a href download>` /
|
||||||
|
* `<a href>` (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<Response> {
|
||||||
|
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 `<img src>`
|
||||||
|
* / `<a href>` 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user