From d75bde4e035f16aae507cf45ceaf448179768012 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Thu, 23 Apr 2026 21:17:32 +0400 Subject: [PATCH] fix(agent-readiness): host-aware oauth-protected-resource endpoint (#3351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-readiness): host-aware oauth-protected-resource endpoint isitagentready.com enforces that `authorization_servers[*]` share origin with `resource` (same-origin rule, matches Cloudflare's mcp.cloudflare.com reference — RFC 9728 §3 permits split origins but the scanner is stricter). A single static file served from 3 hosts (apex/www/api) can only satisfy one origin at a time. Replacing with an edge function that derives both `resource` and `authorization_servers` from the request `Host` header gives each origin self-consistent metadata. No server-side behavior changes: api/oauth/*.js token issuer doesn't bind tokens to a specific resource value (verified in the previous PR's review). * fix(agent-readiness): host-derive resource_metadata + runtime guardrails Addresses P1/P2 review on this PR: - api/mcp.ts (P1): WWW-Authenticate resource_metadata was still hardcoded to apex even when the client hit api.worldmonitor.app. Derive from request.headers.get('host') so each client gets a pointer matching their own origin — consistent with the host- aware edge function this PR introduces. - api/oauth-protected-resource.ts (P2): add Vary: Host so any intermediate cache keys by hostname (belt + suspenders on top of Vercel's routing). - tests/deploy-config.test.mjs (P2): replace regex-on-source with a runtime handler invocation asserting origin-matching metadata for apex/www/api hosts, and tighten the api/mcp.ts assertion to require host-derived resource_metadata construction. --------- Co-authored-by: Elie Habib --- api/api-route-exceptions.json | 7 ++ api/mcp.ts | 11 +- api/oauth-protected-resource.ts | 46 +++++++ public/.well-known/oauth-protected-resource | 6 - tests/deploy-config.test.mjs | 131 +++++++++++--------- vercel.json | 9 +- 6 files changed, 134 insertions(+), 76 deletions(-) create mode 100644 api/oauth-protected-resource.ts delete mode 100644 public/.well-known/oauth-protected-resource diff --git a/api/api-route-exceptions.json b/api/api-route-exceptions.json index 03d3272cc..71283c803 100644 --- a/api/api-route-exceptions.json +++ b/api/api-route-exceptions.json @@ -37,6 +37,13 @@ "owner": "@SebastienMelki", "removal_issue": null }, + { + "path": "api/oauth-protected-resource.ts", + "category": "external-protocol", + "reason": "RFC 9728 OAuth Protected Resource metadata. Dynamic per-host to satisfy scanner origin-match rules — shape dictated by RFC 9728.", + "owner": "@SebastienMelki", + "removal_issue": null + }, { "path": "api/discord/oauth/callback.ts", "category": "external-protocol", diff --git a/api/mcp.ts b/api/mcp.ts index d2e928a69..68f7a1afe 100644 --- a/api/mcp.ts +++ b/api/mcp.ts @@ -830,6 +830,13 @@ export default async function handler(req: Request): Promise { if (origin && origin !== 'https://claude.ai' && origin !== 'https://claude.com') { return new Response('Forbidden', { status: 403, headers: corsHeaders }); } + // Host-derived resource_metadata pointer: a client probing api.worldmonitor.app/mcp + // must see a pointer at its own origin, not the apex — otherwise the 401's + // WWW-Authenticate points at apex metadata whose `resource` field is apex too, + // and same-origin scanners (isitagentready.com, Cloudflare mcp.cloudflare.com) + // flag the mismatch. Matches the host-extraction in api/oauth-protected-resource.ts. + const requestHost = req.headers.get('host') ?? new URL(req.url).host; + const resourceMetadataUrl = `https://${requestHost}/.well-known/oauth-protected-resource`; // Auth chain (in priority order): // 1. Authorization: Bearer — issued by /oauth/token (spec-compliant OAuth 2.0) // 2. X-WorldMonitor-Key header — direct API key (curl, custom integrations) @@ -853,7 +860,7 @@ export default async function handler(req: Request): Promise { // Bearer token present but unresolvable — expired or invalid UUID return new Response( JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Invalid or expired OAuth token. Re-authenticate via /oauth/token.' } }), - { status: 401, headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer realm="worldmonitor", error="invalid_token", resource_metadata="https://worldmonitor.app/.well-known/oauth-protected-resource"', ...corsHeaders } } + { status: 401, headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': `Bearer realm="worldmonitor", error="invalid_token", resource_metadata="${resourceMetadataUrl}"`, ...corsHeaders } } ); } } else { @@ -861,7 +868,7 @@ export default async function handler(req: Request): Promise { if (!candidateKey) { return new Response( JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Authentication required. Use OAuth (/oauth/token) or pass your API key via X-WorldMonitor-Key header.' } }), - { status: 401, headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer realm="worldmonitor", resource_metadata="https://worldmonitor.app/.well-known/oauth-protected-resource"', ...corsHeaders } } + { status: 401, headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': `Bearer realm="worldmonitor", resource_metadata="${resourceMetadataUrl}"`, ...corsHeaders } } ); } const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean); diff --git a/api/oauth-protected-resource.ts b/api/oauth-protected-resource.ts new file mode 100644 index 000000000..497a5b314 --- /dev/null +++ b/api/oauth-protected-resource.ts @@ -0,0 +1,46 @@ +/** + * GET /.well-known/oauth-protected-resource (rewritten to /api/oauth-protected-resource) + * + * RFC 9728 OAuth Protected Resource Metadata, served dynamically so every + * host that terminates the request (apex worldmonitor.app, www, or + * api.worldmonitor.app) returns self-consistent `resource` + + * `authorization_servers` pointing at itself. + * + * Why dynamic: scanners like isitagentready.com (and Cloudflare's reference + * at mcp.cloudflare.com) enforce that `authorization_servers[*]` share + * origin with `resource`. A single static file served from 3 hosts can only + * satisfy one origin at a time; deriving both fields from the request Host + * header makes the response correct regardless of which host is scanned. + * + * RFC 9728 §3 permits split origins, but the scanner is stricter — and + * same-origin by construction is simpler than arguing with scanner authors. + */ + +export const config = { runtime: 'edge' }; + +export default function handler(req: Request): Response { + const url = new URL(req.url); + const host = req.headers.get('host') ?? url.host; + const origin = `https://${host}`; + + const body = JSON.stringify({ + resource: origin, + authorization_servers: [origin], + bearer_methods_supported: ['header'], + scopes_supported: ['mcp'], + }); + + return new Response(body, { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600', + 'Access-Control-Allow-Origin': '*', + // Response body varies by Host (resource/authorization_servers derived + // from it). Any intermediate cache keying on path alone could serve + // wrong-origin metadata across hosts. Vercel's own router is per-host, + // but this is belt-and-braces against downstream caches. + 'Vary': 'Host', + }, + }); +} diff --git a/public/.well-known/oauth-protected-resource b/public/.well-known/oauth-protected-resource deleted file mode 100644 index 1b8d4888d..000000000 --- a/public/.well-known/oauth-protected-resource +++ /dev/null @@ -1,6 +0,0 @@ -{ - "resource": "https://worldmonitor.app", - "authorization_servers": ["https://api.worldmonitor.app"], - "bearer_methods_supported": ["header"], - "scopes_supported": ["mcp"] -} diff --git a/tests/deploy-config.test.mjs b/tests/deploy-config.test.mjs index a3f1ba537..b0af011a7 100644 --- a/tests/deploy-config.test.mjs +++ b/tests/deploy-config.test.mjs @@ -376,72 +376,83 @@ describe('agent readiness: api-catalog + openapi build', () => { }); }); -// The MCP endpoint and OAuth protected-resource metadata must share an -// origin — a scanner or client that enters from a mismatched host sees -// "this server says its resource lives on a different origin", which -// violates RFC 9728 and breaks the PRM discovery flow in strict clients. -// The resource/authorization-server split is intentional: apex serves -// the MCP transport + resource metadata, api.worldmonitor.app serves -// the OAuth endpoints. Keep them in lockstep — all resource-side -// pointers (MCP server-card transport.endpoint, MCP server-card -// authentication.resource, oauth-protected-resource.resource, and every -// WWW-Authenticate resource_metadata pointer emitted from api/mcp.ts) -// must agree, while authorization_servers stays on the api host. +// The MCP endpoint and OAuth protected-resource metadata must be +// self-consistent per host. The static file that used to live at +// public/.well-known/oauth-protected-resource was replaced with a +// dynamic edge function at api/oauth-protected-resource.ts that +// derives `resource` and `authorization_servers` from the request +// Host header, so every origin (apex / www / api) sees same-origin +// metadata regardless of which host the scanner entered from. +// Scanners like isitagentready.com (and Cloudflare's reference at +// mcp.cloudflare.com) enforce that `authorization_servers[*]` share +// origin with `resource` — this construction guarantees that. describe('agent readiness: MCP/OAuth origin alignment', () => { - const mcpCard = JSON.parse( - readFileSync(resolve(__dirname, '../public/.well-known/mcp/server-card.json'), 'utf-8') - ); - const oauthMeta = JSON.parse( - readFileSync(resolve(__dirname, '../public/.well-known/oauth-protected-resource'), 'utf-8') - ); + it('oauth-protected-resource handler returns origin-matching metadata per host', async () => { + // Runtime test (not source-regex): dynamically import the edge handler + // and invoke it against synthetic Host headers to prove the response + // is actually same-origin per host, with correct Vary + Content-Type. + const mod = await import('../api/oauth-protected-resource.ts'); + const handler = mod.default; + assert.equal(typeof handler, 'function', 'handler must be the default export'); - const mcpEndpointOrigin = new URL(mcpCard.transport.endpoint).origin; - const resourceOrigin = new URL(oauthMeta.resource).origin; - - it('MCP transport.endpoint origin matches OAuth metadata resource origin', () => { - assert.equal( - mcpEndpointOrigin, - resourceOrigin, - 'MCP transport.endpoint and OAuth resource must share the same origin' - ); - }); - - it('MCP card authentication.resource equals OAuth metadata resource exactly', () => { - assert.equal( - mcpCard.authentication.resource, - oauthMeta.resource, - 'MCP card authentication.resource must equal OAuth metadata resource' - ); - }); - - it('authorization_servers stay on api.worldmonitor.app (intentional resource/AS split)', () => { - assert.ok( - Array.isArray(oauthMeta.authorization_servers) && oauthMeta.authorization_servers.length > 0, - 'oauth-protected-resource.authorization_servers must be a non-empty array' - ); - for (const s of oauthMeta.authorization_servers) { - assert.equal( - new URL(s).origin, - 'https://api.worldmonitor.app', - `authorization_servers entry must stay on api.worldmonitor.app, got: ${s}` - ); + const hosts = ['worldmonitor.app', 'www.worldmonitor.app', 'api.worldmonitor.app']; + for (const host of hosts) { + const req = new Request(`https://${host}/.well-known/oauth-protected-resource`, { + headers: { host }, + }); + const res = await handler(req); + assert.equal(res.status, 200, `status 200 for ${host}`); + assert.equal(res.headers.get('content-type'), 'application/json', `JSON for ${host}`); + assert.equal(res.headers.get('vary'), 'Host', `Vary: Host for ${host}`); + const json = await res.json(); + assert.equal(json.resource, `https://${host}`, `resource matches ${host}`); + assert.deepEqual(json.authorization_servers, [`https://${host}`], `auth_servers match ${host}`); + assert.deepEqual(json.bearer_methods_supported, ['header']); + assert.deepEqual(json.scopes_supported, ['mcp']); } }); - it('api/mcp.ts WWW-Authenticate resource_metadata pointers share origin with oauth-protected-resource', () => { - const mcpSource = readFileSync(resolve(__dirname, '../api/mcp.ts'), 'utf-8'); - const matches = [...mcpSource.matchAll(/resource_metadata="([^"]+)"/g)]; - assert.ok( - matches.length > 0, - 'api/mcp.ts should emit resource_metadata pointers in its 401 WWW-Authenticate headers' + it('MCP server card authentication.resource is a valid https URL on a known host', () => { + const mcpCard = JSON.parse( + readFileSync(resolve(__dirname, '../public/.well-known/mcp/server-card.json'), 'utf-8') ); - for (const [, url] of matches) { - assert.equal( - new URL(url).origin, - resourceOrigin, - `api/mcp.ts resource_metadata pointer ${url} must share origin with oauth-protected-resource` - ); - } + const u = new URL(mcpCard.authentication.resource); + assert.equal(u.protocol, 'https:'); + assert.ok( + ['worldmonitor.app', 'www.worldmonitor.app', 'api.worldmonitor.app'].includes(u.host), + `unexpected host: ${u.host}` + ); + }); + + it('api/mcp.ts resource_metadata is host-derived, not hardcoded', () => { + const source = readFileSync(resolve(__dirname, '../api/mcp.ts'), 'utf-8'); + // Must NOT contain a hardcoded apex or api URL for resource_metadata — + // that regressed once (PR #3351 review: apex pointer emitted from + // api.worldmonitor.app/mcp 401s) and the grep-only test didn't catch it. + assert.ok( + !/resource_metadata="https:\/\/(?:api\.)?worldmonitor\.app\/\.well-known\//.test(source), + 'api/mcp.ts must not hardcode resource_metadata URL — derive from request host' + ); + // Must contain a template-literal construction that uses a host variable. + assert.match( + source, + /resource_metadata="\$\{[A-Za-z_][A-Za-z0-9_]*\}"|`[^`]*resource_metadata="\$\{[^}]+\}"/, + 'api/mcp.ts must construct resource_metadata from a host-derived variable' + ); + // Must actually read the request host header somewhere in the file. + assert.match( + source, + /request\.headers\.get\(['"]host['"]\)|req\.headers\.get\(['"]host['"]\)/i, + 'api/mcp.ts should read the request host header' + ); + }); + + it('vercel.json rewrites /.well-known/oauth-protected-resource to the edge fn', () => { + const rewrite = vercelConfig.rewrites.find( + (r) => r.source === '/.well-known/oauth-protected-resource' + ); + assert.ok(rewrite, 'expected a rewrite for /.well-known/oauth-protected-resource'); + assert.equal(rewrite.destination, '/api/oauth-protected-resource'); }); }); diff --git a/vercel.json b/vercel.json index 5081591d1..1ef877fec 100644 --- a/vercel.json +++ b/vercel.json @@ -12,6 +12,7 @@ { "source": "/oauth/token", "destination": "/api/oauth/token" }, { "source": "/oauth/register", "destination": "/api/oauth/register" }, { "source": "/oauth/authorize", "destination": "/api/oauth/authorize" }, + { "source": "/.well-known/oauth-protected-resource", "destination": "/api/oauth-protected-resource" }, { "source": "/((?!api|mcp|oauth|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|openapi\\.yaml|\\.well-known|wm-widget-sandbox\\.html).*)", "destination": "/index.html" } ], "headers": [ @@ -39,14 +40,6 @@ { "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" } ] }, - { - "source": "/.well-known/oauth-protected-resource", - "headers": [ - { "key": "Content-Type", "value": "application/json" }, - { "key": "Access-Control-Allow-Origin", "value": "*" }, - { "key": "Cache-Control", "value": "public, max-age=3600" } - ] - }, { "source": "/.well-known/oauth-authorization-server", "headers": [