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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
436 lines · 13.7 KB Raw History
/**
 * End-to-end test of OAuth sessions in the remote helper: the loopback
 * authorization-code flow against a real PDS, sessions on disk, granular
 * scopes, token refresh, and the member read chain entered through OAuth.
 *
 * The "browser" is a fetch that posts the consent form and then follows the
 * redirect to the helper's loopback server, which is exactly what a human
 * plus a browser produce.
 */

import { execFile } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import {
  chmodSync,
  mkdirSync,
  mkdtempSync,
  readFileSync,
  rmSync,
  writeFileSync,
} from 'node:fs';
import { createServer as createHttpServer } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { derivePublicKey, hexToBytes } from '@pdsjs/core/crypto';
import { publicKeyToDidKey } from '@pdsjs/core/plc';
import { createServer } from '@pdsjs/node';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { GIT_REPO_COLLECTION, oauthLogin } from '../src/index.js';
import { saveSession } from '../src/session-store.js';

const execFileAsync = promisify(execFile);

const ALICE_PORT = 2479;
const BOB_PORT = 2480;
const PLC_PORT = 2481;
const ALICE_BASE = `http://127.0.0.1:${ALICE_PORT}`;
const BOB_BASE = `http://127.0.0.1:${BOB_PORT}`;
const PLC_URL = `http://127.0.0.1:${PLC_PORT}`;
const PASSWORD = 'test-password';

const SPACE_REPO = 'oauth-space-repo';
// A private repository's space: the git type, keyed by the repository.
const SPACE_TYPE = 'dev.pdsjs.git.space';
const SPACE_KEY = SPACE_REPO;
const PUBLIC_REPO = 'oauth-public-repo';

/** @param {number} length */
function randomBase32(length) {
  const chars = 'abcdefghijklmnopqrstuvwxyz234567';
  return Array.from(randomBytes(length))
    .map((b) => chars[b % 32])
    .join('');
}

const ALICE_DID = `did:plc:${randomBase32(24)}`;
const BOB_DID = `did:plc:${randomBase32(24)}`;
const SPACE_URI = `at://${ALICE_DID}/space/${SPACE_TYPE}/${SPACE_KEY}`;
const SPACE_REMOTE = `atproto://${ALICE_DID}/${SPACE_REPO}`;
const PUBLIC_REMOTE = `atproto://${ALICE_DID}/${PUBLIC_REPO}`;

/** @type {Array<{close: () => Promise<unknown>}>} */
const servers = [];
/** @type {import('node:http').Server|null} */
let plcServer = null;
/** @type {Map<string, object>} */
const didDocs = new Map();
/** @type {string} */
let workDir = '';
/** @type {string} */
let srcDir = '';
/** @type {Record<string, string>} */
let baseEnv = {};
/** @type {string} */
let aliceJwt = '';

/**
 * @param {string} cwd
 * @param {string[]} args
 * @param {Record<string, string>} [extraEnv]
 * @returns {Promise<string>} stdout
 */
async function runGit(cwd, args, extraEnv = {}) {
  const { stdout } = await execFileAsync('git', args, {
    cwd,
    env: { ...baseEnv, ...extraEnv },
    encoding: 'utf8',
  });
  return stdout;
}

/**
 * Approve the consent page the way a browser would: post the form, follow
 * the resulting redirect to the helper's loopback server.
 * @param {string} authorizeUrl
 */
async function approveConsent(authorizeUrl) {
  const url = new URL(authorizeUrl);
  const res = await fetch(`${url.origin}/oauth/authorize`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      request_uri: url.searchParams.get('request_uri') ?? '',
      client_id: url.searchParams.get('client_id') ?? '',
      password: PASSWORD,
      action: 'approve',
    }).toString(),
    redirect: 'manual',
  });
  expect(res.status).toBe(302);
  const location = res.headers.get('location');
  expect(location).toBeTruthy();
  const callback = await fetch(/** @type {string} */ (location));
  expect(callback.headers.get('connection')).toBe('close');
}

