Files
gerbeur/api/remote/gerbeur-instance.ts

272 lines
8.9 KiB
TypeScript

/**
* 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<InstanceMetadata>(10 * 60_000, 500);
const keysCache = new TtlCache<JSONWebKeySet>(10 * 60_000, 500);
const keysRefetched = new TtlCache<true>(60_000, 500);
/** Keeps which issuer an instance's pages belong to ({@link resolveIssuer}). */
const issuerCache = new TtlCache<string>(10 * 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<void> {
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<Response> {
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<unknown> {
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 `origin` serves at `/.well-known/openid-configuration`,
* whichever issuer it names.
*
* @throws {Error} When it can't be reached or isn't a gerbeur instance's.
*/
async function metadataAt(origin: string): Promise<InstanceMetadata> {
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`);
}
return body;
}
/**
* Returns the metadata of the instance at `origin`, its issuer, 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<InstanceMetadata> {
const hit = metadataCache.get(origin);
if (hit) return hit;
const body = await metadataAt(origin);
if (body.issuer !== origin) {
throw new Error(`${origin} names itself ${body.issuer}`);
}
return metadataCache.set(origin, body);
}
/**
* Returns the issuer of the instance at `origin`, cached: `origin` itself, or,
* when `origin` is where the instance's pages are and its API lives at
* another, the issuer the pages' metadata names, whose own metadata must name
* those pages back as where its `/authorize` is. People know an instance by
* the address in their browser, which a sign-in form or the trust list may be
* given; a handle and the stored identities name the issuer.
*
* @throws {Error} When `origin` can't be reached, isn't a gerbeur instance,
* or names an issuer that doesn't name it back.
*/
export async function resolveIssuer(origin: string): Promise<string> {
const hit = issuerCache.get(origin);
if (hit) return hit;
if (metadataCache.get(origin)) return origin;
const body = await metadataAt(origin);
if (body.issuer === origin) {
metadataCache.set(origin, body);
return issuerCache.set(origin, origin);
}
const pages =
new URL((await fetchMetadata(body.issuer)).authorization_endpoint)
.origin;
if (pages !== origin) {
throw new Error(`${origin} names ${body.issuer}, whose pages are ${pages}`);
}
return issuerCache.set(origin, body.issuer);
}
/**
* 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<JSONWebKeySet> {
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<string> {
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<boolean> {
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<Uint8Array> {
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();
issuerCache.clear();
keysCache.clear();
keysRefetched.clear();
}