/** * Talks to other gerbeur instances for federated sign-in: it reads their * metadata and public keys, trades a code for an identity token, delivers * events, and fetches a user's avatar. `federation-service.ts`, * `identity-provider-service.ts` and `federation-peer-service.ts` reach other * instances only through it. * * An identity is only as safe as the connection it came over, so unlike * `fetch.ts` it never falls back to skipping certificate checks, and follows * no redirect. It reaches only https instances on public addresses, unless * `GERBEUR_FEDERATION_ALLOW_PRIVATE` lets two instances on one machine talk. * * @module */ import type { JSONWebKeySet } from "@panva/jose"; import { type InstanceMetadata, isInstanceMetadata, } from "../../shared/federation.ts"; import { FEDERATION_ALLOW_PRIVATE, MAX_IMAGE_SIZE_BYTES, OG_SITE_NAME, } from "../config.ts"; import { resolvePublicAddress } from "./public-address.ts"; import { readBytesCapped, readTextCapped } from "./fetch.ts"; import { TtlCache } from "../lib/ttl-cache.ts"; /** How long a request to another instance may take, body included. */ const TIMEOUT_MS = 8000; /** Caps a metadata document, a key set or a token response. */ const MAX_JSON_BYTES = 64 * 1024; const USER_AGENT = `gerbeur (${OG_SITE_NAME}; federation)`; /** * Keeps each instance's metadata and keys for 10 minutes, so that a sign-in * or an event doesn't fetch them each time. A key set is refetched sooner * when a token names a key it lacks ({@link fetchKeys} with `fresh`), at most * once a minute: anyone can send a token naming a key that doesn't exist. */ const metadataCache = new TtlCache(10 * 60_000, 500); const keysCache = new TtlCache(10 * 60_000, 500); const keysRefetched = new TtlCache(60_000, 500); /** * Checks that `url` may be reached: https on a public address, or anything * under `GERBEUR_FEDERATION_ALLOW_PRIVATE`. * * @throws {Error} When it may not. */ export async function assertReachable(url: URL): Promise { if (FEDERATION_ALLOW_PRIVATE) { if (url.protocol !== "https:" && url.protocol !== "http:") { throw new Error(`Unsupported protocol ${url.protocol}`); } return; } if (url.protocol !== "https:") { throw new Error(`Refusing to reach ${url.origin} without https`); } await resolvePublicAddress(url); } /** * Makes one request to another instance, after {@link assertReachable}, with * the deadline covering the body. The caller reads or cancels the body. */ async function request(url: string, init: RequestInit = {}): Promise { const target = new URL(url); await assertReachable(target); return await fetch(target, { ...init, redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "User-Agent": USER_AGENT, "Accept": "application/json", ...init.headers, }, }); } /** * Returns the JSON body of `res`, up to {@link MAX_JSON_BYTES}. * * @throws {Error} When the status isn't a success or the body isn't JSON. */ async function readJson(res: Response, what: string): Promise { if (!res.ok) { await res.body?.cancel(); throw new Error(`${what} answered ${res.status}`); } return JSON.parse(await readTextCapped(res, MAX_JSON_BYTES)); } /** * Returns the metadata of the instance at `origin`, from its * `/.well-known/openid-configuration`, cached. It must name `origin` as its * issuer and speak this protocol's version. * * @throws {Error} When it can't be reached or isn't a gerbeur instance. */ export async function fetchMetadata(origin: string): Promise { const hit = metadataCache.get(origin); if (hit) return hit; const body = await readJson( await request(`${origin}/.well-known/openid-configuration`), origin, ); if (!isInstanceMetadata(body)) { throw new Error(`${origin} is not a gerbeur instance this one can trust`); } if (body.issuer !== origin) { throw new Error(`${origin} names itself ${body.issuer}`); } return metadataCache.set(origin, body); } /** * Returns the public keys of the instance at `origin`, cached unless `fresh` * asks to refetch them and they weren't in the last minute. * * @throws {Error} When they can't be fetched. */ export async function fetchKeys( origin: string, fresh = false, ): Promise { const refetch = fresh && !keysRefetched.get(origin); const hit = refetch ? undefined : keysCache.get(origin); if (hit) return hit; if (refetch) keysRefetched.set(origin, true); const { jwks_uri } = await fetchMetadata(origin); const body = await readJson(await request(jwks_uri), `${origin}'s keys`); if ( !body || typeof body !== "object" || !Array.isArray((body as { keys?: unknown }).keys) ) { throw new Error(`${origin}'s keys are not a key set`); } return keysCache.set(origin, body as JSONWebKeySet); } /** * Trades `code` for an identity token at the token endpoint of the instance * at `origin`, and returns the token, unverified. * * @throws {Error} When the instance refuses or answers no token. */ export async function exchangeCode( origin: string, params: { code: string; clientId: string; redirectUri: string; codeVerifier: string; }, ): Promise { const { token_endpoint } = await fetchMetadata(origin); const body = await readJson( await request(token_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code: params.code, client_id: params.clientId, redirect_uri: params.redirectUri, code_verifier: params.codeVerifier, }), }), `${origin}'s token endpoint`, ); const idToken = (body as { id_token?: unknown } | null)?.id_token; if (typeof idToken !== "string") { throw new Error(`${origin} answered no identity token`); } return idToken; } /** * Sends the signed event `event` to the events endpoint of the instance at * `origin`, and returns whether it took it. A 4xx other than 429 means it never * will, which the caller treats as delivered. * * @throws {Error} When it can't be reached, or answers 429 or a 5xx. */ export async function postEvent( origin: string, event: string, ): Promise { const { gerbeur_events_endpoint } = await fetchMetadata(origin); const res = await request(gerbeur_events_endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ event }), }); await res.body?.cancel(); if (res.ok) return true; if (res.status === 429 || res.status >= 500) { throw new Error(`${origin}'s events endpoint answered ${res.status}`); } return false; } /** * Returns the image at `url`, a user's avatar on their home instance, up to * the size an avatar may be. * * @throws {Error} When it can't be fetched. */ export async function fetchAvatar(url: string): Promise { const res = await request(url, { headers: { "Accept": "image/*" } }); if (!res.ok) { await res.body?.cancel(); throw new Error(`The avatar at ${url} answered ${res.status}`); } return await readBytesCapped(res, MAX_IMAGE_SIZE_BYTES + 1); } /** Empties the caches, for the tests' reset between cases. */ export function clearInstanceCaches(): void { metadataCache.clear(); keysCache.clear(); keysRefetched.clear(); }