// Confidential OAuth clients (token_endpoint_auth_method: private_key_jwt) // authenticate at PAR and the token endpoint with a client_assertion JWT signed // by a key in their published JWKS. These tests pin the verification // (PersonalDataServer#authenticateClient) against RFC 7523: a valid assertion // passes, public clients pass untouched, and every tampered/malformed/replayed // assertion is rejected as invalid_client. import { describe, expect, it } from 'vitest'; import { getAuthServerMetadata } from '../packages/core/src/oauth.js'; import { PersonalDataServer } from '../packages/core/src/pds.js'; const HOSTNAME = 'pds.example.com'; const ISSUER = `https://${HOSTNAME}`; const CLIENT_ID = 'https://client.example.com/client-metadata.json'; const ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; /** @param {ArrayBuffer|Uint8Array} bytes */ function b64url(bytes) { let bin = ''; for (const b of new Uint8Array(bytes)) bin += String.fromCharCode(b); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } /** @param {object} obj */ const b64urlJson = (obj) => b64url(new TextEncoder().encode(JSON.stringify(obj))); /** A fresh ES256 client key plus its public JWK (with a kid). */ async function makeClientKey(kid = 'test-key-1') { const kp = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); const jwk = /** @type {any} */ ( await crypto.subtle.exportKey('jwk', kp.publicKey) ); jwk.kid = kid; delete jwk.key_ops; delete jwk.ext; return { privateKey: kp.privateKey, jwk }; } /** * Sign a client_assertion JWT (ES256, raw r||s — the JWS format). * @param {CryptoKey} privateKey * @param {string} kid * @param {any} claims */ async function signAssertion(privateKey, kid, claims) { const signingInput = `${b64urlJson({ alg: 'ES256', typ: 'JWT', kid })}.${b64urlJson(claims)}`; const sig = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, privateKey, new TextEncoder().encode(signingInput), ); return `${signingInput}.${b64url(sig)}`; } function createServer() { /** @type {Set} */ const jtis = new Set(); return new PersonalDataServer({ actorStorage: /** @type {any} */ ({}), sharedStorage: /** @type {any} */ ({ checkAndStoreDpopJti: async (/** @type {string} */ jti) => { if (jtis.has(jti)) return false; jtis.add(jti); return true; }, }), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', hostname: HOSTNAME, plcUrl: 'https://plc.test', }); } const confidentialClient = (/** @type {any} */ jwk) => ({ client_id: CLIENT_ID, token_endpoint_auth_method: 'private_key_jwt', jwks: { keys: [jwk] }, }); function validClaims(overrides = {}) { const now = Math.floor(Date.now() / 1000); return { iss: CLIENT_ID, sub: CLIENT_ID, aud: ISSUER, jti: crypto.randomUUID(), iat: now, exp: now + 60, ...overrides, }; } /** * Run authenticateClient with an assertion and return its Response|null. * @param {PersonalDataServer} pds * @param {any} jwk * @param {string} assertion */ async function auth(pds, jwk, assertion) { return pds._oauth.authenticateClient( confidentialClient(jwk), { client_assertion: assertion, client_assertion_type: ASSERTION_TYPE }, ISSUER, ); } /** @param {Response|null} res */ async function expectRejected(res) { if (!res) throw new Error('expected a rejection Response, got null'); expect(res).not.toBeNull(); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toBe('invalid_client'); } describe('OAuth confidential client authentication', () => { it('advertises private_key_jwt and ES256 in the AS metadata', () => { const md = /** @type {any} */ (getAuthServerMetadata(ISSUER)); expect(md.token_endpoint_auth_methods_supported).toContain( 'private_key_jwt', ); expect(md.token_endpoint_auth_methods_supported).toContain('none'); expect(md.token_endpoint_auth_signing_alg_values_supported).toContain( 'ES256', ); }); it('accepts a valid client_assertion', async () => { const { privateKey, jwk } = await makeClientKey(); const assertion = await signAssertion(privateKey, jwk.kid, validClaims()); expect(await auth(createServer(), jwk, assertion)).toBeNull(); }); it('resolves the key from jwks_uri when jwks is absent', async () => { const { privateKey, jwk } = await makeClientKey(); const pds = createServer(); const assertion = await signAssertion(privateKey, jwk.kid, validClaims()); // Metadata with jwks_uri instead of inline jwks; stub the fetch. const orig = globalThis.fetch; globalThis.fetch = async () => new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }); try { const res = await pds._oauth.authenticateClient( { client_id: CLIENT_ID, token_endpoint_auth_method: 'private_key_jwt', jwks_uri: 'https://client.example.com/jwks.json', }, { client_assertion: assertion, client_assertion_type: ASSERTION_TYPE }, ISSUER, ); expect(res).toBeNull(); } finally { globalThis.fetch = orig; } }); it('passes a public client (none) through untouched', async () => { const res = await createServer()._oauth.authenticateClient( { client_id: CLIENT_ID, token_endpoint_auth_method: 'none' }, {}, ISSUER, ); expect(res).toBeNull(); }); it('rejects a missing client_assertion', async () => { const res = await createServer()._oauth.authenticateClient( { client_id: CLIENT_ID, token_endpoint_auth_method: 'private_key_jwt' }, {}, ISSUER, ); await expectRejected(res); }); it('rejects a signature from the wrong key', async () => { const { jwk } = await makeClientKey(); const attacker = await makeClientKey(jwk.kid); // same kid, different key const assertion = await signAssertion( attacker.privateKey, jwk.kid, validClaims(), ); await expectRejected(await auth(createServer(), jwk, assertion)); }); it('rejects a wrong audience', async () => { const { privateKey, jwk } = await makeClientKey(); const assertion = await signAssertion( privateKey, jwk.kid, validClaims({ aud: 'https://someone-else.example.com' }), ); await expectRejected(await auth(createServer(), jwk, assertion)); }); it('rejects iss/sub that are not the client_id', async () => { const { privateKey, jwk } = await makeClientKey(); const assertion = await signAssertion( privateKey, jwk.kid, validClaims({ iss: 'https://evil.example.com', sub: CLIENT_ID }), ); await expectRejected(await auth(createServer(), jwk, assertion)); }); it('rejects an expired assertion', async () => { const { privateKey, jwk } = await makeClientKey(); const now = Math.floor(Date.now() / 1000); const assertion = await signAssertion( privateKey, jwk.kid, validClaims({ iat: now - 120, exp: now - 60 }), ); await expectRejected(await auth(createServer(), jwk, assertion)); }); it('rejects an assertion issued too long ago', async () => { const { privateKey, jwk } = await makeClientKey(); const now = Math.floor(Date.now() / 1000); // No exp, but iat well beyond the max age. const assertion = await signAssertion( privateKey, jwk.kid, validClaims({ iat: now - 600, exp: undefined }), ); await expectRejected(await auth(createServer(), jwk, assertion)); }); it('rejects a missing jti', async () => { const { privateKey, jwk } = await makeClientKey(); const assertion = await signAssertion( privateKey, jwk.kid, validClaims({ jti: undefined }), ); await expectRejected(await auth(createServer(), jwk, assertion)); }); it('rejects an unknown kid', async () => { const { privateKey, jwk } = await makeClientKey('real-kid'); const assertion = await signAssertion( privateKey, 'unknown-kid', validClaims(), ); await expectRejected(await auth(createServer(), jwk, assertion)); }); it('rejects a non-ES256 alg', async () => { const { privateKey, jwk } = await makeClientKey(); // Hand-forge a header claiming HS256 while still ES256-signing. const claims = validClaims(); const signingInput = `${b64urlJson({ alg: 'HS256', typ: 'JWT', kid: jwk.kid })}.${b64urlJson(claims)}`; const sig = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, privateKey, new TextEncoder().encode(signingInput), ); await expectRejected( await auth(createServer(), jwk, `${signingInput}.${b64url(sig)}`), ); }); it('rejects a replayed jti', async () => { const { privateKey, jwk } = await makeClientKey(); const pds = createServer(); const assertion = await signAssertion(privateKey, jwk.kid, validClaims()); expect(await auth(pds, jwk, assertion)).toBeNull(); await expectRejected(await auth(pds, jwk, assertion)); }); });