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', () => {
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');
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(
'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', () => {
const urls = OAuthUtils.buildWellKnownUrls('https://example.com', true);
const urls = OAuthUtils.buildWellKnownUrls('https://example.com');
expect(urls.protectedResource).toBe(
'https://example.com/.well-known/oauth-protected-resource',
);
@@ -62,10 +62,7 @@ describe('OAuthUtils', () => {
});
it('should handle trailing slash in path', () => {
const urls = OAuthUtils.buildWellKnownUrls(
'https://example.com/mcp/',
true,
);
const urls = OAuthUtils.buildWellKnownUrls('https://example.com/mcp/');
expect(urls.protectedResource).toBe(
'https://example.com/.well-known/oauth-protected-resource/mcp',
);
@@ -73,6 +70,18 @@ describe('OAuthUtils', () => {
'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', () => {

View File

@@ -55,30 +55,26 @@ export const FIVE_MIN_BUFFER_MS = 5 * 60 * 1000;
*/
export class OAuthUtils {
/**
* Construct well-known OAuth endpoint URLs.
* 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.
* Construct well-known OAuth endpoint URLs per RFC 9728 §3.1.
*
* 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 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 {
protectedResource: new URL(
`/.well-known/oauth-protected-resource${pathSuffix}`,
@@ -234,21 +230,21 @@ export class OAuthUtils {
serverUrl: string,
): Promise<MCPOAuthConfig | null> {
try {
// First try standard root-based discovery
const wellKnownUrls = this.buildWellKnownUrls(serverUrl, false);
// Try to get the protected resource metadata at root
// RFC 9728 §3.1: Construct well-known URL by inserting /.well-known/oauth-protected-resource
// between the host and path. This is the RFC-compliant approach.
const wellKnownUrls = this.buildWellKnownUrls(serverUrl);
let resourceMetadata = await this.fetchProtectedResourceMetadata(
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) {
const url = new URL(serverUrl);
if (url.pathname && url.pathname !== '/') {
const pathBasedUrls = this.buildWellKnownUrls(serverUrl, true);
const rootBasedUrls = this.buildWellKnownUrls(serverUrl, true);
resourceMetadata = await this.fetchProtectedResourceMetadata(
pathBasedUrls.protectedResource,
rootBasedUrls.protectedResource,
);
}
}