272 lines
8.9 KiB
TypeScript
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();
|
|
}
|