Files
gerbeur/api/services/identity-provider-service.ts

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,
};