chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Confidential OAuth clients (token_endpoint_auth_method: private_key_jwt)
// authenticate at PAR and the token endpoint with a client_assertion JWT signed
// by a key in their published JWKS. These tests pin the verification
// (PersonalDataServer#authenticateClient) against RFC 7523: a valid assertion
// passes, public clients pass untouched, and every tampered/malformed/replayed
// assertion is rejected as invalid_client.
import { describe, expect, it } from 'vitest';
import { getAuthServerMetadata } from '../packages/core/src/oauth.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';
const HOSTNAME = 'pds.example.com';
const ISSUER = `https://${HOSTNAME}`;
const CLIENT_ID = 'https://client.example.com/client-metadata.json';
const ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
/** @param {ArrayBuffer|Uint8Array} bytes */
function b64url(bytes) {
let bin = '';
for (const b of new Uint8Array(bytes)) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** @param {object} obj */
const b64urlJson = (obj) =>
b64url(new TextEncoder().encode(JSON.stringify(obj)));
/** A fresh ES256 client key plus its public JWK (with a kid). */
async function makeClientKey(kid = 'test-key-1') {
const kp = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
);
const jwk = /** @type {any} */ (
await crypto.subtle.exportKey('jwk', kp.publicKey)
);
jwk.kid = kid;
delete jwk.key_ops;
delete jwk.ext;
return { privateKey: kp.privateKey, jwk };
}
/**
* Sign a client_assertion JWT (ES256, raw r||s — the JWS format).
* @param {CryptoKey} privateKey
* @param {string} kid
* @param {any} claims
*/
async function signAssertion(privateKey, kid, claims) {
const signingInput = `${b64urlJson({ alg: 'ES256', typ: 'JWT', kid })}.${b64urlJson(claims)}`;
const sig = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
privateKey,
new TextEncoder().encode(signingInput),
);
return `${signingInput}.${b64url(sig)}`;
}
function createServer() {
/** @type {Set<string>} */
const jtis = new Set();
return new PersonalDataServer({
actorStorage: /** @type {any} */ ({}),
sharedStorage: /** @type {any} */ ({
checkAndStoreDpopJti: async (/** @type {string} */ jti) => {
if (jtis.has(jti)) return false;
jtis.add(jti);
return true;
},
}),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
hostname: HOSTNAME,
plcUrl: 'https://plc.test',
});
}
const confidentialClient = (/** @type {any} */ jwk) => ({
client_id: CLIENT_ID,
token_endpoint_auth_method: 'private_key_jwt',
jwks: { keys: [jwk] },
});
function validClaims(overrides = {}) {
const now = Math.floor(Date.now() / 1000);
return {
iss: CLIENT_ID,
sub: CLIENT_ID,
aud: ISSUER,
jti: crypto.randomUUID(),
iat: now,
exp: now + 60,
...overrides,
};
}
/**
* Run authenticateClient with an assertion and return its Response|null.
* @param {PersonalDataServer} pds
* @param {any} jwk
* @param {string} assertion
*/
async function auth(pds, jwk, assertion) {
return pds._oauth.authenticateClient(
confidentialClient(jwk),
{ client_assertion: assertion, client_assertion_type: ASSERTION_TYPE },
ISSUER,
);
}
/** @param {Response|null} res */
async function expectRejected(res) {
if (!res) throw new Error('expected a rejection Response, got null');
expect(res).not.toBeNull();
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe('invalid_client');
}
describe('OAuth confidential client authentication', () => {
it('advertises private_key_jwt and ES256 in the AS metadata', () => {
const md = /** @type {any} */ (getAuthServerMetadata(ISSUER));
expect(md.token_endpoint_auth_methods_supported).toContain(
'private_key_jwt',
);
expect(md.token_endpoint_auth_methods_supported).toContain('none');
expect(md.token_endpoint_auth_signing_alg_values_supported).toContain(
'ES256',
);
});
it('accepts a valid client_assertion', async () => {
const { privateKey, jwk } = await makeClientKey();
const assertion = await signAssertion(privateKey, jwk.kid, validClaims());
expect(await auth(createServer(), jwk, assertion)).toBeNull();
});
it('resolves the key from jwks_uri when jwks is absent', async () => {
const { privateKey, jwk } = await makeClientKey();
const pds = createServer();
const assertion = await signAssertion(privateKey, jwk.kid, validClaims());
// Metadata with jwks_uri instead of inline jwks; stub the fetch.
const orig = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 });
try {
const res = await pds._oauth.authenticateClient(
{
client_id: CLIENT_ID,
token_endpoint_auth_method: 'private_key_jwt',
jwks_uri: 'https://client.example.com/jwks.json',
},
{ client_assertion: assertion, client_assertion_type: ASSERTION_TYPE },
ISSUER,
);
expect(res).toBeNull();
} finally {
globalThis.fetch = orig;
}
});
it('passes a public client (none) through untouched', async () => {
const res = await createServer()._oauth.authenticateClient(
{ client_id: CLIENT_ID, token_endpoint_auth_method: 'none' },
{},
ISSUER,
);
expect(res).toBeNull();
});
it('rejects a missing client_assertion', async () => {
const res = await createServer()._oauth.authenticateClient(
{ client_id: CLIENT_ID, token_endpoint_auth_method: 'private_key_jwt' },
{},
ISSUER,
);
await expectRejected(res);
});
it('rejects a signature from the wrong key', async () => {
const { jwk } = await makeClientKey();
const attacker = await makeClientKey(jwk.kid); // same kid, different key
const assertion = await signAssertion(
attacker.privateKey,
jwk.kid,
validClaims(),
);
await expectRejected(await auth(createServer(), jwk, assertion));
});
it('rejects a wrong audience', async () => {
const { privateKey, jwk } = await makeClientKey();
const assertion = await signAssertion(
privateKey,
jwk.kid,
validClaims({ aud: 'https://someone-else.example.com' }),
);
await expectRejected(await auth(createServer(), jwk, assertion));
});
it('rejects iss/sub that are not the client_id', async () => {
const { privateKey, jwk } = await makeClientKey();
const assertion = await signAssertion(
privateKey,
jwk.kid,
validClaims({ iss: 'https://evil.example.com', sub: CLIENT_ID }),
);
await expectRejected(await auth(createServer(), jwk, assertion));
});
it('rejects an expired assertion', async () => {
const { privateKey, jwk } = await makeClientKey();
const now = Math.floor(Date.now() / 1000);
const assertion = await signAssertion(
privateKey,
jwk.kid,
validClaims({ iat: now - 120, exp: now - 60 }),
);
await expectRejected(await auth(createServer(), jwk, assertion));
});
it('rejects an assertion issued too long ago', async () => {
const { privateKey, jwk } = await makeClientKey();
const now = Math.floor(Date.now() / 1000);
// No exp, but iat well beyond the max age.
const assertion = await signAssertion(
privateKey,
jwk.kid,
validClaims({ iat: now - 600, exp: undefined }),
);
await expectRejected(await auth(createServer(), jwk, assertion));
});
it('rejects a missing jti', async () => {
const { privateKey, jwk } = await makeClientKey();
const assertion = await signAssertion(
privateKey,
jwk.kid,
validClaims({ jti: undefined }),
);
await expectRejected(await auth(createServer(), jwk, assertion));
});
it('rejects an unknown kid', async () => {
const { privateKey, jwk } = await makeClientKey('real-kid');
const assertion = await signAssertion(
privateKey,
'unknown-kid',
validClaims(),
);
await expectRejected(await auth(createServer(), jwk, assertion));
});
it('rejects a non-ES256 alg', async () => {
const { privateKey, jwk } = await makeClientKey();
// Hand-forge a header claiming HS256 while still ES256-signing.
const claims = validClaims();
const signingInput = `${b64urlJson({ alg: 'HS256', typ: 'JWT', kid: jwk.kid })}.${b64urlJson(claims)}`;
const sig = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
privateKey,
new TextEncoder().encode(signingInput),
);
await expectRejected(
await auth(createServer(), jwk, `${signingInput}.${b64url(sig)}`),
);
});
it('rejects a replayed jti', async () => {
const { privateKey, jwk } = await makeClientKey();
const pds = createServer();
const assertion = await signAssertion(privateKey, jwk.kid, validClaims());
expect(await auth(pds, jwk, assertion)).toBeNull();
await expectRejected(await auth(pds, jwk, assertion));
});
});