chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// The proofs this app mints are checked by the parser that will receive them,
// so a change to either side that breaks sign-in fails here first.
import { computeJwkThumbprint } from '@pdsjs/core/crypto';
import { parseDpopProof } from '@pdsjs/core/oauth';
import {
base64Url,
dpopProof,
nonceFor,
rememberNonce,
thumbprint,
} from '@pdsjs/oauth-client/dpop';
import { describe, expect, it } from 'vitest';
/** @returns {Promise<CryptoKeyPair>} */
function keyPair() {
return crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['sign', 'verify'],
);
}
describe('base64Url', () => {
it('drops the padding and uses the URL alphabet', () => {
expect(base64Url(new Uint8Array([251, 255, 190]))).toBe('-_--');
expect(base64Url(new Uint8Array([1]))).toBe('AQ');
});
});
describe('thumbprint', () => {
it('agrees with the one the server computes', async () => {
const jwk = {
crv: 'P-256',
kty: 'EC',
x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU',
y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0',
};
expect(await thumbprint(jwk)).toBe(await computeJwkThumbprint(jwk));
});
// A JWK arrives with whatever members the exporter wrote and in whatever
// order. RFC 7638 hashes four of them, sorted, and nothing else.
it('ignores members outside the four the RFC names', async () => {
const jwk = {
kty: 'EC',
crv: 'P-256',
x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU',
y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0',
};
expect(await thumbprint({ ...jwk, use: 'sig', alg: 'ES256' })).toBe(
await thumbprint(jwk),
);
});
});
describe('dpopProof', () => {
it('mints a proof the server-side parser accepts', async () => {
const pair = await keyPair();
const proof = await dpopProof({
keyPair: pair,
method: 'post',
url: 'https://pds.example.com/oauth/par',
});
const parsed = await parseDpopProof(
proof,
'POST',
'https://pds.example.com/oauth/par',
);
expect(parsed.jkt).toBe(await thumbprint(parsed.jwk));
});
it('leaves the query out of htu, which the RFC excludes', async () => {
const pair = await keyPair();
const proof = await dpopProof({
keyPair: pair,
method: 'GET',
url: 'https://pds.example.com/xrpc/com.atproto.space.listRecords?space=x',
});
const payload = JSON.parse(atob(proof.split('.')[1]));
expect(payload.htu).toBe(
'https://pds.example.com/xrpc/com.atproto.space.listRecords',
);
});
it('binds the proof to the token it presents', async () => {
const pair = await keyPair();
const token = 'an-access-token';
const url = 'https://pds.example.com/xrpc/com.atproto.space.getRecord';
const proof = await dpopProof({
keyPair: pair,
method: 'GET',
url,
token,
});
// The parser recomputes ath from the token and refuses a mismatch, so a
// proof that passes here is one bound to this exact token.
await expect(
parseDpopProof(proof, 'GET', url, null, token),
).resolves.toBeTruthy();
await expect(
parseDpopProof(proof, 'GET', url, null, 'a-different-token'),
).rejects.toThrow(/ath/);
});
it('carries a nonce only when one is given', async () => {
const pair = await keyPair();
const bare = await dpopProof({
keyPair: pair,
method: 'POST',
url: 'https://pds.example.com/oauth/token',
});
expect(JSON.parse(atob(bare.split('.')[1])).nonce).toBeUndefined();
const withNonce = await dpopProof({
keyPair: pair,
method: 'POST',
url: 'https://pds.example.com/oauth/token',
nonce: 'abc',
});
expect(JSON.parse(atob(withNonce.split('.')[1])).nonce).toBe('abc');
});
});
describe('the nonce store', () => {
it('is keyed by origin, so one server never sends another its nonce', () => {
rememberNonce(
'https://one.example.com/oauth/par',
new Response(null, { headers: { 'dpop-nonce': 'first' } }),
);
expect(nonceFor('https://one.example.com/oauth/token')).toBe('first');
expect(nonceFor('https://two.example.com/oauth/token')).toBeUndefined();
});
it('keeps the last nonce when a response carries none', () => {
rememberNonce(
'https://three.example.com/a',
new Response(null, { headers: { 'dpop-nonce': 'kept' } }),
);
rememberNonce('https://three.example.com/b', new Response(null));
expect(nonceFor('https://three.example.com/c')).toBe('kept');
});
});