v4: federated sign-in closes the holes a review found — a callback link made from someone else's sign-in signed the visitor in to that account, a password reset left the user's sessions on other instances open, and events could be lost, reordered or delayed. FederatedSignIn keeps the sign-in's state in sessionStorage (utils/auth/federation.ts) and FederationCallback finishes only a sign-in this tab started. A password reset tells the instances the user signed in to, as a change does. The outbox delivers each instance's events in the order they were made, the first that fails holding back the rest, so a late older update no longer undoes a newer one, and delivers to every instance at once, so a dead one delays none of the others; an event queued during a run is sent by it.

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>
This commit is contained in:
khannurien
2026-10-09 16:05:11 +00:00
parent c77e173767
commit 66e402bd91
19 changed files with 447 additions and 128 deletions

View File

@@ -22,11 +22,12 @@
* @module
*/
import { decodeJwt } from "@panva/jose";
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,
@@ -65,6 +66,13 @@ 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.
@@ -157,50 +165,73 @@ export async function startLogin(
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.
* @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 };
try {
try {
return await verifyInstanceToken(token, await fetchKeys(issuer), claims);
} catch (err) {
if ((err as { code?: string }).code !== "ERR_JWKS_NO_MATCHING_KEY") {
throw err;
}
return await verifyInstanceToken(
token,
await fetchKeys(issuer, true),
claims,
);
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;
}
} catch (err) {
console.error(`[federation] a token from ${issuer} failed:`, err);
throw new APIException(401, "Invalid token");
}
console.error(`[federation] a token from ${issuer} failed:`, failure);
throw new APIException(401, "Invalid token");
}
/**
* Returns `claims` once checked: a name a local user could have, and an email.
*
* @throws {APIException} 502 when the home sent something else.
* 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): IdentityClaims {
function checkClaims(claims: unknown, issuer: string): IdentityClaims | null {
if (
!isIdentityClaims(claims) ||
!USERNAME_RE.test(claims.preferred_username) ||
!EMAIL_RE.test(claims.email)
) {
throw new APIException(502, "The instance sent an invalid identity");
return null;
}
if (claims.picture !== undefined) {
try {
if (new URL(claims.picture).origin !== issuer) return null;
} catch {
return null;
}
}
return claims;
}
@@ -255,7 +286,10 @@ export async function completeLogin(
if (payload.nonce !== login.nonce || typeof payload.sub !== "string") {
throw new APIException(401, "Invalid token");
}
const claims = checkClaims(payload);
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(
@@ -342,10 +376,13 @@ async function applyClaims(
* 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 may send one, about one of those accounts.
* 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.
* 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;
@@ -361,6 +398,12 @@ export async function receiveEvent(token: string): Promise<void> {
) {
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 = ?;`,
@@ -370,7 +413,9 @@ export async function receiveEvent(token: string): Promise<void> {
}
if (payload.type === "revoke") {
revokeSessions(identity.user_id, new Date(), "Signed out by home");
} else {
await applyClaims(identity.user_id, issuer, checkClaims(payload.claims));
return;
}
const claims = checkClaims(payload.claims, issuer);
if (!claims) throw new APIException(400, "Invalid event");
await applyClaims(identity.user_id, issuer, claims);
}