A remote user is name@host here, in users.username, in paths (/@alice@music.example) and in mentions, through one handle rule in shared/validation.ts that paths, MENTION_RE and Markdown all build on; Markdown undoes GFM's email autolink of a handle. Their name, email and avatar come from their home at each sign-in and by its signed events (federation_outbox, delivered by the federation job with backoff): a rename at home leaves the old handle an alias here, and a password change or a withdrawn grant ends their sessions here. Their password, email, avatar and name can't change here (assertLocalUser), nor can they sign in elsewhere from here. An admin keeps the trusted and blocked instances on /manage (federation:manage): a trusted instance's users sign in from a button on the login page, a blocked one's can't and are signed out at once, and any other's join with an invite from the register page. The /authorize page asks the user once per instance, and /settings lists the instances their account signs in to, each disconnectable; /login?next= brings a signed-out visitor back to /authorize. Emails stay unique among local accounts only, and migration 0024 adds the tables, makes that index partial and reserves authorize and federation as category slugs. remote/gerbeur-instance.ts is the only way to other instances: https and public addresses only, no redirects, no certificate fallback, unless GERBEUR_FEDERATION_ALLOW_PRIVATE lets two instances on one machine talk. 298 backend tests and 702 frontend tests pass, and deno check, deno fmt, deno lint, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked end to end with two built instances on one machine: sign-in through the other's login and consent pages, consent skipped once granted and asked again after disconnecting, a rename and a password change at home reaching the other, and blocking; screenshots of the login, consent, profile, settings and manage pages at 1280 and 390px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
225 lines
7.0 KiB
TypeScript
225 lines
7.0 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`).
|
|
*/
|
|
const metadataCache = new TtlCache<InstanceMetadata>(10 * 60_000, 500);
|
|
const keysCache = new TtlCache<JSONWebKeySet>(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 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<InstanceMetadata> {
|
|
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.
|
|
*
|
|
* @throws {Error} When they can't be fetched.
|
|
*/
|
|
export async function fetchKeys(
|
|
origin: string,
|
|
fresh = false,
|
|
): Promise<JSONWebKeySet> {
|
|
const hit = fresh ? undefined : keysCache.get(origin);
|
|
if (hit) return hit;
|
|
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();
|
|
keysCache.clear();
|
|
}
|