feat(DPoP): add nonce on client side
This commit is contained in:
@@ -1,4 +1,18 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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';
|
import { createApiFetch } from './client';
|
||||||
|
|
||||||
const ORIGIN = 'https://cloud.example';
|
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', () => {
|
describe('ApiError + apiJson', () => {
|
||||||
it('ApiError carries status, statusText, and a descriptive message', async () => {
|
it('ApiError carries status, statusText, and a descriptive message', async () => {
|
||||||
const { ApiError } = await import('./client');
|
const { ApiError } = await import('./client');
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
|
|
||||||
import { getCsrfHeaders } from './csrf';
|
import { getCsrfHeaders } from './csrf';
|
||||||
import { updateFromHeader } from '$lib/stores/serverStatus.svelte';
|
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
|
* Name of the response header the server stamps while a
|
||||||
@@ -94,7 +99,7 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
|||||||
if (refreshInFlight) return refreshInFlight;
|
if (refreshInFlight) return refreshInFlight;
|
||||||
refreshInFlight = (async () => {
|
refreshInFlight = (async () => {
|
||||||
try {
|
try {
|
||||||
const r = await rawFetch(REFRESH_ENDPOINT, {
|
const r = await dpopFetch(REFRESH_ENDPOINT, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||||
@@ -110,9 +115,56 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
|||||||
return refreshInFlight;
|
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 apiFetch: FetchFn = async (input, init) => {
|
||||||
const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost';
|
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
|
// Server-status header piggyback — the server stamps
|
||||||
// `x-server-status` on every response while a maintenance
|
// `x-server-status` on every response while a maintenance
|
||||||
// event is in progress (see middleware::server_status). Read
|
// event is in progress (see middleware::server_status). Read
|
||||||
@@ -168,7 +220,11 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
|||||||
}
|
}
|
||||||
throw new Error('Session expired');
|
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));
|
updateFromHeader(retryResponse.headers.get(SERVER_STATUS_HEADER));
|
||||||
return retryResponse;
|
return retryResponse;
|
||||||
};
|
};
|
||||||
@@ -176,6 +232,17 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
|||||||
return apiFetch;
|
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 ──────────────────────────────────────────────────────
|
// ── Default singleton ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
let sessionExpiredHandler: () => void = () => {
|
let sessionExpiredHandler: () => void = () => {
|
||||||
|
|||||||
@@ -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<string, unknown> {
|
||||||
|
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<string, string>;
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user