Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

422 lines · 27.1 KBRaw History
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.
10import "./fresh-connections.mjs";
11import { spawn, execFileSync } from "node:child_process";
12import { createHash, randomBytes } from "node:crypto";
13import { mkdtempSync, rmSync } from "node:fs";
14import { createServer as createHttpServer } from "node:http";
15import { tmpdir } from "node:os";
16import { join, dirname, resolve } from "node:path";
17import { fileURLToPath } from "node:url";
18import { test, before, after } from "node:test";
19import assert from "node:assert/strict";
20import { chromium } from "playwright";
21import { migratedState } from "./d1-state.mjs";
22import { freePort } from "./ports.mjs";
23
24const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
25const work = mkdtempSync(join(tmpdir(), "diffinery-oauth-"));
26let base;
27let dev;
28let devLog = "";
29let browser;
30let docs;
31let persist;
32
33/** ada is signed in on one browser, recently enough to connect apps, and runs the agent ada-bot. */
34const ADA_SESSION = "ada-browser-session-0123456789abcdef";
35const hash = (s) => createHash("sha256").update(s).digest("hex");
36const b64url = (b) => Buffer.from(b).toString("base64url");
37
38const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
39async 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}
48const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
49const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command);
50
51before(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
96after(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
107const form = (url, body, headers = {}) => fetch(url, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", ...headers }, body: new URLSearchParams(body) });
108const register = (body, headers = {}) => fetch(`${base}/oauth/register`, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body) });
109const 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 }) });
110const whoami = async (token) => (await (await mcp(token, "tools/call", { name: "whoami", arguments: {} })).json()).result?.structuredContent;
111
112function 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 */
121async 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}
133const 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. */
136async 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. */
149const 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. */
152async function bounce(page, url) {
153 await page.goto("about:blank");
154 await page.goto(url).catch(() => {});
155 await settled(page);
156}
157
158test("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
341test("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
358test("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
397test("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});