Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions apps/mesh/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,19 +214,28 @@ export function createApp(options: CreateAppOptions = {}) {
return c.json({ error: "Connection not found" }, 404);
}

// Get origin auth server - uses shared function that tries all 3 well-known URL formats
// Get origin auth server - tries Protected Resource Metadata first, then falls back to origin root
const resourceRes = await fetchProtectedResourceMetadata(
connection.connection_url,
);
if (!resourceRes.ok) {
return c.json({ error: "Failed to get resource metadata" }, 502);

let originAuthServer: string | undefined;
const connUrl = new URL(connection.connection_url);

if (resourceRes.ok) {
// Origin has Protected Resource Metadata - use authorization_servers from it
const resourceData = (await resourceRes.json()) as {
authorization_servers?: string[];
};
originAuthServer = resourceData.authorization_servers?.[0];
Comment thread
viktormarinho marked this conversation as resolved.
}
const resourceData = (await resourceRes.json()) as {
authorization_servers?: string[];
};
const originAuthServer = resourceData.authorization_servers?.[0];

// Fall back to origin root if:
// - Origin doesn't have Protected Resource Metadata (like Apify)
// - Or metadata exists but has empty/missing authorization_servers
// Many servers expose /.well-known/oauth-authorization-server at the root even without RFC 9728
if (!originAuthServer) {
return c.json({ error: "No authorization server found" }, 404);
originAuthServer = connUrl.origin;
}

// Get OAuth endpoints from auth server metadata - uses shared function that tries all formats
Expand Down
2 changes: 2 additions & 0 deletions apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const MCP_SERVERS = [
{ url: "https://mcp.vercel.com", name: "Vercel" },
{ url: "https://mcp.prisma.io/sse", name: "Prisma" },
{ url: "https://mcp.supabase.com/mcp", name: "Supabase" },
{ url: "https://api.grain.com/_/mcp", name: "Grain" },
{ url: "https://mcp.apify.com/", name: "Apify" },
];

/** MCP servers that DON'T support OAuth - should return 401 without WWW-Authenticate */
Expand Down
184 changes: 173 additions & 11 deletions apps/mesh/src/api/routes/oauth-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,117 @@ describe("OAuth Proxy Routes", () => {

expect(res.status).toBe(401);
});

test("generates synthetic metadata when origin has no metadata but supports OAuth via WWW-Authenticate", async () => {
mockConnectionStorage({
connection_url: "https://mcp.example.com",
});

global.fetch = mock((url: string, options?: RequestInit) => {
// First 3 calls: Protected Resource Metadata discovery (returns 404)
if (
(url as string).includes("oauth-protected-resource") &&
options?.method === "GET"
) {
return Promise.resolve(
new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
statusText: "Not Found",
}),
);
}

// 4th call: checkOriginSupportsOAuth - returns 401 with WWW-Authenticate
if (options?.method === "POST") {
return Promise.resolve(
new Response(
JSON.stringify({
error: "invalid_token",
error_description: "Missing or invalid access token",
}),
{
status: 401,
headers: {
"WWW-Authenticate":
'Bearer realm="OAuth", error="invalid_token"',
"Content-Type": "application/json",
},
},
),
);
}

return Promise.resolve(
new Response(JSON.stringify({ error: "Unexpected request" }), {
status: 500,
}),
);
}) as unknown as typeof fetch;

const res = await app.request(
"http://localhost:3000/.well-known/oauth-protected-resource/mcp/conn_123",
);

expect(res.status).toBe(200);
const body = (await res.json()) as {
resource: string;
authorization_servers: string[];
bearer_methods_supported: string[];
scopes_supported: string[];
};
expect(body.resource).toBe("http://localhost:3000/mcp/conn_123");
expect(body.authorization_servers).toEqual([
"http://localhost:3000/oauth-proxy/conn_123",
]);
expect(body.bearer_methods_supported).toEqual(["header"]);
expect(body.scopes_supported).toEqual(["*"]);
});

test("returns 404 when origin has no metadata and does not support OAuth", async () => {
mockConnectionStorage({
connection_url: "https://mcp.example.com",
});

global.fetch = mock((url: string, options?: RequestInit) => {
// Protected Resource Metadata discovery (returns 404)
if (
(url as string).includes("oauth-protected-resource") &&
options?.method === "GET"
) {
return Promise.resolve(
new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
statusText: "Not Found",
}),
);
}

// checkOriginSupportsOAuth - returns 401 WITHOUT WWW-Authenticate (no OAuth support)
if (options?.method === "POST") {
return Promise.resolve(
new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: {
"Content-Type": "application/json",
},
}),
);
}

return Promise.resolve(
new Response(JSON.stringify({ error: "Unexpected request" }), {
status: 500,
}),
);
}) as unknown as typeof fetch;

const res = await app.request(
"http://localhost:3000/.well-known/oauth-protected-resource/mcp/conn_123",
);

// Should pass through the 404 since origin doesn't support OAuth
expect(res.status).toBe(404);
});
});

describe("Authorization Server Metadata Proxy", () => {
Expand Down Expand Up @@ -320,23 +431,74 @@ describe("OAuth Proxy Routes", () => {
expect(res.status).toBe(404);
});

test("returns 404 when no auth server in protected resource metadata", async () => {
mockConnectionWithAuthServer(
{ connection_url: "https://origin.example.com/mcp" },
new Response(
JSON.stringify({
resource: "https://origin.example.com/mcp",
authorization_servers: [], // Empty
test("falls back to origin root when protected resource metadata has empty auth servers", async () => {
// When Protected Resource Metadata has empty authorization_servers,
// we should fall back to the origin's root and try to fetch auth server metadata from there
(ContextFactory.create as ReturnType<typeof mock>).mockImplementation(
() =>
Promise.resolve({
storage: {
connections: {
findById: mock(() =>
Promise.resolve({
connection_url: "https://origin.example.com/mcp",
}),
),
},
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);

global.fetch = mock((url: string) => {
if ((url as string).includes("oauth-protected-resource")) {
// Return metadata with empty auth servers
return Promise.resolve(
new Response(
JSON.stringify({
resource: "https://origin.example.com/mcp",
authorization_servers: [], // Empty - should trigger fallback
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
}
// Auth server metadata at origin root
if ((url as string).includes("oauth-authorization-server")) {
return Promise.resolve(
new Response(
JSON.stringify({
issuer: "https://origin.example.com",
authorization_endpoint: "https://origin.example.com/authorize",
token_endpoint: "https://origin.example.com/token",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
}
return Promise.resolve(
new Response(JSON.stringify({ error: "Unexpected" }), {
status: 500,
}),
);
}) as unknown as typeof fetch;

const res = await app.request(
"/.well-known/oauth-authorization-server/oauth-proxy/conn_123",
"http://localhost:3000/.well-known/oauth-authorization-server/oauth-proxy/conn_123",
);

expect(res.status).toBe(404);
// Should succeed by falling back to origin root
expect(res.status).toBe(200);
const body = (await res.json()) as {
authorization_endpoint: string;
token_endpoint: string;
};
// URLs should be rewritten to go through our proxy
expect(body.authorization_endpoint).toBe(
"http://localhost:3000/oauth-proxy/conn_123/authorize",
);
expect(body.token_endpoint).toBe(
"http://localhost:3000/oauth-proxy/conn_123/token",
);
});

test("proxies and rewrites authorization server metadata", async () => {
Expand Down
Loading