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/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..fdb4040b32 --- /dev/null +++ b/forge/routes/wellKnown.js @@ -0,0 +1,36 @@ +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` + }) + }) +} 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 new file mode 100644 index 0000000000..5e48abe2b4 --- /dev/null +++ b/test/unit/forge/routes/wellKnown_spec.js @@ -0,0 +1,48 @@ +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']) + }) + }) + + 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' }) + authServer.statusCode.should.equal(200) + }) +})