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

johnandersen777.bsky.social/atlibs

0
0
main
334 lines · 20.8 KB Raw History
// 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" } });
	};
}