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

johnandersen777.bsky.social/atlibs

0
0
main
265 lines · 15.3 KB Raw History
// A fake atproto identity + authorization server + PDS for OAuth tests (no live accounts), on one
// node:http server at http://127.0.0.1:<port> (its own issuer and PDS, did:plc documents at /<did>, handles
// at /xrpc/com.atproto.identity.resolveHandle). It holds clients to the spec where a bug would hide:
// PAR only, PKCE S256, DPoP proofs signed and bound (the token's key thumbprint, ath, htm/htu, a required
// nonce), private_key_jwt assertions checked against the client's JWKS, loopback redirects by path (any
// port), refresh tokens used once (rotation), revocation. The PDS answers listRecords, createRecord
// (only into the token's own repo) and getSession, and logs every write with the token's DID.
// CORS open, for pages. No dependencies; used by javascript/src/oauth-server (also from a page:
// browser.test.mjs) and rust/host/oauth.test.mjs.
import { createHash, randomUUID, verify } from "node:crypto";
import { createServer } from "node:http";

const sha256 = (text) => createHash("sha256").update(text).digest("base64url");
const decode = (jwt) => {
	const [h, p, s] = String(jwt).split(".");
	return { header: JSON.parse(Buffer.from(h, "base64url")), payload: JSON.parse(Buffer.from(p, "base64url")), signed: `${h}.${p}`, sig: Buffer.from(s ?? "", "base64url") };
};
const publicJwk = ({ kty, crv, x, y }) => ({ kty, crv, x, y });
const signedBy = (token, jwk) => verify("sha256", Buffer.from(token.signed), { key: publicJwk(jwk), format: "jwk", dsaEncoding: "ieee-p1363" }, token.sig);
const thumbprint = (jwk) => sha256(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }));
const NONCE = "fake-nonce";
// pages (BrowserOAuthClient) on another origin reach it too
const CORS = { "access-control-allow-origin": "*", "access-control-allow-headers": "Authorization, DPoP, Content-Type, *", "access-control-allow-methods": "GET, POST", "access-control-expose-headers": "DPoP-Nonce, WWW-Authenticate" };

/**
 * @param {{ users?: string[], fetchClient?: (url: string) => Promise<Response> }} [options]
 *   users: names; each is <name>.test, did:plc:<name padded to 24 with a>
 *   fetchClient: how the server fetches an https client_id document (default fetch)
 */
