From f67ce0f7e78c6a2a804fe1c819274948177aa87e Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 24 Aug 2026 19:47:09 +0200 Subject: [PATCH 1/2] feat(mcp): add .well-known OAuth discovery endpoints Add RFC 8414 authorization-server metadata and RFC 9728 protected-resource metadata under /.well-known so MCP clients can auto-discover the OAuth endpoints and the MCP resource URL. Public, license-tier independent. Ref FlowFuse/flowfuse#7431 --- forge/routes/index.js | 1 + forge/routes/wellKnown.js | 59 +++++++++++++++++++++ test/unit/forge/routes/wellKnown_spec.js | 65 ++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 forge/routes/wellKnown.js create mode 100644 test/unit/forge/routes/wellKnown_spec.js diff --git a/forge/routes/index.js b/forge/routes/index.js index 4679deae1f..3b62a74498 100644 --- a/forge/routes/index.js +++ b/forge/routes/index.js @@ -77,6 +77,7 @@ module.exports = fp(async function (app, opts) { await app.register(require('@fastify/websocket')) await app.register(require('./auth'), { logLevel: app.config.logging.http }) await app.register(require('./api'), { prefix: '/api/v1', logLevel: app.config.logging.http }) + await app.register(require('./wellKnown'), { prefix: '/.well-known', logLevel: app.config.logging.http }) await app.register(require('./ui'), { logLevel: app.config.logging.http }) await app.register(require('./setup'), { logLevel: app.config.logging.http }) await app.register(require('./storage'), { prefix: '/storage', logLevel: app.config.logging.http }) diff --git a/forge/routes/wellKnown.js b/forge/routes/wellKnown.js new file mode 100644 index 0000000000..3c0f4c0a5c --- /dev/null +++ b/forge/routes/wellKnown.js @@ -0,0 +1,59 @@ +module.exports = async function (app) { + // RFC 8414: OAuth 2.0 Authorization Server Metadata + app.get('/oauth-authorization-server', { + config: { allowAnonymous: true }, + schema: { + tags: ['Authentication', 'X-HIDDEN'], + response: { + 200: { + type: 'object', + properties: { + issuer: { type: 'string' }, + authorization_endpoint: { type: 'string' }, + token_endpoint: { type: 'string' }, + response_types_supported: { type: 'array', items: { type: 'string' } }, + grant_types_supported: { type: 'array', items: { type: 'string' } }, + code_challenge_methods_supported: { type: 'array', items: { type: 'string' } }, + token_endpoint_auth_methods_supported: { type: 'array', items: { type: 'string' } }, + registration_endpoint: { type: 'string' } + } + } + } + } + }, async (request, reply) => { + const baseUrl = app.config.base_url + reply.send({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/account/authorize`, + token_endpoint: `${baseUrl}/account/token`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + registration_endpoint: `${baseUrl}/account/client` + }) + }) + + // RFC 9728: OAuth 2.0 Protected Resource Metadata + app.get('/oauth-protected-resource', { + config: { allowAnonymous: true }, + schema: { + tags: ['Authentication', 'X-HIDDEN'], + response: { + 200: { + type: 'object', + properties: { + resource: { type: 'string' }, + authorization_servers: { type: 'array', items: { type: 'string' } } + } + } + } + } + }, async (request, reply) => { + const baseUrl = app.config.base_url + reply.send({ + resource: `${baseUrl}/mcp`, + authorization_servers: [baseUrl] + }) + }) +} diff --git a/test/unit/forge/routes/wellKnown_spec.js b/test/unit/forge/routes/wellKnown_spec.js new file mode 100644 index 0000000000..d0bbd37979 --- /dev/null +++ b/test/unit/forge/routes/wellKnown_spec.js @@ -0,0 +1,65 @@ +const should = require('should') // eslint-disable-line no-unused-vars + +const setup = require('./setup') + +describe('.well-known OAuth discovery', function () { + let app + const baseUrl = 'http://localhost:3000' + + before(async function () { + app = await setup({ base_url: baseUrl }) + }) + + after(async function () { + await app.close() + }) + + describe('GET /.well-known/oauth-authorization-server (RFC 8414)', function () { + let body + + before(async function () { + const response = await app.inject({ method: 'GET', url: '/.well-known/oauth-authorization-server' }) + response.statusCode.should.equal(200) + body = response.json() + }) + + it('advertises the issuer and endpoints derived from base_url', function () { + body.should.have.property('issuer', baseUrl) + body.should.have.property('authorization_endpoint', `${baseUrl}/account/authorize`) + body.should.have.property('token_endpoint', `${baseUrl}/account/token`) + body.should.have.property('registration_endpoint', `${baseUrl}/account/client`) + }) + + it('advertises the authorization code and refresh token grants', function () { + body.response_types_supported.should.containEql('code') + body.grant_types_supported.should.containDeep(['authorization_code', 'refresh_token']) + }) + + it('advertises PKCE S256 and public clients (no secret)', function () { + body.code_challenge_methods_supported.should.eql(['S256']) + body.token_endpoint_auth_methods_supported.should.eql(['none']) + }) + }) + + describe('GET /.well-known/oauth-protected-resource (RFC 9728)', function () { + let body + + before(async function () { + const response = await app.inject({ method: 'GET', url: '/.well-known/oauth-protected-resource' }) + response.statusCode.should.equal(200) + body = response.json() + }) + + it('advertises the MCP resource and its authorization server', function () { + body.should.have.property('resource', `${baseUrl}/mcp`) + body.authorization_servers.should.eql([baseUrl]) + }) + }) + + it('serves both documents anonymously, without a session', async function () { + const authServer = await app.inject({ method: 'GET', url: '/.well-known/oauth-authorization-server' }) + const resource = await app.inject({ method: 'GET', url: '/.well-known/oauth-protected-resource' }) + authServer.statusCode.should.equal(200) + resource.statusCode.should.equal(200) + }) +}) From 691aa4b30a0ed22dd2756e370d9cb5e44a928711 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 25 Aug 2026 10:52:56 +0200 Subject: [PATCH 2/2] feat(mcp): serve MCP resource metadata from the EE plugin Move the RFC 9728 protected-resource document out of the root .well-known handler into the license-gated EE mcp plugin, so it is only advertised where the /mcp resource exists. Serve it at the path-inserted /.well-known/oauth-protected-resource/mcp (RFC 9728 3.1) with the bare path kept as an alias, and challenge unauthenticated /mcp requests with a WWW-Authenticate header pointing at that metadata. --- forge/ee/routes/mcp/index.js | 1 + forge/ee/routes/mcp/server.js | 4 ++- forge/ee/routes/mcp/wellKnown.js | 34 ++++++++++++++++++++ forge/routes/wellKnown.js | 23 ------------- test/unit/forge/ee/routes/mcp/server_spec.js | 33 +++++++++++++++++++ test/unit/forge/routes/wellKnown_spec.js | 19 +---------- 6 files changed, 72 insertions(+), 42 deletions(-) create mode 100644 forge/ee/routes/mcp/wellKnown.js diff --git a/forge/ee/routes/mcp/index.js b/forge/ee/routes/mcp/index.js index 13f87d2fd0..c8c6dbd8f6 100644 --- a/forge/ee/routes/mcp/index.js +++ b/forge/ee/routes/mcp/index.js @@ -7,6 +7,7 @@ * @param {import('../../../forge').ForgeApplication} app */ module.exports = async function (app) { + await app.register(require('./wellKnown'), { prefix: '/.well-known', logLevel: app.config.logging.http }) await app.register(require('./registrations'), { prefix: '/api/v1/teams/:teamId/mcp', logLevel: app.config.logging.http }) await app.register(require('./server'), { prefix: '/mcp', logLevel: app.config.logging.http }) } diff --git a/forge/ee/routes/mcp/server.js b/forge/ee/routes/mcp/server.js index 5a1c538507..a11bb6ed56 100644 --- a/forge/ee/routes/mcp/server.js +++ b/forge/ee/routes/mcp/server.js @@ -31,6 +31,8 @@ module.exports = async function (app) { // Resolves the caller's identity and scope, or sends an error reply and returns null. async function resolveCaller (request, reply) { if (!request.session?.User) { + // RFC 9728 §5.1: point unauthenticated callers at the resource metadata. + reply.header('WWW-Authenticate', `Bearer resource_metadata="${app.config.base_url}/.well-known/oauth-protected-resource/mcp"`) reply.code(401).send({ code: 'unauthorized', error: 'Unauthorized' }) return null } @@ -55,7 +57,7 @@ module.exports = async function (app) { // POST serves the MCP Streamable HTTP protocol in JSON mode: each request is // forwarded to the gateway and its response returned. Notifications (no id) // are acknowledged; the gateway populates its session on the first request. - app.post('/', async (request, reply) => { + app.post('/', { config: { allowAnonymous: true } }, async (request, reply) => { const caller = await resolveCaller(request, reply) if (!caller) { return diff --git a/forge/ee/routes/mcp/wellKnown.js b/forge/ee/routes/mcp/wellKnown.js new file mode 100644 index 0000000000..15f0107b46 --- /dev/null +++ b/forge/ee/routes/mcp/wellKnown.js @@ -0,0 +1,34 @@ +module.exports = async function (app) { + // RFC 9728: OAuth 2.0 Protected Resource Metadata for the /mcp resource. + // Lives in the EE mcp plugin so it is only advertised where /mcp exists. + function protectedResourceMetadata () { + const baseUrl = app.config.base_url + return { + resource: `${baseUrl}/mcp`, + authorization_servers: [baseUrl] + } + } + + const schema = { + tags: ['Authentication', 'X-HIDDEN'], + response: { + 200: { + type: 'object', + properties: { + resource: { type: 'string' }, + authorization_servers: { type: 'array', items: { type: 'string' } } + } + } + } + } + + // RFC 9728 §3.1: clients derive the metadata URL by inserting the resource + // path, so the /mcp resource is served at oauth-protected-resource/mcp. The + // bare path is kept as an alias for clients that omit path insertion. + app.get('/oauth-protected-resource/mcp', { config: { allowAnonymous: true }, schema }, async (request, reply) => { + reply.send(protectedResourceMetadata()) + }) + app.get('/oauth-protected-resource', { config: { allowAnonymous: true }, schema }, async (request, reply) => { + reply.send(protectedResourceMetadata()) + }) +} diff --git a/forge/routes/wellKnown.js b/forge/routes/wellKnown.js index 3c0f4c0a5c..fdb4040b32 100644 --- a/forge/routes/wellKnown.js +++ b/forge/routes/wellKnown.js @@ -33,27 +33,4 @@ module.exports = async function (app) { registration_endpoint: `${baseUrl}/account/client` }) }) - - // RFC 9728: OAuth 2.0 Protected Resource Metadata - app.get('/oauth-protected-resource', { - config: { allowAnonymous: true }, - schema: { - tags: ['Authentication', 'X-HIDDEN'], - response: { - 200: { - type: 'object', - properties: { - resource: { type: 'string' }, - authorization_servers: { type: 'array', items: { type: 'string' } } - } - } - } - } - }, async (request, reply) => { - const baseUrl = app.config.base_url - reply.send({ - resource: `${baseUrl}/mcp`, - authorization_servers: [baseUrl] - }) - }) } diff --git a/test/unit/forge/ee/routes/mcp/server_spec.js b/test/unit/forge/ee/routes/mcp/server_spec.js index 08c4803e6d..e97b3c8f21 100644 --- a/test/unit/forge/ee/routes/mcp/server_spec.js +++ b/test/unit/forge/ee/routes/mcp/server_spec.js @@ -41,6 +41,26 @@ describe('MCP Platform Tools Server', function () { }) }) + describe('GET /.well-known/oauth-protected-resource (RFC 9728)', function () { + it('serves the path-inserted resource metadata anonymously', async function () { + const response = await app.inject({ method: 'GET', url: '/.well-known/oauth-protected-resource/mcp' }) + response.statusCode.should.equal(200) + response.json().should.deepEqual({ + resource: `${app.config.base_url}/mcp`, + authorization_servers: [app.config.base_url] + }) + }) + + it('serves the bare alias anonymously', async function () { + const response = await app.inject({ method: 'GET', url: '/.well-known/oauth-protected-resource' }) + response.statusCode.should.equal(200) + response.json().should.deepEqual({ + resource: `${app.config.base_url}/mcp`, + authorization_servers: [app.config.base_url] + }) + }) + }) + describe('POST proxies to the MCP gateway', function () { let proxyRequest @@ -63,6 +83,19 @@ describe('MCP Platform Tools Server', function () { proxyRequest.called.should.be.false() }) + it('should challenge with the protected resource metadata URL', async function () { + const response = await app.inject({ + method: 'POST', + url: '/mcp', + payload: { jsonrpc: '2.0', method: 'initialize', id: 1 } + }) + response.statusCode.should.equal(401) + response.headers.should.have.property( + 'www-authenticate', + `Bearer resource_metadata="${app.config.base_url}/.well-known/oauth-protected-resource/mcp"` + ) + }) + it('should forward the request and return the gateway response', async function () { const response = await app.inject({ method: 'POST', diff --git a/test/unit/forge/routes/wellKnown_spec.js b/test/unit/forge/routes/wellKnown_spec.js index d0bbd37979..5e48abe2b4 100644 --- a/test/unit/forge/routes/wellKnown_spec.js +++ b/test/unit/forge/routes/wellKnown_spec.js @@ -41,25 +41,8 @@ describe('.well-known OAuth discovery', function () { }) }) - describe('GET /.well-known/oauth-protected-resource (RFC 9728)', function () { - let body - - before(async function () { - const response = await app.inject({ method: 'GET', url: '/.well-known/oauth-protected-resource' }) - response.statusCode.should.equal(200) - body = response.json() - }) - - it('advertises the MCP resource and its authorization server', function () { - body.should.have.property('resource', `${baseUrl}/mcp`) - body.authorization_servers.should.eql([baseUrl]) - }) - }) - - it('serves both documents anonymously, without a session', async function () { + it('serves the authorization server document anonymously, without a session', async function () { const authServer = await app.inject({ method: 'GET', url: '/.well-known/oauth-authorization-server' }) - const resource = await app.inject({ method: 'GET', url: '/.well-known/oauth-protected-resource' }) authServer.statusCode.should.equal(200) - resource.statusCode.should.equal(200) }) })