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

@@ -232,3 +232,26 @@ export const ALLOWED_ORIGINS: string[] = Array.from(
*/
export const FEDERATION_ALLOW_PRIVATE: boolean =
Deno.env.get("GERBEUR_FEDERATION_ALLOW_PRIVATE")?.trim() === "1";
/**
* Which ways federated sign-in goes, each on unless set to `0`.
*
* - `incoming` (`GERBEUR_FEDERATION_INCOMING`): users of other instances sign
* in here. Off, the login and register pages offer no other instance,
* `/manage` no list of instances to trust or block,
* `federation-service.ts` refuses their sign-ins, and the accounts that came
* from one are signed out (`getSessionState`) until it is on again. Their
* homes' events are still applied, so that none is lost meanwhile.
* - `outgoing` (`GERBEUR_FEDERATION_OUTGOING`): this instance's users sign in
* to others. Off, `identity-provider-service.ts` lets no instance sign them
* in, and its metadata says so, which the others check before sending
* anyone here to sign in. The settings page lists no instances, since at
* startup `main.ts` withdraws every grant they gave, which signs them out
* there.
*
* index.html tells the client both. An object, so that the tests can turn one
* off for a while.
*/
export const FEDERATION: { incoming: boolean; outgoing: boolean } = {
incoming: Deno.env.get("GERBEUR_FEDERATION_INCOMING")?.trim() !== "0",
outgoing: Deno.env.get("GERBEUR_FEDERATION_OUTGOING")?.trim() !== "0",
};

View File

@@ -10,6 +10,7 @@ import {
BANDCAMP_PLAYER,
DEFAULT_STYLE,
EMAIL_ENABLED,
FEDERATION,
OG_SITE_NAME,
SITE_EMOJI,
} from "../config.ts";
@@ -28,7 +29,8 @@ const indexHtml = new Map<string, { mtime: number; html: string }>();
/**
* Returns `${root}/index.html` with the site name, emoji, icon version,
* Bandcamp player mode, default style and whether email is on filled in.
* Bandcamp player mode, default style, and whether email and each way of
* federated sign-in are on filled in.
*
* @throws {Error} When the file can't be read.
*/
@@ -43,7 +45,9 @@ export async function loadIndexHtml(root: string): Promise<string> {
.replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
.replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE)
.replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED ? "on" : "off");
.replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED ? "on" : "off")
.replaceAll("__FEDERATION_INCOMING__", FEDERATION.incoming ? "on" : "off")
.replaceAll("__FEDERATION_OUTGOING__", FEDERATION.outgoing ? "on" : "off");
indexHtml.set(root, { mtime, html });
return html;
}

View File

