fix(mcp): RFC 9728 protected-resource metadata path + audience binding
All checks were successful
Deploy to Production / deploy (push) Successful in 1m31s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m31s
Codex/RFC review showed that Claude Desktop addresses the MCP resource as <PUBLIC_URL>/mcp (the streamable-HTTP endpoint) rather than the base URL. Per RFC 9728 the protected-resource metadata then lives at .well-known/oauth-protected-resource inserted between host and path: https://mcp.buildmymcpserver.com/.well-known/oauth-protected-resource/<slug>/mcp Runner template now: - publishes `resource: <PUBLIC_URL>/mcp` - sets WWW-Authenticate to the RFC 9728 well-known URL - serves /.well-known/oauth-protected-resource[/*] so the metadata answers at both the legacy and RFC paths during transition - accepts both audiences (<PUBLIC_URL>/mcp + <PUBLIC_URL>) during rollout so already-issued tokens keep working API: - resolveServerByResource() tries port first, then path segment (production path-routing), with a guard against treating "mcp" as a tenant slug - AS metadata advertises resource_parameter_supported: true nginx (scripts/setup-runner-tls.sh + scripts/bmm-mcp-runners.nginx): - new location matches /.well-known/oauth-protected-resource/<slug>/... and proxies to the slug's runner with the slug stripped, so the runner sees the local well-known path Docs (oauth + api-reference) updated to the RFC paths.
This commit is contained in:
@@ -59,9 +59,24 @@ import Fastify from 'fastify';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const PUBLIC_URL = process.env.PUBLIC_URL ?? 'http://localhost:3000';
|
||||
const CONTROL_PLANE_URL = process.env.CONTROL_PLANE_URL ?? 'http://host.docker.internal:4000';
|
||||
const OAUTH_ISSUER = process.env.OAUTH_ISSUER ?? CONTROL_PLANE_URL + '/oauth';
|
||||
function stripTrailingSlash(value) {
|
||||
return value.replace(/\\/$/, '');
|
||||
}
|
||||
|
||||
function protectedResourceMetadataUrl(resourceUrl) {
|
||||
const url = new URL(resourceUrl);
|
||||
const resourcePath = url.pathname === '/' ? '' : url.pathname;
|
||||
url.pathname = '/.well-known/oauth-protected-resource' + resourcePath;
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const PUBLIC_URL = stripTrailingSlash(process.env.PUBLIC_URL ?? 'http://localhost:3000');
|
||||
const CONTROL_PLANE_URL = stripTrailingSlash(process.env.CONTROL_PLANE_URL ?? 'http://host.docker.internal:4000');
|
||||
const OAUTH_ISSUER = stripTrailingSlash(process.env.OAUTH_ISSUER ?? CONTROL_PLANE_URL + '/oauth');
|
||||
const MCP_RESOURCE_URL = PUBLIC_URL + '/mcp';
|
||||
const PROTECTED_RESOURCE_METADATA_URL = protectedResourceMetadataUrl(MCP_RESOURCE_URL);
|
||||
const EXPECTED_AUDIENCES = Array.from(new Set([MCP_RESOURCE_URL, PUBLIC_URL]));
|
||||
const PORT = Number.parseInt(process.env.PORT ?? '3000', 10);
|
||||
|
||||
const server = new McpServer(
|
||||
@@ -75,15 +90,18 @@ const app = Fastify({ logger: { level: 'info' } });
|
||||
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
|
||||
app.get('/.well-known/oauth-protected-resource', async () => ({
|
||||
resource: PUBLIC_URL,
|
||||
const protectedResourceMetadata = async () => ({
|
||||
resource: MCP_RESOURCE_URL,
|
||||
authorization_servers: [OAUTH_ISSUER],
|
||||
bearer_methods_supported: ['header'],
|
||||
scopes_supported: ${JSON.stringify(spec.scopes)},
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/.well-known/oauth-protected-resource', protectedResourceMetadata);
|
||||
app.get('/.well-known/oauth-protected-resource/*', protectedResourceMetadata);
|
||||
|
||||
app.get('/.well-known/oauth-authorization-server', async () => {
|
||||
const r = await fetch(CONTROL_PLANE_URL + '/oauth/.well-known/oauth-authorization-server');
|
||||
const r = await fetch(CONTROL_PLANE_URL + '/.well-known/oauth-authorization-server/oauth');
|
||||
return await r.json();
|
||||
});
|
||||
|
||||
@@ -96,16 +114,17 @@ app.all('/mcp', async (request, reply) => {
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
return reply
|
||||
.code(401)
|
||||
.header('WWW-Authenticate', \`Bearer resource_metadata="\${PUBLIC_URL}/.well-known/oauth-protected-resource"\`)
|
||||
.header('WWW-Authenticate', \`Bearer resource_metadata="\${PROTECTED_RESOURCE_METADATA_URL}"\`)
|
||||
.send({ error: 'unauthorized' });
|
||||
}
|
||||
const token = auth.slice(7);
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer: OAUTH_ISSUER,
|
||||
audience: PUBLIC_URL,
|
||||
audience: EXPECTED_AUDIENCES,
|
||||
});
|
||||
if (payload.aud !== PUBLIC_URL) {
|
||||
const audiences = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
|
||||
if (!audiences.some((aud) => EXPECTED_AUDIENCES.includes(aud))) {
|
||||
return reply.code(403).send({ error: 'invalid_audience' });
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user