Receiving, a home's keys that can't be fetched answer 502, which the sender retries, instead of 401, which it dropped; a bad update answers 400 rather than 502; a blocked instance's events answer 403; events are limited to a burst of 120 per instance, and a token naming an unknown key refetches the keys at most once a minute. An avatar must be on its issuer's origin, and its URL is versioned by its file's write time instead of updatedAt, so a profile edit no longer makes clients download it again. An instance that doesn't answer can be blocked, keeping its name, though not trusted. 303 backend tests and 703 frontend tests pass, the federation tests 8 times in a row, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
422 lines
14 KiB
TypeScript
422 lines
14 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. 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 { 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,
|
|
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 };
|
|
|
|
/**
|
|
* 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 it is
|
|
* blocked, or neither trusted nor invited; 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> {
|
|
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 authorizationEndpoint: string;
|
|
try {
|
|
authorizationEndpoint = (await fetchMetadata(issuer))
|
|
.authorization_endpoint;
|
|
} catch (err) {
|
|
console.error(`[federation] ${issuer} unreachable:`, err);
|
|
throw new APIException(502, "That instance doesn't answer as a gerbeur");
|
|
}
|
|
|
|
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(authorizationEndpoint);
|
|
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 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> {
|
|
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);
|
|
}
|