From 434438c74e18bf9a86ff1b95db2922f49194afd9 Mon Sep 17 00:00:00 2001 From: pragnyanramtha Date: Sat, 16 May 2026 07:14:42 +0000 Subject: [PATCH 1/2] fix(core): validate MCP OAuth resources from metadata URL --- packages/core/src/mcp/oauth-utils.test.ts | 97 +++++++++++++++++++++++ packages/core/src/mcp/oauth-utils.ts | 44 ++++++++-- 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/packages/core/src/mcp/oauth-utils.test.ts b/packages/core/src/mcp/oauth-utils.test.ts index 6dab62a3386..440f703e577 100644 --- a/packages/core/src/mcp/oauth-utils.test.ts +++ b/packages/core/src/mcp/oauth-utils.test.ts @@ -300,6 +300,47 @@ describe('OAuthUtils', () => { scopes: ['read', 'write'], }); }); + + it('should validate against root resource when path metadata falls back to root discovery', async () => { + mockFetch + // path-based protected resource metadata is unavailable + .mockResolvedValueOnce({ + ok: false, + }) + // root-based protected resource metadata succeeds + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + resource: 'https://example.com', + authorization_servers: ['https://auth.example.com'], + bearer_methods_supported: ['header'], + }), + }) + // discoverAuthorizationServerMetadata + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockAuthServerMetadata), + }); + + await expect( + OAuthUtils.discoverOAuthConfig('https://example.com/mcp'), + ).resolves.toEqual({ + authorizationUrl: 'https://auth.example.com/authorize', + issuer: 'https://auth.example.com', + tokenUrl: 'https://auth.example.com/token', + scopes: ['read', 'write'], + }); + + expect(mockFetch).nthCalledWith( + 1, + 'https://example.com/.well-known/oauth-protected-resource/mcp', + ); + expect(mockFetch).nthCalledWith( + 2, + 'https://example.com/.well-known/oauth-protected-resource', + ); + }); }); describe('metadataToOAuthConfig', () => { @@ -401,6 +442,36 @@ describe('OAuthUtils', () => { scopes: ['read', 'write'], }); }); + + it('should validate against resource_metadata URL instead of MCP server path', async () => { + mockFetch + // fetchProtectedResourceMetadata(resource_metadata URL) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + resource: 'https://example.com', + authorization_servers: ['https://auth.example.com'], + }), + }) + // discoverAuthorizationServerMetadata(auth server well-known URL) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockAuthServerMetadata), + }); + + await expect( + OAuthUtils.discoverOAuthFromWWWAuthenticate( + 'Bearer realm="example", resource_metadata="https://example.com/.well-known/oauth-protected-resource"', + 'https://example.com/mcp', + ), + ).resolves.toEqual({ + authorizationUrl: 'https://auth.example.com/authorize', + issuer: 'https://auth.example.com', + tokenUrl: 'https://auth.example.com/token', + scopes: ['read', 'write'], + }); + }); }); describe('extractBaseUrl', () => { @@ -471,6 +542,32 @@ describe('OAuthUtils', () => { }); }); + describe('buildResourceParameterFromMetadataUrl', () => { + it('should infer a root resource from a root protected resource metadata URL', () => { + const result = OAuthUtils.buildResourceParameterFromMetadataUrl( + 'https://example.com/.well-known/oauth-protected-resource', + ); + + expect(result).toBe('https://example.com/'); + }); + + it('should infer a path resource from a path-based protected resource metadata URL', () => { + const result = OAuthUtils.buildResourceParameterFromMetadataUrl( + 'https://example.com/.well-known/oauth-protected-resource/mcp/v1', + ); + + expect(result).toBe('https://example.com/mcp/v1'); + }); + + it('should reject non-protected-resource metadata URLs', () => { + expect(() => + OAuthUtils.buildResourceParameterFromMetadataUrl( + 'https://example.com/.well-known/oauth-authorization-server', + ), + ).toThrow(/Invalid protected resource metadata URL/); + }); + }); + describe('parseTokenExpiry', () => { it('should return the expiry time in milliseconds for a valid token', () => { // Corresponds to a date of 2100-01-01T00:00:00Z diff --git a/packages/core/src/mcp/oauth-utils.ts b/packages/core/src/mcp/oauth-utils.ts index 12ab2bd9ff6..28c5311d9dc 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -87,6 +87,35 @@ export class OAuthUtils { }; } + /** + * Extract the resource identifier prefix represented by a protected resource metadata URL. + * + * RFC 9728 §3.1 constructs metadata URLs by inserting + * /.well-known/oauth-protected-resource between the origin and resource path. + * RFC 9728 §7.3 then validates the resource metadata against that prefix. + * + * @param metadataUrl The protected resource metadata URL + * @returns The resource identifier prefix represented by the metadata URL + */ + static buildResourceParameterFromMetadataUrl(metadataUrl: string): string { + const url = new URL(metadataUrl); + const wellKnownPrefix = '/.well-known/oauth-protected-resource'; + + if ( + url.pathname !== wellKnownPrefix && + !url.pathname.startsWith(`${wellKnownPrefix}/`) + ) { + throw new Error( + `Invalid protected resource metadata URL: ${metadataUrl}`, + ); + } + + const resourcePath = url.pathname.slice(wellKnownPrefix.length) || '/'; + return this.buildResourceParameter( + new URL(resourcePath, `${url.protocol}//${url.host}`).toString(), + ); + } + /** * Fetch OAuth protected resource metadata. * @@ -236,9 +265,9 @@ export class OAuthUtils { // 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, - ); + let resourceMetadataUrl = wellKnownUrls.protectedResource; + let resourceMetadata = + await this.fetchProtectedResourceMetadata(resourceMetadataUrl); // 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 @@ -249,6 +278,9 @@ export class OAuthUtils { resourceMetadata = await this.fetchProtectedResourceMetadata( rootBasedUrls.protectedResource, ); + if (resourceMetadata) { + resourceMetadataUrl = rootBasedUrls.protectedResource; + } } } @@ -256,7 +288,8 @@ export class OAuthUtils { // RFC 9728 Section 7.3: The client MUST ensure that the resource identifier URL // it is using as the prefix for the metadata request exactly matches the value // of the resource metadata parameter in the protected resource metadata document. - const expectedResource = this.buildResourceParameter(serverUrl); + const expectedResource = + this.buildResourceParameterFromMetadataUrl(resourceMetadataUrl); if ( !this.isEquivalentResourceIdentifier( resourceMetadata.resource, @@ -352,7 +385,8 @@ export class OAuthUtils { if (resourceMetadata && mcpServerUrl) { // Validate resource parameter per RFC 9728 Section 7.3 - const expectedResource = this.buildResourceParameter(mcpServerUrl); + const expectedResource = + this.buildResourceParameterFromMetadataUrl(resourceMetadataUri); if ( !this.isEquivalentResourceIdentifier( resourceMetadata.resource, From cbc9d00ec146ae23fa0e6524968e6c1008335f6c Mon Sep 17 00:00:00 2001 From: pragnyanramtha Date: Sat, 16 May 2026 10:23:51 +0000 Subject: [PATCH 2/2] fix(core): reject protocol-relative OAuth metadata paths --- packages/core/src/mcp/oauth-utils.test.ts | 8 ++++++++ packages/core/src/mcp/oauth-utils.ts | 13 ++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/core/src/mcp/oauth-utils.test.ts b/packages/core/src/mcp/oauth-utils.test.ts index 440f703e577..8d7b16aaf48 100644 --- a/packages/core/src/mcp/oauth-utils.test.ts +++ b/packages/core/src/mcp/oauth-utils.test.ts @@ -566,6 +566,14 @@ describe('OAuthUtils', () => { ), ).toThrow(/Invalid protected resource metadata URL/); }); + + it('should reject protocol-relative resource paths', () => { + expect(() => + OAuthUtils.buildResourceParameterFromMetadataUrl( + 'https://example.com/.well-known/oauth-protected-resource//attacker.com/api', + ), + ).toThrow(/Invalid protected resource metadata URL/); + }); }); describe('parseTokenExpiry', () => { diff --git a/packages/core/src/mcp/oauth-utils.ts b/packages/core/src/mcp/oauth-utils.ts index 28c5311d9dc..26dc7c9b0bf 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -111,9 +111,16 @@ export class OAuthUtils { } const resourcePath = url.pathname.slice(wellKnownPrefix.length) || '/'; - return this.buildResourceParameter( - new URL(resourcePath, `${url.protocol}//${url.host}`).toString(), - ); + if (resourcePath.startsWith('//')) { + throw new Error( + `Invalid protected resource metadata URL: ${metadataUrl}`, + ); + } + + url.pathname = resourcePath; + url.search = ''; + url.hash = ''; + return this.buildResourceParameter(url.toString()); } /**