// 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 `.`. Publish it like any other record * (records/.json) or serve it with clientMetadata(). * @param {Record} 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, modules?: Record, * features?: Record }> }} policy stage.json plus features * @param {string} module * @param {Record Promise>} 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 `/client-metadata.json` * for each record, the client_id being that URL on `origin` (unset: only loopback requests are answered, * on http://), as the emdash * atproto-integrations plugin serves its integrations' metadata. With `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 } | Record} 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:, whose document names * origin as its #atproto_pds and publicKeyMultibase (a P-256 did:key the host keeps, `app:`) as #atproto, * holding the records the session records declare in `app`: { records: [{ collection, rkey, value }], * wellKnown: { : JSON } } (served at /.well-known/: 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: