Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-mcp/__tests__/oauth.test.ts

Snapshot at 34e3175

Containing directory

13,343 bytes · typescriptView raw
1import { describe, expect, it } from "vitest";
2import { createOAuthConfiguration, createOAuthHandler } from "../src/oauth";
3import type { ConsentDescription, OAuthHelpers } from "@cloudflare/workers-oauth-provider";
4import type { OAuthEnv } from "../src/oauth";
5import { FixtureCore, fixtureContext, fixtureKv } from "./fixtures";
6
7const origin = "https://mcp.idiot.tax";
8const resource = `${origin}/mcp`;
9const verifier = "fixture-pkce-verifier-abcdefghijklmnopqrstuvwxyz-0123456789";
10const redirect = "https://client.idiot.tax/callback";
11async function setup() {
12  const core = new FixtureCore();
13  const env = { OAUTH_KV: fixtureKv() } as OAuthEnv;
14  const handler = createOAuthHandler({
15    core: core.client,
16    publicOrigin: origin,
17    project: "demo",
18    authorizeOwner: async (request) =>
19      request.headers.get("x-fixture-owner") === "verified" ? { subject: "fixture-owner" } : null,
20  });
21  const fetch = (request: Request) => handler(request, env, fixtureContext());
22  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
23  const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
24    .replaceAll("+", "-")
25    .replaceAll("/", "_")
26    .replace(/=+$/, "");
27  const registered = await fetch(
28    new Request(`${origin}/oauth/register`, {
29      method: "POST",
30      headers: { "content-type": "application/json" },
31      body: JSON.stringify({
32        client_name: "<script>fixture</script>",
33        redirect_uris: [redirect],
34        grant_types: ["authorization_code", "refresh_token"],
35        response_types: ["code"],
36        token_endpoint_auth_method: "none",
37      }),
38    }),
39  );
40  expect(registered.status).toBe(201);
41  const registration = (await registered.json()) as { client_id: string };
42  const authorization = new URL(`${origin}/authorize`);
43  authorization.search = new URLSearchParams({
44    client_id: registration.client_id,
45    redirect_uri: redirect,
46    response_type: "code",
47    scope: "mcp:agent",
48    resource,
49    state: "fixture-state",
50    code_challenge: challenge,
51    code_challenge_method: "S256",
52  }).toString();
53  return { core, fetch, authorization, registration };
54}
55async function consent() {
56  const setupResult = await setup();
57  const page = await setupResult.fetch(
58    new Request(setupResult.authorization, { headers: { "x-fixture-owner": "verified" } }),
59  );
60  expect(page.status).toBe(200);
61  const html = await page.text();
62  const handle = /name="handle" value="([^"]+)"/.exec(html)?.[1];
63  expect(handle).toBeTruthy();
64  const cookie = page.headers
65    .getSetCookie()
66    .map((value) => value.split(";")[0])
67    .join("; ");
68  return { ...setupResult, html, handle: handle!, cookie, page };
69}
70function decision(handle: string, cookie: string, value = "approve", verified = true) {
71  return new Request(`${origin}/authorize`, {
72    method: "POST",
73    headers: {
74      "content-type": "application/x-www-form-urlencoded",
75      origin,
76      cookie,
77      ...(verified ? { "x-fixture-owner": "verified" } : {}),
78    },
79    body: new URLSearchParams({ handle, decision: value }),
80  });
81}
82function tokenRequest(client: string, code: string, codeVerifier: string) {
83  return new Request(`${origin}/oauth/token`, {
84    method: "POST",
85    headers: { "content-type": "application/x-www-form-urlencoded" },
86    body: new URLSearchParams({
87      grant_type: "authorization_code",
88      client_id: client,
89      code,
90      redirect_uri: redirect,
91      code_verifier: codeVerifier,
92      resource,
93    }),
94  });
95}
96
97describe("real OAuth provider with labeled in-memory fixtures", () => {
98  it("serves RFC 9728 and authorization-server discovery with configured URLs and PKCE", async () => {
99    const { fetch } = await setup();
100    const metadata = await fetch(new Request(`${origin}/.well-known/oauth-protected-resource/mcp`));
101    expect(metadata.status).toBe(200);
102    expect(await metadata.json()).toMatchObject({
103      resource,
104      authorization_servers: [origin],
105      bearer_methods_supported: ["header"],
106    });
107    const as = await fetch(new Request(`${origin}/.well-known/oauth-authorization-server`));
108    expect(as.status).toBe(200);
109    expect(await as.json()).toMatchObject({
110      issuer: origin,
111      registration_endpoint: `${origin}/oauth/register`,
112      token_endpoint: `${origin}/oauth/token`,
113      authorization_endpoint: `${origin}/authorize`,
114      code_challenge_methods_supported: ["S256"],
115    });
116    const unauthorized = await fetch(new Request(resource));
117    expect(unauthorized.status).toBe(401);
118    expect(unauthorized.headers.get("www-authenticate")).toContain("oauth-protected-resource");
119  });
120  it("requires verified owner access before consent or enrollment, and escapes client metadata", async () => {
121    const { fetch, authorization, core } = await setup();
122    expect((await fetch(new Request(authorization))).status).toBe(403);
123    expect(core.enrollments).toBe(0);
124    const { html, handle, cookie, page } = await consent();
125    expect(html).not.toContain("<script>");
126    expect(html).toContain("&#60;script&#62;");
127    expect(html).toContain("client.idiot.tax");
128    expect(page.headers.get("content-security-policy")).toContain("frame-ancestors 'none'");
129    expect((await fetch(decision(handle, cookie, "approve", false))).status).toBe(403);
130    expect(core.enrollments).toBe(0);
131  });
132  it("keeps the post form and explicit decisions while making denial the first control", async () => {
133    const { html, page, core } = await consent();
134    expect(html.match(/<h1\b/g)).toHaveLength(1);
135    expect(html).toContain('<html lang="en">');
136    expect(html).toContain('name="viewport" content="width=device-width, initial-scale=1"');
137    expect(html).toContain("prefers-color-scheme: dark");
138    expect(html).toContain('role="note"');
139    expect(html).toContain("It can't approve or promote. Those stay with you.");
140    expect(html).toContain("<ul><li>read project state</li>");
141    const form = /<form\b[^>]*>([\s\S]*?)<\/form>/.exec(html)!;
142    expect(html.match(/<form\b/g)).toHaveLength(1);
143    expect(form[0].slice(0, form[0].indexOf(">") + 1)).toBe('<form method="post">');
144    expect(form[1]!.match(/<input\b[^>]*>/g)).toEqual([
145      expect.stringMatching(/^<input type="hidden" name="handle" value="[^"<>]+">$/),
146    ]);
147    expect(form[1]!.match(/<button\b[^>]*>[^<]*<\/button>/g)).toEqual([
148      '<button type="submit" name="decision" value="deny">Deny</button>',
149      '<button type="submit" name="decision" value="approve">Allow</button>',
150    ]);
151    expect(html).not.toMatch(/autofocus|<script\b|<link\b|@import|url\(/i);
152    expect(page.headers.get("cache-control")).toBe("no-store");
153    expect(core.enrollments).toBe(0);
154  });
155  it("escapes every metadata and hidden-field context without rendering client URLs", async () => {
156    const hostile = `&<img src=x onerror="fixture">'`;
157    const escaped = "&#38;&#60;img src=x onerror=&#34;fixture&#34;&#62;&#39;";
158    const core = new FixtureCore();
159    const auth = {
160      clientId: "fixture", redirectUri: redirect, responseType: "code",
161      scope: ["mcp:agent"], codeChallenge: "A".repeat(43), codeChallengeMethod: "S256",
162    };
163    const details: ConsentDescription = {
164      clientId: "fixture", clientName: hostile.repeat(40), clientDomain: hostile,
165      redirectHost: hostile, redirectUri: redirect, redirectIsLoopback: true,
166      scope: ["mcp:agent", hostile],
167      clientUri: "https://untrusted.idiot.tax", logoUri: "https://untrusted.idiot.tax/logo",
168    };
169    // Labeled provider fixture lets impossible metadata test every escaping boundary directly.
170    const oauth = {
171      parseAuthRequest: async () => auth,
172      lookupClient: async () => ({ redirectUris: [redirect] }),
173      describeConsent: async () => details,
174      beginConsent: async () => ({ handle: hostile, headers: new Headers() }),
175    } as unknown as OAuthHelpers;
176    const configuration = createOAuthConfiguration({
177      core: core.client, publicOrigin: origin, project: "demo",
178      authorizeOwner: async () => ({ subject: "fixture-owner" }),
179    });
180    const handler = configuration.defaultHandler as {
181      fetch(request: Request, env: OAuthEnv, ctx: ExecutionContext): Promise<Response>;
182    };
183    const response = await handler.fetch(new Request(`${origin}/authorize`), {
184      OAUTH_KV: fixtureKv(), OAUTH_PROVIDER: oauth,
185    }, fixtureContext());
186    const html = await response.text();
187    expect(html).toContain(escaped.repeat(40));
188    expect(html).toContain(`<strong><code>${escaped}</code></strong>`);
189    expect(html).toContain(`Client domain: <code>${escaped}</code>`);
190    expect(html).toContain(`name="handle" value="${escaped}"`);
191    expect(html).toContain(`<code>mcp:agent, ${escaped}</code>`);
192    expect(html).toContain("an app on your computer");
193    expect(html).not.toContain("<img");
194    expect(html).not.toContain("https://untrusted.idiot.tax");
195    expect(core.enrollments).toBe(0);
196  });
197  it("enforces PKCE, explicit scope, browser-bound consent, denial and replay protection", async () => {
198    const { fetch, authorization, core } = await setup();
199    for (const method of ["plain", ""]) {
200      authorization.searchParams.set("code_challenge_method", method);
201      expect(
202        (await fetch(new Request(authorization, { headers: { "x-fixture-owner": "verified" } })))
203          .status,
204      ).toBe(400);
205    }
206    const c = await consent();
207    expect((await c.fetch(decision(c.handle, ""))).status).toBe(400);
208    expect(c.core.enrollments).toBe(0);
209    const denied = await c.fetch(decision(c.handle, c.cookie, "deny"));
210    expect(denied.status).toBe(302);
211    expect(new URL(denied.headers.get("location")!).searchParams.get("error")).toBe(
212      "access_denied",
213    );
214    expect((await c.fetch(decision(c.handle, c.cookie))).status).toBe(400);
215    expect(c.core.enrollments).toBe(0);
216    expect(core.enrollments).toBe(0);
217  });
218  it("maps an approved grant to new enrollment and passes only its core credential to MCP", async () => {
219    const { fetch, handle, cookie, registration, core } = await consent();
220    const approved = await fetch(decision(handle, cookie));
221    expect(approved.status).toBe(302);
222    const location = new URL(approved.headers.get("location")!);
223    expect(location.origin).toBe("https://client.idiot.tax");
224    expect(location.searchParams.get("state")).toBe("fixture-state");
225    expect(core.enrollments).toBe(1);
226    const exchange = await fetch(
227      tokenRequest(registration.client_id, location.searchParams.get("code")!, verifier),
228    );
229    expect(exchange.status).toBe(200);
230    const tokens = (await exchange.json()) as { access_token: string; refresh_token: string };
231    expect(tokens.access_token).toBeTruthy();
232    expect(JSON.stringify(tokens)).not.toContain("api_fixture_oauth");
233    const response = await fetch(
234      new Request(resource, {
235        method: "POST",
236        headers: {
237          authorization: `Bearer ${tokens.access_token}`,
238          "content-type": "application/json",
239          accept: "application/json, text/event-stream",
240        },
241        body: JSON.stringify({
242          jsonrpc: "2.0",
243          id: 2,
244          method: "tools/call",
245          params: { name: "status", arguments: { project: "demo" } },
246        }),
247      }),
248    );
249    expect(response.status).toBe(200);
250    expect(core.requests.at(-1)?.headers.get("authorization")).toBe("Bearer api_fixture_oauth_1");
251    expect((await fetch(decision(handle, cookie))).status).toBe(400);
252    // Revoking the enrolled core token immediately invalidates effective grant access.
253    core.tokens.delete("api_fixture_oauth_1");
254    const revoked = await fetch(
255      new Request(resource, { headers: { authorization: `Bearer ${tokens.access_token}` } }),
256    );
257    expect(revoked.status).toBe(401);
258  });
259  it("rejects a wrong PKCE verifier and unregistered redirect without leaking a credential", async () => {
260    const { fetch, handle, cookie, registration, authorization } = await consent();
261    const approved = await fetch(decision(handle, cookie));
262    const code = new URL(approved.headers.get("location")!).searchParams.get("code")!;
263    const bad = await fetch(tokenRequest(registration.client_id, code, "wrong".repeat(12)));
264    expect(bad.status).toBe(400);
265    expect(await bad.text()).not.toContain("api_fixture");
266    authorization.searchParams.set("redirect_uri", "https://attacker.idiot.tax/callback");
267    const redirectRejected = await fetch(
268      new Request(authorization, { headers: { "x-fixture-owner": "verified" } }),
269    );
270    expect(redirectRejected.status).toBe(400);
271    expect(redirectRejected.headers.get("location")).toBeNull();
272  });
273  it("accepts direct enrolled bearer tokens through the same OAuth facade", async () => {
274    const { fetch } = await setup();
275    const response = await fetch(
276      new Request(resource, {
277        method: "POST",
278        headers: {
279          authorization: "Bearer api_fixture_builder",
280          "content-type": "application/json",
281          accept: "application/json, text/event-stream",
282        },
283        body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/list" }),
284      }),
285    );
286    expect(response.status).toBe(200);
287    const body = (await response.json()) as { result: { tools: unknown[] } };
288    expect(body.result.tools).toHaveLength(13);
289  });
290});
291