/**
 * Run the whole login flow and store the session where the helper looks.
 * @param {string} configDir
 * @param {string} service
 * @param {string} identifier
 * @param {string} scope
 * @returns {Promise<import('../src/oauth.js').OauthSession>}
 */
async function loginAndStore(configDir, service, identifier, scope) {
  const session = await oauthLogin({
    service,
    scope,
    identifier,
    openBrowser: (url) => approveConsent(url),
  });
  session.identifier = identifier;
  saveSession({ ATPROTO_GIT_CONFIG_DIR: configDir }, identifier, session);
  return session;
}

/**
 * @param {string} name
 * @param {number} port
 * @param {string} did
 * @returns {Promise<string>} the account's access JWT
 */
async function bootPds(name, port, did) {
  const dir = join(workDir, name);
  mkdirSync(join(dir, 'blobs'), { recursive: true });
  const server = await createServer({
    port,
    dbPath: join(dir, 'pds.db'),
    blobsDir: join(dir, 'blobs'),
    jwtSecret: `test-secret-${name}`,
    hostname: `127.0.0.1:${port}`,
    password: PASSWORD,
    plcUrl: PLC_URL,
    spaces: true,
  });
  await server.listen();
  servers.push(server);

  const base = `http://127.0.0.1:${port}`;
  const privateKey = randomBytes(32).toString('hex');
  const init = await fetch(`${base}/init?did=${did}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      did,
      privateKey,
      handle: `${name}.test`,
      password: PASSWORD,
    }),
  });
  expect(init.ok).toBe(true);

  const { publicKey } = await derivePublicKey(hexToBytes(privateKey));
  const multibase = publicKeyToDidKey(publicKey, 'p256').slice(
    'did:key:'.length,
  );
  didDocs.set(did, {
    '@context': ['https://www.w3.org/ns/did/v1'],
    id: did,
    verificationMethod: [
      {
        id: `${did}#atproto`,
        type: 'Multikey',
        controller: did,
        publicKeyMultibase: multibase,
      },
    ],
    service: [
      {
        id: '#atproto_pds',
        type: 'AtprotoPersonalDataServer',
        serviceEndpoint: base,
      },
    ],
  });

  const res = await fetch(`${base}/xrpc/com.atproto.server.createSession`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ identifier: did, password: PASSWORD }),
  });
  expect(res.ok).toBe(true);
  const { accessJwt } = /** @type {{accessJwt: string}} */ (await res.json());

  // The account's KEM key, which credential reads of its space repos ask for
  // a MAC against.
  const recommended = await fetch(
    `${base}/xrpc/com.atproto.identity.getRecommendedDidCredentials`,
    { headers: { Authorization: `Bearer ${accessJwt}` } },
  );
  expect(recommended.ok).toBe(true);
  const { verificationMethods } =
    /** @type {{verificationMethods: {atproto_kem: string}}} */ (
      await recommended.json()
    );
  const doc =
    /** @type {{verificationMethod: Array<Record<string, string>>}} */ (
      didDocs.get(did)
    );
  doc.verificationMethod.push({
    id: `${did}#atproto_kem`,
    type: 'Multikey',
    controller: did,
    publicKeyMultibase: verificationMethods.atproto_kem.slice(
      'did:key:'.length,
    ),
  });
  return accessJwt;
}

