502 lines
17 KiB
TypeScript
502 lines
17 KiB
TypeScript
/**
|
|
* Plays the client in federated sign-in: it lets users of other gerbeur
|
|
* instances sign in here with their account there, their home.
|
|
*
|
|
* 1. The login or register page names the user's home, and
|
|
* {@link startLogin} answers the home's `/authorize` page with a request:
|
|
* a random state, the hash of a PKCE verifier, and a nonce, kept in
|
|
* `federation_logins` for 10 minutes.
|
|
* 2. The home sends the user back to the `/federation/callback` page with a
|
|
* code. {@link completeLogin} trades it for the home's identity token,
|
|
* checks its signature against the home's keys, and finds or creates the
|
|
* local account linked to it (`user_identities`), named `<name>@<host>`.
|
|
* 3. The home's events ({@link receiveEvent}) keep the account's name, email
|
|
* and avatar in step, and end its sessions when the home says so.
|
|
*
|
|
* Whether a user of another instance may join is the admin's call
|
|
* (`federation-peer-service.ts`): from a trusted instance they may, from a
|
|
* blocked one they may not, and from any other only with an invite, which the
|
|
* register page carries. None may while the deployment turns sign-ins from
|
|
* other instances off (`FEDERATION.incoming`). Once here, an account works like any other, but its
|
|
* password, email, avatar and name are its home's (`assertLocalUser`), and
|
|
* change on its pages there ({@link homePageUrl}).
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { decodeJwt, type JSONWebKeySet } from "@panva/jose";
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import { FEDERATION } from "../config.ts";
|
|
import { db, transaction } from "../db/connection.ts";
|
|
import { verifyInstanceToken } from "../lib/jwt.ts";
|
|
import { isSelfHost } from "../lib/self-url.ts";
|
|
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
|
import {
|
|
type FederationLoginRequest,
|
|
type FederationSessionRequest,
|
|
type IdentityClaims,
|
|
instanceHost,
|
|
type InstanceMetadata,
|
|
isFederationEvent,
|
|
isIdentityClaims,
|
|
parseInstance,
|
|
} from "../../shared/federation.ts";
|
|
import { userPath } from "../../shared/paths.ts";
|
|
import { EMAIL_RE, splitHandle, USERNAME_RE } from "../../shared/validation.ts";
|
|
import { toPublicUser, type User } from "../model/user.ts";
|
|
import {
|
|
exchangeCode,
|
|
fetchAvatar,
|
|
fetchKeys,
|
|
fetchMetadata,
|
|
resolveIssuer,
|
|
} from "../remote/gerbeur-instance.ts";
|
|
import { ISSUER, REDIRECT_URI } from "./identity-provider-service.ts";
|
|
import { getPeerStatus } from "./federation-peer-service.ts";
|
|
import {
|
|
claimInvite,
|
|
releaseInvite,
|
|
validateInvite,
|
|
} from "./invite-service.ts";
|
|
import {
|
|
createRemoteUser,
|
|
getUserById,
|
|
revokeSessions,
|
|
sendWelcomeEmail,
|
|
setUserAvatar,
|
|
setUsername,
|
|
updateUser,
|
|
} from "./user-service.ts";
|
|
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 };
|
|
|
|
/**
|
|
* Refuses a sign-in from another instance while the deployment turns them off
|
|
* (`FEDERATION.incoming`), as {@link startLogin} and {@link completeLogin}
|
|
* must first.
|
|
*
|
|
* @throws {APIException} 403 when they are off.
|
|
*/
|
|
function assertIncoming(): void {
|
|
if (!FEDERATION.incoming) {
|
|
throw new APIException(
|
|
403,
|
|
"This instance doesn't take accounts from other instances",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the issuer of the instance `instance` names, by its API's address or
|
|
* its pages' (`resolveIssuer`), checked as a home: not this instance, nor a
|
|
* blocked one.
|
|
*
|
|
* @throws {APIException} 400 when it names no instance or this one, 403 when
|
|
* it is blocked, 502 when it doesn't answer as a gerbeur.
|
|
*/
|
|
async function homeOf(instance: string): Promise<string> {
|
|
const origin = parseInstance(instance);
|
|
if (!origin) throw new APIException(400, "Not an instance's address");
|
|
const blocked = (issuer: string) => getPeerStatus(issuer) === "blocked";
|
|
if (isSelfHost(new URL(origin).host)) {
|
|
throw new APIException(400, "That is this instance");
|
|
}
|
|
// Typed by its API's address, a blocked instance is refused unreached.
|
|
if (blocked(origin)) {
|
|
throw new APIException(403, "This instance is blocked here");
|
|
}
|
|
let issuer: string;
|
|
try {
|
|
issuer = await resolveIssuer(origin);
|
|
} catch (err) {
|
|
console.error(`[federation] ${origin} unreachable:`, err);
|
|
throw new APIException(502, "That instance doesn't answer as a gerbeur");
|
|
}
|
|
if (issuer === ISSUER) throw new APIException(400, "That is this instance");
|
|
if (blocked(issuer)) {
|
|
throw new APIException(403, "This instance is blocked here");
|
|
}
|
|
return issuer;
|
|
}
|
|
|
|
/**
|
|
* Starts a sign-in with an account on the instance `request.instance` names,
|
|
* and returns its `/authorize` page with the request, for
|
|
* `POST /api/federation/logins`. Unless that instance is trusted, or someone
|
|
* here already comes from it, the request must carry a valid invite, which is
|
|
* claimed only once the account is made; {@link completeLogin} makes no
|
|
* account without one.
|
|
*
|
|
* @throws {APIException} 400 when the instance isn't one; 403 when sign-ins
|
|
* from other instances are off, or it is blocked, neither trusted nor
|
|
* invited, or keeps its users from signing in elsewhere; 404 or 409 when the
|
|
* invite doesn't work; 502 when it doesn't answer as a gerbeur.
|
|
*/
|
|
export async function startLogin(
|
|
request: FederationLoginRequest,
|
|
): Promise<string> {
|
|
assertIncoming();
|
|
const issuer = await homeOf(request.instance);
|
|
if (request.inviteToken) {
|
|
validateInvite(request.inviteToken);
|
|
} else if (
|
|
getPeerStatus(issuer) !== "trusted" &&
|
|
// Someone who joined with an invite signs in again without one; whether
|
|
// this user is one of them is only known once their home answers.
|
|
!db.prepare(`SELECT 1 FROM user_identities WHERE issuer = ? LIMIT 1;`)
|
|
.get(issuer)
|
|
) {
|
|
throw new APIException(
|
|
403,
|
|
"Users of this instance need an invite to join",
|
|
);
|
|
}
|
|
|
|
let metadata: InstanceMetadata;
|
|
try {
|
|
metadata = await fetchMetadata(issuer);
|
|
} catch (err) {
|
|
console.error(`[federation] ${issuer} unreachable:`, err);
|
|
throw new APIException(502, "That instance doesn't answer as a gerbeur");
|
|
}
|
|
if (metadata.gerbeur_outgoing === false) {
|
|
throw new APIException(
|
|
403,
|
|
"Accounts on that instance don't sign in to other instances",
|
|
);
|
|
}
|
|
|
|
const state = randomBytes(24).toString("base64url");
|
|
const verifier = randomBytes(48).toString("base64url");
|
|
const nonce = randomBytes(24).toString("base64url");
|
|
const now = new Date();
|
|
transaction(() => {
|
|
db.prepare(`DELETE FROM federation_logins WHERE created_at < ?;`).run(
|
|
new Date(now.getTime() - LOGIN_TTL_MS).toISOString(),
|
|
);
|
|
db.prepare(
|
|
`INSERT INTO federation_logins (state, issuer, code_verifier, nonce, invite_token, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?);`,
|
|
).run(
|
|
state,
|
|
issuer,
|
|
verifier,
|
|
nonce,
|
|
request.inviteToken ?? null,
|
|
now.toISOString(),
|
|
);
|
|
});
|
|
|
|
const url = new URL(metadata.authorization_endpoint);
|
|
url.search = new URLSearchParams({
|
|
response_type: "code",
|
|
scope: "openid profile email",
|
|
client_id: ISSUER,
|
|
redirect_uri: REDIRECT_URI,
|
|
state,
|
|
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
code_challenge_method: "S256",
|
|
nonce,
|
|
}).toString();
|
|
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, 502 when the keys can't
|
|
* be fetched.
|
|
*/
|
|
async function verifyFrom(
|
|
token: string,
|
|
issuer: string,
|
|
): Promise<Record<string, unknown>> {
|
|
const claims = { issuer, audience: ISSUER };
|
|
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;
|
|
}
|
|
}
|
|
console.error(`[federation] a token from ${issuer} failed:`, failure);
|
|
throw new APIException(401, "Invalid token");
|
|
}
|
|
|
|
/**
|
|
* 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, issuer: string): IdentityClaims | null {
|
|
if (
|
|
!isIdentityClaims(claims) ||
|
|
!USERNAME_RE.test(claims.preferred_username) ||
|
|
!EMAIL_RE.test(claims.email)
|
|
) {
|
|
return null;
|
|
}
|
|
if (claims.picture !== undefined) {
|
|
try {
|
|
if (new URL(claims.picture).origin !== issuer) return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return claims;
|
|
}
|
|
|
|
/**
|
|
* Finishes a sign-in begun by {@link startLogin}, for
|
|
* `POST /api/federation/sessions`, and returns the account it signs in to:
|
|
* the one linked to the identity, brought up to date, or a new one. A login
|
|
* works once. A new account needs the home to be trusted, or the invite the
|
|
* login was started with, claimed now.
|
|
*
|
|
* @throws {APIException} 400 when the login is unknown or expired; 401 when
|
|
* the identity token doesn't verify; 403 when sign-ins from other instances
|
|
* are off, or the account may not be created; 409 when its handle is
|
|
* someone's; 502 when the home fails.
|
|
*/
|
|
export async function completeLogin(
|
|
request: FederationSessionRequest,
|
|
): Promise<User> {
|
|
assertIncoming();
|
|
const login = db.prepare(
|
|
`DELETE FROM federation_logins WHERE state = ?
|
|
RETURNING issuer, code_verifier, nonce, invite_token, created_at;`,
|
|
).get(request.state) as
|
|
| {
|
|
issuer: string;
|
|
code_verifier: string;
|
|
nonce: string;
|
|
invite_token: string | null;
|
|
created_at: string;
|
|
}
|
|
| undefined;
|
|
if (
|
|
!login ||
|
|
Date.now() - new Date(login.created_at).getTime() > LOGIN_TTL_MS
|
|
) {
|
|
throw new APIException(400, "This sign-in has expired, start again");
|
|
}
|
|
const issuer = login.issuer;
|
|
|
|
let idToken: string;
|
|
try {
|
|
idToken = await exchangeCode(issuer, {
|
|
code: request.code,
|
|
clientId: ISSUER,
|
|
redirectUri: REDIRECT_URI,
|
|
codeVerifier: login.code_verifier,
|
|
});
|
|
} catch (err) {
|
|
console.error(`[federation] ${issuer} refused a code:`, err);
|
|
throw new APIException(502, "The instance refused the sign-in");
|
|
}
|
|
const payload = await verifyFrom(idToken, issuer);
|
|
if (payload.nonce !== login.nonce || typeof payload.sub !== "string") {
|
|
throw new APIException(401, "Invalid token");
|
|
}
|
|
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(
|
|
`SELECT user_id FROM user_identities WHERE issuer = ? AND subject = ?;`,
|
|
).get(issuer, subject) as { user_id: string } | undefined;
|
|
if (getPeerStatus(issuer) === "blocked") {
|
|
throw new APIException(403, "This instance is blocked here");
|
|
}
|
|
if (identity) {
|
|
return await applyClaims(identity.user_id, issuer, claims);
|
|
}
|
|
|
|
const trusted = getPeerStatus(issuer) === "trusted";
|
|
if (!trusted && !login.invite_token) {
|
|
throw new APIException(
|
|
403,
|
|
"Users of this instance need an invite to join",
|
|
);
|
|
}
|
|
const inviterId = trusted ? null : claimInvite(login.invite_token!);
|
|
let user: User;
|
|
try {
|
|
user = transaction(() => {
|
|
const created = createRemoteUser({
|
|
username: `${claims.preferred_username}@${instanceHost(issuer)}`,
|
|
email: claims.email,
|
|
}, inviterId);
|
|
db.prepare(
|
|
`INSERT INTO user_identities (user_id, issuer, subject, synced_at) VALUES (?, ?, ?, ?);`,
|
|
).run(created.id, issuer, subject, new Date().toISOString());
|
|
return created;
|
|
});
|
|
} catch (err) {
|
|
if (!trusted) releaseInvite(login.invite_token!);
|
|
throw err;
|
|
}
|
|
sendWelcomeEmail(user);
|
|
return await applyClaims(user.id, issuer, claims);
|
|
}
|
|
|
|
/**
|
|
* Brings the account `userId`, from the instance at `issuer`, in step with
|
|
* `claims` and returns it: a new name at home renames it, the old handle
|
|
* staying its alias, and a new email or avatar replaces its own. A failed
|
|
* avatar fetch is logged, and tried again at the next change or sign-in.
|
|
*
|
|
* @throws {APIException} 409 when the new handle is someone else's.
|
|
*/
|
|
async function applyClaims(
|
|
userId: string,
|
|
issuer: string,
|
|
claims: IdentityClaims,
|
|
): Promise<User> {
|
|
let user = getUserById(userId);
|
|
const handle = `${claims.preferred_username}@${instanceHost(issuer)}`;
|
|
if (handle !== user.username) {
|
|
user = setUsername(userId, handle);
|
|
updateClientUsername(userId, handle);
|
|
broadcastUserUpdated(toPublicUser(user));
|
|
}
|
|
if (claims.email !== user.email) {
|
|
user = await updateUser(userId, { email: claims.email });
|
|
}
|
|
|
|
const { picture } = db.prepare(
|
|
`SELECT picture FROM user_identities WHERE user_id = ?;`,
|
|
).get(userId) as { picture: string | null };
|
|
let synced = claims.picture ?? null;
|
|
if (claims.picture && claims.picture !== picture) {
|
|
try {
|
|
user = await setUserAvatar(userId, await fetchAvatar(claims.picture));
|
|
} catch (err) {
|
|
console.error(`[federation] ${handle}'s avatar failed:`, err);
|
|
synced = picture;
|
|
}
|
|
}
|
|
db.prepare(
|
|
`UPDATE user_identities SET picture = ?, synced_at = ? WHERE user_id = ?;`,
|
|
).run(synced, new Date().toISOString(), userId);
|
|
return user;
|
|
}
|
|
|
|
/**
|
|
* 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, 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; 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;
|
|
try {
|
|
issuer = decodeJwt(token).iss;
|
|
} catch {
|
|
throw new APIException(400, "Invalid event");
|
|
}
|
|
if (
|
|
typeof issuer !== "string" ||
|
|
!db.prepare(`SELECT 1 FROM user_identities WHERE issuer = ? LIMIT 1;`)
|
|
.get(issuer)
|
|
) {
|
|
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 = ?;`,
|
|
).get(issuer, String(payload.sub)) as { user_id: string } | undefined;
|
|
if (!identity || !isFederationEvent(payload)) {
|
|
throw new APIException(400, "Invalid event");
|
|
}
|
|
if (payload.type === "revoke") {
|
|
revokeSessions(identity.user_id, new Date(), "Signed out by home");
|
|
return;
|
|
}
|
|
const claims = checkClaims(payload.claims, issuer);
|
|
if (!claims) throw new APIException(400, "Invalid event");
|
|
await applyClaims(identity.user_id, issuer, claims);
|
|
}
|
|
|
|
/**
|
|
* Returns the URL of `path` on the home of `user`, an account from another
|
|
* instance, for `GET /api/users/:key/home`: their profile there by default,
|
|
* or another of its pages, such as `/settings`, where they change their
|
|
* password. A handle's host is its home's issuer, the API's origin, which
|
|
* needn't be its pages': those are where its `/authorize` page is, and the
|
|
* home's metadata says. While it doesn't answer, the issuer stands in.
|
|
*
|
|
* @throws {APIException} 400 when `path` isn't a path; 404 when `user` is
|
|
* local.
|
|
*/
|
|
export async function homePageUrl(user: User, path?: string): Promise<string> {
|
|
const identity = db.prepare(
|
|
`SELECT issuer FROM user_identities WHERE user_id = ?;`,
|
|
).get(user.id) as { issuer: string } | undefined;
|
|
if (!identity) throw new APIException(404, "This account lives here");
|
|
if (path !== undefined && (!path.startsWith("/") || path.startsWith("//"))) {
|
|
throw new APIException(400, "Not a path");
|
|
}
|
|
|
|
let pages = identity.issuer;
|
|
try {
|
|
const { origin, protocol } = new URL(
|
|
(await fetchMetadata(identity.issuer)).authorization_endpoint,
|
|
);
|
|
if (protocol === "https:" || protocol === "http:") pages = origin;
|
|
} catch (err) {
|
|
console.error(`[federation] ${identity.issuer} unreachable:`, err);
|
|
}
|
|
return `${pages}${path ?? userPath(splitHandle(user.username).name)}`;
|
|
}
|