A remote user is name@host here, in users.username, in paths (/@alice@music.example) and in mentions, through one handle rule in shared/validation.ts that paths, MENTION_RE and Markdown all build on; Markdown undoes GFM's email autolink of a handle. Their name, email and avatar come from their home at each sign-in and by its signed events (federation_outbox, delivered by the federation job with backoff): a rename at home leaves the old handle an alias here, and a password change or a withdrawn grant ends their sessions here. Their password, email, avatar and name can't change here (assertLocalUser), nor can they sign in elsewhere from here. An admin keeps the trusted and blocked instances on /manage (federation:manage): a trusted instance's users sign in from a button on the login page, a blocked one's can't and are signed out at once, and any other's join with an invite from the register page. The /authorize page asks the user once per instance, and /settings lists the instances their account signs in to, each disconnectable; /login?next= brings a signed-out visitor back to /authorize. Emails stay unique among local accounts only, and migration 0024 adds the tables, makes that index partial and reserves authorize and federation as category slugs. remote/gerbeur-instance.ts is the only way to other instances: https and public addresses only, no redirects, no certificate fallback, unless GERBEUR_FEDERATION_ALLOW_PRIVATE lets two instances on one machine talk. 298 backend tests and 702 frontend tests pass, and deno check, deno fmt, deno lint, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked end to end with two built instances on one machine: sign-in through the other's login and consent pages, consent skipped once granted and asked again after disconnecting, a rename and a password change at home reaching the other, and blocking; screenshots of the login, consent, profile, settings and manage pages at 1280 and 390px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
377 lines
13 KiB
TypeScript
377 lines
13 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. Once here, an account works like any other, but its
|
|
* password, email, avatar and name are its home's (`assertLocalUser`).
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { decodeJwt } 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 FederationLoginRequest,
|
|
type FederationSessionRequest,
|
|
type IdentityClaims,
|
|
instanceHost,
|
|
isFederationEvent,
|
|
isIdentityClaims,
|
|
parseInstance,
|
|
} from "../../shared/federation.ts";
|
|
import { EMAIL_RE, USERNAME_RE } from "../../shared/validation.ts";
|
|
import { toPublicUser, type User } from "../model/user.ts";
|
|
import {
|
|
exchangeCode,
|
|
fetchAvatar,
|
|
fetchKeys,
|
|
fetchMetadata,
|
|
} 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;
|
|
|
|
/**
|
|
* Returns the origin of the instance `instance` names, 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.
|
|
*/
|
|
function homeOf(instance: string): string {
|
|
const issuer = parseInstance(instance);
|
|
if (!issuer) throw new APIException(400, "Not an instance's address");
|
|
if (issuer === ISSUER) throw new APIException(400, "That is this instance");
|
|
if (getPeerStatus(issuer) === "blocked") {
|
|
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 it is
|
|
* blocked, or neither trusted nor invited; 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> {
|
|
const issuer = 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 authorizationEndpoint: string;
|
|
try {
|
|
authorizationEndpoint = (await fetchMetadata(issuer))
|
|
.authorization_endpoint;
|
|
} catch (err) {
|
|
console.error(`[federation] ${issuer} unreachable:`, err);
|
|
throw new APIException(502, "That instance doesn't answer as a gerbeur");
|
|
}
|
|
|
|
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(authorizationEndpoint);
|
|
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 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.
|
|
*/
|
|
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,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[federation] a token from ${issuer} failed:`, err);
|
|
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.
|
|
*/
|
|
function checkClaims(claims: unknown): IdentityClaims {
|
|
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 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 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> {
|
|
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);
|
|
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 may send one, about one of those accounts.
|
|
*
|
|
* @throws {APIException} 400 when it isn't an event of a known instance about
|
|
* one of its users; 401 when it doesn't verify.
|
|
*/
|
|
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");
|
|
}
|
|
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");
|
|
} else {
|
|
await applyClaims(identity.user_id, issuer, checkClaims(payload.claims));
|
|
}
|
|
}
|