beforeAll(async () => {
  workDir = mkdtempSync(join(tmpdir(), 'pdsjs-git-oauth-'));

  plcServer = createHttpServer((req, res) => {
    const doc = didDocs.get(decodeURIComponent((req.url ?? '/').slice(1)));
    if (!doc) {
      res.writeHead(404).end('{}');
      return;
    }
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify(doc));
  });
  await new Promise((resolve) =>
    /** @type {import('node:http').Server} */ (plcServer).listen(PLC_PORT, () =>
      resolve(undefined),
    ),
  );

  aliceJwt = await bootPds('alice', ALICE_PORT, ALICE_DID);
  await bootPds('bob', BOB_PORT, BOB_DID);

  for (const [nsid, body] of [
    [
      'com.atproto.space.createSpace',
      {
        spaceType: SPACE_TYPE,
        skey: SPACE_KEY,
        policy: { $type: 'com.atproto.memberspace.policy' },
      },
    ],
    [
      'com.atproto.memberspace.putMember',
      { space: SPACE_URI, did: BOB_DID, read: true, write: true },
    ],
  ]) {
    const res = await fetch(`${ALICE_BASE}/xrpc/${nsid}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${aliceJwt}`,
      },
      body: JSON.stringify(body),
    });
    expect(res.ok, String(nsid)).toBe(true);
  }

  const binDir = join(workDir, 'bin');
  mkdirSync(binDir);
  const cliPath = join(
    dirname(fileURLToPath(import.meta.url)),
    '..',
    'src',
    'cli.js',
  );
  const shim = join(binDir, 'git-remote-atproto');
  writeFileSync(shim, `#!/bin/sh\nexec node ${cliPath} "$@"\n`);
  chmodSync(shim, 0o755);

  baseEnv = {
    .../** @type {Record<string, string>} */ (process.env),
    PATH: `${binDir}:${process.env.PATH}`,
    ATPROTO_GIT_SERVICE: ALICE_BASE,
    // Where the helper resolves an account's KEM key.
    ATPROTO_GIT_PLC_URL: PLC_URL,
    ATPROTO_GIT_CHUNK_SIZE: '1024',
    ATPROTO_GIT_PASSWORD: '',
    GIT_TERMINAL_PROMPT: '0',
    GIT_AUTHOR_NAME: 'Test',
    GIT_AUTHOR_EMAIL: 'test@example.com',
    GIT_COMMITTER_NAME: 'Test',
    GIT_COMMITTER_EMAIL: 'test@example.com',
    // A machine-local credential store must not answer for the missing
    // password: these tests prove the OAuth session alone carries auth.
    HOME: join(workDir, 'no-home'),
    XDG_CONFIG_HOME: join(workDir, 'no-home'),
  };

  srcDir = join(workDir, 'src-repo');
  mkdirSync(srcDir);
  await runGit(srcDir, ['init', '-b', 'main']);
  writeFileSync(join(srcDir, 'README.md'), '# oauth-authenticated repo\n');
  writeFileSync(join(srcDir, 'data.bin'), randomBytes(4096));
  await runGit(srcDir, ['add', '.']);
  await runGit(srcDir, ['commit', '-m', 'initial commit']);
}, 60_000);

afterAll(async () => {
  for (const server of servers) await server.close();
  if (plcServer) await new Promise((resolve) => plcServer?.close(resolve));
  rmSync(workDir, { recursive: true, force: true });
});

