Claude and Codex sign in to MCP through the browser (OAuth), and gh-style PR commands in the CLI (#196)
* OAuth sign-in for MCP clients (work in progress) * CLI: pull request commands find the current branch's PR, pr checks --watch, pr list --head; OAuth fixes * OAuth for MCP: consent fix, end-to-end and unit tests, Claude and Codex docs * Agents may start an empty repository's default branch, as auto_init would * OAuth review fixes: keep every connection, no admin for site admins' apps, fetch app documents only after sign-in, org-agent app tokens stop with the approver's role, no workflow files in an agent's first commit * CLI: pr checks --watch waits for the first check after a push * Docs: Claude and Codex page points to diffinery auth setup-gh for gh and Claude Code's PR status
- CI / Typecheck and bundleFailed
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- End-to-end / End-to-end (agent reviews, tasks, Today)Failed
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)Failed
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (security)success
- Live test / Containers, registry, sandboxes, webhooks, schedules, LFS and pull requestssuccess
24 files changed+1872−66
Files (24)
- apps/cli/src/commands/pr.ts+101−33
- apps/cli/test/helpers.ts+3−1
- apps/cli/test/pr.test.ts+92−0
- apps/diffinery/docs/agents/claude-and-codex.md+145−0
- apps/diffinery/docs/agents/mcp.md+28−6
- apps/diffinery/docs/agents/overview.md+6−4
- apps/diffinery/docs/collaboration/pull-requests.md+5−2
- apps/diffinery/docs/nav.json+1−0
- apps/diffinery/migrations/0036_oauth.sql+57−0
- apps/diffinery/src/app.ts+11−3
- apps/diffinery/src/auth/oauth.ts+559−0
- apps/diffinery/src/docs/reference.ts+6−0
- apps/diffinery/src/http/common.ts+3−1
- apps/diffinery/src/http/oauth.ts+111−0
- apps/diffinery/src/lib/directory.ts+21−9
- apps/diffinery/src/lib/rate-limit.ts+3−3
- apps/diffinery/src/lib/util.ts+1−1
- apps/diffinery/src/repo/RepoDO.ts+10−1
- apps/diffinery/src/web/account.tsx+5−2
- apps/diffinery/src/web/oauth.tsx+243−0
- apps/diffinery/src/web/routes.tsx+2−0
- apps/diffinery/test/e2e/e2e.test.mjs+18−0
- apps/diffinery/test/e2e/oauth.test.mjs+362−0
- apps/diffinery/test/unit/oauth.test.ts+79−0
modifiedapps/cli/src/commands/pr.ts+101−33View file
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | import { readFile } from "node:fs/promises"; |
| 2 | 2 | import path from "node:path"; |
| 3 | -import { exactlyOne, list, positiveInt, repoPath, str, type Command, type Ctx, type Group, type Values } from "../cli.js"; | |
| 3 | +import { exactlyOne, list, positiveInt, repoPath, str, type Command, type Ctx, type Group, type RepoRef, type Values } from "../cli.js"; | |
| 4 | 4 | import { ApiError } from "../api.js"; |
| 5 | -import { currentBranch } from "../git.js"; | |
| 5 | +import { currentBranch, gitCapture } from "../git.js"; | |
| 6 | 6 | import { CliError, ExitCode, readAll } from "../io.js"; |
| 7 | 7 | import { ago, json, statusText, symbol, table, truncate } from "../output.js"; |
| 8 | 8 | |
| @@ -115,6 +115,7 @@ | ||
| 115 | 115 | state: { type: "string", short: "s", desc: "open, closed, merged or all (default: open)", arg: "STATE" }, |
| 116 | 116 | limit: { type: "string", short: "L", desc: "Maximum number to show (default 30)", arg: "N" }, |
| 117 | 117 | author: { type: "string", short: "A", desc: "Only pull requests opened by this login", arg: "LOGIN" }, |
| 118 | + head: { type: "string", short: "H", desc: "Only pull requests from this branch", arg: "BRANCH" }, | |
| 118 | 119 | }, |
| 119 | 120 | repo: true, |
| 120 | 121 | json: true, |
| @@ -124,17 +125,42 @@ | ||
| 124 | 125 | const state = str(o, "state") ?? "open"; |
| 125 | 126 | if (!["open", "closed", "merged", "all"].includes(state)) throw new CliError("--state must be open, closed, merged or all", 2); |
| 126 | 127 | const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30; |
| 128 | + const head = str(o, "head"); | |
| 127 | 129 | const api = await ctx.api(); |
| 128 | - let prs = await api.get<IssueJson[]>(`${repoPath(repo)}/pulls`, { state: state === "merged" ? "closed" : state, per_page: Math.min(100, state === "merged" ? 100 : limit), creator: str(o, "author") }); | |
| 130 | + let prs = await api.get<IssueJson[]>(`${repoPath(repo)}/pulls`, { | |
| 131 | + state: state === "merged" ? "closed" : state, | |
| 132 | + per_page: Math.min(100, state === "merged" ? 100 : limit), | |
| 133 | + creator: str(o, "author"), | |
| 134 | + head: head ? headQuery(repo, head) : undefined, | |
| 135 | + }); | |
| 129 | 136 | if (state === "merged") prs = prs.filter((p) => p.merged); |
| 130 | 137 | prs = prs.slice(0, limit); |
| 131 | 138 | if (ctx.json) return json(ctx.io, prs); |
| 132 | - if (!prs.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}pull requests in ${repo.owner}/${repo.name}.`); | |
| 139 | + if (!prs.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}pull requests ${head ? `from ${head} ` : ""}in ${repo.owner}/${repo.name}.`); | |
| 133 | 140 | const c = ctx.c; |
| 134 | 141 | ctx.io.stdout.write(table(prs.map((p) => [c.bold(`#${p.number}`), truncate(p.title, 60), c.cyan(`${p.head?.ref ?? ""}`), prState(c, p), c.dim(p.user.login), c.dim(ago(p.updated_at))]))); |
| 135 | 142 | }, |
| 136 | 143 | }; |
| 137 | 144 | |
| 145 | +/** GitHub's `head` filter form, OWNER:BRANCH (Diffinery also accepts a bare branch). */ | |
| 146 | +const headQuery = (repo: RepoRef, branch: string) => (branch.includes(":") ? branch : `${repo.owner}:${branch}`); | |
| 147 | + | |
| 148 | +/** | |
| 149 | + * The pull request number argument; without one, like gh, the open pull request whose head is | |
| 150 | + * the current git branch. | |
| 151 | + */ | |
| 152 | +async function prNumber(ctx: Ctx, num: string | undefined): Promise<number> { | |
| 153 | + if (num !== undefined) return positiveInt(num, "pull request number"); | |
| 154 | + const r = await gitCapture(ctx.io, ["symbolic-ref", "--quiet", "--short", "HEAD"]); | |
| 155 | + const branch = r.code === 0 ? r.stdout.trim() : ""; | |
| 156 | + if (!branch) throw new CliError("no pull request number given, and there is no current branch to find one for (detached HEAD or not a git repository); pass a pull request number"); | |
| 157 | + const repo = await ctx.repo(); | |
| 158 | + const prs = await (await ctx.api()).get<IssueJson[]>(`${repoPath(repo)}/pulls`, { head: headQuery(repo, branch), state: "open" }); | |
| 159 | + const pr = prs.find((p) => p.head?.ref === branch); | |
| 160 | + if (!pr) throw new CliError(`no open pull request for the current branch (${branch}) in ${repo.owner}/${repo.name}; pass a pull request number`); | |
| 161 | + return pr.number; | |
| 162 | +} | |
| 163 | + | |
| 138 | 164 | async function getPull(ctx: Ctx, n: number) { |
| 139 | 165 | const repo = await ctx.repo(); |
| 140 | 166 | const api = await ctx.api(); |
| @@ -144,14 +170,14 @@ | ||
| 144 | 170 | const view: Command = { |
| 145 | 171 | name: "pr view", |
| 146 | 172 | summary: "Show a pull request", |
| 147 | - args: "<number>", | |
| 173 | + description: "Without a number, shows the open pull request for the current branch.", | |
| 174 | + args: "[<number>]", | |
| 148 | 175 | options: { comments: { type: "boolean", short: "c", desc: "Include the conversation" } }, |
| 149 | 176 | repo: true, |
| 150 | 177 | json: true, |
| 151 | - minArgs: 1, | |
| 152 | 178 | maxArgs: 1, |
| 153 | 179 | async run(ctx, o, [num]) { |
| 154 | - const n = positiveInt(num, "pull request number"); | |
| 180 | + const n = await prNumber(ctx, num); | |
| 155 | 181 | const { pr, api, repo } = await getPull(ctx, n); |
| 156 | 182 | const comments = o.comments ? await api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`) : undefined; |
| 157 | 183 | if (ctx.json) return json(ctx.io, comments ? { ...pr, comment_list: comments } : pr); |
| @@ -178,14 +204,15 @@ | ||
| 178 | 204 | const diff: Command = { |
| 179 | 205 | name: "pr diff", |
| 180 | 206 | summary: "Show a pull request's changes as a unified diff", |
| 181 | - args: "<number>", | |
| 207 | + description: "Without a number, shows the open pull request for the current branch.", | |
| 208 | + args: "[<number>]", | |
| 182 | 209 | options: { "name-only": { type: "boolean", desc: "Only list changed file names" } }, |
| 183 | 210 | repo: true, |
| 184 | - minArgs: 1, | |
| 185 | 211 | maxArgs: 1, |
| 186 | 212 | async run(ctx, o, [num]) { |
| 213 | + const n = await prNumber(ctx, num); | |
| 187 | 214 | const repo = await ctx.repo(); |
| 188 | - const files = await (await ctx.api()).get<Array<{ filename: string; status: string; patch?: string; binary?: boolean }>>(`${repoPath(repo)}/pulls/${positiveInt(num, "pull request number")}/files`); | |
| 215 | + const files = await (await ctx.api()).get<Array<{ filename: string; status: string; patch?: string; binary?: boolean }>>(`${repoPath(repo)}/pulls/${n}/files`); | |
| 189 | 216 | if (o["name-only"]) { |
| 190 | 217 | ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join("")); |
| 191 | 218 | return; |
| @@ -208,23 +235,64 @@ | ||
| 208 | 235 | }, |
| 209 | 236 | }; |
| 210 | 237 | |
| 238 | +const DONE = ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"]; | |
| 239 | +const FAILED = ["failure", "cancelled", "timed_out", "action_required"]; | |
| 240 | +const PENDING = ["queued", "in_progress", "waiting", "pending", "requested"]; | |
| 241 | +/** Right after a push, a pull request has no checks until its runs start: --watch waits this long for the first. */ | |
| 242 | +const FIRST_CHECK_WAIT_MS = 120_000; | |
| 243 | + | |
| 211 | 244 | const checks: Command = { |
| 212 | 245 | name: "pr checks", |
| 213 | 246 | summary: "Show check status for a pull request", |
| 214 | - description: "Exit status: 0 when all checks passed, 1 when any failed, 8 when some are still pending.", | |
| 215 | - args: "<number>", | |
| 247 | + description: | |
| 248 | + "Without a number, shows the open pull request for the current branch. With --watch, polls until no check is pending (waiting up to 2 minutes for the first check to appear, as right after a push), printing the checks whenever they change (on a terminal; otherwise once at the end).\n\nExit status: 0 when all checks passed, 1 when any failed, 8 when some are still pending (never with --watch).", | |
| 249 | + args: "[<number>]", | |
| 250 | + options: { | |
| 251 | + watch: { type: "boolean", desc: "Wait until no check is pending" }, | |
| 252 | + interval: { type: "string", short: "i", desc: "Seconds between polls with --watch (default 10)", arg: "SECONDS" }, | |
| 253 | + }, | |
| 216 | 254 | repo: true, |
| 217 | 255 | json: true, |
| 218 | - minArgs: 1, | |
| 219 | 256 | maxArgs: 1, |
| 220 | - async run(ctx, _o, [num]) { | |
| 221 | - const { pr } = await getPull(ctx, positiveInt(num, "pull request number")); | |
| 222 | - const list = pr.checks ?? []; | |
| 257 | + async run(ctx, o, [num]) { | |
| 258 | + const interval = Math.max(0, Number(str(o, "interval") ?? 10)) * 1000; | |
| 259 | + if (!Number.isFinite(interval)) throw new CliError("--interval must be a number", 2); | |
| 260 | + const n = await prNumber(ctx, num); | |
| 261 | + const repo = await ctx.repo(); | |
| 262 | + const api = await ctx.api(); | |
| 263 | + const render = (list: PullDetail["checks"]) => table(list.map((k) => [checkSymbol(ctx, k.state), k.name, statusText(ctx.c, DONE.includes(k.state) ? "completed" : k.state, k.state)])); | |
| 264 | + // Like `gh pr checks --watch`: on a terminal, show progress as it happens; when piped, only the result. | |
| 265 | + const live = o.watch === true && !ctx.json && !!ctx.io.stdout.isTTY; | |
| 266 | + let shown: string | undefined; | |
| 267 | + let failures = 0; | |
| 268 | + let waited = 0; | |
| 269 | + let list: PullDetail["checks"]; | |
| 270 | + for (;;) { | |
| 271 | + try { | |
| 272 | + list = (await api.get<PullDetail>(`${repoPath(repo)}/pulls/${n}`)).checks ?? []; | |
| 273 | + failures = 0; | |
| 274 | + } catch (e) { | |
| 275 | + // While watching, ride out transient network/server errors; give up on client errors. | |
| 276 | + const status = (e as { status?: number }).status; | |
| 277 | + if (o.watch !== true || (status && status < 500 && status !== 429) || ++failures > 5) throw e; | |
| 278 | + await ctx.io.sleep(interval); | |
| 279 | + continue; | |
| 280 | + } | |
| 281 | + const waitingForFirst = !list.length && waited < FIRST_CHECK_WAIT_MS; | |
| 282 | + if (o.watch !== true || (!waitingForFirst && !list.some((k) => PENDING.includes(k.state)))) break; | |
| 283 | + const t = render(list); | |
| 284 | + if (live && t && t !== shown) { | |
| 285 | + ctx.io.stdout.write(`${shown === undefined ? "" : "\n"}${t}`); | |
| 286 | + shown = t; | |
| 287 | + } | |
| 288 | + await ctx.io.sleep(interval); | |
| 289 | + waited += Math.max(interval, 1000); | |
| 290 | + } | |
| 223 | 291 | if (ctx.json) json(ctx.io, list); |
| 224 | 292 | else if (!list.length) ctx.err("No checks reported for this pull request."); |
| 225 | - else ctx.io.stdout.write(table(list.map((k) => [checkSymbol(ctx, k.state), k.name, statusText(ctx.c, ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"].includes(k.state) ? "completed" : k.state, k.state)]))); | |
| 226 | - const failed = list.some((k) => ["failure", "cancelled", "timed_out", "action_required"].includes(k.state)); | |
| 227 | - const pending = list.some((k) => ["queued", "in_progress", "waiting", "pending", "requested"].includes(k.state)); | |
| 293 | + else if (render(list) !== shown) ctx.io.stdout.write(`${shown === undefined ? "" : "\n"}${render(list)}`); | |
| 294 | + const failed = list.some((k) => FAILED.includes(k.state)); | |
| 295 | + const pending = list.some((k) => PENDING.includes(k.state)); | |
| 228 | 296 | throw new ExitCode(failed ? 1 : pending ? 8 : 0); |
| 229 | 297 | }, |
| 230 | 298 | }; |
| @@ -232,7 +300,8 @@ | ||
| 232 | 300 | const review: Command = { |
| 233 | 301 | name: "pr review", |
| 234 | 302 | summary: "Approve, request changes or comment on a pull request", |
| 235 | - args: "<number>", | |
| 303 | + description: "Without a number, reviews the open pull request for the current branch.", | |
| 304 | + args: "[<number>]", | |
| 236 | 305 | options: { |
| 237 | 306 | approve: { type: "boolean", short: "a", desc: "Approve" }, |
| 238 | 307 | "request-changes": { type: "boolean", short: "r", desc: "Request changes" }, |
| @@ -241,14 +310,13 @@ | ||
| 241 | 310 | }, |
| 242 | 311 | repo: true, |
| 243 | 312 | json: true, |
| 244 | - minArgs: 1, | |
| 245 | 313 | maxArgs: 1, |
| 246 | 314 | async run(ctx, o, [num]) { |
| 247 | - const n = positiveInt(num, "pull request number"); | |
| 248 | 315 | const which = exactlyOne(o, ["approve", "request-changes", "comment"]); |
| 249 | 316 | const event = which === "approve" ? "APPROVE" : which === "request-changes" ? "REQUEST_CHANGES" : "COMMENT"; |
| 250 | 317 | const body = await bodyFrom(ctx, o); |
| 251 | 318 | if (event !== "APPROVE" && !body) throw new CliError(`--body is required with --${which}`, 2); |
| 319 | + const n = await prNumber(ctx, num); | |
| 252 | 320 | const repo = await ctx.repo(); |
| 253 | 321 | const r = await (await ctx.authedApi()).post<{ id: string; state: string }>(`${repoPath(repo)}/pulls/${n}/reviews`, { event, body }); |
| 254 | 322 | if (ctx.json) return json(ctx.io, r); |
| @@ -260,7 +328,8 @@ | ||
| 260 | 328 | const merge: Command = { |
| 261 | 329 | name: "pr merge", |
| 262 | 330 | summary: "Merge a pull request", |
| 263 | - args: "<number>", | |
| 331 | + description: "Without a number, merges the open pull request for the current branch.", | |
| 332 | + args: "[<number>]", | |
| 264 | 333 | options: { |
| 265 | 334 | merge: { type: "boolean", short: "m", desc: "Create a merge commit (default)" }, |
| 266 | 335 | squash: { type: "boolean", short: "s", desc: "Squash into one commit" }, |
| @@ -275,16 +344,15 @@ | ||
| 275 | 344 | }, |
| 276 | 345 | repo: true, |
| 277 | 346 | json: true, |
| 278 | - minArgs: 1, | |
| 279 | 347 | maxArgs: 1, |
| 280 | 348 | async run(ctx, o, [num]) { |
| 281 | - const n = positiveInt(num, "pull request number"); | |
| 282 | 349 | const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge"); |
| 283 | - const repo = await ctx.repo(); | |
| 284 | - const api = await ctx.authedApi(); | |
| 285 | 350 | const explicit = ["merge", "squash", "rebase"].some((k) => o[k] === true); |
| 286 | 351 | if (o.auto === true && o["disable-auto"] === true) throw new CliError("use only one of --auto, --disable-auto", 2); |
| 287 | 352 | if (o.auto === true && o.admin === true) throw new CliError("--admin merges now; it can't be combined with --auto", 2); |
| 353 | + const n = await prNumber(ctx, num); | |
| 354 | + const repo = await ctx.repo(); | |
| 355 | + const api = await ctx.authedApi(); | |
| 288 | 356 | if (o["disable-auto"] === true) { |
| 289 | 357 | // Like `gh pr merge --disable-auto`: turn auto-merge off. Diffinery should not land it by |
| 290 | 358 | // itself afterwards, so a pull request auto-merge (or someone) already queued leaves the queue too. |
| @@ -346,16 +414,16 @@ | ||
| 346 | 414 | const comment: Command = { |
| 347 | 415 | name: "pr comment", |
| 348 | 416 | summary: "Comment on a pull request", |
| 349 | - args: "<number>", | |
| 417 | + description: "Without a number, comments on the open pull request for the current branch.", | |
| 418 | + args: "[<number>]", | |
| 350 | 419 | options: bodyOpts, |
| 351 | 420 | repo: true, |
| 352 | 421 | json: true, |
| 353 | - minArgs: 1, | |
| 354 | 422 | maxArgs: 1, |
| 355 | 423 | async run(ctx, o, [num]) { |
| 356 | - const n = positiveInt(num, "pull request number"); | |
| 357 | 424 | const body = await bodyFrom(ctx, o, true); |
| 358 | 425 | if (!body?.trim()) throw new CliError("--body is required", 2); |
| 426 | + const n = await prNumber(ctx, num); | |
| 359 | 427 | const repo = await ctx.repo(); |
| 360 | 428 | const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body }); |
| 361 | 429 | if (ctx.json) return json(ctx.io, r); |
| @@ -366,14 +434,14 @@ | ||
| 366 | 434 | const ready: Command = { |
| 367 | 435 | name: "pr ready", |
| 368 | 436 | summary: "Mark a draft pull request ready for review", |
| 369 | - args: "<number>", | |
| 437 | + description: "Without a number, uses the open pull request for the current branch.", | |
| 438 | + args: "[<number>]", | |
| 370 | 439 | options: { undo: { type: "boolean", desc: "Convert the pull request back to a draft" } }, |
| 371 | 440 | repo: true, |
| 372 | 441 | json: true, |
| 373 | - minArgs: 1, | |
| 374 | 442 | maxArgs: 1, |
| 375 | 443 | async run(ctx, o, [num]) { |
| 376 | - const n = positiveInt(num, "pull request number"); | |
| 444 | + const n = await prNumber(ctx, num); | |
| 377 | 445 | const repo = await ctx.repo(); |
| 378 | 446 | const pr = await (await ctx.authedApi()).patch<IssueJson>(`${repoPath(repo)}/pulls/${n}`, { draft: o.undo === true }); |
| 379 | 447 | if (ctx.json) return json(ctx.io, pr); |
modifiedapps/cli/test/helpers.ts+3−1View file
| @@ -107,6 +107,8 @@ | ||
| 107 | 107 | cwd?: string; |
| 108 | 108 | stdin?: string; |
| 109 | 109 | stdinTTY?: boolean; |
| 110 | + /** Makes stdout look like a terminal (set NO_COLOR in env to keep the output plain). */ | |
| 111 | + stdoutTTY?: boolean; | |
| 110 | 112 | /** Stands in for the browser when the CLI opens one. */ |
| 111 | 113 | openUrl?: (url: string) => Promise<boolean>; |
| 112 | 114 | selfPath?: string; |
| @@ -140,7 +142,7 @@ | ||
| 140 | 142 | env: o.env ?? isolatedEnv(), |
| 141 | 143 | cwd: o.cwd ?? tempDir(), |
| 142 | 144 | stdin, |
| 143 | - stdout: { write: (s: string) => ((stdout += s), true) }, | |
| 145 | + stdout: { write: (s: string) => ((stdout += s), true), isTTY: o.stdoutTTY }, | |
| 144 | 146 | stderr: { write: (s: string) => ((stderr += s), true) }, |
| 145 | 147 | sleep: () => new Promise((r) => setTimeout(r, 1)), |
| 146 | 148 | openUrl: o.openUrl, |
modifiedapps/cli/test/pr.test.ts+92−0View file
| @@ -170,6 +170,98 @@ | ||
| 170 | 170 | expect(r.stdout).toContain("ci / test"); |
| 171 | 171 | }); |
| 172 | 172 | |
| 173 | + it("pr list --head only lists pull requests from that branch", async () => { | |
| 174 | + srv.on("GET", "/api/v3/repos/o/r/pulls", { body: [] }); | |
| 175 | + const r = await run(["pr", "list", "-R", "o/r", "--head", "topic"], { env }); | |
| 176 | + expect(r.code).toBe(0); | |
| 177 | + expect(r.stderr).toContain("No open pull requests from topic in o/r."); | |
| 178 | + expect(srv.find("GET", "/api/v3/repos/o/r/pulls")[0]!.query).toMatchObject({ head: "o:topic", state: "open" }); | |
| 179 | + }); | |
| 180 | + | |
| 181 | + it("pr view, checks, diff, comment, review, ready and merge default to the current branch's pull request", async () => { | |
| 182 | + srv.on("GET", "/api/v3/repos/acme/widgets/pulls", (req) => ({ body: req.query.head === "acme:feature/x" ? [pull(7)] : [] })); | |
| 183 | + srv.on("GET", "/api/v3/repos/acme/widgets/pulls/7", { body: { ...pull(7), checks: [{ name: "ci / test", state: "success" }] } }); | |
| 184 | + srv.on("GET", "/api/v3/repos/acme/widgets/pulls/7/files", { body: [{ filename: "a.txt", status: "modified" }] }); | |
| 185 | + srv.on("POST", "/api/v3/repos/acme/widgets/issues/7/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } }); | |
| 186 | + srv.on("POST", "/api/v3/repos/acme/widgets/pulls/7/reviews", { status: 201, body: { id: "rv1", state: "APPROVED" } }); | |
| 187 | + srv.on("PATCH", "/api/v3/repos/acme/widgets/pulls/7", { body: pull(7) }); | |
| 188 | + srv.on("PUT", "/api/v3/repos/acme/widgets/pulls/7/merge", { body: { merged: true, sha: "abc", method: "squash" } }); | |
| 189 | + const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "feature/x"); | |
| 190 | + const view = await run(["pr", "view"], { env, cwd }); | |
| 191 | + expect(view.stderr).toBe(""); | |
| 192 | + expect(view.code).toBe(0); | |
| 193 | + expect(view.stdout).toContain("#7"); | |
| 194 | + expect(srv.find("GET", "/api/v3/repos/acme/widgets/pulls")[0]!.query).toMatchObject({ head: "acme:feature/x", state: "open" }); | |
| 195 | + expect((await run(["pr", "checks"], { env, cwd })).code).toBe(0); | |
| 196 | + expect((await run(["pr", "diff", "--name-only"], { env, cwd })).stdout).toBe("a.txt\n"); | |
| 197 | + expect((await run(["pr", "comment", "-b", "hi"], { env, cwd })).stdout).toContain("Commented on #7"); | |
| 198 | + expect((await run(["pr", "review", "--approve"], { env, cwd })).stdout).toContain("Approved #7"); | |
| 199 | + expect((await run(["pr", "ready"], { env, cwd })).stdout).toContain("Marked #7 ready"); | |
| 200 | + expect((await run(["pr", "merge", "--squash"], { env, cwd })).stdout).toContain("Merged #7"); | |
| 201 | + // Usage errors are reported before any lookup. | |
| 202 | + const before = srv.requests.length; | |
| 203 | + expect((await run(["pr", "merge", "--auto", "--admin"], { env, cwd })).code).toBe(2); | |
| 204 | + expect((await run(["pr", "comment"], { env, cwd, stdinTTY: true })).code).toBe(2); | |
| 205 | + expect(srv.requests.length).toBe(before); | |
| 206 | + }); | |
| 207 | + | |
| 208 | + it("without a number, fails clearly when there is no current branch or no open pull request for it", async () => { | |
| 209 | + srv.on("GET", "/api/v3/repos/acme/widgets/pulls", { body: [] }); | |
| 210 | + const cwd = gitRepo(env, `${srv.url}/acme/widgets.git`, "lonely"); | |
| 211 | + const none = await run(["pr", "checks"], { env, cwd }); | |
| 212 | + expect(none.code).toBe(1); | |
| 213 | + expect(none.stderr).toContain("no open pull request for the current branch (lonely) in acme/widgets; pass a pull request number"); | |
| 214 | + const outside = await run(["pr", "view", "-R", "o/r"], { env }); | |
| 215 | + expect(outside.code).toBe(1); | |
| 216 | + expect(outside.stderr).toContain("no current branch"); | |
| 217 | + expect(outside.stderr).toContain("pass a pull request number"); | |
| 218 | + }); | |
| 219 | + | |
| 220 | + it("pr checks --watch polls until nothing is pending", async () => { | |
| 221 | + const states = [["queued", "queued"], ["in_progress", "success"], ["in_progress", "success"], ["failure", "success"]]; | |
| 222 | + let i = 0; | |
| 223 | + srv.on("GET", "/api/v3/repos/o/r/pulls/9", () => { | |
| 224 | + const [a, b] = states[Math.min(i++, states.length - 1)]!; | |
| 225 | + return { body: { ...pull(9), checks: [{ name: "ci / test", state: a }, { name: "lint", state: b }] } }; | |
| 226 | + }); | |
| 227 | + // Piped: one table, the final one. | |
| 228 | + const r = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "-i", "0"], { env }); | |
| 229 | + expect(r.code).toBe(1); | |
| 230 | + expect(srv.find("GET", "/api/v3/repos/o/r/pulls/9")).toHaveLength(4); | |
| 231 | + expect(r.stdout.match(/ci \/ test/g)).toHaveLength(1); | |
| 232 | + expect(r.stdout).toMatch(/ci \/ test\s+failure/); | |
| 233 | + // On a terminal: the table each time it changes. | |
| 234 | + i = 0; | |
| 235 | + const tty = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "--interval", "0"], { env: { ...env, NO_COLOR: "1" }, stdoutTTY: true }); | |
| 236 | + expect(tty.code).toBe(1); | |
| 237 | + expect(tty.stdout.match(/ci \/ test/g)).toHaveLength(3); | |
| 238 | + expect(tty.stdout).toMatch(/ci \/ test\s+queued[\s\S]*ci \/ test\s+in progress[\s\S]*ci \/ test\s+failure/); | |
| 239 | + // --json prints only the final list, and all passing exits 0. | |
| 240 | + states.splice(3, 1, ["success", "skipped"]); | |
| 241 | + i = 0; | |
| 242 | + const j = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "-i", "0", "--json"], { env }); | |
| 243 | + expect(j.code).toBe(0); | |
| 244 | + expect(JSON.parse(j.stdout)).toEqual([{ name: "ci / test", state: "success" }, { name: "lint", state: "skipped" }]); | |
| 245 | + // Without --watch, pending checks exit 8. | |
| 246 | + i = 0; | |
| 247 | + expect((await run(["pr", "checks", "9", "-R", "o/r"], { env })).code).toBe(8); | |
| 248 | + }); | |
| 249 | + | |
| 250 | + it("pr checks --watch waits for the first check right after a push, and gives up after 2 minutes", async () => { | |
| 251 | + let i = 0; | |
| 252 | + srv.on("GET", "/api/v3/repos/o/r/pulls/9", () => ({ body: { ...pull(9), checks: i++ < 2 ? [] : [{ name: "ci / test", state: i < 4 ? "in_progress" : "success" }] } })); | |
| 253 | + const r = await run(["pr", "checks", "9", "-R", "o/r", "--watch", "-i", "0"], { env }); | |
| 254 | + expect(r.code).toBe(0); | |
| 255 | + expect(srv.find("GET", "/api/v3/repos/o/r/pulls/9")).toHaveLength(4); | |
| 256 | + expect(r.stdout).toMatch(/ci \/ test\s+success/); | |
| 257 | + // A repository without CI: no checks ever appear, so it stops after 2 minutes of polls (1 s each at -i 0). | |
| 258 | + srv.on("GET", "/api/v3/repos/o/r/pulls/10", { body: { ...pull(10), checks: [] } }); | |
| 259 | + const none = await run(["pr", "checks", "10", "-R", "o/r", "--watch", "-i", "0"], { env }); | |
| 260 | + expect(none.code).toBe(0); | |
| 261 | + expect(none.stderr).toContain("No checks reported"); | |
| 262 | + expect(srv.find("GET", "/api/v3/repos/o/r/pulls/10")).toHaveLength(121); | |
| 263 | + }); | |
| 264 | + | |
| 173 | 265 | it("pr comment posts to the issue comments endpoint", async () => { |
| 174 | 266 | srv.on("POST", "/api/v3/repos/o/r/issues/9/comments", { status: 201, body: { id: "c1", body: "hi", user: { login: "alice" }, created_at: "" } }); |
| 175 | 267 | expect((await run(["pr", "comment", "9", "-R", "o/r", "-b", "hi"], { env })).code).toBe(0); |
addedapps/diffinery/docs/agents/claude-and-codex.md+145−0View file
| @@ -1,0 +1,145 @@ | ||
| 1 | +--- | |
| 2 | +title: Claude and Codex | |
| 3 | +description: Connect claude.ai, Claude Code and OpenAI Codex to Diffinery with a browser sign-in, install the Diffinery plugin, and run them headless with a token. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +Claude (claude.ai, the Claude apps and Claude Code) and OpenAI's Codex work with Diffinery through its [MCP server](/docs/agents/mcp) at `https://diffinery.com/mcp`, and through git and the [`diffinery` CLI](/docs/accounts/cli-sign-in) in a terminal. You don't need to copy a token anywhere: each app signs in through your browser, where you choose whom it acts as and what it may do. | |
| 7 | + | |
| 8 | +| You use | Fastest setup | | |
| 9 | +| --- | --- | | |
| 10 | +| claude.ai, Claude Desktop or the Claude mobile apps | Add a custom connector with the URL `https://diffinery.com/mcp`. | | |
| 11 | +| Claude Code | Install the Diffinery plugin, then sign in when Claude Code asks. | | |
| 12 | +| Codex CLI | Install the Diffinery plugin (or `codex mcp add`), then `codex mcp login diffinery`. | | |
| 13 | +| CI, cloud sessions, anything without a browser | A token in `DIFFINERY_TOKEN`. See [Headless use](#headless-use). | | |
| 14 | + | |
| 15 | +## claude.ai and the Claude apps | |
| 16 | + | |
| 17 | +1. In claude.ai, open **Settings → Connectors** and choose **Add custom connector**. (On a Team or Enterprise plan, an owner adds it once under **Organization settings → Connectors**, and each member then connects their own account.) | |
| 18 | +2. Name it `Diffinery` and enter the URL `https://diffinery.com/mcp`. Leave the OAuth client ID and secret empty. | |
| 19 | +3. Choose **Connect**. claude.ai opens Diffinery: sign in with your passkey if you aren't already, then pick whom Claude acts as and its access, and choose **Connect** (see [The consent page](#the-consent-page)). | |
| 20 | +4. In a chat, turn on Diffinery in the tools menu. Claude can now read code, open and review pull requests, follow CI and read job logs, file issues and more, under the same permissions as the account you picked. | |
| 21 | + | |
| 22 | +The connector also works in Claude Desktop and the mobile apps, and in claude.ai Projects, including Claude sessions that work on a project's threads. It's listed on Diffinery under **Settings → Access tokens → Connected apps** as **Claude**, sending you back to `claude.ai`. | |
| 23 | + | |
| 24 | +## Claude Code | |
| 25 | + | |
| 26 | +### Claude Code with the plugin | |
| 27 | + | |
| 28 | +The Diffinery plugin adds the MCP server, a skill that teaches Claude Code how Diffinery repositories work (branches, `diffinery pr create` with an `intent`, waiting for checks, reading failed job logs, auto-merge), a `/diffinery:pr-status` command, and a hook that tells Claude when the repository it is in lives on Diffinery. Its source is [codician/diffinery-plugins](https://diffinery.com/codician/diffinery-plugins). | |
| 29 | + | |
| 30 | +```sh | |
| 31 | +claude plugin marketplace add https://diffinery.com/codician/diffinery-plugins.git | |
| 32 | +claude plugin install diffinery@diffinery | |
| 33 | +``` | |
| 34 | + | |
| 35 | +Inside Claude Code, the same is `/plugin marketplace add https://diffinery.com/codician/diffinery-plugins.git` and `/plugin install diffinery@diffinery`. Restart Claude Code, run `/mcp`, select **diffinery** and choose **Authenticate**; your browser opens Diffinery's consent page. On a machine without a browser, run `claude mcp login diffinery --no-browser`, open the printed link anywhere, and paste the address you land on back into the terminal. | |
| 36 | + | |
| 37 | +### Claude Code without the plugin | |
| 38 | + | |
| 39 | +```sh | |
| 40 | +claude mcp add --transport http diffinery https://diffinery.com/mcp | |
| 41 | +``` | |
| 42 | + | |
| 43 | +Then `/mcp` → **diffinery** → **Authenticate**, as above. To share the server with everyone on a project, put it in the project's `.mcp.json`; each person signs in with their own account: | |
| 44 | + | |
| 45 | +```json | |
| 46 | +{ "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp" } } } | |
| 47 | +``` | |
| 48 | + | |
| 49 | +### Git, pull requests and CI from Claude Code | |
| 50 | + | |
| 51 | +The MCP tools need no git at all. When Claude Code works in a clone instead, give it the CLI once: | |
| 52 | + | |
| 53 | +```sh | |
| 54 | +curl -fsSL https://diffinery.com/install | sh # Windows: irm https://diffinery.com/install.ps1 | iex | |
| 55 | +diffinery login # browser sign-in, or --device on a server | |
| 56 | +diffinery auth setup-git # git push and pull use the same sign-in | |
| 57 | +diffinery auth setup-gh # gh, and Claude Code's pull request and CI status | |
| 58 | +``` | |
| 59 | + | |
| 60 | +Then the usual loop works from Claude Code with no extra setup: push a branch, `diffinery pr create --title ... --intent ...`, `diffinery pr checks --watch` (exit 0 when all passed, 1 when any failed), `diffinery run logs <job-id>` for a failure, and `diffinery pr merge --auto --squash` to merge once the required checks and approvals are in. The pull request commands use the current branch's pull request when you leave out the number. | |
| 61 | + | |
| 62 | +`diffinery auth setup-gh` signs [gh](/docs/agents/github-cli) in to diffinery.com, which is also how Claude Code shows the branch's pull request with its CI status in the terminal and the desktop app. Claude Code can then use `gh` as it would on GitHub; the `diffinery` commands still know Diffinery's extras (`--intent`, `run logs`). | |
| 63 | + | |
| 64 | +## Codex | |
| 65 | + | |
| 66 | +### Codex with the plugin | |
| 67 | + | |
| 68 | +Codex reads the same plugin repository: | |
| 69 | + | |
| 70 | +```sh | |
| 71 | +codex plugin marketplace add https://diffinery.com/codician/diffinery-plugins.git | |
| 72 | +codex plugin add diffinery@diffinery | |
| 73 | +codex mcp login diffinery | |
| 74 | +``` | |
| 75 | + | |
| 76 | +`codex mcp login` opens Diffinery's consent page in your browser; add `--no-browser` to print the link instead and paste back the address you land on. | |
| 77 | + | |
| 78 | +The plugin gives Codex the same skill, a `pr-status` skill in place of the command (Codex plugins have no commands), and the session hook, which Codex runs only once you trust it under `/hooks`. | |
| 79 | + | |
| 80 | +### Codex without the plugin | |
| 81 | + | |
| 82 | +```sh | |
| 83 | +codex mcp add diffinery --url https://diffinery.com/mcp | |
| 84 | +codex mcp login diffinery | |
| 85 | +``` | |
| 86 | + | |
| 87 | +or in `~/.codex/config.toml`: | |
| 88 | + | |
| 89 | +```toml | |
| 90 | +[mcp_servers.diffinery] | |
| 91 | +url = "https://diffinery.com/mcp" | |
| 92 | +``` | |
| 93 | + | |
| 94 | +`codex mcp list` shows the server with **Auth: OAuth** once it is signed in. Install the CLI as for Claude Code to let Codex push and drive pull requests from a clone. | |
| 95 | + | |
| 96 | +## Headless use | |
| 97 | + | |
| 98 | +Where nobody can open a browser (CI jobs, cloud agent sessions, scheduled tasks), give the client a token instead: an [agent](/docs/agents/overview)'s token is best, created under **Settings → Agents**. Put it in `DIFFINERY_TOKEN`, never in a file you commit. | |
| 99 | + | |
| 100 | +- **Claude Code:** `claude mcp add --scope user --transport http diffinery https://diffinery.com/mcp --header 'Authorization: Bearer ${DIFFINERY_TOKEN}'` (with the plugin installed, this entry takes the place of the plugin's own), or in `.mcp.json`, `"headers": { "Authorization": "Bearer ${DIFFINERY_TOKEN}" }`. `diffinery mcp-config` prints this snippet. | |
| 101 | +- **Codex:** `codex mcp add diffinery --url https://diffinery.com/mcp --bearer-token-env-var DIFFINERY_TOKEN`, or `bearer_token_env_var = "DIFFINERY_TOKEN"` under `[mcp_servers.diffinery]`; it takes the place of the plugin's entry. The token and the browser sign-in can't share one entry: with the header set, neither client falls back to OAuth. | |
| 102 | +- **git, gh and the CLI:** the CLI reads `DIFFINERY_TOKEN`; run `diffinery auth setup-git` once so git uses it too, and `diffinery auth setup-gh` for gh. | |
| 103 | +- **Claude Code on the web and other cloud sessions:** allow `diffinery.com` in the environment's network settings, add `DIFFINERY_TOKEN` as an environment secret, and clone over HTTPS. | |
| 104 | + | |
| 105 | +## How sign-in works | |
| 106 | + | |
| 107 | +Diffinery follows the [MCP authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization) (OAuth 2.1), so any MCP client that supports it can connect, not only Claude and Codex. | |
| 108 | + | |
| 109 | +1. The client calls `/mcp` without a token and gets `401` with `WWW-Authenticate: Bearer resource_metadata="https://diffinery.com/.well-known/oauth-protected-resource/mcp"`. | |
| 110 | +2. It reads that document and the authorization server's at `/.well-known/oauth-authorization-server`. | |
| 111 | +3. It identifies itself: by the URL of its own client metadata document (Claude Code uses `https://claude.ai/oauth/claude-code-client-metadata`, Codex `https://chatgpt.com/oauth/codex/client.json`), or by registering at `/oauth/register` (RFC 7591), as claude.ai and most other clients do. | |
| 112 | +4. It sends you to `/oauth/authorize` with a PKCE challenge. You approve on [the consent page](#the-consent-page). | |
| 113 | +5. Diffinery sends your browser back to the address the app registered, with a one-time code that only works together with the PKCE secret the app kept. The app trades it at `/oauth/token` for an access token and a refresh token. | |
| 114 | + | |
| 115 | +| | | | |
| 116 | +| --- | --- | | |
| 117 | +| Access token | An ordinary Diffinery token (`dfy_…`) for the account you picked, with the access you picked. It lasts 8 hours and works everywhere a token does (MCP, the REST API, git), with every permission check and branch rule that applies to that account. | | |
| 118 | +| Refresh token | `dfr_…`. Each use returns a new one and retires the old. A connection that isn't used for 30 days lapses. A retired refresh token used again later is taken as stolen and ends the connection. | | |
| 119 | +| Redirect addresses | `https` anywhere, `http` only on your own computer (`localhost`, `127.0.0.1`), or an app's own scheme such as `cursor://`. Loopback addresses may use any port. | | |
| 120 | +| Endpoints | `/.well-known/oauth-protected-resource/mcp`, `/.well-known/oauth-authorization-server`, `/oauth/register`, `/oauth/authorize`, `/oauth/token`, `/oauth/revoke` (RFC 7009). | | |
| 121 | +| Grants | Authorization code with PKCE (S256 only), and refresh tokens. No implicit, password or client credentials grants. | | |
| 122 | + | |
| 123 | +### The consent page | |
| 124 | + | |
| 125 | +The page names the app and where it will send you back. An app identified by a client metadata document shows the site that publishes it ("An app published by claude.ai"); an app that registered itself shows the name it chose, marked as not checked by Diffinery. Only connect an app you are setting up yourself at that moment. | |
| 126 | + | |
| 127 | +- **Act as:** yourself, or one of your agents (yours, or an organization's you own or administer). Acting as an agent is a good default for an AI app: its commits and comments show as the agent's, on your behalf, and its pull requests need a person's approval, so nothing it does lands unreviewed. | |
| 128 | +- **Access:** Read, Write (preselected when the app asks for more) or Admin. Admin is never preselected, and isn't offered for a site administrator's own account: an app would carry site-wide administration in a token. | |
| 129 | + | |
| 130 | +Approving needs a passkey sign-in from the last 2 hours, like creating a token. | |
| 131 | + | |
| 132 | +### Connected apps | |
| 133 | + | |
| 134 | +**Settings → Access tokens → Connected apps** lists every app you connected: its name, where it sends you back, whom it acts as, its access, and when it was last used. **Disconnect** ends the connection and its tokens at once. Each approval is its own connection, so the same app on two computers shows twice. Connections and disconnections are in the [audit log](/docs/security/audit-log) as `oauth.connect` and `oauth.disconnect`. | |
| 135 | + | |
| 136 | +## Troubleshooting | |
| 137 | + | |
| 138 | +| Symptom | Cause and fix | | |
| 139 | +| --- | --- | | |
| 140 | +| The browser says "Can't connect this app" | The app's registration expired (registrations nobody used are removed after a week), or it asked to send you back to an address it didn't register. Remove the server or connector and add it again. | | |
| 141 | +| Claude Code shows the server as needing authentication | Run `/mcp` and choose **Authenticate**, or `claude mcp login diffinery`. | | |
| 142 | +| `codex mcp list` says **Not logged in** | Run `codex mcp login diffinery`. | | |
| 143 | +| An app that worked asks you to sign in again | The connection lapsed after 30 days unused, or it was disconnected (by you, or after a reused refresh token). Sign in again. | | |
| 144 | +| Tools fail with `this token needs the write scope` | You connected with Read access. Disconnect it under Connected apps and connect again with Write. | | |
| 145 | +| A tool fails with `agents may not push to protected or default branches` | The app acts as an agent. It should work on a branch and open a pull request; see [What agents can and can't do](/docs/agents/overview). | |
modifiedapps/diffinery/docs/agents/mcp.md+28−6View file
| @@ -22,27 +22,47 @@ | ||
| 22 | 22 | |
| 23 | 23 | ## Authentication |
| 24 | 24 | |
| 25 | -Every request needs a Diffinery token in the `Authorization` header: | |
| 25 | +There are two ways to authenticate, and every tool call goes through the same checks either way. | |
| 26 | 26 | |
| 27 | +**Sign in through the browser (OAuth).** Clients that support MCP's OAuth sign-in (claude.ai connectors, Claude Code, Codex, Cursor, VS Code) need nothing but the URL. On their first request they get a `401` whose header says where to sign in: | |
| 28 | + | |
| 27 | 29 | ```http |
| 30 | +WWW-Authenticate: Bearer realm="Diffinery", resource_metadata="https://diffinery.com/.well-known/oauth-protected-resource/mcp" | |
| 31 | +``` | |
| 32 | + | |
| 33 | +The client then opens Diffinery in your browser, where you choose whom it acts as (you, or one of your [agents](/docs/agents/overview)) and its access, and connect it. It gets tokens that last 8 hours and renew themselves; the connection is listed under **Settings → Access tokens → Connected apps**, where you can disconnect it. See [Claude and Codex](/docs/agents/claude-and-codex#how-sign-in-works) for the details. | |
| 34 | + | |
| 35 | +**A token in the header.** For scripts, CI and cloud sessions where nobody can click through a browser, send a Diffinery token: | |
| 36 | + | |
| 37 | +```http | |
| 28 | 38 | Authorization: Bearer dfy_... |
| 29 | 39 | ``` |
| 30 | 40 | |
| 31 | -`token dfy_...` and HTTP Basic auth with the token as the password also work. Use an agent's token for an AI agent (see [Agents](/docs/agents/overview)), or your own personal token (see [Tokens](/docs/accounts/tokens)). The token's scope limits what the tools can do: every tool that isn't read-only needs a token with the `write` or `admin` scope, so a `read` token can only use read-only tools (see [Token scope and write tools](#token-scope-and-write-tools)). There is no OAuth flow. | |
| 41 | +`token dfy_...` and HTTP Basic auth with the token as the password also work. Use an agent's token for an AI agent (see [Agents](/docs/agents/overview)), or your own personal token (see [Tokens](/docs/accounts/tokens)). | |
| 32 | 42 | |
| 43 | +Either way, the token's scope limits what the tools can do: every tool that isn't read-only needs the `write` or `admin` scope, so a `read` token can only use read-only tools (see [Token scope and write tools](#token-scope-and-write-tools)). | |
| 44 | + | |
| 33 | 45 | | Response | Meaning | |
| 34 | 46 | | --- | --- | |
| 35 | -| `401 {"message": "sign in required"}` | No `Authorization` header. | | |
| 36 | -| `401 {"message": "invalid, expired or revoked token"}` | The token is wrong, expired or revoked, or its account (or agent) is disabled. | | |
| 47 | +| `401 {"message": "sign in required"}` | No `Authorization` header. The `WWW-Authenticate` header points at the OAuth metadata. | | |
| 48 | +| `401 {"message": "invalid, expired or revoked token"}` | The token is wrong, expired or revoked, or its account (or agent) is disabled. `WWW-Authenticate` adds `error="invalid_token"`, so an OAuth client renews its token or signs in again. | | |
| 37 | 49 | | `401 {"message": "unrecognised token; create one under Settings → Access tokens"}` | The header doesn't hold a `dfy_` token. | |
| 38 | 50 | |
| 39 | 51 | ## Connect your client |
| 40 | 52 | |
| 41 | -Create a token first in the web UI: for an agent, in **Settings → Agents**; for yourself, in **Settings → Access tokens**. Tokens can't create tokens. Keep it out of files you commit; most clients below can read it from an environment variable. | |
| 53 | +For claude.ai, Claude Code and Codex, the [Claude and Codex](/docs/agents/claude-and-codex) page has the shortest path: a connector or a plugin, and a browser sign-in. The snippets below use a token in the header instead, for clients without OAuth and for headless use. Create the token in the web UI: for an agent, in **Settings → Agents**; for yourself, in **Settings → Access tokens**. Tokens can't create tokens. Keep it out of files you commit; most clients below can read it from an environment variable. | |
| 42 | 54 | |
| 43 | 55 | ### Claude Code |
| 44 | 56 | |
| 57 | +With a browser on hand, add the server without a header and sign in when Claude Code asks (or run `/mcp`): | |
| 58 | + | |
| 45 | 59 | ```sh |
| 60 | +claude mcp add --transport http diffinery https://diffinery.com/mcp | |
| 61 | +``` | |
| 62 | + | |
| 63 | +With a token instead: | |
| 64 | + | |
| 65 | +```sh | |
| 46 | 66 | claude mcp add --transport http diffinery https://diffinery.com/mcp \ |
| 47 | 67 | --header "Authorization: Bearer dfy_..." |
| 48 | 68 | ``` |
| @@ -466,13 +486,15 @@ | ||
| 466 | 486 | |
| 467 | 487 | - The server offers tools only: no resources, prompts or sampling. |
| 468 | 488 | - Responses are plain JSON. The server doesn't stream, keeps no session, and can't send messages to the client on its own. |
| 469 | -- Authentication is a Diffinery token in a header; there is no OAuth flow, so clients that only support OAuth for remote servers need a bridge (see [Claude Desktop and other clients that start local servers](#claude-desktop-and-other-clients-that-start-local-servers)). | |
| 489 | +- OAuth sign-in supports the authorization code flow with PKCE only, for apps that register themselves or identify by a client metadata document. There is no device flow for MCP; on a machine without a browser, use a token in the header. | |
| 470 | 490 | |
| 471 | 491 | ## Troubleshooting |
| 472 | 492 | |
| 473 | 493 | | Symptom | Cause and fix | |
| 474 | 494 | | --- | --- | |
| 475 | 495 | | The client says it can't connect or gets `401` | The `Authorization` header is missing or the token is wrong. Check the header name and the `Bearer ` prefix, and that the token hasn't expired. | |
| 496 | +| The browser says "Can't connect this app" | The app's registration expired (unused registrations are removed after a week) or it asked to send you back to an address it didn't register. Remove the server in the app and add it again. | | |
| 497 | +| An app that used to work asks you to sign in again | Its connection lapsed after 30 days unused, or you (or a security check after a reused refresh token) disconnected it. Sign in again. | | |
| 476 | 498 | | `repository not found` | Wrong `owner` or `repo`, or the account (or, for an agent, its owner) can't see the repository. | |
| 477 | 499 | | `this token needs the write scope` | The tool isn't read-only and the token has only the `read` scope. Create a `write` token (for an agent, in **Settings → Agents** while signed in). | |
| 478 | 500 | | `write access required` (or `triage access required`) | The account's role, or the token's scope, is too low for the tool. A `read` token can only use read-only tools. | |
modifiedapps/diffinery/docs/agents/overview.md+6−4View file
| @@ -121,7 +121,7 @@ | ||
| 121 | 121 | |
| 122 | 122 | | Rule | Details | How to relax it | |
| 123 | 123 | | --- | --- | --- | |
| 124 | -| No pushes to the default branch or protected branches | Pushes, `commit_files`, the contents API, the git data refs API, the merges API, update-branch and branch updates are refused with `agents may not push to protected or default branches; open a pull request`. | Tick **Allow agents to push directly** (`agentsMayPush`) on that branch's protection rule. | | |
| 124 | +| No pushes to the default branch or protected branches | Pushes, `commit_files`, the contents API, the git data refs API, the merges API, update-branch and branch updates are refused with `agents may not push to protected or default branches; open a pull request`. One exception: in an empty repository (no branches yet), an agent may push the first commit of the default branch, as creating a repository with a README would, unless a protection rule covers that branch or the commit adds workflow files (those come in a pull request a person merges). | Tick **Allow agents to push directly** (`agentsMayPush`) on that branch's protection rule. | | |
| 125 | 125 | | No merges into the default branch or protected branches | The merge blocker `agents may not merge into this branch; a human must merge`. Agents can't add pull requests to a merge queue or turn on auto-merge there either. | Tick **Allow agents to merge pull requests** (`agentsMayMerge`) on the rule, or, so that CI decides instead of the agent, **Allow agents to land pull requests through auto-merge** (`allowAgentAutoMerge`, see [Agents landing changes](/docs/repositories/branch-protection#agents-landing-changes)); the agent then turns on [auto-merge](/docs/collaboration/pull-requests#auto-merge-for-agents-and-workflow-tokens) with `enable_auto_merge` or `PUT /repos/{owner}/{repo}/pulls/{number}/auto-merge`. | |
| 126 | 126 | | Pull requests opened by agents need a human approval | At least one approval from a person, even on a branch with no protection rule. When an agent has Diffinery land its pull request on the default branch or a protected branch through auto-merge or the merge queue, the approval counts only for the commit it approved. | Only for landings an agent asked for: **Let agents' auto-merges land without an approving review** (`agentAutoMergeWithoutApproval`), which lets green required checks stand in for the approval. See [Waiving approvals for agents](/docs/repositories/branch-protection#waiving-approvals-for-agents). | |
| 127 | 127 | | Agent approvals don't count | An agent's Approve review is shown but never counts toward required approvals. An agent's Request changes review still blocks. | None. | |
| @@ -233,11 +233,11 @@ | ||
| 233 | 233 | 2. **Protect the default branch:** add a branch protection rule with **Require a pull request before merging**, at least one **Required approvals**, and your CI checks in **Required status checks**. Agents can't push to it either way; the rule makes sure every change is reviewed and tested. |
| 234 | 234 | 3. **Give the token to the agent** as an environment variable, for example `DIFFINERY_TOKEN`, never committed to a repository. |
| 235 | 235 | 4. **Connect the agent** with MCP or git, as below. |
| 236 | -5. **Tell the agent how to work.** For example, in your agent instructions file: work on a new branch, open a pull request with an `intent` that states the goal, wait for checks with `diffinery pr checks` or `list_workflow_runs`, and fix failures before asking for review. | |
| 236 | +5. **Tell the agent how to work.** For example, in your agent instructions file: work on a new branch, open a pull request with an `intent` that states the goal, wait for checks with `diffinery pr checks --watch` or `list_workflow_runs`, and fix failures before asking for review. | |
| 237 | 237 | |
| 238 | 238 | ### Connect over MCP |
| 239 | 239 | |
| 240 | -For Claude Code: | |
| 240 | +claude.ai, Claude Code and Codex can sign in through your browser instead of using a token, and a plugin teaches Claude Code and Codex how Diffinery repositories work: see [Claude and Codex](/docs/agents/claude-and-codex). With a token, for Claude Code: | |
| 241 | 241 | |
| 242 | 242 | ```sh |
| 243 | 243 | claude mcp add --transport http diffinery https://diffinery.com/mcp \ |
| @@ -271,9 +271,11 @@ | ||
| 271 | 271 | git push -u origin fix-pricing-link |
| 272 | 272 | diffinery pr create --title "Fix the Contact sales link" \ |
| 273 | 273 | --intent "Point the Contact sales link at /contact; no other changes." |
| 274 | -diffinery pr checks 12 # exit 0 passed, 1 failed, 8 pending | |
| 274 | +diffinery pr checks --watch # wait for this branch's pull request: exit 0 passed, 1 failed | |
| 275 | 275 | ``` |
| 276 | 276 | |
| 277 | +As with `gh`, `pr view`, `pr checks`, `pr diff`, `pr comment`, `pr review`, `pr ready` and `pr merge` act on the open pull request for the current branch when you leave out its number. Without `--watch`, `diffinery pr checks` returns at once and exits `8` while checks are still pending. | |
| 278 | + | |
| 277 | 279 | The CLI reads `DIFFINERY_TOKEN` (and `DIFFINERY_URL`, which defaults to `https://diffinery.com`) from the environment, so no `diffinery login` is needed. Without the CLI, use the token as the git password: `git clone https://x-token:dfy_...@diffinery.com/acme/website.git`. |
| 278 | 280 | |
| 279 | 281 | ## Differences from GitHub |
modifiedapps/diffinery/docs/collaboration/pull-requests.md+5−2View file
| @@ -365,7 +365,7 @@ | ||
| 365 | 365 | |
| 366 | 366 | - The merge box lists every check with its state: `All checks have passed`, `N failing checks`, or `Some checks haven't completed yet`. |
| 367 | 367 | - The **Checks** tab shows each check and the pull request's workflow runs. |
| 368 | -- `diffinery pr checks 12` prints them and exits `0` when all passed, `1` when any failed, and `8` when some are still pending, so scripts can wait on it. | |
| 368 | +- `diffinery pr checks 12` prints them and exits `0` when all passed, `1` when any failed, and `8` when some are still pending, so scripts can wait on it. Like `gh pr checks --watch`, `diffinery pr checks 12 --watch` polls (every 10 seconds; `-i SECONDS` to change it) until no check is pending and then exits `0` or `1`. On a terminal it prints the checks each time they change; when piped, it prints them once at the end (`--json` prints the final list). | |
| 369 | 369 | - `GET /repos/{owner}/{repo}/pulls/12` includes `checks` (name and state). |
| 370 | 370 | |
| 371 | 371 | A check counts as passing when its conclusion is `success`, `skipped` or `neutral` (a status: when its state is `success`). Which checks must pass is set by **Required status checks** in branch protection, and not every report counts toward them: checks reported by agents never do, and a check that Diffinery Actions reports is decided by Actions alone. See [Branch protection](/docs/repositories/branch-protection#required-checks). |
| @@ -879,12 +879,15 @@ | ||
| 879 | 879 | |
| 880 | 880 | ```sh |
| 881 | 881 | diffinery pr list --state merged --author diffy --limit 20 |
| 882 | +diffinery pr list --head fix-pricing-link | |
| 882 | 883 | diffinery pr view 12 --comments |
| 883 | 884 | diffinery pr diff 12 --name-only |
| 884 | 885 | ``` |
| 885 | 886 | |
| 886 | -`pr list --state` accepts `open` (default), `closed`, `merged` or `all`. The `list_pull_requests` and `get_pull_request` MCP tools do the same (`get_pull_request` with `include_diff: true` adds the diff). | |
| 887 | +`pr list --state` accepts `open` (default), `closed`, `merged` or `all`, and `pr list --head BRANCH` lists only pull requests from that branch. | |
| 887 | 888 | |
| 889 | +As with `gh`, the pull request number is optional for `pr view`, `pr checks`, `pr diff`, `pr comment`, `pr review`, `pr ready` and `pr merge`: without it, the CLI uses the open pull request whose head is your current git branch, in the repository of your `origin` remote (or `-R OWNER/NAME`). With no current branch (a detached HEAD) or no open pull request for it, the command fails and asks for a number. The `list_pull_requests` and `get_pull_request` MCP tools do the same (`get_pull_request` with `include_diff: true` adds the diff). | |
| 890 | + | |
| 888 | 891 | The REST API is `GET /repos/{owner}/{repo}/pulls`. Closed results include merged ones. Results come back as a page, with a `Link` header for the next pages and `x-total-count` with the total. |
| 889 | 892 | |
| 890 | 893 | | Parameter | Meaning | |
addedapps/diffinery/migrations/0036_oauth.sql+57−0View file
| @@ -1,0 +1,57 @@ | ||
| 1 | +-- OAuth 2.1 sign-in for MCP clients (auth/oauth.ts): claude.ai connectors, Claude Code, Codex, | |
| 2 | +-- Cursor and other apps connect to /mcp by sending the person through /oauth/authorize instead of | |
| 3 | +-- asking for a pasted token. Only hashes of codes, refresh tokens and client secrets are stored. | |
| 4 | + | |
| 5 | +-- Apps that registered themselves (RFC 7591 dynamic registration, client_id "dfc_…") or that | |
| 6 | +-- identify by a client metadata document (client_id is the document's https URL, cached here). | |
| 7 | +CREATE TABLE IF NOT EXISTS oauth_clients ( | |
| 8 | + id TEXT PRIMARY KEY, | |
| 9 | + kind TEXT NOT NULL CHECK (kind IN ('registered', 'metadata')), | |
| 10 | + name TEXT NOT NULL, | |
| 11 | + client_uri TEXT, | |
| 12 | + redirect_uris TEXT NOT NULL, -- JSON array | |
| 13 | + auth_method TEXT NOT NULL DEFAULT 'none' CHECK (auth_method IN ('none', 'client_secret_basic', 'client_secret_post')), | |
| 14 | + secret_hash TEXT, | |
| 15 | + software TEXT, -- software_id and software_version as the app sent them | |
| 16 | + created_at TEXT NOT NULL, | |
| 17 | + fetched_at TEXT, -- metadata documents: when the document was last read | |
| 18 | + used_at TEXT | |
| 19 | +); | |
| 20 | + | |
| 21 | +-- One connection: a person allowed an app to act as them or as one of their agents. Its access | |
| 22 | +-- tokens are ordinary tokens (tokens.oauth_grant_id); revoking the connection revokes them. | |
| 23 | +CREATE TABLE IF NOT EXISTS oauth_grants ( | |
| 24 | + id TEXT PRIMARY KEY, | |
| 25 | + client_id TEXT NOT NULL, | |
| 26 | + client_name TEXT NOT NULL, -- as shown when the person approved it | |
| 27 | + redirect_host TEXT NOT NULL, | |
| 28 | + person_id TEXT NOT NULL, -- who approved it | |
| 29 | + account_id TEXT NOT NULL, -- whom its tokens act as: the person or one of their agents | |
| 30 | + scope TEXT NOT NULL, | |
| 31 | + refresh_hash TEXT UNIQUE, | |
| 32 | + prev_refresh_hash TEXT, -- the refresh token it replaced, to spot a stolen one being reused | |
| 33 | + refreshed_at TEXT, | |
| 34 | + refresh_expires_at TEXT NOT NULL, | |
| 35 | + created_at TEXT NOT NULL, | |
| 36 | + used_at TEXT, | |
| 37 | + revoked_at TEXT | |
| 38 | +); | |
| 39 | +CREATE INDEX IF NOT EXISTS oauth_grants_person ON oauth_grants(person_id, revoked_at); | |
| 40 | +CREATE INDEX IF NOT EXISTS oauth_grants_prev ON oauth_grants(prev_refresh_hash); | |
| 41 | + | |
| 42 | +-- Authorization codes: one use, a few minutes, bound to the PKCE challenge and redirect URI. | |
| 43 | +CREATE TABLE IF NOT EXISTS oauth_codes ( | |
| 44 | + code_hash TEXT PRIMARY KEY, | |
| 45 | + client_id TEXT NOT NULL, | |
| 46 | + redirect_uri TEXT NOT NULL, | |
| 47 | + code_challenge TEXT NOT NULL, | |
| 48 | + person_id TEXT NOT NULL, | |
| 49 | + account_id TEXT NOT NULL, | |
| 50 | + scope TEXT NOT NULL, | |
| 51 | + created_at TEXT NOT NULL, | |
| 52 | + expires_at TEXT NOT NULL, | |
| 53 | + used_at TEXT | |
| 54 | +); | |
| 55 | + | |
| 56 | +ALTER TABLE tokens ADD COLUMN oauth_grant_id TEXT; | |
| 57 | +CREATE INDEX IF NOT EXISTS tokens_oauth_grant ON tokens(oauth_grant_id); |
modifiedapps/diffinery/src/app.ts+11−3View file
| @@ -41,6 +41,8 @@ | ||
| 41 | 41 | import { expireExports } from "./accounts/export.js"; |
| 42 | 42 | import { cliLogin } from "./http/cli-login.js"; |
| 43 | 43 | import { CliLogins } from "./auth/cli-login.js"; |
| 44 | +import { OAUTH_API_PATH, oauthApi } from "./http/oauth.js"; | |
| 45 | +import { OAuth } from "./auth/oauth.js"; | |
| 44 | 46 | |
| 45 | 47 | export const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 46 | 48 | |
| @@ -54,7 +56,10 @@ | ||
| 54 | 56 | app.use("*", async (c, next) => { |
| 55 | 57 | // Browser sessions ride on cookies, so refuse state-changing requests that come from another site. |
| 56 | 58 | // Token-authenticated calls (git, CLI, MCP, runners) carry no ambient credentials and are exempt. |
| 57 | - if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && !credentialFrom(c.req.raw)) { | |
| 59 | + // OAuth's token, registration and revocation endpoints use no cookies and take client credentials | |
| 60 | + // of their own (http/oauth.ts): any site's MCP client may call them, and they skip the session lookup. | |
| 61 | + const oauthCall = OAUTH_API_PATH.test(c.req.path); | |
| 62 | + if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && !oauthCall && !credentialFrom(c.req.raw)) { | |
| 58 | 63 | const site = c.req.header("sec-fetch-site"); |
| 59 | 64 | const origin = c.req.header("origin"); |
| 60 | 65 | const ours = new URL(c.env.PUBLIC_URL).origin; |
| @@ -63,7 +68,7 @@ | ||
| 63 | 68 | } |
| 64 | 69 | } |
| 65 | 70 | // One D1 session per request: replica reads that still see this client's earlier writes (see lib/d1.ts). |
| 66 | - const token = !!credentialFrom(c.req.raw); | |
| 71 | + const token = !oauthCall && !!credentialFrom(c.req.raw); | |
| 67 | 72 | const cookie = getCookie(c, BOOKMARK_COOKIE); |
| 68 | 73 | const d1 = d1Session(c.env, sessionConstraint({ header: c.req.header(BOOKMARK_HEADER), cookie, token })); |
| 69 | 74 | c.env = d1.env; |
| @@ -73,7 +78,7 @@ | ||
| 73 | 78 | const runtime = c.req.path.startsWith("/twirp/") || c.req.path.startsWith("/_runtime/"); |
| 74 | 79 | // Avatar images are public and on every page many times over: skip the session lookup for them. |
| 75 | 80 | const avatar = (c.req.method === "GET" || c.req.method === "HEAD") && c.req.path.startsWith("/api/avatars/"); |
| 76 | - c.set("principal", runtime || avatar ? { type: "anonymous" } : await resolvePrincipal(c.req.raw, c.env, dir)); | |
| 81 | + c.set("principal", runtime || avatar || oauthCall ? { type: "anonymous" } : await resolvePrincipal(c.req.raw, c.env, dir)); | |
| 77 | 82 | const limited = await overLimit(c.env, bucketsFor(c.req.method, c.req.path, c.get("principal"), c.req.header("cf-connecting-ip") ?? "unknown")); |
| 78 | 83 | if (limited) c.res = tooMany(c, limited); |
| 79 | 84 | else await next(); |
| @@ -97,6 +102,7 @@ | ||
| 97 | 102 | |
| 98 | 103 | app.route("/", avatars); |
| 99 | 104 | app.route("/", cliLogin); |
| 105 | +app.route("/", oauthApi); | |
| 100 | 106 | app.route("/", wellKnown); |
| 101 | 107 | app.route("/", securityTxtRoutes); |
| 102 | 108 | app.route("/", git); |
| @@ -260,6 +266,8 @@ | ||
| 260 | 266 | (at.getUTCMinutes() < 5 ? pruneExpired(env.DB, at) : Promise.resolve(null)).catch((e) => console.error("retention pruning failed", e)), |
| 261 | 267 | // CLI sign-ins a day past their expiry (auth/cli-login.ts). |
| 262 | 268 | new CliLogins(env).prune().catch((e) => console.error("cli sign-in pruning failed", e)), |
| 269 | + // Spent OAuth codes, expired connected-app tokens, old connections and unused registrations (auth/oauth.ts). | |
| 270 | + new OAuth(env).prune().catch((e) => console.error("oauth pruning failed", e)), | |
| 263 | 271 | // Account exports past their 7 days (accounts/export.ts). |
| 264 | 272 | expireExports(env).catch((e) => console.error("export expiry failed", e)), |
| 265 | 273 | // Expire abandoned draft bulk imports after 7 days, and null their stored GitHub token (Low 5). |
addedapps/diffinery/src/auth/oauth.ts+559−0View file
| @@ -1,0 +1,559 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import { Directory, type Account, type TokenScope } from "../lib/directory.js"; | |
| 3 | +import { checkPublicUrl } from "../imports/options.js"; | |
| 4 | +import { HttpError, b64url, fromB64, newId, now, randomToken, sha256 } from "../lib/util.js"; | |
| 5 | +import { localOnly } from "./dev.js"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * OAuth 2.1 for MCP clients, so people connect an app (a claude.ai connector, Claude Code, Codex, | |
| 9 | + * Cursor, VS Code) to Diffinery by approving it in the browser instead of pasting a token. It | |
| 10 | + * follows the MCP authorization spec: Diffinery is both the resource (/mcp, described at | |
| 11 | + * /.well-known/oauth-protected-resource) and its authorization server (/.well-known/oauth-authorization-server). | |
| 12 | + * | |
| 13 | + * - Apps identify by registering themselves (RFC 7591, client_id "dfc_…"), or by a client metadata | |
| 14 | + * document whose https URL is the client_id (as the MCP spec's 2025-11-25 revision prefers). | |
| 15 | + * - Only the authorization code flow with PKCE S256. Redirect URIs must match what the app | |
| 16 | + * registered exactly, except that a loopback http URI may use any port (RFC 8252). | |
| 17 | + * - The person approves on /oauth/authorize, with a passkey sign-in from the last 2 hours, and picks | |
| 18 | + * whom the app acts as (themselves or one of their agents) and its access (read, write or admin; | |
| 19 | + * admin is never preselected). | |
| 20 | + * - Access tokens are ordinary Diffinery tokens (dfy_…) that last 8 hours, so they work for git, | |
| 21 | + * the REST API and the CLI as well as MCP, with every permission check those apply. Refresh tokens | |
| 22 | + * (dfr_…) rotate on each use and lapse after 30 days without one; a stolen one used after it was | |
| 23 | + * replaced ends the connection. | |
| 24 | + * - Each connection is listed under Settings → Access tokens → Connected apps, where revoking it | |
| 25 | + * revokes its tokens too. | |
| 26 | + */ | |
| 27 | + | |
| 28 | +export const ACCESS_TTL_S = 8 * 3600; | |
| 29 | +export const REFRESH_IDLE_DAYS = 30; | |
| 30 | +export const CODE_TTL_S = 300; | |
| 31 | +/** A refresh token used again this soon after it was replaced is a client retrying, not a thief: refused, connection kept. */ | |
| 32 | +export const REUSE_GRACE_S = 120; | |
| 33 | +/** Scopes an app may ask for (the token scopes); the person decides. Not imported from principal.ts, which would be an import cycle. */ | |
| 34 | +export const OAUTH_SCOPES: readonly TokenScope[] = ["read", "write", "admin"]; | |
| 35 | +const MAX_REDIRECTS = 10; | |
| 36 | +const MAX_URI = 2000; | |
| 37 | +const METADATA_TTL_MS = 86400_000; | |
| 38 | +const METADATA_MAX_BYTES = 8192; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * Copies of the metadata documents of the two clients this is mostly for, used only when reading | |
| 42 | + * the live document fails (chatgpt.com's bot protection refuses some servers). Both send people | |
| 43 | + * back only to a loopback address on their own computer, so a copy can't send a code elsewhere. | |
| 44 | + */ | |
| 45 | +const KNOWN_CLIENTS: Record<string, { name: string; clientUri: string; redirectUris: string[] }> = { | |
| 46 | + "https://claude.ai/oauth/claude-code-client-metadata": { name: "Claude Code", clientUri: "https://claude.ai", redirectUris: ["http://localhost/callback", "http://127.0.0.1/callback"] }, | |
| 47 | + "https://chatgpt.com/oauth/codex/client.json": { name: "Codex", clientUri: "https://chatgpt.com/codex", redirectUris: ["http://127.0.0.1/callback", "http://localhost/callback"] }, | |
| 48 | +}; | |
| 49 | + | |
| 50 | +export type AuthMethod = "none" | "client_secret_basic" | "client_secret_post"; | |
| 51 | + | |
| 52 | +export interface OAuthClient { | |
| 53 | + id: string; | |
| 54 | + kind: "registered" | "metadata"; | |
| 55 | + name: string; | |
| 56 | + client_uri: string | null; | |
| 57 | + redirect_uris: string[]; | |
| 58 | + auth_method: AuthMethod; | |
| 59 | + secret_hash: string | null; | |
| 60 | +} | |
| 61 | + | |
| 62 | +export interface OAuthGrant { | |
| 63 | + id: string; | |
| 64 | + client_id: string; | |
| 65 | + client_name: string; | |
| 66 | + redirect_host: string; | |
| 67 | + person_id: string; | |
| 68 | + account_id: string; | |
| 69 | + scope: TokenScope; | |
| 70 | + created_at: string; | |
| 71 | + used_at: string | null; | |
| 72 | + refresh_expires_at: string; | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** An error the OAuth endpoints answer with ({error, error_description}), RFC 6749 §5.2. */ | |
| 76 | +export class OAuthError extends Error { | |
| 77 | + constructor( | |
| 78 | + readonly error: "invalid_request" | "invalid_client" | "invalid_grant" | "unauthorized_client" | "unsupported_grant_type" | "invalid_scope" | "invalid_target" | "invalid_redirect_uri" | "invalid_client_metadata" | "access_denied" | "server_error", | |
| 79 | + readonly description: string, | |
| 80 | + readonly status = 400, | |
| 81 | + ) { | |
| 82 | + super(description); | |
| 83 | + } | |
| 84 | +} | |
| 85 | + | |
| 86 | +const base = (env: Pick<Env, "PUBLIC_URL">) => env.PUBLIC_URL.replace(/\/$/, ""); | |
| 87 | + | |
| 88 | +/** RFC 9728: what /mcp is and who issues tokens for it. */ | |
| 89 | +export function protectedResourceMetadata(env: Pick<Env, "PUBLIC_URL">) { | |
| 90 | + const b = base(env); | |
| 91 | + return { | |
| 92 | + resource: `${b}/mcp`, | |
| 93 | + authorization_servers: [b], | |
| 94 | + scopes_supported: [...OAUTH_SCOPES], | |
| 95 | + bearer_methods_supported: ["header"], | |
| 96 | + resource_name: "Diffinery", | |
| 97 | + resource_documentation: `${b}/docs/agents/mcp`, | |
| 98 | + }; | |
| 99 | +} | |
| 100 | + | |
| 101 | +/** RFC 8414: the authorization server's endpoints and what it supports. */ | |
| 102 | +export function authorizationServerMetadata(env: Pick<Env, "PUBLIC_URL">) { | |
| 103 | + const b = base(env); | |
| 104 | + return { | |
| 105 | + issuer: b, | |
| 106 | + authorization_endpoint: `${b}/oauth/authorize`, | |
| 107 | + token_endpoint: `${b}/oauth/token`, | |
| 108 | + registration_endpoint: `${b}/oauth/register`, | |
| 109 | + revocation_endpoint: `${b}/oauth/revoke`, | |
| 110 | + scopes_supported: [...OAUTH_SCOPES], | |
| 111 | + response_types_supported: ["code"], | |
| 112 | + response_modes_supported: ["query"], | |
| 113 | + grant_types_supported: ["authorization_code", "refresh_token"], | |
| 114 | + code_challenge_methods_supported: ["S256"], | |
| 115 | + token_endpoint_auth_methods_supported: ["none", "client_secret_basic", "client_secret_post"], | |
| 116 | + revocation_endpoint_auth_methods_supported: ["none", "client_secret_basic", "client_secret_post"], | |
| 117 | + authorization_response_iss_parameter_supported: true, | |
| 118 | + client_id_metadata_document_supported: true, | |
| 119 | + service_documentation: `${b}/docs/agents/claude-and-codex`, | |
| 120 | + }; | |
| 121 | +} | |
| 122 | + | |
| 123 | +/** The WWW-Authenticate header a 401 from /mcp carries, so a client finds where to sign in (RFC 9728 §5.1). */ | |
| 124 | +export const mcpChallenge = (env: Pick<Env, "PUBLIC_URL">, error?: "invalid_token") => | |
| 125 | + `Bearer realm="Diffinery", resource_metadata="${base(env)}/.well-known/oauth-protected-resource/mcp"${error ? `, error="${error}"` : ""}`; | |
| 126 | + | |
| 127 | +// ---------------------------------------------------------------- redirect URIs | |
| 128 | + | |
| 129 | +const LOOPBACK_HOST = /^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/; | |
| 130 | +/** Schemes an app's redirect URI may never use: they run code, read files, or aren't apps. */ | |
| 131 | +const BAD_SCHEMES = new Set(["javascript:", "data:", "vbscript:", "file:", "blob:", "about:", "filesystem:", "ftp:", "ws:", "wss:", "mailto:", "tel:", "sms:", "view-source:", "chrome:", "chrome-extension:", "moz-extension:", "intent:", "content:", "jar:"]); | |
| 132 | + | |
| 133 | +/** | |
| 134 | + * A redirect URI an app may register: https on any host, http only on this computer (RFC 8252 | |
| 135 | + * loopback), or an app's own scheme such as cursor:// or vscode://. Never a fragment or credentials. | |
| 136 | + */ | |
| 137 | +export function validRedirectUri(raw: unknown): raw is string { | |
| 138 | + if (typeof raw !== "string" || !raw || raw.length > MAX_URI) return false; | |
| 139 | + let u: URL; | |
| 140 | + try { | |
| 141 | + u = new URL(raw); | |
| 142 | + } catch { | |
| 143 | + return false; | |
| 144 | + } | |
| 145 | + if (u.hash || raw.includes("#") || u.username || u.password) return false; | |
| 146 | + if (u.protocol === "https:") return !!u.hostname; | |
| 147 | + if (u.protocol === "http:") return LOOPBACK_HOST.test(u.hostname); | |
| 148 | + if (BAD_SCHEMES.has(u.protocol)) return false; | |
| 149 | + // A private-use scheme (RFC 8252 §7.1): letters, digits, + . -, starting with a letter. | |
| 150 | + return /^[a-z][a-z0-9+.-]*:$/.test(u.protocol); | |
| 151 | +} | |
| 152 | + | |
| 153 | +/** Whether `asked` is one of `registered`: exactly, or a loopback http URI that differs only in its port. */ | |
| 154 | +export function redirectMatches(registered: string[], asked: string): boolean { | |
| 155 | + if (registered.includes(asked)) return true; | |
| 156 | + let a: URL; | |
| 157 | + try { | |
| 158 | + a = new URL(asked); | |
| 159 | + } catch { | |
| 160 | + return false; | |
| 161 | + } | |
| 162 | + if (a.protocol !== "http:" || !LOOPBACK_HOST.test(a.hostname)) return false; | |
| 163 | + return registered.some((r) => { | |
| 164 | + try { | |
| 165 | + const u = new URL(r); | |
| 166 | + return u.protocol === "http:" && u.hostname === a.hostname && u.pathname === a.pathname && u.search === a.search; | |
| 167 | + } catch { | |
| 168 | + return false; | |
| 169 | + } | |
| 170 | + }); | |
| 171 | +} | |
| 172 | + | |
| 173 | +/** What the person sees as where they'll be sent back: a host, or an app's scheme. */ | |
| 174 | +export function redirectLabel(uri: string): string { | |
| 175 | + const u = new URL(uri); | |
| 176 | + if (u.protocol === "http:" && LOOPBACK_HOST.test(u.hostname)) return "an app on this computer"; | |
| 177 | + return u.protocol === "https:" ? u.host : `${u.protocol}//${u.host}`; | |
| 178 | +} | |
| 179 | + | |
| 180 | +/** The CSP form-action source that lets the consent form lead to this redirect URI. */ | |
| 181 | +export function formActionSource(uri: string): string { | |
| 182 | + const u = new URL(uri); | |
| 183 | + if (u.protocol === "https:" || u.protocol === "http:") return u.origin; | |
| 184 | + return u.protocol; | |
| 185 | +} | |
| 186 | + | |
| 187 | +/** The access an app asked for, as a starting choice: the widest of read and write it names, never admin. */ | |
| 188 | +export function requestedScope(raw: string | undefined | null): TokenScope { | |
| 189 | + const words = (raw ?? "").split(/[\s+,]+/).filter(Boolean); | |
| 190 | + if (!words.length) return "write"; | |
| 191 | + if (words.includes("write") || words.includes("admin")) return "write"; | |
| 192 | + if (words.includes("read")) return "read"; | |
| 193 | + return "write"; | |
| 194 | +} | |
| 195 | + | |
| 196 | +/** The resource indicator (RFC 8707), when sent, must name this server or its /mcp. */ | |
| 197 | +export function checkResource(env: Pick<Env, "PUBLIC_URL">, resource: string | undefined | null): void { | |
| 198 | + if (!resource) return; | |
| 199 | + const b = base(env); | |
| 200 | + const r = resource.replace(/\/+$/, ""); | |
| 201 | + if (r !== b && r !== `${b}/mcp`) throw new OAuthError("invalid_target", `resource must be ${b}/mcp`); | |
| 202 | +} | |
| 203 | + | |
| 204 | +const cleanText = (v: unknown, max: number) => | |
| 205 | + (typeof v === "string" ? v : "") | |
| 206 | + .replace(/[\u0000-\u001f\u007f<>]/g, "") | |
| 207 | + .replace(/\s+/g, " ") | |
| 208 | + .trim() | |
| 209 | + .slice(0, max); | |
| 210 | + | |
| 211 | +/** A client_id that is a metadata document's URL: https with a path, no fragment or credentials. */ | |
| 212 | +export function isMetadataClientId(id: string, local = false): boolean { | |
| 213 | + // Plain http only for a Worker on this computer (the end-to-end tests' document server). | |
| 214 | + if (!(id.startsWith("https://") || (local && id.startsWith("http://"))) || id.length > MAX_URI) return false; | |
| 215 | + try { | |
| 216 | + const u = new URL(id); | |
| 217 | + return u.pathname.length > 1 && !u.hash && !u.username && !u.password; | |
| 218 | + } catch { | |
| 219 | + return false; | |
| 220 | + } | |
| 221 | +} | |
| 222 | + | |
| 223 | +/** | |
| 224 | + * Whom a person may connect an app as: themselves, an agent they own, or an agent of an organization | |
| 225 | + * they own or administer. Checked when they approve, and again on every refresh, so losing the | |
| 226 | + * right ends the connection. | |
| 227 | + */ | |
| 228 | +/** | |
| 229 | + * The access a person may give an app acting as `account`. A site administrator's account can't be connected | |
| 230 | + * with admin access: an app would carry site-wide administration in a token, without the passkey step-up. | |
| 231 | + */ | |
| 232 | +export const adminAllowed = (account: Account) => !account.site_admin; | |
| 233 | + | |
| 234 | +export async function canActAs(dir: Directory, person: Account, account: Account): Promise<boolean> { | |
| 235 | + if (person.kind !== "user" || person.status !== "active" || person.suspended_at || account.status !== "active") return false; | |
| 236 | + if (account.id === person.id) return true; | |
| 237 | + if (account.kind !== "agent" || !account.owner_id) return false; | |
| 238 | + if (account.owner_id === person.id) return true; | |
| 239 | + const role = await dir.orgRole(account.owner_id, person.id); | |
| 240 | + return role === "owner" || role === "admin"; | |
| 241 | +} | |
| 242 | + | |
| 243 | +// ---------------------------------------------------------------- the store | |
| 244 | + | |
| 245 | +export class OAuth { | |
| 246 | + constructor( | |
| 247 | + private env: Env, | |
| 248 | + private dir = new Directory(env), | |
| 249 | + ) {} | |
| 250 | + | |
| 251 | + private get db(): D1Database { | |
| 252 | + return this.env.DB; | |
| 253 | + } | |
| 254 | + | |
| 255 | + /** RFC 7591 dynamic registration. Anyone may register (rate limited per address); approving is what counts. */ | |
| 256 | + async register(body: Record<string, unknown>): Promise<Record<string, unknown>> { | |
| 257 | + const uris = body.redirect_uris; | |
| 258 | + if (!Array.isArray(uris) || !uris.length) throw new OAuthError("invalid_redirect_uri", "redirect_uris must list at least one URI"); | |
| 259 | + if (uris.length > MAX_REDIRECTS) throw new OAuthError("invalid_redirect_uri", `at most ${MAX_REDIRECTS} redirect_uris`); | |
| 260 | + for (const u of uris) if (!validRedirectUri(u)) throw new OAuthError("invalid_redirect_uri", `redirect URI not allowed: ${String(u).slice(0, 200)} (use https, http on localhost or 127.0.0.1, or your app's own scheme)`); | |
| 261 | + const method = (body.token_endpoint_auth_method ?? "none") as AuthMethod; | |
| 262 | + if (!["none", "client_secret_basic", "client_secret_post"].includes(method)) throw new OAuthError("invalid_client_metadata", "token_endpoint_auth_method must be none, client_secret_basic or client_secret_post"); | |
| 263 | + const grants = body.grant_types; | |
| 264 | + if (grants !== undefined && (!Array.isArray(grants) || grants.some((g) => g !== "authorization_code" && g !== "refresh_token"))) throw new OAuthError("invalid_client_metadata", "grant_types may be authorization_code and refresh_token"); | |
| 265 | + const responses = body.response_types; | |
| 266 | + if (responses !== undefined && (!Array.isArray(responses) || responses.some((r) => r !== "code"))) throw new OAuthError("invalid_client_metadata", "response_types may only be code"); | |
| 267 | + const name = cleanText(body.client_name, 80) || "Unnamed app"; | |
| 268 | + const clientUri = typeof body.client_uri === "string" && /^https:\/\//.test(body.client_uri) && body.client_uri.length <= MAX_URI ? body.client_uri : null; | |
| 269 | + const software = cleanText([body.software_id, body.software_version].filter((v) => typeof v === "string").join(" "), 120) || null; | |
| 270 | + const id = `dfc_${randomToken(18)}`; | |
| 271 | + const secret = method === "none" ? null : `dfs_${randomToken(30)}`; | |
| 272 | + const at = now(); | |
| 273 | + await this.db | |
| 274 | + .prepare("INSERT INTO oauth_clients (id, kind, name, client_uri, redirect_uris, auth_method, secret_hash, software, created_at) VALUES (?, 'registered', ?, ?, ?, ?, ?, ?, ?)") | |
| 275 | + .bind(id, name, clientUri, JSON.stringify(uris), method, secret ? await sha256(secret) : null, software, at) | |
| 276 | + .run(); | |
| 277 | + return { | |
| 278 | + client_id: id, | |
| 279 | + ...(secret ? { client_secret: secret, client_secret_expires_at: 0 } : {}), | |
| 280 | + client_id_issued_at: Math.floor(Date.parse(at) / 1000), | |
| 281 | + client_name: name, | |
| 282 | + redirect_uris: uris, | |
| 283 | + grant_types: ["authorization_code", "refresh_token"], | |
| 284 | + response_types: ["code"], | |
| 285 | + token_endpoint_auth_method: method, | |
| 286 | + ...(clientUri ? { client_uri: clientUri } : {}), | |
| 287 | + }; | |
| 288 | + } | |
| 289 | + | |
| 290 | + /** | |
| 291 | + * The app a client_id names: registered here, or its metadata document (fetched, then cached a day). | |
| 292 | + * `fetch: false` (the token and revocation endpoints) uses only what an approval already fetched, so | |
| 293 | + * nobody can make Diffinery fetch and store documents without signing in. | |
| 294 | + */ | |
| 295 | + async client(id: string | undefined | null, opts: { fetch?: boolean } = {}): Promise<OAuthClient | null> { | |
| 296 | + if (!id || id.length > MAX_URI) return null; | |
| 297 | + const row = await this.db.prepare("SELECT * FROM oauth_clients WHERE id = ?").bind(id).first<Record<string, string | null>>(); | |
| 298 | + const fresh = row && (row.kind === "registered" || (row.fetched_at && Date.now() - Date.parse(row.fetched_at) < METADATA_TTL_MS)); | |
| 299 | + if (row && (fresh || opts.fetch === false)) return toClient(row); | |
| 300 | + if (opts.fetch === false || !isMetadataClientId(id, localOnly(this.env))) return null; | |
| 301 | + const doc = | |
| 302 | + (await this.fetchMetadata(id).catch((e) => { | |
| 303 | + console.error("client metadata document failed", id, String(e)); | |
| 304 | + return null; | |
| 305 | + })) ?? KNOWN_CLIENTS[id] ?? null; | |
| 306 | + if (!doc) return row ? toClient(row) : null; | |
| 307 | + const at = now(); | |
| 308 | + await this.db | |
| 309 | + .prepare( | |
| 310 | + "INSERT INTO oauth_clients (id, kind, name, client_uri, redirect_uris, auth_method, created_at, fetched_at) VALUES (?, 'metadata', ?, ?, ?, 'none', ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, client_uri = excluded.client_uri, redirect_uris = excluded.redirect_uris, fetched_at = excluded.fetched_at", | |
| 311 | + ) | |
| 312 | + .bind(id, doc.name, doc.clientUri, JSON.stringify(doc.redirectUris), at, at) | |
| 313 | + .run(); | |
| 314 | + return { id, kind: "metadata", name: doc.name, client_uri: doc.clientUri, redirect_uris: doc.redirectUris, auth_method: "none", secret_hash: null }; | |
| 315 | + } | |
| 316 | + | |
| 317 | + /** Reads a client metadata document: https on a public host, no redirects, small, and naming itself. */ | |
| 318 | + private async fetchMetadata(id: string): Promise<{ name: string; clientUri: string | null; redirectUris: string[] } | null> { | |
| 319 | + checkPublicUrl(id, localOnly(this.env), "client_id", new URL(this.env.PUBLIC_URL).hostname); | |
| 320 | + const res = await fetch(id, { redirect: "manual", headers: { accept: "application/json", "user-agent": "Diffinery-OAuth/1 (+https://diffinery.com/docs/agents/claude-and-codex)" }, signal: AbortSignal.timeout(5000) }); | |
| 321 | + if (!res.ok) { | |
| 322 | + console.error("client metadata document refused", id, res.status); | |
| 323 | + return null; | |
| 324 | + } | |
| 325 | + const reader = res.body?.getReader(); | |
| 326 | + if (!reader) return null; | |
| 327 | + const chunks: Uint8Array[] = []; | |
| 328 | + let size = 0; | |
| 329 | + for (;;) { | |
| 330 | + const { done, value } = await reader.read(); | |
| 331 | + if (done) break; | |
| 332 | + size += value.byteLength; | |
| 333 | + if (size > METADATA_MAX_BYTES) { | |
| 334 | + await reader.cancel(); | |
| 335 | + return null; | |
| 336 | + } | |
| 337 | + chunks.push(value); | |
| 338 | + } | |
| 339 | + const bytes = new Uint8Array(size); | |
| 340 | + let off = 0; | |
| 341 | + for (const ch of chunks) { | |
| 342 | + bytes.set(ch, off); | |
| 343 | + off += ch.byteLength; | |
| 344 | + } | |
| 345 | + let doc: Record<string, unknown>; | |
| 346 | + try { | |
| 347 | + doc = JSON.parse(new TextDecoder().decode(bytes)); | |
| 348 | + } catch { | |
| 349 | + return null; | |
| 350 | + } | |
| 351 | + if (!doc || typeof doc !== "object" || doc.client_id !== id) return null; | |
| 352 | + const uris = doc.redirect_uris; | |
| 353 | + if (!Array.isArray(uris) || !uris.length || uris.length > MAX_REDIRECTS || !uris.every(validRedirectUri)) return null; | |
| 354 | + const method = doc.token_endpoint_auth_method ?? "none"; | |
| 355 | + if (method !== "none") return null; | |
| 356 | + return { name: cleanText(doc.client_name, 80) || new URL(id).host, clientUri: typeof doc.client_uri === "string" && /^https:\/\//.test(doc.client_uri) ? doc.client_uri.slice(0, MAX_URI) : null, redirectUris: uris as string[] }; | |
| 357 | + } | |
| 358 | + | |
| 359 | + /** | |
| 360 | + * Checks an authorization request before anyone sees a consent page. Errors that can't safely go | |
| 361 | + * back to the app (an unknown app or redirect URI) throw HttpError, to be shown on the page; | |
| 362 | + * the rest throw OAuthError, which the caller sends back to the redirect URI. | |
| 363 | + */ | |
| 364 | + async checkAuthorize(q: { client_id?: string; redirect_uri?: string; response_type?: string; code_challenge?: string; code_challenge_method?: string; resource?: string }): Promise<{ client: OAuthClient; redirectUri: string }> { | |
| 365 | + const client = await this.client(q.client_id); | |
| 366 | + if (!client) throw new HttpError(400, "This app isn't registered with Diffinery, or its registration expired. Connect it again from the app."); | |
| 367 | + let redirectUri = q.redirect_uri; | |
| 368 | + if (!redirectUri) { | |
| 369 | + if (client.redirect_uris.length !== 1) throw new HttpError(400, "The app didn't say where to send you back (redirect_uri)."); | |
| 370 | + redirectUri = client.redirect_uris[0]!; | |
| 371 | + } | |
| 372 | + if (!redirectMatches(client.redirect_uris, redirectUri)) throw new HttpError(400, "The app asked to send you back to an address it didn't register. Nothing was shared."); | |
| 373 | + if (q.response_type !== "code") throw new OAuthError("invalid_request", "response_type must be code"); | |
| 374 | + if (!q.code_challenge || !/^[A-Za-z0-9_-]{43,128}$/.test(q.code_challenge)) throw new OAuthError("invalid_request", "a PKCE code_challenge (S256) is required"); | |
| 375 | + if (q.code_challenge_method !== "S256") throw new OAuthError("invalid_request", "code_challenge_method must be S256"); | |
| 376 | + checkResource(this.env, q.resource); | |
| 377 | + return { client, redirectUri }; | |
| 378 | + } | |
| 379 | + | |
| 380 | + /** The person approved: a one-time code for the app's redirect URI. */ | |
| 381 | + async issueCode(input: { client: OAuthClient; redirectUri: string; challenge: string; person: Account; account: Account; scope: TokenScope }): Promise<string> { | |
| 382 | + const code = `dfa_${randomToken(32)}`; | |
| 383 | + const at = now(); | |
| 384 | + await this.db | |
| 385 | + .prepare("INSERT INTO oauth_codes (code_hash, client_id, redirect_uri, code_challenge, person_id, account_id, scope, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 386 | + .bind(await sha256(code), input.client.id, input.redirectUri, input.challenge, input.person.id, input.account.id, input.scope, at, new Date(Date.now() + CODE_TTL_S * 1000).toISOString()) | |
| 387 | + .run(); | |
| 388 | + await this.db.prepare("UPDATE oauth_clients SET used_at = ? WHERE id = ?").bind(at, input.client.id).run(); | |
| 389 | + return code; | |
| 390 | + } | |
| 391 | + | |
| 392 | + /** Who is calling the token or revocation endpoint: a public app names itself, a confidential one proves it. */ | |
| 393 | + async authenticateClient(form: Record<string, string>, authorization: string | null | undefined): Promise<OAuthClient> { | |
| 394 | + let id = form.client_id; | |
| 395 | + let secret = form.client_secret; | |
| 396 | + if (authorization && /^basic\s/i.test(authorization)) { | |
| 397 | + let decoded: string; | |
| 398 | + try { | |
| 399 | + decoded = new TextDecoder().decode(fromB64(authorization.replace(/^basic\s+/i, "").trim())); | |
| 400 | + } catch { | |
| 401 | + throw new OAuthError("invalid_client", "malformed basic credentials", 401); | |
| 402 | + } | |
| 403 | + const i = decoded.indexOf(":"); | |
| 404 | + id = decodeURIComponent(i >= 0 ? decoded.slice(0, i) : decoded); | |
| 405 | + secret = decodeURIComponent(i >= 0 ? decoded.slice(i + 1) : ""); | |
| 406 | + } | |
| 407 | + const client = await this.client(id, { fetch: false }); | |
| 408 | + if (!client) throw new OAuthError("invalid_client", "unknown client_id", 401); | |
| 409 | + if (client.auth_method !== "none") { | |
| 410 | + if (!secret || !client.secret_hash || (await sha256(secret)) !== client.secret_hash) throw new OAuthError("invalid_client", "client authentication failed", 401); | |
| 411 | + } | |
| 412 | + return client; | |
| 413 | + } | |
| 414 | + | |
| 415 | + /** authorization_code: the code, with the verifier the app kept and the same redirect URI. */ | |
| 416 | + async exchangeCode(client: OAuthClient, form: Record<string, string>): Promise<TokenResponse> { | |
| 417 | + if (!form.code || !form.code_verifier) throw new OAuthError("invalid_request", "code and code_verifier are required"); | |
| 418 | + checkResource(this.env, form.resource); | |
| 419 | + const at = now(); | |
| 420 | + const row = await this.db | |
| 421 | + .prepare("UPDATE oauth_codes SET used_at = ? WHERE code_hash = ? AND used_at IS NULL AND expires_at > ? RETURNING *") | |
| 422 | + .bind(at, await sha256(form.code), at) | |
| 423 | + .first<{ client_id: string; redirect_uri: string; code_challenge: string; person_id: string; account_id: string; scope: string }>(); | |
| 424 | + if (!row) throw new OAuthError("invalid_grant", "unknown, expired or already used code"); | |
| 425 | + // Checked after the claim, so a wrong guess burns the code instead of allowing retries. | |
| 426 | + if (row.client_id !== client.id) throw new OAuthError("invalid_grant", "the code was issued to another client"); | |
| 427 | + if (form.redirect_uri !== undefined && form.redirect_uri !== row.redirect_uri) throw new OAuthError("invalid_grant", "redirect_uri doesn't match the authorization request"); | |
| 428 | + if (b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(form.code_verifier)))) !== row.code_challenge) throw new OAuthError("invalid_grant", "the code verifier doesn't match"); | |
| 429 | + const person = await this.dir.account(row.person_id); | |
| 430 | + const account = row.account_id === row.person_id ? person : await this.dir.account(row.account_id); | |
| 431 | + if (!person || !account || !(await canActAs(this.dir, person, account))) throw new OAuthError("invalid_grant", "the account that approved this can no longer sign in"); | |
| 432 | + const scope = scopeName(row.scope); | |
| 433 | + const grantId = newId(); | |
| 434 | + const refresh = `dfr_${randomToken(32)}`; | |
| 435 | + let host: string; | |
| 436 | + try { | |
| 437 | + host = redirectLabel(row.redirect_uri); | |
| 438 | + } catch { | |
| 439 | + host = "unknown"; | |
| 440 | + } | |
| 441 | + // Each approval is its own connection, even for the same app and account: Claude Code and Codex share one | |
| 442 | + // client_id across every computer they run on, so replacing would sign out the person's other computers. | |
| 443 | + await this.db | |
| 444 | + .prepare("INSERT INTO oauth_grants (id, client_id, client_name, redirect_host, person_id, account_id, scope, refresh_hash, refresh_expires_at, created_at, used_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 445 | + .bind(grantId, client.id, client.name, host, person.id, account.id, scope, await sha256(refresh), refreshExpiry(), at, at) | |
| 446 | + .run(); | |
| 447 | + const access = await this.mintAccess(account, { id: grantId, client_name: client.name, scope }, true); | |
| 448 | + await this.dir.audit({ actorId: person.id, actorLogin: person.login, action: "oauth.connect", target: account.login, detail: { app: client.name, client_id: client.id, scope, redirect: host } }); | |
| 449 | + return { access_token: access, token_type: "Bearer", expires_in: ACCESS_TTL_S, refresh_token: refresh, scope }; | |
| 450 | + } | |
| 451 | + | |
| 452 | + /** refresh_token: a new access token and a new refresh token; the old refresh token stops working. */ | |
| 453 | + async refresh(client: OAuthClient, form: Record<string, string>): Promise<TokenResponse> { | |
| 454 | + if (!form.refresh_token) throw new OAuthError("invalid_request", "refresh_token is required"); | |
| 455 | + checkResource(this.env, form.resource); | |
| 456 | + const hash = await sha256(form.refresh_token); | |
| 457 | + const next = `dfr_${randomToken(32)}`; | |
| 458 | + const at = now(); | |
| 459 | + const g = await this.db | |
| 460 | + .prepare("UPDATE oauth_grants SET prev_refresh_hash = refresh_hash, refresh_hash = ?, refreshed_at = ?, used_at = ?, refresh_expires_at = ? WHERE refresh_hash = ? AND revoked_at IS NULL AND refresh_expires_at > ? RETURNING *") | |
| 461 | + .bind(await sha256(next), at, at, refreshExpiry(), hash, at) | |
| 462 | + .first<OAuthGrant>(); | |
| 463 | + if (!g) { | |
| 464 | + // A replaced refresh token used again: someone else may have it. Past a short grace for a | |
| 465 | + // client retrying a lost response, the connection ends (OAuth 2.1 §4.3.1). | |
| 466 | + const stale = await this.db.prepare("SELECT id, refreshed_at FROM oauth_grants WHERE prev_refresh_hash = ? AND revoked_at IS NULL").bind(hash).first<{ id: string; refreshed_at: string | null }>(); | |
| 467 | + if (stale && (!stale.refreshed_at || Date.now() - Date.parse(stale.refreshed_at) > REUSE_GRACE_S * 1000)) { | |
| 468 | + await this.revokeGrant(stale.id); | |
| 469 | + console.warn("oauth refresh token reused; connection revoked", stale.id); | |
| 470 | + } | |
| 471 | + throw new OAuthError("invalid_grant", "unknown, expired or already used refresh token"); | |
| 472 | + } | |
| 473 | + if (g.client_id !== client.id) { | |
| 474 | + await this.revokeGrant(g.id); | |
| 475 | + throw new OAuthError("invalid_grant", "the refresh token was issued to another client"); | |
| 476 | + } | |
| 477 | + const [person, account] = await Promise.all([this.dir.account(g.person_id), this.dir.account(g.account_id)]); | |
| 478 | + if (!person || !account || !(await canActAs(this.dir, person, account))) { | |
| 479 | + await this.revokeGrant(g.id); | |
| 480 | + throw new OAuthError("invalid_grant", "the account this connection acts as can no longer sign in"); | |
| 481 | + } | |
| 482 | + const access = await this.mintAccess(account, g, false); | |
| 483 | + return { access_token: access, token_type: "Bearer", expires_in: ACCESS_TTL_S, refresh_token: next, scope: scopeName(g.scope) }; | |
| 484 | + } | |
| 485 | + | |
| 486 | + private async mintAccess(account: Account, g: { id: string; client_name: string; scope: string }, first: boolean): Promise<string> { | |
| 487 | + const t = await this.dir.createToken(account, { name: `${g.client_name} (connected app)`.slice(0, 80), scopes: [scopeName(g.scope)], expiresInSeconds: ACCESS_TTL_S, client: "oauth", oauthGrantId: g.id, notify: first }); | |
| 488 | + return t.token; | |
| 489 | + } | |
| 490 | + | |
| 491 | + /** RFC 7009: revoking either token ends the whole connection. Unknown tokens are not an error. */ | |
| 492 | + async revokeToken(client: OAuthClient, token: string): Promise<void> { | |
| 493 | + const hash = await sha256(token); | |
| 494 | + let grant = await this.db.prepare("SELECT id, client_id FROM oauth_grants WHERE refresh_hash = ? OR prev_refresh_hash = ?").bind(hash, hash).first<{ id: string; client_id: string }>(); | |
| 495 | + if (!grant) { | |
| 496 | + const t = await this.db.prepare("SELECT g.id, g.client_id FROM tokens t JOIN oauth_grants g ON g.id = t.oauth_grant_id WHERE t.token_hash = ?").bind(hash).first<{ id: string; client_id: string }>(); | |
| 497 | + grant = t ?? null; | |
| 498 | + } | |
| 499 | + if (grant && grant.client_id === client.id) await this.revokeGrant(grant.id); | |
| 500 | + } | |
| 501 | + | |
| 502 | + /** Ends a connection and its access tokens. */ | |
| 503 | + async revokeGrant(id: string): Promise<void> { | |
| 504 | + const at = now(); | |
| 505 | + await this.db.batch([ | |
| 506 | + this.db.prepare("UPDATE oauth_grants SET revoked_at = ?, refresh_hash = NULL WHERE id = ? AND revoked_at IS NULL").bind(at, id), | |
| 507 | + this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE oauth_grant_id = ? AND revoked_at IS NULL").bind(at, id), | |
| 508 | + ]); | |
| 509 | + } | |
| 510 | + | |
| 511 | + /** The connections a person approved and that still work, newest first. */ | |
| 512 | + async grantsFor(personId: string): Promise<Array<OAuthGrant & { account_login: string; last_used_at: string | null }>> { | |
| 513 | + const r = await this.db | |
| 514 | + .prepare( | |
| 515 | + "SELECT g.*, a.login AS account_login, (SELECT max(t.last_used_at) FROM tokens t WHERE t.oauth_grant_id = g.id) AS last_used_at FROM oauth_grants g JOIN accounts a ON a.id = g.account_id WHERE g.person_id = ? AND g.revoked_at IS NULL AND g.refresh_expires_at > ? ORDER BY g.created_at DESC", | |
| 516 | + ) | |
| 517 | + .bind(personId, now()) | |
| 518 | + .all<OAuthGrant & { account_login: string; last_used_at: string | null }>(); | |
| 519 | + return r.results; | |
| 520 | + } | |
| 521 | + | |
| 522 | + /** The person revokes one of their connections. False when it isn't theirs or was already gone. */ | |
| 523 | + async revokeFor(personId: string, id: string): Promise<OAuthGrant | null> { | |
| 524 | + const g = await this.db.prepare("SELECT * FROM oauth_grants WHERE id = ? AND person_id = ? AND revoked_at IS NULL").bind(id, personId).first<OAuthGrant>(); | |
| 525 | + if (!g) return null; | |
| 526 | + await this.revokeGrant(g.id); | |
| 527 | + return g; | |
| 528 | + } | |
| 529 | + | |
| 530 | + /** Housekeeping: spent codes, expired access tokens, old connections, and registrations nobody used. */ | |
| 531 | + async prune(): Promise<void> { | |
| 532 | + const day = new Date(Date.now() - 86400_000).toISOString(); | |
| 533 | + const month = new Date(Date.now() - 30 * 86400_000).toISOString(); | |
| 534 | + const week = new Date(Date.now() - 7 * 86400_000).toISOString(); | |
| 535 | + await this.db.batch([ | |
| 536 | + this.db.prepare("DELETE FROM oauth_codes WHERE expires_at < ?").bind(day), | |
| 537 | + this.db.prepare("DELETE FROM tokens WHERE oauth_grant_id IS NOT NULL AND expires_at < ?").bind(day), | |
| 538 | + this.db.prepare("DELETE FROM oauth_grants WHERE (revoked_at IS NOT NULL AND revoked_at < ?) OR refresh_expires_at < ?").bind(month, month), | |
| 539 | + this.db.prepare("DELETE FROM oauth_clients WHERE kind = 'registered' AND created_at < ? AND (used_at IS NULL OR used_at < ?) AND id NOT IN (SELECT client_id FROM oauth_grants)").bind(week, month), | |
| 540 | + // Metadata documents are cached copies: drop the ones no connection uses once they're a day old. | |
| 541 | + this.db.prepare("DELETE FROM oauth_clients WHERE kind = 'metadata' AND fetched_at < ? AND id NOT IN (SELECT client_id FROM oauth_grants) AND id NOT IN (SELECT client_id FROM oauth_codes)").bind(day), | |
| 542 | + ]); | |
| 543 | + } | |
| 544 | +} | |
| 545 | + | |
| 546 | +export interface TokenResponse { | |
| 547 | + access_token: string; | |
| 548 | + token_type: "Bearer"; | |
| 549 | + expires_in: number; | |
| 550 | + refresh_token: string; | |
| 551 | + scope: TokenScope; | |
| 552 | +} | |
| 553 | + | |
| 554 | +const refreshExpiry = () => new Date(Date.now() + REFRESH_IDLE_DAYS * 86400_000).toISOString(); | |
| 555 | +const scopeName = (s: string): TokenScope => (OAUTH_SCOPES.includes(s as TokenScope) ? (s as TokenScope) : "read"); | |
| 556 | + | |
| 557 | +function toClient(row: Record<string, string | null>): OAuthClient { | |
| 558 | + return { id: row.id!, kind: row.kind as OAuthClient["kind"], name: row.name!, client_uri: row.client_uri ?? null, redirect_uris: JSON.parse(row.redirect_uris!), auth_method: (row.auth_method ?? "none") as AuthMethod, secret_hash: row.secret_hash ?? null }; | |
| 559 | +} |
modifiedapps/diffinery/src/docs/reference.ts+6−0View file
| @@ -258,6 +258,12 @@ | ||
| 258 | 258 | ["GET, PUT /{owner}/{repo}.git/info/lfs/objects/{oid}", "Git LFS object download and upload."], |
| 259 | 259 | ["GET /{owner}/{repo}/actions/runs/{run_id}/artifacts/{name}", "Download a workflow artifact by name or numeric id, for browsers (the run page links here). Serves a zip, the file itself for an artifact uploaded as a single unzipped file, or a `.tar.gz` for an artifact from an older runner. Expired artifacts aren't served."], |
| 260 | 260 | ["POST /mcp", "The MCP server (Streamable HTTP, JSON-RPC). See [MCP](/docs/agents/mcp) and the [MCP tool reference](/docs/reference/mcp-tools)."], |
| 261 | + ["GET /.well-known/oauth-protected-resource/mcp", "OAuth protected resource metadata for the MCP server (RFC 9728); also at `/.well-known/oauth-protected-resource`. See [Claude and Codex](/docs/agents/claude-and-codex#how-sign-in-works)."], | |
| 262 | + ["GET /.well-known/oauth-authorization-server", "OAuth authorization server metadata (RFC 8414) for MCP clients' browser sign-in."], | |
| 263 | + ["POST /oauth/register", "Dynamic client registration for MCP clients (RFC 7591): public clients, or confidential ones with `client_secret_basic` or `client_secret_post`."], | |
| 264 | + ["GET, POST /oauth/authorize", "The consent page where a person connects an MCP client, choosing whom it acts as and its access. Authorization code with PKCE (S256) only."], | |
| 265 | + ["POST /oauth/token", "Trades an authorization code (with its PKCE verifier) or a refresh token for an 8-hour access token and a new refresh token."], | |
| 266 | + ["POST /oauth/revoke", "Revokes a connected app's access or refresh token, ending its connection (RFC 7009)."], | |
| 261 | 267 | ["/v2/...", "The OCI container registry (Docker Registry HTTP API v2). See [Container registry](/docs/packages/container-registry)."], |
| 262 | 268 | ["GET /.well-known/openid-configuration", "OIDC discovery for workflow ID tokens. See [OIDC](/docs/actions/oidc)."], |
| 263 | 269 | ["GET /.well-known/jwks", "Public keys that sign workflow ID tokens."], |
modifiedapps/diffinery/src/http/common.ts+3−1View file
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { Env } from "../env.js"; |
| 2 | +import { mcpChallenge } from "../auth/oauth.js"; | |
| 2 | 3 | import type { Repo } from "../lib/directory.js"; |
| 3 | 4 | import { denied, readOnly, requestPermission, type JobPermissions } from "../lib/job-permissions.js"; |
| 4 | 5 | import { HttpError } from "../lib/util.js"; |
| @@ -161,7 +162,8 @@ | ||
| 161 | 162 | }; |
| 162 | 163 | if (e instanceof HttpError) { |
| 163 | 164 | const headers: Record<string, string> = {}; |
| 164 | - if (e.status === 401) headers["www-authenticate"] = 'Basic realm="Diffinery", charset="UTF-8"'; | |
| 165 | + // MCP clients learn from /mcp's challenge where to sign in with OAuth (auth/oauth.ts); git and curl get Basic. | |
| 166 | + if (e.status === 401) headers["www-authenticate"] = c && c.req.path === "/mcp" ? mcpChallenge(c.env, c.get("principal")?.type === "anonymous" ? undefined : "invalid_token") : 'Basic realm="Diffinery", charset="UTF-8"'; | |
| 165 | 167 | // GitHub's answer for a renamed or transferred repository: the new URL in Location and in the body. |
| 166 | 168 | if (e instanceof RepoMoved) return json(e.status, { message: e.detail, url: e.location, documentation_url: GITHUB_DOCS }, { ...headers, location: e.location }); |
| 167 | 169 | return json(e.status, { message: e.detail, documentation_url: GITHUB_DOCS, ...e.extra }, headers); |
addedapps/diffinery/src/http/oauth.ts+111−0View file
| @@ -1,0 +1,111 @@ | ||
| 1 | +import { Hono, type Context } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppVars } from "../auth/principal.js"; | |
| 4 | +import { OAuth, OAuthError, authorizationServerMetadata, protectedResourceMetadata } from "../auth/oauth.js"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * OAuth's machine endpoints for MCP clients (auth/oauth.ts has the flow; the consent page is in | |
| 8 | + * web/oauth.tsx): discovery documents, dynamic client registration, the token endpoint and | |
| 9 | + * revocation. They carry no cookies, so any origin may call them (a browser-based MCP client | |
| 10 | + * such as the MCP Inspector does), and app.ts lets them through without a session. | |
| 11 | + */ | |
| 12 | +export const oauthApi = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 13 | + | |
| 14 | +/** Paths here that app.ts treats as credential-free API calls (no session, no cross-site check). */ | |
| 15 | +export const OAUTH_API_PATH = /^\/oauth\/(?:register|token|revoke)$/; | |
| 16 | + | |
| 17 | +const CORS = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, POST, OPTIONS", "access-control-allow-headers": "authorization, content-type, mcp-protocol-version", "access-control-max-age": "86400" }; | |
| 18 | +const NO_STORE = { "cache-control": "no-store", pragma: "no-cache" }; | |
| 19 | +const DISCOVERY = { ...CORS, "cache-control": "public, max-age=3600" }; | |
| 20 | + | |
| 21 | +oauthApi.options("/.well-known/oauth-protected-resource/*", (c) => c.body(null, 204, CORS)); | |
| 22 | +oauthApi.options("/.well-known/oauth-protected-resource", (c) => c.body(null, 204, CORS)); | |
| 23 | +oauthApi.options("/.well-known/oauth-authorization-server", (c) => c.body(null, 204, CORS)); | |
| 24 | +oauthApi.options("/oauth/*", (c) => c.body(null, 204, CORS)); | |
| 25 | + | |
| 26 | +// RFC 9728: at the root and at the path of the resource (/mcp), where the 401's header points. | |
| 27 | +oauthApi.get("/.well-known/oauth-protected-resource", (c) => c.json(protectedResourceMetadata(c.env), 200, DISCOVERY)); | |
| 28 | +oauthApi.get("/.well-known/oauth-protected-resource/mcp", (c) => c.json(protectedResourceMetadata(c.env), 200, DISCOVERY)); | |
| 29 | +oauthApi.get("/.well-known/oauth-authorization-server", (c) => c.json(authorizationServerMetadata(c.env), 200, DISCOVERY)); | |
| 30 | +// Clients that put the resource's path after the well-known name (RFC 8414 §3.1 with an issuer path) get the same document. | |
| 31 | +oauthApi.get("/.well-known/oauth-authorization-server/mcp", (c) => c.json(authorizationServerMetadata(c.env), 200, DISCOVERY)); | |
| 32 | + | |
| 33 | +const oauthError = (c: Context, e: OAuthError) => { | |
| 34 | + const headers: Record<string, string> = { ...CORS, ...NO_STORE }; | |
| 35 | + if (e.status === 401) headers["www-authenticate"] = 'Basic realm="Diffinery OAuth"'; | |
| 36 | + return c.json({ error: e.error, error_description: e.description }, e.status as 400 | 401, headers); | |
| 37 | +}; | |
| 38 | + | |
| 39 | +/** The request's parameters: form-encoded, as OAuth specifies, or JSON, which some clients send. */ | |
| 40 | +async function params(req: Request, max = 16384): Promise<Record<string, string>> { | |
| 41 | + if (Number(req.headers.get("content-length") ?? "0") > max) throw new OAuthError("invalid_request", "request too large", 413 as 400); | |
| 42 | + const type = req.headers.get("content-type") ?? ""; | |
| 43 | + const text = await req.text(); | |
| 44 | + if (text.length > max) throw new OAuthError("invalid_request", "request too large", 413 as 400); | |
| 45 | + const out: Record<string, string> = {}; | |
| 46 | + if (/^application\/json\b/i.test(type)) { | |
| 47 | + let b: unknown; | |
| 48 | + try { | |
| 49 | + b = JSON.parse(text); | |
| 50 | + } catch { | |
| 51 | + throw new OAuthError("invalid_request", "invalid JSON"); | |
| 52 | + } | |
| 53 | + if (!b || typeof b !== "object" || Array.isArray(b)) throw new OAuthError("invalid_request", "expected a JSON object"); | |
| 54 | + for (const [k, v] of Object.entries(b)) if (typeof v === "string") out[k] = v; | |
| 55 | + return out; | |
| 56 | + } | |
| 57 | + for (const [k, v] of new URLSearchParams(text)) { | |
| 58 | + // RFC 6749 §3.2: a parameter sent twice is an error. | |
| 59 | + if (k in out) throw new OAuthError("invalid_request", `${k} was sent more than once`); | |
| 60 | + out[k] = v; | |
| 61 | + } | |
| 62 | + return out; | |
| 63 | +} | |
| 64 | + | |
| 65 | +oauthApi.post("/oauth/register", async (c) => { | |
| 66 | + try { | |
| 67 | + if (!/^application\/json\b/i.test(c.req.header("content-type") ?? "")) throw new OAuthError("invalid_client_metadata", "send the client metadata as JSON"); | |
| 68 | + const text = await c.req.text(); | |
| 69 | + if (text.length > 16384) throw new OAuthError("invalid_client_metadata", "client metadata too large"); | |
| 70 | + let body: unknown; | |
| 71 | + try { | |
| 72 | + body = JSON.parse(text); | |
| 73 | + } catch { | |
| 74 | + throw new OAuthError("invalid_client_metadata", "invalid JSON"); | |
| 75 | + } | |
| 76 | + if (!body || typeof body !== "object" || Array.isArray(body)) throw new OAuthError("invalid_client_metadata", "expected a JSON object"); | |
| 77 | + const out = await new OAuth(c.env, c.get("dir")).register(body as Record<string, unknown>); | |
| 78 | + return c.json(out, 201, { ...CORS, ...NO_STORE }); | |
| 79 | + } catch (e) { | |
| 80 | + if (e instanceof OAuthError) return oauthError(c, e); | |
| 81 | + throw e; | |
| 82 | + } | |
| 83 | +}); | |
| 84 | + | |
| 85 | +oauthApi.post("/oauth/token", async (c) => { | |
| 86 | + try { | |
| 87 | + const form = await params(c.req.raw); | |
| 88 | + const oauth = new OAuth(c.env, c.get("dir")); | |
| 89 | + const client = await oauth.authenticateClient(form, c.req.header("authorization")); | |
| 90 | + if (form.grant_type === "authorization_code") return c.json(await oauth.exchangeCode(client, form), 200, { ...CORS, ...NO_STORE }); | |
| 91 | + if (form.grant_type === "refresh_token") return c.json(await oauth.refresh(client, form), 200, { ...CORS, ...NO_STORE }); | |
| 92 | + throw new OAuthError("unsupported_grant_type", "grant_type must be authorization_code or refresh_token"); | |
| 93 | + } catch (e) { | |
| 94 | + if (e instanceof OAuthError) return oauthError(c, e); | |
| 95 | + throw e; | |
| 96 | + } | |
| 97 | +}); | |
| 98 | + | |
| 99 | +oauthApi.post("/oauth/revoke", async (c) => { | |
| 100 | + try { | |
| 101 | + const form = await params(c.req.raw); | |
| 102 | + const oauth = new OAuth(c.env, c.get("dir")); | |
| 103 | + const client = await oauth.authenticateClient(form, c.req.header("authorization")); | |
| 104 | + if (!form.token) throw new OAuthError("invalid_request", "token is required"); | |
| 105 | + await oauth.revokeToken(client, form.token); | |
| 106 | + return c.body(null, 200, { ...CORS, ...NO_STORE }); | |
| 107 | + } catch (e) { | |
| 108 | + if (e instanceof OAuthError) return oauthError(c, e); | |
| 109 | + throw e; | |
| 110 | + } | |
| 111 | +}); |
modifiedapps/diffinery/src/lib/directory.ts+21−9View file
| @@ -468,26 +468,31 @@ | ||
| 468 | 468 | |
| 469 | 469 | // ---------------------------------------------------------------- tokens |
| 470 | 470 | |
| 471 | - /** `importId`: a GitHub import's short-lived push token (see imports/service.ts). `client`: "cli" for a CLI sign-in's token (auth/cli-login.ts). */ | |
| 472 | - async createToken(account: Account, input: { name: string; scopes?: TokenScope[]; repoIds?: string[] | null; expiresInDays?: number; importId?: string; client?: "cli" }): Promise<{ token: string; id: string; expiresAt: string }> { | |
| 471 | + /** `importId`: a GitHub import's short-lived push token (see imports/service.ts). `client`: "cli" for a CLI sign-in's token (auth/cli-login.ts), "oauth" for a connected app's (auth/oauth.ts, with `oauthGrantId`). */ | |
| 472 | + async createToken( | |
| 473 | + account: Account, | |
| 474 | + input: { name: string; scopes?: TokenScope[]; repoIds?: string[] | null; expiresInDays?: number; expiresInSeconds?: number; importId?: string; client?: "cli" | "oauth"; oauthGrantId?: string; notify?: boolean }, | |
| 475 | + ): Promise<{ token: string; id: string; expiresAt: string }> { | |
| 473 | 476 | const days = Math.min(Math.max(input.expiresInDays ?? 90, 1), 366); |
| 474 | 477 | // Letters and digits only after the prefix: the GitHub CLI masks a token after its last `_` |
| 475 | 478 | // (`gh auth status`), so an underscore in the random part would show the rest of it. |
| 476 | 479 | const token = `dfy_${randomAlnum(40)}`; |
| 477 | 480 | const id = newId(); |
| 478 | - const expiresAt = new Date(Date.now() + days * 86400_000).toISOString(); | |
| 481 | + const expiresAt = new Date(Date.now() + (input.expiresInSeconds ? Math.min(input.expiresInSeconds, days * 86400) * 1000 : days * 86400_000)).toISOString(); | |
| 479 | 482 | await this.db |
| 480 | - .prepare("INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, repo_ids, expires_at, created_at, import_id, client) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 481 | - .bind(id, account.id, input.name || "token", await sha256(token), token.slice(0, 10), JSON.stringify(input.scopes?.length ? input.scopes : ["write"]), input.repoIds ? JSON.stringify(input.repoIds) : null, expiresAt, now(), input.importId ?? null, input.client ?? null) | |
| 483 | + .prepare("INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, repo_ids, expires_at, created_at, import_id, client, oauth_grant_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 484 | + .bind(id, account.id, input.name || "token", await sha256(token), token.slice(0, 10), JSON.stringify(input.scopes?.length ? input.scopes : ["write"]), input.repoIds ? JSON.stringify(input.repoIds) : null, expiresAt, now(), input.importId ?? null, input.client ?? null, input.oauthGrantId ?? null) | |
| 482 | 485 | .run(); |
| 483 | - // An import's own push token is server-managed and short-lived: no "new token" email. | |
| 484 | - if (!input.importId) await tokenCreated(this.env, this, account, { name: input.name || "token", scopes: input.scopes?.length ? input.scopes : ["write"] }).catch((e) => console.error("token email failed", e)); | |
| 486 | + // An import's own push token is server-managed and short-lived: no "new token" email. A connected | |
| 487 | + // app's tokens (auth/oauth.ts) email once, when the app is connected, not on every refresh. | |
| 488 | + if (!input.importId && input.notify !== false) await tokenCreated(this.env, this, account, { name: input.name || "token", scopes: input.scopes?.length ? input.scopes : ["write"] }).catch((e) => console.error("token email failed", e)); | |
| 485 | 489 | return { token, id, expiresAt }; |
| 486 | 490 | } |
| 487 | 491 | |
| 488 | 492 | async tokens(accountId: string): Promise<Array<{ id: string; name: string; prefix: string; scopes: string[]; repoIds: string[] | null; expiresAt: string; lastUsedAt: string | null; createdAt: string; client: string | null }>> { |
| 489 | - // Import push tokens (import_id set) are server-managed and hidden from the personal token list. | |
| 490 | - const r = await this.db.prepare("SELECT * FROM tokens WHERE account_id = ? AND revoked_at IS NULL AND import_id IS NULL ORDER BY created_at DESC").bind(accountId).all<Record<string, string | null>>(); | |
| 493 | + // Import push tokens (import_id set) are server-managed and hidden from the personal token list, and | |
| 494 | + // connected apps' tokens are listed by connection (Settings → Access tokens → Connected apps). | |
| 495 | + const r = await this.db.prepare("SELECT * FROM tokens WHERE account_id = ? AND revoked_at IS NULL AND import_id IS NULL AND oauth_grant_id IS NULL ORDER BY created_at DESC").bind(accountId).all<Record<string, string | null>>(); | |
| 491 | 496 | return r.results.map((t) => ({ id: t.id!, name: t.name!, prefix: t.prefix!, scopes: JSON.parse(t.scopes!), repoIds: t.repo_ids ? JSON.parse(t.repo_ids) : null, expiresAt: t.expires_at!, lastUsedAt: t.last_used_at ?? null, createdAt: t.created_at!, client: t.client ?? null })); |
| 492 | 497 | } |
| 493 | 498 | |
| @@ -504,6 +509,13 @@ | ||
| 504 | 509 | if (account.kind === "agent" && account.owner_id) { |
| 505 | 510 | const owner = await this.account(account.owner_id); |
| 506 | 511 | if (!owner || owner.suspended_at || owner.status !== "active") return null; |
| 512 | + // A connected app acting as an organization's agent (auth/oauth.ts) stops the moment the person who | |
| 513 | + // approved it is no longer an owner or admin of that organization, not only at its next refresh. | |
| 514 | + if (row.oauth_grant_id && owner.kind === "org") { | |
| 515 | + const g = await this.db.prepare("SELECT person_id FROM oauth_grants WHERE id = ?").bind(row.oauth_grant_id).first<{ person_id: string }>(); | |
| 516 | + const role = g ? await this.orgRole(owner.id, g.person_id) : null; | |
| 517 | + if (role !== "owner" && role !== "admin") return null; | |
| 518 | + } | |
| 507 | 519 | } |
| 508 | 520 | if (!row.last_used_at || Date.now() - Date.parse(row.last_used_at) > 3600_000) { |
| 509 | 521 | await this.db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ?").bind(now(), row.id!).run(); |
modifiedapps/diffinery/src/lib/rate-limit.ts+3−3View file
| @@ -9,8 +9,8 @@ | ||
| 9 | 9 | * configured (local development) or that fails lets the request through. |
| 10 | 10 | * |
| 11 | 11 | * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, open sign-up, private |
| 12 | - * security reports (/security/report), and CLI sign-ins (starting one, typing or approving a | |
| 13 | - * code), per IP address. | |
| 12 | + * security reports (/security/report), CLI sign-ins (starting one, typing or approving a | |
| 13 | + * code), and MCP apps registering themselves for OAuth (/oauth/register), per IP address. | |
| 14 | 14 | * - RL_ANON: everything else from someone who isn't signed in, per IP address. |
| 15 | 15 | * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account. |
| 16 | 16 | * - RL_CREATE: creating things other people see or that cost storage (repositories, |
| @@ -33,7 +33,7 @@ | ||
| 33 | 33 | |
| 34 | 34 | /** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */ |
| 35 | 35 | const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/; |
| 36 | -const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|-\/cli\/device$)/; | |
| 36 | +const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|-\/cli\/device$|oauth\/register$)/; | |
| 37 | 37 | const CREATE: Array<[method: string, path: RegExp]> = [ |
| 38 | 38 | ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/], |
| 39 | 39 | ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/], |
modifiedapps/diffinery/src/lib/util.ts+1−1View file
| @@ -93,7 +93,7 @@ | ||
| 93 | 93 | } |
| 94 | 94 | |
| 95 | 95 | /** Top-level paths the app serves; no account may use them as its login. */ |
| 96 | -export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "cli", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "security", "status", "v2", "imports", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]); | |
| 96 | +export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "security", "status", "v2", "imports", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]); | |
| 97 | 97 | |
| 98 | 98 | /** |
| 99 | 99 | * Who a deleted account's issues, pull requests and comments are attributed to, as on GitHub. Not an account: |
modifiedapps/diffinery/src/repo/RepoDO.ts+10−1View file
| @@ -749,7 +749,16 @@ | ||
| 749 | 749 | const branch = ref.slice(11); |
| 750 | 750 | const rules = this.protectionFor(branch); |
| 751 | 751 | const isDefault = branch === this.meta.defaultBranch; |
| 752 | - if (caller.kind === "agent" && (isDefault || rules) && !rules?.agentsMayPush) { | |
| 752 | + // An empty repository's first commit: an agent that created a repository (or was given an empty one) may | |
| 753 | + // start its default branch, as GitHub's auto_init would; from then on it works through pull requests. | |
| 754 | + // A protection rule covering the branch still applies, and the commit may not add workflow files: those reach | |
| 755 | + // the default branch only through a pull request a person merges, as with auto-merge. | |
| 756 | + const seeding = caller.kind === "agent" && isDefault && !rules && oldOid === ZERO_OID && !this.sql.exec("SELECT 1 FROM refs WHERE name LIKE 'refs/heads/%' LIMIT 1").toArray().length; | |
| 757 | + if (seeding) { | |
| 758 | + const workflow = await this.policy.changedWorkflow(ZERO_OID, newOid); | |
| 759 | + if (workflow) return { ref, ok: false, reason: `an agent's first commit to an empty repository can't add workflow file \`${workflow}\`; push it without workflows, then add them in a pull request a person merges` }; | |
| 760 | + } | |
| 761 | + if (caller.kind === "agent" && (isDefault || rules) && !rules?.agentsMayPush && !seeding) { | |
| 753 | 762 | return { ref, ok: false, reason: "agents may not push to protected or default branches; open a pull request" }; |
| 754 | 763 | } |
| 755 | 764 | if (deleting && isDefault) return { ref, ok: false, reason: "cannot delete the default branch" }; |
modifiedapps/diffinery/src/web/account.tsx+5−2View file
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | /** @jsxImportSource hono/jsx */ |
| 2 | 2 | import { raw } from "hono/html"; |
| 3 | +import { OAuth } from "../auth/oauth.js"; | |
| 4 | +import { ConnectedApps } from "./oauth.js"; | |
| 3 | 5 | import type { Child, FC } from "hono/jsx"; |
| 4 | 6 | import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js"; |
| 5 | 7 | import { OrgAgentSettings, orgAgentData } from "../agentic/org-web.js"; |
| @@ -1040,7 +1042,7 @@ | ||
| 1040 | 1042 | const tokensPage = async (c: AppContext, me: Account, created?: { token: string }) => { |
| 1041 | 1043 | const dir = c.get("dir"); |
| 1042 | 1044 | const t = csrfToken(c); |
| 1043 | - const [tokens, repos] = await Promise.all([dir.tokens(me.id), dir.reposVisibleTo(me)]); | |
| 1045 | + const [tokens, repos, grants] = await Promise.all([dir.tokens(me.id), dir.reposVisibleTo(me), new OAuth(c.env, dir).grantsFor(me.id)]); | |
| 1044 | 1046 | const repoName = new Map(repos.map((r) => [r.id, `${r.owner_login}/${r.name}`])); |
| 1045 | 1047 | return page( |
| 1046 | 1048 | c, |
| @@ -1051,7 +1053,7 @@ | ||
| 1051 | 1053 | <h1>Personal access tokens</h1> |
| 1052 | 1054 | <p class="muted">Tokens authenticate Git over HTTPS, the REST API, the CLI and MCP clients. Use your username (or anything) as the Git username and the token as the password.</p> |
| 1053 | 1055 | <p class="muted"> |
| 1054 | - For the CLI you don't need one: run <code>diffinery login</code> and approve the sign-in in your browser. Each CLI sign-in gets its own token, listed here. | |
| 1056 | + For the CLI you don't need one: run <code>diffinery login</code> and approve the sign-in in your browser. Each CLI sign-in gets its own token, listed here. Nor for MCP apps such as claude.ai, Claude Code and Codex: they <a href="/docs/agents/claude-and-codex">connect through your browser</a> and appear under Connected apps. | |
| 1055 | 1057 | </p> |
| 1056 | 1058 | {created ? <TokenOnce c={c} token={created.token} title="Your new token" /> : null} |
| 1057 | 1059 | <div class="box"> |
| @@ -1094,6 +1096,7 @@ | ||
| 1094 | 1096 | <div class="empty">No tokens yet.</div> |
| 1095 | 1097 | )} |
| 1096 | 1098 | </div> |
| 1099 | + <ConnectedApps t={t} grants={grants} /> | |
| 1097 | 1100 | <Form action="/settings/tokens" t={t} class="box"> |
| 1098 | 1101 | <div class="head"> |
| 1099 | 1102 | <h2>Create a token</h2> |
addedapps/diffinery/src/web/oauth.tsx+243−0View file
| @@ -1,0 +1,243 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { FC } from "hono/jsx"; | |
| 4 | +import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js"; | |
| 5 | +import { ACCESS_TTL_S, OAuth, OAuthError, REFRESH_IDLE_DAYS, adminAllowed, canActAs, formActionSource, redirectLabel, requestedScope, type OAuthClient } from "../auth/oauth.js"; | |
| 6 | +import type { Account, TokenScope } from "../lib/directory.js"; | |
| 7 | +import { HttpError } from "../lib/util.js"; | |
| 8 | +import { ICONS } from "./assets.js"; | |
| 9 | +import { csrfToken, type FormData } from "./csrf.js"; | |
| 10 | +import { KRadio } from "./kumo.js"; | |
| 11 | +import { Form, ago, post, render, type WebApp } from "./ui.js"; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Where a person approves an app that wants to use Diffinery through MCP (auth/oauth.ts has the | |
| 15 | + * flow): /oauth/authorize shows who is asking, where they'll be sent back, whom the app acts as and | |
| 16 | + * with what access. It needs a passkey sign-in from the last 2 hours, like creating a token. | |
| 17 | + * Connected apps are listed, and revoked, on Settings → Access tokens. | |
| 18 | + */ | |
| 19 | + | |
| 20 | +const SCOPE_ITEMS = [ | |
| 21 | + { value: "read", label: "Read", description: "Read code, issues, pull requests, runs and logs." }, | |
| 22 | + { value: "write", label: "Write", description: "Also push and commit, open and merge pull requests, comment, and run workflows (up to maintain)." }, | |
| 23 | + { value: "admin", label: "Admin", description: "Everything the account can do, including settings, secrets and variables." }, | |
| 24 | +]; | |
| 25 | + | |
| 26 | +/** The authorization request's parameters, as the app sent them. */ | |
| 27 | +const FIELDS = ["client_id", "redirect_uri", "response_type", "code_challenge", "code_challenge_method", "state", "scope", "resource"] as const; | |
| 28 | +type Req = Partial<Record<(typeof FIELDS)[number], string>>; | |
| 29 | + | |
| 30 | +const reqFrom = (get: (k: string) => string | undefined): Req => Object.fromEntries(FIELDS.map((k) => [k, get(k)]).filter(([, v]) => v !== undefined && v !== "")) as Req; | |
| 31 | + | |
| 32 | +/** Back to the app with a code or an error, and the issuer (RFC 9207) so it knows who answered. */ | |
| 33 | +function backTo(c: AppContext, redirectUri: string, q: Record<string, string | undefined>): Response { | |
| 34 | + const u = new URL(redirectUri); | |
| 35 | + for (const [k, v] of Object.entries(q)) if (v !== undefined) u.searchParams.set(k, v); | |
| 36 | + u.searchParams.set("iss", c.env.PUBLIC_URL.replace(/\/$/, "")); | |
| 37 | + return c.redirect(u.toString(), 303); | |
| 38 | +} | |
| 39 | + | |
| 40 | +const problem = (c: AppContext, message: string, status = 400) => | |
| 41 | + render( | |
| 42 | + c, | |
| 43 | + { title: "Can't connect this app", status }, | |
| 44 | + <div class="authcard box"> | |
| 45 | + <div class="body"> | |
| 46 | + <div class="authmark" aria-hidden="true"> | |
| 47 | + {raw(ICONS.code)} | |
| 48 | + </div> | |
| 49 | + <h1>Can't connect this app</h1> | |
| 50 | + <p class="muted">{message}</p> | |
| 51 | + </div> | |
| 52 | + </div>, | |
| 53 | + ); | |
| 54 | + | |
| 55 | +/** The accounts a person may connect an app as: themselves first, then the agents they run. */ | |
| 56 | +async function choices(c: AppContext, me: Account): Promise<Account[]> { | |
| 57 | + const dir = c.get("dir"); | |
| 58 | + const orgs = (await dir.orgsFor(me.id)).filter((o) => o.role === "owner" || o.role === "admin"); | |
| 59 | + const agents = (await dir.agentsOwnedBy([me.id, ...orgs.map((o) => o.id)])).filter((a) => a.status === "active"); | |
| 60 | + return [me, ...agents]; | |
| 61 | +} | |
| 62 | + | |
| 63 | +function person(c: AppContext, next: string): Account { | |
| 64 | + const me = requireAccount(c); | |
| 65 | + if (me.kind !== "user") throw new HttpError(403, "Only people can connect apps. Agents use their own tokens."); | |
| 66 | + const p = c.get("principal"); | |
| 67 | + if (p.type === "account" && p.via === "token") throw new HttpError(403, "Open this page in your browser, signed in with your passkey."); | |
| 68 | + requireRecentSignIn(c, next); | |
| 69 | + return me; | |
| 70 | +} | |
| 71 | + | |
| 72 | +const Consent: FC<{ c: AppContext; me: Account; client: OAuthClient; redirectUri: string; req: Req; accounts: Account[] }> = ({ c, me, client, redirectUri, req, accounts }) => { | |
| 73 | + const t = csrfToken(c); | |
| 74 | + const verified = client.kind === "metadata" ? new URL(client.id).host : null; | |
| 75 | + const scope: TokenScope = requestedScope(req.scope); | |
| 76 | + return ( | |
| 77 | + <div class="authcard box"> | |
| 78 | + <div class="body"> | |
| 79 | + <div class="authmark" aria-hidden="true"> | |
| 80 | + {raw(ICONS.bot)} | |
| 81 | + </div> | |
| 82 | + <h1> | |
| 83 | + Connect <span>{client.name}</span> to Diffinery | |
| 84 | + </h1> | |
| 85 | + <p class="muted"> | |
| 86 | + {verified ? ( | |
| 87 | + <> | |
| 88 | + An app published by <b>{verified}</b> wants to use Diffinery as you choose below. | |
| 89 | + </> | |
| 90 | + ) : ( | |
| 91 | + <> | |
| 92 | + An app calling itself <b>{client.name}</b> (a name it chose, not checked by Diffinery) wants to use Diffinery as you choose below. | |
| 93 | + </> | |
| 94 | + )} | |
| 95 | + </p> | |
| 96 | + <dl class="kv mb"> | |
| 97 | + <dt>Signed in as</dt> | |
| 98 | + <dd> | |
| 99 | + <b>{me.login}</b> | |
| 100 | + </dd> | |
| 101 | + <dt>Sends you back to</dt> | |
| 102 | + <dd> | |
| 103 | + <b>{redirectLabel(redirectUri)}</b> | |
| 104 | + </dd> | |
| 105 | + </dl> | |
| 106 | + <Form action="/oauth/authorize" t={t}> | |
| 107 | + {Object.entries(req).map(([k, v]) => ( | |
| 108 | + <input type="hidden" name={k} value={v} /> | |
| 109 | + ))} | |
| 110 | + <div class="field"> | |
| 111 | + <label>Act as</label> | |
| 112 | + <KRadio | |
| 113 | + name="act_as" | |
| 114 | + label="Act as" | |
| 115 | + value={me.login} | |
| 116 | + items={accounts.map((a) => ({ | |
| 117 | + value: a.login, | |
| 118 | + label: a.id === me.id ? `${a.login} (you)` : `${a.login} (agent)`, | |
| 119 | + description: a.id === me.id ? "Changes appear as yours." : "Changes appear as the agent's, on your behalf; its pull requests need a person's approval.", | |
| 120 | + }))} | |
| 121 | + /> | |
| 122 | + </div> | |
| 123 | + <div class="field"> | |
| 124 | + <label>Access</label> | |
| 125 | + <KRadio name="access" label="Access" value={scope} items={SCOPE_ITEMS.map((i) => (i.value === "admin" && !adminAllowed(me) ? { ...i, disabled: true, description: "Not offered to site administrators: the app would carry site-wide administration." } : i))} /> | |
| 126 | + </div> | |
| 127 | + {me.site_admin ? <p class="muted small">You're a site administrator, so an app acting as you can do what site administrators can, within the access above. Consider connecting it as one of your agents instead.</p> : null} | |
| 128 | + <p class="muted small"> | |
| 129 | + The app gets tokens that last {ACCESS_TTL_S / 3600} hours and renew themselves while it's in use (the connection lapses after {REFRESH_IDLE_DAYS} days unused). It can never do more than the account it acts as, and branch protection still applies. You can disconnect it any time under{" "} | |
| 130 | + <a href="/settings/tokens#connected-apps">Settings → Access tokens</a>. | |
| 131 | + </p> | |
| 132 | + <div class="flash warn small">Only connect an app you are setting up yourself right now. Never approve one because someone sent you a link.</div> | |
| 133 | + <div class="row"> | |
| 134 | + <button class="btn primary grow" name="decision" value="approve"> | |
| 135 | + Connect | |
| 136 | + </button> | |
| 137 | + <button class="btn" name="decision" value="deny"> | |
| 138 | + Cancel | |
| 139 | + </button> | |
| 140 | + </div> | |
| 141 | + </Form> | |
| 142 | + </div> | |
| 143 | + </div> | |
| 144 | + ); | |
| 145 | +}; | |
| 146 | + | |
| 147 | +/** Checks the request. Problems with the app or its redirect URI are shown here; the rest go back to the app. */ | |
| 148 | +async function check(c: AppContext, req: Req): Promise<{ client: OAuthClient; redirectUri: string } | Response> { | |
| 149 | + const oauth = new OAuth(c.env, c.get("dir")); | |
| 150 | + try { | |
| 151 | + return await oauth.checkAuthorize(req); | |
| 152 | + } catch (e) { | |
| 153 | + if (e instanceof HttpError) return problem(c, e.detail); | |
| 154 | + if (e instanceof OAuthError) { | |
| 155 | + // checkAuthorize only throws OAuthError once the redirect URI is known to be the app's. | |
| 156 | + const client = await oauth.client(req.client_id); | |
| 157 | + const uri = req.redirect_uri ?? client?.redirect_uris[0]; | |
| 158 | + if (uri) return backTo(c, uri, { error: e.error, error_description: e.description, state: req.state }); | |
| 159 | + return problem(c, e.description); | |
| 160 | + } | |
| 161 | + throw e; | |
| 162 | + } | |
| 163 | +} | |
| 164 | + | |
| 165 | +export function registerOAuth(web: WebApp): void { | |
| 166 | + web.get("/oauth/authorize", async (c) => { | |
| 167 | + const req = reqFrom((k) => c.req.query(k)); | |
| 168 | + // Signed in first: an app's metadata document is fetched only for a person about to approve it. | |
| 169 | + const url = new URL(c.req.url); | |
| 170 | + const me = person(c, url.pathname + url.search); | |
| 171 | + const ok = await check(c, req); | |
| 172 | + if (ok instanceof Response) return ok; | |
| 173 | + return render(c, { title: `Connect ${ok.client.name}`, formAction: formActionSource(ok.redirectUri) }, <Consent c={c} me={me} client={ok.client} redirectUri={ok.redirectUri} req={req} accounts={await choices(c, me)} />); | |
| 174 | + }); | |
| 175 | + | |
| 176 | + post(web, "/oauth/authorize", async (c, f: FormData) => { | |
| 177 | + const req = reqFrom((k) => f.s(k) || undefined); | |
| 178 | + const me = person(c, `/oauth/authorize?${new URLSearchParams(req as Record<string, string>)}`); | |
| 179 | + const ok = await check(c, req); | |
| 180 | + if (ok instanceof Response) return ok; | |
| 181 | + if (f.s("decision") !== "approve") return backTo(c, ok.redirectUri, { error: "access_denied", error_description: "the person cancelled", state: req.state }); | |
| 182 | + const account = (await choices(c, me)).find((a) => a.login === f.s("act_as")); | |
| 183 | + if (!account || !(await canActAs(c.get("dir"), me, account))) return problem(c, "You can connect an app only as yourself or as an agent you run.", 403); | |
| 184 | + const scope = SCOPE_ITEMS.find((s) => s.value === f.s("access"))?.value as TokenScope | undefined; | |
| 185 | + if (!scope) return problem(c, "Choose the access the app gets."); | |
| 186 | + if (scope === "admin" && !adminAllowed(account)) return problem(c, "Admin access can't be given to an app acting as a site administrator. Choose Write, or connect it as an agent.", 403); | |
| 187 | + const code = await new OAuth(c.env, c.get("dir")).issueCode({ client: ok.client, redirectUri: ok.redirectUri, challenge: req.code_challenge!, person: me, account, scope }); | |
| 188 | + return backTo(c, ok.redirectUri, { code, state: req.state }); | |
| 189 | + }); | |
| 190 | + | |
| 191 | + post(web, "/settings/connected-apps/:id/revoke", async (c) => { | |
| 192 | + const me = requireAccount(c); | |
| 193 | + const g = await new OAuth(c.env, c.get("dir")).revokeFor(me.id, c.req.param("id") ?? ""); | |
| 194 | + if (g) c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "oauth.disconnect", target: g.account_id, detail: { app: g.client_name, client_id: g.client_id } })); | |
| 195 | + return c.redirect("/settings/tokens#connected-apps", 303); | |
| 196 | + }); | |
| 197 | +} | |
| 198 | + | |
| 199 | +/** Settings → Access tokens: the apps this person connected, and a way to disconnect each. */ | |
| 200 | +export const ConnectedApps: FC<{ t: string; grants: Awaited<ReturnType<OAuth["grantsFor"]>> }> = ({ t, grants }) => ( | |
| 201 | + <div class="box" id="connected-apps"> | |
| 202 | + <div class="head"> | |
| 203 | + <h2>Connected apps</h2> | |
| 204 | + </div> | |
| 205 | + {grants.length ? ( | |
| 206 | + <table class="tbl"> | |
| 207 | + <thead> | |
| 208 | + <tr> | |
| 209 | + <th>App</th> | |
| 210 | + <th>Acts as</th> | |
| 211 | + <th>Access</th> | |
| 212 | + <th>Connected</th> | |
| 213 | + <th>Last used</th> | |
| 214 | + <th /> | |
| 215 | + </tr> | |
| 216 | + </thead> | |
| 217 | + <tbody> | |
| 218 | + {grants.map((g) => ( | |
| 219 | + <tr> | |
| 220 | + <td> | |
| 221 | + <b>{g.client_name}</b> | |
| 222 | + <div class="faint small">{g.redirect_host}</div> | |
| 223 | + </td> | |
| 224 | + <td>{g.account_login}</td> | |
| 225 | + <td>{g.scope}</td> | |
| 226 | + <td class="small">{ago(g.created_at)}</td> | |
| 227 | + <td class="small">{g.last_used_at ? ago(g.last_used_at) : <span class="muted">never</span>}</td> | |
| 228 | + <td class="right"> | |
| 229 | + <Form action={`/settings/connected-apps/${g.id}/revoke`} t={t} class="inline" confirm={`Disconnect ${g.client_name}? It stops working until you connect it again.`}> | |
| 230 | + <button class="btn sm danger">Disconnect</button> | |
| 231 | + </Form> | |
| 232 | + </td> | |
| 233 | + </tr> | |
| 234 | + ))} | |
| 235 | + </tbody> | |
| 236 | + </table> | |
| 237 | + ) : ( | |
| 238 | + <div class="empty"> | |
| 239 | + No apps connected. Apps such as claude.ai, Claude Code and Codex connect through <a href="/docs/agents/claude-and-codex">MCP sign-in</a>. | |
| 240 | + </div> | |
| 241 | + )} | |
| 242 | + </div> | |
| 243 | +); |
modifiedapps/diffinery/src/web/routes.tsx+2−0View file
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | import { registerStatus } from "./status.js"; |
| 15 | 15 | import { registerAuth } from "./auth.js"; |
| 16 | 16 | import { registerCliLogin } from "./cli-login.js"; |
| 17 | +import { registerOAuth } from "./oauth.js"; | |
| 17 | 18 | import { registerActions } from "./actions.js"; |
| 18 | 19 | import { registerCode } from "./code.js"; |
| 19 | 20 | import { registerCollab } from "./collab.js"; |
| @@ -69,6 +70,7 @@ | ||
| 69 | 70 | registerKumoAssets(web); |
| 70 | 71 | registerAuth(web); |
| 71 | 72 | registerCliLogin(web); |
| 73 | +registerOAuth(web); | |
| 72 | 74 | registerPackages(web); |
| 73 | 75 | registerMailWeb(web); |
| 74 | 76 | registerSecurityReports(web); |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+18−0View file
| @@ -341,6 +341,24 @@ | ||
| 341 | 341 | const pr = await ok("POST", `/repos/${owner}/demo/pulls`, { title: "Add a fourth line", head: "feature", base: "main", intent: "Extend app.txt for the e2e test" }, agentToken); |
| 342 | 342 | assert.equal(pr.number, 1); |
| 343 | 343 | |
| 344 | + // An agent may start an empty repository's default branch (as auto_init would), and then only through pull requests. | |
| 345 | + await ok("POST", "/user/repos", { name: "agent-seeded", private: true }); | |
| 346 | + const seed = join(work, "agent-seeded"); | |
| 347 | + git(work, ["init", "-q", "-b", "main", seed]); | |
| 348 | + mkdirSync(join(seed, ".github/workflows"), { recursive: true }); | |
| 349 | + writeFileSync(join(seed, ".github/workflows/ci.yml"), "on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: true\n"); | |
| 350 | + writeFileSync(join(seed, "README.md"), "# seeded by an agent\n"); | |
| 351 | + git(seed, ["add", "-A"]); | |
| 352 | + git(seed, ["commit", "-q", "-m", "Initial commit"]); | |
| 353 | + // Not with workflow files: those reach the default branch only through a pull request a person merges. | |
| 354 | + assert.match(gitFails(seed, ["push", `${base}/${owner}/agent-seeded.git`, "main"], agentToken), /can't add workflow file `\.github\/workflows\/ci\.yml`/); | |
| 355 | + git(seed, ["rm", "-q", "-r", ".github"]); | |
| 356 | + git(seed, ["commit", "-q", "--amend", "-m", "Initial commit"]); | |
| 357 | + git(seed, ["push", "-q", `${base}/${owner}/agent-seeded.git`, "main"], agentToken); | |
| 358 | + writeFileSync(join(seed, "README.md"), "# changed\n"); | |
| 359 | + git(seed, ["commit", "-q", "-am", "Second commit"]); | |
| 360 | + assert.match(gitFails(seed, ["push", `${base}/${owner}/agent-seeded.git`, "main"], agentToken), /agents may not push to protected or default branches/); | |
| 361 | + | |
| 344 | 362 | // The pull_request run executes; until then the PR is blocked on the required check. |
| 345 | 363 | let m = await ok("GET", `/repos/${owner}/demo/pulls/1/merge`); |
| 346 | 364 | assert.equal(m.mergeable, false); |
addedapps/diffinery/test/e2e/oauth.test.mjs+362−0View file
| @@ -1,0 +1,362 @@ | ||
| 1 | +// End-to-end OAuth sign-in for MCP clients (src/auth/oauth.ts): a real `wrangler dev` Worker and a real | |
| 2 | +// Chromium, doing what claude.ai, Claude Code and Codex do when they connect to /mcp. | |
| 3 | +// | |
| 4 | +// pnpm --filter @diffinery/app exec node --test test/e2e/oauth.test.mjs | |
| 5 | +// | |
| 6 | +// Covers: discovery from /mcp's 401, dynamic registration (and the redirect URIs it refuses), a | |
| 7 | +// client metadata document as the client_id, the consent page (signed out, unknown app, wrong redirect, | |
| 8 | +// cancel, acting as yourself or an agent, admin never preselected), PKCE, codes working once, MCP calls | |
| 9 | +// with the token, refresh rotation and reuse, confidential clients, revocation, and Connected apps. | |
| 10 | +import { spawn, execFileSync } from "node:child_process"; | |
| 11 | +import { createHash, randomBytes } from "node:crypto"; | |
| 12 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 13 | +import { createServer as createHttpServer } from "node:http"; | |
| 14 | +import { createServer } from "node:net"; | |
| 15 | +import { tmpdir } from "node:os"; | |
| 16 | +import { join, dirname, resolve } from "node:path"; | |
| 17 | +import { fileURLToPath } from "node:url"; | |
| 18 | +import { test, before, after } from "node:test"; | |
| 19 | +import assert from "node:assert/strict"; | |
| 20 | +import { chromium } from "playwright"; | |
| 21 | + | |
| 22 | +const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | |
| 23 | +const work = mkdtempSync(join(tmpdir(), "diffinery-oauth-")); | |
| 24 | +let base; | |
| 25 | +let dev; | |
| 26 | +let devLog = ""; | |
| 27 | +let browser; | |
| 28 | +let docs; | |
| 29 | +let persist; | |
| 30 | + | |
| 31 | +/** ada is signed in on one browser, recently enough to connect apps, and runs the agent ada-bot. */ | |
| 32 | +const ADA_SESSION = "ada-browser-session-0123456789abcdef"; | |
| 33 | +const hash = (s) => createHash("sha256").update(s).digest("hex"); | |
| 34 | +const b64url = (b) => Buffer.from(b).toString("base64url"); | |
| 35 | + | |
| 36 | +const freePort = () => | |
| 37 | + new Promise((res) => { | |
| 38 | + const s = createServer(); | |
| 39 | + s.listen(0, "127.0.0.1", () => { | |
| 40 | + const { port } = s.address(); | |
| 41 | + s.close(() => res(port)); | |
| 42 | + }); | |
| 43 | + }); | |
| 44 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 45 | +async function until(what, fn, timeoutMs = 60_000) { | |
| 46 | + const end = Date.now() + timeoutMs; | |
| 47 | + while (Date.now() < end) { | |
| 48 | + const v = await fn(); | |
| 49 | + if (v) return v; | |
| 50 | + await sleep(500); | |
| 51 | + } | |
| 52 | + assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); | |
| 53 | +} | |
| 54 | +const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 55 | +const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command); | |
| 56 | + | |
| 57 | +before(async () => { | |
| 58 | + const port = await freePort(); | |
| 59 | + base = `http://localhost:${port}`; | |
| 60 | + persist = join(work, "state"); | |
| 61 | + wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist); | |
| 62 | + const at = new Date().toISOString(); | |
| 63 | + const later = new Date(Date.now() + 86400_000).toISOString(); | |
| 64 | + sql( | |
| 65 | + [ | |
| 66 | + `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('ada1', 'ada', 'user', 'Ada', 'ada@example.com', 'active', '${at}')`, | |
| 67 | + `INSERT INTO accounts (id, login, kind, name, status, owner_id, created_at) VALUES ('bot1', 'ada-bot', 'agent', 'Ada bot', 'active', 'ada1', '${at}')`, | |
| 68 | + `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('bob1', 'bob', 'user', 'Bob', 'bob@example.com', 'active', '${at}')`, | |
| 69 | + `INSERT INTO accounts (id, login, kind, name, status, owner_id, created_at) VALUES ('bot2', 'bob-bot', 'agent', 'Bob bot', 'active', 'bob1', '${at}')`, | |
| 70 | + `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(ADA_SESSION)}', 'ada1', 'passkey', '${at}', '${at}', '${later}')`, | |
| 71 | + ].join("; "), | |
| 72 | + ); | |
| 73 | + // A client metadata document server, as https://claude.ai/oauth/claude-code-client-metadata is for Claude Code. | |
| 74 | + docs = createHttpServer((req, res) => { | |
| 75 | + const id = `http://127.0.0.1:${docs.address().port}${req.url}`; | |
| 76 | + const doc = req.url === "/client.json" ? { client_id: id, client_name: "Doc App", redirect_uris: ["http://127.0.0.1/callback"], token_endpoint_auth_method: "none" } : req.url === "/liar.json" ? { client_id: "https://elsewhere.example/client.json", client_name: "Liar", redirect_uris: ["http://127.0.0.1/callback"] } : null; | |
| 77 | + res.writeHead(doc ? 200 : 404, { "content-type": "application/json" }); | |
| 78 | + res.end(JSON.stringify(doc ?? {})); | |
| 79 | + }); | |
| 80 | + await new Promise((r) => docs.listen(0, "127.0.0.1", r)); | |
| 81 | + dev = spawn( | |
| 82 | + "pnpm", | |
| 83 | + ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"], | |
| 84 | + { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, | |
| 85 | + ); | |
| 86 | + dev.stdout.on("data", (d) => (devLog += d)); | |
| 87 | + dev.stderr.on("data", (d) => (devLog += d)); | |
| 88 | + await until( | |
| 89 | + "wrangler dev", | |
| 90 | + async () => { | |
| 91 | + try { | |
| 92 | + return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok; | |
| 93 | + } catch { | |
| 94 | + return false; | |
| 95 | + } | |
| 96 | + }, | |
| 97 | + 120_000, | |
| 98 | + ); | |
| 99 | + browser = await chromium.launch(); | |
| 100 | +}); | |
| 101 | + | |
| 102 | +after(async () => { | |
| 103 | + await browser?.close(); | |
| 104 | + docs?.close(); | |
| 105 | + if (dev?.pid) { | |
| 106 | + try { | |
| 107 | + process.kill(-dev.pid, "SIGTERM"); | |
| 108 | + } catch {} | |
| 109 | + } | |
| 110 | + if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); | |
| 111 | +}); | |
| 112 | + | |
| 113 | +const form = (url, body, headers = {}) => fetch(url, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", ...headers }, body: new URLSearchParams(body) }); | |
| 114 | +const register = (body, headers = {}) => fetch(`${base}/oauth/register`, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body) }); | |
| 115 | +const mcp = (token, method, params = {}) => fetch(`${base}/mcp`, { method: "POST", headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) }); | |
| 116 | +const whoami = async (token) => (await (await mcp(token, "tools/call", { name: "whoami", arguments: {} })).json()).result?.structuredContent; | |
| 117 | + | |
| 118 | +function pkce() { | |
| 119 | + const verifier = b64url(randomBytes(32)); | |
| 120 | + return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()) }; | |
| 121 | +} | |
| 122 | + | |
| 123 | +/** | |
| 124 | + * A signed-in browser for ada. Requests to an app's callback are recorded here, as the app's own listener would | |
| 125 | + * see them (nothing listens, so the navigation itself fails; route() can't catch the target of a redirect). | |
| 126 | + */ | |
| 127 | +async function adaBrowser() { | |
| 128 | + const context = await browser.newContext(); | |
| 129 | + const callbacks = []; | |
| 130 | + context.on("request", (r) => { | |
| 131 | + const u = new URL(r.url()); | |
| 132 | + if (u.origin !== new URL(base).origin && (u.pathname === "/callback" || u.host === "app.example")) callbacks.push(u); | |
| 133 | + }); | |
| 134 | + const page = await context.newPage(); | |
| 135 | + await page.goto(`${base}/login`); | |
| 136 | + await page.evaluate((v) => (document.cookie = `__Host-dfy_session=${v}; path=/; secure; samesite=lax`), ADA_SESSION); | |
| 137 | + return { context, page, callbacks }; | |
| 138 | +} | |
| 139 | +const text = async (page) => ((await page.textContent("main")) ?? "").replace(/\s+/g, " "); | |
| 140 | + | |
| 141 | +/** Opens the consent page, picks the account and access, and presses a button. Returns the app's callback URL. */ | |
| 142 | +async function consent(ada, url, { actAs, scope, decision = "approve" } = {}) { | |
| 143 | + await ada.page.goto(url); | |
| 144 | + for (const [name, v] of [["act_as", actAs], ["access", scope]]) { | |
| 145 | + if (v) await ada.page.evaluate(([n, val]) => document.querySelectorAll(`input[name="${n}"]`).forEach((i) => (i.value = val)), [name, v]); | |
| 146 | + } | |
| 147 | + const before = ada.callbacks.length; | |
| 148 | + await ada.page.click(`button[value="${decision}"]`, { noWaitAfter: true }); | |
| 149 | + const cb = await until("the app's callback", async () => ada.callbacks[before]); | |
| 150 | + await settled(ada.page); | |
| 151 | + return cb; | |
| 152 | +} | |
| 153 | + | |
| 154 | +/** Nothing listens at the app's callback, so the browser lands on an error page: wait for it, so the next goto isn't interrupted. */ | |
| 155 | +const settled = (page) => until("the failed callback navigation", async () => page.url().startsWith("chrome-error:")); | |
| 156 | + | |
| 157 | +/** Opens a URL that sends the browser straight back to the app. */ | |
| 158 | +async function bounce(page, url) { | |
| 159 | + await page.goto("about:blank"); | |
| 160 | + await page.goto(url).catch(() => {}); | |
| 161 | + await settled(page); | |
| 162 | +} | |
| 163 | + | |
| 164 | +test("OAuth for MCP clients: discovery, registration, consent, tokens, refresh and revocation", async () => { | |
| 165 | + // ------------------------------------------------------------ discovery, as an MCP client starts it | |
| 166 | + const anon = await mcp(null, "initialize", { protocolVersion: "2025-06-18" }); | |
| 167 | + assert.equal(anon.status, 401); | |
| 168 | + assert.equal(anon.headers.get("www-authenticate"), `Bearer realm="Diffinery", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`); | |
| 169 | + const bad = await mcp("dfy_not_a_real_token", "initialize"); | |
| 170 | + assert.equal(bad.status, 401); | |
| 171 | + assert.match(bad.headers.get("www-authenticate"), /error="invalid_token"/); | |
| 172 | + // git and the REST API still get Basic. | |
| 173 | + assert.match((await fetch(`${base}/api/v3/user`)).headers.get("www-authenticate") ?? "", /^Basic/); | |
| 174 | + const prm = await (await fetch(`${base}/.well-known/oauth-protected-resource/mcp`)).json(); | |
| 175 | + assert.deepEqual(prm.authorization_servers, [base]); | |
| 176 | + assert.equal(prm.resource, `${base}/mcp`); | |
| 177 | + const meta = await fetch(`${base}/.well-known/oauth-authorization-server`, { headers: { origin: "https://app.example" } }); | |
| 178 | + assert.equal(meta.headers.get("access-control-allow-origin"), "*"); | |
| 179 | + const as = await meta.json(); | |
| 180 | + assert.equal(as.issuer, base); | |
| 181 | + assert.deepEqual(as.code_challenge_methods_supported, ["S256"]); | |
| 182 | + assert.equal(as.client_id_metadata_document_supported, true); | |
| 183 | + | |
| 184 | + // ------------------------------------------------------------ dynamic registration | |
| 185 | + for (const uri of ["http://evil.example/callback", "javascript:alert(1)", "https://app.example/cb#frag", "data:text/html,hi"]) { | |
| 186 | + const r = await register({ redirect_uris: [uri], client_name: "Bad" }); | |
| 187 | + assert.equal(r.status, 400, uri); | |
| 188 | + assert.equal((await r.json()).error, "invalid_redirect_uri"); | |
| 189 | + } | |
| 190 | + // From a browser on another site, as the MCP Inspector registers: no cookies, so allowed. | |
| 191 | + const reg = await register({ redirect_uris: ["http://127.0.0.1/callback", "https://app.example/oauth/cb"], client_name: "E2E <b>App</b>", token_endpoint_auth_method: "none" }, { origin: "https://inspector.example", "sec-fetch-site": "cross-site" }); | |
| 192 | + assert.equal(reg.status, 201, await reg.clone().text()); | |
| 193 | + const app = await reg.json(); | |
| 194 | + assert.match(app.client_id, /^dfc_/); | |
| 195 | + assert.equal(app.client_secret, undefined); | |
| 196 | + assert.equal(app.client_name, "E2E bApp/b"); | |
| 197 | + | |
| 198 | + const ada = await adaBrowser(); | |
| 199 | + const { verifier, challenge } = pkce(); | |
| 200 | + const authorize = (q) => `${base}/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id: app.client_id, redirect_uri: "http://127.0.0.1:41999/callback", code_challenge: challenge, code_challenge_method: "S256", state: "st-1", scope: "read write admin", resource: `${base}/mcp`, ...q })}`; | |
| 201 | + | |
| 202 | + // ------------------------------------------------------------ the consent page refuses what it can't trust | |
| 203 | + const signedOut = await (await browser.newContext()).newPage(); | |
| 204 | + await signedOut.goto(authorize()); | |
| 205 | + assert.equal(new URL(signedOut.url()).pathname, "/login"); | |
| 206 | + assert.match(new URL(signedOut.url()).searchParams.get("next"), /^\/oauth\/authorize\?/); | |
| 207 | + await ada.page.goto(authorize({ client_id: "dfc_unknown" })); | |
| 208 | + assert.match(await text(ada.page), /isn't registered with Diffinery/); | |
| 209 | + await ada.page.goto(authorize({ redirect_uri: "https://evil.example/callback" })); | |
| 210 | + assert.match(await text(ada.page), /didn't register\. Nothing was shared/); | |
| 211 | + assert.equal(ada.callbacks.length, 0); | |
| 212 | + // A request without PKCE goes back to the app with an error. | |
| 213 | + await bounce(ada.page, authorize({ code_challenge: "" })); | |
| 214 | + await until("the PKCE error", async () => ada.callbacks[0]); | |
| 215 | + assert.equal(ada.callbacks[0].searchParams.get("error"), "invalid_request"); | |
| 216 | + assert.equal(ada.callbacks[0].searchParams.get("state"), "st-1"); | |
| 217 | + await bounce(ada.page, authorize({ resource: "https://elsewhere.example/mcp" })); | |
| 218 | + await until("the resource error", async () => ada.callbacks[1]); | |
| 219 | + assert.equal(ada.callbacks[1].searchParams.get("error"), "invalid_target"); | |
| 220 | + | |
| 221 | + // ------------------------------------------------------------ the page, then Cancel | |
| 222 | + await ada.page.goto(authorize()); | |
| 223 | + const page = await text(ada.page); | |
| 224 | + assert.match(page, /Connect E2E bApp\/b to Diffinery/); | |
| 225 | + assert.match(page, /a name it chose, not checked by Diffinery/); | |
| 226 | + assert.match(page, /Sends you back to\s*an app on this computer/); | |
| 227 | + assert.match(page, /ada \(you\)/); | |
| 228 | + assert.match(page, /ada-bot \(agent\)/); | |
| 229 | + assert.doesNotMatch(page, /bob-bot/); | |
| 230 | + // The app asked for admin; admin is never preselected. | |
| 231 | + assert.equal(await ada.page.locator('input[name="access"]').first().inputValue(), "write"); | |
| 232 | + if (process.env.E2E_SCREENSHOTS) await ada.page.screenshot({ path: join(process.env.E2E_SCREENSHOTS, "oauth-consent.png"), fullPage: true }); | |
| 233 | + const cancelled = await consent(ada, authorize(), { decision: "deny" }); | |
| 234 | + assert.equal(cancelled.searchParams.get("error"), "access_denied"); | |
| 235 | + assert.equal(cancelled.searchParams.get("iss"), base); | |
| 236 | + | |
| 237 | + // Someone else's agent can't be picked, even by editing the form. | |
| 238 | + await ada.page.goto(authorize()); | |
| 239 | + await ada.page.evaluate(() => document.querySelectorAll('input[name="act_as"]').forEach((i) => (i.value = "bob-bot"))); | |
| 240 | + await ada.page.click('button[value="approve"]'); | |
| 241 | + assert.match(await text(ada.page), /only as yourself or as an agent you run/); | |
| 242 | + | |
| 243 | + // ------------------------------------------------------------ approve as the agent, with read access | |
| 244 | + const cb = await consent(ada, authorize(), { actAs: "ada-bot", scope: "read" }); | |
| 245 | + assert.equal(cb.port, "41999"); | |
| 246 | + assert.equal(cb.searchParams.get("state"), "st-1"); | |
| 247 | + const code = cb.searchParams.get("code"); | |
| 248 | + assert.match(code, /^dfa_/); | |
| 249 | + const exchange = (extra) => form(`${base}/oauth/token`, { grant_type: "authorization_code", code, client_id: app.client_id, redirect_uri: "http://127.0.0.1:41999/callback", code_verifier: verifier, ...extra }); | |
| 250 | + // A wrong verifier burns the code. | |
| 251 | + const wrong = await exchange({ code_verifier: pkce().verifier }); | |
| 252 | + assert.equal(wrong.status, 400); | |
| 253 | + assert.equal((await wrong.json()).error, "invalid_grant"); | |
| 254 | + assert.equal((await (await exchange()).json()).error, "invalid_grant"); | |
| 255 | + | |
| 256 | + const cb2 = await consent(ada, authorize(), { actAs: "ada-bot", scope: "read" }); | |
| 257 | + const tokRes = await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb2.searchParams.get("code"), client_id: app.client_id, redirect_uri: "http://127.0.0.1:41999/callback", code_verifier: verifier, resource: `${base}/mcp` }, { origin: "https://inspector.example" }); | |
| 258 | + assert.equal(tokRes.status, 200, await tokRes.clone().text()); | |
| 259 | + assert.equal(tokRes.headers.get("cache-control"), "no-store"); | |
| 260 | + const tok = await tokRes.json(); | |
| 261 | + assert.equal(tok.token_type, "Bearer"); | |
| 262 | + assert.equal(tok.expires_in, 8 * 3600); | |
| 263 | + assert.equal(tok.scope, "read"); | |
| 264 | + assert.match(tok.access_token, /^dfy_/); | |
| 265 | + assert.match(tok.refresh_token, /^dfr_/); | |
| 266 | + assert.deepEqual(await whoami(tok.access_token), { login: "ada-bot", kind: "agent", on_behalf_of: "ada" }); | |
| 267 | + // Read access: a tool that changes things is refused. | |
| 268 | + const write = await (await mcp(tok.access_token, "tools/call", { name: "create_issue", arguments: { owner: "ada", repo: "nope", title: "x" } })).json(); | |
| 269 | + assert.equal(write.result.isError, true); | |
| 270 | + assert.match(write.result.content[0].text, /write scope/); | |
| 271 | + // The code works once. | |
| 272 | + const again = await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb2.searchParams.get("code"), client_id: app.client_id, code_verifier: verifier }); | |
| 273 | + assert.equal((await again.json()).error, "invalid_grant"); | |
| 274 | + | |
| 275 | + // ------------------------------------------------------------ refresh: rotation, and a replaced token used again | |
| 276 | + const refresh = (rt, client_id = app.client_id) => form(`${base}/oauth/token`, { grant_type: "refresh_token", refresh_token: rt, client_id }); | |
| 277 | + const r1 = await (await refresh(tok.refresh_token)).json(); | |
| 278 | + assert.match(r1.access_token, /^dfy_/); | |
| 279 | + assert.notEqual(r1.refresh_token, tok.refresh_token); | |
| 280 | + assert.equal((await whoami(r1.access_token)).login, "ada-bot"); | |
| 281 | + // Another app can't use it. | |
| 282 | + const other = await (await register({ redirect_uris: ["http://127.0.0.1/callback"], client_name: "Other" })).json(); | |
| 283 | + assert.equal((await (await refresh(r1.refresh_token, other.client_id)).json()).error, "invalid_grant"); | |
| 284 | + // ...and trying ended the connection, since the token was presented by the wrong app. | |
| 285 | + assert.equal((await (await refresh(r1.refresh_token)).json()).error, "invalid_grant"); | |
| 286 | + assert.equal((await mcp(r1.access_token, "ping")).status, 401); | |
| 287 | + | |
| 288 | + // A fresh connection: a replaced refresh token used again soon after is refused, but the connection stays. | |
| 289 | + const cb3 = await consent(ada, authorize(), { scope: "write" }); | |
| 290 | + const t3 = await (await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb3.searchParams.get("code"), client_id: app.client_id, code_verifier: verifier })).json(); | |
| 291 | + assert.deepEqual(await whoami(t3.access_token), { login: "ada", kind: "user", on_behalf_of: null }); | |
| 292 | + const t4 = await (await refresh(t3.refresh_token)).json(); | |
| 293 | + assert.equal((await (await refresh(t3.refresh_token)).json()).error, "invalid_grant"); | |
| 294 | + const t5 = await (await refresh(t4.refresh_token)).json(); | |
| 295 | + assert.match(t5.access_token, /^dfy_/); | |
| 296 | + // Long after it was replaced, the old one ends the connection (someone else has it). | |
| 297 | + sql(`UPDATE oauth_grants SET refreshed_at = '${new Date(Date.now() - 3600_000).toISOString()}' WHERE refresh_hash = '${hash(t5.refresh_token)}'`); | |
| 298 | + assert.equal((await (await refresh(t4.refresh_token)).json()).error, "invalid_grant"); | |
| 299 | + assert.equal((await (await refresh(t5.refresh_token)).json()).error, "invalid_grant"); | |
| 300 | + assert.equal((await mcp(t5.access_token, "ping")).status, 401); | |
| 301 | + | |
| 302 | + // ------------------------------------------------------------ Connected apps, and disconnecting | |
| 303 | + const cb4 = await consent(ada, authorize(), { scope: "write" }); | |
| 304 | + const t6 = await (await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb4.searchParams.get("code"), client_id: app.client_id, code_verifier: verifier })).json(); | |
| 305 | + // The same app approved again (say, Claude Code on a second computer) is a second connection: the first keeps working. | |
| 306 | + const cb4b = await consent(ada, authorize(), { scope: "read" }); | |
| 307 | + const t6b = await (await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb4b.searchParams.get("code"), client_id: app.client_id, code_verifier: verifier })).json(); | |
| 308 | + assert.equal((await whoami(t6.access_token)).login, "ada"); | |
| 309 | + assert.equal((await whoami(t6b.access_token)).login, "ada"); | |
| 310 | + await ada.page.goto(`${base}/settings/tokens`); | |
| 311 | + const settings = await text(ada.page); | |
| 312 | + assert.match(settings, /Connected apps.*E2E bApp\/b.*an app on this computer.*ada.*write/); | |
| 313 | + // Its rotating tokens aren't listed one by one with the personal tokens. | |
| 314 | + assert.doesNotMatch(settings, /connected app\)/); | |
| 315 | + ada.page.on("dialog", (d) => d.accept()); | |
| 316 | + for (let i = 0; i < 2; i++) await Promise.all([ada.page.waitForURL(/\/settings\/tokens/), ada.page.locator("button:has-text('Disconnect')").first().click()]); | |
| 317 | + assert.match(await text(ada.page), /No apps connected/); | |
| 318 | + assert.equal((await mcp(t6.access_token, "ping")).status, 401); | |
| 319 | + assert.equal((await (await refresh(t6.refresh_token)).json()).error, "invalid_grant"); | |
| 320 | + | |
| 321 | + // ------------------------------------------------------------ revocation endpoint (RFC 7009) | |
| 322 | + const cb5 = await consent(ada, authorize(), { scope: "write" }); | |
| 323 | + const t7 = await (await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb5.searchParams.get("code"), client_id: app.client_id, code_verifier: verifier })).json(); | |
| 324 | + assert.equal((await form(`${base}/oauth/revoke`, { token: t7.refresh_token, client_id: app.client_id })).status, 200); | |
| 325 | + assert.equal((await mcp(t7.access_token, "ping")).status, 401); | |
| 326 | + assert.equal((await form(`${base}/oauth/revoke`, { token: "dfr_unknown", client_id: app.client_id })).status, 200); | |
| 327 | + | |
| 328 | + // ------------------------------------------------------------ a confidential client proves itself | |
| 329 | + const conf = await (await register({ redirect_uris: ["https://app.example/oauth/cb"], client_name: "Server App", token_endpoint_auth_method: "client_secret_basic" })).json(); | |
| 330 | + assert.match(conf.client_secret, /^dfs_/); | |
| 331 | + const confAuth = (q) => `${base}/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id: conf.client_id, redirect_uri: "https://app.example/oauth/cb", code_challenge: challenge, code_challenge_method: "S256", ...q })}`; | |
| 332 | + await ada.page.goto(confAuth()); | |
| 333 | + assert.match(await text(ada.page), /Sends you back to\s*app\.example/); | |
| 334 | + const cb6 = await consent(ada, confAuth(), { scope: "read" }); | |
| 335 | + const c6 = cb6.searchParams.get("code"); | |
| 336 | + const noSecret = await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: c6, client_id: conf.client_id, code_verifier: verifier }); | |
| 337 | + assert.equal(noSecret.status, 401); | |
| 338 | + assert.equal((await noSecret.json()).error, "invalid_client"); | |
| 339 | + const basic = `Basic ${Buffer.from(`${conf.client_id}:${conf.client_secret}`).toString("base64")}`; | |
| 340 | + const cb7 = await consent(ada, confAuth(), { scope: "read" }); | |
| 341 | + const t8 = await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb7.searchParams.get("code"), code_verifier: verifier }, { authorization: basic }); | |
| 342 | + assert.equal(t8.status, 200, await t8.clone().text()); | |
| 343 | + assert.equal((await whoami((await t8.json()).access_token)).login, "ada"); | |
| 344 | + await ada.context.close(); | |
| 345 | +}); | |
| 346 | + | |
| 347 | +test("OAuth: a client metadata document as the client_id", async () => { | |
| 348 | + const ada = await adaBrowser(); | |
| 349 | + const { verifier, challenge } = pkce(); | |
| 350 | + const docUrl = `http://127.0.0.1:${docs.address().port}/client.json`; | |
| 351 | + const authorize = (client_id) => `${base}/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id, redirect_uri: "http://127.0.0.1:42111/callback", code_challenge: challenge, code_challenge_method: "S256", state: "s" })}`; | |
| 352 | + // A document that doesn't name itself is refused. | |
| 353 | + await ada.page.goto(authorize(`http://127.0.0.1:${docs.address().port}/liar.json`)); | |
| 354 | + assert.match(await text(ada.page), /isn't registered with Diffinery/); | |
| 355 | + await ada.page.goto(authorize(docUrl)); | |
| 356 | + assert.match(await text(ada.page), /Connect Doc App to Diffinery/); | |
| 357 | + assert.match(await text(ada.page), /An app published by 127\.0\.0\.1/); | |
| 358 | + const cb = await consent(ada, authorize(docUrl), { scope: "read" }); | |
| 359 | + const t = await (await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb.searchParams.get("code"), client_id: docUrl, code_verifier: verifier })).json(); | |
| 360 | + assert.equal((await whoami(t.access_token)).login, "ada"); | |
| 361 | + await ada.context.close(); | |
| 362 | +}); |
addedapps/diffinery/test/unit/oauth.test.ts+79−0View file
| @@ -1,0 +1,79 @@ | ||
| 1 | +/** | |
| 2 | + * OAuth for MCP clients (auth/oauth.ts): which redirect URIs an app may register and use, the access | |
| 3 | + * preselected on the consent page, resource indicators, metadata document client IDs, and the | |
| 4 | + * discovery documents. The whole flow, in a browser, is test/e2e/oauth.test.mjs. | |
| 5 | + */ | |
| 6 | +import { describe, expect, it } from "vitest"; | |
| 7 | +import { adminAllowed, authorizationServerMetadata, checkResource, formActionSource, isMetadataClientId, mcpChallenge, protectedResourceMetadata, redirectLabel, redirectMatches, requestedScope, validRedirectUri } from "../../src/auth/oauth.js"; | |
| 8 | +import { bucketsFor } from "../../src/lib/rate-limit.js"; | |
| 9 | + | |
| 10 | +const env = { PUBLIC_URL: "https://diffinery.com" }; | |
| 11 | + | |
| 12 | +describe("redirect URIs", () => { | |
| 13 | + it("allows https anywhere, http only on this computer, and apps' own schemes", () => { | |
| 14 | + for (const ok of ["https://claude.ai/api/mcp/auth_callback", "http://localhost/callback", "http://127.0.0.1:33418/cb", "http://[::1]:9/x", "cursor://anysphere.cursor-retrieval/oauth/callback", "vscode://vscode.github-authentication/did-authenticate"]) expect(validRedirectUri(ok), ok).toBe(true); | |
| 15 | + for (const bad of ["http://evil.example/cb", "http://localhost.evil.example/cb", "javascript:alert(1)", "data:text/html,x", "file:///etc/passwd", "https://a.example/cb#x", "https://user:pw@a.example/cb", "chrome-extension://abc/cb", "not a url", "", 42]) expect(validRedirectUri(bad), String(bad)).toBe(false); | |
| 16 | + }); | |
| 17 | + | |
| 18 | + it("matches exactly, except a loopback port (RFC 8252)", () => { | |
| 19 | + const reg = ["http://127.0.0.1/callback", "https://app.example/cb"]; | |
| 20 | + expect(redirectMatches(reg, "https://app.example/cb")).toBe(true); | |
| 21 | + expect(redirectMatches(reg, "http://127.0.0.1:51234/callback")).toBe(true); | |
| 22 | + expect(redirectMatches(reg, "http://127.0.0.1:51234/other")).toBe(false); | |
| 23 | + expect(redirectMatches(reg, "http://localhost:51234/callback")).toBe(false); | |
| 24 | + expect(redirectMatches(reg, "https://app.example:8443/cb")).toBe(false); | |
| 25 | + expect(redirectMatches(reg, "https://app.example/cb?x=1")).toBe(false); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it("names where the person goes back to, and lets the form lead there", () => { | |
| 29 | + expect(redirectLabel("http://127.0.0.1:5/callback")).toBe("an app on this computer"); | |
| 30 | + expect(redirectLabel("https://claude.ai/api/mcp/auth_callback")).toBe("claude.ai"); | |
| 31 | + expect(redirectLabel("cursor://anysphere.cursor-retrieval/oauth")).toBe("cursor://anysphere.cursor-retrieval"); | |
| 32 | + expect(formActionSource("https://claude.ai/api/mcp/auth_callback")).toBe("https://claude.ai"); | |
| 33 | + expect(formActionSource("cursor://anysphere.cursor-retrieval/oauth")).toBe("cursor:"); | |
| 34 | + }); | |
| 35 | +}); | |
| 36 | + | |
| 37 | +describe("scopes and resources", () => { | |
| 38 | + it("preselects write for apps that ask for everything, never admin", () => { | |
| 39 | + expect(requestedScope("read write admin")).toBe("write"); | |
| 40 | + expect(requestedScope("admin")).toBe("write"); | |
| 41 | + expect(requestedScope("read")).toBe("read"); | |
| 42 | + expect(requestedScope("")).toBe("write"); | |
| 43 | + expect(requestedScope("openid profile")).toBe("write"); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + it("never lets an app act as a site administrator with admin access", () => { | |
| 47 | + expect(adminAllowed({ site_admin: 1 } as never)).toBe(false); | |
| 48 | + expect(adminAllowed({ site_admin: 0 } as never)).toBe(true); | |
| 49 | + }); | |
| 50 | + | |
| 51 | + it("accepts only this server or its /mcp as the resource", () => { | |
| 52 | + expect(() => checkResource(env, "https://diffinery.com/mcp")).not.toThrow(); | |
| 53 | + expect(() => checkResource(env, "https://diffinery.com/")).not.toThrow(); | |
| 54 | + expect(() => checkResource(env, undefined)).not.toThrow(); | |
| 55 | + expect(() => checkResource(env, "https://evil.example/mcp")).toThrow(/resource must be/); | |
| 56 | + }); | |
| 57 | + | |
| 58 | + it("takes https URLs with a path as metadata document client IDs (plain http only locally)", () => { | |
| 59 | + expect(isMetadataClientId("https://claude.ai/oauth/claude-code-client-metadata")).toBe(true); | |
| 60 | + expect(isMetadataClientId("https://claude.ai/")).toBe(false); | |
| 61 | + expect(isMetadataClientId("http://127.0.0.1:9/client.json")).toBe(false); | |
| 62 | + expect(isMetadataClientId("http://127.0.0.1:9/client.json", true)).toBe(true); | |
| 63 | + expect(isMetadataClientId("dfc_abc")).toBe(false); | |
| 64 | + }); | |
| 65 | +}); | |
| 66 | + | |
| 67 | +describe("discovery", () => { | |
| 68 | + it("points /mcp's 401 at the resource metadata, and that at this server", () => { | |
| 69 | + expect(mcpChallenge(env)).toBe('Bearer realm="Diffinery", resource_metadata="https://diffinery.com/.well-known/oauth-protected-resource/mcp"'); | |
| 70 | + expect(mcpChallenge(env, "invalid_token")).toMatch(/error="invalid_token"$/); | |
| 71 | + expect(protectedResourceMetadata(env)).toMatchObject({ resource: "https://diffinery.com/mcp", authorization_servers: ["https://diffinery.com"] }); | |
| 72 | + expect(authorizationServerMetadata(env)).toMatchObject({ issuer: "https://diffinery.com", token_endpoint: "https://diffinery.com/oauth/token", code_challenge_methods_supported: ["S256"], client_id_metadata_document_supported: true }); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + it("counts registrations against the sign-in rate limit", () => { | |
| 76 | + expect(bucketsFor("POST", "/oauth/register", { type: "anonymous" }, "1.2.3.4")).toEqual([["RL_AUTH", "ip:1.2.3.4"]]); | |
| 77 | + expect(bucketsFor("POST", "/oauth/token", { type: "anonymous" }, "1.2.3.4")).toEqual([["RL_ANON", "ip:1.2.3.4"]]); | |
| 78 | + }); | |
| 79 | +}); |