diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index ecef8e15e..16d3d2908 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -743,14 +743,21 @@ private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResou return false; } - // Per RFC: The resource value must be identical to the URL that the client used - // to make the request to the resource server. Compare entire URIs, not just the host. - // Normalize the URIs to ensure consistent comparison string normalizedMetadataResource = NormalizeUri(protectedResourceMetadata.Resource); string normalizedResourceLocation = NormalizeUri(resourceLocation); - return string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase); + // Accept exact match with the full MCP endpoint URI + if (string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Per MCP spec: "The authorization base URL MUST be derived by discarding the path component from the MCP server URL" + // Accept match with the base URL (authority only, path discarded) as this is the expected behavior per MCP spec + + string normalizedBaseUrl = NormalizeUri(new Uri(resourceLocation.GetLeftPart(UriPartial.Authority))); + return string.Equals(normalizedMetadataResource, normalizedBaseUrl, StringComparison.OrdinalIgnoreCase); } /// diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index c4979fb10..e30291ad4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -807,8 +807,14 @@ await McpClient.CreateAsync( Assert.Contains("does not match", ex.Message); } + /// + /// Verifies that OAuth authentication succeeds when the protected resource metadata URI + /// matches the root server URL, even when the actual MCP endpoint is at a subpath. + /// This tests the flexible URI matching behavior where the resource URI can be less specific + /// than the actual endpoint being accessed. + /// [Fact] - public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath() + public async Task CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath() { const string requestedResourcePath = "/mcp/tools"; @@ -839,12 +845,52 @@ public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPa }, }, HttpClient, LoggerFactory); - var ex = await Assert.ThrowsAsync(async () => + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + /// + /// Verifies that OAuth authentication fails when the protected resource metadata URI + /// does not match the requested MCP server endpoint. This ensures that clients cannot + /// use OAuth tokens intended for one server to access a different server. + /// + [Fact] + public async Task CannotAuthenticate_WhenResourceMetadataUriDoesNotMatch() + { + const string requestedResourcePath = "/mcp/tools"; + const string differentResourceUri = "http://different-server.example.com"; + + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => { - await McpClient.CreateAsync( - transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + options.ResourceMetadata = new ProtectedResourceMetadata + { + Resource = differentResourceUri, + AuthorizationServers = { OAuthServerUrl }, + }; }); + await using var app = Builder.Build(); + + app.MapMcp(requestedResourcePath).RequireAuthorization(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new Uri($"{McpServerUrl}{requestedResourcePath}"), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + + // This should fail because the resource URI doesn't match + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Contains("does not match", ex.Message); } @@ -853,7 +899,7 @@ public async Task ResourceMetadata_DoesNotAddTrailingSlash() { // This test verifies that automatically derived resource URIs don't have trailing slashes // and that the client doesn't add them during authentication - + // Don't explicitly set Resource - let it be derived from the request await using var app = await StartMcpServerAsync(); @@ -993,10 +1039,10 @@ public async Task ResourceMetadata_PreservesExplicitTrailingSlash() { // This test verifies that explicitly configured trailing slashes are preserved const string resourceWithTrailingSlash = "http://localhost:5000/"; - + // Configure ValidResources to accept the trailing slash version for this test TestOAuthServer.ValidResources = [resourceWithTrailingSlash, "http://localhost:5000/mcp"]; - + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => { options.ResourceMetadata = new ProtectedResourceMetadata