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:
6
API.md
6
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
|
||||
|
||||
11
README.md
11
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 (?, ?, ?, ?)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user