/** * Plays the client in federated sign-in: it lets users of other gerbeur * instances sign in here with their account there, their home. * * 1. The login or register page names the user's home, and * {@link startLogin} answers the home's `/authorize` page with a request: * a random state, the hash of a PKCE verifier, and a nonce, kept in * `federation_logins` for 10 minutes. * 2. The home sends the user back to the `/federation/callback` page with a * code. {@link completeLogin} trades it for the home's identity token, * checks its signature against the home's keys, and finds or creates the * local account linked to it (`user_identities`), named `@`. * 3. The home's events ({@link receiveEvent}) keep the account's name, email * and avatar in step, and end its sessions when the home says so. * * Whether a user of another instance may join is the admin's call * (`federation-peer-service.ts`): from a trusted instance they may, from a * blocked one they may not, and from any other only with an invite, which the * register page carries. None may while the deployment turns sign-ins from * other instances off (`FEDERATION.incoming`). Once here, an account works like any other, but its * password, email, avatar and name are its home's (`assertLocalUser`). * * @module */ import { decodeJwt, type JSONWebKeySet } from "@panva/jose"; import { createHash, randomBytes } from "node:crypto"; import { APIException } from "../lib/api-exception.ts"; import { FEDERATION } from "../config.ts"; import { db, transaction } from "../db/connection.ts"; import { verifyInstanceToken } from "../lib/jwt.ts"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; import { type FederationLoginRequest, type FederationSessionRequest, type IdentityClaims, instanceHost, type InstanceMetadata, isFederationEvent, isIdentityClaims, parseInstance, } from "../../shared/federation.ts"; import { EMAIL_RE, USERNAME_RE } from "../../shared/validation.ts"; import { toPublicUser, type User } from "../model/user.ts"; import { exchangeCode, fetchAvatar, fetchKeys, fetchMetadata, } from "../remote/gerbeur-instance.ts"; import { ISSUER, REDIRECT_URI } from "./identity-provider-service.ts"; import { getPeerStatus } from "./federation-peer-service.ts"; import { claimInvite, releaseInvite, validateInvite, } from "./invite-service.ts"; import { createRemoteUser, getUserById, revokeSessions, sendWelcomeEmail, setUserAvatar, setUsername, updateUser, } from "./user-service.ts"; import { broadcastUserUpdated, updateClientUsername } from "./ws-service.ts"; /** How long a sign-in may take, from the login page to the callback. */ const LOGIN_TTL_MS = 10 * 60_000; /** * Allows an instance a burst of 120 events, as when many of its users change * at once, then one a second. Each may fetch its keys, so a stranger posting * events in an instance's name can't make this one hammer it. */ const EVENT_LIMIT: RateLimit = { capacity: 120, refill: 1 }; /** * Refuses a sign-in from another instance while the deployment turns them off * (`FEDERATION.incoming`), as {@link startLogin} and {@link completeLogin} * must first. * * @throws {APIException} 403 when they are off. */ function assertIncoming(): void { if (!FEDERATION.incoming) { throw new APIException( 403, "This instance doesn't take accounts from other instances", ); } } /** * Returns the origin of the instance `instance` names, checked as a home: not * this instance, nor a blocked one. * * @throws {APIException} 400 when it names no instance or this one, 403 when * it is blocked. */ function homeOf(instance: string): string { const issuer = parseInstance(instance); if (!issuer) throw new APIException(400, "Not an instance's address"); if (issuer === ISSUER) throw new APIException(400, "That is this instance"); if (getPeerStatus(issuer) === "blocked") { throw new APIException(403, "This instance is blocked here"); } return issuer; } /** * Starts a sign-in with an account on the instance `request.instance` names, * and returns its `/authorize` page with the request, for * `POST /api/federation/logins`. Unless that instance is trusted, or someone * here already comes from it, the request must carry a valid invite, which is * claimed only once the account is made; {@link completeLogin} makes no * account without one. * * @throws {APIException} 400 when the instance isn't one; 403 when sign-ins * from other instances are off, or it is blocked, neither trusted nor * invited, or keeps its users from signing in elsewhere; 404 or 409 when the * invite doesn't work; 502 when it doesn't answer as a gerbeur. */ export async function startLogin( request: FederationLoginRequest, ): Promise { assertIncoming(); const issuer = homeOf(request.instance); if (request.inviteToken) { validateInvite(request.inviteToken); } else if ( getPeerStatus(issuer) !== "trusted" && // Someone who joined with an invite signs in again without one; whether // this user is one of them is only known once their home answers. !db.prepare(`SELECT 1 FROM user_identities WHERE issuer = ? LIMIT 1;`) .get(issuer) ) { throw new APIException( 403, "Users of this instance need an invite to join", ); } let metadata: InstanceMetadata; try { metadata = await fetchMetadata(issuer); } catch (err) { console.error(`[federation] ${issuer} unreachable:`, err); throw new APIException(502, "That instance doesn't answer as a gerbeur"); } if (metadata.gerbeur_outgoing === false) { throw new APIException( 403, "Accounts on that instance don't sign in to other instances", ); } const state = randomBytes(24).toString("base64url"); const verifier = randomBytes(48).toString("base64url"); const nonce = randomBytes(24).toString("base64url"); const now = new Date(); transaction(() => { db.prepare(`DELETE FROM federation_logins WHERE created_at < ?;`).run( new Date(now.getTime() - LOGIN_TTL_MS).toISOString(), ); db.prepare( `INSERT INTO federation_logins (state, issuer, code_verifier, nonce, invite_token, created_at) VALUES (?, ?, ?, ?, ?, ?);`, ).run( state, issuer, verifier, nonce, request.inviteToken ?? null, now.toISOString(), ); }); const url = new URL(metadata.authorization_endpoint); url.search = new URLSearchParams({ response_type: "code", scope: "openid profile email", client_id: ISSUER, redirect_uri: REDIRECT_URI, state, code_challenge: createHash("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256", nonce, }).toString(); return url.href; } /** * Returns the keys of the instance at `issuer`, refetched when `fresh` asks. * * @throws {APIException} 502 when they can't be fetched: the sender may try * again, which it wouldn't after a 4xx. */ async function keysOf(issuer: string, fresh: boolean): Promise { try { return await fetchKeys(issuer, fresh); } catch (err) { console.error(`[federation] ${issuer}'s keys failed:`, err); throw new APIException(502, "That instance's keys can't be fetched"); } } /** * Returns the payload of `token`, an identity token or an event from the * instance at `issuer`, once its signature checks against that instance's * keys and it is addressed to this one. The keys are refetched once when the * token names one they lack. * * @throws {APIException} 401 when it doesn't verify, 502 when the keys can't * be fetched. */ async function verifyFrom( token: string, issuer: string, ): Promise> { const claims = { issuer, audience: ISSUER }; let failure: unknown; for (const fresh of [false, true]) { if ( fresh && (failure as { code?: string }).code !== "ERR_JWKS_NO_MATCHING_KEY" ) { break; } const keys = await keysOf(issuer, fresh); try { return await verifyInstanceToken(token, keys, claims); } catch (err) { failure = err; } } console.error(`[federation] a token from ${issuer} failed:`, failure); throw new APIException(401, "Invalid token"); } /** * Returns `claims` from the instance at `issuer` once checked: a name a local * user could have, an email, and an avatar on that instance, so that it can't * make this server fetch from elsewhere. Returns null when they aren't. */ function checkClaims(claims: unknown, issuer: string): IdentityClaims | null { if ( !isIdentityClaims(claims) || !USERNAME_RE.test(claims.preferred_username) || !EMAIL_RE.test(claims.email) ) { return null; } if (claims.picture !== undefined) { try { if (new URL(claims.picture).origin !== issuer) return null; } catch { return null; } } return claims; } /** * Finishes a sign-in begun by {@link startLogin}, for * `POST /api/federation/sessions`, and returns the account it signs in to: * the one linked to the identity, brought up to date, or a new one. A login * works once. A new account needs the home to be trusted, or the invite the * login was started with, claimed now. * * @throws {APIException} 400 when the login is unknown or expired; 401 when * the identity token doesn't verify; 403 when sign-ins from other instances * are off, or the account may not be created; 409 when its handle is * someone's; 502 when the home fails. */ export async function completeLogin( request: FederationSessionRequest, ): Promise { assertIncoming(); const login = db.prepare( `DELETE FROM federation_logins WHERE state = ? RETURNING issuer, code_verifier, nonce, invite_token, created_at;`, ).get(request.state) as | { issuer: string; code_verifier: string; nonce: string; invite_token: string | null; created_at: string; } | undefined; if ( !login || Date.now() - new Date(login.created_at).getTime() > LOGIN_TTL_MS ) { throw new APIException(400, "This sign-in has expired, start again"); } const issuer = login.issuer; let idToken: string; try { idToken = await exchangeCode(issuer, { code: request.code, clientId: ISSUER, redirectUri: REDIRECT_URI, codeVerifier: login.code_verifier, }); } catch (err) { console.error(`[federation] ${issuer} refused a code:`, err); throw new APIException(502, "The instance refused the sign-in"); } const payload = await verifyFrom(idToken, issuer); if (payload.nonce !== login.nonce || typeof payload.sub !== "string") { throw new APIException(401, "Invalid token"); } const claims = checkClaims(payload, issuer); if (!claims) { throw new APIException(502, "The instance sent an invalid identity"); } const subject = payload.sub; const identity = db.prepare( `SELECT user_id FROM user_identities WHERE issuer = ? AND subject = ?;`, ).get(issuer, subject) as { user_id: string } | undefined; if (getPeerStatus(issuer) === "blocked") { throw new APIException(403, "This instance is blocked here"); } if (identity) { return await applyClaims(identity.user_id, issuer, claims); } const trusted = getPeerStatus(issuer) === "trusted"; if (!trusted && !login.invite_token) { throw new APIException( 403, "Users of this instance need an invite to join", ); } const inviterId = trusted ? null : claimInvite(login.invite_token!); let user: User; try { user = transaction(() => { const created = createRemoteUser({ username: `${claims.preferred_username}@${instanceHost(issuer)}`, email: claims.email, }, inviterId); db.prepare( `INSERT INTO user_identities (user_id, issuer, subject, synced_at) VALUES (?, ?, ?, ?);`, ).run(created.id, issuer, subject, new Date().toISOString()); return created; }); } catch (err) { if (!trusted) releaseInvite(login.invite_token!); throw err; } sendWelcomeEmail(user); return await applyClaims(user.id, issuer, claims); } /** * Brings the account `userId`, from the instance at `issuer`, in step with * `claims` and returns it: a new name at home renames it, the old handle * staying its alias, and a new email or avatar replaces its own. A failed * avatar fetch is logged, and tried again at the next change or sign-in. * * @throws {APIException} 409 when the new handle is someone else's. */ async function applyClaims( userId: string, issuer: string, claims: IdentityClaims, ): Promise { let user = getUserById(userId); const handle = `${claims.preferred_username}@${instanceHost(issuer)}`; if (handle !== user.username) { user = setUsername(userId, handle); updateClientUsername(userId, handle); broadcastUserUpdated(toPublicUser(user)); } if (claims.email !== user.email) { user = await updateUser(userId, { email: claims.email }); } const { picture } = db.prepare( `SELECT picture FROM user_identities WHERE user_id = ?;`, ).get(userId) as { picture: string | null }; let synced = claims.picture ?? null; if (claims.picture && claims.picture !== picture) { try { user = await setUserAvatar(userId, await fetchAvatar(claims.picture)); } catch (err) { console.error(`[federation] ${handle}'s avatar failed:`, err); synced = picture; } } db.prepare( `UPDATE user_identities SET picture = ?, synced_at = ? WHERE user_id = ?;`, ).run(synced, new Date().toISOString(), userId); return user; } /** * Applies the signed event `token` from a user's home, for * `POST /api/federation/events`: `update` brings their account in step, * `revoke` ends their sessions here. Only an instance some account here comes * from, and not blocked, may send one, about one of those accounts, and at * most {@link EVENT_LIMIT}'s rate. The sender gives up on an event at a 4xx * other than 429, and tries it again later otherwise. * * @throws {APIException} 400 when it isn't an event of a known instance about * one of its users; 401 when it doesn't verify; 403 when the instance is * blocked; 429 when it sends too many; 502 when its keys can't be fetched. */ export async function receiveEvent(token: string): Promise { let issuer: unknown; try { issuer = decodeJwt(token).iss; } catch { throw new APIException(400, "Invalid event"); } if ( typeof issuer !== "string" || !db.prepare(`SELECT 1 FROM user_identities WHERE issuer = ? LIMIT 1;`) .get(issuer) ) { throw new APIException(400, "Unknown instance"); } if (getPeerStatus(issuer) === "blocked") { throw new APIException(403, "This instance is blocked here"); } if (!takeToken(`federation-events:${issuer}`, EVENT_LIMIT)) { throw new APIException(429, "Too many events"); } const payload = await verifyFrom(token, issuer); const identity = db.prepare( `SELECT user_id FROM user_identities WHERE issuer = ? AND subject = ?;`, ).get(issuer, String(payload.sub)) as { user_id: string } | undefined; if (!identity || !isFederationEvent(payload)) { throw new APIException(400, "Invalid event"); } if (payload.type === "revoke") { revokeSessions(identity.user_id, new Date(), "Signed out by home"); return; } const claims = checkClaims(payload.claims, issuer); if (!claims) throw new APIException(400, "Invalid event"); await applyClaims(identity.user_id, issuer, claims); }