diff --git a/packages/core/src/mcp/oauth-utils.test.ts b/packages/core/src/mcp/oauth-utils.test.ts index 6dab62a3386..8d7b16aaf48 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,40 @@ 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/); + }); + + 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', () => { 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..26dc7c9b0bf 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -87,6 +87,42 @@ 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) || '/'; + if (resourcePath.startsWith('//')) { + throw new Error( + `Invalid protected resource metadata URL: ${metadataUrl}`, + ); + } + + url.pathname = resourcePath; + url.search = ''; + url.hash = ''; + return this.buildResourceParameter(url.toString()); + } + /** * Fetch OAuth protected resource metadata. * @@ -236,9 +272,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 +285,9 @@ export class OAuthUtils { resourceMetadata = await this.fetchProtectedResourceMetadata( rootBasedUrls.protectedResource, ); + if (resourceMetadata) { + resourceMetadataUrl = rootBasedUrls.protectedResource; + } } } @@ -256,7 +295,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 +392,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,