@@ -1,6 +1,8 @@
/**
* Builds the Oak application and, when run directly, starts the recurring
* jobs and listens on {@link LISTEN_HOST}:{@link PORT}. A request goes
* jobs and listens on {@link LISTEN_HOST}:{@link PORT}, having first signed
* the users out of other instances if they may no longer sign in there
* (`FEDERATION.outgoing`). A request goes
* through the error middleware, the OG tags and CORS, then the routers, one
* per resource of the API (`API.md` lists them), and last the built frontend,
* which answers every path no router did.
@@ -37,7 +39,13 @@ import usersRouter from "./routes/users.ts";
import wellKnownRouter from "./routes/well-known.ts";
import wsRouter from "./routes/ws.ts";
import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts";
import {
ALLOWED_ORIGINS,
FEDERATION,
LISTEN_HOST,
PORT,
PUBLIC_URL,
} from "./config.ts";
import { errorMiddleware } from "./middleware/error.ts";
import { ogMiddleware } from "./middleware/og.ts";
import { routeStaticFilesFrom } from "./lib/static.ts";
@@ -46,7 +54,10 @@ import { rateLimitSweepJob } from "./lib/rate-limit.ts";
import { digestJob } from "./services/digest-service.ts";
import { communityJob } from "./services/community-service.ts";
import { pingJob, typingExpiryJob } from "./services/ws-service.ts";
import { outboxJob } from "./services/identity-provider-service.ts";
import {
outboxJob,
withdrawAllGrants,
} from "./services/identity-provider-service.ts";
/**
* The whole server, exported so the tests' helpers can send it requests
@@ -115,6 +126,12 @@ app.addEventListener(
);
if (import.meta.main) {
if (!FEDERATION.outgoing) {
const withdrawn = await withdrawAllGrants();
if (withdrawn > 0) {
console.log(`Signed users out of other instances (${withdrawn} grants)`);
}
}
startJobs([
digestJob,
communityJob,

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

View File

@@ -28,6 +28,7 @@ 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,
@@ -102,6 +103,7 @@ export function getInstanceMetadata(): InstanceMetadata {
gerbeur_site_name: OG_SITE_NAME,
gerbeur_redirect_uri: REDIRECT_URI,
gerbeur_events_endpoint: `${PUBLIC_URL}/api/federation/events`,
gerbeur_outgoing: FEDERATION.outgoing,
};
}
@@ -143,16 +145,24 @@ export function identityClaims(user: User): IdentityClaims {
/**
* 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.
* 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 the client
* is blocked here; 502 when it doesn't answer as a gerbeur.
* 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,
@@ -362,6 +372,24 @@ export async function revokeGrant(userId: string, host: string): Promise<void> {
).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`

View File

@@ -31,7 +31,7 @@ import {
updateClientAvatar,
} from "./ws-service.ts";
import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts";
import { AVATARS_DIR } from "../config.ts";
import { AVATARS_DIR, FEDERATION } from "../config.ts";
import { hashPassword } from "../lib/password.ts";
import { linkAttachments } from "./attachment-service.ts";
@@ -187,14 +187,15 @@ export function getUserById(userId: string): User {
/**
* Returns the user's live role and their last password change, before which
* their tokens don't count, or undefined when the user is gone, or comes from
* an instance an admin blocked. `lib/auth.ts` checks every session and media
* token against it.
* another instance while an admin blocks it or the deployment turns sign-ins
* from other instances off (`FEDERATION.incoming`). `lib/auth.ts` checks
* every session and media token against it.
*/
export function getSessionState(
userId: string,
): { role: Role; tokensValidAfter: string | null } | undefined {
const row = db.prepare(
`SELECT u.role, u.tokens_valid_after, p.status AS peer_status
`SELECT u.role, u.tokens_valid_after, ui.issuer, p.status AS peer_status
FROM users u
LEFT JOIN user_identities ui ON ui.user_id = u.id
LEFT JOIN federation_peers p ON p.issuer = ui.issuer
@@ -203,10 +204,12 @@ export function getSessionState(
| {
role: Role;
tokens_valid_after: string | null;
issuer: string | null;
peer_status: string | null;
}
| undefined;
if (!row || row.peer_status === "blocked") return undefined;
if (row.issuer && !FEDERATION.incoming) return undefined;
return {
role: row.role,
tokensValidAfter: row.tokens_valid_after,

View File

@@ -19,6 +19,7 @@ import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { clearRichContentCache } from "../services/rich-content-service.ts";
import { clearImageCache } from "../remote/image-proxy.ts";
import { clearInstanceCaches } from "../remote/gerbeur-instance.ts";
import { FEDERATION } from "../config.ts";
import {
type OutgoingEmail,
setEmailTransport,
@@ -464,8 +465,14 @@ export interface FakeInstance {
handle(url: URL, init?: RequestInit): Promise<Response> | null;
}
/** Makes a {@link FakeInstance} at `origin` with a fresh key. */
export async function fakeInstance(origin: string): Promise<FakeInstance> {
/**
* Makes a {@link FakeInstance} at `origin` with a fresh key, its metadata
* with `metadata` over the defaults.
*/
export async function fakeInstance(
origin: string,
metadata: Record<string, unknown> = {},
): Promise<FakeInstance> {
const { kid, privateJwk, publicJwk } = await generateInstanceKey();
const key = await importInstanceKey(kid, privateJwk);
const codes = new Map<
@@ -496,6 +503,7 @@ export async function fakeInstance(origin: string): Promise<FakeInstance> {
gerbeur_site_name: `Fake ${new URL(origin).host}`,
gerbeur_redirect_uri: `${origin}/federation/callback`,
gerbeur_events_endpoint: `${origin}/api/federation/events`,
...metadata,
});
case "/.well-known/jwks.json":
return json({ keys: [publicJwk] });
@@ -564,6 +572,20 @@ export async function fakeInstance(origin: string): Promise<FakeInstance> {
};
}
/**
* Turns one way of federated sign-in off for the life of the returned handle
* (`using _off = federationOff("incoming")`), as `GERBEUR_FEDERATION_*=0`
* would for the server's life.
*/
export function federationOff(way: keyof typeof FEDERATION): Disposable {
FEDERATION[way] = false;
return {
[Symbol.dispose]() {
FEDERATION[way] = true;
},
};
}
// ── Email ────────────────────────────────────────────────────────────────────
/**

View File

@@ -8,11 +8,11 @@ Deno.test("loadIndexHtml: fills the placeholders, and rereads a rebuilt file", a
const path = `${root}/index.html`;
await Deno.writeTextFile(
path,
"<title>__SITE_NAME__</title><body>__BANDCAMP_PLAYER__ __DEFAULT_STYLE__ __EMAIL_ENABLED__</body>",
"<title>__SITE_NAME__</title><body>__BANDCAMP_PLAYER__ __DEFAULT_STYLE__ __EMAIL_ENABLED__ __FEDERATION_INCOMING__ __FEDERATION_OUTGOING__</body>",
);
assertEquals(
await loadIndexHtml(root),
"<title>gerbeur</title><body>embed smooth off</body>",
"<title>gerbeur</title><body>embed smooth off on on</body>",
);
await Deno.writeTextFile(path, "<title>rebuilt __SITE_NAME__</title>");

View File

@@ -1,9 +1,11 @@
import {
count,
createUser,
db,
del,
type FakeInstance,
fakeInstance,
federationOff,
formWithFile,
get,
patch,
@@ -193,6 +195,48 @@ test("federation: blocking an instance refuses its users and ends their sessions
assertEquals((await get("/api/me", { auth })).status, 200);
});
test("federation: with sign-ins from other instances off, none starts or finishes, and those in are out until it is on", async () => {
const home = await fakeInstance(HOME);
using _net = network(home);
await trust(HOME);
const { token } = (await signIn(home, ALICE)).data as AuthResponse;
const auth = { Authorization: `Bearer ${token}` };
const started = await post<FederationLoginResponse>(
"/api/federation/logins",
{ json: { instance: "home.test" } },
);
const state = new URL(started.data.authorizeUrl).searchParams.get("state");
const code = home.issue(started.data.authorizeUrl, ALICE);
{
using _off = federationOff("incoming");
assertEquals((await get("/api/me", { auth })).status, 401);
assertEquals(
(await post("/api/federation/logins", {
json: { instance: "home.test" },
})).status,
403,
);
assertEquals(
(await post("/api/federation/sessions", { json: { code, state } }))
.status,
403,
);
}
assertEquals((await get("/api/me", { auth })).status, 200);
});
test("federation: a home whose users don't sign in elsewhere is refused before the user leaves", async () => {
const home = await fakeInstance(HOME, { gerbeur_outgoing: false });
using _net = network(home);
await trust(HOME);
const start = await post("/api/federation/logins", {
json: { instance: "home.test" },
});
assertEquals(start.status, 403);
assertEquals(count(`SELECT COUNT(*) AS n FROM federation_logins;`), 0);
});
test("federation: only admins edit the peers, which must answer as gerbeurs", async () => {
const home = await fakeInstance(HOME);
using _net = network(home);

View File

@@ -6,6 +6,7 @@ import {
del,
type FakeInstance,
fakeInstance,
federationOff,
formWithFile,
get,
patch,
@@ -25,6 +26,7 @@ import {
import {
deliverOutbox,
identityClaims,
withdrawAllGrants,
} from "../../services/identity-provider-service.ts";
import { getUserById } from "../../services/user-service.ts";
import type {
@@ -105,6 +107,7 @@ test("identity provider: metadata and keys are served at the well-known paths",
assertEquals(body.authorization_endpoint, `${BASE_URL}/authorize`);
assertEquals(body.gerbeur_redirect_uri, `${BASE_URL}/federation/callback`);
assertEquals(body.gerbeur_federation, 1);
assertEquals(body.gerbeur_outgoing, true);
const jwks = await get("/.well-known/jwks.json");
const keys = JSON.parse(new TextDecoder().decode(jwks.bytes)).keys;
assertEquals(keys[0].crv, "Ed25519");
@@ -260,6 +263,40 @@ test("identity provider: the instances a user signed in to hear of their changes
);
});
test("identity provider: with sign-ins elsewhere off, no instance signs the users in, and their grants are withdrawn", async () => {
const fake = await fakeInstance(CLIENT);
using _net = network(fake);
const user = await createUser();
const { verifier, challenge } = pkce();
const pending = new URL(
(await authorize(user.auth, challenge)).data.redirectTo,
).searchParams.get("code")!;
using _off = federationOff("outgoing");
const meta = await get("/.well-known/openid-configuration");
assertEquals(
(JSON.parse(new TextDecoder().decode(meta.bytes)) as InstanceMetadata)
.gerbeur_outgoing,
false,
);
assertEquals(
(await get(`/api/oauth/clients?client_id=${encodeURIComponent(CLIENT)}`, {
auth: user.auth,
})).status,
403,
);
assertEquals((await authorize(user.auth, challenge)).status, 403);
assertEquals(await withdrawAllGrants(), 1);
await until(() => fake.received.length === 1);
assertEquals(decodeJwt(fake.received[0]).type, "revoke");
assertEquals((await trade(pending, verifier)).status, 400);
assertEquals(
(await get<FederationGrant[]>("/api/me/grants", { auth: user.auth })).data,
[],
);
});
test("identity provider: an event an instance fails to take waits in the outbox", async () => {
const fake = await fakeInstance(CLIENT);
using _net = network(fake, () => new Response("", { status: 503 }));