feat(DPoP): add nonce on client side

This commit is contained in:
Edouard Vanbelle
2026-08-08 14:33:25 +02:00
parent 8b79e26329
commit 514bbc35ab
4 changed files with 499 additions and 3 deletions
+155
View File
@@ -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');
});
});
+162
View File
@@ -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);
}