chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Service auth tokens the account mints for this server, and the one route
// that redeems them. The Bluesky video service takes such a token, transcodes
// the video, then writes the result back through uploadBlob minutes later.
// Both halves matter: a token that outlives the transcode, and a Bearer token
// signed by the account key rather than the session secret.
import { k256Signer, secp256k1Verify } from '@pdsjs/node';
import { describe, expect, it } from 'vitest';
import {
createAccessJwt,
createServiceJwt,
} from '../packages/core/src/auth.js';
import {
base64UrlDecode,
encodeStoredKey,
generateKeyPair,
importPrivateKey,
} from '../packages/core/src/crypto.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';
import { createVerifier } from '../packages/core/src/verify.js';
const DID = 'did:plc:serviceauthaccount00000';
const HOSTNAME = 'pds.example.com';
const JWT_SECRET = 'test-secret';
const PNG = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2,
]);
/** @param {{curve?: 'p256'|'secp256k1'}} [options] */
async function server({ curve = 'p256' } = {}) {
const privateKey =
curve === 'secp256k1'
? encodeStoredKey(k256Signer.generatePrivateKey(), 'secp256k1')
: (await generateKeyPair()).privateKey;
/** @type {Map<string, {mimeType: string, size: number}>} */
const meta = new Map();
/** @type {Map<string, Uint8Array>} */
const store = new Map();
const pds = new PersonalDataServer({
actorStorage: /** @type {any} */ ({
async getDid() {
return DID;
},
async getPrivateKey() {
return privateKey;
},
async getHandle() {
return 'alice.example.com';
},
async putBlob(
/** @type {string} */ cid,
/** @type {string} */ mimeType,
/** @type {number} */ size,
) {
meta.set(cid, { mimeType, size });
},
}),
sharedStorage: /** @type {any} */ ({}),
blobs: /** @type {any} */ ({
async put(
/** @type {string} */ _did,
/** @type {string} */ cid,
/** @type {Uint8Array} */ data,
) {
store.set(cid, data);
},
}),
jwtSecret: JWT_SECRET,
hostname: HOSTNAME,
verifier: createVerifier({ secp256k1: secp256k1Verify }),
k256Signer,
});
const signingKey =
/** @type {import('../packages/core/src/crypto.js').SigningKey} */ (
await pds.getSigningKey()
);
return { pds, meta, store, signingKey };
}
/** @param {string} [scope] */
function accessToken(scope = 'com.atproto.access') {
return createAccessJwt(DID, JWT_SECRET, 3600, scope);
}
/**
* @param {PersonalDataServer} pds
* @param {Record<string, string>} params
*/
async function getServiceAuth(pds, params) {
const url = new URL(
`https://${HOSTNAME}/xrpc/com.atproto.server.getServiceAuth`,
);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return pds.fetch(
new Request(url, {
headers: { authorization: `Bearer ${await accessToken()}` },
}),
);
}
/**
* @param {PersonalDataServer} pds
* @param {string} token
*/
function uploadBlob(pds, token) {
return pds.fetch(
new Request(`https://${HOSTNAME}/xrpc/com.atproto.repo.uploadBlob`, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'image/png',
},
body: PNG,
}),
);
}
/** @param {string} jwt */
function claims(jwt) {
return JSON.parse(
new TextDecoder().decode(base64UrlDecode(jwt.split('.')[1])),
);
}
describe('getServiceAuth expiry', () => {
it('honors a requested expiry, so a token outlives a video transcode', async () => {
const { pds } = await server();
const exp = Math.floor(Date.now() / 1000) + 1800;
const response = await getServiceAuth(pds, {
aud: `did:web:${HOSTNAME}`,
lxm: 'com.atproto.repo.uploadBlob',
exp: String(exp),
});
expect(response.status).toBe(200);
expect(claims((await response.json()).token).exp).toBe(exp);
});
it('expires a token in a minute when the caller asks for nothing', async () => {
const { pds } = await server();
const response = await getServiceAuth(pds, {
aud: 'did:web:api.bsky.app',
lxm: 'app.bsky.feed.getTimeline',
});
const { exp, iat } = claims((await response.json()).token);
expect(exp - iat).toBe(60);
});
it('refuses an expiry in the past', async () => {
const { pds } = await server();
const response = await getServiceAuth(pds, {
aud: `did:web:${HOSTNAME}`,
lxm: 'com.atproto.repo.uploadBlob',
exp: String(Math.floor(Date.now() / 1000) - 10),
});
expect(response.status).toBe(400);
expect((await response.json()).error).toBe('BadExpiration');
});
it('refuses an expiry more than an hour out', async () => {
const { pds } = await server();
const response = await getServiceAuth(pds, {
aud: `did:web:${HOSTNAME}`,
lxm: 'com.atproto.repo.uploadBlob',
exp: String(Math.floor(Date.now() / 1000) + 3601),
});
expect(response.status).toBe(400);
expect((await response.json()).error).toBe('BadExpiration');
});
});
describe('uploadBlob with a service auth token', () => {
it('accepts the token this server minted for itself', async () => {
const { pds, meta } = await server();
const response = await getServiceAuth(pds, {
aud: `did:web:${HOSTNAME}`,
lxm: 'com.atproto.repo.uploadBlob',
exp: String(Math.floor(Date.now() / 1000) + 1800),
});
const { token } = await response.json();
const upload = await uploadBlob(pds, token);
expect(upload.status).toBe(200);
const { blob } = await upload.json();
expect(blob.mimeType).toBe('image/png');
expect(blob.size).toBe(PNG.length);
expect(meta.has(blob.ref.$link)).toBe(true);
});
it('accepts a token signed ES256K, the curve an account carries by default', async () => {
const { pds, meta } = await server({ curve: 'secp256k1' });
const response = await getServiceAuth(pds, {
aud: `did:web:${HOSTNAME}`,
lxm: 'com.atproto.repo.uploadBlob',
exp: String(Math.floor(Date.now() / 1000) + 1800),
});
const { token } = await response.json();
const upload = await uploadBlob(pds, token);
expect(upload.status).toBe(200);
expect(meta.size).toBe(1);
});
it('refuses a token bound to another method', async () => {
const { pds, signingKey } = await server();
const token = await createServiceJwt({
iss: DID,
aud: `did:web:${HOSTNAME}`,
lxm: 'app.bsky.feed.getTimeline',
signingKey,
});
expect((await uploadBlob(pds, token)).status).toBe(401);
});
it('refuses a token addressed to another service', async () => {
const { pds, signingKey } = await server();
const token = await createServiceJwt({
iss: DID,
aud: 'did:web:video.bsky.app',
lxm: 'com.atproto.repo.uploadBlob',
signingKey,
});
expect((await uploadBlob(pds, token)).status).toBe(401);
});
it('refuses a token issued by another account', async () => {
const { pds } = await server();
const other = await generateKeyPair();
const token = await createServiceJwt({
iss: 'did:plc:someoneelse000000000000',
aud: `did:web:${HOSTNAME}`,
lxm: 'com.atproto.repo.uploadBlob',
signingKey: await importPrivateKey(other.privateKey),
});
expect((await uploadBlob(pds, token)).status).toBe(401);
});
it('refuses an expired token', async () => {
const { pds, signingKey } = await server();
const token = await createServiceJwt({
iss: DID,
aud: `did:web:${HOSTNAME}`,
lxm: 'com.atproto.repo.uploadBlob',
signingKey,
exp: Math.floor(Date.now() / 1000) - 1,
});
expect((await uploadBlob(pds, token)).status).toBe(401);
});
it('refuses a token signed by a key that is not the account key', async () => {
const { pds } = await server();
const forged = await generateKeyPair();
const token = await createServiceJwt({
iss: DID,
aud: `did:web:${HOSTNAME}`,
lxm: 'com.atproto.repo.uploadBlob',
signingKey: await importPrivateKey(forged.privateKey),
});
expect((await uploadBlob(pds, token)).status).toBe(401);
});
it('leaves every other route on session credentials', async () => {
const { pds, signingKey } = await server();
const token = await createServiceJwt({
iss: DID,
aud: `did:web:${HOSTNAME}`,
lxm: 'com.atproto.server.getSession',
signingKey,
});
const response = await pds.fetch(
new Request(`https://${HOSTNAME}/xrpc/com.atproto.server.getSession`, {
headers: { authorization: `Bearer ${token}` },
}),
);
expect(response.status).toBe(401);
});
});