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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
// OAuth client metadata for atlibs pages and servers: compose integration records, serve them at build time
// known paths (`atlibs stage`) or dynamically (clientMetadataHandler), and let modules share sessions under a page policy.
// No dependencies; works in the browser and in Node.
//
// Records are com.fedfork.exec.oauth.clientMetadata (lexicons/). A "session" is one such record,
// named by its rkey (fedproxy, requester, atirc); a module's module.json `session` names it.
//
// A browser origin holds one OAuth session per DID (@atproto/oauth-client-browser keeps one
// IndexedDB and one localStorage sub per origin), so a page that runs several modules signs in
// once with a composite client whose scope is the union of theirs. Each module's session is then
// the subset of the granted scope its own record declares, and policyCall() keeps every module
// inside it unless a policy feature grants it another module's session.
import { dagCbor, toBase32 } from "../encoding/index.js";
const BASE_SCOPE = "atproto";
const COLLECTION = "com.fedfork.exec.oauth.clientMetadata";
/** What `atlibs stage --target spa` writes for the origin when given no --origin; replaced at deploy time. */
export const TEMPLATE_ORIGIN = "https://atlibs.invalid";
const ACTIONS = ["create", "update", "delete"];
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
const scopeList = (scope) => String(scope ?? "").split(" ").filter(Boolean);
const join = (scopes) => [...new Set([BASE_SCOPE, ...scopes])].join(" ");
/**
* Compose integration records into one record of the same lexicon: the union of their scopes,
* their scope groups kept and keyed `<session>.<key>`. Publish it like any other record
* (records/<name>.json) or serve it with clientMetadata().
* @param {Record<string, object>} records session name -> clientMetadata record
* @param {{ name: string, description?: string, clientId: string, redirectUri?: string, createdAt?: string }} options
*/
export function composeRecord(records, { name, description, clientId, redirectUri = new URL(clientId).origin, createdAt = new Date().toISOString() }) {
const entries = Object.entries(records);
return {
$type: COLLECTION,
name,
description: description ?? `${entries.map(([, record]) => record.name).join(", ")} in one sign-in.`,
clientMetadata: {
client_id: clientId,
client_name: name,
client_uri: new URL(clientId).origin,
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
scope: join(entries.flatMap(([, record]) => scopeList(record.clientMetadata.scope))),
application_type: "web",
token_endpoint_auth_method: "none",
dpop_bound_access_tokens: true,
},
scopeGroups: entries.flatMap(([session, record]) =>
(record.scopeGroups ?? []).map((group) => ({ key: `${session}.${group.key}`, label: group.label, scopes: group.scopes, ...(group.optional && { optional: true }) })),
),
createdAt,
};
}
const REBASED = ["client_uri", "logo_uri", "tos_uri", "policy_uri"];
/**
* The client metadata document a site serves at clientId for a record, as the emdash
* atproto-integrations plugin does: URLs on the template's origin move to the site's origin,
* and the client is always public and DPoP-bound.
*/
export function clientMetadata(record, { clientId, redirectUri }) {
const template = new URL(record.clientMetadata.client_id).origin;
const site = new URL(clientId).origin;
const rebased = REBASED.flatMap((field) => {
const value = record.clientMetadata[field];
return typeof value === "string" && value.startsWith(template) ? [[field, `${site}${value.slice(template.length)}`]] : [];
});
if (!scopeList(record.clientMetadata.scope).includes(BASE_SCOPE)) throw new Error(`${record.name} scope must include ${BASE_SCOPE}`);
return {
client_name: record.clientMetadata.client_name ?? record.name,
...Object.fromEntries(rebased),
client_id: clientId,
redirect_uris: [redirectUri],
scope: record.clientMetadata.scope,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
application_type: "web",
token_endpoint_auth_method: "none",
dpop_bound_access_tokens: true,
};
}
/**
* The client_id to load BrowserOAuthClient with: the staged https client_id, or on localhost the
* loopback development client (https://atproto.com/specs/oauth#localhost-client-development) with
* the same scope, redirecting back to this page's directory on 127.0.0.1.
* @param {{ clientId: string, scope: string }} client stage.json, or one of its sessions
*/
export function clientId({ clientId, scope }, location = globalThis.location) {
if (!LOOPBACK.has(location.hostname)) return clientId;
const redirect = new URL(".", location.href);
redirect.hostname = "127.0.0.1";
return `http://localhost?${new URLSearchParams({ scope, redirect_uri: redirect.href })}`;
}
/**
* The client_id an example page signs in with: on localhost the loopback development client for
* `scope`; deployed, the client `atlibs stage --target spa` wrote next to the page (`./stage.json`),
* else `fallback`.
* @param {string} scope what the page asks for (must be within the staged client's scope)
* @param {{ base?: string | URL, fallback?: string, fetch?: typeof fetch, location?: Location }} [options]
*/
export async function pageClientId(scope, { location = globalThis.location, base = new URL("./", location.href), fallback, fetch: fetchImpl = globalThis.fetch } = {}) {
if (LOOPBACK.has(location.hostname)) return clientId({ scope }, location);
const stage = await fetchImpl(new URL("stage.json", base)).then((r) => (r.ok ? r.json() : null), () => null);
if (stage?.clientId?.startsWith(TEMPLATE_ORIGIN)) throw new Error(`${new URL("stage.json", base)} is a template: replace ${TEMPLATE_ORIGIN} with this page's origin`);
if (stage?.clientId) return stage.clientId;
if (fallback) return fallback;
throw new Error(`no stage.json next to ${base}: deploy the page with atlibs stage --target spa`);
}
/** The scope to sign in with to hold just these sessions (one integration, or several). */
export const requestScope = (policy, names) => join(names.flatMap((name) => scopeList(policy.sessions[name]?.scope)));
/**
* The sessions a granted scope covers (OAuthSession.getTokenInfo().scope): all of a session's `required`
* scope (stage.json: its scope less its optional scope groups), else all of its scope.
*/
export function grantedSessions(policy, granted) {
const have = new Set(scopeList(granted));
return Object.entries(policy.sessions).filter(([, { scope, required = scope }]) => scopeList(required).every((item) => have.has(item))).map(([name]) => name);
}
/** Policy features whose required sessions are all connected. */
export const enabledFeatures = (policy, connected) =>
Object.entries(policy.features ?? {}).filter(([, feature]) => feature.requires.every((name) => connected.includes(name))).map(([name]) => name);
/** The connected sessions a module may call with: its own first, then those enabled features grant it. */
function sessionsFor(policy, module, connected) {
const granted = enabledFeatures(policy, connected).flatMap((feature) => policy.features[feature].grants?.[module] ?? []);
return [...new Set([policy.modules?.[module]?.session, ...granted])].filter((name) => name && connected.includes(name));
}
// What a call needs from a session: null (public read, host-local) or a list of permissions.
const READS = /^com\.atproto\.(repo\.(getRecord|listRecords|describeRepo)|sync\.|identity\.resolveHandle$)/;
const WRITE = { "com.atproto.repo.createRecord": ["create"], "com.atproto.repo.putRecord": ["create", "update"], "com.atproto.repo.deleteRecord": ["delete"] };
export function permissions(nsid, { params, input, proxy, service, endpoints, subscribe, serviceAuth } = {}) {
if (service && endpoints) return [{ rpc: nsid, aud: service }]; // did#id with endpoints: service auth aud did#id
if (service?.startsWith("did:")) return null;
if (nsid.startsWith("com.fedfork.exec.")) return null; // host pseudo-calls: attest, webrtc.connect, iroh.connect
// a token for that lxm and aud: what the PDS checks an OAuth session for (an http allowlist's serviceAuth)
if (nsid === "com.atproto.server.getServiceAuth") return [{ rpc: params?.lxm ?? "", aud: params?.aud ?? "" }];
if (subscribe && !serviceAuth) return null; // a public stream: no token is minted for it
if (proxy) return [{ rpc: nsid, aud: proxy }];
if (service) return [{ rpc: nsid, aud: `did:web:${encodeURIComponent(new URL(service).host)}` }];
if (WRITE[nsid]) return WRITE[nsid].map((action) => ({ repo: input?.collection, action }));
if (nsid === "com.atproto.repo.applyWrites") {
return (input?.writes ?? []).map((write) => ({ repo: write.collection, action: String(write.$type).split("#")[1] }));
}
if (nsid === "com.atproto.repo.uploadBlob") return [{ blob: true }];
if (READS.test(nsid)) return null;
return [{ rpc: nsid, aud: "*" }];
}
function parseScope(item) {
const [head, query = ""] = item.split(/\?(.*)/s);
const colon = head.indexOf(":");
const resource = colon < 0 ? head : head.slice(0, colon);
const positional = colon < 0 ? undefined : head.slice(colon + 1);
const params = new URLSearchParams(query);
if (resource === "repo") {
const actions = params.getAll("action");
return { resource, collections: positional ? [positional] : params.getAll("collection"), actions: actions.length ? actions : ACTIONS };
}
if (resource === "rpc") return { resource, lxms: positional ? [positional] : params.getAll("lxm"), auds: params.getAll("aud") };
return { resource: item.startsWith("transition:generic") ? "generic" : resource };
}
const anyOf = (list, value) => list.includes("*") || list.includes(value);
function grants(scope, need) {
if (scope.resource === "generic") return !(need.rpc ?? "").startsWith("chat.bsky.");
if (need.repo) return scope.resource === "repo" && anyOf(scope.collections, need.repo) && scope.actions.includes(need.action);
if (need.rpc) return scope.resource === "rpc" && anyOf(scope.lxms, need.rpc) && (need.aud === "*" || anyOf(scope.auds, need.aud));
return need.blob === true && scope.resource === "blob";
}
/** Whether a scope string lets a session make this call (runModule's call request shape). */
export function scopeAllows(scope, nsid, request) {
const needs = permissions(nsid, request);
if (needs === null) return true;
const parsed = scopeList(scope).map(parseScope);
return needs.length > 0 && needs.every((need) => parsed.some((item) => grants(item, need)));
}
/**
* The `call` to hand runModule for one module: each call goes to the first session the module may
* use (sessionsFor) whose scope allows it; one that needs no scope (permissions() null: public reads,
* host pseudo-calls) goes through any connected session, so a module with no session of its own still
* reads; anything else is refused. Servers run every signed-in run under it too (server sessionCall,
* rust/host oauth::ScopeCall: the same rule and error text).
* @param {{ sessions: Record<string, { scope: string }>, modules?: Record<string, { session: string }>,
* features?: Record<string, { requires: string[], grants?: Record<string, string[]> }> }} policy stage.json plus features
* @param {string} module
* @param {Record<string, (nsid: string, request: object) => Promise<unknown>>} calls connected session name -> call
*/
export function policyCall(policy, module, calls) {
const names = sessionsFor(policy, module, Object.keys(calls));
return async (nsid, request = {}) => {
const name = names.find((name) => scopeAllows(policy.sessions[name].scope, nsid, request));
const call = name ? calls[name] : permissions(nsid, request) === null ? Object.values(calls)[0] : undefined;
if (!call) throw new Error(`${module} may not call ${nsid} with ${names.length ? `sessions ${names.join(", ")}` : "no connected session"}`);
return call(nsid, request);
};
}
/**
* A fetch-style handler (Request -> Response) that serves `<path><session>/client-metadata.json`
* for each record, the client_id being that URL on `origin` (unset: only loopback requests are answered,
* on http://<their host>), as the emdash
* atproto-integrations plugin serves its integrations' metadata. With `name`, `<path><name>/...` is
* the composite of all of them (composeRecord). Answers undefined for any other request, so a server
* falls through to its own routes. No dependencies: Hono mounts it directly
* (`app.get("/oauth/*", (c) => handler(c.req.raw) ?? c.notFound())`), as does any fetch-style server.
* @param {{ sessions: Record<string, { record: object }> } | Record<string, object>} source
* `atlibs stage --target server` stage.json or atlibs.lock.json, or session name -> record
* @param {{ path?: string, origin?: string, redirectUri?: string, name?: string }} [options]
*/
export function clientMetadataHandler(source, { path = source.oauthPath ?? "/oauth/", origin, redirectUri, name } = {}) {
const records = sessionRecords(source);
const prefix = `/${path.replace(/^\/+|\/+$/g, "")}/`.replace("//", "/");
const json = (status, body) => Response.json(body, { status, headers: { "cache-control": "public, max-age=60" } });
return (request) => {
const url = new URL(request.url);
if (request.method !== "GET" && request.method !== "HEAD") return undefined;
if (!url.pathname.startsWith(prefix) || !url.pathname.endsWith("/client-metadata.json")) return undefined;
const session = decodeURIComponent(url.pathname.slice(prefix.length, -"/client-metadata.json".length));
if (!session || session.includes("/")) return undefined;
// A non-loopback Host is never trusted: a public deployment passes origin (e.g. process.env.ATLIBS_ORIGIN).
if (!origin && !LOOPBACK.has(url.hostname)) return json(500, { error: "InternalServerError", message: "no origin configured: pass clientMetadataHandler(source, { origin }); only loopback hosts are read from the request" });
const base = (origin ?? `http://${url.host}`).replace(/\/+$/, "");
const clientId = `${base}${prefix}${session}/client-metadata.json`;
const redirect = redirectUri ?? `${base}/`;
const record = Object.hasOwn(records, session) ? records[session] : session === name ? composeRecord(records, { name, clientId, redirectUri: redirect, createdAt: "1970-01-01T00:00:00.000Z" }) : null;
if (!record) return json(404, { error: "NOT_FOUND" });
try {
return json(200, clientMetadata(record, { clientId, redirectUri: redirect }));
} catch (error) {
return json(500, { error: "InternalServerError", message: error.message });
}
};
}
// session name -> record, from stage.json / atlibs.lock.json ({ sessions: { name: { record } } }) or the map itself
const sessionRecords = (source) => (source.sessions ? Object.fromEntries(Object.entries(source.sessions).filter(([, s]) => s.record).map(([k, s]) => [k, s.record])) : source);
/**
* An app's own identity and read-only repo (Rust: oauth::AppRepo): did:web:<origin's host>, whose document names
* origin as its #atproto_pds and publicKeyMultibase (a P-256 did:key the host keeps, `app:<origin>`) as #atproto,
* holding the records the session records declare in `app`: { records: [{ collection, rkey, value }],
* wellKnown: { <file>: JSON } } (served at /.well-known/<file>: e.g. co/core's cocore-app.json). Strings on a
* record's template origin (its client_id's) move to origin, and did:web:<template host> becomes this DID, as
* clientMetadata rebases. Two sessions may not declare one collection/rkey differently. A loopback origin has no
* app identity: a did:web resolves over https.
* @param {{ sessions: Record<string, { record: object }> } | Record<string, object>} source as clientMetadataHandler's
* @param {{ origin: string, publicKeyMultibase: string }} options
* @returns {Promise<{ did: string, document: object, records: { uri: string, cid: string, value: object, collection: string, rkey: string }[], wellKnown: Record<string, unknown> }>}
*/
export async function appRepo(source, { origin, publicKeyMultibase }) {
const site = new URL(origin);
if (site.protocol !== "https:" || LOOPBACK.has(site.hostname)) throw new Error(`an app identity needs a public https origin (a did:web resolves over https), not ${origin}`);
const did = `did:web:${site.host.replace(":", "%3A")}`;
const records = [];
const wellKnown = {};
for (const [session, record] of Object.entries(sessionRecords(source))) {
const template = new URL(record.clientMetadata.client_id);
const templateDid = `did:web:${template.host.replace(":", "%3A")}`;
const rebase = (v) =>
typeof v === "string" ? (v === templateDid ? did : v.startsWith(template.origin) ? `${site.origin}${v.slice(template.origin.length)}` : v) : Array.isArray(v) ? v.map(rebase) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, rebase(x)])) : v;
for (const { collection, rkey, value } of record.app?.records ?? []) {
if (!/^[a-zA-Z0-9.-]+$/.test(collection ?? "") || !/^[a-zA-Z0-9._:~-]{1,512}$/.test(rkey ?? "") || typeof value !== "object" || value === null) throw new Error(`${session}: an app record is { collection, rkey, value }`);
const rebased = rebase(value);
const same = records.find((r) => r.collection === collection && r.rkey === rkey);
if (same && JSON.stringify(same.value) !== JSON.stringify(rebased)) throw new Error(`${session}: ${collection}/${rkey} is declared twice, differently`);
if (same) continue;
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array(dagCbor(rebased))));
records.push({ uri: `at://${did}/${collection}/${rkey}`, cid: `b${toBase32(Uint8Array.from([0x01, 0x71, 0x12, 0x20, ...digest]))}`, value: rebased, collection, rkey });
}
for (const [file, value] of Object.entries(record.app?.wellKnown ?? {})) {
if (!/^[a-zA-Z0-9._-]+$/.test(file) || ["did.json", "atproto-did"].includes(file)) throw new Error(`${session}: app wellKnown ${file} is not a file name it may serve`);
wellKnown[file] = rebase(value);
}
}
const document = {
"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"],
id: did,
verificationMethod: [{ id: `${did}#atproto`, type: "Multikey", controller: did, publicKeyMultibase }],
service: [{ id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: site.origin }],
};
return { did, document, records, wellKnown };
}
/**
* appRepo's answer to a GET (Rust: AppRepo::respond): [status, content-type, body] for /.well-known/did.json,
* /.well-known/atproto-did, its wellKnown files and the repo reads com.atproto.repo.getRecord, listRecords and
* describeRepo; undefined for anything else (a server falls through to its own routes). Read-only: no
* createAccount, no getServiceAuth (the key signs nothing), no writes and no sync (there are no commits:
* relays cannot crawl it, and a changed declaration changes only the served record and its CID).
*/
export function appRepoAnswer(repo, pathAndQuery) {
const url = new URL(pathAndQuery, "http://app.invalid");
const json = (status, body) => [status, "application/json", JSON.stringify(body)];
const p = url.pathname;
if (p === "/.well-known/did.json") return json(200, repo.document);
if (p === "/.well-known/atproto-did") return [200, "text/plain", repo.did];
if (p.startsWith("/.well-known/") && Object.hasOwn(repo.wellKnown, p.slice(13))) return json(200, repo.wellKnown[p.slice(13)]);
const nsid = p.startsWith("/xrpc/") ? p.slice(6) : undefined;
if (!["com.atproto.repo.getRecord", "com.atproto.repo.listRecords", "com.atproto.repo.describeRepo"].includes(nsid)) return undefined;
const q = (k) => url.searchParams.get(k);
if (q("repo") !== repo.did && q("repo") !== repo.did.slice(8).replace("%3A", ":")) return json(400, { error: "RepoNotFound", message: `this PDS holds only ${repo.did}` });
const out = ({ uri, cid, value }) => ({ uri, cid, value });
if (nsid === "com.atproto.repo.describeRepo") return json(200, { handle: repo.did.slice(8).replace("%3A", ":"), did: repo.did, didDoc: repo.document, collections: [...new Set(repo.records.map((r) => r.collection))], handleIsCorrect: true });
if (nsid === "com.atproto.repo.listRecords") return json(200, { records: repo.records.filter((r) => r.collection === q("collection")).map(out) });
const found = repo.records.find((r) => r.collection === q("collection") && r.rkey === q("rkey"));
return found ? json(200, out(found)) : json(400, { error: "RecordNotFound", message: `Could not locate record: at://${repo.did}/${q("collection")}/${q("rkey")}` });
}
/** appRepoAnswer as a fetch-style handler (Request -> Response | undefined), its Host held to the repo's origin; public reads (CORS *). */
export function appRepoHandler(repo) {
const host = repo.did.slice(8).replace("%3A", ":");
return (request) => {
const url = new URL(request.url);
if ((request.method !== "GET" && request.method !== "HEAD") || url.host !== host) return undefined;
const answer = appRepoAnswer(repo, url.pathname + url.search);
if (!answer) return undefined;
const [status, type, body] = answer;
return new Response(body, { status, headers: { "content-type": type, "access-control-allow-origin": "*", "cache-control": "public, max-age=60" } });
};
}