From 4a2f0f3f4034b81544dbdf82b37b1b435db3f7fa Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 21 May 2026 07:45:42 -0700 Subject: [PATCH] Add MCP well-known metadata endpoints (RFC 8414, RFC 9728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of MCP OAuth support (issue #86). Mounts three discovery documents under /.well-known/* so MCP clients (Claude Desktop, Cursor, mcp-remote) can find the authorization server, the supported methods, and the public keys to verify tokens against. - /.well-known/oauth-protected-resource — RFC 9728 PRM document. Becomes the entry point once Stage 5's withMCPAuth issues 401 + WWW-Authenticate pointing here. - /.well-known/oauth-authorization-server — RFC 8414 AS metadata. Advertises authorize / token / register / JWKS endpoints, S256-only PKCE, code-only response type, both client secret and public-client auth methods, RS256 + EdDSA signing algs, RFC 8707 resource parameter support. - /.well-known/jwks.json — placeholder returning an empty key set until Stage 4 generates and persists the signing keypair in the harper_oauth_mcp_keys table. All three documents include Access-Control-Allow-Origin: * so browser- based MCP clients can fetch them cross-origin (Gemini's blocker on the draft of this PR — adding it before push). Two new MCP config fields: mcp.issuer (authorization-server identity) and mcp.resource (canonical resource URI for RFC 8707 audience binding). Both default to derive-from-request, but the issuer derivation trusts the Host header and so MUST be pinned in production once Stage 4 starts signing tokens with `iss` — documented in docs/configuration.md. Handlers registered via Harper's server.http(handler, { urlPath }) middleware. urlPath matching is prefix-based with segment-boundary awareness, so each handler additionally exact-checks request.pathname to reject sub-paths. Config is read at request time through a getter so options changes apply without re-registering routes (Harper's server.http has no deregistration mechanism). 552 unit tests pass locally (+25 from this PR). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/configuration.md | 34 ++++- src/index.ts | 7 + src/lib/mcp/wellKnown.ts | 194 +++++++++++++++++++++++ src/types.ts | 10 ++ test/lib/mcp/wellKnown.test.js | 270 +++++++++++++++++++++++++++++++++ 5 files changed, 508 insertions(+), 7 deletions(-) create mode 100644 src/lib/mcp/wellKnown.ts create mode 100644 test/lib/mcp/wellKnown.test.js diff --git a/docs/configuration.md b/docs/configuration.md index df37f79..822c266 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,12 +57,20 @@ Each provider requires: ### MCP OAuth (work in progress) -Opt-in support for the Model Context Protocol authorization flow ([issue #86](https://github.com/HarperFast/oauth/issues/86)). The first stage adds Dynamic Client Registration at `POST /oauth/mcp/register` (RFC 7591) so MCP clients (Claude Desktop, Cursor, `mcp-remote`) can register themselves at runtime. `/authorize`, `/token`, and the `withMCPAuth` wrapper land in subsequent releases. +Opt-in support for the Model Context Protocol authorization flow ([issue #86](https://github.com/HarperFast/oauth/issues/86)). The first two stages add Dynamic Client Registration at `POST /oauth/mcp/register` (RFC 7591) and the discovery documents under `/.well-known/*` (RFCs 8414, 9728) so MCP clients (Claude Desktop, Cursor, `mcp-remote`) can find and register themselves. `/authorize`, `/token`, and the `withMCPAuth` wrapper land in subsequent releases. ```yaml '@harperfast/oauth': mcp: enabled: true + # Pin the authorization-server identity. STRONGLY recommended in + # production — without this we derive issuer from the request's Host + # header, which a client controls. When tokens are signed (a later + # release), trusting Host would let an attacker influence `iss` claims. + issuer: https://my-app.example.com + # Canonical resource URI advertised in PRM and used as the `aud` claim + # on issued tokens (RFC 8707). Defaults to `/mcp` when unset. + resource: https://my-app.example.com/mcp dynamicClientRegistration: # Optional: require Authorization: Bearer on /register. # Without this, registration is OPEN per RFC 7591 — anyone can register. @@ -72,15 +80,27 @@ Opt-in support for the Model Context Protocol authorization flow ([issue #86](ht - app.example.com ``` -| Option | Type | Default | Description | -| ------------------------------------------------------- | -------- | ------- | ------------------------------------------------------------------------------------------ | -| `mcp.enabled` | boolean | `false` | Master switch for the MCP OAuth endpoints | -| `mcp.dynamicClientRegistration.enabled` | boolean | `true` | Enable the `/register` endpoint when `mcp.enabled` is true | -| `mcp.dynamicClientRegistration.initialAccessToken` | string | (none) | If set, registration requires `Authorization: Bearer `. Otherwise open per RFC 7591 | -| `mcp.dynamicClientRegistration.allowedRedirectUriHosts` | string[] | (none) | Allowlist for redirect_uri hosts. Localhost always allowed per RFC 8252 | +| Option | Type | Default | Description | +| ------------------------------------------------------- | -------- | ---------------------- | ------------------------------------------------------------------------------------------------------------ | +| `mcp.enabled` | boolean | `false` | Master switch for the MCP OAuth endpoints | +| `mcp.issuer` | string | (derived from request) | Authorization-server URI advertised in AS metadata. Pin in production to prevent Host-header-driven identity | +| `mcp.resource` | string | `/mcp` | Canonical resource URI advertised in PRM (RFC 9728) and validated as `aud` on issued tokens (RFC 8707) | +| `mcp.dynamicClientRegistration.enabled` | boolean | `true` | Enable the `/register` endpoint when `mcp.enabled` is true | +| `mcp.dynamicClientRegistration.initialAccessToken` | string | (none) | If set, registration requires `Authorization: Bearer `. Otherwise open per RFC 7591 | +| `mcp.dynamicClientRegistration.allowedRedirectUriHosts` | string[] | (none) | Allowlist for redirect_uri hosts. Localhost always allowed per RFC 8252 | Sensitive leaves inside `mcp` support `${ENV_VAR}` expansion (e.g., `initialAccessToken: ${OAUTH_MCP_REGISTRATION_TOKEN}`), the same way provider credentials do. +**Discovery endpoints** (served when `mcp.enabled: true`): + +| Path | Spec | Purpose | +| ----------------------------------------- | -------- | ------------------------------------------------------------------------------------ | +| `/.well-known/oauth-protected-resource` | RFC 9728 | Tells MCP clients where to find the authorization server | +| `/.well-known/oauth-authorization-server` | RFC 8414 | Advertises authorize / token / register / JWKS endpoints and supported methods | +| `/.well-known/jwks.json` | — | Public keys for verifying issued JWTs (returns an empty key set until signing lands) | + +All three documents include `Access-Control-Allow-Origin: *` so browser-based MCP clients and discovery tools can fetch them cross-origin. + ## Environment Variables All configuration options can be set via environment variables: diff --git a/src/index.ts b/src/index.ts index 8e67d23..4b97ee2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { validateAndRefreshSession } from './lib/sessionValidator.ts'; import { clearOAuthSession } from './lib/handlers.ts'; import { HookManager } from './lib/hookManager.ts'; import { DynamicProviderCache } from './lib/dynamicProviderCache.ts'; +import { registerWellKnownHandlers } from './lib/mcp/wellKnown.ts'; import type { Scope, OAuthPluginConfig, ProviderRegistry, OAuthHooks } from './types.ts'; // Export HookManager class, OAuthResource class, and types @@ -299,6 +300,12 @@ export async function handleApplication(scope: Scope): Promise { // Initial configuration (errors propagate to plugin loader) await updateConfiguration(); + // Register MCP well-known metadata endpoints once. The handlers read the + // current mcpConfig from OAuthResource at request time, so live config + // changes apply without re-registering routes (Harper's server.http does + // not support deregistration). + registerWellKnownHandlers(server, () => OAuthResource.mcpConfig, logger); + // Watch for configuration changes (errors caught internally) scope.options.on('change', () => { runUpdate().catch((error) => { diff --git a/src/lib/mcp/wellKnown.ts b/src/lib/mcp/wellKnown.ts new file mode 100644 index 0000000..6a2b901 --- /dev/null +++ b/src/lib/mcp/wellKnown.ts @@ -0,0 +1,194 @@ +/** + * MCP Well-Known Metadata Endpoints + * + * Mounts three discovery documents under `/.well-known/*` for MCP clients: + * + * - `/.well-known/oauth-protected-resource` — RFC 9728 Protected Resource + * Metadata. The entry point: an MCP client that hits the protected resource + * without credentials receives a `401 + WWW-Authenticate: Bearer + * resource_metadata="..."` (issued by withMCPAuth, Stage 5) pointing here, + * then fetches this document to discover the authorization server. + * - `/.well-known/oauth-authorization-server` — RFC 8414 Authorization Server + * Metadata. Advertises authorize / token / register / JWKS endpoints and + * the supported algorithms / methods / response types. + * - `/.well-known/jwks.json` — Public key set used to verify issued JWTs. + * Returns an empty key set until Stage 4 of issue #86 lands key material in + * the `harper_oauth_mcp_keys` table. + * + * Endpoints are registered through `server.http(handler, { urlPath })` so + * routing works for `.well-known` paths that don't fit Harper's Resource API. + * Harper's urlPath matching is prefix-based (segment-boundary aware), so each + * handler also checks the exact path before responding — sub-paths like + * `/.well-known/oauth-authorization-server/foo` fall through to 404. + */ + +import type { Logger, MCPConfig } from '../../types.ts'; + +interface HarperRequest { + pathname?: string; + protocol?: string; + host?: string; + headers?: Record & { host?: string }; +} + +type HttpResponse = { + status: number; + headers: Record; + body: string; +}; + +const PRM_PATH = '/.well-known/oauth-protected-resource'; +const AS_METADATA_PATH = '/.well-known/oauth-authorization-server'; +const JWKS_PATH = '/.well-known/jwks.json'; + +function jsonResponse(body: unknown, status = 200): HttpResponse { + return { + status, + headers: { + 'Content-Type': 'application/json', + // Discovery documents are unauthenticated and intended for cross-origin + // fetches — browser-based MCP clients and inspectors won't be able to + // read them without CORS. Simple `*` is sufficient because no + // credentials are involved. + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + }, + body: JSON.stringify(body), + }; +} + +/** + * Resolve the issuer (authorization-server origin). Configured value wins; + * otherwise derive from the request — scheme + host. + * + * Security note: the request-derived path trusts the Host header, which a + * client controls. For Stage 2 (metadata-only) this is self-defeating — an + * attacker who spoofs Host gets back metadata describing their own origin. + * Stage 4 will sign JWTs with `iss`; at that point `mcp.issuer` MUST be + * pinned at startup (or derived from a fixed config) to prevent attacker- + * controlled `iss` claims. Production deployments should set `mcp.issuer` + * explicitly regardless. Documented in docs/configuration.md. + */ +export function resolveIssuer(request: HarperRequest, mcpConfig: MCPConfig): string { + if (mcpConfig.issuer) return mcpConfig.issuer; + const host = request.host ?? request.headers?.host ?? 'localhost'; + const scheme = request.protocol ?? 'https'; + return `${scheme}://${host}`; +} + +/** + * Resolve the canonical resource URI (the MCP endpoint clients talk to). + * Configured value wins; otherwise derive as `/mcp`. + */ +export function resolveResource(request: HarperRequest, mcpConfig: MCPConfig): string { + if (mcpConfig.resource) return mcpConfig.resource; + return `${resolveIssuer(request, mcpConfig)}/mcp`; +} + +/** + * RFC 9728 Protected Resource Metadata document. + */ +export function buildProtectedResourceMetadata(request: HarperRequest, mcpConfig: MCPConfig): Record { + const resource = resolveResource(request, mcpConfig); + const issuer = resolveIssuer(request, mcpConfig); + return { + resource, + authorization_servers: [issuer], + bearer_methods_supported: ['header'], + }; +} + +/** + * RFC 8414 Authorization Server Metadata document. + * Advertises the spec-required fields for the MCP authorization spec 2025-06-18. + */ +export function buildAuthorizationServerMetadata( + request: HarperRequest, + mcpConfig: MCPConfig +): Record { + const issuer = resolveIssuer(request, mcpConfig); + return { + issuer, + authorization_endpoint: `${issuer}/oauth/mcp/authorize`, + token_endpoint: `${issuer}/oauth/mcp/token`, + registration_endpoint: `${issuer}/oauth/mcp/register`, + jwks_uri: `${issuer}${JWKS_PATH}`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'], + // Advertise both signing algorithms; the concrete key is selected at + // token-mint time (Stage 4). Empty until that lands; clients see this + // advertisement and the empty JWKS together for now. + id_token_signing_alg_values_supported: ['RS256', 'EdDSA'], + // RFC 8707 §2: server understands the `resource` parameter. + resource_parameter_supported: true, + // CORS-friendly metadata is the spec norm; we serve cross-origin reads. + // (No spec-mandated field for this — included as a hint for proxy configs.) + }; +} + +/** + * JWKS document. Placeholder until Stage 4 generates and persists signing keys + * in the `harper_oauth_mcp_keys` table. + */ +export function buildJWKS(_mcpConfig: MCPConfig): Record { + return { keys: [] }; +} + +type WellKnownMatch = { + exactPath: string; + build: (request: HarperRequest, mcpConfig: MCPConfig) => Record; +}; + +const HANDLERS: WellKnownMatch[] = [ + { exactPath: PRM_PATH, build: buildProtectedResourceMetadata }, + { exactPath: AS_METADATA_PATH, build: buildAuthorizationServerMetadata }, + { exactPath: JWKS_PATH, build: (_req, cfg) => buildJWKS(cfg) }, +]; + +/** + * Make a Harper http() middleware bound to one well-known path. + * + * - Falls through (calls `next`) when MCP is disabled or the request doesn't + * match the exact path (urlPath matching is prefix-based, so sub-paths + * reach this handler and we must reject them here). + * - Reads `getConfig()` at request time so live config changes take effect + * without re-registering the route. + */ +function makeHandler( + match: WellKnownMatch, + getConfig: () => MCPConfig | undefined, + logger?: Logger +): (req: HarperRequest, next: (r: HarperRequest) => any) => any { + return (req, next) => { + const cfg = getConfig(); + if (!cfg?.enabled) return next(req); + if (req.pathname !== match.exactPath) return next(req); + try { + return jsonResponse(match.build(req, cfg)); + } catch (error) { + logger?.error?.(`MCP well-known handler ${match.exactPath} failed:`, (error as Error).message); + return jsonResponse({ error: 'server_error' }, 500); + } + }; +} + +/** + * Register all MCP well-known handlers against the Harper server. + * + * `getConfig` is a getter so middleware sees the current config on each + * request — the OAuth plugin re-initializes its config block on options + * change, and we want those changes to apply without re-registering routes + * (Harper's server.http() does not support deregistration). + */ +export function registerWellKnownHandlers(server: any, getConfig: () => MCPConfig | undefined, logger?: Logger): void { + if (typeof server?.http !== 'function') { + logger?.warn?.('MCP well-known: server.http() not available; skipping route registration'); + return; + } + for (const match of HANDLERS) { + server.http(makeHandler(match, getConfig, logger), { urlPath: match.exactPath }); + } + logger?.debug?.('MCP well-known handlers registered'); +} diff --git a/src/types.ts b/src/types.ts index 37f1d6d..c34a86f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,6 +49,16 @@ export interface OAuthPluginConfig { export interface MCPConfig { /** Master switch for MCP OAuth endpoints */ enabled?: boolean; + /** + * Canonical resource URI advertised in PRM and validated as the `aud` claim + * on issued tokens (RFC 8707). Defaults to `/mcp` when unset. + */ + resource?: string; + /** + * Authorization-server issuer URI advertised in AS metadata. Defaults to + * the request origin (scheme + host) when unset. + */ + issuer?: string; /** Dynamic Client Registration settings (RFC 7591) */ dynamicClientRegistration?: MCPDynamicClientRegistrationConfig; } diff --git a/test/lib/mcp/wellKnown.test.js b/test/lib/mcp/wellKnown.test.js new file mode 100644 index 0000000..d667f61 --- /dev/null +++ b/test/lib/mcp/wellKnown.test.js @@ -0,0 +1,270 @@ +/** + * Tests for MCP well-known metadata endpoints (RFCs 8414, 9728). + */ + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildAuthorizationServerMetadata, + buildJWKS, + buildProtectedResourceMetadata, + registerWellKnownHandlers, + resolveIssuer, + resolveResource, +} from '../../../dist/lib/mcp/wellKnown.js'; + +function makeRequest(overrides = {}) { + return { + pathname: '/.well-known/oauth-protected-resource', + protocol: 'https', + host: 'app.example.com', + headers: { host: 'app.example.com' }, + ...overrides, + }; +} + +describe('MCP well-known: URI resolution', () => { + it('resolveIssuer uses configured value when set', () => { + const req = makeRequest(); + const issuer = resolveIssuer(req, { issuer: 'https://canonical.example.com' }); + assert.equal(issuer, 'https://canonical.example.com'); + }); + + it('resolveIssuer derives from request scheme + host when unset', () => { + const req = makeRequest({ protocol: 'https', host: 'auto.example.com' }); + assert.equal(resolveIssuer(req, {}), 'https://auto.example.com'); + }); + + it('resolveResource uses configured value when set', () => { + const req = makeRequest(); + const resource = resolveResource(req, { resource: 'https://canonical.example.com/mcp-v2' }); + assert.equal(resource, 'https://canonical.example.com/mcp-v2'); + }); + + it('resolveResource derives /mcp when unset', () => { + const req = makeRequest({ protocol: 'https', host: 'derived.example.com' }); + assert.equal(resolveResource(req, {}), 'https://derived.example.com/mcp'); + }); + + it('resolveResource respects configured issuer when resource is unset', () => { + const req = makeRequest(); + const resource = resolveResource(req, { issuer: 'https://forced.example.com' }); + assert.equal(resource, 'https://forced.example.com/mcp'); + }); +}); + +describe('MCP well-known: PRM document (RFC 9728)', () => { + it('includes required fields: resource + authorization_servers', () => { + const doc = buildProtectedResourceMetadata(makeRequest(), { enabled: true }); + assert.equal(doc.resource, 'https://app.example.com/mcp'); + assert.deepEqual(doc.authorization_servers, ['https://app.example.com']); + }); + + it('advertises header-based bearer method only (no query-string)', () => { + const doc = buildProtectedResourceMetadata(makeRequest(), { enabled: true }); + assert.deepEqual(doc.bearer_methods_supported, ['header']); + }); + + it('reflects configured canonical resource URI verbatim', () => { + const doc = buildProtectedResourceMetadata(makeRequest(), { + enabled: true, + resource: 'https://my-app.example.com/mcp', + }); + assert.equal(doc.resource, 'https://my-app.example.com/mcp'); + }); +}); + +describe('MCP well-known: AS metadata document (RFC 8414)', () => { + it('includes spec-required endpoints', () => { + const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + assert.equal(doc.issuer, 'https://app.example.com'); + assert.equal(doc.authorization_endpoint, 'https://app.example.com/oauth/mcp/authorize'); + assert.equal(doc.token_endpoint, 'https://app.example.com/oauth/mcp/token'); + assert.equal(doc.registration_endpoint, 'https://app.example.com/oauth/mcp/register'); + assert.equal(doc.jwks_uri, 'https://app.example.com/.well-known/jwks.json'); + }); + + it('advertises PKCE S256 only (no plain)', () => { + const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + assert.deepEqual(doc.code_challenge_methods_supported, ['S256']); + }); + + it('advertises only authorization_code + refresh_token grants', () => { + const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + assert.deepEqual(doc.grant_types_supported, ['authorization_code', 'refresh_token']); + }); + + it('advertises only `code` response type', () => { + const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + assert.deepEqual(doc.response_types_supported, ['code']); + }); + + it('advertises token-endpoint auth methods including the public-client default', () => { + const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + const methods = doc.token_endpoint_auth_methods_supported; + assert.ok(methods.includes('none'), 'public clients (none) must be advertised'); + assert.ok(methods.includes('client_secret_basic')); + assert.ok(methods.includes('client_secret_post')); + }); + + it('advertises both RS256 and EdDSA signing algorithms', () => { + const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + assert.deepEqual(doc.id_token_signing_alg_values_supported, ['RS256', 'EdDSA']); + }); + + it('signals RFC 8707 resource-parameter support', () => { + const doc = buildAuthorizationServerMetadata(makeRequest(), { enabled: true }); + assert.equal(doc.resource_parameter_supported, true); + }); +}); + +describe('MCP well-known: JWKS document', () => { + it('returns an empty key set (placeholder until Stage 4)', () => { + const doc = buildJWKS({ enabled: true }); + assert.deepEqual(doc, { keys: [] }); + }); +}); + +describe('MCP well-known: handler registration', () => { + let registrations; + let mockServer; + let currentConfig; + let getConfig; + + beforeEach(() => { + registrations = []; + mockServer = { + http: (handler, options) => { + registrations.push({ handler, options }); + }, + }; + currentConfig = undefined; + getConfig = () => currentConfig; + }); + + it('registers three handlers with the expected urlPaths', () => { + registerWellKnownHandlers(mockServer, getConfig); + assert.equal(registrations.length, 3); + const paths = registrations.map((r) => r.options.urlPath).sort(); + assert.deepEqual(paths, [ + '/.well-known/jwks.json', + '/.well-known/oauth-authorization-server', + '/.well-known/oauth-protected-resource', + ]); + }); + + it('logs and skips when server.http() is not available', () => { + const warnings = []; + const logger = { warn: (msg) => warnings.push(msg) }; + registerWellKnownHandlers({}, getConfig, logger); + assert.equal(registrations.length, 0); + assert.ok(warnings.some((w) => w.includes('server.http'))); + }); + + describe('handler behavior at request time', () => { + beforeEach(() => { + registerWellKnownHandlers(mockServer, getConfig); + }); + + function findHandler(urlPath) { + const reg = registrations.find((r) => r.options.urlPath === urlPath); + return reg.handler; + } + + it('falls through to next when MCP is disabled', () => { + currentConfig = { enabled: false }; + const handler = findHandler('/.well-known/oauth-protected-resource'); + let nextCalled = false; + const next = () => { + nextCalled = true; + return 'fallthrough'; + }; + const result = handler(makeRequest(), next); + assert.equal(nextCalled, true); + assert.equal(result, 'fallthrough'); + }); + + it('falls through to next when config is undefined', () => { + currentConfig = undefined; + const handler = findHandler('/.well-known/oauth-authorization-server'); + let nextCalled = false; + const next = () => { + nextCalled = true; + return 'fallthrough'; + }; + handler(makeRequest({ pathname: '/.well-known/oauth-authorization-server' }), next); + assert.equal(nextCalled, true); + }); + + it('falls through to next on sub-paths (urlPath is prefix-matched)', () => { + currentConfig = { enabled: true }; + const handler = findHandler('/.well-known/oauth-protected-resource'); + let nextCalled = false; + const next = () => { + nextCalled = true; + return null; + }; + handler(makeRequest({ pathname: '/.well-known/oauth-protected-resource/extra' }), next); + assert.equal(nextCalled, true, 'sub-paths should fall through, not be served'); + }); + + it('serves PRM as JSON with Content-Type when enabled and path matches', () => { + currentConfig = { enabled: true }; + const handler = findHandler('/.well-known/oauth-protected-resource'); + const response = handler(makeRequest(), () => null); + assert.equal(response.status, 200); + assert.equal(response.headers['Content-Type'], 'application/json'); + const body = JSON.parse(response.body); + assert.equal(body.resource, 'https://app.example.com/mcp'); + assert.deepEqual(body.authorization_servers, ['https://app.example.com']); + }); + + it('serves PRM with CORS headers so browser MCP clients can fetch cross-origin', () => { + currentConfig = { enabled: true }; + const handler = findHandler('/.well-known/oauth-protected-resource'); + const response = handler(makeRequest(), () => null); + assert.equal(response.headers['Access-Control-Allow-Origin'], '*'); + assert.equal(response.headers['Access-Control-Allow-Methods'], 'GET, OPTIONS'); + }); + + it('serves AS metadata and JWKS with the same CORS headers', () => { + currentConfig = { enabled: true }; + for (const path of ['/.well-known/oauth-authorization-server', '/.well-known/jwks.json']) { + const handler = findHandler(path); + const response = handler(makeRequest({ pathname: path }), () => null); + assert.equal(response.headers['Access-Control-Allow-Origin'], '*', `${path} should set CORS`); + assert.equal(response.headers['Access-Control-Allow-Methods'], 'GET, OPTIONS'); + } + }); + + it('serves AS metadata as JSON with Content-Type when path matches', () => { + currentConfig = { enabled: true }; + const handler = findHandler('/.well-known/oauth-authorization-server'); + const response = handler(makeRequest({ pathname: '/.well-known/oauth-authorization-server' }), () => null); + assert.equal(response.status, 200); + assert.equal(response.headers['Content-Type'], 'application/json'); + const body = JSON.parse(response.body); + assert.equal(body.issuer, 'https://app.example.com'); + assert.deepEqual(body.code_challenge_methods_supported, ['S256']); + }); + + it('serves JWKS as JSON with empty keys', () => { + currentConfig = { enabled: true }; + const handler = findHandler('/.well-known/jwks.json'); + const response = handler(makeRequest({ pathname: '/.well-known/jwks.json' }), () => null); + assert.equal(response.status, 200); + assert.equal(response.headers['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(response.body), { keys: [] }); + }); + + it('falls back to localhost when the request omits scheme/host', () => { + currentConfig = { enabled: true }; + const handler = findHandler('/.well-known/oauth-protected-resource'); + const response = handler({ pathname: '/.well-known/oauth-protected-resource' }, () => null); + assert.equal(response.status, 200); + const body = JSON.parse(response.body); + assert.equal(body.resource, 'https://localhost/mcp'); + assert.deepEqual(body.authorization_servers, ['https://localhost']); + }); + }); +});