/** * 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 = { 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 = { 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 = { 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 = { 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 = { success: true, data: null }; ctx.response.body = responseBody; }, ); export default router;