export async function fakeAtproto({ users = ["alice", "bob", "carol"], fetchClient = (url) => fetch(url) } = {}) {
	const accounts = new Map(users.map((name) => [`${name}.test`, `did:plc:${name.padEnd(24, "a")}`]));
	const handleOf = (did) => [...accounts].find(([, d]) => d === did)?.[0];
	const pars = new Map(), codes = new Map(), access = new Map(), refresh = new Map();
	const fake = { accessTtl: 3600, refreshes: 0, revocations: 0, logins: 0, writes: [], errors: [], base: "" };

	const reply = (res, status, body, headers = {}) => {
		res.writeHead(status, { "content-type": "application/json", "dpop-nonce": NONCE, ...CORS, ...headers });
		res.end(JSON.stringify(body));
	};
	const fail = (res, status, error, error_description = "") => {
		fake.errors.push(`${error}: ${error_description}`);
		reply(res, status, { error, error_description });
	};
	async function clientOf(clientId) {
		if (clientId.startsWith("http://localhost")) {
			const query = new URL(clientId).searchParams;
			return { client_id: clientId, redirect_uris: query.getAll("redirect_uri").length ? query.getAll("redirect_uri") : ["http://127.0.0.1/"], scope: query.get("scope") ?? "atproto", token_endpoint_auth_method: "none" };
		}
		const doc = await (await fetchClient(clientId)).json();
		if (doc.client_id !== clientId) throw new Error(`the document at ${clientId} names ${doc.client_id}`);
		if (doc.jwks_uri && !doc.jwks) doc.jwks = await (await fetchClient(doc.jwks_uri)).json();
		return doc;
	}
	// the proof's key thumbprint, or an error string
	function dpop(req, url, token) {
		const proof = req.headers.dpop;
		if (!proof) return "no DPoP proof";
		const parsed = decode(proof);
		if (parsed.header.typ !== "dpop+jwt" || !signedBy(parsed, parsed.header.jwk)) return "bad DPoP signature";
		const { htm, htu, nonce, ath, iat } = parsed.payload;
		if (htm !== req.method || htu !== `${fake.base}${url.pathname}`) return `DPoP htm/htu ${htm} ${htu}`;
		if (Math.abs(Date.now() / 1000 - iat) > 300) return "DPoP iat";
		if (token !== undefined && ath !== sha256(token)) return "DPoP ath";
		if (nonce !== NONCE) return "use_dpop_nonce";
		return thumbprint(parsed.header.jwk);
	}
	async function authenticate(form) {
		const client = await clientOf(form.get("client_id"));
		if (client.token_endpoint_auth_method === "private_key_jwt") {
			const assertion = form.get("client_assertion");
			if (form.get("client_assertion_type") !== "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" || !assertion) throw new Error("a confidential client must authenticate");
			const parsed = decode(assertion);
			const key = (client.jwks?.keys ?? []).find((k) => k.kid === parsed.header.kid);
			if (!key || !signedBy(parsed, key)) throw new Error("bad client assertion signature");
			if (parsed.payload.iss !== client.client_id || parsed.payload.sub !== client.client_id || parsed.payload.aud !== fake.base) throw new Error("bad client assertion claims");
		} else if (form.get("client_assertion")) throw new Error("a public client sent an assertion");
		return client;
	}
	const redirectAllowed = (client, uri) =>
		client.redirect_uris.some((allowed) => {
			if (allowed === uri) return true;
			const [a, b] = [new URL(allowed), new URL(uri)];
			return client.client_id.startsWith("http://localhost") && a.hostname === b.hostname && a.pathname === b.pathname && a.protocol === b.protocol;
		});
	const tokens = (sub, jkt, clientId, scope) => {
		const at = `at-${randomUUID()}`, rt = `rt-${randomUUID()}`;
		access.set(at, { sub, jkt, exp: Date.now() + fake.accessTtl * 1000 });
		refresh.set(rt, { sub, jkt, clientId, scope });
		return { access_token: at, token_type: "DPoP", refresh_token: rt, scope, expires_in: fake.accessTtl, sub };
	};

	async function as(req, res, url, body) {
		const form = new URLSearchParams(body);
		const jkt = dpop(req, url);
		if (jkt === "use_dpop_nonce") return fail(res, 400, "use_dpop_nonce", "a nonce is required");
		if (jkt.includes(" ") || jkt.startsWith("DPoP")) return fail(res, 400, "invalid_dpop_proof", jkt);
		let client;
		try {
			client = await authenticate(form);
		} catch (error) {
			return fail(res, 401, "invalid_client", error.message);
		}
		if (url.pathname === "/oauth/par") {
			if (form.get("code_challenge_method") !== "S256" || !form.get("code_challenge")) return fail(res, 400, "invalid_request", "PKCE S256 required");
			if (!redirectAllowed(client, form.get("redirect_uri"))) return fail(res, 400, "invalid_request", `redirect_uri ${form.get("redirect_uri")} is not the client's`);
			const id = `urn:ietf:params:oauth:request_uri:${randomUUID()}`;
			pars.set(id, { clientId: client.client_id, jkt, form });
			return reply(res, 201, { request_uri: id, expires_in: 300 });
		}
		if (url.pathname === "/oauth/token" && form.get("grant_type") === "authorization_code") {
			const code = codes.get(form.get("code"));
			codes.delete(form.get("code"));
			if (!code || code.clientId !== client.client_id || code.form.get("redirect_uri") !== form.get("redirect_uri")) return fail(res, 400, "invalid_grant", "bad code");
			if (sha256(form.get("code_verifier") ?? "") !== code.form.get("code_challenge")) return fail(res, 400, "invalid_grant", "PKCE");
			if (code.jkt !== jkt) return fail(res, 400, "invalid_dpop_proof", "not the PAR's key");
			fake.logins++;
			return reply(res, 200, tokens(code.sub, jkt, client.client_id, code.form.get("scope")));
		}
		if (url.pathname === "/oauth/token" && form.get("grant_type") === "refresh_token") {
			const found = refresh.get(form.get("refresh_token"));
			fake.refreshes++;
			if (!found) return fail(res, 400, "invalid_grant", "refresh token unknown or already used");
			if (found.jkt !== jkt || found.clientId !== client.client_id) return fail(res, 400, "invalid_grant", "not this session's key or client");
			refresh.delete(form.get("refresh_token"));
			return reply(res, 200, tokens(found.sub, jkt, client.client_id, found.scope));
		}
		if (url.pathname === "/oauth/revoke") {
			fake.revocations++;
			const found = refresh.get(form.get("token")) ?? access.get(form.get("token"));
			for (const map of [refresh, access]) for (const [k, v] of map) if (found && v.sub === found.sub && v.jkt === found.jkt) map.delete(k);
			return reply(res, 200, {});
		}
		return fail(res, 400, "unsupported_grant_type", form.get("grant_type") ?? url.pathname);
	}

	async function pds(req, res, url, body) {
		const token = req.headers.authorization?.match(/^DPoP (.+)$/)?.[1];
		if (!token) return reply(res, 401, { error: "AuthMissing", message: "DPoP-bound OAuth tokens only" });
		const jkt = dpop(req, url, token);
		if (jkt === "use_dpop_nonce") return reply(res, 401, { error: "use_dpop_nonce" }, { "www-authenticate": 'DPoP error="use_dpop_nonce"' });
		const held = access.get(token);
		if (!held || held.exp < Date.now() || held.jkt !== jkt) return reply(res, 401, { error: "invalid_token", message: held ? "expired or not this key" : "unknown token" }, { "www-authenticate": 'DPoP error="invalid_token"' });
		const nsid = url.pathname.slice("/xrpc/".length);
		if (nsid === "com.atproto.server.getSession") return reply(res, 200, { did: held.sub, handle: handleOf(held.sub) });
		if (nsid === "com.atproto.repo.listRecords") return reply(res, 200, { records: [] });
		if (nsid === "com.atproto.repo.createRecord") {
			const input = JSON.parse(body || "{}");
			fake.writes.push({ sub: held.sub, repo: input.repo, collection: input.collection });
			if (input.repo !== held.sub) return reply(res, 403, { error: "Forbidden", message: `${held.sub} cannot write ${input.repo}` });
			return reply(res, 200, { uri: `at://${held.sub}/${input.collection}/${randomUUID()}`, cid: "bafyfake" });
		}
		return reply(res, 501, { error: "MethodNotImplemented" });
	}

	const server = createServer(async (req, res) => {
		const url = new URL(req.url, fake.base);
		const chunks = [];
		for await (const chunk of req) chunks.push(chunk);
		const body = Buffer.concat(chunks).toString();
		try {
			if (req.method === "OPTIONS") return res.writeHead(204, CORS).end();
			if (url.pathname === "/xrpc/com.atproto.identity.resolveHandle") {
				const did = accounts.get(url.searchParams.get("handle"));
				return did ? reply(res, 200, { did }) : reply(res, 400, { error: "InvalidRequest", message: "Unable to resolve handle" });
			}
			if (decodeURIComponent(url.pathname).startsWith("/did:plc:")) {
				const did = decodeURIComponent(url.pathname).slice(1);
				if (!handleOf(did)) return reply(res, 404, { message: "DID not registered" });
				return reply(res, 200, { "@context": ["https://www.w3.org/ns/did/v1"], id: did, alsoKnownAs: [`at://${handleOf(did)}`], verificationMethod: [], service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: fake.base }] });
			}
			if (url.pathname === "/.well-known/oauth-protected-resource") return reply(res, 200, { resource: fake.base, authorization_servers: [fake.base], scopes_supported: [], bearer_methods_supported: ["header"] });
			if (url.pathname === "/.well-known/oauth-authorization-server") {
				return reply(res, 200, {
					issuer: fake.base,
					authorization_endpoint: `${fake.base}/oauth/authorize`,
					token_endpoint: `${fake.base}/oauth/token`,
					pushed_authorization_request_endpoint: `${fake.base}/oauth/par`,
					revocation_endpoint: `${fake.base}/oauth/revoke`,
					require_pushed_authorization_requests: true,
					response_types_supported: ["code"],
					response_modes_supported: ["query", "fragment", "form_post"],
					grant_types_supported: ["authorization_code", "refresh_token"],
					code_challenge_methods_supported: ["S256"],
					token_endpoint_auth_methods_supported: ["none", "private_key_jwt"],
					token_endpoint_auth_signing_alg_values_supported: ["ES256"],
					scopes_supported: ["atproto", "transition:generic"],
					subject_types_supported: ["public"],
					dpop_signing_alg_values_supported: ["ES256"],
					authorization_response_iss_parameter_supported: true,
					client_id_metadata_document_supported: true,
					request_parameter_supported: false,
					request_uri_parameter_supported: true,
					protected_resources: [fake.base],
				});
			}
			// The user approves at once: the account the login_hint names.
			if (url.pathname === "/oauth/authorize") {
				const par = pars.get(url.searchParams.get("request_uri"));
				pars.delete(url.searchParams.get("request_uri"));
				if (!par || par.clientId !== url.searchParams.get("client_id")) return reply(res, 400, { error: "invalid_request", error_description: "unknown request_uri" });
				const hint = par.form.get("login_hint");
				const sub = hint?.startsWith("did:") ? hint : accounts.get(hint);
				const back = new URL(par.form.get("redirect_uri"));
				const code = `code-${randomUUID()}`;
				codes.set(code, { ...par, sub });
				const answer = new URLSearchParams({ code, state: par.form.get("state"), iss: fake.base });
				if (par.form.get("response_mode") === "fragment") back.hash = answer; // BrowserOAuthClient's default
				else back.search = answer;
				res.writeHead(302, { location: back.href });
				return res.end();
			}
			if (req.method === "POST" && url.pathname.startsWith("/oauth/")) return await as(req, res, url, body);
			if (url.pathname.startsWith("/xrpc/")) return await pds(req, res, url, body);
			reply(res, 404, { error: "NotFound" });
		} catch (error) {
			fake.errors.push(error.message);
			reply(res, 500, { error: "InternalServerError", message: error.message });
		}
	});
	await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
	fake.base = `http://127.0.0.1:${server.address().port}`;
	fake.did = (name) => accounts.get(`${name}.test`);
	/** Every access token stops working now (as at expiry), refresh tokens still good. */
	fake.expireAll = () => access.forEach((held) => (held.exp = 0));
	fake.close = () => new Promise((resolve) => server.close(resolve));
	return fake;
}

