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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1616-mcp-oauth-part-2-authorize-with-consent → main 1 commit fork
2 files
changed +28
−1
README.md +9
| @@ -79,6 +79,15 @@ real address is an opt-in under **Commit email** in `/settings` (or | ||
| 79 | 79 | `PATCH /api/v1/user` with `{"commitEmail":"account"}`); commits you push |
| 80 | 80 | yourself carry whatever your own git says. |
| 81 | 81 | |
| 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 | ||
| 82 | 91 | ### Proposal tokens |
| 83 | 92 | |
| 84 | 93 | 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"; | ||
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | 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"; | |
| 13 | 13 | import { createSession } from "../src/core/sessions.ts"; |
| 14 | 14 | import { createUser, type User } from "../src/core/users.ts"; |
| 15 | 15 | import { verifyIdToken } from "../src/core/oidc.ts"; |
| @@ -167,6 +167,24 @@ describe("oauth authorize and token", () => { | ||
| 167 | 167 | expect(tell.text).toContain("forbidden"); |
| 168 | 168 | }); |
| 169 | 169 | |
| 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 | ||
| 170 | 188 | test("the code is stored hashed, never raw", async () => { |
| 171 | 189 | const code = await codeFrom(); |
| 172 | 190 | const count = (hash: string) => |
new above
offline