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

A harness that disconnects revokes its token: POST /oauth/revoke (RFC 7009) and revocation_endpoint in the metadata #8367

Merged opened this

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

Merged

torque/forge:torque/8360-a-harness-that-disconnects-revokes-its-t → main 3 commits fork

  • Merged. torque/8360-a-harness-that-disconnects-revokes-its-t was merged into main as b894872.

Review

Sign in to review this change.
6 files changed +294 −10
docs/features/oauth-revoke.md added +36
@@ -0,0 +1,36 @@
1 # OAuth revoke
2
3 A harness that disconnects ends its own token: `POST /oauth/revoke`
4 (RFC 7009). Both `/.well-known/oauth-authorization-server` and
5 `/.well-known/openid-configuration` name it as `revocation_endpoint`.
6
7 ## Calling it
8
9 ```
10 curl -X POST https://<forge>/oauth/revoke -d token=<access_token>
11 ```
12
13 The body is a form (`token=…`) or, leniently, JSON (`{"token":"…"}`), at most
14 16 KiB. No cookie and no client secret: the token is the proof.
15
16 It answers an empty `200` for every token it is given: one it ended, an
17 unknown one, a repeat, and a personal token made in settings (which it does
18 not touch). It never says whether the token existed. `413` for a body past
19 the cap; `429` with `Retry-After` past 60 requests a minute per client address
20 (IPv6 by its /64) or 600 a minute across the forge.
21
22 A revoked token is refused at once by `/api/v1` and `/mcp`, and a
23 `token.deleted` event (`via: oauth_revoke`) lands in the audit trail.
24
25 ## What it can end
26
27 Only tokens the OAuth token endpoint minted: each is marked in the
28 `oauth_token` table, which cascades away with the token. Tokens minted before
29 that migration carry no mark and cannot be revoked here; end them in settings.
30
31 ## The person's side
32
33 No new screen is needed. A person's connected harness is a row in the token
34 list (the settings page, and the terminal's sign-ins screen with `d`), which already deletes any
35 token, OAuth-minted or personal; deleting it removes its `oauth_token` row by
36 cascade, and the harness's next call is refused.
src/core/migration-pins/20261102160000-oauth-token.pin added +1
@@ -0,0 +1,1 @@
1 3aa6b8bb21a0188d902297d4a9d205a0a7a3e999b76cd6efc3eb535bd1fc823b
src/core/migrations/20261102160000-oauth-token.ts added +10
@@ -0,0 +1,10 @@
1 // A token the OAuth token endpoint minted is marked, so POST /oauth/revoke
2 // (RFC 7009, #8360) can end it and refuse every other kind: a personal token
3 // made in settings is not the harness's to revoke. Additive; tokens minted
4 // before this carry no mark and are ended in settings.
5 export default `
6 CREATE TABLE oauth_token (
7 token_id TEXT PRIMARY KEY REFERENCES token(id) ON DELETE CASCADE,
8 client_id TEXT NOT NULL
9 );
10 `;
src/core/oauth.ts +25
@@ -334,6 +334,10 @@ export async function redeemCode(
334334 ? {}
335335 : { expiresAt: new Date(Date.now() + row.lifetime_s * 1000).toISOString() }),
336336 });
337 db.query("INSERT INTO oauth_token (token_id, client_id) VALUES (?1, ?2)").run(
338 minted.id,
339 row.client_id,
340 );
337341 const scopes = row.oidc_scopes === "" ? [] : row.oidc_scopes.split(" ");
338342 if (scopes.length === 0)
339343 return ok({ token: minted.token, scope: row.scope, expiresIn: row.lifetime_s });
@@ -347,3 +351,24 @@ export async function redeemCode(
347351 });
348352 return ok({ token: minted.token, scope: row.scope, expiresIn: row.lifetime_s, idToken });
349353 }
354
355 /**
356 * End a token the OAuth endpoint minted (RFC 7009 §2.1). A token of any
357 * other kind, or none at all, is left alone and the caller learns nothing:
358 * the lookup is the same either way. Returns the token and its owner when
359 * one was revoked, so the caller can leave the audit trail.
360 */
361 export function revokeOAuthToken(
362 db: Database,
363 presented: string,
364 ): { tokenId: string; userId: string } | null {
365 const row = db
366 .query<{ id: string; user_id: string }, [string]>(
367 `SELECT t.id, t.user_id FROM token t JOIN oauth_token o ON o.token_id = t.id
368 WHERE t.token_hash = ?1`,
369 )
370 .get(sha256(presented));
371 if (row === null) return null;
372 db.query("DELETE FROM token WHERE id = ?1").run(row.id);
373 return { tokenId: row.id, userId: row.user_id };
374 }
src/http/oauth.ts +52 −10
@@ -9,7 +9,13 @@
99 * checked on its POST.
1010 */
1111 import type { App } from "../app.ts";
12 import { createClient, OAUTH_SCOPES, parseRegistration, redeemCode } from "../core/oauth.ts";
12 import {
13 createClient,
14 OAUTH_SCOPES,
15 parseRegistration,
16 redeemCode,
17 revokeOAuthToken,
18 } from "../core/oauth.ts";
1319 import { jwks, OIDC_SCOPES, userinfo } from "../core/oidc.ts";
1420 import { RateLimiter } from "../core/ratelimit.ts";
1521 import type { Handler } from "./context.ts";
@@ -21,19 +27,27 @@ import { authorizePage, authorizeSubmit } from "../web/pages/oauth.ts";
2127
2228 /** Registrations per address per minute. */
2329 const REGISTER_PER_MINUTE = 10;
30 /** Revocations per address per minute: a token is 40 random characters, not guessable at this rate. */
31 const REVOKE_PER_MINUTE = 60;
32 /** Revocations per minute across every address: the whole-forge cap beside the per-address one. */
33 const REVOKE_ALL_PER_MINUTE = 600;
2434 /** A registration is a few hundred bytes; refuse anything past this. */
2535 const BODY_MAX = 16 * 1024;
2636
2737 const limiters = new WeakMap<App, RateLimiter>();
28 const limiterOf = (app: App): RateLimiter => {
29 let limiter = limiters.get(app);
38 const revokeLimiters = new WeakMap<App, RateLimiter>();
39 const revokeAllLimiters = new WeakMap<App, RateLimiter>();
40
41 /** The app's limiter in `store`, made with `perMinute` the first time. */
42 const limiterIn = (
43 store: WeakMap<App, RateLimiter>,
44 app: App,
45 perMinute: number,
46 ): RateLimiter => {
47 let limiter = store.get(app);
3048 if (limiter === undefined) {
31 limiter = new RateLimiter({
32 perMinute: REGISTER_PER_MINUTE,
33 adminPerMinute: REGISTER_PER_MINUTE,
34 enabled: true,
35 });
36 limiters.set(app, limiter);
49 limiter = new RateLimiter({ perMinute, adminPerMinute: perMinute, enabled: true });
50 store.set(app, limiter);
3751 }
3852 return limiter;
3953 };
@@ -98,6 +112,30 @@ export function oauthRoutes(router: Router<Handler>): void {
98112 noStore,
99113 );
100114 });
115 router.add("POST", "/oauth/revoke", async (ctx) => {
116 const verdict = [
117 limiterIn(revokeLimiters, ctx.app, REVOKE_PER_MINUTE).take(clientBucket(ctx.request), "ro"),
118 limiterIn(revokeAllLimiters, ctx.app, REVOKE_ALL_PER_MINUTE).take("all", "ro"),
119 ].find((v) => !v.allowed) ?? { allowed: true, retryAfter: 0 };
120 if (!verdict.allowed)
121 return json({ error: "rate_limited" }, 429, {
122 ...noStore,
123 "retry-after": String(verdict.retryAfter),
124 });
125 const get = await tokenParams(ctx.request);
126 if (get === null) return grantError("invalid_request", 413);
127 const presented = get("token");
128 // RFC 7009 §2.2: unknown, foreign and already-revoked tokens all get the
129 // same empty 200, so the route never says whether a token existed.
130 const revoked = presented === "" ? null : revokeOAuthToken(ctx.app.db, presented);
131 if (revoked !== null)
132 ctx.app.bus.append({
133 kind: "token.deleted",
134 actorId: revoked.userId,
135 payload: { tokenId: revoked.tokenId, via: "oauth_revoke" },
136 });
137 return new Response(null, { status: 200, headers: noStore });
138 });
101139 router.add("GET", "/oauth/jwks", async (ctx) =>
102140 json(await jwks(ctx.app.db, ctx.app.config.dataDir), 200, docHeaders),
103141 );
@@ -120,6 +158,7 @@ export function oauthRoutes(router: Router<Handler>): void {
120158 issuer: ctx.url.origin,
121159 authorization_endpoint: `${ctx.url.origin}/oauth/authorize`,
122160 token_endpoint: `${ctx.url.origin}/oauth/token`,
161 revocation_endpoint: `${ctx.url.origin}/oauth/revoke`,
123162 userinfo_endpoint: `${ctx.url.origin}/oauth/userinfo`,
124163 jwks_uri: `${ctx.url.origin}/oauth/jwks`,
125164 registration_endpoint: `${ctx.url.origin}/oauth/register`,
@@ -130,6 +169,7 @@ export function oauthRoutes(router: Router<Handler>): void {
130169 id_token_signing_alg_values_supported: ["ES256"],
131170 code_challenge_methods_supported: ["S256"],
132171 token_endpoint_auth_methods_supported: ["none"],
172 revocation_endpoint_auth_methods_supported: ["none"],
133173 claims_supported: ["sub", "iss", "aud", "exp", "iat", "nonce", "name", "preferred_username", "email", "email_verified"],
134174 },
135175 200,
@@ -156,19 +196,21 @@ export function oauthRoutes(router: Router<Handler>): void {
156196 issuer: ctx.url.origin,
157197 authorization_endpoint: `${ctx.url.origin}/oauth/authorize`,
158198 token_endpoint: `${ctx.url.origin}/oauth/token`,
199 revocation_endpoint: `${ctx.url.origin}/oauth/revoke`,
159200 registration_endpoint: `${ctx.url.origin}/oauth/register`,
160201 scopes_supported: [...OAUTH_SCOPES],
161202 response_types_supported: ["code"],
162203 grant_types_supported: ["authorization_code"],
163204 code_challenge_methods_supported: ["S256"],
164205 token_endpoint_auth_methods_supported: ["none"],
206 revocation_endpoint_auth_methods_supported: ["none"],
165207 },
166208 200,
167209 docHeaders,
168210 ),
169211 );
170212 router.add("POST", "/oauth/register", async (ctx) => {
171 const verdict = limiterOf(ctx.app).take(clientBucket(ctx.request), "ro");
213 const verdict = limiterIn(limiters, ctx.app, REGISTER_PER_MINUTE).take(clientBucket(ctx.request), "ro");
172214 if (!verdict.allowed)
173215 return json(
174216 {
test/oauth-revoke.test.ts added +170
@@ -0,0 +1,170 @@
1 /**
2 * POST /oauth/revoke (RFC 7009, #8360): a harness that disconnects ends its
3 * own token; an unknown token and a personal one get the same empty 200 and
4 * are not touched.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createClient, issueCode, parseAuthRequest, redeemCode } from "../src/core/oauth.ts";
12 import { createToken, deleteToken, listTokens } from "../src/core/tokens.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15
16 const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
17 const verifier = "v".repeat(60);
18 const challenge = new Bun.CryptoHasher("sha256").update(verifier).digest("base64url");
19
20 describe("oauth revoke", () => {
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24 let base: string;
25 let alice: User;
26
27 beforeAll(async () => {
28 dataDir = await mkdtemp(join(tmpdir(), "forge-oauth-revoke-"));
29 app = createApp({ dataDir, port: 0 });
30 servers = await serve(app);
31 base = servers.url;
32 const made = await createUser(app.db, {
33 origin: "seed",
34 trusted: true,
35 username: "alice",
36 email: "[email protected]",
37 password: "a-good-passphrase",
38 adminIfFirst: true,
39 });
40 if (!made.ok) throw new Error(made.error);
41 alice = made.value;
42 });
43 afterAll(async () => {
44 servers.stop();
45 await rm(dataDir, { recursive: true, force: true });
46 });
47
48 async function oauthToken(): Promise<string> {
49 const client = createClient(app.db, { name: "Fake", redirectUris: [REDIRECT] });
50 const params: Record<string, string> = {
51 client_id: client.id,
52 redirect_uri: REDIRECT,
53 response_type: "code",
54 code_challenge_method: "S256",
55 code_challenge: challenge,
56 scope: "rw",
57 };
58 const request = parseAuthRequest(app.db, (n) => params[n] ?? "");
59 if (!request.ok) throw new Error(request.error);
60 const code = issueCode(app.db, alice.id, request.value, { scope: "rw", name: "Fake", lifetimeS: null });
61 const minted = await redeemCode(app.db, dataDir, {
62 code,
63 clientId: client.id,
64 redirectUri: REDIRECT,
65 verifier,
66 issuer: base,
67 });
68 if (!minted.ok || minted.value.token === undefined) throw new Error("no token");
69 return minted.value.token;
70 }
71
72 const revoke = (token: string) =>
73 fetch(`${base}/oauth/revoke`, {
74 method: "POST",
75 headers: { "content-type": "application/x-www-form-urlencoded" },
76 body: new URLSearchParams({ token }),
77 });
78 const callApi = (token: string) =>
79 fetch(`${base}/api/v1/asked`, { headers: { authorization: `Bearer ${token}` } });
80 const mcp = (token: string) =>
81 fetch(`${base}/mcp`, {
82 method: "POST",
83 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
84 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
85 });
86
87 test("a revoked token is refused by /api/v1 and /mcp, and the audit trail says so", async () => {
88 const token = await oauthToken();
89 expect((await mcp(token)).status).not.toBe(401);
90 expect((await callApi(token)).status).not.toBe(401);
91 const res = await revoke(token);
92 expect(res.status).toBe(200);
93 expect(await res.text()).toBe("");
94 expect((await mcp(token)).status).toBe(401);
95 expect((await callApi(token)).status).toBe(401);
96 const row = app.db
97 .query<{ actor_id: string; payload: string }, []>(
98 "SELECT actor_id, payload FROM event WHERE kind = 'token.deleted' ORDER BY seq DESC LIMIT 1",
99 )
100 .get();
101 expect(row?.actor_id).toBe(alice.id);
102 expect(row?.payload).toContain("oauth_revoke");
103 });
104
105 test("an unknown token, a repeat and an empty body all answer 200 with no body", async () => {
106 for (const token of ["forge_nope", "", "x".repeat(500)]) {
107 const res = await revoke(token);
108 expect(res.status).toBe(200);
109 expect(await res.text()).toBe("");
110 }
111 const token = await oauthToken();
112 await revoke(token);
113 expect((await revoke(token)).status).toBe(200);
114 });
115
116 test("a personal token is not revocable here", async () => {
117 const mine = createToken(app.db, alice.id, "mine", "rw");
118 const res = await revoke(mine.token);
119 expect(res.status).toBe(200);
120 expect(await res.text()).toBe("");
121 expect(listTokens(app.db, alice.id).some((t) => t.id === mine.id)).toBe(true);
122 expect((await callApi(mine.token)).status).not.toBe(401);
123 });
124
125 test("the revoke accepts a JSON body", async () => {
126 const token = await oauthToken();
127 const res = await fetch(`${base}/oauth/revoke`, {
128 method: "POST",
129 headers: { "content-type": "application/json" },
130 body: JSON.stringify({ token }),
131 });
132 expect(res.status).toBe(200);
133 expect((await callApi(token)).status).toBe(401);
134 });
135
136 test("the oauth_token row goes with the revoke, and with a delete in settings", async () => {
137 const rows = () =>
138 app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM oauth_token").get()?.n ?? 0;
139 const before = rows();
140 const first = await oauthToken();
141 expect(rows()).toBe(before + 1);
142 await revoke(first);
143 expect(rows()).toBe(before);
144 const second = await oauthToken();
145 expect(rows()).toBe(before + 1);
146 const listed = listTokens(app.db, alice.id);
147 for (const t of listed) deleteToken(app.db, alice.id, t.id);
148 expect(rows()).toBe(before);
149 expect((await callApi(second)).status).toBe(401);
150 });
151
152 test("past the per-address limit the revoke answers 429 with Retry-After", async () => {
153 let last = 200;
154 let waitHeader: string | null = null;
155 for (let i = 0; i < 70 && last === 200; i++) {
156 const res = await revoke("forge_nope");
157 last = res.status;
158 waitHeader = res.headers.get("retry-after");
159 }
160 expect(last).toBe(429);
161 expect(Number(waitHeader)).toBeGreaterThan(0);
162 });
163
164 test("both metadata documents name the revocation endpoint", async () => {
165 for (const path of ["oauth-authorization-server", "openid-configuration"]) {
166 const doc = (await (await fetch(`${base}/.well-known/${path}`)).json()) as Record<string, unknown>;
167 expect(doc["revocation_endpoint"]).toBe(`${base}/oauth/revoke`);
168 }
169 });
170 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs