mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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.
This commit is contained in:
parent
e651045ac5
commit
bdb21ab25e
4 changed files with 133 additions and 11 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
91
apps/mcp/src/server/oauth-metadata.test.ts
Normal file
91
apps/mcp/src/server/oauth-metadata.test.ts
Normal file
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
16
apps/mcp/src/server/oauth-metadata.ts
Normal file
16
apps/mcp/src/server/oauth-metadata.ts
Normal file
|
|
@ -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()
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue