From bdb21ab25efd6aafbf19939cb3ec6fcfd3d7d098 Mon Sep 17 00:00:00 2001 From: abhinav7x94 <204053250+abhinav7x94@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:24:43 +0530 Subject: [PATCH] fix(mcp): harden OAuth metadata discovery Derive protected-resource metadata URLs from the canonical MCP resource instead of request forwarding headers. Preserve RFC 9728 path and query semantics, constrain metadata routing, add route-level regression coverage, and document custom deployment behavior. --- apps/mcp/README.md | 10 ++- apps/mcp/src/server/index.ts | 27 +++++-- apps/mcp/src/server/oauth-metadata.test.ts | 91 ++++++++++++++++++++++ apps/mcp/src/server/oauth-metadata.ts | 16 ++++ 4 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 apps/mcp/src/server/oauth-metadata.test.ts create mode 100644 apps/mcp/src/server/oauth-metadata.ts diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 1cf39bc1..569c9d20 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -39,8 +39,12 @@ Example client configuration: } ``` -The client discovers the OAuth authorization server through -`/.well-known/oauth-protected-resource/mcp`. +For the default resource, the client discovers the OAuth authorization server +through `/.well-known/oauth-protected-resource/mcp`. Custom deployments derive +this location from `MCP_RESOURCE` by inserting +`/.well-known/oauth-protected-resource` before the resource path while +preserving its query string. For example, `https://memory.example.com/gateway/mcp` +uses `https://memory.example.com/.well-known/oauth-protected-resource/gateway/mcp`. ## Tools @@ -129,7 +133,7 @@ discovery and rejection tests still run. | Variable | Purpose | Default | | --- | --- | --- | | `API_URL` | Supermemory API and OAuth issuer | `https://api.supermemory.ai` | -| `MCP_RESOURCE` | Expected OAuth audience | `https://mcp.supermemory.ai/mcp` | +| `MCP_RESOURCE` | Expected OAuth audience and canonical resource URL used for metadata discovery | `https://mcp.supermemory.ai/mcp` | | `ALLOWED_MCP_ORIGIN_HOSTNAMES` | Additional comma-separated browser origins | Built-in host allowlist | | `POSTHOG_API_KEY` | Server-side MCP tool analytics project key | Disabled | | `POSTHOG_HOST` | PostHog ingestion host | `https://us.i.posthog.com` | diff --git a/apps/mcp/src/server/index.ts b/apps/mcp/src/server/index.ts index f435ea3a..e8a3540a 100644 --- a/apps/mcp/src/server/index.ts +++ b/apps/mcp/src/server/index.ts @@ -4,6 +4,10 @@ import { Hono, type Context } from "hono" import { cors } from "hono/cors" import { validateOAuthToken, type AuthUser } from "./auth" import { SupermemoryMCP } from "./legacy-protocol-state" +import { + PROTECTED_RESOURCE_METADATA_PATH, + protectedResourceMetadataUrl, +} from "./oauth-metadata" import { createSupermemoryServer } from "./server" import type { ActorContext, ServerEnv } from "./types" import { SpaceState, uploadStateName } from "./space-state" @@ -14,8 +18,6 @@ const app = new Hono<{ Bindings: Bindings }>() const DEFAULT_API_URL = "https://api.supermemory.ai" const DEFAULT_MCP_RESOURCE = "https://mcp.supermemory.ai/mcp" -const PROTECTED_RESOURCE_METADATA_PATH = - "/.well-known/oauth-protected-resource/mcp" const UPLOAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const DEFAULT_ALLOWED_ORIGIN_HOSTNAMES = [ @@ -58,6 +60,19 @@ app.get("/", (c) => { function resourceMetadata(c: Context<{ Bindings: Bindings }>) { const apiUrl = c.env.API_URL || DEFAULT_API_URL const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE + const requestUrl = new URL(c.req.url) + const canonicalMetadataUrl = new URL( + protectedResourceMetadataUrl(mcpResource), + ) + // MCP clients also try the root well-known location as a compatibility fallback. + const isMcpRootFallback = + requestUrl.pathname === PROTECTED_RESOURCE_METADATA_PATH && + requestUrl.search === "" + const isCanonicalLocation = + requestUrl.pathname === canonicalMetadataUrl.pathname && + requestUrl.search === canonicalMetadataUrl.search + + if (!isMcpRootFallback && !isCanonicalLocation) return c.notFound() return c.json({ resource: mcpResource, @@ -68,8 +83,8 @@ function resourceMetadata(c: Context<{ Bindings: Bindings }>) { }) } -app.get("/.well-known/oauth-protected-resource", resourceMetadata) app.get(PROTECTED_RESOURCE_METADATA_PATH, resourceMetadata) +app.get(`${PROTECTED_RESOURCE_METADATA_PATH}/*`, resourceMetadata) app.get("/.well-known/openai-apps-challenge", (c) => { return c.text(c.env.OPENAI_APPS_CHALLENGE || "") @@ -167,11 +182,7 @@ async function handleMcpRequest( const apiUrl = c.env.API_URL || DEFAULT_API_URL const mcpResource = c.env.MCP_RESOURCE || DEFAULT_MCP_RESOURCE - const reqHost = c.req.header("x-forwarded-host") || c.req.header("host") || "" - const reqProto = c.req.header("x-forwarded-proto") || "https" - const resourceMetadataUrl = reqHost - ? `${reqProto}://${reqHost}${PROTECTED_RESOURCE_METADATA_PATH}` - : PROTECTED_RESOURCE_METADATA_PATH + const resourceMetadataUrl = protectedResourceMetadataUrl(mcpResource) const mcpOrigin = c.env.MCP_PUBLIC_ORIGIN || new URL(mcpResource).origin if (!token) return unauthorizedResponse(resourceMetadataUrl) diff --git a/apps/mcp/src/server/oauth-metadata.test.ts b/apps/mcp/src/server/oauth-metadata.test.ts new file mode 100644 index 00000000..6fe3d54d --- /dev/null +++ b/apps/mcp/src/server/oauth-metadata.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest" +import app from "./index" +import { PROTECTED_RESOURCE_METADATA_PATH } from "./oauth-metadata" +import type { ServerEnv } from "./types" + +vi.mock("cloudflare:workers", () => ({ DurableObject: class {} })) +vi.mock("../../dist/src/widget/index.html", () => ({ default: "" })) + +describe("OAuth protected-resource metadata challenge", () => { + it("ignores spoofed forwarding headers", async () => { + const resource = "https://mcp.example.com/mcp" + const response = await app.request( + "https://mcp.example.com/mcp", + { + method: "POST", + headers: { + host: "attacker.example", + "x-forwarded-host": "attacker.example", + "x-forwarded-proto": "http", + }, + }, + { MCP_RESOURCE: resource } as ServerEnv, + ) + + expect(response.status).toBe(401) + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}/mcp"`, + ) + }) + + it("keeps discovery aligned behind a trusted proxy", async () => { + const resource = "https://memory.example.com/gateway/mcp/?tenant=acme" + const metadataUrl = + "https://memory.example.com/.well-known/oauth-protected-resource/gateway/mcp/?tenant=acme" + const env = { MCP_RESOURCE: resource } as ServerEnv + const response = await app.request( + "http://worker.internal/mcp", + { method: "POST" }, + env, + ) + + expect(response.status).toBe(401) + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="${metadataUrl}"`, + ) + + const metadataResponse = await app.request(metadataUrl, undefined, env) + expect(metadataResponse.status).toBe(200) + await expect(metadataResponse.json()).resolves.toMatchObject({ resource }) + + const rootFallbackResponse = await app.request( + `https://memory.example.com${PROTECTED_RESOURCE_METADATA_PATH}`, + undefined, + env, + ) + expect(rootFallbackResponse.status).toBe(200) + + for (const mismatchedUrl of [ + metadataUrl.replace("tenant=acme", "tenant=other"), + metadataUrl.replace("/gateway/mcp/", "/other/"), + `https://memory.example.com${PROTECTED_RESOURCE_METADATA_PATH}?tenant=acme`, + ]) { + const mismatchedResponse = await app.request( + mismatchedUrl, + undefined, + env, + ) + expect(mismatchedResponse.status).toBe(404) + } + }) + + it("supports a resource identifier without a path", async () => { + const resource = "https://mcp.example.com" + const response = await app.request(resource, { method: "POST" }, { + MCP_RESOURCE: resource, + } as ServerEnv) + + expect(response.status).toBe(401) + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}"`, + ) + + const metadataResponse = await app.request( + `https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}`, + undefined, + { MCP_RESOURCE: resource } as ServerEnv, + ) + expect(metadataResponse.status).toBe(200) + await expect(metadataResponse.json()).resolves.toMatchObject({ resource }) + }) +}) diff --git a/apps/mcp/src/server/oauth-metadata.ts b/apps/mcp/src/server/oauth-metadata.ts new file mode 100644 index 00000000..73d8a01a --- /dev/null +++ b/apps/mcp/src/server/oauth-metadata.ts @@ -0,0 +1,16 @@ +export const PROTECTED_RESOURCE_METADATA_PATH = + "/.well-known/oauth-protected-resource" + +/** + * Builds the protected-resource metadata URL advertised to OAuth clients. + * + * The canonical resource is the single source of truth so the advertised URL + * and the metadata document cannot disagree. Forwarded headers are intentionally + * excluded because they are request-controlled in some proxy deployments. + */ +export function protectedResourceMetadataUrl(resource: string): string { + const resourceUrl = new URL(resource) + const resourcePath = resourceUrl.pathname === "/" ? "" : resourceUrl.pathname + resourceUrl.pathname = `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath}` + return resourceUrl.toString() +}