// A fake atproto identity + authorization server + PDS for OAuth tests (no live accounts), on one // node:http server at http://127.0.0.1: (its own issuer and PDS, did:plc documents at /, 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 }} [options] * users: names; each is .test, did:plc: * 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"); }, }; }