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>
487 lines
16 KiB
TypeScript
487 lines
16 KiB
TypeScript
/**
|
|
* Plays the home instance in federated sign-in: it lets this instance's users
|
|
* sign in to other gerbeur instances with their account here.
|
|
*
|
|
* The flow is OpenID Connect's authorization code with PKCE, in three steps.
|
|
*
|
|
* 1. Another instance, the client, sends the user to the `/authorize` page
|
|
* with a request. The page shows who asks ({@link describeClient}) and,
|
|
* once the user consents, gets a code ({@link createAuthorizationCode}),
|
|
* which it carries back to the client's callback page.
|
|
* 2. The client trades the code for an identity token, server to server
|
|
* ({@link exchangeCode}): this user's name, email and avatar, signed with
|
|
* 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 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
|
|
* instance signs in from there. `routes/oauth.ts`, `routes/well-known.ts` and
|
|
* the routes that change a user call it.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import { db, transaction } from "../db/connection.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 {
|
|
type AuthorizeRequest,
|
|
FEDERATION_VERSION,
|
|
type FederationEvent,
|
|
type FederationGrant,
|
|
type IdentityClaims,
|
|
instanceHost,
|
|
type InstanceMetadata,
|
|
type OAuthClient,
|
|
parseInstance,
|
|
} from "../../shared/federation.ts";
|
|
import { fetchMetadata, postEvent } from "../remote/gerbeur-instance.ts";
|
|
import { isRemoteUser, type User } from "../model/user.ts";
|
|
import { getSigningKey } from "./instance-key-service.ts";
|
|
import { getUserById } from "./user-service.ts";
|
|
import { getPeerStatus } from "./federation-peer-service.ts";
|
|
|
|
/**
|
|
* The instance's name in every token it signs and every token addressed to
|
|
* it: its public URL, which must be an origin for other instances to find
|
|
* its metadata.
|
|
*/
|
|
export const ISSUER: string = PUBLIC_URL;
|
|
|
|
/**
|
|
* The client's page a home instance sends the user back to, on the frontend.
|
|
* Its metadata announces it, and a home accepts no other.
|
|
*/
|
|
export const REDIRECT_URI = `${FRONTEND_URL}/federation/callback`;
|
|
|
|
/** How long a code may wait to be traded, from the consent. */
|
|
const CODE_TTL_MS = 60_000;
|
|
|
|
/**
|
|
* How long an identity token is valid: enough to cross from one server to the
|
|
* other, never enough to be replayed later.
|
|
*/
|
|
const ID_TOKEN_TTL = "5m";
|
|
|
|
/**
|
|
* How long an event is valid, and so how long the outbox keeps trying to
|
|
* deliver it before giving up.
|
|
*/
|
|
const EVENT_TTL_MS = 3 * 86_400_000;
|
|
|
|
/** The longest wait between two tries of an event. */
|
|
const MAX_RETRY_MS = 6 * 3_600_000;
|
|
|
|
/**
|
|
* Returns the instance's metadata, served at
|
|
* `/.well-known/openid-configuration`.
|
|
*/
|
|
export function getInstanceMetadata(): InstanceMetadata {
|
|
return {
|
|
issuer: ISSUER,
|
|
authorization_endpoint: `${FRONTEND_URL}/authorize`,
|
|
token_endpoint: `${PUBLIC_URL}/api/oauth/tokens`,
|
|
jwks_uri: `${PUBLIC_URL}/.well-known/jwks.json`,
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code"],
|
|
subject_types_supported: ["public"],
|
|
id_token_signing_alg_values_supported: ["EdDSA"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
scopes_supported: ["openid", "profile", "email"],
|
|
gerbeur_federation: FEDERATION_VERSION,
|
|
gerbeur_site_name: OG_SITE_NAME,
|
|
gerbeur_redirect_uri: REDIRECT_URI,
|
|
gerbeur_events_endpoint: `${PUBLIC_URL}/api/federation/events`,
|
|
};
|
|
}
|
|
|
|
/** Returns the SHA-256 of `value`, as base64url. */
|
|
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, so that a client fetches it
|
|
* again only then.
|
|
*/
|
|
export function identityClaims(user: User): IdentityClaims {
|
|
return {
|
|
preferred_username: user.username,
|
|
email: user.email,
|
|
...(user.avatarMime && {
|
|
picture: `${PUBLIC_URL}/api/users/${user.id}/avatar?v=${
|
|
avatarVersion(user)
|
|
}`,
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the client `clientId` names, checked: an instance other than this
|
|
* one, not blocked here, answering as a gerbeur. `user` must have a local
|
|
* account, which alone may sign in elsewhere.
|
|
*
|
|
* @throws {APIException} 400 when `clientId` isn't an instance's address, is
|
|
* this instance, or `user` comes from another instance; 403 when the client
|
|
* is blocked here; 502 when it doesn't answer as a gerbeur.
|
|
*/
|
|
async function checkClient(
|
|
user: User,
|
|
clientId: string,
|
|
): Promise<{ clientId: string; metadata: InstanceMetadata }> {
|
|
if (isRemoteUser(user)) {
|
|
throw new APIException(
|
|
400,
|
|
"This account signs in from the instance it comes from",
|
|
);
|
|
}
|
|
if (parseInstance(clientId) !== clientId || clientId === ISSUER) {
|
|
throw new APIException(400, "Invalid client");
|
|
}
|
|
if (getPeerStatus(clientId) === "blocked") {
|
|
throw new APIException(403, "This instance is blocked here");
|
|
}
|
|
try {
|
|
return { clientId, metadata: await fetchMetadata(clientId) };
|
|
} catch (err) {
|
|
console.error(`[federation] client ${clientId} unreachable:`, err);
|
|
throw new APIException(502, "That instance doesn't answer as a gerbeur");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the instance `clientId` names, for the `/authorize` page's question
|
|
* to user `userId`, and whether they already let it sign them in.
|
|
*
|
|
* @throws {APIException} As {@link checkClient}.
|
|
*/
|
|
export async function describeClient(
|
|
userId: string,
|
|
clientId: string,
|
|
): Promise<OAuthClient> {
|
|
const { metadata } = await checkClient(getUserById(userId), clientId);
|
|
const granted = !!db.prepare(
|
|
`SELECT 1 FROM federation_grants WHERE user_id = ? AND client_id = ?;`,
|
|
).get(userId, clientId);
|
|
return {
|
|
clientId,
|
|
host: instanceHost(clientId),
|
|
name: metadata.gerbeur_site_name,
|
|
granted,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Records user `userId`'s consent to sign in to the client of `request`, and
|
|
* returns where to send them: the client's callback page with a code, which
|
|
* the client trades within a minute ({@link exchangeCode}). The grant lasts
|
|
* until they withdraw it, so the page asks no more.
|
|
*
|
|
* @throws {APIException} 400 when the request is malformed or its
|
|
* `redirectUri` isn't the client's own; otherwise as {@link checkClient}.
|
|
*/
|
|
export async function createAuthorizationCode(
|
|
userId: string,
|
|
request: AuthorizeRequest,
|
|
): Promise<string> {
|
|
const user = getUserById(userId);
|
|
const { clientId, metadata } = await checkClient(user, request.clientId);
|
|
if (request.redirectUri !== metadata.gerbeur_redirect_uri) {
|
|
throw new APIException(400, "Invalid redirect URI");
|
|
}
|
|
if (
|
|
request.codeChallengeMethod !== "S256" ||
|
|
!/^[A-Za-z0-9_-]{43}$/.test(request.codeChallenge) ||
|
|
!request.state || request.state.length > 512 ||
|
|
!request.nonce || request.nonce.length > 512
|
|
) {
|
|
throw new APIException(400, "Invalid request");
|
|
}
|
|
|
|
const code = randomBytes(32).toString("base64url");
|
|
const now = new Date();
|
|
transaction(() => {
|
|
db.prepare(`DELETE FROM oauth_codes WHERE expires_at < ?;`).run(
|
|
now.toISOString(),
|
|
);
|
|
db.prepare(
|
|
`INSERT INTO oauth_codes (code_hash, user_id, client_id, redirect_uri, code_challenge, nonce, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
|
).run(
|
|
sha256(code),
|
|
userId,
|
|
clientId,
|
|
request.redirectUri,
|
|
request.codeChallenge,
|
|
request.nonce,
|
|
new Date(now.getTime() + CODE_TTL_MS).toISOString(),
|
|
);
|
|
db.prepare(
|
|
`INSERT INTO federation_grants (user_id, client_id, client_name, granted_at, last_used_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT (user_id, client_id) DO UPDATE SET client_name = excluded.client_name;`,
|
|
).run(
|
|
userId,
|
|
clientId,
|
|
metadata.gerbeur_site_name,
|
|
now.toISOString(),
|
|
now.toISOString(),
|
|
);
|
|
});
|
|
|
|
const redirect = new URL(request.redirectUri);
|
|
redirect.searchParams.set("code", code);
|
|
redirect.searchParams.set("state", request.state);
|
|
// RFC 9207: tells the client which instance answered, against mix-ups.
|
|
redirect.searchParams.set("iss", ISSUER);
|
|
return redirect.href;
|
|
}
|
|
|
|
/**
|
|
* Trades a code for an identity token, for `POST /api/oauth/tokens`, which
|
|
* the client calls server to server with the form `params`. A code works once,
|
|
* within a minute, for the client and redirect it was made for, and with the
|
|
* verifier whose hash the request carried (PKCE). The token, signed with the
|
|
* instance key and addressed to the client, carries {@link identityClaims}
|
|
* and the request's nonce.
|
|
*
|
|
* @throws {APIException} 400 when anything doesn't match.
|
|
*/
|
|
export async function exchangeCode(
|
|
params: URLSearchParams,
|
|
): Promise<{ id_token: string; token_type: string }> {
|
|
const code = params.get("code");
|
|
const clientId = params.get("client_id");
|
|
const verifier = params.get("code_verifier");
|
|
if (
|
|
params.get("grant_type") !== "authorization_code" || !code || !clientId ||
|
|
!verifier || !/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)
|
|
) {
|
|
throw new APIException(400, "invalid_request");
|
|
}
|
|
|
|
// Taken in the same statement that finds it, so it can't be traded twice.
|
|
const row = db.prepare(
|
|
`DELETE FROM oauth_codes WHERE code_hash = ?
|
|
RETURNING user_id, client_id, redirect_uri, code_challenge, nonce, expires_at;`,
|
|
).get(sha256(code)) as
|
|
| {
|
|
user_id: string;
|
|
client_id: string;
|
|
redirect_uri: string;
|
|
code_challenge: string;
|
|
nonce: string;
|
|
expires_at: string;
|
|
}
|
|
| undefined;
|
|
if (
|
|
!row || row.expires_at < new Date().toISOString() ||
|
|
row.client_id !== clientId ||
|
|
row.redirect_uri !== params.get("redirect_uri") ||
|
|
row.code_challenge !== sha256(verifier)
|
|
) {
|
|
throw new APIException(400, "invalid_grant");
|
|
}
|
|
|
|
const user = getUserById(row.user_id);
|
|
if (isRemoteUser(user)) throw new APIException(400, "invalid_grant");
|
|
db.prepare(
|
|
`UPDATE federation_grants SET last_used_at = ? WHERE user_id = ? AND client_id = ?;`,
|
|
).run(new Date().toISOString(), user.id, clientId);
|
|
|
|
const idToken = await signInstanceToken(
|
|
{ ...identityClaims(user), nonce: row.nonce },
|
|
await getSigningKey(),
|
|
{ issuer: ISSUER, audience: clientId, subject: user.id },
|
|
ID_TOKEN_TTL,
|
|
);
|
|
return { id_token: idToken, token_type: "Bearer" };
|
|
}
|
|
|
|
interface GrantRow {
|
|
client_id: string;
|
|
client_name: string | null;
|
|
granted_at: string;
|
|
last_used_at: string;
|
|
}
|
|
|
|
/**
|
|
* Returns the instances user `userId` let their account sign in to, the most
|
|
* recently used first, for the settings page.
|
|
*/
|
|
export function listGrants(userId: string): FederationGrant[] {
|
|
const rows = db.prepare(
|
|
`SELECT client_id, client_name, granted_at, last_used_at FROM federation_grants
|
|
WHERE user_id = ? ORDER BY last_used_at DESC;`,
|
|
).all(userId) as unknown as GrantRow[];
|
|
return rows.map((r) => ({
|
|
clientId: r.client_id,
|
|
host: instanceHost(r.client_id),
|
|
name: r.client_name,
|
|
grantedAt: new Date(r.granted_at),
|
|
lastUsedAt: new Date(r.last_used_at),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Withdraws user `userId`'s grant to the instance whose host is `host`: it
|
|
* stops answering for them there, and is told to end their sessions.
|
|
*
|
|
* @throws {APIException} 404 when they gave that instance no grant.
|
|
*/
|
|
export async function revokeGrant(userId: string, host: string): Promise<void> {
|
|
const grant = listGrants(userId).find((g) => g.host === host.toLowerCase());
|
|
if (!grant) throw new APIException(404, "Grant not found");
|
|
await notifyClients(userId, { type: "revoke" }, [grant.clientId]);
|
|
db.prepare(
|
|
`DELETE FROM federation_grants WHERE user_id = ? AND client_id = ?;`,
|
|
).run(userId, grant.clientId);
|
|
}
|
|
|
|
/**
|
|
* Tells each instance user `userId` signed in to of `event`, or only those of
|
|
* `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,
|
|
* after those queued before for the same instance ({@link deliverTo}).
|
|
*/
|
|
export async function notifyClients(
|
|
userId: string,
|
|
event: FederationEvent | { type: "update" },
|
|
clientIds?: string[],
|
|
): Promise<void> {
|
|
const clients = clientIds ?? listGrants(userId).map((g) => g.clientId);
|
|
if (clients.length === 0) return;
|
|
const payload: FederationEvent = event.type === "update"
|
|
? { type: "update", claims: identityClaims(getUserById(userId)) }
|
|
: event;
|
|
|
|
const key = await getSigningKey();
|
|
const now = new Date();
|
|
for (const clientId of clients) {
|
|
const token = await signInstanceToken(
|
|
{ ...payload },
|
|
key,
|
|
{ issuer: ISSUER, audience: clientId, subject: userId },
|
|
`${EVENT_TTL_MS / 1000}s`,
|
|
);
|
|
db.prepare(
|
|
`INSERT INTO federation_outbox (id, client_id, event, next_attempt_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?);`,
|
|
).run(
|
|
crypto.randomUUID(),
|
|
clientId,
|
|
token,
|
|
now.toISOString(),
|
|
now.toISOString(),
|
|
);
|
|
}
|
|
for (const clientId of clients) deliverTo(clientId);
|
|
}
|
|
|
|
/**
|
|
* 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 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> {
|
|
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 {
|
|
do {
|
|
queuedMeanwhile.delete(clientId);
|
|
await sendDue(clientId);
|
|
} while (queuedMeanwhile.has(clientId));
|
|
} catch (err) {
|
|
console.error(`[federation] delivery to ${clientId} failed:`, err);
|
|
} finally {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Retries the outbox's failed events every minute. */
|
|
export const outboxJob: Job = {
|
|
name: "federation",
|
|
everyMs: 60_000,
|
|
run: deliverOutbox,
|
|
};
|