/** * 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; 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 `@`. */ 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; 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; return typeof v.preferred_username === "string" && typeof v.email === "string" && (v.picture === undefined || typeof v.picture === "string"); }