v4: an instance can be named by the address of its pages — sign-in and the trust list took only an instance's issuer, its API's origin, so where the pages live elsewhere the address people see in their browser named no instance: in development, localhost:3000 answered its /.well-known with index.html, and the sign-in failed as "doesn't answer as a gerbeur". resolveIssuer (remote/gerbeur-instance.ts) now reads the metadata at the address given and, when it names another issuer, takes it only if that issuer's own metadata names those pages back as where its /authorize is, so an impostor can't claim one; sign-in, the trust list and the check against this instance go through it. The Vite dev server passes /.well-known on to its API, as whatever serves the pages must in production (README). A handle still carries the API's host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 22:14:21 +00:00
parent 9608520675
commit f1bc23a65e
7 changed files with 161 additions and 27 deletions

6
API.md
View File

@@ -218,7 +218,11 @@ account on another gerbeur instance, their home, by OpenID Connect's
authorization code flow with PKCE, with tokens signed by each instance's Ed25519 authorization code flow with PKCE, with tokens signed by each instance's Ed25519
key. With `GERBEUR_FEDERATION_INCOMING=0`, `POST /api/federation/logins` and key. With `GERBEUR_FEDERATION_INCOMING=0`, `POST /api/federation/logins` and
`/sessions` answer 403; with `GERBEUR_FEDERATION_OUTGOING=0`, `/sessions` answer 403; with `GERBEUR_FEDERATION_OUTGOING=0`,
`GET /api/oauth/clients` and `POST /api/oauth/codes` do. `GET /api/oauth/clients` and `POST /api/oauth/codes` do. An instance is named by
its issuer, its API's origin, whose host is the one in its users' handles. Where
`instance` is asked for, the origin of its pages works too: their
`/.well-known/openid-configuration` names the issuer, whose own metadata must
name those pages back as where its `/authorize` is.
- `GET /.well-known/openid-configuration`, `/.well-known/jwks.json` — anyone. - `GET /.well-known/openid-configuration`, `/.well-known/jwks.json` — anyone.
The instance's metadata and public keys, bare JSON: the one exception to the The instance's metadata and public keys, bare JSON: the one exception to the

View File

@@ -152,10 +152,13 @@ home instance, and the others follow its changes.
Every instance is ready for it: nothing to configure, as long as Every instance is ready for it: nothing to configure, as long as
`GERBEUR_PUBLIC_URL` is the instance's https origin (no path) and the instances `GERBEUR_PUBLIC_URL` is the instance's https origin (no path) and the instances
can reach each other. Either way can be turned off: can reach each other. Users and admins may name an instance by the address of
`GERBEUR_FEDERATION_INCOMING=0` takes no users of other instances, and its API or of its pages: when the frontend runs on a host of its own
`GERBEUR_FEDERATION_OUTGOING=0` lets no user sign in elsewhere. An admin decides (`GERBEUR_FRONTEND_URL`), that host must pass `/.well-known/` on to the API, as
who comes in, on `/manage`: the Vite dev server does. A handle carries the API's host. Either way can be
turned off: `GERBEUR_FEDERATION_INCOMING=0` takes no users of other instances,
and `GERBEUR_FEDERATION_OUTGOING=0` lets no user sign in elsewhere. An admin
decides who comes in, on `/manage`:
- users of a **trusted** instance sign in directly, from a button on the login - users of a **trusted** instance sign in directly, from a button on the login
page; page;

View File

