v4: users sign in to one gerbeur instance with their account on another — topic instances, each with its own admins, domain and data, had no way to share accounts. Sign-in is federated over a subset of OpenID Connect, the authorization code flow with PKCE: each instance is an identity provider for its own users and a client for the others', and signs what it tells them with an Ed25519 key kept in federation_keys and published at /.well-known/jwks.json, beside its metadata at /.well-known/openid-configuration. Only sign-in is federated: an account from another instance is a local users row linked to (issuer, subject) in user_identities, with no password (password_hash ''), so sessions, roles, permissions, the socket and every foreign key work as before.

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>
This commit is contained in:
khannurien
2026-10-09 14:23:00 +00:00
parent 2d26d0aada
commit c0d03e9aaa
67 changed files with 5261 additions and 339 deletions

267
shared/federation.ts Normal file
View File

@@ -0,0 +1,267 @@
/**
* Types what crosses the wire for federated sign-in, by which a user signs in
* to one gerbeur instance with their account on another.
*
* The protocol is a subset of OpenID Connect, the authorization code flow
* with PKCE, with tokens signed by each instance's Ed25519 key. Between the
* instances, and between each instance and its client, travel:
*
* - each instance's metadata, served at `/.well-known/openid-configuration`;
* - the pages' requests to start and finish a sign-in, here
* (`/api/federation/*`) and on the home instance (`/api/oauth/*`);
* - the claims of an identity, and the events a home instance sends the
* instances its users signed in to;
* - the instances an admin trusts or blocks, and the instances a user let
* their account sign in to.
*
* In the code, the user's own instance is their home, playing the identity
* provider; the instance they sign in to is the client.
*
* @module
*/
/**
* The version of the protocol below, which an instance announces in its
* {@link InstanceMetadata}. An instance refuses one announcing another.
*/
export const FEDERATION_VERSION = 1;
/**
* Served by every instance at `/.well-known/openid-configuration`
* (`routes/well-known.ts`), and read by another before it trusts it, starts a
* sign-in on it or sends it an event. The names are OpenID Connect's, and the
* `gerbeur_` fields are this protocol's own.
*/
export interface InstanceMetadata {
/** The instance's public URL, an origin, which names it in every token. */
issuer: string;
/** Its `/authorize` page, where a user lets another instance sign them in. */
authorization_endpoint: string;
/** `POST /api/oauth/tokens`, which trades a code for an identity token. */
token_endpoint: string;
/** `GET /.well-known/jwks.json`, its public keys. */
jwks_uri: string;
response_types_supported: string[];
grant_types_supported: string[];
subject_types_supported: string[];
id_token_signing_alg_values_supported: string[];
code_challenge_methods_supported: string[];
scopes_supported: string[];
/** {@link FEDERATION_VERSION}. */
gerbeur_federation: number;
/** The site's name, which a consent page and the peer list show. */
gerbeur_site_name: string;
/**
* Where its home instance sends a user back once they consent, as a client:
* its `/federation/callback` page, on its frontend.
*/
gerbeur_redirect_uri: string;
/** `POST /api/federation/events`, where it receives a home's events. */
gerbeur_events_endpoint: string;
}
/**
* Checks whether `value` is an {@link InstanceMetadata} of this protocol's
* version, as `remote/gerbeur-instance.ts` must before reading one.
*/
export function isInstanceMetadata(value: unknown): value is InstanceMetadata {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return typeof v.issuer === "string" &&
typeof v.authorization_endpoint === "string" &&
typeof v.token_endpoint === "string" &&
typeof v.jwks_uri === "string" &&
v.gerbeur_federation === FEDERATION_VERSION &&
typeof v.gerbeur_site_name === "string" &&
typeof v.gerbeur_redirect_uri === "string" &&
typeof v.gerbeur_events_endpoint === "string";
}
/**
* Returns the origin of the instance `input` names, or null when it names
* none. Someone may type a bare host (`music.example`, taken as https), a URL,
* or a handle's host with its port (`localhost:8001`); a URL with a path names
* no instance. Whether plain http is allowed is the server's call.
*/
export function parseInstance(input: string): string | null {
const trimmed = input.trim().replace(/\/+$/, "");
if (!trimmed) return null;
try {
const url = new URL(
/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`,
);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
if (url.pathname !== "/" || url.search || url.hash) return null;
if (url.username || url.password) return null;
return url.origin;
} catch {
return null;
}
}
/**
* Returns the host of the instance at `issuer`, with its port if it has one:
* the part after the `@` of its users' handles here (`alice@music.example`).
*/
export function instanceHost(issuer: string): string {
return new URL(issuer).host;
}
/**
* What an admin decided about an instance. Users of a trusted one sign in
* here directly; those of a blocked one never do, and its users already here
* are signed out. An instance with neither is invite only: its users sign in
* here with an invite.
*/
export type PeerStatus = "trusted" | "blocked";
/** Every {@link PeerStatus}. */
export const PEER_STATUSES: readonly PeerStatus[] = ["trusted", "blocked"];
/** Checks whether `value` is a {@link PeerStatus}. */
export function isPeerStatus(value: unknown): value is PeerStatus {
return typeof value === "string" &&
(PEER_STATUSES as readonly string[]).includes(value);
}
/**
* Returned by `GET /api/federation/peers`: the login page offers each trusted
* one, and `/manage` lists them all.
*/
export interface FederationPeer {
issuer: string;
/** {@link instanceHost} of the issuer. */
host: string;
/** The site's name, as its metadata gave it when it was added. */
name: string | null;
status: PeerStatus;
createdAt: Date;
}
/** Sent to `PUT /api/federation/peers` by `/manage`. */
export interface SetPeerRequest {
/** As someone typed it, read with {@link parseInstance}. */
instance: string;
status: PeerStatus;
}
/**
* Sent to `POST /api/federation/logins` by the login page, or by the register
* page with the invite it was opened with.
*/
export interface FederationLoginRequest {
/** The user's home instance, read with {@link parseInstance}. */
instance: string;
inviteToken?: string;
}
/**
* Returned by `POST /api/federation/logins`: the home instance's `/authorize`
* page with the request in its query, which the client navigates to.
*/
export interface FederationLoginResponse {
authorizeUrl: string;
}
/**
* Sent to `POST /api/federation/sessions` by the `/federation/callback` page,
* from the query the home instance sent the user back with. The answer is an
* `AuthResponse`, as a sign-in's.
*/
export interface FederationSessionRequest {
code: string;
state: string;
}
/**
* Returned by `GET /api/oauth/clients?client_id=` to the `/authorize` page,
* which asks the user whether to sign in there.
*/
export interface OAuthClient {
clientId: string;
host: string;
name: string;
/**
* Whether the user already let it sign them in, in which case the page
* doesn't ask again.
*/
granted: boolean;
}
/**
* Sent to `POST /api/oauth/codes` by the `/authorize` page once the user
* consents, from the query the client instance sent them with.
*/
export interface AuthorizeRequest {
clientId: string;
redirectUri: string;
state: string;
codeChallenge: string;
codeChallengeMethod: string;
nonce: string;
}
/**
* Returned by `POST /api/oauth/codes`: the client's callback page with the
* code, where the `/authorize` page sends the user.
*/
export interface AuthorizeResponse {
redirectTo: string;
}
/**
* Returned by `GET /api/me/grants` to the settings page: an instance the
* user let their account sign in to.
*/
export interface FederationGrant {
clientId: string;
host: string;
name: string | null;
grantedAt: Date;
lastUsedAt: Date;
}
/**
* Carried by an identity token and by an `update` event: what a home instance
* tells another of a user. The names are OpenID Connect's.
*/
export interface IdentityClaims {
/** The user's name at home; the client calls them `<name>@<host>`. */
preferred_username: string;
email: string;
/** Their avatar's URL at home, versioned; absent without one. */
picture?: string;
}
/**
* The payload of a signed event, which a home instance sends each instance a
* user signed in to (`POST /api/federation/events`): `update` when their name,
* email or avatar changed, `revoke` when their sessions there must end, after
* a password change or when they withdraw the grant.
*/
export type FederationEvent =
| { type: "update"; claims: IdentityClaims }
| { type: "revoke" };
/**
* Checks whether `value` is a {@link FederationEvent}, as the events endpoint
* must once the signature is verified.
*/
export function isFederationEvent(value: unknown): value is FederationEvent {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
if (v.type === "revoke") return true;
return v.type === "update" && isIdentityClaims(v.claims);
}
/**
* Checks whether `value` carries the {@link IdentityClaims}, as a client must
* before creating or updating an account from them.
*/
export function isIdentityClaims(value: unknown): value is IdentityClaims {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return typeof v.preferred_username === "string" &&
typeof v.email === "string" &&
(v.picture === undefined || typeof v.picture === "string");
}