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

MCP OAuth, part 2: authorize with consent and a PKCE token exchange that mints the same ro/rw forge token #2496

Merged opened this

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

Merged

scout/forge:scout/1616-mcp-oauth-part-2-authorize-with-consent → main 1 commit fork

  • Merged. scout/1616-mcp-oauth-part-2-authorize-with-consent was merged into main as 48f534d.

Review

Sign in to review this change.
2 files changed +28 −1
README.md +9
@@ -79,6 +79,15 @@ real address is an opt-in under **Commit email** in `/settings` (or
7979 `PATCH /api/v1/user` with `{"commitEmail":"account"}`); commits you push
8080 yourself carry whatever your own git says.
8181
82 ### Attaching the Claude app or Grok (OAuth)
83
84 Add a custom connector with the URL `https://<your forge>/mcp`. The client
85 registers itself at `/oauth/register` (the Claude app's callback is
86 `https://claude.ai/api/mcp/auth_callback`), sends you to `/oauth/authorize`
87 to sign in and choose `ro` or `rw` and a token name, then exchanges a
88 single-use 60-second code with its PKCE S256 verifier at `/oauth/token` for
89 the same token Settings mints; revoke it there. Details in `docs/MCP.md`.
90
8291 ### Proposal tokens
8392
8493 A writer of a repository can hand a child (a fork, or a project on another
test/oauth-flow.test.ts +19 −1
@@ -9,7 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
12 import { listTokens } from "../src/core/tokens.ts";
12 import { deleteToken, listTokens } from "../src/core/tokens.ts";
1313 import { createSession } from "../src/core/sessions.ts";
1414 import { createUser, type User } from "../src/core/users.ts";
1515 import { verifyIdToken } from "../src/core/oidc.ts";
@@ -167,6 +167,24 @@ describe("oauth authorize and token", () => {
167167 expect(tell.text).toContain("forbidden");
168168 });
169169
170 test("revoking the minted token in Settings cuts the connector off, and an rw grant may tell", async () => {
171 const ro = (await (await exchange({ code: await codeFrom({ name: "to revoke" }) })).json()) as any;
172 expect((await mcp(ro.access_token, "apps")).error).toBe(false);
173 const listed = listTokens(app.db, alice.id).find((t) => t.name === "to revoke")!;
174 expect(deleteToken(app.db, alice.id, listed.id)).toBe(true);
175 const gone = await fetch(`${base}/mcp`, {
176 method: "POST",
177 headers: { authorization: `Bearer ${ro.access_token}`, "content-type": "application/json" },
178 body: "{}",
179 });
180 expect(gone.status).toBe(401);
181
182 const rw = (await (await exchange({ code: await codeFrom({ grant: "rw", name: "can act" }) })).json()) as any;
183 expect(rw.scope).toBe("rw");
184 const kept = await mcp(rw.access_token, "tell", { text: "a thought from a connector" });
185 expect(kept.error).toBe(false);
186 });
187
170188 test("the code is stored hashed, never raw", async () => {
171189 const code = await codeFrom();
172190 const count = (hash: string) =>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs