mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-27 19:15:01 +02:00
fix(core): Serve RFC 9728 metadata at bare /.well-known/oauth-protected-resource (#33776)
This commit is contained in:
parent
0e186ad8d3
commit
3e0188cf2a
|
|
@ -146,6 +146,47 @@ describe('GET /.well-known/oauth-protected-resource/mcp-server/http', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /.well-known/oauth-protected-resource (bare path)', () => {
|
||||
test('resolves to the default registered resource', async () => {
|
||||
const response = await testServer.restlessAgent.get('/.well-known/oauth-protected-resource');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
resource: expect.stringContaining('/mcp-server/http'),
|
||||
bearer_methods_supported: ['header'],
|
||||
authorization_servers: [expect.any(String)],
|
||||
...(SUPPORTED_SCOPES.length > 0 && { scopes_supported: SUPPORTED_SCOPES }),
|
||||
});
|
||||
});
|
||||
|
||||
test('matches the resource-scoped metadata for the default resource', async () => {
|
||||
const bareResponse = await testServer.restlessAgent.get(
|
||||
'/.well-known/oauth-protected-resource',
|
||||
);
|
||||
const scopedResponse = await testServer.restlessAgent.get(
|
||||
'/.well-known/oauth-protected-resource/mcp-server/http',
|
||||
);
|
||||
|
||||
expect(bareResponse.statusCode).toBe(200);
|
||||
expect(bareResponse.body).toEqual(scopedResponse.body);
|
||||
});
|
||||
|
||||
test('is accessible without authentication', async () => {
|
||||
const response = await testServer.restlessAgent.get('/.well-known/oauth-protected-resource');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('responds to OPTIONS with CORS headers', async () => {
|
||||
const response = await testServer.restlessAgent.options(
|
||||
'/.well-known/oauth-protected-resource',
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(204);
|
||||
expect(response.headers['access-control-allow-origin']).toBe('*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /mcp-oauth/register', () => {
|
||||
beforeEach(async () => {
|
||||
await mcpSettingsService.setEnabled(true);
|
||||
|
|
@ -829,6 +870,8 @@ describe('IP rate limit configuration', () => {
|
|||
'metadataOptions',
|
||||
'protectedResourceMetadata',
|
||||
'protectedResourceMetadataOptions',
|
||||
'defaultProtectedResourceMetadata',
|
||||
'defaultProtectedResourceMetadataOptions',
|
||||
])('applies the configured well-known limit to %s', (handlerName) => {
|
||||
const config = Container.get(OAuthServerConfig);
|
||||
const routeMetadata = Container.get(ControllerRegistryMetadata).getRouteMetadata(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { Container } from '@n8n/di';
|
||||
import type { Response, Request, RequestHandler, Router } from 'express';
|
||||
|
||||
import type { ProtectedResource } from '@/services/protected-resource.registry';
|
||||
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
|
|
@ -213,6 +214,54 @@ export class OAuthController {
|
|||
return;
|
||||
}
|
||||
|
||||
res.json(this.buildProtectedResourceMetadata(resource));
|
||||
}
|
||||
|
||||
@Options('/.well-known/oauth-protected-resource', {
|
||||
skipAuth: true,
|
||||
usesTemplates: true,
|
||||
ipRateLimit: wellKnownIpRateLimit,
|
||||
})
|
||||
defaultProtectedResourceMetadataOptions(_req: Request, res: Response) {
|
||||
this.setCorsHeaders(res);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9728 protected-resource metadata for the bare `/.well-known/
|
||||
* oauth-protected-resource` path (no resource-path suffix).
|
||||
*
|
||||
* Per RFC 9728 §3.1, a resource server whose resource identifier has no
|
||||
* path component publishes its metadata at this exact well-known URI.
|
||||
* We also serve it as a courtesy for clients that probe the origin-level
|
||||
* document before (or instead of) the resource-scoped one advertised via
|
||||
* `WWW-Authenticate: resource_metadata=...` — without this route, such a
|
||||
* probe previously fell through to the SPA's catch-all handler, which
|
||||
* answered with `200 text/html` instead of a clean `404`, breaking OAuth
|
||||
* discovery for clients that don't special-case a non-JSON `200`.
|
||||
*
|
||||
* Resolves to this instance's default protected resource (today, always
|
||||
* the single instance-wide MCP resource); returns 404 when no default is
|
||||
* registered so the caller can fall back to the resource-scoped URL.
|
||||
*/
|
||||
@Get('/.well-known/oauth-protected-resource', {
|
||||
skipAuth: true,
|
||||
usesTemplates: true,
|
||||
ipRateLimit: wellKnownIpRateLimit,
|
||||
})
|
||||
defaultProtectedResourceMetadata(_req: Request, res: Response) {
|
||||
this.setCorsHeaders(res);
|
||||
|
||||
const resource = this.resourceRegistry.getDefaultResource();
|
||||
if (!resource) {
|
||||
res.status(404).json({ message: 'Unknown protected resource' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(this.buildProtectedResourceMetadata(resource));
|
||||
}
|
||||
|
||||
private buildProtectedResourceMetadata(resource: ProtectedResource): Record<string, unknown> {
|
||||
const baseUrl = this.urlService.getInstanceBaseUrl();
|
||||
const metadata: Record<string, unknown> = {
|
||||
resource: resource.getResourceUrl(),
|
||||
|
|
@ -224,6 +273,6 @@ export class OAuthController {
|
|||
metadata.scopes_supported = resource.scopes;
|
||||
}
|
||||
|
||||
res.json(metadata);
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user