chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Signers here use @pdsjs/core/crypto's sign(), which normalizes to low-S.
// atproto rejects high-S signatures as malleable and raw crypto.subtle.sign
// emits them about half the time, so a test signing the raw way would verify
// fine under WebCrypto while the reference implementation rejected it.
import { compressPublicKey, sign } from '@pdsjs/core/crypto';
import { publicKeyToDidKey } from '@pdsjs/core/plc';
import { beforeEach, describe, expect, it } from 'vitest';
import { ATPROTO_KEY_ID, ATPROTO_PDS_ID } from '../src/authority.js';
import { checkUserAccess } from '../src/handlers/auth.js';
import {
createSpaceSigHeaders,
generateSpaceSigKey,
} from '../src/http-signature.js';
import {
createMemoryBlobContext,
createMemorySpaceStorage,
} from '../src/memory-storage.js';
import { createSpaceRoutes } from '../src/routes.js';
import { makeSpaceRow } from '../src/space-row.js';
import { verifyRecord } from '../src/star.js';
import { createSpaceToken } from '../src/token.js';
import { createVerifier } from '../src/verifier.js';
import { applyWrites } from '../src/writer.js';
import { makeKemKey, responseMac, wantMac } from './helpers/kem.js';
const AUTHORITY = 'did:plc:authority';
const USER = 'did:plc:user';
const SPACE = `at://${AUTHORITY}/space/com.example.forum/general`;
const COL = 'com.example.post';
// A realistic grant: whole-space read plus writes to one collection.
const SCOPE =
`space:com.example.forum?authority=${'did:plc:authority'}` +
`&collection=${'com.example.post'}`;
/** A P-256 identity whose did:key can go in a DID document. */
async function makeIdentity() {
const keys = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify'],
);
const raw = new Uint8Array(
await crypto.subtle.exportKey('raw', keys.publicKey),
);
const didKey = publicKeyToDidKey(compressPublicKey(raw));
const jwk = /** @type {any} */ (
await crypto.subtle.exportKey('jwk', keys.publicKey)
);
return {
didKey,
multibase: didKey.slice('did:key:'.length),
// What a client publishes for an attestation to be checked against.
publicJwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y },
signer: {
/** @param {Uint8Array} bytes */
sign: (bytes) => sign(keys.privateKey, bytes),
},
};
}
/**
* Run `fn` with the client's metadata document served from its client_id.
*
* The attestation verifier resolves the client through the global fetch, the
* same way the OAuth client authentication does.
*
* @template T
* @param {string} clientId
* @param {{keys: unknown[]}} jwks
* @param {() => Promise<T>} fn
* @returns {Promise<T>}
*/
async function withClientMetadata(clientId, jwks, fn) {
const original = globalThis.fetch;
globalThis.fetch = async (/** @type {any} */ input) => {
if (String(input) === clientId) {
return Response.json({ client_id: clientId, jwks });
}
throw new Error(`unexpected fetch: ${input}`);
};
try {
return await fn();
} finally {
globalThis.fetch = original;
}
}
/** @type {any} */
let authority;
/** @type {any} */
let user;
/** @type {any} */
let didDocs;
/** The application's key, which every credential here is bound to. */
/** @type {import('../src/http-signature.js').SpaceSigKey} */
let sigKey;
/** The user's KEM key, which their repo host MACs reads under. */
/** @type {{privateKey: Uint8Array, didKey: string}} */
let userKem;
/**
* Build a PDS-side route table for one account.
* @param {string} did
* @param {any} identity
* @param {any} storage
*/
function routesFor(did, identity, storage) {
return createSpaceRoutes({
spaceStorage: storage,
...createMemoryBlobContext(),
getDid: async () => did,
getSigner: async () => identity.signer,
getKemKey: async () => (did === USER ? userKem : null),
resolveDid: async (/** @type {string} */ d) => didDocs[d] ?? null,
verifier: createVerifier(),
});
}
/** @type {any} */
let authorityRoutes;
/** @type {any} */
let userRoutes;
/** @type {any} */
let authorityStore;
/** @type {any} */
let userStore;
beforeEach(async () => {
authority = await makeIdentity();
user = await makeIdentity();
sigKey = await generateSpaceSigKey();
userKem = await makeKemKey();
didDocs = {
[AUTHORITY]: {
id: AUTHORITY,
verificationMethod: [
// `#atproto` signs the authority's credentials.
{
id: ATPROTO_KEY_ID,
type: 'Multikey',
publicKeyMultibase: authority.multibase,
},
],
service: [
{
id: ATPROTO_PDS_ID,
type: 'AtprotoPersonalDataServer',
serviceEndpoint: 'https://authority.example',
},
],
},
[USER]: {
id: USER,
verificationMethod: [
{
id: '#atproto',
type: 'Multikey',
publicKeyMultibase: user.multibase,
},
],
},
};
authorityStore = createMemorySpaceStorage();
userStore = createMemorySpaceStorage();
authorityRoutes = routesFor(AUTHORITY, authority, authorityStore);
userRoutes = routesFor(USER, user, userStore);
// The authority owns the space.
await authorityStore.putSpace(makeSpaceRow(SPACE, { isOwner: true }));
});
/** @param {any} routes @param {string} path @param {Record<string,string>} params @param {Record<string,string>} [headers] */
function get(routes, path, params = {}, headers = {}) {
const url = new URL(`https://pds.example${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const request = new Request(url, { headers });
return routes[path].handler(request, url, {
did: params._did ?? USER,
scope: params._scope ?? SCOPE,
});
}
/** @param {any} routes @param {string} path @param {any} body @param {Record<string,string>} [headers] */
function post(routes, path, body, headers = {}) {
const url = new URL(`https://pds.example${path}`);
const request = new Request(url, {
method: 'POST',
body: JSON.stringify(body),
headers,
});
return routes[path].handler(request, url, null);
}
/**
* A request presenting a space credential: the Atproto-Space-Credential scheme,
* and a signature over it and the audience by the key the credential is bound
* to. The audience is the repo the request names, or else the authority.
* @param {import('../src/http-signature.js').SpaceSigKey} holder
* @param {string} credential
* @param {URL} url
* @param {string} [audience]
* @param {Record<string, string>} [extra] - further request headers
* @returns {Promise<Request>}
*/
async function presenting(holder, credential, url, audience, extra = {}) {
return new Request(url, {
headers: {
...(await createSpaceSigHeaders(holder, {
authorization: `Atproto-Space-Credential ${credential}`,
audience: audience ?? url.searchParams.get('repo') ?? AUTHORITY,
})),
...extra,
},
});
}
/**
* Ask the authority for a credential.
* @param {Record<string, unknown>} body
*/
function requestCredential(body) {
return post(authorityRoutes, '/xrpc/com.atproto.space.getSpaceCredential', {
credentialKid: sigKey.did,
...body,
});
}
/** The full chain: delegation token -> credential bound to `holder`'s key. */
async function getCredential(holder = sigKey, space = SPACE) {
const { token } = await (
await get(
userRoutes,
'/xrpc/com.atproto.space.getDelegationToken',
{ space },
{},
)
).json();
const res = await requestCredential({
space,
credentialKid: holder.did,
delegationToken: token,
});
return { res, token };
}
describe('getDelegationToken', () => {
it('mints a token addressed to the authority space host', async () => {
const res = await get(
userRoutes,
'/xrpc/com.atproto.space.getDelegationToken',
{
space: SPACE,
},
);
expect(res.status).toBe(200);
const { token } = await res.json();
const payload = JSON.parse(
new TextDecoder().decode(
Uint8Array.from(
atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')),
(c) => c.charCodeAt(0),
),
),
);
expect(payload.iss).toBe(USER);
expect(payload.sub).toBe(SPACE);
expect(payload.aud).toBe(`${AUTHORITY}${ATPROTO_PDS_ID}`);
expect(payload.exp - payload.iat).toBe(60);
});
it('refuses to mint for another account', async () => {
const url = new URL(
'https://pds.example/xrpc/com.atproto.space.getDelegationToken',
);
url.searchParams.set('space', SPACE);
const res = await userRoutes[
'/xrpc/com.atproto.space.getDelegationToken'
].handler(new Request(url), url, {
did: 'did:plc:someone-else',
scope: SCOPE,
});
expect(res.status).toBe(403);
});
});
/** A memberspace anyone may read and write. */
const PUBLIC = {
$type: 'com.atproto.memberspace.policy',
publicRead: true,
publicWrite: true,
};
describe('getSpaceCredential', () => {
// A refusal answers as a space that does not exist, so it discloses
// nothing about the space to someone without access to it.
it('answers a non-member of a memberspace that the space is not there', async () => {
const { res } = await getCredential();
expect(res.status).toBe(404);
expect((await res.json()).error).toBe('SpaceNotFound');
});
it('checks the user before the app', async () => {
await authorityStore.putSpace(
makeSpaceRow(SPACE, {
isOwner: true,
policy: {
$type: 'com.atproto.memberspace.policy',
allowedApps: ['https://allowed.example/client-metadata.json'],
},
}),
);
const { res } = await getCredential();
expect((await res.json()).error).toBe('SpaceNotFound');
});
it('admits nobody but the authority to a personalspace', async () => {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
await authorityStore.putSpace(
makeSpaceRow(SPACE, {
isOwner: true,
policy: { $type: 'com.atproto.personalspace.policy' },
}),
);
const { res } = await getCredential();
expect((await res.json()).error).toBe('SpaceNotFound');
});
/** A delegation token from the member, signed by `signer`. */
async function memberToken(signer = user.signer, claims = {}) {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
return createSpaceToken(
'delegation',
{
iss: USER,
sub: SPACE,
aud: `${AUTHORITY}${ATPROTO_PDS_ID}`,
...claims,
},
signer,
);
}
/** @param {string} jwt */
const payloadOf = (jwt) =>
JSON.parse(
new TextDecoder().decode(
Uint8Array.from(
atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')),
(c) => c.charCodeAt(0),
),
),
);
it('binds the credential to the key the body names', async () => {
const res = await requestCredential({
space: SPACE,
delegationToken: await memberToken(),
});
expect(res.status).toBe(200);
const payload = payloadOf((await res.json()).credential);
expect(payload.cnf).toEqual({ kid: sigKey.did });
expect(payload.exp - payload.iat).toBe(600);
});
it('refuses a credentialKid that is not a P-256 did:key', async () => {
const delegationToken = await memberToken();
for (const credentialKid of [
undefined,
'not-a-did',
// secp256k1
'did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme',
]) {
const res = await requestCredential({
space: SPACE,
delegationToken,
credentialKid,
});
expect(res.status, String(credentialKid)).toBe(400);
expect((await res.json()).error).toBe('InvalidRequest');
}
});
it('issues a credential to the authority for its own space', async () => {
// No member row for the authority, and the policy is member-list. The
// authority is the only party who can add one, so a policy that locked it
// out would leave it unable to open its own space.
const token = await createSpaceToken(
'delegation',
{ iss: AUTHORITY, sub: SPACE, aud: `${AUTHORITY}${ATPROTO_PDS_ID}` },
authority.signer,
);
const res = await requestCredential({
space: SPACE,
delegationToken: token,
});
expect(res.status).toBe(200);
expect(typeof (await res.json()).credential).toBe('string');
});
it('issues a credential to a member', async () => {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
const { res } = await getCredential();
expect(res.status).toBe(200);
const { credential } = await res.json();
expect(typeof credential).toBe('string');
const header = JSON.parse(
new TextDecoder().decode(
Uint8Array.from(atob(credential.split('.')[0]), (c) => c.charCodeAt(0)),
),
);
expect(header.typ).toBe('atproto-space-credential+jwt');
expect(header.kid).toBe(ATPROTO_KEY_ID);
const payload = JSON.parse(
new TextDecoder().decode(
Uint8Array.from(atob(credential.split('.')[1]), (c) => c.charCodeAt(0)),
),
);
// The binding, without which the credential is a bearer token for the space.
expect(payload.cnf).toEqual({ kid: sigKey.did });
expect(payload.aud).toBeUndefined();
});
it('issues to anyone under a public policy', async () => {
await authorityStore.putSpace(
makeSpaceRow(SPACE, {
isOwner: true,
policy: PUBLIC,
}),
);
const { res } = await getCredential();
expect(res.status).toBe(200);
});
it('issues without a delegation token under a public read policy', async () => {
await authorityStore.putSpace(
makeSpaceRow(SPACE, { isOwner: true, policy: PUBLIC }),
);
const res = await requestCredential({ space: SPACE });
expect(res.status).toBe(200);
const payload = payloadOf((await res.json()).credential);
expect(payload.sub).toBe(SPACE);
expect(payload.cnf).toEqual({ kid: sigKey.did });
});
// Without a delegation token, a space that is not public answers the same as
// one that does not exist.
it('answers SpaceNotFound without a delegation token', async () => {
const res = await requestCredential({ space: SPACE });
expect(res.status).toBe(404);
expect((await res.json()).error).toBe('SpaceNotFound');
const missing = await requestCredential({
space: `at://${AUTHORITY}/space/com.example.forum/missing`,
});
expect(missing.status).toBe(404);
expect((await missing.json()).error).toBe('SpaceNotFound');
});
it('answers SpaceNotFound for a space another authority holds', async () => {
const res = await requestCredential({
space: 'at://did:plc:elsewhere/space/com.example.forum/general',
});
expect(res.status).toBe(404);
expect((await res.json()).error).toBe('SpaceNotFound');
});
it('rejects a delegation token signed by the wrong key', async () => {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
const impostor = await makeIdentity();
const token = await createSpaceToken(
'delegation',
{ iss: USER, sub: SPACE, aud: `${AUTHORITY}${ATPROTO_PDS_ID}` },
impostor.signer,
);
const res = await post(
authorityRoutes,
'/xrpc/com.atproto.space.getSpaceCredential',
{ space: SPACE, credentialKid: sigKey.did, delegationToken: token },
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('InvalidDelegationToken');
});
it('rejects a delegation token whose header contradicts the issuer key', async () => {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
// A correct signature under a P-256 key, stamped ES256K. The reference
// implementation refuses the mismatch, so this server has to as well.
const token = await createSpaceToken(
'delegation',
{
iss: USER,
sub: SPACE,
aud: `${AUTHORITY}${ATPROTO_PDS_ID}`,
alg: 'ES256K',
},
user.signer,
);
const res = await post(
authorityRoutes,
'/xrpc/com.atproto.space.getSpaceCredential',
{ space: SPACE, credentialKid: sigKey.did, delegationToken: token },
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('InvalidDelegationToken');
});
/**
* An authority whose resolver holds a stale document for the user: the key
* they rotated away from, until a caller forces a refresh.
* @param {any} rotated - the identity the user rotated to
*/
function authorityHoldingStaleKey(rotated) {
const stale = didDocs[USER];
const fresh = {
...stale,
verificationMethod: [
{
id: '#atproto',
type: 'Multikey',
publicKeyMultibase: rotated.multibase,
},
],
};
return createSpaceRoutes({
spaceStorage: authorityStore,
...createMemoryBlobContext(),
getDid: async () => AUTHORITY,
getSigner: async () => authority.signer,
getKemKey: async () => null,
resolveDid: /** @type {import('@pdsjs/core/ports').DidResolverPort} */ (
async (d, opts) => {
if (d !== USER) return didDocs[d] ?? null;
return opts?.forceRefresh ? fresh : stale;
}
),
verifier: createVerifier(),
});
}
it('accepts a token signed by the key the issuer rotated to', async () => {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
const rotated = await makeIdentity();
const token = await createSpaceToken(
'delegation',
{ iss: USER, sub: SPACE, aud: `${AUTHORITY}${ATPROTO_PDS_ID}` },
rotated.signer,
);
const res = await post(
authorityHoldingStaleKey(rotated),
'/xrpc/com.atproto.space.getSpaceCredential',
{ space: SPACE, credentialKid: sigKey.did, delegationToken: token },
);
expect(res.status).toBe(200);
});
it('refuses a token that matches neither the held key nor the current one', async () => {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
const rotated = await makeIdentity();
const impostor = await makeIdentity();
const token = await createSpaceToken(
'delegation',
{ iss: USER, sub: SPACE, aud: `${AUTHORITY}${ATPROTO_PDS_ID}` },
impostor.signer,
);
const res = await post(
authorityHoldingStaleKey(rotated),
'/xrpc/com.atproto.space.getSpaceCredential',
{ space: SPACE, credentialKid: sigKey.did, delegationToken: token },
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('InvalidDelegationToken');
});
it('rejects a token addressed to a different authority', async () => {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
const token = await createSpaceToken(
'delegation',
{ iss: USER, sub: SPACE, aud: 'did:plc:elsewhere#atproto_pds' },
user.signer,
);
const res = await post(
authorityRoutes,
'/xrpc/com.atproto.space.getSpaceCredential',
{ space: SPACE, credentialKid: sigKey.did, delegationToken: token },
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('InvalidDelegationToken');
});
it('rejects a token whose subject is a different space', async () => {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
const token = await createSpaceToken(
'delegation',
{
iss: USER,
sub: `at://${AUTHORITY}/space/com.example.forum/other`,
aud: `${AUTHORITY}${ATPROTO_PDS_ID}`,
},
user.signer,
);
const res = await post(
authorityRoutes,
'/xrpc/com.atproto.space.getSpaceCredential',
{ space: SPACE, credentialKid: sigKey.did, delegationToken: token },
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('InvalidDelegationToken');
});
it('spends the delegation token, so it buys exactly one credential', async () => {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
const { token } = await (
await get(userRoutes, '/xrpc/com.atproto.space.getDelegationToken', {
space: SPACE,
})
).json();
const exchange = () =>
requestCredential({ space: SPACE, delegationToken: token });
expect((await exchange()).status).toBe(200);
const second = await exchange();
expect(second.status).toBe(400);
expect((await second.json()).error).toBe('InvalidDelegationToken');
});
it('answers SpaceDeleted for a deleted space, with or without a token', async () => {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
await authorityStore.deleteSpace(SPACE, new Date().toISOString());
const { res } = await getCredential();
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('SpaceDeleted');
const bare = await requestCredential({ space: SPACE });
expect((await bare.json()).error).toBe('SpaceDeleted');
});
it('enforces an app allow list', async () => {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
await authorityStore.putSpace(
makeSpaceRow(SPACE, {
isOwner: true,
policy: {
$type: 'com.atproto.memberspace.policy',
allowedApps: ['https://allowed.example/client-metadata.json'],
},
}),
);
const { res } = await getCredential();
expect(res.status).toBe(403);
expect((await res.json()).error).toBe('AppNotAuthorized');
});
it('authorizes an attested client on the allow list', async () => {
const clientId = 'https://allowed.example/client-metadata.json';
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
await authorityStore.putSpace(
makeSpaceRow(SPACE, {
isOwner: true,
policy: {
$type: 'com.atproto.memberspace.policy',
allowedApps: [clientId],
},
}),
);
const app = await makeIdentity();
const attestation = await createSpaceToken(
'clientAttestation',
{
iss: clientId,
sub: clientId,
aud: `${AUTHORITY}${ATPROTO_PDS_ID}`,
kid: 'key-1',
},
app.signer,
);
const { token } = await (
await get(userRoutes, '/xrpc/com.atproto.space.getDelegationToken', {
space: SPACE,
})
).json();
const res = await withClientMetadata(
clientId,
{ keys: [{ ...app.publicJwk, kid: 'key-1' }] },
async () =>
requestCredential({
space: SPACE,
delegationToken: token,
clientAttestation: attestation,
}),
);
expect(res.status).toBe(200);
});
it('refuses an attestation signed by a key the client does not publish', async () => {
const clientId = 'https://allowed.example/client-metadata.json';
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
await authorityStore.putSpace(
makeSpaceRow(SPACE, {
isOwner: true,
policy: {
$type: 'com.atproto.memberspace.policy',
allowedApps: [clientId],
},
}),
);
const app = await makeIdentity();
const impostor = await makeIdentity();
const attestation = await createSpaceToken(
'clientAttestation',
{
iss: clientId,
sub: clientId,
aud: `${AUTHORITY}${ATPROTO_PDS_ID}`,
kid: 'key-1',
},
impostor.signer,
);
const { token } = await (
await get(userRoutes, '/xrpc/com.atproto.space.getDelegationToken', {
space: SPACE,
})
).json();
const res = await withClientMetadata(
clientId,
{ keys: [{ ...app.publicJwk, kid: 'key-1' }] },
async () =>
requestCredential({
space: SPACE,
delegationToken: token,
clientAttestation: attestation,
}),
);
expect(res.status).toBe(400);
expect((await res.json()).error).toBe('InvalidClientAttestation');
});
it('refuses under a managedspace whose managing app cannot be reached', async () => {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
await authorityStore.putSpace(
makeSpaceRow(SPACE, {
isOwner: true,
policy: {
$type: 'com.atproto.managedspace.policy',
managingApp: 'did:web:app.example#service',
},
}),
);
const { res } = await getCredential();
expect(res.status).toBe(404);
expect((await res.json()).error).toBe('SpaceNotFound');
});
});
describe('reading with a credential', () => {
/** Issue a real credential for the space. */
async function credential() {
await authorityStore.putMember(SPACE, USER, { read: true, write: true });
const { res } = await getCredential();
return (await res.json()).credential;
}
it('lets a third party read a repo it does not own', async () => {
await applyWrites(userStore, {
space: SPACE,
writes: [
{ action: 'create', collection: COL, rkey: 'a', record: { v: 1 } },
],
});
const cred = await credential();
const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
url.searchParams.set('space', SPACE);
url.searchParams.set('collection', COL);
url.searchParams.set('rkey', 'a');
const want = await wantMac('atproto-space-record', userKem.didKey);
const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
await presenting(sigKey, cred, url, USER, {
'atproto-space-want-mac': want.header,
}),
url,
// No PDS session: the credential is the only thing granting access.
null,
);
expect(res.status).toBe(200);
const record = await res.json();
expect(record.value).toEqual({ v: 1 });
expect(
await verifyRecord(record, {
uri: `${SPACE}/${USER}/${COL}/a`,
sharedSecret: want.sharedSecret,
mac: responseMac(res, 'atproto-space-record'),
}),
).toBe(true);
});
it('refuses a credential read that asks for no MAC', async () => {
await applyWrites(userStore, {
space: SPACE,
writes: [
{ action: 'create', collection: COL, rkey: 'a', record: { v: 1 } },
],
});
const cred = await credential();
for (const path of [
'/xrpc/com.atproto.space.getRecord',
'/xrpc/com.atproto.space.getLatestCommit',
'/xrpc/com.atproto.space.listRepoOps',
'/xrpc/com.atproto.space.getRepo',
]) {
const url = new URL(`https://pds.example${path}`);
url.searchParams.set('space', SPACE);
url.searchParams.set('collection', COL);
url.searchParams.set('rkey', 'a');
const res = await userRoutes[path].handler(
await presenting(sigKey, cred, url, USER),
url,
null,
);
expect(res.status, path).toBe(400);
expect((await res.json()).error).toBe('BadSpaceMac');
}
});
it('lists records to a credential without a MAC', async () => {
const cred = await credential();
const url = new URL(
'https://pds.example/xrpc/com.atproto.space.listRecords',
);
url.searchParams.set('space', SPACE);
const res = await userRoutes['/xrpc/com.atproto.space.listRecords'].handler(
await presenting(sigKey, cred, url, USER),
url,
null,
);
expect(res.status).toBe(200);
});
it('refuses a credential presented without a signature', async () => {
const cred = await credential();
const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
url.searchParams.set('space', SPACE);
url.searchParams.set('collection', COL);
url.searchParams.set('rkey', 'a');
const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
new Request(url, {
headers: {
authorization: `Atproto-Space-Credential ${cred}`,
'atproto-space-audience': USER,
},
}),
url,
null,
);
expect(res.status).toBe(401);
expect((await res.json()).error).toBe('BadSpaceSignature');
});
it('refuses a credential presented as a bearer token', async () => {
const cred = await credential();
const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
url.searchParams.set('space', SPACE);
url.searchParams.set('collection', COL);
url.searchParams.set('rkey', 'a');
const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
new Request(url, { headers: { authorization: `Bearer ${cred}` } }),
url,
null,
);
expect(res.status).toBe(401);
expect((await res.json()).error).toBe('AuthenticationRequired');
});
// The property the binding exists for: a repo host handed a credential to
// serve its own repo cannot turn round and use it on another host.
it('refuses a signature by a key the credential is not bound to', async () => {
const cred = await credential();
const thief = await generateSpaceSigKey();
const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
url.searchParams.set('space', SPACE);
url.searchParams.set('collection', COL);
url.searchParams.set('rkey', 'a');
const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
await presenting(thief, cred, url, USER),
url,
null,
);
expect(res.status).toBe(401);
expect((await res.json()).error).toBe('BadSpaceSignature');
});
it('refuses a request addressed to another account', async () => {
const cred = await credential();
const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
url.searchParams.set('space', SPACE);
url.searchParams.set('collection', COL);
url.searchParams.set('rkey', 'a');
const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
await presenting(sigKey, cred, url, 'did:plc:somebody-else'),
url,
null,
);
expect(res.status).toBe(401);
expect((await res.json()).error).toBe('BadSpaceAudience');
});
it('refuses an audience sent twice', async () => {
const cred = await credential();
const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
url.searchParams.set('space', SPACE);
url.searchParams.set('collection', COL);
url.searchParams.set('rkey', 'a');
const request = await presenting(sigKey, cred, url, USER);
request.headers.append('atproto-space-audience', USER);
const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
request,
url,
null,
);
expect(res.status).toBe(401);
expect((await res.json()).error).toBe('BadSpaceSignature');
});
// Nothing in the signature covers the request itself, so one made for this
// credential and audience serves every request that pairs them.
it('accepts a signature reused on a second request', async () => {
const cred = await credential();
const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
url.searchParams.set('space', SPACE);
url.searchParams.set('collection', COL);
url.searchParams.set('rkey', 'a');
const request = await presenting(sigKey, cred, url, USER);
for (const attempt of [request.clone(), request]) {
const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
attempt,
url,
null,
);
expect(res.status).not.toBe(401);
}
});
it('refuses a credential the authority revoked', async () => {
const cred = await credential();
const { jti } = JSON.parse(atob(cred.split('.')[1]));
await userStore.putCredentialRevocations(
SPACE,
[jti],
new Date(Date.now() + 60_000).toISOString(),
);
const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
url.searchParams.set('space', SPACE);
url.searchParams.set('collection', COL);
url.searchParams.set('rkey', 'a');
const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
await presenting(sigKey, cred, url, USER),
url,
null,
);
expect(res.status).toBe(401);
expect((await res.json()).error).toBe('CredentialRevoked');
});
it('refuses a read with neither session nor credential', async () => {
const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
url.searchParams.set('space', SPACE);
url.searchParams.set('collection', COL);
url.searchParams.set('rkey', 'a');
const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
new Request(url),
url,
null,
);
expect(res.status).toBe(401);
});
it('refuses a credential minted for a different space', async () => {
const other = `at://${AUTHORITY}/space/com.example.forum/other`;
await authorityStore.putSpace(
makeSpaceRow(other, { isOwner: true, policy: PUBLIC }),
);
const cred = (await (await getCredential(sigKey, other)).res.json())
.credential;
const url = new URL('https://pds.example/xrpc/com.atproto.space.getRecord');
url.searchParams.set('space', SPACE);
url.searchParams.set('collection', COL);
url.searchParams.set('rkey', 'a');
const res = await userRoutes['/xrpc/com.atproto.space.getRecord'].handler(
await presenting(sigKey, cred, url, USER),
url,
null,
);
expect(res.status).toBe(401);
expect((await res.json()).error).toBe('BadJwtSub');
});
});
describe('listRepos', () => {
/** Issue a real credential for a space, as a member would hold. */
async function credentialFor(space = SPACE) {
await authorityStore.putMember(space, USER, { read: true, write: true });
const { res } = await getCredential(sigKey, space);
return (await res.json()).credential;
}
/** @param {string|null} cred */
async function listRepos(cred, space = SPACE) {
const url = new URL('https://pds.example/xrpc/com.atproto.space.listRepos');
url.searchParams.set('space', space);
return authorityRoutes['/xrpc/com.atproto.space.listRepos'].handler(
cred ? await presenting(sigKey, cred, url) : new Request(url),
url,
null,
);
}
beforeEach(async () => {
await authorityStore.putSpaceWriter(
SPACE,
USER,
'3lkaaa',
new Uint8Array(32).fill(3),
'3lksss',
);
});
it('lists the writer set to a credential holder', async () => {
const res = await listRepos(await credentialFor());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.repos).toHaveLength(1);
expect(body.repos[0].did).toBe(USER);
expect(body.repos[0].repoRev).toBe('3lkaaa');
expect(body.repos[0].spaceRev).toBe('3lksss');
expect(typeof body.repos[0].data.$bytes).toBe('string');
expect(body.cursor).toBe('3lksss');
});
it('ends on an empty page without a cursor', async () => {
const cred = await credentialFor();
const url = new URL('https://pds.example/xrpc/com.atproto.space.listRepos');
url.searchParams.set('space', SPACE);
url.searchParams.set('cursor', '3lksss');
const res = await authorityRoutes[
'/xrpc/com.atproto.space.listRepos'
].handler(await presenting(sigKey, cred, url), url, null);
const body = await res.json();
expect(body.repos).toEqual([]);
expect(body.cursor).toBeUndefined();
});
it('refuses a request addressed to the repo host rather than the authority', async () => {
const cred = await credentialFor();
const url = new URL('https://pds.example/xrpc/com.atproto.space.listRepos');
url.searchParams.set('space', SPACE);
const res = await authorityRoutes[
'/xrpc/com.atproto.space.listRepos'
].handler(await presenting(sigKey, cred, url, USER), url, null);
expect(res.status).toBe(401);
expect((await res.json()).error).toBe('BadSpaceAudience');
});
// The writer set names who is in a space and when each of them last wrote.
// Serving it openly discloses the membership of a private space to anyone
// who can name it, and a space URI is guessable: the DID is public, the type
// is a published NSID, and `self` is a common record key.
it('refuses a caller presenting nothing', async () => {
const res = await listRepos(null);
expect(res.status).toBe(401);
expect((await res.json()).error).toBe('AuthenticationRequired');
});
it('refuses a credential presented without a signature', async () => {
const cred = await credentialFor();
const url = new URL('https://pds.example/xrpc/com.atproto.space.listRepos');
url.searchParams.set('space', SPACE);
const res = await authorityRoutes[
'/xrpc/com.atproto.space.listRepos'
].handler(
new Request(url, {
headers: { authorization: `Atproto-Space-Credential ${cred}` },
}),
url,
null,
);
expect(res.status).toBe(401);
expect((await res.json()).error).toBe('BadSpaceSignature');
});
// A credential is bound to one space by its `sub`, so holding one for a space
// this server also hosts says nothing about another.
it('refuses a credential issued for a different space', async () => {
const other = `at://${AUTHORITY}/space/com.example.other/skey`;
await authorityStore.putSpace(makeSpaceRow(other, { isOwner: true }));
const res = await listRepos(await credentialFor(other), SPACE);
expect(res.status).toBe(401);
});
// The credential is checked before the space is looked up, so a caller with
// none cannot learn which spaces this server is the authority for.
it('answers a space it does not host the same as one it does', async () => {
const res = await listRepos(
null,
'at://did:plc:someoneelse/space/com.example.forum/skey',
);
expect(res.status).toBe(401);
expect((await res.json()).error).toBe('AuthenticationRequired');
});
});
describe('scope enforcement', () => {
it('refuses a delegation token without a covering scope', async () => {
const url = new URL(
'https://pds.example/xrpc/com.atproto.space.getDelegationToken',
);
url.searchParams.set('space', SPACE);
const res = await userRoutes[
'/xrpc/com.atproto.space.getDelegationToken'
].handler(new Request(url), url, {
did: USER,
scope: 'repo:com.example.post',
});
expect(res.status).toBe(403);
expect((await res.json()).error).toBe('InvalidToken');
});
it('refuses a delegation token to a read_self-only grant', async () => {
const url = new URL(
'https://pds.example/xrpc/com.atproto.space.getDelegationToken',
);
url.searchParams.set('space', SPACE);
const res = await userRoutes[
'/xrpc/com.atproto.space.getDelegationToken'
].handler(new Request(url), url, {
did: USER,
scope: `space:com.example.forum?authority=${AUTHORITY}&action=read_self`,
});
expect(res.status).toBe(403);
});
it('refuses a write to a collection outside the grant', async () => {
const url = new URL(
'https://pds.example/xrpc/com.atproto.space.createRecord',
);
const res = await userRoutes[
'/xrpc/com.atproto.space.createRecord'
].handler(
new Request(url, {
method: 'POST',
body: JSON.stringify({
space: SPACE,
repo: USER,
collection: 'com.example.other',
record: {},
}),
}),
url,
{ did: USER, scope: SCOPE },
);
expect(res.status).toBe(403);
expect((await res.json()).error).toBe('InvalidToken');
});
it('permits a write inside the grant', async () => {
const url = new URL(
'https://pds.example/xrpc/com.atproto.space.createRecord',
);
const res = await userRoutes[
'/xrpc/com.atproto.space.createRecord'
].handler(
new Request(url, {
method: 'POST',
body: JSON.stringify({
space: SPACE,
repo: USER,
collection: COL,
record: {},
}),
}),
url,
{ did: USER, scope: SCOPE },
);
expect(res.status).toBe(200);
});
});
describe('checkUserAccess', () => {
it('asks the managing app with com.atproto.managedspace.checkUserAccess', async () => {
/** @type {string[]} */
const asked = [];
const authorized = await checkUserAccess({
managingApp: 'did:web:app.example#forum',
authorityDid: AUTHORITY,
space: SPACE,
access: 'read',
userDid: USER,
clientId: 'https://app.example/client-metadata.json',
resolveDid: async () => ({
id: 'did:web:app.example',
service: [
{
id: '#forum',
type: 'Forum',
serviceEndpoint: 'https://app.example',
},
],
}),
getSigner: async () => authority.signer,
fetch: async (/** @type {any} */ url) => {
asked.push(String(url));
return Response.json({ authorized: true });
},
});
expect(authorized).toBe(true);
const url = new URL(asked[0]);
expect(url.pathname).toBe('/xrpc/com.atproto.managedspace.checkUserAccess');
expect(url.searchParams.get('user')).toBe(USER);
expect(url.searchParams.get('clientId')).toBe(
'https://app.example/client-metadata.json',
);
});
});