/** * 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}>} */ const servers = []; /** @type {import('node:http').Server|null} */ let plcServer = null; /** @type {Map} */ const didDocs = new Map(); /** @type {string} */ let workDir = ''; /** @type {string} */ let srcDir = ''; /** @type {Record} */ let baseEnv = {}; /** @type {string} */ let aliceJwt = ''; /** * @param {string} cwd * @param {string[]} args * @param {Record} [extraEnv] * @returns {Promise} 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} */ 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} 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>}} */ ( 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} */ (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); }); });