diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 662e436eb..1d05da0d0 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -774,15 +774,19 @@ private async Task PerformDynamicClientRegistrationAsync( } /// - /// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC. - /// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server. + /// Verifies that the resource URI in the metadata matches the original request URL. + /// Accepts either an exact match with the full request URL, or a match with the base URL + /// (authority only, path discarded) as allowed by the MCP spec, which derives the authorization + /// base URL by discarding the path component from the MCP server URL. /// /// The metadata to verify. /// /// The original URL the client used to make the request to the resource server or the root Uri for the resource server /// if the metadata was automatically requested from the root well-known location. /// - /// True if the resource URI exactly matches the original request URL, otherwise false. + /// + /// True if the resource URI exactly matches the original request URL or its authority-level base URL, otherwise false. + /// private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri resourceLocation) { if (protectedResourceMetadata.Resource is null) @@ -790,14 +794,22 @@ 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 the MCP spec's "Canonical Server URI" section, both the path-specific URI (e.g. https://mcp.example.com/mcp) + // and the authority-only URI (e.g. https://mcp.example.com) are valid canonical URIs for identifying an MCP server. + // Accept a match with the base URL (authority only, path discarded) to support servers that use the less specific form. + + 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 1ec6fddc6..84c25e38c 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -768,7 +768,7 @@ public async Task CannotAuthenticate_WhenResourceMetadataResourceIsNonRootParent // // https://datatracker.ietf.org/doc/html/rfc9728/#section-3.3 // - // CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates we won't fall back to root in this case. + // CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates that a root-level resource is accepted in this case. // CanAuthenticate_WithResourceMetadataPathFallbacks validates we will fall back to root when resource_metadata is missing. Builder.Services.Configure(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme); Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => @@ -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,99 @@ 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); + } + + /// + /// Verifies that OAuth authentication fails when the protected resource metadata URI is an + /// unrelated path on the same host as the requested endpoint (e.g. resource=.../service-a vs + /// endpoint .../service-b). This ensures the authority-level fallback only accepts an exact match + /// or an authority-only resource, and not arbitrary sibling paths on the same host. + /// + [Fact] + public async Task CannotAuthenticate_WhenResourceMetadataResourceIsDifferentPathOnSameAuthority() + { + const string requestedResourcePath = "/service-b"; + const string differentResourcePath = "/service-a"; + + Builder.Services.Configure(McpAuthenticationDefaults.AuthenticationScheme, options => + { + options.ResourceMetadata = new ProtectedResourceMetadata + { + Resource = $"{McpServerUrl}{differentResourcePath}", + 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 is a different path on the same host, + // which is neither an exact match nor the authority-only base URL. + var ex = await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Contains("does not match", ex.Message); } @@ -853,7 +946,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 +1086,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