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:
@@ -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.
|
||||
@@ -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$/);
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user