v4: a deployment chooses which ways federated sign-in goes — every instance took users of the others and let its own sign in to them, with no way to turn either off. GERBEUR_FEDERATION_INCOMING=0 keeps users of other instances out: the login and register pages offer no other instance, /manage no list of instances, their sign-ins are refused, and those already in are signed out until it is on again, their homes' events still applied meanwhile. GERBEUR_FEDERATION_OUTGOING=0 keeps the instance's users from signing in elsewhere: its /authorize page lets no instance sign them in, its metadata says so (gerbeur_outgoing), which other instances check before sending anyone there, the settings page lists no instances, and at startup every grant is withdrawn, which signs them out there. Both are on by default and reach the client through index.html. The dev instance gerbeur (:3000) takes no users of the others, while its own still sign in to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 16:18:21 +00:00
parent 66e402bd91
commit 83dc44050e
26 changed files with 420 additions and 121 deletions

View File

@@ -16,7 +16,8 @@
* 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
* 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`).
*
* @module
@@ -25,6 +26,7 @@
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 { type RateLimit, takeToken } from "../lib/rate-limit.ts";
@@ -33,6 +35,7 @@ import {
type FederationSessionRequest,
type IdentityClaims,
instanceHost,
type InstanceMetadata,
isFederationEvent,
isIdentityClaims,
parseInstance,
@@ -73,6 +76,22 @@ const LOGIN_TTL_MS = 10 * 60_000;
*/
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 origin of the instance `instance` names, checked as a home: not
* this instance, nor a blocked one.
@@ -98,13 +117,15 @@ function homeOf(instance: string): string {
* 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.
* @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 = homeOf(request.instance);
if (request.inviteToken) {
validateInvite(request.inviteToken);
@@ -121,14 +142,19 @@ export async function startLogin(
);
}
let authorizationEndpoint: string;
let metadata: InstanceMetadata;
try {
authorizationEndpoint = (await fetchMetadata(issuer))
.authorization_endpoint;
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");
@@ -151,7 +177,7 @@ export async function startLogin(
);
});
const url = new URL(authorizationEndpoint);
const url = new URL(metadata.authorization_endpoint);
url.search = new URLSearchParams({
response_type: "code",
scope: "openid profile email",
@@ -244,12 +270,14 @@ function checkClaims(claims: unknown, issuer: string): IdentityClaims | null {
* 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.
* 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;`,