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);
});
});