Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-mcp/__tests__/oauth.test.ts
Snapshot at 34e3175
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("<script>");
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 = "&<img src=x onerror="fixture">'";
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