// 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} fn * @returns {Promise} */ 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} params @param {Record} [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} [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} [extra] - further request headers * @returns {Promise} */ 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} 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', ); }); });