Public: anyone can read this; the crew can change it.

The /api/v1/tokens and /api/v1/sessions routes move out of src/http/api.ts into src/http/api/tokens.ts, unchanged #7482

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/7447-the-api-v1-tokens-and-api-v1-sessions-ro → main 5 commits fork

  • Merged. mason/7447-the-api-v1-tokens-and-api-v1-sessions-ro was merged into main as cc3de56.

Review

Sign in to review this change.
3 files changed +135 −118
src/http/api.ts +3 −117
@@ -35,7 +35,6 @@ import { readScorecards } from "../actions/scorecard.ts";
3535 import {
3636 accountSignIns,
3737 endAccountSession,
38 signOutHere,
3938 } from "../actions/signins.ts";
4039 import { STRANGER_MAX } from "./limits.ts";
4140 import { VIA_FORM, WORDS_MAX } from "../core/fence.ts";
@@ -109,7 +108,6 @@ import {
109108 } from "../actions/repos.ts";
110109 import {
111110 createToken,
112 deleteToken,
113111 HUMAN_CHECK_MESSAGE,
114112 mintConversationToken,
115113 PASSKEY_REQUIRED_MESSAGE,
@@ -165,9 +163,7 @@ import {
165163 listReviews,
166164 withOutdated,
167165 } from "../core/reviews.ts";
168 import { createSession } from "../core/sessions.ts";
169166 import { mintProposal } from "../actions/proposal_tokens.ts";
170 import { ADMIN_TOKEN_WORDS, listTokens } from "../core/tokens.ts";
171167 import {
172168 headInNamespace,
173169 PROPOSAL_BODY_MAX,
@@ -177,8 +173,6 @@ import {
177173 import {
178174 getUserByName,
179175 hiddenFrom,
180 verifyPasswordThrottled,
181 type User,
182176 } from "../core/users.ts";
183177 import { isPinned } from "../core/pins.ts";
184178 import { diffCommit } from "../git/diff.ts";
@@ -279,7 +273,7 @@ import {
279273 undoFleetOp,
280274 type FleetError,
281275 } from "../actions/fleet.ts";
282 import { EDGE_NAME_MESSAGE, pinnedTo, sessionCookie, signupSlot } from "../web/pages/site.ts";
276 import { EDGE_NAME_MESSAGE, pinnedTo, signupSlot } from "../web/pages/site.ts";
283277 import {
284278 FIRST_ADMIN_TOKEN_MAX_S,
285279 FIRST_ADMIN_TOKEN_MIN_S,
@@ -304,7 +298,6 @@ import {
304298 respondAccess,
305299 str,
306300 unauthorized,
307 tooManyAttempts,
308301 } from "./respond.ts";
309302 import {
310303 explorePage,
@@ -323,6 +316,7 @@ import { agentRoutes } from "./api/agents.ts";
323316 import { boardRoutes } from "./api/boards.ts";
324317 import { followingRoutes } from "./api/following.ts";
325318 import { labelsRoutes } from "./api/labels.ts";
319 import { tokenRoutes } from "./api/tokens.ts";
326320 import { accountAdmin, adminRoutes } from "./api/admin.ts";
327321 import { issueRoutes, labelsHandler } from "./api/issues.ts";
328322 import { userRoutes } from "./api/user.ts";
@@ -1113,38 +1107,7 @@ export function apiRoutes(router: Router<Handler>): void {
11131107 );
11141108 });
11151109
1116 router.add("POST", "/api/v1/sessions", async (ctx) => {
1117 // The answer sets a cookie, so a page on another site must not be able
1118 // to sign a visitor in as someone else (login CSRF).
1119 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
1120 // Anyone may call this, so its body is a stranger's (`http/limits.ts`).
1121 const input = await boundedBody(ctx.request, STRANGER_MAX);
1122 if (input === "too_large")
1123 return apiError("too_large", 413, `at most ${STRANGER_MAX} bytes`);
1124 const verified = await verifyPasswordThrottled(ctx.app.db, {
1125 username: str(input.username),
1126 password: str(input.password),
1127 source: clientAddress(ctx.request),
1128 });
1129 if (!verified.ok)
1130 return verified.error.reason === "throttled"
1131 ? tooManyAttempts(verified.error.retryAfter)
1132 : unauthorized();
1133 const session = createSession(ctx.app.db, verified.value.id);
1134 return json(publicUser(verified.value), 201, {
1135 "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url),
1136 });
1137 });
1138
1139 router.add("DELETE", "/api/v1/sessions", (ctx) => {
1140 // Cookie-authenticated logout, so it must be same-origin like any other
1141 // cookie mutation — otherwise it's a cross-site forced-logout.
1142 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
1143 signOutHere(ctx.app, cookieValue(ctx.request, "forge_session"));
1144 return json({}, 200, {
1145 "set-cookie": sessionCookie("", new Date(0), ctx.url, ctx.app.config.url),
1146 });
1147 });
1110 tokenRoutes(router);
11481111
11491112 userRoutes(router);
11501113 adminRoutes(router);
@@ -1217,67 +1180,6 @@ export function apiRoutes(router: Router<Handler>): void {
12171180 return timePhase(ctx.request, "render", () => json(card));
12181181 });
12191182
1220 // Tokens are minted, listed, and revoked with the account itself (a
1221 // session or the password) — never with another token.
1222 router.add("POST", "/api/v1/tokens", async (ctx) => {
1223 // Anyone may call this, so its body is a stranger's (`http/limits.ts`).
1224 const input = await boundedBody(ctx.request, STRANGER_MAX);
1225 if (input === "too_large")
1226 return apiError("too_large", 413, `at most ${STRANGER_MAX} bytes`);
1227 let user: User | undefined;
1228 if (
1229 (ctx.auth.via === "session" || ctx.auth.via === "edge") &&
1230 ctx.auth.user
1231 ) {
1232 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
1233 // From a page, a site admin's token is minted only on purpose (#596).
1234 if (ctx.auth.user.isAdmin && input.adminConfirm !== true)
1235 return json(
1236 {
1237 error: "admin_confirm",
1238 message:
1239 "you are the forge's admin: send adminConfirm: true to mean a token that acts as admin, or mint it from your everyday account",
1240 },
1241 422,
1242 );
1243 user = ctx.auth.user;
1244 } else {
1245 const verified = await verifyPasswordThrottled(ctx.app.db, {
1246 username: str(input.username),
1247 password: str(input.password),
1248 source: clientAddress(ctx.request),
1249 });
1250 if (!verified.ok && verified.error.reason === "throttled")
1251 return tooManyAttempts(verified.error.retryAfter);
1252 if (verified.ok) user = verified.value;
1253 // With the password too, a site admin's token is minted on purpose.
1254 if (user?.isAdmin && input.adminConfirm !== true)
1255 return json(
1256 {
1257 error: "admin_confirm",
1258 message:
1259 "you are the forge's admin: send adminConfirm: true to mean a token that acts as admin, or mint it from your everyday account",
1260 },
1261 422,
1262 );
1263 }
1264 if (!user) return unauthorized();
1265 const scope = str(input.scope);
1266 if (scope !== "ro" && scope !== "rw" && scope !== "admin") {
1267 return json({ error: "invalid_scope" }, 422);
1268 }
1269 const minted = createToken(
1270 ctx.app,
1271 user,
1272 str(input.name) || "unnamed",
1273 scope,
1274 );
1275 if (!minted.ok && minted.error === "forbidden")
1276 return apiError("forbidden", 403, ADMIN_TOKEN_WORDS);
1277 if (!minted.ok) return unauthorized();
1278 return json(minted.value, 201);
1279 });
1280
12811183 // The token the forge's agent acts with in one conversation (#7648): only
12821184 // the person's own browser session mints it, same-origin, for themself.
12831185 // Room for `words` at their cap even when every character is escaped.
@@ -1383,22 +1285,6 @@ export function apiRoutes(router: Router<Handler>): void {
13831285 return json({ user: publicUser(user), scope, id, token, expiresAt, expiresIn }, 201);
13841286 });
13851287
1386 router.add("GET", "/api/v1/tokens", (ctx) => {
1387 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1388 const denied = requireAccount(ctx);
1389 if (denied) return denied;
1390 const tokens = timePhase(ctx.request, "tokens", () => listTokens(ctx.app.db, ctx.auth.user!.id));
1391 return timePhase(ctx.request, "render", () => json(tokens));
1392 });
1393
1394 router.add("DELETE", "/api/v1/tokens/:id", (ctx) => {
1395 const denied = requireAccount(ctx);
1396 if (denied) return denied;
1397 const removed = deleteToken(ctx.app, ctx.auth.user, ctx.params.id!);
1398 if (!removed.ok) return respondAccess(removed.error, 422);
1399 return new Response(null, { status: 204 });
1400 });
1401
14021288 // Where an account is signed in: its person, or the forge's owner for any
14031289 // person (`actions/signins.ts`). Anyone else finds no such account.
14041290 router.add("GET", "/api/v1/users/:username/signins", (ctx) => {
src/http/api/tokens.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * Signing in and API tokens: `POST` and `DELETE` `/api/v1/sessions`, and
3 * `POST` and `GET` `/api/v1/tokens` with `DELETE` `…/tokens/:id`. Moved out
4 * of `api.ts` (#7447), unchanged; the rules live in `actions/users.ts`,
5 * `core/sessions.ts` and `core/tokens.ts`.
6 */
7
8 import { signOutHere } from "../../actions/signins.ts";
9 import { createToken, deleteToken } from "../../actions/users.ts";
10 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
11 import { createSession } from "../../core/sessions.ts";
12 import { ADMIN_TOKEN_WORDS, listTokens } from "../../core/tokens.ts";
13 import { type User, verifyPasswordThrottled } from "../../core/users.ts";
14 import { sessionCookie } from "../../web/pages/site.ts";
15 import { cookieValue, crossOrigin, type Handler } from "../context.ts";
16 import { STRANGER_MAX } from "../limits.ts";
17 import { clientAddress } from "../peer.ts";
18 import { apiError, boundedBody, forbidden, json, publicUser, requireAccount, respondAccess, str, tooManyAttempts, unauthorized } from "../respond.ts";
19 import type { Router } from "../router.ts";
20
21 export function tokenRoutes(router: Router<Handler>): void {
22 router.add("POST", "/api/v1/sessions", async (ctx) => {
23 // The answer sets a cookie, so a page on another site must not be able
24 // to sign a visitor in as someone else (login CSRF).
25 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
26 // Anyone may call this, so its body is a stranger's (`http/limits.ts`).
27 const input = await boundedBody(ctx.request, STRANGER_MAX);
28 if (input === "too_large")
29 return apiError("too_large", 413, `at most ${STRANGER_MAX} bytes`);
30 const verified = await verifyPasswordThrottled(ctx.app.db, {
31 username: str(input.username),
32 password: str(input.password),
33 source: clientAddress(ctx.request),
34 });
35 if (!verified.ok)
36 return verified.error.reason === "throttled"
37 ? tooManyAttempts(verified.error.retryAfter)
38 : unauthorized();
39 const session = createSession(ctx.app.db, verified.value.id);
40 return json(publicUser(verified.value), 201, {
41 "set-cookie": sessionCookie(session.token, session.expiresAt, ctx.url, ctx.app.config.url),
42 });
43 });
44
45 router.add("DELETE", "/api/v1/sessions", (ctx) => {
46 // Cookie-authenticated logout, so it must be same-origin like any other
47 // cookie mutation — otherwise it's a cross-site forced-logout.
48 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
49 signOutHere(ctx.app, cookieValue(ctx.request, "forge_session"));
50 return json({}, 200, {
51 "set-cookie": sessionCookie("", new Date(0), ctx.url, ctx.app.config.url),
52 });
53 });
54
55 // Tokens are minted, listed, and revoked with the account itself (a
56 // session or the password) — never with another token.
57 router.add("POST", "/api/v1/tokens", async (ctx) => {
58 // Anyone may call this, so its body is a stranger's (`http/limits.ts`).
59 const input = await boundedBody(ctx.request, STRANGER_MAX);
60 if (input === "too_large")
61 return apiError("too_large", 413, `at most ${STRANGER_MAX} bytes`);
62 let user: User | undefined;
63 if (
64 (ctx.auth.via === "session" || ctx.auth.via === "edge") &&
65 ctx.auth.user
66 ) {
67 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
68 // From a page, a site admin's token is minted only on purpose (#596).
69 if (ctx.auth.user.isAdmin && input.adminConfirm !== true)
70 return json(
71 {
72 error: "admin_confirm",
73 message:
74 "you are the forge's admin: send adminConfirm: true to mean a token that acts as admin, or mint it from your everyday account",
75 },
76 422,
77 );
78 user = ctx.auth.user;
79 } else {
80 const verified = await verifyPasswordThrottled(ctx.app.db, {
81 username: str(input.username),
82 password: str(input.password),
83 source: clientAddress(ctx.request),
84 });
85 if (!verified.ok && verified.error.reason === "throttled")
86 return tooManyAttempts(verified.error.retryAfter);
87 if (verified.ok) user = verified.value;
88 // With the password too, a site admin's token is minted on purpose.
89 if (user?.isAdmin && input.adminConfirm !== true)
90 return json(
91 {
92 error: "admin_confirm",
93 message:
94 "you are the forge's admin: send adminConfirm: true to mean a token that acts as admin, or mint it from your everyday account",
95 },
96 422,
97 );
98 }
99 if (!user) return unauthorized();
100 const scope = str(input.scope);
101 if (scope !== "ro" && scope !== "rw" && scope !== "admin") {
102 return json({ error: "invalid_scope" }, 422);
103 }
104 const minted = createToken(
105 ctx.app,
106 user,
107 str(input.name) || "unnamed",
108 scope,
109 );
110 if (!minted.ok && minted.error === "forbidden")
111 return apiError("forbidden", 403, ADMIN_TOKEN_WORDS);
112 if (!minted.ok) return unauthorized();
113 return json(minted.value, 201);
114 });
115
116 router.add("GET", "/api/v1/tokens", (ctx) => {
117 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
118 const denied = requireAccount(ctx);
119 if (denied) return denied;
120 const tokens = timePhase(ctx.request, "tokens", () => listTokens(ctx.app.db, ctx.auth.user!.id));
121 return timePhase(ctx.request, "render", () => json(tokens));
122 });
123
124 router.add("DELETE", "/api/v1/tokens/:id", (ctx) => {
125 const denied = requireAccount(ctx);
126 if (denied) return denied;
127 const removed = deleteToken(ctx.app, ctx.auth.user, ctx.params.id!);
128 if (!removed.ok) return respondAccess(removed.error, 422);
129 return new Response(null, { status: 204 });
130 });
131 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs