chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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<string>}
*/
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<string>}
*/
async createProof(method, url, accessToken, options = {}) {
return createDpopProof({
privateKey: this.#privateKey,
jwk: this.#jwk,
method,
url,
accessToken,
omitNonce: options.omitNonce,
});
}
}