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

Merged opened this

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

Merged

mason/forge:mason/9199-get-api-v1-tokens-carries-an-etag-and-an → main 2 commits fork

  • Merged. mason/9199-get-api-v1-tokens-carries-an-etag-and-an was merged into main as 12a9d3f.

Review

Sign in to review this change.
2 files changed +156 −1
src/http/api/tokens.ts +11 −1
@@ -13,6 +13,7 @@ import { listTokensWithClients } from "../../core/oauth.ts";
1313 import { ADMIN_TOKEN_WORDS } from "../../core/tokens.ts";
1414 import { type User, verifyPasswordThrottled } from "../../core/users.ts";
1515 import { sessionCookie } from "../../web/pages/site.ts";
16 import { holds, weakEtag } from "../etag.ts";
1617 import { cookieValue, crossOrigin, type Handler } from "../context.ts";
1718 import { STRANGER_MAX } from "../limits.ts";
1819 import { clientAddress } from "../peer.ts";
@@ -119,7 +120,16 @@ export function tokenRoutes(router: Router<Handler>): void {
119120 const denied = requireAccount(ctx);
120121 if (denied) return denied;
121122 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 });
123133 });
124134
125135 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs