From c6abc52e1fa0f7231e2a40ee9d1121c3b304f1a1 Mon Sep 17 00:00:00 2001 From: vincent <62140498+vincentrit@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:13:24 +0200 Subject: [PATCH] feat: change resource url to comply with RFC 9728 Section 7.3 (#33855) * feat: change oauth protected resource url to comply with RFC 9728 Section 7.3 Signed-off-by: Vincent Rittner * feat: enable dynamic mcp resource paths Signed-off-by: Vincent Rittner * refactor: move url discovery in route handler Signed-off-by: Vincent Rittner * refactor: revert to upfront route registration Signed-off-by: Vincent Rittner --------- Signed-off-by: Vincent Rittner --- .changeset/shaky-years-doubt.md | 6 ++ .../mcp-actions-backend/src/plugin.test.ts | 69 +++++++++++++++++-- plugins/mcp-actions-backend/src/plugin.ts | 35 ++++++---- 3 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 .changeset/shaky-years-doubt.md 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], + }); + }, + ); + } } }, });