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:
@@ -65,12 +65,14 @@ export function listPeers(trustedOnly: boolean): FederationPeer[] {
|
||||
|
||||
/**
|
||||
* Puts the instance `instance` names on the list of `status`, for `/manage`,
|
||||
* and returns it. It must answer as a gerbeur instance, whose name is kept.
|
||||
* Blocking one ends the sessions of its users here at once: their tokens stop
|
||||
* counting by `getSessionState`, and this closes their sockets.
|
||||
* and returns it. A trusted one must answer as a gerbeur instance, whose name
|
||||
* is kept; a blocked one is blocked whether it answers or not, keeping the
|
||||
* name it had. Blocking one ends the sessions of its users here at once:
|
||||
* their tokens stop counting by `getSessionState`, and this closes their
|
||||
* sockets.
|
||||
*
|
||||
* @throws {APIException} 400 when `instance` names no instance, or this one,
|
||||
* 502 when it doesn't answer as a gerbeur instance.
|
||||
* 502 when an instance to trust doesn't answer as a gerbeur instance.
|
||||
*/
|
||||
export async function setPeer(
|
||||
instance: string,
|
||||
@@ -81,17 +83,20 @@ export async function setPeer(
|
||||
if (issuer === PUBLIC_URL) {
|
||||
throw new APIException(400, "That is this instance");
|
||||
}
|
||||
let name: string;
|
||||
let name: string | null = null;
|
||||
try {
|
||||
name = (await fetchMetadata(issuer)).gerbeur_site_name;
|
||||
} catch (err) {
|
||||
console.error(`[federation] ${issuer} can't be added:`, err);
|
||||
throw new APIException(502, "That address doesn't answer as a gerbeur");
|
||||
console.error(`[federation] ${issuer} doesn't answer:`, err);
|
||||
if (status === "trusted") {
|
||||
throw new APIException(502, "That address doesn't answer as a gerbeur");
|
||||
}
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT INTO federation_peers (issuer, status, name, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (issuer) DO UPDATE SET status = excluded.status, name = excluded.name;`,
|
||||
ON CONFLICT (issuer) DO UPDATE SET status = excluded.status,
|
||||
name = coalesce(excluded.name, name);`,
|
||||
).run(issuer, status, name, new Date().toISOString());
|
||||
|
||||
if (status === "blocked") {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* 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
|
||||
* change or reset. {@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
|
||||
@@ -26,7 +26,12 @@
|
||||
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 {
|
||||
AVATARS_DIR,
|
||||
FRONTEND_URL,
|
||||
OG_SITE_NAME,
|
||||
PUBLIC_URL,
|
||||
} from "../config.ts";
|
||||
import { signInstanceToken } from "../lib/jwt.ts";
|
||||
import type { Job } from "../lib/scheduler.ts";
|
||||
import {
|
||||
@@ -105,9 +110,23 @@ function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the version of `user`'s avatar: when its file was last written,
|
||||
* which only a new avatar does, unlike `updatedAt`, which every profile edit
|
||||
* bumps.
|
||||
*/
|
||||
function avatarVersion(user: User): number {
|
||||
try {
|
||||
return Deno.statSync(`${AVATARS_DIR}/${user.id}`).mtime?.getTime() ?? 0;
|
||||
} catch {
|
||||
return user.updatedAt?.getTime() ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns what this instance tells another of `user`: their name, email and
|
||||
* avatar, whose URL changes with each new one.
|
||||
* avatar, whose URL changes with each new one, so that a client fetches it
|
||||
* again only then.
|
||||
*/
|
||||
export function identityClaims(user: User): IdentityClaims {
|
||||
return {
|
||||
@@ -115,7 +134,7 @@ export function identityClaims(user: User): IdentityClaims {
|
||||
email: user.email,
|
||||
...(user.avatarMime && {
|
||||
picture: `${PUBLIC_URL}/api/users/${user.id}/avatar?v=${
|
||||
user.updatedAt?.getTime() ?? 0
|
||||
avatarVersion(user)
|
||||
}`,
|
||||
}),
|
||||
};
|
||||
@@ -348,7 +367,8 @@ export async function revokeGrant(userId: string, host: string): Promise<void> {
|
||||
* `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.
|
||||
* The events are queued in the outbox and their delivery starts at once,
|
||||
* after those queued before for the same instance ({@link deliverTo}).
|
||||
*/
|
||||
export async function notifyClients(
|
||||
userId: string,
|
||||
@@ -381,52 +401,80 @@ export async function notifyClients(
|
||||
now.toISOString(),
|
||||
);
|
||||
}
|
||||
deliverOutbox().catch((err) =>
|
||||
console.error("[federation] delivery failed:", err)
|
||||
);
|
||||
for (const clientId of clients) deliverTo(clientId);
|
||||
}
|
||||
|
||||
/** Set while {@link deliverOutbox} runs, so that two runs don't overlap. */
|
||||
let delivering = false;
|
||||
/**
|
||||
* The instances whose events {@link deliverTo} is sending, so that two runs
|
||||
* don't send the same events, and those whose events came meanwhile, which
|
||||
* the running one sends too once done.
|
||||
*/
|
||||
const delivering = new Set<string>();
|
||||
const queuedMeanwhile = new Set<string>();
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Sends the outbox's events to each instance that has some, every instance at
|
||||
* once so that one that doesn't answer delays none of the others.
|
||||
*/
|
||||
export async function deliverOutbox(): Promise<void> {
|
||||
if (delivering) return;
|
||||
delivering = true;
|
||||
db.prepare(`DELETE FROM federation_outbox WHERE created_at < ?;`).run(
|
||||
new Date(Date.now() - EVENT_TTL_MS).toISOString(),
|
||||
);
|
||||
const clients = db.prepare(
|
||||
`SELECT DISTINCT client_id FROM federation_outbox;`,
|
||||
).all() as { client_id: string }[];
|
||||
await Promise.all(clients.map((c) => deliverTo(c.client_id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the events for the instance `clientId` in the order they were made,
|
||||
* as long as it takes them, since each `update` carries all the claims and a
|
||||
* late older one would undo a newer one. One it took, or refused for good,
|
||||
* leaves the outbox. The first that fails stops the run, and waits twice as
|
||||
* long as the time before, up to 6 hours, with the events behind it, until
|
||||
* it expires after 3 days.
|
||||
*/
|
||||
async function deliverTo(clientId: string): Promise<void> {
|
||||
if (delivering.has(clientId)) {
|
||||
queuedMeanwhile.add(clientId);
|
||||
return;
|
||||
}
|
||||
delivering.add(clientId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
do {
|
||||
queuedMeanwhile.delete(clientId);
|
||||
await sendDue(clientId);
|
||||
} while (queuedMeanwhile.has(clientId));
|
||||
} catch (err) {
|
||||
console.error(`[federation] delivery to ${clientId} failed:`, err);
|
||||
} finally {
|
||||
delivering = false;
|
||||
delivering.delete(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs one pass of {@link deliverTo}. */
|
||||
async function sendDue(clientId: string): Promise<void> {
|
||||
for (;;) {
|
||||
const row = db.prepare(
|
||||
`SELECT id, event, attempts, next_attempt_at FROM federation_outbox
|
||||
WHERE client_id = ? ORDER BY created_at, rowid LIMIT 1;`,
|
||||
).get(clientId) as
|
||||
| { id: string; event: string; attempts: number; next_attempt_at: string }
|
||||
| undefined;
|
||||
if (!row || row.next_attempt_at > new Date().toISOString()) return;
|
||||
try {
|
||||
if (!await postEvent(clientId, row.event)) {
|
||||
console.error(`[federation] ${clientId} 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] ${clientId} unreachable:`, err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,8 +65,9 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets `newPassword` for the user of a reset token and marks the token used.
|
||||
* Through `updateUser`, this ends the user's other sessions.
|
||||
* Sets `newPassword` for the user of a reset token, marks the token used, and
|
||||
* returns the user's id. Through `updateUser`, this ends the user's sessions
|
||||
* here; the route tells the instances they signed in to.
|
||||
*
|
||||
* @throws {APIException} 400 when the password's length is out of bounds or
|
||||
* the token is invalid or expired, 404 when the token isn't on record or its
|
||||
@@ -75,7 +76,7 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
||||
export async function resetPassword(
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
): Promise<string> {
|
||||
if (
|
||||
newPassword.length < VALIDATION.PASSWORD_MIN ||
|
||||
newPassword.length > VALIDATION.PASSWORD_MAX
|
||||
@@ -118,4 +119,5 @@ export async function resetPassword(
|
||||
.run(token);
|
||||
throw err;
|
||||
}
|
||||
return payload.userId;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user