fix(core): use RFC 9728 compliant path-based OAuth protected resource discovery (#15756)

Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
This commit is contained in:
Vijay Vasudevan
2026-01-23 10:55:23 -08:00
committed by GitHub
parent 1ec8f40096
commit 3066288c06
2 changed files with 51 additions and 46 deletions

View File

@@ -28,21 +28,8 @@ describe('OAuthUtils', () => {
}); });
describe('buildWellKnownUrls', () => { describe('buildWellKnownUrls', () => {
it('should build standard root-based URLs by default', () => { it('should build RFC 9728 compliant path-based URLs by default', () => {
const urls = OAuthUtils.buildWellKnownUrls('https://example.com/mcp'); const urls = OAuthUtils.buildWellKnownUrls('https://example.com/mcp');
expect(urls.protectedResource).toBe(
'https://example.com/.well-known/oauth-protected-resource',
);
expect(urls.authorizationServer).toBe(
'https://example.com/.well-known/oauth-authorization-server',
);
});
it('should build path-based URLs when includePathSuffix is true', () => {
const urls = OAuthUtils.buildWellKnownUrls(
'https://example.com/mcp',
true,
);
expect(urls.protectedResource).toBe( expect(urls.protectedResource).toBe(
'https://example.com/.well-known/oauth-protected-resource/mcp', 'https://example.com/.well-known/oauth-protected-resource/mcp',
); );
@@ -51,8 +38,21 @@ describe('OAuthUtils', () => {
); );
}); });
it('should build root-based URLs when useRootDiscovery is true', () => {
const urls = OAuthUtils.buildWellKnownUrls(
'https://example.com/mcp',
true,
);
expect(urls.protectedResource).toBe(
'https://example.com/.well-known/oauth-protected-resource',
);
expect(urls.authorizationServer).toBe(
'https://example.com/.well-known/oauth-authorization-server',
);
});
it('should handle root path correctly', () => { it('should handle root path correctly', () => {
const urls = OAuthUtils.buildWellKnownUrls('https://example.com', true); const urls = OAuthUtils.buildWellKnownUrls('https://example.com');
expect(urls.protectedResource).toBe( expect(urls.protectedResource).toBe(
'https://example.com/.well-known/oauth-protected-resource', 'https://example.com/.well-known/oauth-protected-resource',
); );
@@ -62,10 +62,7 @@ describe('OAuthUtils', () => {
}); });
it('should handle trailing slash in path', () => { it('should handle trailing slash in path', () => {
const urls = OAuthUtils.buildWellKnownUrls( const urls = OAuthUtils.buildWellKnownUrls('https://example.com/mcp/');
'https://example.com/mcp/',
true,
);
expect(urls.protectedResource).toBe( expect(urls.protectedResource).toBe(
'https://example.com/.well-known/oauth-protected-resource/mcp', 'https://example.com/.well-known/oauth-protected-resource/mcp',
); );
@@ -73,6 +70,18 @@ describe('OAuthUtils', () => {
'https://example.com/.well-known/oauth-authorization-server/mcp', 'https://example.com/.well-known/oauth-authorization-server/mcp',
); );
}); });
it('should handle deep paths per RFC 9728', () => {
const urls = OAuthUtils.buildWellKnownUrls(
'https://app.mintmcp.com/s/g_2lj2CNDoJdf3xnbFeeF6vx/mcp',
);
expect(urls.protectedResource).toBe(
'https://app.mintmcp.com/.well-known/oauth-protected-resource/s/g_2lj2CNDoJdf3xnbFeeF6vx/mcp',
);
expect(urls.authorizationServer).toBe(
'https://app.mintmcp.com/.well-known/oauth-authorization-server/s/g_2lj2CNDoJdf3xnbFeeF6vx/mcp',
);
});
}); });
describe('fetchProtectedResourceMetadata', () => { describe('fetchProtectedResourceMetadata', () => {

View File

@@ -55,30 +55,26 @@ export const FIVE_MIN_BUFFER_MS = 5 * 60 * 1000;
*/ */
export class OAuthUtils { export class OAuthUtils {
/** /**
* Construct well-known OAuth endpoint URLs. * Construct well-known OAuth endpoint URLs per RFC 9728 §3.1.
* By default, uses standard root-based well-known URLs. *
* If includePathSuffix is true, appends any path from the base URL to the well-known endpoints. * The well-known URI is constructed by inserting /.well-known/oauth-protected-resource
* between the host and any existing path component. This preserves the resource's
* path structure in the metadata URL.
*
* Examples:
* - https://example.com -> https://example.com/.well-known/oauth-protected-resource
* - https://example.com/api/resource -> https://example.com/.well-known/oauth-protected-resource/api/resource
*
* @param baseUrl The resource URL
* @param useRootDiscovery If true, ignores path and uses root-based discovery (for fallback compatibility)
*/ */
static buildWellKnownUrls(baseUrl: string, includePathSuffix = false) { static buildWellKnownUrls(baseUrl: string, useRootDiscovery = false) {
const serverUrl = new URL(baseUrl); const serverUrl = new URL(baseUrl);
const base = `${serverUrl.protocol}//${serverUrl.host}`; const base = `${serverUrl.protocol}//${serverUrl.host}`;
const pathSuffix = useRootDiscovery
? ''
: serverUrl.pathname.replace(/\/$/, ''); // Remove trailing slash
if (!includePathSuffix) {
// Standard discovery: use root-based well-known URLs
return {
protectedResource: new URL(
'/.well-known/oauth-protected-resource',
base,
).toString(),
authorizationServer: new URL(
'/.well-known/oauth-authorization-server',
base,
).toString(),
};
}
// Path-based discovery: append path suffix to well-known URLs
const pathSuffix = serverUrl.pathname.replace(/\/$/, ''); // Remove trailing slash
return { return {
protectedResource: new URL( protectedResource: new URL(
`/.well-known/oauth-protected-resource${pathSuffix}`, `/.well-known/oauth-protected-resource${pathSuffix}`,
@@ -234,21 +230,21 @@ export class OAuthUtils {
serverUrl: string, serverUrl: string,
): Promise<MCPOAuthConfig | null> { ): Promise<MCPOAuthConfig | null> {
try { try {
// First try standard root-based discovery // RFC 9728 §3.1: Construct well-known URL by inserting /.well-known/oauth-protected-resource
const wellKnownUrls = this.buildWellKnownUrls(serverUrl, false); // between the host and path. This is the RFC-compliant approach.
const wellKnownUrls = this.buildWellKnownUrls(serverUrl);
// Try to get the protected resource metadata at root
let resourceMetadata = await this.fetchProtectedResourceMetadata( let resourceMetadata = await this.fetchProtectedResourceMetadata(
wellKnownUrls.protectedResource, wellKnownUrls.protectedResource,
); );
// If root discovery fails and we have a path, try path-based discovery // Fallback: If path-based discovery fails and we have a path, try root-based discovery
// for backwards compatibility with servers that don't implement RFC 9728 path handling
if (!resourceMetadata) { if (!resourceMetadata) {
const url = new URL(serverUrl); const url = new URL(serverUrl);
if (url.pathname && url.pathname !== '/') { if (url.pathname && url.pathname !== '/') {
const pathBasedUrls = this.buildWellKnownUrls(serverUrl, true); const rootBasedUrls = this.buildWellKnownUrls(serverUrl, true);
resourceMetadata = await this.fetchProtectedResourceMetadata( resourceMetadata = await this.fetchProtectedResourceMetadata(
pathBasedUrls.protectedResource, rootBasedUrls.protectedResource,
); );
} }
} }