import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import crypto from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import http from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, before, describe, test } from "node:test"; import { PDS, envToCfg, envToSecrets, readEnv } from "@atproto/pds"; import { MAX_ACCOUNT_AGE_MS, PREAUTHORIZE_PATH, missingApi, mountPreauthorize } from "./preauthorize.mjs"; const ADMIN_PASSWORD = "preauthorize-test-admin"; const CLIENT_ID = "https://client.preauthorize-test.org/oauth-client-metadata.json"; const OTHER_CLIENT_ID = "https://other.preauthorize-test.org/oauth-client-metadata.json"; const SCOPE = "atproto transition:generic"; const b64u = (bytes) => Buffer.from(bytes).toString("base64url"); const clientMetadata = (clientId) => { const origin = new URL(clientId).origin; return { client_id: clientId, client_name: "preauthorize test", client_uri: origin, redirect_uris: [`${origin}/oauth/callback`], scope: SCOPE, grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", application_type: "web", dpop_bound_access_tokens: true }; }; const loopbackClient = (port) => { const redirect = `http://127.0.0.1:${port}/oauth/callback`; return { id: `http://localhost?redirect_uri=${encodeURIComponent(redirect)}&scope=${encodeURIComponent(SCOPE)}`, redirect }; }; // the pds fetches client metadata through globalThis.fetch once ssrf protection is off const realFetch = globalThis.fetch; globalThis.fetch = async (input, init) => { const url = typeof input === "string" ? input : input.url; if (url === CLIENT_ID || url === OTHER_CLIENT_ID) { return Response.json(clientMetadata(url)); } return realFetch(input, init); }; const startPlc = async () => { const ops = new Map(); const server = http.createServer((req, res) => { let body = ""; req.on("data", (chunk) => (body += chunk)); req.on("end", () => { const [did, part] = decodeURIComponent(req.url.split("?")[0]).split("/").filter(Boolean); const send = (status, value) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(value)); }; if (req.method === "POST") { ops.set(did, JSON.parse(body)); return send(200, {}); } const op = ops.get(did); if (!op) return send(404, { message: "not found" }); if (part === "data") return send(200, { did, ...op }); if (part === "log") return send(200, [op]); send(200, { "@context": ["https://www.w3.org/ns/did/v1"], id: did, alsoKnownAs: op.alsoKnownAs, verificationMethod: Object.entries(op.verificationMethods ?? {}).map(([id, key]) => ({ id: `${did}#${id}`, type: "Multikey", controller: did, publicKeyMultibase: key.replace("did:key:", "") })), service: Object.entries(op.services ?? {}).map(([id, service]) => ({ id: `#${id}`, type: service.type, serviceEndpoint: service.endpoint })) }); }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); return server; }; const freePort = async () => { const server = http.createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address(); await new Promise((resolve) => server.close(resolve)); return port; }; const pdsEnv = (port, plcPort, dataDir) => ({ PDS_HOSTNAME: "localhost", PDS_PORT: String(port), PDS_DEV_MODE: "true", PDS_DISABLE_SSRF_PROTECTION: "true", PDS_DATA_DIRECTORY: dataDir, PDS_BLOBSTORE_DISK_LOCATION: join(dataDir, "blocks"), PDS_DID_PLC_URL: `http://127.0.0.1:${plcPort}`, PDS_ADMIN_PASSWORD: ADMIN_PASSWORD, PDS_JWT_SECRET: crypto.randomBytes(16).toString("hex"), PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: crypto.randomBytes(32).toString("hex"), PDS_INVITE_REQUIRED: "false", LOG_ENABLED: "false" }); const newDpopKey = () => { const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); const { kty, crv, x, y } = publicKey.export({ format: "jwk" }); return { privateKey, jwk: { kty, crv, x, y } }; }; const dpopProof = (key, htm, htu, nonce) => { const header = { typ: "dpop+jwt", alg: "ES256", jwk: key.jwk }; const payload = { jti: crypto.randomUUID(), htm, htu, iat: Math.floor(Date.now() / 1000), nonce }; const input = `${b64u(JSON.stringify(header))}.${b64u(JSON.stringify(payload))}`; const signature = crypto.sign("sha256", Buffer.from(input), { key: key.privateKey, dsaEncoding: "ieee-p1363" }); return `${input}.${b64u(signature)}`; }; const dpopPost = async (key, url, form) => { let nonce; for (let attempt = 0; attempt < 2; attempt++) { const res = await realFetch(url, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", DPoP: dpopProof(key, "POST", url, nonce) }, body: new URLSearchParams(form) }); const body = await res.json().catch(() => null); if (body?.error !== "use_dpop_nonce") return { status: res.status, body }; nonce = res.headers.get("dpop-nonce"); } throw new Error("pds kept asking for a dpop nonce"); }; describe("tangled preauthorize route", () => { let pds, plc, dataDir, base; const clock = { offset: 0 }; let loopback; before(async () => { plc = await startPlc(); dataDir = mkdtempSync(join(tmpdir(), "tangled-pds-")); const port = await freePort(); base = `http://localhost:${port}`; loopback = loopbackClient(port); Object.assign(process.env, pdsEnv(port, plc.address().port, dataDir)); const env = readEnv(); pds = await PDS.create(envToCfg(env), envToSecrets(env)); mountPreauthorize(pds, [CLIENT_ID, loopback.id], () => Date.now() + clock.offset); await pds.start(); }); after(async () => { await pds?.destroy(); plc?.close(); rmSync(dataDir, { recursive: true, force: true }); }); const createAccount = async () => { const handle = `u${crypto.randomBytes(5).toString("hex")}.test`; const res = await realFetch(`${base}/xrpc/com.atproto.server.createAccount`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ handle, email: `${handle}@example.com`, password: "preauthorize-password" }) }); assert.equal(res.status, 200, await res.clone().text()); const { did } = await res.json(); return { did, handle }; }; const par = async ({ clientId = CLIENT_ID, loginHint } = {}) => { const key = newDpopKey(); const verifier = b64u(crypto.randomBytes(32)); const state = crypto.randomUUID(); const redirect = clientId === loopback.id ? loopback.redirect : clientMetadata(clientId).redirect_uris[0]; const form = { client_id: clientId, redirect_uri: redirect, response_type: "code", response_mode: "fragment", code_challenge: b64u(crypto.createHash("sha256").update(verifier).digest()), code_challenge_method: "S256", scope: SCOPE, state }; if (loginHint) form.login_hint = loginHint; const res = await dpopPost(key, `${base}/oauth/par`, form); assert.equal(res.status, 201, JSON.stringify(res.body)); return { key, verifier, state, clientId, redirect, requestUri: res.body.request_uri }; }; const preauthorize = (body, auth = `admin:${ADMIN_PASSWORD}`) => realFetch(`${base}${PREAUTHORIZE_PATH}`, { method: "POST", headers: { "content-type": "application/json", ...(auth ? { authorization: `Basic ${Buffer.from(auth).toString("base64")}` } : {}) }, body: JSON.stringify(body) }); const exchange = (request, code) => dpopPost(request.key, `${base}/oauth/token`, { grant_type: "authorization_code", code, code_verifier: request.verifier, redirect_uri: request.redirect, client_id: request.clientId }); const refusal = async (res) => { assert.equal(res.status, 403); return (await res.json()).message; }; test("a fresh account gets a code the browser's own key can exchange, once", async () => { const account = await createAccount(); const request = await par(); const res = await preauthorize({ did: account.did, requestUri: request.requestUri }); assert.equal(res.status, 200); const { code, state, iss } = await res.json(); assert.equal(state, request.state); assert.equal(iss, base); const token = await exchange(request, code); assert.equal(token.status, 200, JSON.stringify(token.body)); assert.equal(token.body.sub, account.did); assert.equal(token.body.token_type, "DPoP"); const again = await preauthorize({ did: account.did, requestUri: request.requestUri }); assert.equal(await refusal(again), "unknown request"); }); test("an account that already authorized a client is refused", async () => { const account = await createAccount(); const first = await par(); assert.equal( (await preauthorize({ did: account.did, requestUri: first.requestUri })).status, 200 ); const second = await par(); const res = await preauthorize({ did: account.did, requestUri: second.requestUri }); assert.equal(await refusal(res), "account already authorized a client"); }); test("an account that already holds oauth tokens is refused", async () => { const account = await createAccount(); const first = await par({ clientId: loopback.id }); const res = await preauthorize({ did: account.did, requestUri: first.requestUri }); assert.equal(res.status, 200); assert.equal((await exchange(first, (await res.json()).code)).status, 200); const second = await par({ clientId: loopback.id }); const again = await preauthorize({ did: account.did, requestUri: second.requestUri }); assert.equal(await refusal(again), "account already has oauth tokens"); }); test("an account older than the window is refused", async () => { const account = await createAccount(); const request = await par(); clock.offset = MAX_ACCOUNT_AGE_MS + 1000; try { const res = await preauthorize({ did: account.did, requestUri: request.requestUri }); assert.equal(await refusal(res), "account is not new"); } finally { clock.offset = 0; } }); test("a client outside the allowlist is refused", async () => { const account = await createAccount(); const request = await par({ clientId: OTHER_CLIENT_ID }); const res = await preauthorize({ did: account.did, requestUri: request.requestUri }); assert.equal(await refusal(res), "client is not allowed"); }); test("a request hinted at another account is refused and dropped", async () => { const account = await createAccount(); const other = await createAccount(); const request = await par({ loginHint: other.handle }); const res = await preauthorize({ did: account.did, requestUri: request.requestUri }); assert.equal(await refusal(res), "request is for another account"); const retry = await preauthorize({ did: other.did, requestUri: request.requestUri }); assert.equal(await refusal(retry), "unknown request"); }); test("a request hinted at the account itself goes through", async () => { const account = await createAccount(); const request = await par({ loginHint: account.handle }); const res = await preauthorize({ did: account.did, requestUri: request.requestUri }); assert.equal(res.status, 200); }); test("without the admin password nothing is checked", async () => { const account = await createAccount(); const request = await par(); const bodies = [ { did: account.did, requestUri: request.requestUri }, { did: "did:plc:nobody", requestUri: "urn:ietf:params:oauth:request_uri:nothing" }, {} ]; for (const auth of [null, "admin:wrong", `someone:${ADMIN_PASSWORD}`]) { for (const body of bodies) { const res = await preauthorize(body, auth); assert.equal(res.status, 401); assert.deepEqual(await res.json(), { error: "AuthenticationRequired", message: "admin auth required" }); } } assert.equal((await preauthorize(bodies[0])).status, 200); }); test("an unknown account is refused", async () => { const request = await par(); const res = await preauthorize({ did: "did:plc:nobody", requestUri: request.requestUri }); assert.equal(await refusal(res), "unknown account"); }); }); describe("the packaged pds", { skip: !process.env.TANGLED_PDS_BIN }, () => { test("mounts the route at startup", async (t) => { const plc = await startPlc(); const dataDir = mkdtempSync(join(tmpdir(), "tangled-pds-bin-")); const port = await freePort(); const child = spawn(process.env.TANGLED_PDS_BIN, [], { env: { ...process.env, ...pdsEnv(port, plc.address().port, dataDir) }, stdio: "inherit" }); t.after(() => { child.kill(); plc.close(); rmSync(dataDir, { recursive: true, force: true }); }); const base = `http://localhost:${port}`; for (let attempt = 0; ; attempt++) { const health = await realFetch(`${base}/xrpc/_health`).catch(() => null); if (health?.ok) break; assert.ok(attempt < 300 && child.exitCode === null, "pds did not start"); await new Promise((resolve) => setTimeout(resolve, 100)); } const res = await realFetch(`${base}${PREAUTHORIZE_PATH}`, { method: "POST" }); assert.equal(res.status, 401); }); }); describe("a pds that lacks an internal the route needs", () => { const fn = () => {}; const pdsWith = (mounted) => ({ app: { post: (...args) => mounted.push(args) }, ctx: { authVerifier: { adminToken: fn }, accountManager: { getAccount: fn }, oauthProvider: { issuer: "https://pds.test", clientManager: { getClient: fn }, accountManager: { getAccount: fn, setAuthorizedClient: fn }, requestManager: { peekClientId: fn, get: fn, setAuthorized: fn, delete: fn }, tokenManager: { listAccountTokens: fn } } } }); test("is named", () => { const pds = pdsWith([]); assert.deepEqual(missingApi(pds), []); delete pds.ctx.oauthProvider.requestManager.peekClientId; delete pds.ctx.oauthProvider.issuer; assert.deepEqual(missingApi(pds), [ "oauthProvider.requestManager.peekClientId", "oauthProvider.issuer" ]); }); test("still starts, without the route", () => { const mounted = []; const pds = pdsWith(mounted); mountPreauthorize(pds, [CLIENT_ID]); assert.equal(mounted.length, 1); mounted.length = 0; delete pds.ctx.oauthProvider.tokenManager.listAccountTokens; assert.doesNotThrow(() => mountPreauthorize(pds, [CLIENT_ID])); assert.equal(mounted.length, 0); }); });