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
key. With `GERBEUR_FEDERATION_INCOMING=0`, `POST /api/federation/logins` and
`/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.
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
`GERBEUR_PUBLIC_URL` is the instance's https origin (no path) and the instances
can reach each other. 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`:
can reach each other. Users and admins may name an instance by the address of
its API or of its pages: when the frontend runs on a host of its own
(`GERBEUR_FRONTEND_URL`), that host must pass `/.well-known/` on to the API, as
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
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 keysCache = new TtlCache<JSONWebKeySet>(10 * 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
@@ -97,15 +99,12 @@ async function readJson(res: Response, what: string): Promise<unknown> {
}
/**
* Returns the metadata of the instance at `origin`, from its
* `/.well-known/openid-configuration`, cached. It must name `origin` as its
* issuer and speak this protocol's version.
* Returns the metadata `origin` serves at `/.well-known/openid-configuration`,
* whichever issuer it names.
*
* @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> {
const hit = metadataCache.get(origin);
if (hit) return hit;
async function metadataAt(origin: string): Promise<InstanceMetadata> {
const body = await readJson(
await request(`${origin}/.well-known/openid-configuration`),
origin,
@@ -113,12 +112,54 @@ export async function fetchMetadata(origin: string): Promise<InstanceMetadata> {
if (!isInstanceMetadata(body)) {
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) {
throw new Error(`${origin} names itself ${body.issuer}`);
}
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`
* 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. */
export function clearInstanceCaches(): void {
metadataCache.clear();
issuerCache.clear();
keysCache.clear();
keysRefetched.clear();
}

View File

@@ -18,7 +18,8 @@ import {
parseInstance,
type PeerStatus,
} 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";
interface PeerRow {
@@ -64,8 +65,8 @@ export function listPeers(trustedOnly: boolean): FederationPeer[] {
}
/**
* Puts the instance `instance` names on the list of `status`, for `/manage`,
* and returns it. A trusted one must answer as a gerbeur instance, whose name
* Puts the instance `instance` names, by its API's address or its pages'
* (`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
* name it had. Blocking one ends the sessions of its users here at once:
* their tokens stop counting by `getSessionState`, and this closes their
@@ -78,20 +79,27 @@ export async function setPeer(
instance: string,
status: PeerStatus,
): Promise<FederationPeer> {
const issuer = parseInstance(instance);
if (!issuer) throw new APIException(400, "Not an instance's address");
if (issuer === PUBLIC_URL) {
const origin = parseInstance(instance);
if (!origin) throw new APIException(400, "Not an instance's address");
if (isSelfHost(new URL(origin).host)) {
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;
try {
issuer = await resolveIssuer(origin);
name = (await fetchMetadata(issuer)).gerbeur_site_name;
} catch (err) {
console.error(`[federation] ${issuer} doesn't answer:`, err);
console.error(`[federation] ${origin} doesn't answer:`, err);
if (status === "trusted") {
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(
`INSERT INTO federation_peers (issuer, status, name, created_at)
VALUES (?, ?, ?, ?)

View File

@@ -30,6 +30,7 @@ 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 { isSelfHost } from "../lib/self-url.ts";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
import {
type FederationLoginRequest,
@@ -49,6 +50,7 @@ import {
fetchAvatar,
fetchKeys,
fetchMetadata,
resolveIssuer,
} from "../remote/gerbeur-instance.ts";
import { ISSUER, REDIRECT_URI } from "./identity-provider-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
* this instance, nor a blocked one.
* Returns the issuer of the instance `instance` names, by its API's address or
* 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
* it is blocked.
* it is blocked, 502 when it doesn't answer as a gerbeur.
*/
function homeOf(instance: string): string {
const issuer = parseInstance(instance);
if (!issuer) throw new APIException(400, "Not an instance's address");
async function homeOf(instance: string): Promise<string> {
const origin = parseInstance(instance);
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 (getPeerStatus(issuer) === "blocked") {
if (blocked(issuer)) {
throw new APIException(403, "This instance is blocked here");
}
return issuer;
@@ -128,7 +146,7 @@ export async function startLogin(
request: FederationLoginRequest,
): Promise<string> {
assertIncoming();
const issuer = homeOf(request.instance);
const issuer = await homeOf(request.instance);
if (request.inviteToken) {
validateInvite(request.inviteToken);
} 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 () => {
const home = await fakeInstance(HOME);
using _net = network(home);

View File

@@ -118,6 +118,14 @@ export default defineConfig({
watch: {
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: {
rollupOptions: {