| 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 "./fresh-connections.mjs"; |
| 11 | import { spawn, execFileSync } from "node:child_process"; |
| 12 | import { createHash, randomBytes } from "node:crypto"; |
| 13 | import { mkdtempSync, rmSync } from "node:fs"; |
| 14 | import { createServer as createHttpServer } from "node:http"; |
| 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 | import { migratedState } from "./d1-state.mjs"; |
| 22 | import { freePort } from "./ports.mjs"; |
| 23 | |
| 24 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 25 | const work = mkdtempSync(join(tmpdir(), "diffinery-oauth-")); |
| 26 | let base; |
| 27 | let dev; |
| 28 | let devLog = ""; |
| 29 | let browser; |
| 30 | let docs; |
| 31 | let persist; |
| 32 | |
| 33 | /** ada is signed in on one browser, recently enough to connect apps, and runs the agent ada-bot. */ |
| 34 | const ADA_SESSION = "ada-browser-session-0123456789abcdef"; |
| 35 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 36 | const b64url = (b) => Buffer.from(b).toString("base64url"); |
| 37 | |
| 38 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 39 | async function until(what, fn, timeoutMs = 60_000) { |
| 40 | const end = Date.now() + timeoutMs; |
| 41 | while (Date.now() < end) { |
| 42 | const v = await fn(); |
| 43 | if (v) return v; |
| 44 | await sleep(500); |
| 45 | } |
| 46 | assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); |
| 47 | } |
| 48 | const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 49 | const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command); |
| 50 | |
| 51 | before(async () => { |
| 52 | const port = await freePort(); |
| 53 | base = `http://localhost:${port}`; |
| 54 | persist = join(work, "state"); |
| 55 | migratedState(APP, persist); |
| 56 | const at = new Date().toISOString(); |
| 57 | const later = new Date(Date.now() + 86400_000).toISOString(); |
| 58 | sql( |
| 59 | [ |
| 60 | `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('ada1', 'ada', 'user', 'Ada', 'ada@example.com', 'active', '${at}')`, |
| 61 | `INSERT INTO accounts (id, login, kind, name, status, owner_id, created_at) VALUES ('bot1', 'ada-bot', 'agent', 'Ada bot', 'active', 'ada1', '${at}')`, |
| 62 | `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('bob1', 'bob', 'user', 'Bob', 'bob@example.com', 'active', '${at}')`, |
| 63 | `INSERT INTO accounts (id, login, kind, name, status, owner_id, created_at) VALUES ('bot2', 'bob-bot', 'agent', 'Bob bot', 'active', 'bob1', '${at}')`, |
| 64 | `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(ADA_SESSION)}', 'ada1', 'passkey', '${at}', '${at}', '${later}')`, |
| 65 | ].join("; "), |
| 66 | ); |
| 67 | // A client metadata document server, as https://claude.ai/oauth/claude-code-client-metadata is for Claude Code. |
| 68 | docs = createHttpServer((req, res) => { |
| 69 | const id = `http://127.0.0.1:${docs.address().port}${req.url}`; |
| 70 | 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; |
| 71 | res.writeHead(doc ? 200 : 404, { "content-type": "application/json" }); |
| 72 | res.end(JSON.stringify(doc ?? {})); |
| 73 | }); |
| 74 | await new Promise((r) => docs.listen(0, "127.0.0.1", r)); |
| 75 | dev = spawn( |
| 76 | "pnpm", |
| 77 | ["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"], |
| 78 | { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, |
| 79 | ); |
| 80 | dev.stdout.on("data", (d) => (devLog += d)); |
| 81 | dev.stderr.on("data", (d) => (devLog += d)); |
| 82 | await until( |
| 83 | "wrangler dev", |
| 84 | async () => { |
| 85 | try { |
| 86 | return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok; |
| 87 | } catch { |
| 88 | return false; |
| 89 | } |
| 90 | }, |
| 91 | 120_000, |
| 92 | ); |
| 93 | browser = await chromium.launch(); |
| 94 | }); |
| 95 | |
| 96 | after(async () => { |
| 97 | await browser?.close(); |
| 98 | docs?.close(); |
| 99 | if (dev?.pid) { |
| 100 | try { |
| 101 | process.kill(-dev.pid, "SIGTERM"); |
| 102 | } catch {} |
| 103 | } |
| 104 | if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 105 | }); |
| 106 | |
| 107 | const form = (url, body, headers = {}) => fetch(url, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", ...headers }, body: new URLSearchParams(body) }); |
| 108 | const register = (body, headers = {}) => fetch(`${base}/oauth/register`, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body) }); |
| 109 | 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 }) }); |
| 110 | const whoami = async (token) => (await (await mcp(token, "tools/call", { name: "whoami", arguments: {} })).json()).result?.structuredContent; |
| 111 | |
| 112 | function pkce() { |
| 113 | const verifier = b64url(randomBytes(32)); |
| 114 | return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()) }; |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * A signed-in browser for ada. Requests to an app's callback are recorded here, as the app's own listener would |
| 119 | * see them (nothing listens, so the navigation itself fails; route() can't catch the target of a redirect). |
| 120 | */ |
| 121 | async function adaBrowser() { |
| 122 | const context = await browser.newContext(); |
| 123 | const callbacks = []; |
| 124 | context.on("request", (r) => { |
| 125 | const u = new URL(r.url()); |
| 126 | if (u.origin !== new URL(base).origin && (u.pathname === "/callback" || u.host === "app.example")) callbacks.push(u); |
| 127 | }); |
| 128 | const page = await context.newPage(); |
| 129 | await page.goto(`${base}/login`); |
| 130 | await page.evaluate((v) => (document.cookie = `__Host-dfy_session=${v}; path=/; secure; samesite=lax`), ADA_SESSION); |
| 131 | return { context, page, callbacks }; |
| 132 | } |
| 133 | const text = async (page) => ((await page.textContent("main")) ?? "").replace(/\s+/g, " "); |
| 134 | |
| 135 | /** Opens the consent page, picks the account and access, and presses a button. Returns the app's callback URL. */ |
| 136 | async function consent(ada, url, { actAs, scope, decision = "approve" } = {}) { |
| 137 | await ada.page.goto(url); |
| 138 | for (const [name, v] of [["act_as", actAs], ["access", scope]]) { |
| 139 | if (v) await ada.page.evaluate(([n, val]) => document.querySelectorAll(`input[name="${n}"]`).forEach((i) => (i.value = val)), [name, v]); |
| 140 | } |
| 141 | const before = ada.callbacks.length; |
| 142 | await ada.page.click(`button[value="${decision}"]`, { noWaitAfter: true }); |
| 143 | const cb = await until("the app's callback", async () => ada.callbacks[before]); |
| 144 | await settled(ada.page); |
| 145 | return cb; |
| 146 | } |
| 147 | |
| 148 | /** 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. */ |
| 149 | const settled = (page) => until("the failed callback navigation", async () => page.url().startsWith("chrome-error:")); |
| 150 | |
| 151 | /** Opens a URL that sends the browser straight back to the app. */ |
| 152 | async function bounce(page, url) { |
| 153 | await page.goto("about:blank"); |
| 154 | await page.goto(url).catch(() => {}); |
| 155 | await settled(page); |
| 156 | } |
| 157 | |
| 158 | test("OAuth for MCP clients: discovery, registration, consent, tokens, refresh and revocation", async () => { |
| 159 | // ------------------------------------------------------------ discovery, as an MCP client starts it |
| 160 | const anon = await mcp(null, "initialize", { protocolVersion: "2025-06-18" }); |
| 161 | assert.equal(anon.status, 401); |
| 162 | assert.equal(anon.headers.get("www-authenticate"), `Bearer realm="Diffinery", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`); |
| 163 | const bad = await mcp("dfy_not_a_real_token", "initialize"); |
| 164 | assert.equal(bad.status, 401); |
| 165 | assert.match(bad.headers.get("www-authenticate"), /error="invalid_token"/); |
| 166 | // git and the REST API still get Basic. |
| 167 | assert.match((await fetch(`${base}/api/v3/user`)).headers.get("www-authenticate") ?? "", /^Basic/); |
| 168 | const prm = await (await fetch(`${base}/.well-known/oauth-protected-resource/mcp`)).json(); |
| 169 | assert.deepEqual(prm.authorization_servers, [base]); |
| 170 | assert.equal(prm.resource, `${base}/mcp`); |
| 171 | const meta = await fetch(`${base}/.well-known/oauth-authorization-server`, { headers: { origin: "https://app.example" } }); |
| 172 | assert.equal(meta.headers.get("access-control-allow-origin"), "*"); |
| 173 | const as = await meta.json(); |
| 174 | assert.equal(as.issuer, base); |
| 175 | assert.deepEqual(as.code_challenge_methods_supported, ["S256"]); |
| 176 | assert.equal(as.client_id_metadata_document_supported, true); |
| 177 | |
| 178 | // ------------------------------------------------------------ dynamic registration |
| 179 | for (const uri of ["http://evil.example/callback", "javascript:alert(1)", "https://app.example/cb#frag", "data:text/html,hi"]) { |
| 180 | const r = await register({ redirect_uris: [uri], client_name: "Bad" }); |
| 181 | assert.equal(r.status, 400, uri); |
| 182 | assert.equal((await r.json()).error, "invalid_redirect_uri"); |
| 183 | } |
| 184 | // From a browser on another site, as the MCP Inspector registers: no cookies, so allowed. |
| 185 | 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" }); |
| 186 | assert.equal(reg.status, 201, await reg.clone().text()); |
| 187 | const app = await reg.json(); |
| 188 | assert.match(app.client_id, /^dfc_/); |
| 189 | assert.equal(app.client_secret, undefined); |
| 190 | assert.equal(app.client_name, "E2E bApp/b"); |
| 191 | |
| 192 | const ada = await adaBrowser(); |
| 193 | const { verifier, challenge } = pkce(); |
| 194 | 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 })}`; |
| 195 | |
| 196 | // ------------------------------------------------------------ the consent page refuses what it can't trust |
| 197 | const signedOut = await (await browser.newContext()).newPage(); |
| 198 | await signedOut.goto(authorize()); |
| 199 | assert.equal(new URL(signedOut.url()).pathname, "/login"); |
| 200 | assert.match(new URL(signedOut.url()).searchParams.get("next"), /^\/oauth\/authorize\?/); |
| 201 | await ada.page.goto(authorize({ client_id: "dfc_unknown" })); |
| 202 | assert.match(await text(ada.page), /isn't registered with Diffinery/); |
| 203 | await ada.page.goto(authorize({ redirect_uri: "https://evil.example/callback" })); |
| 204 | assert.match(await text(ada.page), /didn't register\. Nothing was shared/); |
| 205 | assert.equal(ada.callbacks.length, 0); |
| 206 | // A request without PKCE goes back to the app with an error. |
| 207 | await bounce(ada.page, authorize({ code_challenge: "" })); |
| 208 | await until("the PKCE error", async () => ada.callbacks[0]); |
| 209 | assert.equal(ada.callbacks[0].searchParams.get("error"), "invalid_request"); |
| 210 | assert.equal(ada.callbacks[0].searchParams.get("state"), "st-1"); |
| 211 | await bounce(ada.page, authorize({ resource: "https://elsewhere.example/mcp" })); |
| 212 | await until("the resource error", async () => ada.callbacks[1]); |
| 213 | assert.equal(ada.callbacks[1].searchParams.get("error"), "invalid_target"); |
| 214 | |
| 215 | // ------------------------------------------------------------ the page, then Cancel |
| 216 | await ada.page.goto(authorize()); |
| 217 | const page = await text(ada.page); |
| 218 | assert.match(page, /Connect E2E bApp\/b to Diffinery/); |
| 219 | assert.match(page, /a name it chose, not checked by Diffinery/); |
| 220 | assert.match(page, /Sends you back to\s*an app on this computer/); |
| 221 | assert.match(page, /ada \(you\)/); |
| 222 | assert.match(page, /ada-bot \(agent\)/); |
| 223 | assert.doesNotMatch(page, /bob-bot/); |
| 224 | // The app asked for admin; admin is never preselected. |
| 225 | assert.equal(await ada.page.locator('input[name="access"]').first().inputValue(), "write"); |
| 226 | if (process.env.E2E_SCREENSHOTS) await ada.page.screenshot({ path: join(process.env.E2E_SCREENSHOTS, "oauth-consent.png"), fullPage: true }); |
| 227 | const cancelled = await consent(ada, authorize(), { decision: "deny" }); |
| 228 | assert.equal(cancelled.searchParams.get("error"), "access_denied"); |
| 229 | assert.equal(cancelled.searchParams.get("iss"), base); |
| 230 | |
| 231 | // Someone else's agent can't be picked, even by editing the form. |
| 232 | await ada.page.goto(authorize()); |
| 233 | await ada.page.evaluate(() => document.querySelectorAll('input[name="act_as"]').forEach((i) => (i.value = "bob-bot"))); |
| 234 | await ada.page.click('button[value="approve"]'); |
| 235 | assert.match(await text(ada.page), /only as yourself or as an agent you run/); |
| 236 | |
| 237 | // ------------------------------------------------------------ approve as the agent, with read access |
| 238 | const cb = await consent(ada, authorize(), { actAs: "ada-bot", scope: "read" }); |
| 239 | assert.equal(cb.port, "41999"); |
| 240 | assert.equal(cb.searchParams.get("state"), "st-1"); |
| 241 | const code = cb.searchParams.get("code"); |
| 242 | assert.match(code, /^dfa_/); |
| 243 | 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 }); |
| 244 | // A wrong verifier burns the code. |
| 245 | const wrong = await exchange({ code_verifier: pkce().verifier }); |
| 246 | assert.equal(wrong.status, 400); |
| 247 | assert.equal((await wrong.json()).error, "invalid_grant"); |
| 248 | assert.equal((await (await exchange()).json()).error, "invalid_grant"); |
| 249 | |
| 250 | const cb2 = await consent(ada, authorize(), { actAs: "ada-bot", scope: "read" }); |
| 251 | 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" }); |
| 252 | assert.equal(tokRes.status, 200, await tokRes.clone().text()); |
| 253 | assert.equal(tokRes.headers.get("cache-control"), "no-store"); |
| 254 | const tok = await tokRes.json(); |
| 255 | assert.equal(tok.token_type, "Bearer"); |
| 256 | assert.equal(tok.expires_in, 8 * 3600); |
| 257 | assert.equal(tok.scope, "read"); |
| 258 | assert.match(tok.access_token, /^dfy_/); |
| 259 | assert.match(tok.refresh_token, /^dfr_/); |
| 260 | assert.deepEqual(await whoami(tok.access_token), { login: "ada-bot", kind: "agent", on_behalf_of: "ada" }); |
| 261 | // Read access: a tool that changes things is refused. |
| 262 | const write = await (await mcp(tok.access_token, "tools/call", { name: "create_issue", arguments: { owner: "ada", repo: "nope", title: "x" } })).json(); |
| 263 | assert.equal(write.result.isError, true); |
| 264 | assert.match(write.result.content[0].text, /write scope/); |
| 265 | // The code works once. |
| 266 | const again = await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb2.searchParams.get("code"), client_id: app.client_id, code_verifier: verifier }); |
| 267 | assert.equal((await again.json()).error, "invalid_grant"); |
| 268 | |
| 269 | // ------------------------------------------------------------ refresh: rotation, and a replaced token used again |
| 270 | const refresh = (rt, client_id = app.client_id) => form(`${base}/oauth/token`, { grant_type: "refresh_token", refresh_token: rt, client_id }); |
| 271 | const r1 = await (await refresh(tok.refresh_token)).json(); |
| 272 | assert.match(r1.access_token, /^dfy_/); |
| 273 | assert.notEqual(r1.refresh_token, tok.refresh_token); |
| 274 | assert.equal((await whoami(r1.access_token)).login, "ada-bot"); |
| 275 | // Another app can't use it. |
| 276 | const other = await (await register({ redirect_uris: ["http://127.0.0.1/callback"], client_name: "Other" })).json(); |
| 277 | assert.equal((await (await refresh(r1.refresh_token, other.client_id)).json()).error, "invalid_grant"); |
| 278 | // ...and trying ended the connection, since the token was presented by the wrong app. |
| 279 | assert.equal((await (await refresh(r1.refresh_token)).json()).error, "invalid_grant"); |
| 280 | assert.equal((await mcp(r1.access_token, "ping")).status, 401); |
| 281 | |
| 282 | // A fresh connection: a replaced refresh token used again soon after is refused, but the connection stays. |
| 283 | const cb3 = await consent(ada, authorize(), { scope: "write" }); |
| 284 | 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(); |
| 285 | assert.deepEqual(await whoami(t3.access_token), { login: "ada", kind: "user", on_behalf_of: null }); |
| 286 | const t4 = await (await refresh(t3.refresh_token)).json(); |
| 287 | assert.equal((await (await refresh(t3.refresh_token)).json()).error, "invalid_grant"); |
| 288 | const t5 = await (await refresh(t4.refresh_token)).json(); |
| 289 | assert.match(t5.access_token, /^dfy_/); |
| 290 | // Long after it was replaced, the old one ends the connection (someone else has it). |
| 291 | sql(`UPDATE oauth_grants SET refreshed_at = '${new Date(Date.now() - 3600_000).toISOString()}' WHERE refresh_hash = '${hash(t5.refresh_token)}'`); |
| 292 | assert.equal((await (await refresh(t4.refresh_token)).json()).error, "invalid_grant"); |
| 293 | assert.equal((await (await refresh(t5.refresh_token)).json()).error, "invalid_grant"); |
| 294 | assert.equal((await mcp(t5.access_token, "ping")).status, 401); |
| 295 | |
| 296 | // ------------------------------------------------------------ Connected apps, and disconnecting |
| 297 | const cb4 = await consent(ada, authorize(), { scope: "write" }); |
| 298 | 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(); |
| 299 | // The same app approved again (say, Claude Code on a second computer) is a second connection: the first keeps working. |
| 300 | const cb4b = await consent(ada, authorize(), { scope: "read" }); |
| 301 | 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(); |
| 302 | assert.equal((await whoami(t6.access_token)).login, "ada"); |
| 303 | assert.equal((await whoami(t6b.access_token)).login, "ada"); |
| 304 | await ada.page.goto(`${base}/settings/tokens`); |
| 305 | const settings = await text(ada.page); |
| 306 | assert.match(settings, /Connected apps.*E2E bApp\/b.*an app on this computer.*ada.*write/); |
| 307 | // Its rotating tokens aren't listed one by one with the personal tokens. |
| 308 | assert.doesNotMatch(settings, /connected app\)/); |
| 309 | ada.page.on("dialog", (d) => d.accept()); |
| 310 | for (let i = 0; i < 2; i++) await Promise.all([ada.page.waitForURL(/\/settings\/tokens/), ada.page.locator("button:has-text('Disconnect')").first().click()]); |
| 311 | assert.match(await text(ada.page), /No apps connected/); |
| 312 | assert.equal((await mcp(t6.access_token, "ping")).status, 401); |
| 313 | assert.equal((await (await refresh(t6.refresh_token)).json()).error, "invalid_grant"); |
| 314 | |
| 315 | // ------------------------------------------------------------ revocation endpoint (RFC 7009) |
| 316 | const cb5 = await consent(ada, authorize(), { scope: "write" }); |
| 317 | 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(); |
| 318 | assert.equal((await form(`${base}/oauth/revoke`, { token: t7.refresh_token, client_id: app.client_id })).status, 200); |
| 319 | assert.equal((await mcp(t7.access_token, "ping")).status, 401); |
| 320 | assert.equal((await form(`${base}/oauth/revoke`, { token: "dfr_unknown", client_id: app.client_id })).status, 200); |
| 321 | |
| 322 | // ------------------------------------------------------------ a confidential client proves itself |
| 323 | const conf = await (await register({ redirect_uris: ["https://app.example/oauth/cb"], client_name: "Server App", token_endpoint_auth_method: "client_secret_basic" })).json(); |
| 324 | assert.match(conf.client_secret, /^dfs_/); |
| 325 | 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 })}`; |
| 326 | await ada.page.goto(confAuth()); |
| 327 | assert.match(await text(ada.page), /Sends you back to\s*app\.example/); |
| 328 | const cb6 = await consent(ada, confAuth(), { scope: "read" }); |
| 329 | const c6 = cb6.searchParams.get("code"); |
| 330 | const noSecret = await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: c6, client_id: conf.client_id, code_verifier: verifier }); |
| 331 | assert.equal(noSecret.status, 401); |
| 332 | assert.equal((await noSecret.json()).error, "invalid_client"); |
| 333 | const basic = `Basic ${Buffer.from(`${conf.client_id}:${conf.client_secret}`).toString("base64")}`; |
| 334 | const cb7 = await consent(ada, confAuth(), { scope: "read" }); |
| 335 | const t8 = await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb7.searchParams.get("code"), code_verifier: verifier }, { authorization: basic }); |
| 336 | assert.equal(t8.status, 200, await t8.clone().text()); |
| 337 | assert.equal((await whoami((await t8.json()).access_token)).login, "ada"); |
| 338 | await ada.context.close(); |
| 339 | }); |
| 340 | |
| 341 | test("OAuth: a client metadata document as the client_id", async () => { |
| 342 | const ada = await adaBrowser(); |
| 343 | const { verifier, challenge } = pkce(); |
| 344 | const docUrl = `http://127.0.0.1:${docs.address().port}/client.json`; |
| 345 | 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" })}`; |
| 346 | // A document that doesn't name itself is refused. |
| 347 | await ada.page.goto(authorize(`http://127.0.0.1:${docs.address().port}/liar.json`)); |
| 348 | assert.match(await text(ada.page), /isn't registered with Diffinery/); |
| 349 | await ada.page.goto(authorize(docUrl)); |
| 350 | assert.match(await text(ada.page), /Connect Doc App to Diffinery/); |
| 351 | assert.match(await text(ada.page), /An app published by 127\.0\.0\.1/); |
| 352 | const cb = await consent(ada, authorize(docUrl), { scope: "read" }); |
| 353 | const t = await (await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb.searchParams.get("code"), client_id: docUrl, code_verifier: verifier })).json(); |
| 354 | assert.equal((await whoami(t.access_token)).login, "ada"); |
| 355 | await ada.context.close(); |
| 356 | }); |
| 357 | |
| 358 | test("OAuth: Diffinery's own editor extension (apps/vscode)", async () => { |
| 359 | const ada = await adaBrowser(); |
| 360 | const { verifier, challenge } = pkce(); |
| 361 | const client_id = `${base}/oauth/clients/editor.json`; |
| 362 | // The server publishes the extension's metadata document at its client_id. |
| 363 | const doc = await (await fetch(client_id)).json(); |
| 364 | assert.equal(doc.client_id, client_id); |
| 365 | assert.ok(doc.redirect_uris.includes("cursor://codician.diffinery/oauth") && doc.redirect_uris.includes("vscode://codician.diffinery/oauth")); |
| 366 | const authorize = (redirect_uri) => `${base}/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id, redirect_uri, code_challenge: challenge, code_challenge_method: "S256", state: "ed", scope: "write" })}`; |
| 367 | // An editor's own scheme: the consent page names the publisher and where it sends you, and approving redirects there. |
| 368 | await ada.page.goto(authorize("cursor://codician.diffinery/oauth")); |
| 369 | assert.match(await text(ada.page), /Connect Diffinery extension for VS Code and Cursor to Diffinery/); |
| 370 | assert.match(await text(ada.page), /An app published by localhost/); |
| 371 | assert.match(await text(ada.page), /Sends you back to\s*cursor:\/\/codician\.diffinery/); |
| 372 | let location; |
| 373 | const onResponse = (r) => (r.headers().location?.startsWith("cursor://") ? (location = r.headers().location) : null); |
| 374 | ada.context.on("response", onResponse); |
| 375 | await ada.page.click('button[value="approve"]', { noWaitAfter: true }).catch(() => {}); |
| 376 | const cursorCb = new URL(await until("the redirect to Cursor", async () => location)); |
| 377 | ada.context.off("response", onResponse); |
| 378 | assert.equal(cursorCb.searchParams.get("state"), "ed"); |
| 379 | const t1 = await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cursorCb.searchParams.get("code"), client_id, code_verifier: verifier, redirect_uri: "cursor://codician.diffinery/oauth" }); |
| 380 | assert.equal(t1.status, 200, await t1.clone().text()); |
| 381 | const tok = await t1.json(); |
| 382 | assert.equal((await whoami(tok.access_token)).login, "ada"); |
| 383 | // The token works for the REST API the extension lists repositories with. |
| 384 | assert.equal((await (await fetch(`${base}/api/v3/user`, { headers: { authorization: `Bearer ${tok.access_token}` } })).json()).login, "ada"); |
| 385 | // It renews with the refresh token, as the extension does before the access token expires. |
| 386 | const r = await (await form(`${base}/oauth/token`, { grant_type: "refresh_token", refresh_token: tok.refresh_token, client_id })).json(); |
| 387 | assert.match(r.access_token, /^dfy_/); |
| 388 | // An editor the server has no scheme for uses a loopback address, any port. |
| 389 | const cb = await consent(ada, authorize("http://127.0.0.1:45123/callback"), { scope: "write" }); |
| 390 | assert.equal(cb.port, "45123"); |
| 391 | // Other schemes and hosts aren't the extension's. |
| 392 | await ada.page.goto(authorize("evil://codician.diffinery/oauth")); |
| 393 | assert.match(await text(ada.page), /didn't register\. Nothing was shared/); |
| 394 | await ada.context.close(); |
| 395 | }); |
| 396 | |
| 397 | test("OAuth: Diffinery's own plugin for JetBrains IDEs (apps/jetbrains)", async () => { |
| 398 | const ada = await adaBrowser(); |
| 399 | const { verifier, challenge } = pkce(); |
| 400 | const client_id = `${base}/oauth/clients/jetbrains.json`; |
| 401 | const doc = await (await fetch(client_id)).json(); |
| 402 | assert.equal(doc.client_id, client_id); |
| 403 | assert.equal(doc.client_name, "Diffinery plugin for JetBrains IDEs"); |
| 404 | assert.deepEqual(doc.redirect_uris, ["http://127.0.0.1/callback"]); |
| 405 | const authorize = (redirect_uri) => `${base}/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id, redirect_uri, code_challenge: challenge, code_challenge_method: "S256", state: "jb", scope: "write" })}`; |
| 406 | // The IDE listens on a loopback port of its choosing. |
| 407 | await ada.page.goto(authorize("http://127.0.0.1:51234/callback")); |
| 408 | assert.match(await text(ada.page), /Connect Diffinery plugin for JetBrains IDEs to Diffinery/); |
| 409 | assert.match(await text(ada.page), /Sends you back to\s*an app on this computer/); |
| 410 | const cb = await consent(ada, authorize("http://127.0.0.1:51234/callback"), { scope: "write" }); |
| 411 | assert.equal(cb.port, "51234"); |
| 412 | assert.equal(cb.searchParams.get("state"), "jb"); |
| 413 | const t = await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cb.searchParams.get("code"), client_id, code_verifier: verifier, redirect_uri: "http://127.0.0.1:51234/callback" }); |
| 414 | assert.equal(t.status, 200, await t.clone().text()); |
| 415 | assert.equal((await whoami((await t.json()).access_token)).login, "ada"); |
| 416 | // Only loopback: no editor schemes, no other hosts. |
| 417 | for (const bad of ["vscode://codician.diffinery/oauth", "http://example.com/callback"]) { |
| 418 | await ada.page.goto(authorize(bad)); |
| 419 | assert.match(await text(ada.page), /didn't register\. Nothing was shared/, bad); |
| 420 | } |
| 421 | await ada.context.close(); |
| 422 | }); |