1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
// 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");
},
};
}