Files
gerbeur/shared/federation.ts

276 lines
9.2 KiB
TypeScript

/**
* 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;
/**
* Whether its users may sign in to other instances, so that a client
* refuses at once to send them to its `/authorize` page, which would. An
* instance that doesn't say lets them.
*/
gerbeur_outgoing?: boolean;
}
/**
* 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" &&
(v.gerbeur_outgoing === undefined ||
typeof v.gerbeur_outgoing === "boolean");
}
/**
* 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");
}