From e475574be832f49425eb9dde7b197048a5340240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Ballan?= Date: Wed, 15 Apr 2026 10:27:07 +0200 Subject: [PATCH 1/5] Fix: Relax resource URI validation to accept base URL Per MCP spec, authorization operates at base URL level (path discarded). The SDK now accepts OAuth metadata when the 'resource' field contains either the full MCP endpoint URI or the base URL (authority only). --- .../Authentication/ClientOAuthProvider.cs | 15 ++++-- .../OAuth/AuthTests.cs | 49 ++++++++++++++++--- 2 files changed, 53 insertions(+), 11 deletions(-) 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..8cc498f67 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -808,7 +808,7 @@ await McpClient.CreateAsync( } [Fact] - public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath() + public async Task CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath() { const string requestedResourcePath = "/mcp/tools"; @@ -839,12 +839,47 @@ 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); + } + + [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 +888,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 +1028,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 From 7a65e0d2f55f32de274917c30bb2100a28673cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Ballan?= Date: Wed, 15 Apr 2026 11:09:44 +0200 Subject: [PATCH 2/5] Update comments --- .../OAuth/AuthTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 8cc498f67..e30291ad4 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -807,6 +807,12 @@ 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 CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath() { @@ -843,6 +849,11 @@ public async Task CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath( 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() { From 465b68a37d95924fd24056ec4a372c9897daf8a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Ballan?= Date: Wed, 15 Apr 2026 10:27:07 +0200 Subject: [PATCH 3/5] Fix: Relax resource URI validation to accept base URL Per MCP spec, authorization operates at base URL level (path discarded). The SDK now accepts OAuth metadata when the 'resource' field contains either the full MCP endpoint URI or the base URL (authority only). --- .../Authentication/ClientOAuthProvider.cs | 15 ++++-- .../OAuth/AuthTests.cs | 49 ++++++++++++++++--- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 662e436eb..f2563468a 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -790,14 +790,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 1ec6fddc6..1d4d61229 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -808,7 +808,7 @@ await McpClient.CreateAsync( } [Fact] - public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath() + public async Task CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath() { const string requestedResourcePath = "/mcp/tools"; @@ -839,12 +839,47 @@ 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); + } + + [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 +888,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 +1028,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 From 6310e3785a1db2402c946383f8b0b807dc95fa2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Ballan?= Date: Wed, 15 Apr 2026 11:09:44 +0200 Subject: [PATCH 4/5] Update comments --- .../OAuth/AuthTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index 1d4d61229..4d8a9cbe1 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -807,6 +807,12 @@ 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 CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath() { @@ -843,6 +849,11 @@ public async Task CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath( 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() { From 1fac559d72be3f58c90ec536827550ca6f635946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Ballan?= Date: Mon, 1 Jun 2026 12:49:30 +0200 Subject: [PATCH 5/5] Apply comments --- .../Authentication/ClientOAuthProvider.cs | 15 ++++-- .../OAuth/AuthTests.cs | 49 ++++++++++++++++++- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index f2563468a..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) @@ -800,8 +804,9 @@ private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResou 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 + // 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 4d8a9cbe1..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 => @@ -894,6 +894,53 @@ public async Task CannotAuthenticate_WhenResourceMetadataUriDoesNotMatch() 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); + } + [Fact] public async Task ResourceMetadata_DoesNotAddTrailingSlash() {