diff --git a/.changeset/shaky-years-doubt.md b/.changeset/shaky-years-doubt.md new file mode 100644 index 0000000000..d1aff678e0 --- /dev/null +++ b/.changeset/shaky-years-doubt.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-mcp-actions-backend': patch +--- + +Fixed the `.well-known/oauth-protected-resource` resource URL to comply with +[RFC 9728 Section 7.3](https://datatracker.ietf.org/doc/html/rfc9728#name-impersonation-attacks). Enabling dynamic resource paths. diff --git a/plugins/mcp-actions-backend/src/plugin.test.ts b/plugins/mcp-actions-backend/src/plugin.test.ts index f42dbbd738..31dce6bad5 100644 --- a/plugins/mcp-actions-backend/src/plugin.test.ts +++ b/plugins/mcp-actions-backend/src/plugin.test.ts @@ -306,7 +306,7 @@ describe('Mcp Backend', () => { expect(response.status).toBe(404); }); - it('should expose oauth-protected-resource when DCR is enabled', async () => { + it('should expose default oauth-protected-resource when DCR is enabled', async () => { const mockExternalBaseUrl = 'http://external.local:0/api'; const mockDiscovery = mockServices.discovery.mock({ getExternalBaseUrl: async pluginId => @@ -336,10 +336,10 @@ describe('Mcp Backend', () => { }); const response = await request(server).get( - '/.well-known/oauth-protected-resource', + '/.well-known/oauth-protected-resource/api/mcp-actions/v1', ); expect(response.status).toBe(200); - expect(response.body.resource).toMatch(/\/api\/mcp-actions$/); + expect(response.body.resource).toMatch(/\/api\/mcp-actions\/v1$/); expect(response.body.authorization_servers).toHaveLength(1); expect(response.body.authorization_servers[0]).toMatch(/\/api\/auth$/); expect(response.body.resource).toContain(`${mockExternalBaseUrl}`); @@ -348,6 +348,65 @@ describe('Mcp Backend', () => { ); }); + const pathTestCases = [ + { name: 'auth', suffix: '/v1/auth' }, + { name: 'catalog', suffix: '/v1/catalog' }, + { name: 'scaffolder', suffix: '/v1/scaffolder' }, + ]; + + it.each(pathTestCases)( + 'should expose dynamic oauth-protected-resource for $name', + async ({ suffix }) => { + const mockExternalBaseUrl = 'http://external.local:0/api'; + const mockDiscovery = mockServices.discovery.mock({ + getExternalBaseUrl: async pluginId => + `${mockExternalBaseUrl}/${pluginId}`, + }); + + const { server } = await startTestBackend({ + features: [ + mcpPlugin, + mockPluginWithActions, + mockDiscovery.factory, + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['local'], + }, + }, + auth: { + experimentalDynamicClientRegistration: { + enabled: true, + }, + }, + mcpActions: { + servers: { + auth: { name: 'Auth', filter: { include: [] } }, + catalog: { name: 'Catalog', filter: { include: [] } }, + scaffolder: { name: 'Scaffolder', filter: { include: [] } }, + }, + }, + }, + }), + ], + }); + + const response = await request(server).get( + `/.well-known/oauth-protected-resource/api/mcp-actions${suffix}`, + ); + expect(response.status).toBe(200); + const expectedResourceRegex = new RegExp(`/api/mcp-actions${suffix}$`); + expect(response.body.resource).toMatch(expectedResourceRegex); + expect(response.body.authorization_servers).toHaveLength(1); + expect(response.body.authorization_servers[0]).toMatch(/\/api\/auth$/); + expect(response.body.resource).toContain(`${mockExternalBaseUrl}`); + expect(response.body.authorization_servers[0]).toContain( + `${mockExternalBaseUrl}/`, + ); + }, + ); + it('should expose oauth-protected-resource when CIMD is enabled', async () => { const { server } = await startTestBackend({ features: [ @@ -371,10 +430,10 @@ describe('Mcp Backend', () => { }); const response = await request(server).get( - '/.well-known/oauth-protected-resource', + '/.well-known/oauth-protected-resource/api/mcp-actions/v1', ); expect(response.status).toBe(200); - expect(response.body.resource).toMatch(/\/api\/mcp-actions$/); + expect(response.body.resource).toMatch(/\/api\/mcp-actions\/v1$/); expect(response.body.authorization_servers).toHaveLength(1); expect(response.body.authorization_servers[0]).toMatch(/\/api\/auth$/); }); diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index 3055459cc9..6baaf8489d 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -140,19 +140,28 @@ export const mcpPlugin = createBackendPlugin({ // Protected Resource Metadata (RFC 9728) // https://datatracker.ietf.org/doc/html/rfc9728 // This allows MCP clients to discover the authorization server for this resource - rootRouter.use( - '/.well-known/oauth-protected-resource', - async (_, res) => { - const [authBaseUrl, mcpBaseUrl] = await Promise.all([ - discovery.getExternalBaseUrl('auth'), - discovery.getExternalBaseUrl('mcp-actions'), - ]); - res.json({ - resource: mcpBaseUrl, - authorization_servers: [authBaseUrl], - }); - }, - ); + const serverSuffixes = serverConfigs?.size + ? [...serverConfigs.keys()].map(key => `/v1/${key}`) + : ['/v1']; + + for (const suffix of serverSuffixes) { + const mcpBasePath = `/api/mcp-actions${suffix}`; + + rootRouter.use( + `/.well-known/oauth-protected-resource${mcpBasePath}`, + async (_req, res) => { + const [authBaseUrl, mcpBaseUrl] = await Promise.all([ + discovery.getExternalBaseUrl('auth'), + discovery.getExternalBaseUrl('mcp-actions'), + ]); + + res.json({ + resource: `${mcpBaseUrl}${suffix}`, + authorization_servers: [authBaseUrl], + }); + }, + ); + } } }, });