diff --git a/API.md b/API.md index e5975895..4c448ec9 100644 --- a/API.md +++ b/API.md @@ -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 diff --git a/README.md b/README.md index de349913..028a2865 100644 --- a/README.md +++ b/README.md @@ -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; diff --git a/api/remote/gerbeur-instance.ts b/api/remote/gerbeur-instance.ts index ddb1ba36..112e89e1 100644 --- a/api/remote/gerbeur-instance.ts +++ b/api/remote/gerbeur-instance.ts @@ -44,6 +44,8 @@ const USER_AGENT = `gerbeur (${OG_SITE_NAME}; federation)`; const metadataCache = new TtlCache(10 * 60_000, 500); const keysCache = new TtlCache(10 * 60_000, 500); const keysRefetched = new TtlCache(60_000, 500); +/** Keeps which issuer an instance's pages belong to ({@link resolveIssuer}). */ +const issuerCache = new TtlCache(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 { } /** - * 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 { - const hit = metadataCache.get(origin); - if (hit) return hit; +async function metadataAt(origin: string): Promise { const body = await readJson( await request(`${origin}/.well-known/openid-configuration`), origin, @@ -113,12 +112,54 @@ export async function fetchMetadata(origin: string): Promise { 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 { + 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 { + 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 { /** Empties the caches, for the tests' reset between cases. */ export function clearInstanceCaches(): void { metadataCache.clear(); + issuerCache.clear(); keysCache.clear(); keysRefetched.clear(); } diff --git a/api/services/federation-peer-service.ts b/api/services/federation-peer-service.ts index 35d38259..efed7191 100644 --- a/api/services/federation-peer-service.ts +++ b/api/services/federation-peer-service.ts @@ -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 { - 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 (?, ?, ?, ?) diff --git a/api/services/federation-service.ts b/api/services/federation-service.ts index 3cd8cf2d..a95f675d 100644 --- a/api/services/federation-service.ts +++ b/api/services/federation-service.ts @@ -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 { + 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 { assertIncoming(); - const issuer = homeOf(request.instance); + const issuer = await homeOf(request.instance); if (request.inviteToken) { validateInvite(request.inviteToken); } else if ( diff --git a/api/test/services/federation-service.test.ts b/api/test/services/federation-service.test.ts index 33558e22..58943936 100644 --- a/api/test/services/federation-service.test.ts +++ b/api/test/services/federation-service.test.ts @@ -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("/api/federation/logins", { + json: { instance: "https://www.home.test" }, + }); + assertEquals(start.status, 200); + const authorizeUrl = start.data.authorizeUrl; + const session = await post("/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); diff --git a/vite.config.ts b/vite.config.ts index 44e81782..3266c3c1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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: {