/** * DPoP proof generation for e2e tests */ import { base64UrlEncode, computeJwkThumbprint } from '@pdsjs/core/crypto'; /** * @typedef {Object} DpopPayload * @property {string} jti * @property {string} htm * @property {string} htu * @property {number} iat * @property {string} [ath] * @property {string} [nonce] */ /** * @typedef {Object} EcJwk * @property {string} kty * @property {string} crv * @property {string} x * @property {string} y */ /** * Generate an ES256 key pair for DPoP * @returns {Promise<{privateKey: CryptoKey, publicKey: CryptoKey, jwk: EcJwk}>} */ export async function generateKeyPair() { const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); /** @type {EcJwk} */ const publicJwk = { kty: jwk.kty || '', crv: jwk.crv || '', x: jwk.x || '', y: jwk.y || '', }; return { privateKey: keyPair.privateKey, publicKey: keyPair.publicKey, jwk: publicJwk, }; } // The server's DPoP nonce is global (an HMAC over a time window), so tests // share one value: seed it once with seedDpopNonce(), and every proof // includes it automatically unless omitNonce is set. /** @type {string | null} */ let serverNonce = null; /** * Learn the server's current DPoP nonce by poking the PAR endpoint. * @param {string} base - PDS base URL */ export async function seedDpopNonce(base) { const { privateKey, jwk } = await generateKeyPair(); const proof = await createDpopProof({ privateKey, jwk, method: 'POST', url: `${base}/oauth/par`, omitNonce: true, }); const res = await fetch(`${base}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: proof, }, body: 'client_id=seed', }); const nonce = res.headers.get('DPoP-Nonce'); if (nonce) serverNonce = nonce; } /** * Create a DPoP proof JWT * @param {object} params * @param {CryptoKey} params.privateKey * @param {EcJwk} params.jwk * @param {string} params.method * @param {string} params.url * @param {string} [params.accessToken] * @param {boolean} [params.omitNonce] - Skip the shared server nonce * @returns {Promise} */ export async function createDpopProof({ privateKey, jwk, method, url, accessToken, omitNonce, }) { const header = { typ: 'dpop+jwt', alg: 'ES256', jwk }; /** @type {DpopPayload} */ const payload = { jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))), htm: method, htu: url, iat: Math.floor(Date.now() / 1000), }; if (serverNonce && !omitNonce) { payload.nonce = serverNonce; } if (accessToken) { const hash = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(accessToken), ); payload.ath = base64UrlEncode(new Uint8Array(hash)); } const headerB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(header)), ); const payloadB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(payload)), ); const signingInput = `${headerB64}.${payloadB64}`; const signature = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, privateKey, new TextEncoder().encode(signingInput), ); return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`; } /** * DPoP client helper */ export class DpopClient { /** @type {CryptoKey} */ #privateKey; /** @type {EcJwk} */ #jwk; /** @type {string|null} */ #jkt = null; /** * @param {CryptoKey} privateKey * @param {EcJwk} jwk */ constructor(privateKey, jwk) { this.#privateKey = privateKey; this.#jwk = jwk; } static async create() { const { privateKey, jwk } = await generateKeyPair(); return new DpopClient(privateKey, jwk); } async getJkt() { if (!this.#jkt) this.#jkt = await computeJwkThumbprint(this.#jwk); return this.#jkt; } getJwk() { return this.#jwk; } /** * @param {string} method * @param {string} url * @param {string} [accessToken] * @param {{omitNonce?: boolean}} [options] * @returns {Promise} */ async createProof(method, url, accessToken, options = {}) { return createDpopProof({ privateKey: this.#privateKey, jwk: this.#jwk, method, url, accessToken, omitNonce: options.omitNonce, }); } }