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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7447-the-api-v1-tokens-and-api-v1-sessions-ro → main 5 commits fork
3 files
changed +135
−118
src/http/api.ts +3 −117
| @@ -35,7 +35,6 @@ import { readScorecards } from "../actions/scorecard.ts"; | ||
| 35 | 35 | import { |
| 36 | 36 | accountSignIns, |
| 37 | 37 | endAccountSession, |
| 38 | signOutHere, | |
| 39 | 38 | } from "../actions/signins.ts"; |
| 40 | 39 | import { STRANGER_MAX } from "./limits.ts"; |
| 41 | 40 | import { VIA_FORM, WORDS_MAX } from "../core/fence.ts"; |
| @@ -109,7 +108,6 @@ import { | ||
| 109 | 108 | } from "../actions/repos.ts"; |
| 110 | 109 | import { |
| 111 | 110 | createToken, |
| 112 | deleteToken, | |
| 113 | 111 | HUMAN_CHECK_MESSAGE, |
| 114 | 112 | mintConversationToken, |
| 115 | 113 | PASSKEY_REQUIRED_MESSAGE, |
| @@ -165,9 +163,7 @@ import { | ||
| 165 | 163 | listReviews, |
| 166 | 164 | withOutdated, |
| 167 | 165 | } from "../core/reviews.ts"; |
| 168 | import { createSession } from "../core/sessions.ts"; | |
| 169 | 166 | import { mintProposal } from "../actions/proposal_tokens.ts"; |
| 170 | import { ADMIN_TOKEN_WORDS, listTokens } from "../core/tokens.ts"; | |
| 171 | 167 | import { |
| 172 | 168 | headInNamespace, |
| 173 | 169 | PROPOSAL_BODY_MAX, |
| @@ -177,8 +173,6 @@ import { | ||
| 177 | 173 | import { |
| 178 | 174 | getUserByName, |
| 179 | 175 | hiddenFrom, |
| 180 | verifyPasswordThrottled, | |
| 181 | type User, | |
| 182 | 176 | } from "../core/users.ts"; |
| 183 | 177 | import { isPinned } from "../core/pins.ts"; |
| 184 | 178 | import { diffCommit } from "../git/diff.ts"; |
| @@ -279,7 +273,7 @@ import { | ||
| 279 | 273 | undoFleetOp, |
| 280 | 274 | type FleetError, |
| 281 | 275 | } 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"; | |
| 283 | 277 | import { |
| 284 | 278 | FIRST_ADMIN_TOKEN_MAX_S, |
| 285 | 279 | FIRST_ADMIN_TOKEN_MIN_S, |
| @@ -304,7 +298,6 @@ import { | ||
| 304 | 298 | respondAccess, |
| 305 | 299 | str, |
| 306 | 300 | unauthorized, |
| 307 | tooManyAttempts, | |
| 308 | 301 | } from "./respond.ts"; |
| 309 | 302 | import { |
| 310 | 303 | explorePage, |
| @@ -323,6 +316,7 @@ import { agentRoutes } from "./api/agents.ts"; | ||
| 323 | 316 | import { boardRoutes } from "./api/boards.ts"; |
| 324 | 317 | import { followingRoutes } from "./api/following.ts"; |
| 325 | 318 | import { labelsRoutes } from "./api/labels.ts"; |
| 319 | import { tokenRoutes } from "./api/tokens.ts"; | |
| 326 | 320 | import { accountAdmin, adminRoutes } from "./api/admin.ts"; |
| 327 | 321 | import { issueRoutes, labelsHandler } from "./api/issues.ts"; |
| 328 | 322 | import { userRoutes } from "./api/user.ts"; |
| @@ -1113,38 +1107,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1113 | 1107 | ); |
| 1114 | 1108 | }); |
| 1115 | 1109 | |
| 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); | |
| 1148 | 1111 | |
| 1149 | 1112 | userRoutes(router); |
| 1150 | 1113 | adminRoutes(router); |
| @@ -1217,67 +1180,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1217 | 1180 | return timePhase(ctx.request, "render", () => json(card)); |
| 1218 | 1181 | }); |
| 1219 | 1182 | |
| 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 | ||
| 1281 | 1183 | // The token the forge's agent acts with in one conversation (#7648): only |
| 1282 | 1184 | // the person's own browser session mints it, same-origin, for themself. |
| 1283 | 1185 | // Room for `words` at their cap even when every character is escaped. |
| @@ -1383,22 +1285,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1383 | 1285 | return json({ user: publicUser(user), scope, id, token, expiresAt, expiresIn }, 201); |
| 1384 | 1286 | }); |
| 1385 | 1287 | |
| 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 | ||
| 1402 | 1288 | // Where an account is signed in: its person, or the forge's owner for any |
| 1403 | 1289 | // person (`actions/signins.ts`). Anyone else finds no such account. |
| 1404 | 1290 | 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 | } |
test/cookie-flags.test.ts +1 −1
| @@ -29,7 +29,7 @@ setDefaultTimeout(60_000); | ||
| 29 | 29 | * until it is walked here or named. |
| 30 | 30 | */ |
| 31 | 31 | const WALKED: Record<string, number> = { |
| 32 | "src/http/api.ts": 4, // import, password sign-in, sign-out (+1 mention) | |
| 32 | "src/http/api/tokens.ts": 4, // password sign-in, sign-out: a set-cookie header and a sessionCookie() call each | |
| 33 | 33 | "src/http/server.ts": 1, // the mode switch |
| 34 | 34 | "src/http/rp.ts": 6, // parent sign-in and link: state cookie set and cleared |
| 35 | 35 | "src/apps/proxy.ts": 2, // app grant, app sign-out |
new above
offline