@@ -44,6 +44,8 @@ const USER_AGENT = `gerbeur (${OG_SITE_NAME}; federation)`;
const metadataCache = new TtlCache<InstanceMetadata>(10 * 60_000, 500); const metadataCache = new TtlCache<InstanceMetadata>(10 * 60_000, 500);
const keysCache = new TtlCache<JSONWebKeySet>(10 * 60_000, 500); const keysCache = new TtlCache<JSONWebKeySet>(10 * 60_000, 500);
const keysRefetched = new TtlCache<true>(60_000, 500); const keysRefetched = new TtlCache<true>(60_000, 500);
/** Keeps which issuer an instance's pages belong to ({@link resolveIssuer}). */
const issuerCache = new TtlCache<string>(10 * 60_000, 500);
/** /**
* Checks that `url` may be reached: https on a public address, or anything * Checks that `url` may be reached: https on a public address, or anything
@@ -97,15 +99,12 @@ async function readJson(res: Response, what: string): Promise<unknown> {
} }
/** /**
* Returns the metadata of the instance at `origin`, from its * Returns the metadata `origin` serves at `/.well-known/openid-configuration`,
* `/.well-known/openid-configuration`, cached. It must name `origin` as its * whichever issuer it names.
* issuer and speak this protocol's version.
* *
* @throws {Error} When it can't be reached or isn't a gerbeur instance. * @throws {Error} When it can't be reached or isn't a gerbeur instance's.
*/ */
export async function fetchMetadata(origin: string): Promise<InstanceMetadata> { async function metadataAt(origin: string): Promise<InstanceMetadata> {
const hit = metadataCache.get(origin);
if (hit) return hit;
const body = await readJson( const body = await readJson(
await request(`${origin}/.well-known/openid-configuration`), await request(`${origin}/.well-known/openid-configuration`),
origin, origin,
@@ -113,12 +112,54 @@ export async function fetchMetadata(origin: string): Promise<InstanceMetadata> {
if (!isInstanceMetadata(body)) { if (!isInstanceMetadata(body)) {
throw new Error(`${origin} is not a gerbeur instance this one can trust`); throw new Error(`${origin} is not a gerbeur instance this one can trust`);
} }
return body;
}
/**
* Returns the metadata of the instance at `origin`, its issuer, cached. It
* must name `origin` as its issuer and speak this protocol's version.
*
* @throws {Error} When it can't be reached or isn't a gerbeur instance.
*/
export async function fetchMetadata(origin: string): Promise<InstanceMetadata> {
const hit = metadataCache.get(origin);
if (hit) return hit;
const body = await metadataAt(origin);
if (body.issuer !== origin) { if (body.issuer !== origin) {
throw new Error(`${origin} names itself ${body.issuer}`); throw new Error(`${origin} names itself ${body.issuer}`);
} }
return metadataCache.set(origin, body); return metadataCache.set(origin, body);
} }
/**
* Returns the issuer of the instance at `origin`, cached: `origin` itself, or,
* when `origin` is where the instance's pages are and its API lives at
* another, the issuer the pages' metadata names, whose own metadata must name
* those pages back as where its `/authorize` is. People know an instance by
* the address in their browser, which a sign-in form or the trust list may be
* given; a handle and the stored identities name the issuer.
*
* @throws {Error} When `origin` can't be reached, isn't a gerbeur instance,
* or names an issuer that doesn't name it back.
*/
export async function resolveIssuer(origin: string): Promise<string> {
const hit = issuerCache.get(origin);
if (hit) return hit;
if (metadataCache.get(origin)) return origin;
const body = await metadataAt(origin);
if (body.issuer === origin) {
metadataCache.set(origin, body);
return issuerCache.set(origin, origin);
}
const pages =
new URL((await fetchMetadata(body.issuer)).authorization_endpoint)
.origin;
if (pages !== origin) {
throw new Error(`${origin} names ${body.issuer}, whose pages are ${pages}`);
}
return issuerCache.set(origin, body.issuer);
}
/** /**
* Returns the public keys of the instance at `origin`, cached unless `fresh` * Returns the public keys of the instance at `origin`, cached unless `fresh`
* asks to refetch them and they weren't in the last minute. * asks to refetch them and they weren't in the last minute.
@@ -224,6 +265,7 @@ export async function fetchAvatar(url: string): Promise<Uint8Array> {
/** Empties the caches, for the tests' reset between cases. */ /** Empties the caches, for the tests' reset between cases. */
export function clearInstanceCaches(): void { export function clearInstanceCaches(): void {
metadataCache.clear(); metadataCache.clear();
issuerCache.clear();
keysCache.clear(); keysCache.clear();
keysRefetched.clear(); keysRefetched.clear();
} }

View File

@@ -18,7 +18,8 @@ import {
parseInstance, parseInstance,
type PeerStatus, type PeerStatus,
} from "../../shared/federation.ts"; } from "../../shared/federation.ts";
import { fetchMetadata } from "../remote/gerbeur-instance.ts"; import { fetchMetadata, resolveIssuer } from "../remote/gerbeur-instance.ts";
import { isSelfHost } from "../lib/self-url.ts";
import { closeUserSockets } from "./ws-service.ts"; import { closeUserSockets } from "./ws-service.ts";
interface PeerRow { interface PeerRow {
@@ -64,8 +65,8 @@ export function listPeers(trustedOnly: boolean): FederationPeer[] {
} }
/** /**
* Puts the instance `instance` names on the list of `status`, for `/manage`, * Puts the instance `instance` names, by its API's address or its pages'
* and returns it. A trusted one must answer as a gerbeur instance, whose name * (`resolveIssuer`), on the list of `status`, for `/manage`, and returns it. A trusted one must answer as a gerbeur instance, whose name
* is kept; a blocked one is blocked whether it answers or not, keeping the * is kept; a blocked one is blocked whether it answers or not, keeping the
* name it had. Blocking one ends the sessions of its users here at once: * name it had. Blocking one ends the sessions of its users here at once:
* their tokens stop counting by `getSessionState`, and this closes their * their tokens stop counting by `getSessionState`, and this closes their
@@ -78,20 +79,27 @@ export async function setPeer(
instance: string, instance: string,
status: PeerStatus, status: PeerStatus,
): Promise<FederationPeer> { ): Promise<FederationPeer> {
const issuer = parseInstance(instance); const origin = parseInstance(instance);
if (!issuer) throw new APIException(400, "Not an instance's address"); if (!origin) throw new APIException(400, "Not an instance's address");
if (issuer === PUBLIC_URL) { if (isSelfHost(new URL(origin).host)) {
throw new APIException(400, "That is this instance"); throw new APIException(400, "That is this instance");
} }
// An instance is listed by its issuer, which its pages' address leads to;
// one that doesn't answer may still be blocked, by the address given.
let issuer = origin;
let name: string | null = null; let name: string | null = null;
try { try {
issuer = await resolveIssuer(origin);
name = (await fetchMetadata(issuer)).gerbeur_site_name; name = (await fetchMetadata(issuer)).gerbeur_site_name;
} catch (err) { } catch (err) {
console.error(`[federation] ${issuer} doesn't answer:`, err); console.error(`[federation] ${origin} doesn't answer:`, err);
if (status === "trusted") { if (status === "trusted") {
throw new APIException(502, "That address doesn't answer as a gerbeur"); throw new APIException(502, "That address doesn't answer as a gerbeur");
} }
} }
if (issuer === PUBLIC_URL) {
throw new APIException(400, "That is this instance");
}
db.prepare( db.prepare(
`INSERT INTO federation_peers (issuer, status, name, created_at) `INSERT INTO federation_peers (issuer, status, name, created_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)

View File

@@ -30,6 +30,7 @@ import { APIException } from "../lib/api-exception.ts";
import { FEDERATION } from "../config.ts"; import { FEDERATION } from "../config.ts";
import { db, transaction } from "../db/connection.ts"; import { db, transaction } from "../db/connection.ts";
import { verifyInstanceToken } from "../lib/jwt.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 RateLimit, takeToken } from "../lib/rate-limit.ts";
import { import {
type FederationLoginRequest, type FederationLoginRequest,
@@ -49,6 +50,7 @@ import {
fetchAvatar, fetchAvatar,
fetchKeys, fetchKeys,
fetchMetadata, fetchMetadata,
resolveIssuer,
} from "../remote/gerbeur-instance.ts"; } from "../remote/gerbeur-instance.ts";
import { ISSUER, REDIRECT_URI } from "./identity-provider-service.ts"; import { ISSUER, REDIRECT_URI } from "./identity-provider-service.ts";
import { getPeerStatus } from "./federation-peer-service.ts"; import { getPeerStatus } from "./federation-peer-service.ts";
@@ -95,17 +97,33 @@ function assertIncoming(): void {
} }
/** /**
* Returns the origin of the instance `instance` names, checked as a home: not * Returns the issuer of the instance `instance` names, by its API's address or
* this instance, nor a blocked one. * 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 * @throws {APIException} 400 when it names no instance or this one, 403 when
* it is blocked. * it is blocked, 502 when it doesn't answer as a gerbeur.
*/ */
function homeOf(instance: string): string { async function homeOf(instance: string): Promise<string> {
const issuer = parseInstance(instance); const origin = parseInstance(instance);
if (!issuer) throw new APIException(400, "Not an instance's address"); 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 (issuer === ISSUER) throw new APIException(400, "That is this instance");
if (getPeerStatus(issuer) === "blocked") { if (blocked(issuer)) {
throw new APIException(403, "This instance is blocked here"); throw new APIException(403, "This instance is blocked here");
} }
return issuer; return issuer;
@@ -128,7 +146,7 @@ export async function startLogin(
request: FederationLoginRequest, request: FederationLoginRequest,
): Promise<string> { ): Promise<string> {
assertIncoming(); assertIncoming();
const issuer = homeOf(request.instance); const issuer = await homeOf(request.instance);
if (request.inviteToken) { if (request.inviteToken) {
validateInvite(request.inviteToken); validateInvite(request.inviteToken);
} else if ( } else if (

View File

@@ -156,6 +156,57 @@ test("federation: a remote user's home link leads to its pages, not its API", as
); );
}); });
test("federation: an instance is reached by its pages' address too, which its API must name back", async () => {
const home = await fakeInstance(HOME, {
authorization_endpoint: "https://www.home.test/authorize",
});
// Its pages pass `/.well-known` on to its API, as a proxy would; so does an
// impostor's, which the home doesn't name.
const proxy = (origin: string): FakeInstance => ({
...home,
origin,
handle: (url, init) =>
url.origin === origin
? home.handle(new URL(url.pathname + url.search, HOME), init)
: null,
});
using _net = network(
home,
proxy("https://www.home.test"),
proxy("https://evil.test"),
);
const peer = await trust("www.home.test");
assertEquals(peer.data.host, "home.test");
const start = await post<FederationLoginResponse>("/api/federation/logins", {
json: { instance: "https://www.home.test" },
});
assertEquals(start.status, 200);
const authorizeUrl = start.data.authorizeUrl;
const session = await post<AuthResponse>("/api/federation/sessions", {
json: {
code: home.issue(authorizeUrl, ALICE),
state: new URL(authorizeUrl).searchParams.get("state"),
},
});
assertEquals(session.data.user.username, "alice@home.test");
assertEquals(
(await post("/api/federation/logins", { json: { instance: "evil.test" } }))
.status,
502,
);
assertEquals((await trust("evil.test")).status, 502);
// Nor is this instance another, by its API's address or its pages'.
assertEquals(
(await post("/api/federation/logins", {
json: { instance: "gerbeur.test" },
}))
.status,
400,
);
});
test("federation: a login works once, with the code the home made for it", async () => { test("federation: a login works once, with the code the home made for it", async () => {
const home = await fakeInstance(HOME); const home = await fakeInstance(HOME);
using _net = network(home); using _net = network(home);

View File

@@ -118,6 +118,14 @@ export default defineConfig({
watch: { watch: {
ignored: ["**/api/**"], ignored: ["**/api/**"],
}, },
// Another instance may know this one by its pages' address, and reads its
// metadata there (resolveIssuer in api/remote/gerbeur-instance.ts), which
// the API serves; in production, whatever serves the pages passes it on.
proxy: {
"/.well-known": `${process.env.VITE_API_PROTOCOL || "http"}://${
process.env.VITE_API_HOSTNAME || "localhost"
}:${process.env.VITE_API_PORT || "8000"}`,
},
}, },
build: { build: {
rollupOptions: { rollupOptions: {