Files
gerbeur/api/services/federation-service.ts

450 lines
15 KiB
TypeScript

/**
* 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 `<name>@<host>`.
* 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<string> {
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<JSONWebKeySet> {
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<Record<string, unknown>> {
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<User> {
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<User> {
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<void> {
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);
}