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 <Vincent_R@gmx.de>

* feat: enable dynamic mcp resource paths

Signed-off-by: Vincent Rittner <Vincent_R@gmx.de>

* refactor: move url discovery in route handler

Signed-off-by: Vincent Rittner <Vincent_R@gmx.de>

* refactor: revert to upfront route registration

Signed-off-by: Vincent Rittner <Vincent_R@gmx.de>

---------

Signed-off-by: Vincent Rittner <Vincent_R@gmx.de>
This commit is contained in:
vincent
2026-04-14 14:13:24 +02:00
committed by GitHub
parent 297302ebce
commit c6abc52e1f
3 changed files with 92 additions and 18 deletions
+6
View File
@@ -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.
+64 -5
View File
@@ -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$/);
});
+22 -13
View File
@@ -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],
});
},
);
}
}
},
});