import { calculateJwkThumbprint, exportJWK, generateKeyPair, importJWK, SignJWT } from 'jose';
import { base64url, digest } from './util.js';
/** DPoP proof algorithms this library can create, in preference order. */
export const DPOP_ALGS = Object.freeze(['ES256', 'ES384', 'PS256', 'RS256']);
/**
* Where a DPoP key pair is kept between page loads. `extractable: true` says the store can only hold an
* extractable key (Web Storage holds strings), so `DPoPKey.loadOrCreate` generates one.
* @typedef {object} KeyStore
* @property {() => Promise<StoredKey|undefined>} get
* @property {(value: StoredKey) => Promise<void>} set
* @property {() => Promise<void>} delete
* @property {boolean} [extractable]
*/
/**
* @typedef {object} StoredKey
* @property {string} alg
* @property {CryptoKeyPair} keyPair
*/
/**
* A DPoP key pair (RFC 9449) and the proofs made with it.
*
* The private key is generated NON-EXTRACTABLE: script running in the page (including injected script)
* can ask it to sign while the page is open, but can never read it out, so a stolen access or refresh
* token is useless anywhere else. Keep the same key for the lifetime of the tokens it binds.
* `IndexedDBKeyStore` keeps that non-extractable key across page loads. `WebStorageKeyStore`
* keeps an extractable copy in sessionStorage for this tab, which is what Web Storage can hold.
*/
export class DPoPKey {
#privateKey;
/** @type {Record<string, any>} */
publicJwk;
/** @type {string} */
alg;
/** RFC 7638 SHA-256 thumbprint of the public key (the `jkt`). @type {string} */
thumbprint;
/**
* Use `DPoPKey.create()` or `DPoPKey.loadOrCreate()` rather than this.
* @param {string} alg
* @param {CryptoKey} privateKey
* @param {Record<string, string>} publicJwk
* @param {string} thumbprint
*/
constructor(alg, privateKey, publicJwk, thumbprint) {
this.alg = alg;
this.#privateKey = privateKey;
this.publicJwk = publicJwk;
this.thumbprint = thumbprint;
}
/**
* Builds a key from a CryptoKeyPair, generating a new pair if none is given.
* Generated keys are non-extractable unless `extractable` is set, which sessionStorage requires.
* @param {{ alg?: string, keyPair?: CryptoKeyPair, extractable?: boolean }} [opts]
*/
static async create({ alg = 'ES256', keyPair, extractable = false } = {}) {
const pair = keyPair ?? await generateKeyPair(alg, { extractable });
const { kty, crv, x, y, n, e } = await exportJWK(pair.publicKey);
const publicJwk = Object.fromEntries(Object.entries({ kty, crv, x, y, n, e }).filter(([, v]) => v !== undefined));
const thumbprint = await calculateJwkThumbprint(publicJwk, 'sha256');
const key = new DPoPKey(alg, pair.privateKey, publicJwk, thumbprint);
key.keyPair = pair;
return key;
}
/**
* Loads the key held in `store`, or creates one and stores it.
* @param {{ get(): Promise<{alg: string, keyPair: CryptoKeyPair}|undefined>, set(value): Promise<void> }} store
* @param {{ alg?: string }} [opts]
*/
static async loadOrCreate(store, { alg = 'ES256' } = {}) {
const held = await store.get();
if (held?.keyPair && held.alg === alg) return DPoPKey.create({ alg, keyPair: held.keyPair });
const key = await DPoPKey.create({ alg, extractable: store.extractable === true });
await store.set({ alg, keyPair: key.keyPair });
return key;
}
/**
* Creates a DPoP proof JWT (RFC 9449 §4.2).
* @param {{ method: string, url: string, nonce?: string, accessToken?: string }} opts
*/
async proof({ method, url, nonce, accessToken }) {
const claims = { htm: method.toUpperCase(), htu: htuOf(url), jti: crypto.randomUUID() };
if (nonce) claims.nonce = nonce;
if (accessToken) claims.ath = base64url(await digest(accessToken, 'SHA-256'));
return new SignJWT(claims)
.setProtectedHeader({ typ: 'dpop+jwt', alg: this.alg, jwk: this.publicJwk })
.setIssuedAt()
.sign(this.#privateKey);
}
}
/**
* The `htu` of a request: its URL without query and fragment (RFC 9449 §4.2).
* @param {string|URL} url
* @returns {string}
*/
export function htuOf(url) {
const u = new URL(url);
u.search = '';
u.hash = '';
return u.href;
}
/** Remembers the most recent `DPoP-Nonce` each server origin handed out (RFC 9449 §8, §9). */
export class DPoPNonceCache {
#nonces = new Map();
/**
* @param {string|URL} url
* @returns {string|undefined}
*/
get(url) {
return this.#nonces.get(new URL(url).origin);
}
/**
* Records the nonce a response carries, if any, and returns it.
* @param {string|URL} url
* @param {Response} response
* @returns {string|undefined}
*/
update(url, response) {
const nonce = response?.headers?.get?.('dpop-nonce');
if (nonce) this.#nonces.set(new URL(url).origin, nonce);
return nonce ?? undefined;
}
}
/**
* Whether a resource server's 401 asks for a (new) DPoP nonce (RFC 9449 §9).
* @param {Response} response
* @returns {boolean}
*/
export function wantsDpopNonce(response) {
if (response.status !== 401) return false;
const www = response.headers.get('www-authenticate') ?? '';
return /\bdpop\b/i.test(www) && /error="use_dpop_nonce"/.test(www) && !!response.headers.get('dpop-nonce');
}
/**
* Keeps one DPoP key pair in IndexedDB. CryptoKeys are stored as-is (structured clone), so a
* non-extractable private key survives page loads without ever being exported.
*/
export class IndexedDBKeyStore {
#name;
#key;
/** @param {{ database?: string, key?: string }} [opts] */
constructor({ database = 'oidc-caep-client', key = 'dpop-key' } = {}) {
this.#name = database;
this.#key = key;
}
#open() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(this.#name, 1);
req.onupgradeneeded = () => req.result.createObjectStore('keys');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async #tx(mode, fn) {
const db = await this.#open();
try {
return await new Promise((resolve, reject) => {
const tx = db.transaction('keys', mode);
const req = fn(tx.objectStore('keys'));
tx.oncomplete = () => resolve(req.result);
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
}
/** @returns {Promise<StoredKey|undefined>} */
get() { return this.#tx('readonly', (s) => s.get(this.#key)); }
/** @param {StoredKey} value @returns {Promise<void>} */
set(value) { return this.#tx('readwrite', (s) => s.put(value, this.#key)); }
/** @returns {Promise<void>} */
delete() { return this.#tx('readwrite', (s) => s.delete(this.#key)); }
}
/**
* Keeps one DPoP key pair in Web Storage, sessionStorage by default. Storage holds strings, so the
* private key is a JWK and therefore extractable. It survives a reload and the round trip to the
* identity provider, and it is cleared when the tab closes. `extractable` is what `DPoPKey.loadOrCreate`
* reads when it has to generate a key this store can keep.
*/
export class WebStorageKeyStore {
#storage;
#key;
/** @param {{ storage?: Storage, key?: string }} [opts] */
constructor({ storage = globalThis.sessionStorage, key = 'oidc-caep-client:dpop-key' } = {}) {
if (!storage) throw new TypeError('Web Storage is not available here');
this.extractable = true;
this.#storage = storage;
this.#key = key;
}
/** @returns {Promise<StoredKey|undefined>} */
async get() {
const raw = this.#storage.getItem(this.#key);
if (!raw) return undefined;
const saved = JSON.parse(raw);
if (!saved?.alg || !saved.privateJwk || !saved.publicJwk) return undefined;
const [privateKey, publicKey] = await Promise.all([
importJWK(saved.privateJwk, saved.alg, { extractable: true }),
importJWK(saved.publicJwk, saved.alg),
]);
// An asymmetric JWK always imports as a CryptoKey (only "oct" keys import as bytes).
return { alg: saved.alg, keyPair: { privateKey: /** @type {CryptoKey} */ (privateKey), publicKey: /** @type {CryptoKey} */ (publicKey) } };
}
/** @param {StoredKey} value @returns {Promise<void>} */
async set(value) {
const privateKey = value?.keyPair?.privateKey;
if (!privateKey?.extractable) {
throw new TypeError('WebStorageKeyStore can only store an extractable private key');
}
const [privateJwk, publicJwk] = await Promise.all([
exportJWK(privateKey),
exportJWK(value.keyPair.publicKey),
]);
this.#storage.setItem(this.#key, JSON.stringify({ alg: value.alg, privateJwk, publicJwk }));
}
async delete() {
this.#storage.removeItem(this.#key);
}
}
/** Keeps a DPoP key in memory only (a new key after every page load). */
export class MemoryKeyStore {
#value;
/** @returns {Promise<StoredKey|undefined>} */
async get() { return this.#value; }
/** @param {StoredKey} value */
async set(value) { this.#value = value; }
async delete() { this.#value = undefined; }
}