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

Settings › Tokens names the harness behind each OAuth token and says it renews itself #8564

Merged opened this

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

Merged

scout/forge:scout/8432-settings-tokens-names-the-harness-behind → main 3 commits fork

  • Merged. scout/8432-settings-tokens-names-the-harness-behind was merged into main as 5be96f9.

Review

Sign in to review this change.
12 files changed +259 −15
docs/features/oauth-refresh.md +2
@@ -19,3 +19,5 @@ curl -X POST https://<forge>/oauth/token \
1919 expires does not: the harness refreshes it.
2020 - Refresh tokens live 90 days from their issue, are stored hashed (`oauth_refresh`),
2121 and are bound to the client that got them. Sign-in-only (OpenID) grants get none.
22
23 Which harness holds each OAuth token, on every door: [tokens-harness](tokens-harness.md).
docs/features/tokens-harness.md added +17
@@ -0,0 +1,17 @@
1 # Which harness holds a token
2
3 A token minted through the OAuth endpoint (`/oauth/token`, when a harness such
4 as Claude Code connects) is listed with the registered name of the client that
5 holds it, so you can tell who can act as you. A token you made by hand shows
6 nothing extra.
7
8 - **Settings › Tokens**: under the token's name, "<client> · Signed in through
9 OAuth; renews itself until you delete it". The name is escaped.
10 - **`GET /api/v1/tokens`** and MCP **`tokens`**: each token carries
11 `oauthClient`, the client's name, or `null` for a hand-made token.
12 - **Terminal, sign-ins screen**: the row reads `<name> · <scope> · <client>, renews itself`.
13
14 It appears for every token the code exchange or a refresh minted while the
15 client is registered. A token from before OAuth tokens were marked is listed as
16 hand-made. "Renews itself" means the harness swaps it with its refresh token
17 before it lapses; deleting it ends that, see [OAuth refresh](oauth-refresh.md).
src/actions/signins.ts +4 −3
@@ -14,14 +14,15 @@ import {
1414 sessionOf,
1515 type SessionInfo,
1616 } from "../core/sessions.ts";
17 import { deleteToken, listTokens, type TokenInfo } from "../core/tokens.ts";
17 import { listTokensWithClients, type ListedToken } from "../core/oauth.ts";
18 import { deleteToken } from "../core/tokens.ts";
1819 import { getUserByName, type User } from "../core/users.ts";
1920 import { err, ok, type Result } from "../kernel/result.ts";
2021 import { requireUser } from "./access.ts";
2122
2223 export interface SignIns {
2324 readonly user: User;
24 readonly tokens: readonly TokenInfo[];
25 readonly tokens: readonly ListedToken[];
2526 readonly sessions: readonly SessionInfo[];
2627 }
2728
@@ -53,7 +54,7 @@ export function accountSignIns(
5354 if (!user.ok) return user;
5455 return ok({
5556 user: user.value,
56 tokens: listTokens(app.db, user.value.id),
57 tokens: listTokensWithClients(app.db, user.value.id),
5758 sessions: listSessions(
5859 app.db,
5960 user.value.id,
src/contract/openapi/routes/getApiV1Tokens.ts +1 −1
@@ -3,7 +3,7 @@ import type { Route } from "../shared.ts";
33 export const route: Route = {
44 method: "get",
55 path: "/api/v1/tokens",
6 summary: "List your own tokens; a bearer token is refused with 403, call it from a browser session or with a password",
6 summary: "List your own tokens; a bearer token is refused with 403, call it from a browser session or with a password. Each token carries `oauthClient`: the registered name of the harness that holds it, null for a hand-made one",
77 auth: false,
88 refusals: { forbidden: "the caller holds a bearer token, which cannot manage tokens" },
99 };
src/core/oauth.ts +27 −1
@@ -8,7 +8,7 @@ import { sha256 } from "../kernel/hash.ts";
88 import { id, secret } from "../kernel/id.ts";
99 import { err, ok, type Result } from "../kernel/result.ts";
1010 import { NONCE_MAX, oidcScopes, recordGrant, signIdToken } from "./oidc.ts";
11 import { createToken } from "./tokens.ts";
11 import { createToken, listTokens, type TokenInfo } from "./tokens.ts";
1212 import { getUserById, isActive } from "./users.ts";
1313
1414 export const NAME_MAX = 100;
@@ -136,6 +136,32 @@ export function getClient(db: Database, clientId: string): OAuthClient | null {
136136 };
137137 }
138138
139 /**
140 * The registered client name behind each of a person's tokens the OAuth
141 * endpoint minted, keyed by token id, in one query for the whole list.
142 * A hand-made token is absent.
143 */
144 export function oauthClientNames(db: Database, userId: string): Map<string, string> {
145 const rows = db
146 .query<{ token_id: string; name: string }, [string]>(
147 `SELECT o.token_id, c.name FROM oauth_token o
148 JOIN token t ON t.id = o.token_id
149 JOIN oauth_client c ON c.id = o.client_id
150 WHERE t.user_id = ?1`,
151 )
152 .all(userId);
153 return new Map(rows.map((r) => [r.token_id, r.name]));
154 }
155
156 /** A token as every door lists it: `oauthClient` names the harness that holds it, null for a hand-made one. */
157 export type ListedToken = TokenInfo & { readonly oauthClient: string | null };
158
159 /** A person's tokens with the harness behind each: two reads for the whole list, whatever its length. */
160 export function listTokensWithClients(db: Database, userId: string): ListedToken[] {
161 const clients = oauthClientNames(db, userId);
162 return listTokens(db, userId).map((t) => ({ ...t, oauthClient: clients.get(t.id) ?? null }));
163 }
164
139165 // ---- the authorization request, the code, and the exchange ----
140166
141167 /** A code is a minute's grace between consent and exchange, no more. */
src/http/api/tokens.ts +3 −2
@@ -9,7 +9,8 @@ import { signOutHere } from "../../actions/signins.ts";
99 import { createToken, deleteToken } from "../../actions/users.ts";
1010 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1111 import { createSession } from "../../core/sessions.ts";
12 import { ADMIN_TOKEN_WORDS, listTokens } from "../../core/tokens.ts";
12 import { listTokensWithClients } from "../../core/oauth.ts";
13 import { ADMIN_TOKEN_WORDS } from "../../core/tokens.ts";
1314 import { type User, verifyPasswordThrottled } from "../../core/users.ts";
1415 import { sessionCookie } from "../../web/pages/site.ts";
1516 import { cookieValue, crossOrigin, type Handler } from "../context.ts";
@@ -117,7 +118,7 @@ export function tokenRoutes(router: Router<Handler>): void {
117118 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
118119 const denied = requireAccount(ctx);
119120 if (denied) return denied;
120 const tokens = timePhase(ctx.request, "tokens", () => listTokens(ctx.app.db, ctx.auth.user!.id));
121 const tokens = timePhase(ctx.request, "tokens", () => listTokensWithClients(ctx.app.db, ctx.auth.user!.id));
121122 return timePhase(ctx.request, "render", () => json(tokens));
122123 });
123124
src/http/mcp/tools/tokens.ts +3 −3
@@ -1,13 +1,13 @@
1 import { listTokens } from "../../../core/tokens.ts";
1 import { listTokensWithClients } from "../../../core/oauth.ts";
22 import { good, type Tool } from "../shared.ts";
33
44 export const tool: Tool = {
55 name: "tokens",
6 doc: "your own API tokens, as `GET /api/v1/tokens` lists them: id, name, scope, created and last used; never a token or its hash",
6 doc: "your own API tokens, as `GET /api/v1/tokens` lists them: id, name, scope, created and last used, and `oauthClient`: the harness that holds a token minted through OAuth, null for a hand-made one; never a token or its hash",
77 description: "Your API tokens, no secrets.",
88 inputSchema: { type: "object", properties: {} },
99 writes: false,
1010 run(ctx, user) {
11 return good({ items: listTokens(ctx.app.db, user.id) });
11 return good({ items: listTokensWithClients(ctx.app.db, user.id) });
1212 },
1313 };
src/tui/screens/common.ts +3 −1
@@ -784,7 +784,9 @@ export function live(
784784 timer = setTimeout(() => {
785785 timer = null;
786786 if (signal.aborted) return;
787 void Promise.resolve(refresh()).catch(() => undefined);
787 // A refresh that throws at once (the forge closed under it) fails as
788 // quietly as one that rejects, never as an uncaught error on the timer.
789 void Promise.resolve().then(refresh).catch(() => undefined);
788790 }, LIVE_COALESCE_MS);
789791 });
790792 signal.addEventListener("abort", () => {
src/tui/screens/signins.ts +1 −1
@@ -80,7 +80,7 @@ export function signInsOf(f: Session): SignIn[] {
8080 ...seen.value.tokens.map((t) => ({
8181 kind: "token" as const,
8282 id: t.id,
83 where: `${t.name} · ${t.scope}`,
83 where: t.oauthClient === null ? `${t.name} · ${t.scope}` : `${t.name} · ${t.scope} · ${t.oauthClient}, renews itself`,
8484 seen: t.lastUsedAt,
8585 since: t.createdAt,
8686 here: false,
src/web/pages/account.ts +12 −3
@@ -34,6 +34,7 @@ import { linkEdgeLogin, linkedLogin, unlinkEdgeLogin } from "../../core/edge_lin
3434 import { LAST_WAY_IN, linkedParent, parentIssuerOf, unlinkParent } from "../../core/federation.ts";
3535 import { activeEdgeToken, edgeIdentity } from "../../http/edge.ts";
3636 import { listKeys } from "../../core/keys.ts";
37 import { listTokensWithClients } from "../../core/oauth.ts";
3738 import {
3839 PROFILE_FALLBACK,
3940 PROFILE_REFUSAL_FIELD,
@@ -41,7 +42,7 @@ import {
4142 } from "../../actions/profile_words.ts";
4243 import { endAccountSession } from "../../actions/signins.ts";
4344 import { listSessions, type SessionInfo } from "../../core/sessions.ts";
44 import { ADMIN_TOKEN_WORDS, listTokens, type TokenScope } from "../../core/tokens.ts";
45 import { ADMIN_TOKEN_WORDS, type TokenScope } from "../../core/tokens.ts";
4546 import type { CommitEmail, User } from "../../core/users.ts";
4647 import { isStandInEmail } from "../../core/users.ts";
4748 import { cookieValue, isAccountItself, type Ctx } from "../../http/context.ts";
@@ -597,7 +598,7 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html {
597598 }
598599
599600 function tokensView(ctx: Ctx): Html {
600 const tokens = timePhase(ctx.request, "tokens", () => listTokens(ctx.app.db, ctx.auth.user!.id));
601 const tokens = timePhase(ctx.request, "tokens", () => listTokensWithClients(ctx.app.db, ctx.auth.user!.id));
601602 const sessions = timePhase(ctx.request, "sessions", () =>
602603 listSessions(ctx.app.db, ctx.auth.user!.id, cookieValue(ctx.request, "forge_session")),
603604 );
@@ -629,7 +630,15 @@ function tokensView(ctx: Ctx): Html {
629630 ${tokens.map(
630631 (t) =>
631632 html`<tr>
632 <td><strong>${t.name}</strong></td>
633 <td>
634 <strong>${t.name}</strong>
635 ${when(
636 t.oauthClient,
637 (client) => html`<div class="muted small" data-oauth-client>
638 ${client} · Signed in through OAuth; renews itself until you delete it
639 </div>`,
640 )}
641 </td>
633642 <td>${badge(t.scope)}</td>
634643 <td class="muted">${time(t.createdAt)}</td>
635644 <td class="muted">
test/tokens-oauth-client.test.ts added +157
@@ -0,0 +1,157 @@
1 /**
2 * Settings › Tokens names the harness behind each token the OAuth endpoint
3 * minted (#8432): its client's registered name, escaped, and that it renews
4 * itself; `GET /api/v1/tokens` and MCP `tokens` carry the same name as
5 * `oauthClient`, and the terminal's sign-ins screen names it on the row. A hand-made token looks as it always did.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtempSync, rmSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createClient, issueCode, listTokensWithClients, oauthClientNames, parseAuthRequest, redeemCode } from "../src/core/oauth.ts";
13 import { createToken, listTokens } from "../src/core/tokens.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { signInsOf } from "../src/tui/screens/signins.ts";
17 import { Session } from "../src/tui/session.ts";
18 import { proveAll } from "./helpers/verified.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
24 const verifier = "v".repeat(60);
25 const challenge = new Bun.CryptoHasher("sha256").update(verifier).digest("base64url");
26 const RENEWS = "Signed in through OAuth; renews itself until you delete it";
27
28 let root = "";
29 let app: App;
30 let servers: Servers;
31 let alice: User;
32 let cookie = "";
33
34 beforeAll(async () => {
35 root = mkdtempSync(join(tmpdir(), "forge-tokens-oauth-client-"));
36 app = createApp({ dataDir: root, port: 0 });
37 const u = await createUser(app.db, {
38 origin: "seed",
39 trusted: true,
40 username: "alice",
41 email: "[email protected]",
42 password: "a-good-passphrase",
43 adminIfFirst: true,
44 });
45 if (!u.ok) throw new Error(u.error);
46 alice = u.value;
47 proveAll(app);
48 servers = await serve(app);
49 const signin = await fetch(`${servers.url}/signin`, {
50 method: "POST",
51 headers: { origin: servers.url },
52 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
53 redirect: "manual",
54 });
55 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
56 });
57 afterAll(() => {
58 servers.stop();
59 app.close();
60 rmSync(root, { recursive: true, force: true });
61 });
62
63 async function exchange(clientName: string, tokenName: string): Promise<void> {
64 const client = createClient(app.db, { name: clientName, redirectUris: [REDIRECT] });
65 const params: Record<string, string> = {
66 client_id: client.id,
67 redirect_uri: REDIRECT,
68 response_type: "code",
69 code_challenge_method: "S256",
70 code_challenge: challenge,
71 scope: "rw",
72 };
73 const request = parseAuthRequest(app.db, (n) => params[n] ?? "");
74 if (!request.ok) throw new Error(request.error);
75 const code = issueCode(app.db, alice.id, request.value, { scope: "rw", name: tokenName, lifetimeS: null });
76 const minted = await redeemCode(app.db, root, {
77 code,
78 clientId: client.id,
79 redirectUri: REDIRECT,
80 verifier,
81 issuer: servers.url,
82 });
83 if (!minted.ok || minted.value.token === undefined) throw new Error("no token");
84 }
85
86 /** The table row that carries a token's name. */
87 function rowOf(page: string, tokenName: string): string {
88 const row = page.split("<tr>").find((r) => r.includes(`<strong>${tokenName}</strong>`));
89 if (row === undefined) throw new Error(`no row for ${tokenName}`);
90 return row;
91 }
92
93 test("a token from the code exchange names its client; a hand-made one does not", async () => {
94 await exchange("Claude Code", "harness-token");
95 const made = createToken(app.db, alice.id, "laptop", "rw");
96 expect(made.id).toBeTruthy();
97
98 const page = await (await fetch(`${servers.url}/settings/tokens`, { headers: { cookie } })).text();
99 const oauthRow = rowOf(page, "harness-token");
100 expect(oauthRow).toContain("Claude Code");
101 expect(oauthRow).toContain(RENEWS);
102 const handRow = rowOf(page, "laptop");
103 expect(handRow).not.toContain("data-oauth-client");
104 expect(handRow).not.toContain(RENEWS);
105 });
106
107 test("a client name with <script> is escaped", async () => {
108 await exchange("<script>alert(1)</script>", "evil-token");
109 const page = await (await fetch(`${servers.url}/settings/tokens`, { headers: { cookie } })).text();
110 const row = rowOf(page, "evil-token");
111 expect(row).not.toContain("<script>alert(1)</script>");
112 expect(row).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
113 });
114
115 test("the names come from one read covering only the person's own OAuth tokens", () => {
116 const names = oauthClientNames(app.db, alice.id);
117 const byName = new Map(listTokens(app.db, alice.id).map((t) => [t.name, t.id]));
118 expect(names.get(byName.get("harness-token")!)).toBe("Claude Code");
119 expect(names.has(byName.get("laptop")!)).toBe(false);
120 expect(oauthClientNames(app.db, "nobody").size).toBe(0);
121 });
122
123 const harnessOf = (items: { name: string; oauthClient: string | null }[]) =>
124 Object.fromEntries(items.map((t) => [t.name, t.oauthClient]));
125
126 test("GET /api/v1/tokens carries oauthClient on each token, null for a hand-made one", async () => {
127 const res = await fetch(`${servers.url}/api/v1/tokens`, { headers: { cookie } });
128 expect(res.status).toBe(200);
129 const listed = harnessOf((await res.json()) as { name: string; oauthClient: string | null }[]);
130 expect(listed["harness-token"]).toBe("Claude Code");
131 expect(listed["laptop"]).toBeNull();
132 });
133
134 test("MCP `tokens` carries oauthClient on each token, null for a hand-made one", async () => {
135 const bearer = createToken(app.db, alice.id, "mcp-caller", "rw").token;
136 const res = await fetch(`${servers.url}/mcp`, {
137 method: "POST",
138 headers: {
139 "content-type": "application/json",
140 accept: "application/json, text/event-stream",
141 authorization: `Bearer ${bearer}`,
142 },
143 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "tokens", arguments: {} } }),
144 });
145 const body = (await res.json()) as { result: { content: { text: string }[] } };
146 const listed = harnessOf(JSON.parse(body.result.content[0]!.text).items);
147 expect(listed["harness-token"]).toBe("Claude Code");
148 expect(listed["mcp-caller"]).toBeNull();
149 expect(listTokensWithClients(app.db, alice.id).length).toBe(Object.keys(listed).length);
150 });
151
152 test("the terminal's sign-ins screen names the harness on an OAuth token's row", () => {
153 const rows = signInsOf(new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice }));
154 const where = (name: string) => rows.find((r) => r.kind === "token" && r.where.startsWith(`${name} · `))?.where;
155 expect(where("harness-token")).toBe("harness-token · rw · Claude Code, renews itself");
156 expect(where("laptop")).toBe("laptop · rw");
157 });
test/tui-live-throw.test.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * A live screen's refresh that throws at once, as a reload does when the
3 * forge has closed under it, fails quietly: never an uncaught error on the
4 * timer, which bun pins on whatever test runs next.
5 */
6 import { expect, test } from "bun:test";
7 import { live } from "../src/tui/screens/common.ts";
8 import type { Session } from "../src/tui/session.ts";
9
10 test("a refresh that throws synchronously raises nothing uncaught", async () => {
11 let fire: (event: unknown) => void = () => undefined;
12 const session = { app: { bus: { subscribe: (fn: (event: unknown) => void) => ((fire = fn), () => undefined) } } } as unknown as Session;
13 const uncaught: unknown[] = [];
14 const note = (e: unknown) => uncaught.push(e);
15 process.on("uncaughtException", note);
16 let calls = 0;
17 try {
18 live(session, new AbortController().signal, {}, () => {
19 calls++;
20 throw new RangeError("Cannot use a closed database");
21 });
22 fire({ kind: "issue.opened", repoId: "r" });
23 await Bun.sleep(100);
24 } finally {
25 process.off("uncaughtException", note);
26 }
27 expect(calls).toBe(1);
28 expect(uncaught).toEqual([]);
29 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs