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