feat(DPoP): add nonce on client side
This commit is contained in:
@@ -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<typeof import('$lib/auth/dpop-proof')>('$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');
|
||||
|
||||
@@ -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<Response> {
|
||||
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<Response> => {
|
||||
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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user