/**
 * A browser with a cookie jar: follows redirects (GET), keeping cookies per host. `route(url)` says how to
 * reach a URL (default fetch): a test maps a public origin to its local server.
 */
export function browser(route = () => fetch) {
	const jar = new Map(); // host -> Map(name -> value)
	const cookies = (url) => [...(jar.get(new URL(url).host) ?? [])].map(([k, v]) => `${k}=${v}`).join("; ");
	const keep = (url, response) => {
		for (const line of response.headers.getSetCookie?.() ?? []) {
			const [pair, ...attrs] = line.split(";");
			const [name, value] = pair.trim().split(/=(.*)/s);
			const host = new URL(url).host;
			if (!jar.has(host)) jar.set(host, new Map());
			if (attrs.some((a) => /^\s*max-age=0\s*$/i.test(a)) || value === "") jar.get(host).delete(name);
			else jar.get(host).set(name, value);
		}
	};
	const send = async (url, init = {}) => {
		const response = await route(url)(new Request(url, { ...init, redirect: "manual", headers: { ...init.headers, ...(cookies(url) && { cookie: cookies(url) }) } }));
		keep(url, response);
		return response;
	};
	return {
		jar,
		cookie: (url, name) => jar.get(new URL(url).host)?.get(name),
		send,
		/** GET url, following redirects; the last response. */
		async go(url) {
			for (let hops = 0; hops < 10; hops++) {
				const response = await send(url);
				if (![301, 302, 303, 307, 308].includes(response.status)) return Object.defineProperty(response, "finalUrl", { value: url });
				url = new URL(response.headers.get("location"), url).href;
			}
			throw new Error("too many redirects");
		},
	};
}