chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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);
});
});