515 lines
18 KiB
TypeScript
515 lines
18 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,
|
|
FEDERATION,
|
|
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`,
|
|
gerbeur_outgoing: FEDERATION.outgoing,
|
|
};
|
|
}
|
|
|
|
/** 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, and this instance must let its
|
|
* users sign in elsewhere (`FEDERATION.outgoing`).
|
|
*
|
|
* @throws {APIException} 400 when `clientId` isn't an instance's address, is
|
|
* this instance, or `user` comes from another instance; 403 when this
|
|
* instance's users don't sign in elsewhere, or 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 (!FEDERATION.outgoing) {
|
|
throw new APIException(
|
|
403,
|
|
"Accounts on this instance don't sign in to other instances",
|
|
);
|
|
}
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Withdraws every grant, as each user would theirs, and drops the codes not
|
|
* yet traded, so that no account here stays signed in elsewhere: `main.ts`
|
|
* calls it at startup when `FEDERATION.outgoing` is off. Returns how many
|
|
* grants it withdrew.
|
|
*/
|
|
export async function withdrawAllGrants(): Promise<number> {
|
|
db.prepare(`DELETE FROM oauth_codes;`).run();
|
|
const grants = db.prepare(
|
|
`SELECT user_id, client_id FROM federation_grants;`,
|
|
).all() as { user_id: string; client_id: string }[];
|
|
for (const { user_id, client_id } of grants) {
|
|
await notifyClients(user_id, { type: "revoke" }, [client_id]);
|
|
}
|
|
db.prepare(`DELETE FROM federation_grants;`).run();
|
|
return grants.length;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|