/** * Browser DPoP primitives for OAuth and other credentials bound to one key. * The key remains non-extractable in IndexedDB. */ /** * @typedef {Object} DpopClient * @property {() => Promise} dpopKeyPair * @property {() => Promise} forgetDpopKey * @property {(bytes: Uint8Array) => string} base64Url * @property {(text: string) => Promise} sha256Base64Url * @property {(jwk: {crv: string, kty: string, x: string, y: string}) => Promise} thumbprint * @property {() => Promise} dpopThumbprint * @property {(opts: {keyPair: CryptoKeyPair, method: string, url: string, nonce?: string, token?: string}) => Promise} dpopProof * @property {(url: string) => string|undefined} nonceFor * @property {(url: string, response: Response) => void} rememberNonce */ /** * @param {Object} [options] * @param {string} [options.databaseName] * @returns {DpopClient} */ export function createDpopClient({ databaseName = 'pdsjs-oauth-client' } = {}) { const STORE = 'keys'; const KEY_ID = 'dpop'; /** * @returns {Promise} */ function openDb() { return new Promise((resolve, reject) => { const request = indexedDB.open(databaseName, 1); request.onupgradeneeded = () => { request.result.createObjectStore(STORE); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } /** * @template T * @param {IDBTransactionMode} mode * @param {(store: IDBObjectStore) => IDBRequest} run * @returns {Promise} */ async function withStore(mode, run) { const db = await openDb(); try { return await new Promise((resolve, reject) => { const request = run(db.transaction(STORE, mode).objectStore(STORE)); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } finally { db.close(); } } /** * @returns {Promise} */ async function dpopKeyPair() { const stored = /** @type {CryptoKeyPair|undefined} */ ( await withStore('readonly', (store) => store.get(KEY_ID)) ); if (stored?.privateKey) return stored; const pair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify'], ); await withStore('readwrite', (store) => store.put(pair, KEY_ID)); return pair; } async function forgetDpopKey() { await withStore('readwrite', (store) => store.delete(KEY_ID)); } /** * @param {Uint8Array} bytes * @returns {string} */ function base64Url(bytes) { let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } /** * @param {string} text * @returns {Promise} */ async function sha256Base64Url(text) { const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(text), ); return base64Url(new Uint8Array(digest)); } /** * @param {CryptoKey} publicKey * @returns {Promise<{crv: string, kty: string, x: string, y: string}>} */ async function publicJwk(publicKey) { const jwk = await crypto.subtle.exportKey('jwk', publicKey); return { crv: String(jwk.crv), kty: String(jwk.kty), x: String(jwk.x), y: String(jwk.y), }; } /** * @param {{crv: string, kty: string, x: string, y: string}} jwk * @returns {Promise} */ function thumbprint(jwk) { return sha256Base64Url( JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }), ); } async function dpopThumbprint() { const pair = await dpopKeyPair(); return thumbprint(await publicJwk(pair.publicKey)); } /** * @param {unknown} value * @returns {string} */ function encodeSegment(value) { return base64Url(new TextEncoder().encode(JSON.stringify(value))); } /** * @param {{keyPair: CryptoKeyPair, method: string, url: string, nonce?: string, token?: string}} opts * @returns {Promise} */ async function dpopProof({ keyPair, method, url, nonce, token }) { const htu = new URL(url); htu.search = ''; htu.hash = ''; const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: await publicJwk(keyPair.publicKey), }; /** @type {Record} */ const payload = { jti: crypto.randomUUID(), htm: method.toUpperCase(), htu: htu.toString(), iat: Math.floor(Date.now() / 1000), }; if (nonce) payload.nonce = nonce; if (token) payload.ath = await sha256Base64Url(token); const signingInput = `${encodeSegment(header)}.${encodeSegment(payload)}`; const signature = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, new TextEncoder().encode(signingInput), ); return `${signingInput}.${base64Url(new Uint8Array(signature))}`; } /** @type {Map} */ const nonces = new Map(); /** @param {string} url */ function nonceFor(url) { return nonces.get(new URL(url).origin); } /** * @param {string} url * @param {Response} response */ function rememberNonce(url, response) { const nonce = response.headers.get('dpop-nonce'); if (nonce) nonces.set(new URL(url).origin, nonce); } return { dpopKeyPair, forgetDpopKey, base64Url, sha256Base64Url, thumbprint, dpopThumbprint, dpopProof, nonceFor, rememberNonce, }; } const defaultDpop = createDpopClient(); export const { dpopKeyPair, forgetDpopKey, base64Url, sha256Base64Url, thumbprint, dpopThumbprint, dpopProof, nonceFor, rememberNonce, } = defaultDpop;