chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* Browser DPoP primitives for OAuth and other credentials bound to one key.
* The key remains non-extractable in IndexedDB.
*/
/**
* @typedef {Object} DpopClient
* @property {() => Promise<CryptoKeyPair>} dpopKeyPair
* @property {() => Promise<void>} forgetDpopKey
* @property {(bytes: Uint8Array) => string} base64Url
* @property {(text: string) => Promise<string>} sha256Base64Url
* @property {(jwk: {crv: string, kty: string, x: string, y: string}) => Promise<string>} thumbprint
* @property {() => Promise<string>} dpopThumbprint
* @property {(opts: {keyPair: CryptoKeyPair, method: string, url: string, nonce?: string, token?: string}) => Promise<string>} 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<IDBDatabase>}
*/
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<T>}
*/
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<CryptoKeyPair>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string, unknown>} */
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<string, string>} */
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;