fix(core): Serve RFC 9728 metadata at bare /.well-known/oauth-protected-resource (#33776)

This commit is contained in:
Vishesh Gupta 2026-07-22 15:33:24 +00:00 committed by GitHub
parent 0e186ad8d3
commit 3e0188cf2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 93 additions and 1 deletions

View File

@ -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(

View File

@ -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;
}
}