From 514bbc35ab329e70a4bae18c0fc8d3f78b3a3966 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 14:33:25 +0200 Subject: [PATCH] feat(DPoP): add nonce on client side --- frontend/src/lib/api/client.test.ts | 112 ++++++++++++++++ frontend/src/lib/api/client.ts | 73 +++++++++- frontend/src/lib/auth/dpop-proof.test.ts | 155 ++++++++++++++++++++++ frontend/src/lib/auth/dpop-proof.ts | 162 +++++++++++++++++++++++ 4 files changed, 499 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/auth/dpop-proof.test.ts create mode 100644 frontend/src/lib/auth/dpop-proof.ts diff --git a/frontend/src/lib/api/client.test.ts b/frontend/src/lib/api/client.test.ts index 02157ff6..a3924471 100644 --- a/frontend/src/lib/api/client.test.ts +++ b/frontend/src/lib/api/client.test.ts @@ -1,4 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// vi.mock runs BEFORE all imports; vi.hoisted gives us a shared +// binding that both the mock factory and per-test setup can mutate. +// Reset in each test's beforeEach so tests don't leak state. +const dpopState = vi.hoisted(() => ({ proof: 'proof.value.here' as string | null })); +vi.mock('$lib/auth/dpop-proof', async () => { + const actual = + await vi.importActual('$lib/auth/dpop-proof'); + return { + ...actual, + buildDpopProof: vi.fn(async () => dpopState.proof) + }; +}); + import { createApiFetch } from './client'; const ORIGIN = 'https://cloud.example'; @@ -129,6 +143,104 @@ describe('createApiFetch — 401 refresh/retry parity', () => { }); }); +describe('createApiFetch — DPoP header injection + nonce challenge', () => { + beforeEach(() => { + dpopState.proof = 'proof.value.here'; + }); + + it('attaches a DPoP header on same-origin requests', async () => { + const rawFetch = vi.fn().mockResolvedValue(jsonResponse(200, {})); + const apiFetch = createApiFetch({ + rawFetch, + onSessionExpired: () => {}, + origin: ORIGIN + }); + + await apiFetch(`${ORIGIN}/api/files`); + const [, init] = rawFetch.mock.calls[0]; + const hdrs = new Headers((init as RequestInit)?.headers ?? {}); + expect(hdrs.get('DPoP')).toBe('proof.value.here'); + }); + + it('does NOT attach a DPoP header on cross-origin requests', async () => { + const rawFetch = vi.fn().mockResolvedValue(jsonResponse(200, {})); + const apiFetch = createApiFetch({ + rawFetch, + onSessionExpired: () => {}, + origin: ORIGIN + }); + + await apiFetch('https://third-party.example/api/thing'); + const [, init] = rawFetch.mock.calls[0]; + const hdrs = new Headers((init as RequestInit)?.headers ?? {}); + expect(hdrs.get('DPoP')).toBeNull(); + }); + + it('skips the DPoP header when the keypair is unavailable (fail-open)', async () => { + dpopState.proof = null; + const rawFetch = vi.fn().mockResolvedValue(jsonResponse(200, {})); + const apiFetch = createApiFetch({ + rawFetch, + onSessionExpired: () => {}, + origin: ORIGIN + }); + + const res = await apiFetch(`${ORIGIN}/api/files`); + expect(res.status).toBe(200); + const [, init] = rawFetch.mock.calls[0]; + const hdrs = new Headers((init as RequestInit)?.headers ?? {}); + expect(hdrs.get('DPoP')).toBeNull(); + }); + + it('retries once on a use_dpop_nonce challenge (harvests nonce, rebuilds proof)', async () => { + const challenge = new Response(null, { + status: 401, + headers: { + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'srv-fresh' + } + }); + const rawFetch = vi + .fn() + .mockResolvedValueOnce(challenge) + .mockResolvedValueOnce(jsonResponse(200, { ok: true })); + const apiFetch = createApiFetch({ + rawFetch, + onSessionExpired: () => {}, + origin: ORIGIN + }); + + const res = await apiFetch(`${ORIGIN}/api/files`); + expect(res.status).toBe(200); + expect(rawFetch).toHaveBeenCalledTimes(2); // original + one retry + }); + + it('does not loop when the retry ALSO returns use_dpop_nonce', async () => { + // Use an auth-primitive path so the outer 401-refresh path is + // bypassed — this test is scoped to the DPoP inner retry + // only. `mockResolvedValue` (not `Once`) so we can COUNT how + // many times the interceptor called through — it must be + // exactly 2 (original + one retry), never 3. + const challenge = new Response(null, { + status: 401, + headers: { + 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', + 'DPoP-Nonce': 'srv-fresh' + } + }); + const rawFetch = vi.fn().mockResolvedValue(challenge); + const apiFetch = createApiFetch({ + rawFetch, + onSessionExpired: () => {}, + origin: ORIGIN + }); + + const res = await apiFetch(`${ORIGIN}/api/auth/login`); + expect(res.status).toBe(401); + expect(rawFetch).toHaveBeenCalledTimes(2); + }); +}); + describe('ApiError + apiJson', () => { it('ApiError carries status, statusText, and a descriptive message', async () => { const { ApiError } = await import('./client'); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index d837b2e3..64e742e5 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -19,6 +19,11 @@ import { getCsrfHeaders } from './csrf'; import { updateFromHeader } from '$lib/stores/serverStatus.svelte'; +import { + buildDpopProof, + isDpopNonceChallenge, + updateNonceFromResponse +} from '$lib/auth/dpop-proof'; /** * Name of the response header the server stamps while a @@ -94,7 +99,7 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn { if (refreshInFlight) return refreshInFlight; refreshInFlight = (async () => { try { - const r = await rawFetch(REFRESH_ENDPOINT, { + const r = await dpopFetch(REFRESH_ENDPOINT, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, @@ -110,9 +115,56 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn { return refreshInFlight; } + /** + * Wrap the raw fetch with DPoP proof injection + nonce challenge/retry. + * + * 1. Build proof for the request's method + canonical URL (no query). + * 2. Attach as `DPoP` header. Fail-open if the keypair is unavailable + * (browser without SubtleCrypto / IndexedDB) — we simply skip the + * header and let the request go through unbound; server-side + * middleware exempts unbound sessions. + * 3. After response, harvest a fresh `DPoP-Nonce` if the server sent + * one, so the NEXT request has the current nonce. + * 4. If the response is a nonce challenge (`401 use_dpop_nonce`), + * REBUILD the proof with the just-received nonce and retry ONCE. + * A second challenge on the retry is a bug — surface it as a real + * 401 rather than looping. + * + * Cross-origin requests skip DPoP entirely (privacy — don't leak the + * user's public key to third parties). Request bodies are consumed at + * most once during retry: `init.body` is passed by reference, and the + * only mutating step is `Headers`; a caller-supplied `ReadableStream` + * body would need `duplex: 'half'`, which they'd already have to opt + * into for cross-origin CORS anyway. + */ + async function dpopFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost'; + const urlStr = urlString(input as RequestInfo | URL); + if (isCrossOrigin(urlStr, origin)) return rawFetch(input, init); + + const method = init?.method ?? 'GET'; + const withProof = async (): Promise => { + const proof = await buildDpopProof(method, urlStr); + const initWithProof: RequestInit = proof + ? { ...init, headers: mergeHeader(init?.headers, 'DPoP', proof) } + : (init ?? {}); + const res = await rawFetch(input, initWithProof); + updateNonceFromResponse(res); + return res; + }; + + const first = await withProof(); + if (!isDpopNonceChallenge(first)) return first; + // `updateNonceFromResponse` already stored the fresh nonce + // carried on this 401; the next `buildDpopProof` will pick it + // up. If the RETRY also produces `use_dpop_nonce`, surface it + // — infinite retry would mask a server-side nonce bug. + return withProof(); + } + const apiFetch: FetchFn = async (input, init) => { const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost'; - const response = await rawFetch(input, init); + const response = await dpopFetch(input, init); // Server-status header piggyback — the server stamps // `x-server-status` on every response while a maintenance // event is in progress (see middleware::server_status). Read @@ -168,7 +220,11 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn { } throw new Error('Session expired'); } - const retryResponse = await rawFetch(input, init); + // Retry through dpopFetch (not rawFetch directly) so the + // post-refresh request also carries a valid DPoP proof — + // otherwise a session bound to a keypair would 401 again on + // the retry with `dpop_missing`. + const retryResponse = await dpopFetch(input, init); updateFromHeader(retryResponse.headers.get(SERVER_STATUS_HEADER)); return retryResponse; }; @@ -176,6 +232,17 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn { return apiFetch; } +/** + * Merge a single header into an existing `HeadersInit` (`Headers`, plain + * object, or array-of-pairs), returning a fresh `Headers` so the caller's + * init isn't mutated. Preserves case-insensitivity via the `Headers` API. + */ +function mergeHeader(base: HeadersInit | undefined, name: string, value: string): Headers { + const merged = new Headers(base ?? {}); + merged.set(name, value); + return merged; +} + // ── Default singleton ────────────────────────────────────────────────────── let sessionExpiredHandler: () => void = () => { diff --git a/frontend/src/lib/auth/dpop-proof.test.ts b/frontend/src/lib/auth/dpop-proof.test.ts new file mode 100644 index 00000000..9e29add4 --- /dev/null +++ b/frontend/src/lib/auth/dpop-proof.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock the keypair source: jsdom has no IndexedDB, so the real +// `ensureKeypair()` throws. We generate a real P-256 pair via +// SubtleCrypto (Node 20+ ships it natively as `crypto.webcrypto`, +// exposed on `globalThis.crypto` in the test env) — same shape the +// browser sees, real signing bytes exercised end-to-end. +const KEYPAIR_PROMISE = crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify' +]); +vi.mock('./dpop', () => ({ + ensureKeypair: () => KEYPAIR_PROMISE +})); + +import { + buildDpopProof, + canonicalHtu, + clearNonce, + isDpopNonceChallenge, + updateNonceFromResponse +} from './dpop-proof'; + +/** Base64URL decode → bytes. Only used for test assertions. */ +function b64uDecode(s: string): Uint8Array { + const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4)); + const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad; + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function b64uDecodeJson(s: string): Record { + return JSON.parse(new TextDecoder().decode(b64uDecode(s))); +} + +beforeEach(() => { + // Nonce state is module-level — reset between tests so cross-file + // order doesn't leak nonces from one test into another. + clearNonce(); +}); + +describe('canonicalHtu', () => { + it('strips query and fragment', () => { + expect(canonicalHtu('https://oxi.example/api/x?y=1&z=2#frag')).toBe( + 'https://oxi.example/api/x' + ); + }); + it('resolves relative against location.origin', () => { + expect(canonicalHtu('/api/auth/me')).toMatch(/^https?:\/\/.+\/api\/auth\/me$/); + }); +}); + +describe('isDpopNonceChallenge', () => { + function res(status: number, wwwAuth?: string): Response { + return new Response(null, { + status, + headers: wwwAuth ? { 'WWW-Authenticate': wwwAuth } : {} + }); + } + + it('matches DPoP scheme + use_dpop_nonce error', () => { + expect(isDpopNonceChallenge(res(401, 'DPoP error="use_dpop_nonce"'))).toBe(true); + expect(isDpopNonceChallenge(res(401, 'dpop error="use_dpop_nonce"'))).toBe(true); + expect(isDpopNonceChallenge(res(401, 'DPoP error=use_dpop_nonce'))).toBe(true); + }); + it('rejects other errors', () => { + expect(isDpopNonceChallenge(res(401, 'DPoP error="invalid_dpop_proof"'))).toBe(false); + expect(isDpopNonceChallenge(res(401, 'Bearer error="invalid_token"'))).toBe(false); + }); + it('requires status 401', () => { + expect(isDpopNonceChallenge(res(200, 'DPoP error="use_dpop_nonce"'))).toBe(false); + }); + it('handles missing header', () => { + expect(isDpopNonceChallenge(res(401))).toBe(false); + }); +}); + +describe('buildDpopProof', () => { + it('produces a compact JWS with the expected header + claims', async () => { + const proof = await buildDpopProof('POST', 'https://oxi.example/api/foo?bar=1'); + expect(proof).not.toBeNull(); + const parts = proof!.split('.'); + expect(parts).toHaveLength(3); + + const header = b64uDecodeJson(parts[0]); + expect(header.typ).toBe('dpop+jwt'); + expect(header.alg).toBe('ES256'); + const jwk = header.jwk as Record; + expect(jwk.kty).toBe('EC'); + expect(jwk.crv).toBe('P-256'); + expect(jwk.x).toMatch(/^[A-Za-z0-9_-]+$/); + expect(jwk.y).toMatch(/^[A-Za-z0-9_-]+$/); + // Only the RFC 7638 members — no `use`, `alg`, `kid` etc leaking in. + expect(Object.keys(jwk).sort()).toEqual(['crv', 'kty', 'x', 'y']); + + const claims = b64uDecodeJson(parts[1]); + expect(claims.htm).toBe('POST'); + // htu MUST NOT carry the query string + expect(claims.htu).toBe('https://oxi.example/api/foo'); + expect(typeof claims.iat).toBe('number'); + expect(typeof claims.jti).toBe('string'); + expect((claims.jti as string).length).toBeGreaterThan(10); + // No nonce sent when none has been received yet — bootstrap branch. + expect(claims.nonce).toBeUndefined(); + + // Signature bytes are 64 for P-256 raw (R || S) + expect(b64uDecode(parts[2]).length).toBe(64); + }); + + it('includes the current nonce claim once one has been received', async () => { + updateNonceFromResponse( + new Response(null, { status: 200, headers: { 'DPoP-Nonce': 'srv-nonce-abc' } }) + ); + const proof = await buildDpopProof('GET', '/api/auth/me'); + const claims = b64uDecodeJson(proof!.split('.')[1]); + expect(claims.nonce).toBe('srv-nonce-abc'); + }); + + it('mints a fresh jti per call so replay-cache can distinguish', async () => { + const a = await buildDpopProof('GET', '/api/foo'); + const b = await buildDpopProof('GET', '/api/foo'); + const jtiA = b64uDecodeJson(a!.split('.')[1]).jti as string; + const jtiB = b64uDecodeJson(b!.split('.')[1]).jti as string; + expect(jtiA).not.toBe(jtiB); + }); + + it('uppercases the method in the htm claim', async () => { + const proof = await buildDpopProof('post', '/api/x'); + const claims = b64uDecodeJson(proof!.split('.')[1]); + expect(claims.htm).toBe('POST'); + }); +}); + +describe('updateNonceFromResponse', () => { + it('extracts and stores DPoP-Nonce', async () => { + updateNonceFromResponse( + new Response(null, { status: 200, headers: { 'DPoP-Nonce': 'nonce-1' } }) + ); + const proof = await buildDpopProof('GET', '/api/x'); + expect(b64uDecodeJson(proof!.split('.')[1]).nonce).toBe('nonce-1'); + }); + it('is a no-op when the header is absent', async () => { + updateNonceFromResponse(new Response(null, { status: 200 })); + const proof = await buildDpopProof('GET', '/api/x'); + expect(b64uDecodeJson(proof!.split('.')[1]).nonce).toBeUndefined(); + }); + it('overwrites when a new nonce arrives', async () => { + updateNonceFromResponse(new Response(null, { status: 200, headers: { 'DPoP-Nonce': 'v1' } })); + updateNonceFromResponse(new Response(null, { status: 200, headers: { 'DPoP-Nonce': 'v2' } })); + const proof = await buildDpopProof('GET', '/api/x'); + expect(b64uDecodeJson(proof!.split('.')[1]).nonce).toBe('v2'); + }); +}); diff --git a/frontend/src/lib/auth/dpop-proof.ts b/frontend/src/lib/auth/dpop-proof.ts new file mode 100644 index 00000000..72610b08 --- /dev/null +++ b/frontend/src/lib/auth/dpop-proof.ts @@ -0,0 +1,162 @@ +/** + * Build a DPoP proof JWT (RFC 9449) signed with the browser's persistent + * P-256 keypair (see `./dpop.ts`), and manage the `DPoP-Nonce` state + * that the server issues in response headers. + * + * A proof is minted PER request and carries the exact `htm` (method) + + * `htu` (target URL, no query) it's bound to. Server rejects any + * mismatch — a stolen proof cannot be replayed against a different URL + * or method, and the `jti` (unique per proof) prevents even same-URL + * replay within the nonce's lifetime. + * + * Nonce handling: at Gate 5b the server issues a `DPoP-Nonce` response + * header rotating every ~2min. This module holds the current nonce in + * module-level state (mirrored to `sessionStorage` so it survives a + * `/api/auth/me` remount but is per-tab per the plan — each tab does + * its own bootstrap challenge). At Gate 4 the server does not yet emit + * nonces; the client sends proofs without the `nonce` claim, which + * server-side (Gate 5) accepts on the bootstrap-only clock branch. + */ + +import { ensureKeypair } from './dpop'; + +const NONCE_STORAGE_KEY = 'oxicloud-dpop-nonce'; + +let currentNonce: string | null = null; + +/** Initialise nonce state from sessionStorage on first import. */ +function loadNonceOnce(): void { + if (currentNonce !== null) return; + try { + const stored = sessionStorage.getItem(NONCE_STORAGE_KEY); + if (stored) currentNonce = stored; + } catch { + /* sessionStorage may be absent (SSR / privacy mode) — ignore */ + } +} + +/** Update the nonce state from a fresh `DPoP-Nonce` response header. */ +export function updateNonceFromResponse(response: Response): void { + const fresh = response.headers.get('DPoP-Nonce'); + if (!fresh || fresh === currentNonce) return; + currentNonce = fresh; + try { + sessionStorage.setItem(NONCE_STORAGE_KEY, fresh); + } catch { + /* sessionStorage full / disabled — keep in-memory copy */ + } +} + +/** Wipe the current nonce — called on logout so a new session bootstraps fresh. */ +export function clearNonce(): void { + currentNonce = null; + try { + sessionStorage.removeItem(NONCE_STORAGE_KEY); + } catch { + /* ignore */ + } +} + +/** Base64URL encode, no padding — RFC 7515 §2. */ +function b64u(bytes: Uint8Array): string { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function b64uJson(value: unknown): string { + return b64u(new TextEncoder().encode(JSON.stringify(value))); +} + +/** + * Canonicalise a URL for the `htu` claim (RFC 9449 §4.2). Rules: + * * scheme + authority (host + port) + path + * * NO query string, NO fragment + * * lowercase scheme + host per URI normalisation + * + * Accepts absolute (`https://oxi.example/api/x`) or relative + * (`/api/x`) URLs; relative resolves against `location.origin`. + */ +export function canonicalHtu(url: string): string { + const base = typeof location !== 'undefined' ? location.origin : 'http://localhost'; + const u = new URL(url, base); + return `${u.protocol}//${u.host}${u.pathname}`; +} + +/** + * Build a signed DPoP proof for the given method+URL. Returns the + * compact JWS string ready to drop into a `DPoP` HTTP header, or + * `null` when the keypair is unavailable (SubtleCrypto absent, + * IndexedDB blocked, etc.) — caller MUST proceed without the header + * in that case (fail-open contract, matches `docs/plan/dpop.md`). + * + * Each call mints a fresh `jti` (random UUID) and stamps a current + * `iat`, so two calls made in the same tick still produce different + * proofs — replay-cache friendly by construction. + */ +export async function buildDpopProof(method: string, url: string): Promise { + loadNonceOnce(); + + let keypair: CryptoKeyPair; + try { + keypair = await ensureKeypair(); + } catch (err) { + console.debug('dpop-proof: keypair unavailable', err); + return null; + } + + // Export the public key JWK — becomes the `jwk` header member. + // Extractable is a property of the PUBLIC key (we only ever set + // `extractable: false` on generation for the private half); the + // public half is always extractable in ECDSA/`P-256` regardless. + const jwk = await crypto.subtle.exportKey('jwk', keypair.publicKey); + + const header = { + typ: 'dpop+jwt', + alg: 'ES256', + jwk: { + crv: jwk.crv, + kty: jwk.kty, + x: jwk.x, + y: jwk.y + } + }; + + const claims: Record = { + htm: method.toUpperCase(), + htu: canonicalHtu(url), + iat: Math.floor(Date.now() / 1000), + jti: crypto.randomUUID() + }; + if (currentNonce) claims.nonce = currentNonce; + + const signingInput = `${b64uJson(header)}.${b64uJson(claims)}`; + const signatureBytes = new Uint8Array( + await crypto.subtle.sign( + // ES256: raw R || S concatenation (64 bytes for P-256) — RFC + // 7515 A.3. SubtleCrypto emits exactly this format; no DER + // unwrapping needed. + { name: 'ECDSA', hash: 'SHA-256' }, + keypair.privateKey, + new TextEncoder().encode(signingInput) + ) + ); + return `${signingInput}.${b64u(signatureBytes)}`; +} + +/** + * True when the current 401 response is the server's DPoP-Nonce + * challenge: `WWW-Authenticate: DPoP error="use_dpop_nonce"`. The + * fetch interceptor uses this to decide whether to retry the original + * request once with a freshly-received nonce (which the same response + * carries in its `DPoP-Nonce` header). + */ +export function isDpopNonceChallenge(response: Response): boolean { + if (response.status !== 401) return false; + const auth = response.headers.get('WWW-Authenticate') ?? ''; + // Header syntax per RFC 6750 §3 / RFC 9449 §7.1: whitespace-tolerant + // scheme + comma-separated key="value" pairs. Case-insensitive + // scheme + key match; strict "use_dpop_nonce" for the error value. + if (!/^\s*DPoP(\s|,|$)/i.test(auth)) return false; + return /error\s*=\s*"?use_dpop_nonce"?/i.test(auth); +}