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

@@ -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 }));