Public: anyone can read this; the crew can change it.
GET /api/v1/tokens carries an ETag and answers a repeat with 304 until a token is made, used or revoked #9209
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9199-get-api-v1-tokens-carries-an-etag-and-an → main 2 commits fork
2 files
changed +156
−1
src/http/api/tokens.ts +11 −1
| @@ -13,6 +13,7 @@ import { listTokensWithClients } from "../../core/oauth.ts"; | ||
| 13 | 13 | import { ADMIN_TOKEN_WORDS } from "../../core/tokens.ts"; |
| 14 | 14 | import { type User, verifyPasswordThrottled } from "../../core/users.ts"; |
| 15 | 15 | import { sessionCookie } from "../../web/pages/site.ts"; |
| 16 | import { holds, weakEtag } from "../etag.ts"; | |
| 16 | 17 | import { cookieValue, crossOrigin, type Handler } from "../context.ts"; |
| 17 | 18 | import { STRANGER_MAX } from "../limits.ts"; |
| 18 | 19 | import { clientAddress } from "../peer.ts"; |
| @@ -119,7 +120,16 @@ export function tokenRoutes(router: Router<Handler>): void { | ||
| 119 | 120 | const denied = requireAccount(ctx); |
| 120 | 121 | if (denied) return denied; |
| 121 | 122 | const tokens = timePhase(ctx.request, "tokens", () => listTokensWithClients(ctx.app.db, ctx.auth.user!.id)); |
| 122 | return timePhase(ctx.request, "render", () => json(tokens)); | |
| 123 | // The tag is of the exact bytes sent, which name every token with its | |
| 124 | // last use and client, so a token made, used or revoked moves it; the 401 | |
| 125 | // and 403 above carry none, so a 304 is reached only past the access check. | |
| 126 | return timePhase(ctx.request, "render", () => { | |
| 127 | const text = JSON.stringify(tokens); | |
| 128 | const etag = weakEtag(text); | |
| 129 | const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" }; | |
| 130 | if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers }); | |
| 131 | return new Response(text, { headers: { "content-type": "application/json", ...headers } }); | |
| 132 | }); | |
| 123 | 133 | }); |
| 124 | 134 | |
| 125 | 135 | router.add("DELETE", "/api/v1/tokens/:id", (ctx) => { |
test/tokens-etag.test.ts added +145
| @@ -0,0 +1,145 @@ | ||
| 1 | /** GET /api/v1/tokens carries a weak ETag and answers a repeat with 304 until a token is made, used or revoked (#9199). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createClient, listTokensWithClients } from "../src/core/oauth.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | import { proveAll } from "./helpers/verified.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | let dataDir = ""; | |
| 17 | let app: App, servers: Servers; | |
| 18 | let alice: User, bob: User; | |
| 19 | let aliceCookie = "", bobCookie = ""; | |
| 20 | const url = () => `${servers.url}/api/v1/tokens`; | |
| 21 | const get = (headers: Record<string, string> = {}) => fetch(url(), { headers }); | |
| 22 | const asAlice = (headers: Record<string, string> = {}) => get({ cookie: aliceCookie, ...headers }); | |
| 23 | const tagOf = async (cookie = aliceCookie) => { | |
| 24 | const r = await get({ cookie }); | |
| 25 | await r.text(); | |
| 26 | return r.headers.get("etag")!; | |
| 27 | }; | |
| 28 | async function changed(before: string, what: string): Promise<string> { | |
| 29 | const res = await asAlice({ "if-none-match": before }); | |
| 30 | expect(res.status, what).toBe(200); | |
| 31 | await res.text(); | |
| 32 | const tag = res.headers.get("etag")!; | |
| 33 | expect(tag, what).not.toBe(before); | |
| 34 | // And it settles: the new tag is a 304 again. | |
| 35 | const again = await asAlice({ "if-none-match": tag }); | |
| 36 | expect(again.status, what).toBe(304); | |
| 37 | return tag; | |
| 38 | } | |
| 39 | const mk = async (username: string) => { | |
| 40 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst: username === "sam" }); | |
| 41 | if (!r.ok) throw new Error("user"); | |
| 42 | return r.value; | |
| 43 | }; | |
| 44 | const signIn = async (username: string) => { | |
| 45 | const res = await fetch(`${servers.url}/api/v1/sessions`, { | |
| 46 | method: "POST", | |
| 47 | headers: { "content-type": "application/json", origin: servers.url }, | |
| 48 | body: JSON.stringify({ username, password: "a-good-passphrase" }), | |
| 49 | }); | |
| 50 | if (res.status !== 201) throw new Error(`sign in ${username}: ${res.status}`); | |
| 51 | return (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 52 | }; | |
| 53 | ||
| 54 | beforeAll(async () => { | |
| 55 | dataDir = await mkdtemp(join(tmpdir(), "forge-tokensetag-")); | |
| 56 | app = createApp({ dataDir, port: 0 }); | |
| 57 | await mk("sam"); | |
| 58 | alice = await mk("alice"); | |
| 59 | bob = await mk("bob"); | |
| 60 | proveAll(app); | |
| 61 | servers = await serve(app); | |
| 62 | aliceCookie = await signIn("alice"); | |
| 63 | bobCookie = await signIn("bob"); | |
| 64 | }); | |
| 65 | afterAll(async () => { | |
| 66 | servers.stop(); | |
| 67 | app.close(); | |
| 68 | await rm(dataDir, { recursive: true, force: true }); | |
| 69 | }); | |
| 70 | ||
| 71 | test("200 carries a weak tag, private no-cache and vary over the same body; a repeat is a bodiless 304 with the floor headers", async () => { | |
| 72 | createToken(app.db, alice.id, "laptop", "rw"); | |
| 73 | const res = await asAlice(); | |
| 74 | expect(res.status).toBe(200); | |
| 75 | const tag = res.headers.get("etag")!; | |
| 76 | expect(tag).toMatch(/^W\//); | |
| 77 | expect(res.headers.get("cache-control")).toBe("private, no-cache"); | |
| 78 | expect(res.headers.get("vary")).toContain("authorization, cookie"); | |
| 79 | expect(res.headers.get("content-type")).toBe("application/json"); | |
| 80 | expect(await res.text()).toBe(JSON.stringify(listTokensWithClients(app.db, alice.id))); | |
| 81 | const again = await asAlice({ "if-none-match": tag }); | |
| 82 | expect(again.status).toBe(304); | |
| 83 | expect(again.headers.get("etag")).toBe(tag); | |
| 84 | expect(again.headers.get("cache-control")).toBe("private, no-cache"); | |
| 85 | expect(again.headers.get("vary")).toContain("authorization, cookie"); | |
| 86 | expect(again.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 87 | expect(again.headers.get("content-security-policy")).not.toBeNull(); | |
| 88 | expect(again.headers.get("referrer-policy")).not.toBeNull(); | |
| 89 | expect(await again.text()).toBe(""); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a token made, used, given a client, its client renamed, and revoked each move the tag", async () => { | |
| 93 | let tag = await tagOf(); | |
| 94 | const made = await fetch(url(), { | |
| 95 | method: "POST", | |
| 96 | headers: { "content-type": "application/json", cookie: aliceCookie, origin: servers.url }, | |
| 97 | body: JSON.stringify({ name: "cli", scope: "ro" }), | |
| 98 | }); | |
| 99 | expect(made.status).toBe(201); | |
| 100 | const minted = (await made.json()) as { id: string; token: string }; | |
| 101 | tag = await changed(tag, "token made"); | |
| 102 | ||
| 103 | const used = await fetch(`${servers.url}/api/v1/user`, { headers: { authorization: `Bearer ${minted.token}` } }); | |
| 104 | await used.text(); | |
| 105 | tag = await changed(tag, "token used"); | |
| 106 | ||
| 107 | const client = createClient(app.db, { name: "Harness", redirectUris: ["https://example.com/cb"] }); | |
| 108 | app.db.query("INSERT INTO oauth_token (token_id, client_id) VALUES (?1, ?2)").run(minted.id, client.id); | |
| 109 | tag = await changed(tag, "client attached"); | |
| 110 | app.db.query("UPDATE oauth_client SET name = 'Harness 2' WHERE id = ?1").run(client.id); | |
| 111 | tag = await changed(tag, "client renamed"); | |
| 112 | ||
| 113 | const gone = await fetch(`${url()}/${minted.id}`, { method: "DELETE", headers: { cookie: aliceCookie, origin: servers.url } }); | |
| 114 | expect(gone.status).toBe(204); | |
| 115 | await changed(tag, "token revoked"); | |
| 116 | }); | |
| 117 | ||
| 118 | test("two viewers shown different bodies never share a tag, and one's tag buys the other nothing", async () => { | |
| 119 | createToken(app.db, bob.id, "bob-box", "rw"); | |
| 120 | const aliceTag = await tagOf(aliceCookie); | |
| 121 | const bobTag = await tagOf(bobCookie); | |
| 122 | expect(aliceTag).not.toBe(bobTag); | |
| 123 | const replay = await get({ cookie: bobCookie, "if-none-match": aliceTag }); | |
| 124 | expect(replay.status).toBe(200); | |
| 125 | expect(await replay.text()).toContain("bob-box"); | |
| 126 | const own = await get({ cookie: bobCookie, "if-none-match": bobTag }); | |
| 127 | expect(own.status).toBe(304); | |
| 128 | }); | |
| 129 | ||
| 130 | test("no session or account answers as before, with no tag, even holding If-None-Match: *", async () => { | |
| 131 | const anon = await get({ "if-none-match": "*" }); | |
| 132 | expect(anon.status).toBe(401); | |
| 133 | expect(anon.headers.get("etag")).toBeNull(); | |
| 134 | expect(anon.headers.get("cache-control")).not.toBe("private, no-cache"); | |
| 135 | ||
| 136 | const bearer = createToken(app.db, alice.id, "agent", "rw").token; | |
| 137 | const viaToken = await get({ authorization: `Bearer ${bearer}`, "if-none-match": "*" }); | |
| 138 | expect(viaToken.status).toBeGreaterThanOrEqual(400); | |
| 139 | expect(viaToken.status).not.toBe(304); | |
| 140 | expect(viaToken.headers.get("etag")).toBeNull(); | |
| 141 | ||
| 142 | const forged = await asAlice({ origin: "https://evil.example", "sec-fetch-site": "cross-site", "if-none-match": "*" }); | |
| 143 | expect(forged.status).toBe(403); | |
| 144 | expect(forged.headers.get("etag")).toBeNull(); | |
| 145 | }); |
new above
offline