Skip to content

Commit 74d31f9

Browse files
add tests for oauth proxy, handle mcps that do not support oauth but still need some token, fix /authorize redirect (#2084)
* add some tests and fixes for oauth proxy * rm not needed json file * readme btn * fix rq cache invalidation * mock contextfactory.set * use spy instead of module mock
1 parent 20c5a90 commit 74d31f9

11 files changed

Lines changed: 620 additions & 85 deletions

File tree

apps/mesh/src/api/app.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,16 @@ export function createApp(options: CreateAppOptions = {}) {
247247
const reqUrl = new URL(c.req.url);
248248
targetUrl.search = reqUrl.search;
249249

250-
// Forward headers
250+
// For authorize endpoint, REDIRECT instead of proxying
251+
// The browser needs to navigate directly to the auth server so that:
252+
// 1. CSS/JS loads correctly from the origin
253+
// 2. Cookies are set on the correct domain
254+
// 3. The user can interact with the consent screen
255+
if (endpoint === "authorize") {
256+
return c.redirect(targetUrl.toString(), 302);
257+
}
258+
259+
// Forward headers for token/register endpoints
251260
const headers: Record<string, string> = {
252261
Accept: c.req.header("Accept") || "application/json",
253262
};
@@ -256,7 +265,7 @@ export function createApp(options: CreateAppOptions = {}) {
256265
const authorization = c.req.header("Authorization");
257266
if (authorization) headers["Authorization"] = authorization;
258267

259-
// Proxy the request
268+
// Proxy the request (token and register endpoints only)
260269
const response = await fetch(targetUrl.toString(), {
261270
method: c.req.method,
262271
headers,
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/**
2+
* MCP OAuth Proxy E2E Tests
3+
*
4+
* Tests the Mesh OAuth proxy against real MCP servers.
5+
* All servers in mcp-test-servers.json must pass all tests.
6+
*
7+
* Run with: bun test oauth-proxy.e2e.test.ts
8+
*/
9+
10+
import {
11+
describe,
12+
test,
13+
expect,
14+
beforeAll,
15+
afterAll,
16+
spyOn,
17+
mock,
18+
} from "bun:test";
19+
import {
20+
createDatabase,
21+
closeDatabase,
22+
type MeshDatabase,
23+
} from "../../database";
24+
import { createTestSchema } from "../../storage/test-helpers";
25+
import { createApp } from "../app";
26+
import type { EventBus } from "../../event-bus";
27+
import { auth } from "../../auth";
28+
29+
// =============================================================================
30+
// Test Data
31+
// =============================================================================
32+
33+
/** MCP servers that support OAuth - all should pass OAuth discovery tests */
34+
const MCP_SERVERS = [
35+
{ url: "https://mcp.stripe.com/", name: "Stripe" },
36+
{ url: "https://sites-openrouter.decocache.com/mcp", name: "OpenRouter" },
37+
{ url: "https://api.decocms.com/apps/deco/github/mcp", name: "Deco GitHub" },
38+
{
39+
url: "https://server.smithery.ai/@exa-labs/exa-code-mcp/mcp",
40+
name: "Smithery",
41+
},
42+
{ url: "https://mcp.notion.com/mcp", name: "Notion" },
43+
{ url: "https://api.githubcopilot.com/mcp/", name: "GitHub Copilot" },
44+
{ url: "https://mcp.vercel.com", name: "Vercel" },
45+
{ url: "https://mcp.prisma.io/sse", name: "Prisma" },
46+
{ url: "https://mcp.supabase.com/mcp", name: "Supabase" },
47+
];
48+
49+
/** MCP servers that DON'T support OAuth - should return 401 without WWW-Authenticate */
50+
const NO_OAUTH_SERVERS = [
51+
{ url: "https://mcp.postman.com/mcp", name: "Postman" },
52+
];
53+
54+
interface ProtectedResourceMetadata {
55+
resource?: string;
56+
authorization_servers?: string[];
57+
}
58+
59+
interface AuthServerMetadata {
60+
authorization_endpoint?: string;
61+
token_endpoint?: string;
62+
registration_endpoint?: string;
63+
}
64+
65+
// =============================================================================
66+
// Test Setup
67+
// =============================================================================
68+
69+
function createMockEventBus(): EventBus {
70+
return {
71+
start: async () => {},
72+
stop: () => {},
73+
isRunning: () => false,
74+
publish: async () => ({}) as never,
75+
subscribe: async () => ({}) as never,
76+
unsubscribe: async () => ({ success: true }),
77+
listSubscriptions: async () => [],
78+
getSubscription: async () => null,
79+
getEvent: async () => null,
80+
cancelEvent: async () => ({ success: true }),
81+
ackEvent: async () => ({ success: true }),
82+
syncSubscriptions: async () => ({
83+
created: 0,
84+
updated: 0,
85+
deleted: 0,
86+
unchanged: 0,
87+
subscriptions: [],
88+
}),
89+
};
90+
}
91+
92+
let database: MeshDatabase;
93+
let app: ReturnType<typeof createApp>;
94+
const connectionMap = new Map<string, string>();
95+
96+
describe("MCP OAuth Proxy E2E", () => {
97+
beforeAll(async () => {
98+
// Restore all mocks in case other tests mocked global.fetch
99+
mock.restore();
100+
101+
database = createDatabase(":memory:");
102+
await createTestSchema(database.db);
103+
app = createApp({ database, eventBus: createMockEventBus() });
104+
105+
const orgId = "org_test";
106+
107+
// Mock auth to allow authenticated requests
108+
spyOn(auth.api, "getMcpSession").mockResolvedValue(null);
109+
spyOn(auth.api, "verifyApiKey").mockResolvedValue({
110+
valid: true,
111+
error: null,
112+
key: {
113+
id: "test-key-id",
114+
name: "Test API Key",
115+
userId: "test-user-id",
116+
permissions: { self: ["COLLECTION_CONNECTIONS_LIST"] },
117+
metadata: {
118+
organization: {
119+
id: orgId,
120+
slug: "test-org",
121+
name: "Test Organization",
122+
},
123+
},
124+
},
125+
} as never);
126+
127+
// Create a connection for each MCP server (OAuth-supporting)
128+
for (const server of MCP_SERVERS) {
129+
const connectionId = `conn_${server.name.toLowerCase().replace(/[^a-z0-9]/g, "_")}`;
130+
connectionMap.set(server.url, connectionId);
131+
132+
await database.db
133+
.insertInto("connections")
134+
.values({
135+
id: connectionId,
136+
organization_id: orgId,
137+
created_by: "test_user",
138+
title: server.name,
139+
connection_type: "HTTP",
140+
connection_url: server.url,
141+
status: "active",
142+
created_at: new Date().toISOString(),
143+
updated_at: new Date().toISOString(),
144+
})
145+
.execute();
146+
}
147+
148+
// Create connections for non-OAuth servers
149+
for (const server of NO_OAUTH_SERVERS) {
150+
const connectionId = `conn_${server.name.toLowerCase().replace(/[^a-z0-9]/g, "_")}`;
151+
connectionMap.set(server.url, connectionId);
152+
153+
await database.db
154+
.insertInto("connections")
155+
.values({
156+
id: connectionId,
157+
organization_id: orgId,
158+
created_by: "test_user",
159+
title: server.name,
160+
connection_type: "HTTP",
161+
connection_url: server.url,
162+
status: "active",
163+
created_at: new Date().toISOString(),
164+
updated_at: new Date().toISOString(),
165+
})
166+
.execute();
167+
}
168+
});
169+
170+
afterAll(async () => {
171+
await closeDatabase(database);
172+
});
173+
174+
// ===========================================================================
175+
// Step 1: Protected Resource Metadata Discovery
176+
// ===========================================================================
177+
178+
describe("Protected Resource Metadata", () => {
179+
for (const server of MCP_SERVERS) {
180+
test(`${server.name} - discovery and URL rewriting`, async () => {
181+
const connectionId = connectionMap.get(server.url)!;
182+
const res = await app.request(
183+
`/.well-known/oauth-protected-resource/mcp/${connectionId}`,
184+
);
185+
186+
expect(res.status).toBe(200);
187+
188+
const metadata: ProtectedResourceMetadata = await res.json();
189+
190+
// Must have authorization_servers pointing to our proxy
191+
expect(metadata.authorization_servers).toBeDefined();
192+
expect(metadata.authorization_servers!.length).toBeGreaterThan(0);
193+
194+
const authServer = metadata.authorization_servers![0];
195+
expect(authServer).toContain(`oauth-proxy/${connectionId}`);
196+
});
197+
}
198+
});
199+
200+
// ===========================================================================
201+
// Step 2: Authorization Server Metadata Discovery
202+
// ===========================================================================
203+
204+
describe("Auth Server Metadata", () => {
205+
for (const server of MCP_SERVERS) {
206+
test(`${server.name} - discovery and endpoint rewriting`, async () => {
207+
const connectionId = connectionMap.get(server.url)!;
208+
const res = await app.request(
209+
`/.well-known/oauth-authorization-server/oauth-proxy/${connectionId}`,
210+
);
211+
212+
expect(res.status).toBe(200);
213+
214+
const metadata: AuthServerMetadata = await res.json();
215+
216+
// Must have key OAuth endpoints
217+
expect(metadata.authorization_endpoint).toBeDefined();
218+
expect(metadata.token_endpoint).toBeDefined();
219+
220+
// Endpoints must be rewritten to point to our proxy
221+
expect(metadata.authorization_endpoint).toContain(
222+
`oauth-proxy/${connectionId}`,
223+
);
224+
expect(metadata.token_endpoint).toContain(
225+
`oauth-proxy/${connectionId}`,
226+
);
227+
});
228+
}
229+
});
230+
231+
// ===========================================================================
232+
// Step 3: Authorize Endpoint (must redirect, not proxy HTML)
233+
// ===========================================================================
234+
235+
describe("Authorize Endpoint", () => {
236+
for (const server of MCP_SERVERS) {
237+
test(`${server.name} - must redirect, not proxy HTML`, async () => {
238+
const connectionId = connectionMap.get(server.url)!;
239+
const res = await app.request(
240+
`/oauth-proxy/${connectionId}/authorize?response_type=code&client_id=test&state=test`,
241+
{ redirect: "manual" },
242+
);
243+
244+
// Must be a redirect (302)
245+
expect(res.status).toBe(302);
246+
247+
// Must NOT return HTML (that was the Vercel bug)
248+
const contentType = res.headers.get("content-type") || "";
249+
expect(contentType.includes("text/html")).toBe(false);
250+
251+
// Location header must point to origin's authorize endpoint
252+
const location = res.headers.get("location");
253+
expect(location).toBeDefined();
254+
expect(location).not.toContain("oauth-proxy"); // Should be origin URL
255+
});
256+
}
257+
});
258+
259+
// ===========================================================================
260+
// Servers without OAuth support - should return 401 without WWW-Authenticate
261+
// ===========================================================================
262+
263+
describe("Non-OAuth Servers", () => {
264+
for (const server of NO_OAUTH_SERVERS) {
265+
test(`${server.name} - returns 401 without WWW-Authenticate`, async () => {
266+
const connectionId = connectionMap.get(server.url)!;
267+
268+
// Try to access the MCP endpoint with auth - should get 401 from origin without WWW-Authenticate
269+
const res = await app.request(`/mcp/${connectionId}`, {
270+
method: "POST",
271+
headers: {
272+
"Content-Type": "application/json",
273+
Accept: "application/json",
274+
Authorization: "Bearer test-api-key", // Triggers our mock auth
275+
},
276+
body: JSON.stringify({
277+
jsonrpc: "2.0",
278+
id: 1,
279+
method: "initialize",
280+
params: {
281+
protocolVersion: "2025-06-18",
282+
capabilities: {},
283+
clientInfo: { name: "test", version: "1.0.0" },
284+
},
285+
}),
286+
});
287+
288+
// Should be 401 (from origin, proxied through our system)
289+
expect(res.status).toBe(401);
290+
291+
// Should NOT have WWW-Authenticate header (server doesn't support OAuth)
292+
const wwwAuth = res.headers.get("WWW-Authenticate");
293+
expect(wwwAuth).toBeNull();
294+
295+
// Should have JSON error body
296+
const body = await res.json();
297+
expect(body.error).toBe("unauthorized");
298+
});
299+
}
300+
});
301+
});

0 commit comments

Comments
 (0)