/** * 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 { 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 { 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 { 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 { 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 { 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, };