276 lines
9.2 KiB
TypeScript
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");
|
|
}
|