Files
gerbeur/api/services/identity-provider-service.ts
khannurien c0d03e9aaa 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>
2026-10-09 14:23:00 +00:00

439 lines
15 KiB
TypeScript

/**
* Plays the home instance in federated sign-in: it lets this instance's users
* sign in to other gerbeur instances with their account here.
*
* The flow is OpenID Connect's authorization code with PKCE, in three steps.
*
* 1. Another instance, the client, sends the user to the `/authorize` page
* with a request. The page shows who asks ({@link describeClient}) and,
* once the user consents, gets a code ({@link createAuthorizationCode}),
* which it carries back to the client's callback page.
* 2. The client trades the code for an identity token, server to server
* ({@link exchangeCode}): this user's name, email and avatar, signed with
* the instance key.
* 3. While the user's grant lasts, the client hears of their changes: a new
* name, email or avatar, and the end of their sessions after a password
* change. {@link notifyClients} queues the signed events in
* `federation_outbox`, and {@link outboxJob} delivers them.
*
* Only a local account signs in elsewhere: a user who came from another
* instance signs in from there. `routes/oauth.ts`, `routes/well-known.ts` and
* the routes that change a user call it.
*
* @module
*/
import { createHash, randomBytes } from "node:crypto";
import { APIException } from "../lib/api-exception.ts";
import { db, transaction } from "../db/connection.ts";
import { FRONTEND_URL, OG_SITE_NAME, PUBLIC_URL } from "../config.ts";
import { signInstanceToken } from "../lib/jwt.ts";
import type { Job } from "../lib/scheduler.ts";
import {
type AuthorizeRequest,
FEDERATION_VERSION,
type FederationEvent,
type FederationGrant,
type IdentityClaims,
instanceHost,
type InstanceMetadata,
type OAuthClient,
parseInstance,
} from "../../shared/federation.ts";
import { fetchMetadata, postEvent } from "../remote/gerbeur-instance.ts";
import { isRemoteUser, type User } from "../model/user.ts";
import { getSigningKey } from "./instance-key-service.ts";
import { getUserById } from "./user-service.ts";
import { getPeerStatus } from "./federation-peer-service.ts";
/**
* The instance's name in every token it signs and every token addressed to
* it: its public URL, which must be an origin for other instances to find
* its metadata.
*/
export const ISSUER: string = PUBLIC_URL;
/**
* The client's page a home instance sends the user back to, on the frontend.
* Its metadata announces it, and a home accepts no other.
*/
export const REDIRECT_URI = `${FRONTEND_URL}/federation/callback`;
/** How long a code may wait to be traded, from the consent. */
const CODE_TTL_MS = 60_000;
/**
* How long an identity token is valid: enough to cross from one server to the
* other, never enough to be replayed later.
*/
const ID_TOKEN_TTL = "5m";
/**
* How long an event is valid, and so how long the outbox keeps trying to
* deliver it before giving up.
*/
const EVENT_TTL_MS = 3 * 86_400_000;
/** The longest wait between two tries of an event. */
const MAX_RETRY_MS = 6 * 3_600_000;
/**
* Returns the instance's metadata, served at
* `/.well-known/openid-configuration`.
*/
export function getInstanceMetadata(): InstanceMetadata {
return {
issuer: ISSUER,
authorization_endpoint: `${FRONTEND_URL}/authorize`,
token_endpoint: `${PUBLIC_URL}/api/oauth/tokens`,
jwks_uri: `${PUBLIC_URL}/.well-known/jwks.json`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["EdDSA"],
code_challenge_methods_supported: ["S256"],
scopes_supported: ["openid", "profile", "email"],
gerbeur_federation: FEDERATION_VERSION,
gerbeur_site_name: OG_SITE_NAME,
gerbeur_redirect_uri: REDIRECT_URI,
gerbeur_events_endpoint: `${PUBLIC_URL}/api/federation/events`,
};
}
/** Returns the SHA-256 of `value`, as base64url. */
function sha256(value: string): string {
return createHash("sha256").update(value).digest("base64url");
}
/**
* Returns what this instance tells another of `user`: their name, email and
* avatar, whose URL changes with each new one.
*/
export function identityClaims(user: User): IdentityClaims {
return {
preferred_username: user.username,
email: user.email,
...(user.avatarMime && {
picture: `${PUBLIC_URL}/api/users/${user.id}/avatar?v=${
user.updatedAt?.getTime() ?? 0
}`,
}),
};
}
/**
* Returns the client `clientId` names, checked: an instance other than this
* one, not blocked here, answering as a gerbeur. `user` must have a local
* account, which alone may sign in elsewhere.
*
* @throws {APIException} 400 when `clientId` isn't an instance's address, is
* this instance, or `user` comes from another instance; 403 when the client
* is blocked here; 502 when it doesn't answer as a gerbeur.
*/
async function checkClient(
user: User,
clientId: string,
): Promise<{ clientId: string; metadata: InstanceMetadata }> {
if (isRemoteUser(user)) {
throw new APIException(
400,
"This account signs in from the instance it comes from",
);
}
if (parseInstance(clientId) !== clientId || clientId === ISSUER) {
throw new APIException(400, "Invalid client");
}
if (getPeerStatus(clientId) === "blocked") {
throw new APIException(403, "This instance is blocked here");
}
try {
return { clientId, metadata: await fetchMetadata(clientId) };
} catch (err) {
console.error(`[federation] client ${clientId} unreachable:`, err);
throw new APIException(502, "That instance doesn't answer as a gerbeur");
}
}
/**
* Returns the instance `clientId` names, for the `/authorize` page's question
* to user `userId`, and whether they already let it sign them in.
*
* @throws {APIException} As {@link checkClient}.
*/
export async function describeClient(
userId: string,
clientId: string,
): Promise<OAuthClient> {
const { metadata } = await checkClient(getUserById(userId), clientId);
const granted = !!db.prepare(
`SELECT 1 FROM federation_grants WHERE user_id = ? AND client_id = ?;`,
).get(userId, clientId);
return {
clientId,
host: instanceHost(clientId),
name: metadata.gerbeur_site_name,
granted,
};
}
/**
* Records user `userId`'s consent to sign in to the client of `request`, and
* returns where to send them: the client's callback page with a code, which
* the client trades within a minute ({@link exchangeCode}). The grant lasts
* until they withdraw it, so the page asks no more.
*
* @throws {APIException} 400 when the request is malformed or its
* `redirectUri` isn't the client's own; otherwise as {@link checkClient}.
*/
export async function createAuthorizationCode(
userId: string,
request: AuthorizeRequest,
): Promise<string> {
const user = getUserById(userId);
const { clientId, metadata } = await checkClient(user, request.clientId);
if (request.redirectUri !== metadata.gerbeur_redirect_uri) {
throw new APIException(400, "Invalid redirect URI");
}
if (
request.codeChallengeMethod !== "S256" ||
!/^[A-Za-z0-9_-]{43}$/.test(request.codeChallenge) ||
!request.state || request.state.length > 512 ||
!request.nonce || request.nonce.length > 512
) {
throw new APIException(400, "Invalid request");
}
const code = randomBytes(32).toString("base64url");
const now = new Date();
transaction(() => {
db.prepare(`DELETE FROM oauth_codes WHERE expires_at < ?;`).run(
now.toISOString(),
);
db.prepare(
`INSERT INTO oauth_codes (code_hash, user_id, client_id, redirect_uri, code_challenge, nonce, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?);`,
).run(
sha256(code),
userId,
clientId,
request.redirectUri,
request.codeChallenge,
request.nonce,
new Date(now.getTime() + CODE_TTL_MS).toISOString(),
);
db.prepare(
`INSERT INTO federation_grants (user_id, client_id, client_name, granted_at, last_used_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id, client_id) DO UPDATE SET client_name = excluded.client_name;`,
).run(
userId,
clientId,
metadata.gerbeur_site_name,
now.toISOString(),
now.toISOString(),
);
});
const redirect = new URL(request.redirectUri);
redirect.searchParams.set("code", code);
redirect.searchParams.set("state", request.state);
// RFC 9207: tells the client which instance answered, against mix-ups.
redirect.searchParams.set("iss", ISSUER);
return redirect.href;
}
/**
* Trades a code for an identity token, for `POST /api/oauth/tokens`, which
* the client calls server to server with the form `params`. A code works once,
* within a minute, for the client and redirect it was made for, and with the
* verifier whose hash the request carried (PKCE). The token, signed with the
* instance key and addressed to the client, carries {@link identityClaims}
* and the request's nonce.
*
* @throws {APIException} 400 when anything doesn't match.
*/
export async function exchangeCode(
params: URLSearchParams,
): Promise<{ id_token: string; token_type: string }> {
const code = params.get("code");
const clientId = params.get("client_id");
const verifier = params.get("code_verifier");
if (
params.get("grant_type") !== "authorization_code" || !code || !clientId ||
!verifier || !/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)
) {
throw new APIException(400, "invalid_request");
}
// Taken in the same statement that finds it, so it can't be traded twice.
const row = db.prepare(
`DELETE FROM oauth_codes WHERE code_hash = ?
RETURNING user_id, client_id, redirect_uri, code_challenge, nonce, expires_at;`,
).get(sha256(code)) as
| {
user_id: string;
client_id: string;
redirect_uri: string;
code_challenge: string;
nonce: string;
expires_at: string;
}
| undefined;
if (
!row || row.expires_at < new Date().toISOString() ||
row.client_id !== clientId ||
row.redirect_uri !== params.get("redirect_uri") ||
row.code_challenge !== sha256(verifier)
) {
throw new APIException(400, "invalid_grant");
}
const user = getUserById(row.user_id);
if (isRemoteUser(user)) throw new APIException(400, "invalid_grant");
db.prepare(
`UPDATE federation_grants SET last_used_at = ? WHERE user_id = ? AND client_id = ?;`,
).run(new Date().toISOString(), user.id, clientId);
const idToken = await signInstanceToken(
{ ...identityClaims(user), nonce: row.nonce },
await getSigningKey(),
{ issuer: ISSUER, audience: clientId, subject: user.id },
ID_TOKEN_TTL,
);
return { id_token: idToken, token_type: "Bearer" };
}
interface GrantRow {
client_id: string;
client_name: string | null;
granted_at: string;
last_used_at: string;
}
/**
* Returns the instances user `userId` let their account sign in to, the most
* recently used first, for the settings page.
*/
export function listGrants(userId: string): FederationGrant[] {
const rows = db.prepare(
`SELECT client_id, client_name, granted_at, last_used_at FROM federation_grants
WHERE user_id = ? ORDER BY last_used_at DESC;`,
).all(userId) as unknown as GrantRow[];
return rows.map((r) => ({
clientId: r.client_id,
host: instanceHost(r.client_id),
name: r.client_name,
grantedAt: new Date(r.granted_at),
lastUsedAt: new Date(r.last_used_at),
}));
}
/**
* Withdraws user `userId`'s grant to the instance whose host is `host`: it
* stops answering for them there, and is told to end their sessions.
*
* @throws {APIException} 404 when they gave that instance no grant.
*/
export async function revokeGrant(userId: string, host: string): Promise<void> {
const grant = listGrants(userId).find((g) => g.host === host.toLowerCase());
if (!grant) throw new APIException(404, "Grant not found");
await notifyClients(userId, { type: "revoke" }, [grant.clientId]);
db.prepare(
`DELETE FROM federation_grants WHERE user_id = ? AND client_id = ?;`,
).run(userId, grant.clientId);
}
/**
* Tells each instance user `userId` signed in to of `event`, or only those of
* `clientIds`: `update` when their name, email or avatar changed, `revoke`
* when their sessions there must end. The routes that change a user call it,
* and for an `update`, `event` may be just the type: the claims are read now.
* The events are queued in the outbox and their delivery starts at once.
*/
export async function notifyClients(
userId: string,
event: FederationEvent | { type: "update" },
clientIds?: string[],
): Promise<void> {
const clients = clientIds ?? listGrants(userId).map((g) => g.clientId);
if (clients.length === 0) return;
const payload: FederationEvent = event.type === "update"
? { type: "update", claims: identityClaims(getUserById(userId)) }
: event;
const key = await getSigningKey();
const now = new Date();
for (const clientId of clients) {
const token = await signInstanceToken(
{ ...payload },
key,
{ issuer: ISSUER, audience: clientId, subject: userId },
`${EVENT_TTL_MS / 1000}s`,
);
db.prepare(
`INSERT INTO federation_outbox (id, client_id, event, next_attempt_at, created_at)
VALUES (?, ?, ?, ?, ?);`,
).run(
crypto.randomUUID(),
clientId,
token,
now.toISOString(),
now.toISOString(),
);
}
deliverOutbox().catch((err) =>
console.error("[federation] delivery failed:", err)
);
}
/** Set while {@link deliverOutbox} runs, so that two runs don't overlap. */
let delivering = false;
/**
* Sends each due event of the outbox to its instance. One it took, or refused
* for good, leaves the outbox; one that failed waits twice as long as the time
* before, up to 6 hours, until it expires after 3 days.
*/
export async function deliverOutbox(): Promise<void> {
if (delivering) return;
delivering = true;
try {
const now = new Date();
db.prepare(`DELETE FROM federation_outbox WHERE created_at < ?;`).run(
new Date(now.getTime() - EVENT_TTL_MS).toISOString(),
);
const due = db.prepare(
`SELECT id, client_id, event, attempts FROM federation_outbox
WHERE next_attempt_at <= ? ORDER BY created_at LIMIT 100;`,
).all(now.toISOString()) as {
id: string;
client_id: string;
event: string;
attempts: number;
}[];
for (const row of due) {
try {
if (!await postEvent(row.client_id, row.event)) {
console.error(`[federation] ${row.client_id} refused an event`);
}
db.prepare(`DELETE FROM federation_outbox WHERE id = ?;`).run(row.id);
} catch (err) {
const wait = Math.min(60_000 * 2 ** row.attempts, MAX_RETRY_MS);
db.prepare(
`UPDATE federation_outbox SET attempts = attempts + 1, next_attempt_at = ? WHERE id = ?;`,
).run(new Date(Date.now() + wait).toISOString(), row.id);
console.error(`[federation] ${row.client_id} unreachable:`, err);
}
}
} finally {
delivering = false;
}
}
/** Retries the outbox's failed events every minute. */
export const outboxJob: Job = {
name: "federation",
everyMs: 60_000,
run: deliverOutbox,
};