Receiving, a home's keys that can't be fetched answer 502, which the sender retries, instead of 401, which it dropped; a bad update answers 400 rather than 502; a blocked instance's events answer 403; events are limited to a burst of 120 per instance, and a token naming an unknown key refetches the keys at most once a minute. An avatar must be on its issuer's origin, and its URL is versioned by its file's write time instead of updatedAt, so a profile edit no longer makes clients download it again. An instance that doesn't answer can be blocked, keeping its name, though not trusted. 303 backend tests and 703 frontend tests pass, the federation tests 8 times in a row, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
5.3 KiB
TypeScript
153 lines
5.3 KiB
TypeScript
/**
|
|
* Lets users of other gerbeur instances sign in here with their account
|
|
* there, as `federation-service.ts` describes, and lets admins pick the
|
|
* instances to trust or block. The login and register pages start a sign-in,
|
|
* the `/federation/callback` page finishes it, `/manage` edits the peers, and
|
|
* the other instances' servers send their events.
|
|
*
|
|
* - `POST /api/federation/logins` — public. Starts a sign-in with an account
|
|
* on another instance (`FederationLoginRequest`, with an invite unless it
|
|
* is trusted), and answers its `/authorize` page to go to. Rate-limited per
|
|
* instance.
|
|
* - `POST /api/federation/sessions` — public. Finishes it
|
|
* (`FederationSessionRequest`) and answers the user and their tokens, as a
|
|
* sign-in does. Each sign-in started finishes once, so the limit on
|
|
* `/logins` bounds it.
|
|
* - `POST /api/federation/events` — public, server to server. Applies a
|
|
* home instance's signed event about one of its users (`{ event }`).
|
|
* Rate-limited per instance; a 4xx tells the sender to give up on the
|
|
* event, a 429 or a 5xx to try again.
|
|
* - `GET /api/federation/peers` — optional sign-in. Answers the trusted
|
|
* instances, or every listed one to `federation:manage`.
|
|
* - `PUT /api/federation/peers` — permission `federation:manage`. Trusts or
|
|
* blocks an instance (`SetPeerRequest`) and answers it; 502 when one to
|
|
* trust doesn't answer as a gerbeur.
|
|
* - `DELETE /api/federation/peers/:host` — permission `federation:manage`.
|
|
* Takes an instance off the lists.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { Router } from "@oak/oak";
|
|
import type { APIResponse } from "../../shared/api.ts";
|
|
import {
|
|
type FederationLoginResponse,
|
|
type FederationPeer,
|
|
parseInstance,
|
|
} from "../../shared/federation.ts";
|
|
import type { AuthResponse } from "../../shared/user.ts";
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import { parseOptionalAuth } from "../lib/auth.ts";
|
|
import { issueTokens } from "../lib/jwt.ts";
|
|
import { hasPermission } from "../../shared/permissions.ts";
|
|
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
|
|
import {
|
|
type AuthContext,
|
|
authMiddleware,
|
|
requirePermission,
|
|
} from "../middleware/auth.ts";
|
|
import { toSelfUser } from "../model/user.ts";
|
|
import {
|
|
isFederationLoginRequest,
|
|
isFederationSessionRequest,
|
|
isSetPeerRequest,
|
|
} from "../validators/federation.ts";
|
|
import {
|
|
completeLogin,
|
|
receiveEvent,
|
|
startLogin,
|
|
} from "../services/federation-service.ts";
|
|
import {
|
|
listPeers,
|
|
removePeer,
|
|
setPeer,
|
|
} from "../services/federation-peer-service.ts";
|
|
import { getUserById } from "../services/user-service.ts";
|
|
import { fieldOf } from "../validators/user.ts";
|
|
|
|
/**
|
|
* Allows a burst of 30 sign-ins per instance, then one every 2 seconds, so
|
|
* that nobody makes this instance hammer another.
|
|
*/
|
|
const LOGIN_LIMIT: RateLimit = { capacity: 30, refill: 1 / 2 };
|
|
|
|
/**
|
|
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
|
* An `APIException` thrown by a handler becomes the JSON error response there.
|
|
*/
|
|
const router: Router = new Router({ prefix: "/api/federation" });
|
|
|
|
router.post("/logins", async (ctx) => {
|
|
const body: unknown = await ctx.request.body.json();
|
|
if (!isFederationLoginRequest(body)) {
|
|
throw new APIException(400, "Invalid request");
|
|
}
|
|
if (!takeToken(`federation:${parseInstance(body.instance)}`, LOGIN_LIMIT)) {
|
|
throw new APIException(429, "Too many sign-ins — try again in a minute");
|
|
}
|
|
const responseBody: APIResponse<FederationLoginResponse> = {
|
|
success: true,
|
|
data: { authorizeUrl: await startLogin(body) },
|
|
};
|
|
ctx.response.body = responseBody;
|
|
});
|
|
|
|
router.post("/sessions", async (ctx) => {
|
|
const body: unknown = await ctx.request.body.json();
|
|
if (!isFederationSessionRequest(body)) {
|
|
throw new APIException(400, "Invalid request");
|
|
}
|
|
const user = await completeLogin(body);
|
|
const responseBody: APIResponse<AuthResponse> = {
|
|
success: true,
|
|
data: { ...await issueTokens(user), user: toSelfUser(user) },
|
|
};
|
|
ctx.response.body = responseBody;
|
|
});
|
|
|
|
router.post("/events", async (ctx) => {
|
|
const event = fieldOf(await ctx.request.body.json(), "event");
|
|
if (typeof event !== "string") throw new APIException(400, "Invalid event");
|
|
await receiveEvent(event);
|
|
ctx.response.status = 204;
|
|
});
|
|
|
|
router.get("/peers", async (ctx) => {
|
|
const userId = await parseOptionalAuth(ctx);
|
|
const all = !!userId &&
|
|
hasPermission(getUserById(userId).role, "federation:manage");
|
|
const responseBody: APIResponse<FederationPeer[]> = {
|
|
success: true,
|
|
data: listPeers(!all),
|
|
};
|
|
ctx.response.body = responseBody;
|
|
});
|
|
|
|
router.put(
|
|
"/peers",
|
|
authMiddleware,
|
|
requirePermission("federation:manage"),
|
|
async (ctx: AuthContext) => {
|
|
const body: unknown = await ctx.request.body.json();
|
|
if (!isSetPeerRequest(body)) throw new APIException(400, "Invalid request");
|
|
const responseBody: APIResponse<FederationPeer> = {
|
|
success: true,
|
|
data: await setPeer(body.instance, body.status),
|
|
};
|
|
ctx.response.body = responseBody;
|
|
},
|
|
);
|
|
|
|
router.delete(
|
|
"/peers/:host",
|
|
authMiddleware,
|
|
requirePermission("federation:manage"),
|
|
(ctx: AuthContext<"/peers/:host">) => {
|
|
removePeer(ctx.params.host);
|
|
const responseBody: APIResponse<null> = { success: true, data: null };
|
|
ctx.response.body = responseBody;
|
|
},
|
|
);
|
|
|
|
export default router;
|