~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
292 lines · 8.7 KB Raw History
// 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);
  });
});