// 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} */ const meta = new Map(); /** @type {Map} */ 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} 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); }); });