describe('OAuth sessions in the remote helper', () => {
  const publicDir = () => join(workDir, 'config-public');
  const spaceDir = () => join(workDir, 'config-space');
  const bobDir = () => join(workDir, 'config-bob');

  it('logs in with a granular scope and pushes a public repo', async () => {
    const session = await loginAndStore(
      publicDir(),
      ALICE_BASE,
      ALICE_DID,
      `repo:${GIT_REPO_COLLECTION} blob:*/*`,
    );
    expect(session.did).toBe(ALICE_DID);
    expect(session.scope).toContain(`repo:${GIT_REPO_COLLECTION}`);

    await runGit(srcDir, ['remote', 'add', 'public', PUBLIC_REMOTE]);
    await runGit(srcDir, ['push', 'public', 'main'], {
      ATPROTO_GIT_CONFIG_DIR: publicDir(),
      ATPROTO_GIT_IDENTIFIER: ALICE_DID,
    });

    // The key is minted, so the pushed record is found by the name it
    // carries.
    const params = new URLSearchParams({
      repo: ALICE_DID,
      collection: GIT_REPO_COLLECTION,
      limit: '100',
    });
    const res = await fetch(
      `${ALICE_BASE}/xrpc/com.atproto.repo.listRecords?${params}`,
    );
    expect(res.ok).toBe(true);
    const { records } =
      /** @type {{records: Array<{value: {name: string}}>}} */ (
        await res.json()
      );
    expect(records.some((row) => row.value.name === PUBLIC_REPO)).toBe(true);
  });

  it('refreshes an expired access token and rotates the stored pair', async () => {
    const file = join(publicDir(), `${ALICE_DID.replace(/:/g, '_')}.json`);
    const before = JSON.parse(readFileSync(file, 'utf8'));
    writeFileSync(file, JSON.stringify({ ...before, expiresAt: 0 }));

    writeFileSync(join(srcDir, 'update.txt'), 'second commit\n');
    await runGit(srcDir, ['add', '.']);
    await runGit(srcDir, ['commit', '-m', 'second commit']);
    await runGit(srcDir, ['push', 'public', 'main'], {
      ATPROTO_GIT_CONFIG_DIR: publicDir(),
      ATPROTO_GIT_IDENTIFIER: ALICE_DID,
    });

    const after = JSON.parse(readFileSync(file, 'utf8'));
    expect(after.expiresAt).toBeGreaterThan(Date.now());
    expect(after.refreshToken).not.toBe(before.refreshToken);
    expect(after.accessToken).not.toBe(before.accessToken);
  });

  it('pushes into a space with a space-scoped session', async () => {
    await loginAndStore(
      spaceDir(),
      ALICE_BASE,
      ALICE_DID,
      `space:${SPACE_TYPE}?authority=${ALICE_DID}&skey=${SPACE_KEY}&collection=${GIT_REPO_COLLECTION} blob:*/*`,
    );

    await runGit(srcDir, ['remote', 'add', 'space', SPACE_REMOTE]);
    await runGit(srcDir, ['push', 'space', 'main'], {
      ATPROTO_GIT_CONFIG_DIR: spaceDir(),
      ATPROTO_GIT_IDENTIFIER: ALICE_DID,
    });

    const params = new URLSearchParams({
      space: SPACE_URI,
      collection: GIT_REPO_COLLECTION,
      rkey: SPACE_REPO,
    });
    const res = await fetch(
      `${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${params}`,
      { headers: { Authorization: `Bearer ${aliceJwt}` } },
    );
    expect(res.ok).toBe(true);
  });

  it('a member clones through the credential chain with a read-only scope', async () => {
    await loginAndStore(
      bobDir(),
      BOB_BASE,
      BOB_DID,
      `space:${SPACE_TYPE}?authority=${ALICE_DID}&skey=${SPACE_KEY}&action=read`,
    );

    const cloneDir = join(workDir, 'clone-bob');
    await runGit(workDir, ['clone', SPACE_REMOTE, cloneDir], {
      ATPROTO_GIT_CONFIG_DIR: bobDir(),
      ATPROTO_GIT_IDENTIFIER: BOB_DID,
    });
    const readme = await runGit(cloneDir, ['show', 'HEAD:README.md']);
    expect(readme).toContain('oauth-authenticated repo');
  });

  it('a session without a space scope cannot push to the space', async () => {
    writeFileSync(join(srcDir, 'update2.txt'), 'third commit\n');
    await runGit(srcDir, ['add', '.']);
    await runGit(srcDir, ['commit', '-m', 'third commit']);
    await expect(
      runGit(srcDir, ['push', 'space', 'main'], {
        ATPROTO_GIT_CONFIG_DIR: publicDir(),
        ATPROTO_GIT_IDENTIFIER: ALICE_DID,
      }),
    ).rejects.toThrow(/space|scope|exit code/i);
  });
});