Skip to content

Commit 168c371

Browse files
JBallanJérémy BallantarekghTarek Mahmoud Sayed
authored
Fix: Relax resource URI validation to accept base URL (#1517)
Co-authored-by: Jérémy Ballan <jballan@chapsvision.com> Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
1 parent a87518c commit 168c371

2 files changed

Lines changed: 122 additions & 16 deletions

File tree

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -774,30 +774,42 @@ private async Task PerformDynamicClientRegistrationAsync(
774774
}
775775

776776
/// <summary>
777-
/// Verifies that the resource URI in the metadata exactly matches the original request URL as required by the RFC.
778-
/// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server.
777+
/// Verifies that the resource URI in the metadata matches the original request URL.
778+
/// Accepts either an exact match with the full request URL, or a match with the base URL
779+
/// (authority only, path discarded) as allowed by the MCP spec, which derives the authorization
780+
/// base URL by discarding the path component from the MCP server URL.
779781
/// </summary>
780782
/// <param name="protectedResourceMetadata">The metadata to verify.</param>
781783
/// <param name="resourceLocation">
782784
/// The original URL the client used to make the request to the resource server or the root Uri for the resource server
783785
/// if the metadata was automatically requested from the root well-known location.
784786
/// </param>
785-
/// <returns>True if the resource URI exactly matches the original request URL, otherwise false.</returns>
787+
/// <returns>
788+
/// True if the resource URI exactly matches the original request URL or its authority-level base URL, otherwise false.
789+
/// </returns>
786790
private static bool VerifyResourceMatch(ProtectedResourceMetadata protectedResourceMetadata, Uri resourceLocation)
787791
{
788792
if (protectedResourceMetadata.Resource is null)
789793
{
790794
return false;
791795
}
792796

793-
// Per RFC: The resource value must be identical to the URL that the client used
794-
// to make the request to the resource server. Compare entire URIs, not just the host.
795-
796797
// Normalize the URIs to ensure consistent comparison
797798
string normalizedMetadataResource = NormalizeUri(protectedResourceMetadata.Resource);
798799
string normalizedResourceLocation = NormalizeUri(resourceLocation);
799800

800-
return string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase);
801+
// Accept exact match with the full MCP endpoint URI
802+
if (string.Equals(normalizedMetadataResource, normalizedResourceLocation, StringComparison.OrdinalIgnoreCase))
803+
{
804+
return true;
805+
}
806+
807+
// Per the MCP spec's "Canonical Server URI" section, both the path-specific URI (e.g. https://mcp.example.com/mcp)
808+
// and the authority-only URI (e.g. https://mcp.example.com) are valid canonical URIs for identifying an MCP server.
809+
// Accept a match with the base URL (authority only, path discarded) to support servers that use the less specific form.
810+
811+
string normalizedBaseUrl = NormalizeUri(new Uri(resourceLocation.GetLeftPart(UriPartial.Authority)));
812+
return string.Equals(normalizedMetadataResource, normalizedBaseUrl, StringComparison.OrdinalIgnoreCase);
801813
}
802814

803815
/// <summary>
@@ -916,7 +928,8 @@ private async Task<ProtectedResourceMetadata> ExtractProtectedResourceMetadata(H
916928
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements
917929
metadata.WwwAuthenticateScope = wwwAuthenticateScope;
918930

919-
// Per RFC: The resource value must be identical to the URL that the client used to make the request to the resource server
931+
// Validate that the resource URI in metadata corresponds to the server we're connecting to.
932+
// VerifyResourceMatch accepts both an exact URI match and an authority-level (base URL) match per the MCP spec.
920933
LogValidatingResourceMetadata(resourceUri);
921934

922935
if (!isLegacyFallback && !VerifyResourceMatch(metadata, resourceUri))

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,7 @@ public async Task CannotAuthenticate_WhenResourceMetadataResourceIsNonRootParent
768768
//
769769
// https://datatracker.ietf.org/doc/html/rfc9728/#section-3.3
770770
//
771-
// CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates we won't fall back to root in this case.
771+
// CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath validates that a root-level resource is accepted in this case.
772772
// CanAuthenticate_WithResourceMetadataPathFallbacks validates we will fall back to root when resource_metadata is missing.
773773
Builder.Services.Configure<AuthenticationOptions>(options => options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme);
774774
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
@@ -807,8 +807,14 @@ await McpClient.CreateAsync(
807807
Assert.Contains("does not match", ex.Message);
808808
}
809809

810+
/// <summary>
811+
/// Verifies that OAuth authentication succeeds when the protected resource metadata URI
812+
/// matches the root server URL, even when the actual MCP endpoint is at a subpath.
813+
/// This tests the flexible URI matching behavior where the resource URI can be less specific
814+
/// than the actual endpoint being accessed.
815+
/// </summary>
810816
[Fact]
811-
public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath()
817+
public async Task CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath()
812818
{
813819
const string requestedResourcePath = "/mcp/tools";
814820

@@ -839,12 +845,99 @@ public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPa
839845
},
840846
}, HttpClient, LoggerFactory);
841847

842-
var ex = await Assert.ThrowsAsync<McpException>(async () =>
848+
await using var client = await McpClient.CreateAsync(
849+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
850+
}
851+
852+
/// <summary>
853+
/// Verifies that OAuth authentication fails when the protected resource metadata URI
854+
/// does not match the requested MCP server endpoint. This ensures that clients cannot
855+
/// use OAuth tokens intended for one server to access a different server.
856+
/// </summary>
857+
[Fact]
858+
public async Task CannotAuthenticate_WhenResourceMetadataUriDoesNotMatch()
859+
{
860+
const string requestedResourcePath = "/mcp/tools";
861+
const string differentResourceUri = "http://different-server.example.com";
862+
863+
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
843864
{
844-
await McpClient.CreateAsync(
845-
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
865+
options.ResourceMetadata = new ProtectedResourceMetadata
866+
{
867+
Resource = differentResourceUri,
868+
AuthorizationServers = { OAuthServerUrl },
869+
};
846870
});
847871

872+
await using var app = Builder.Build();
873+
874+
app.MapMcp(requestedResourcePath).RequireAuthorization();
875+
876+
await app.StartAsync(TestContext.Current.CancellationToken);
877+
878+
await using var transport = new HttpClientTransport(new()
879+
{
880+
Endpoint = new Uri($"{McpServerUrl}{requestedResourcePath}"),
881+
OAuth = new()
882+
{
883+
ClientId = "demo-client",
884+
ClientSecret = "demo-secret",
885+
RedirectUri = new Uri("http://localhost:1179/callback"),
886+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
887+
},
888+
}, HttpClient, LoggerFactory);
889+
890+
// This should fail because the resource URI doesn't match
891+
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
892+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
893+
894+
Assert.Contains("does not match", ex.Message);
895+
}
896+
897+
/// <summary>
898+
/// Verifies that OAuth authentication fails when the protected resource metadata URI is an
899+
/// unrelated path on the same host as the requested endpoint (e.g. resource=.../service-a vs
900+
/// endpoint .../service-b). This ensures the authority-level fallback only accepts an exact match
901+
/// or an authority-only resource, and not arbitrary sibling paths on the same host.
902+
/// </summary>
903+
[Fact]
904+
public async Task CannotAuthenticate_WhenResourceMetadataResourceIsDifferentPathOnSameAuthority()
905+
{
906+
const string requestedResourcePath = "/service-b";
907+
const string differentResourcePath = "/service-a";
908+
909+
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
910+
{
911+
options.ResourceMetadata = new ProtectedResourceMetadata
912+
{
913+
Resource = $"{McpServerUrl}{differentResourcePath}",
914+
AuthorizationServers = { OAuthServerUrl },
915+
};
916+
});
917+
918+
await using var app = Builder.Build();
919+
920+
app.MapMcp(requestedResourcePath).RequireAuthorization();
921+
922+
await app.StartAsync(TestContext.Current.CancellationToken);
923+
924+
await using var transport = new HttpClientTransport(new()
925+
{
926+
Endpoint = new Uri($"{McpServerUrl}{requestedResourcePath}"),
927+
OAuth = new()
928+
{
929+
ClientId = "demo-client",
930+
ClientSecret = "demo-secret",
931+
RedirectUri = new Uri("http://localhost:1179/callback"),
932+
AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync,
933+
},
934+
}, HttpClient, LoggerFactory);
935+
936+
// This should fail because the resource URI is a different path on the same host,
937+
// which is neither an exact match nor the authority-only base URL.
938+
var ex = await Assert.ThrowsAsync<McpException>(() => McpClient.CreateAsync(
939+
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
940+
848941
Assert.Contains("does not match", ex.Message);
849942
}
850943

@@ -853,7 +946,7 @@ public async Task ResourceMetadata_DoesNotAddTrailingSlash()
853946
{
854947
// This test verifies that automatically derived resource URIs don't have trailing slashes
855948
// and that the client doesn't add them during authentication
856-
949+
857950
// Don't explicitly set Resource - let it be derived from the request
858951
await using var app = await StartMcpServerAsync();
859952

@@ -993,10 +1086,10 @@ public async Task ResourceMetadata_PreservesExplicitTrailingSlash()
9931086
{
9941087
// This test verifies that explicitly configured trailing slashes are preserved
9951088
const string resourceWithTrailingSlash = "http://localhost:5000/";
996-
1089+
9971090
// Configure ValidResources to accept the trailing slash version for this test
9981091
TestOAuthServer.ValidResources = [resourceWithTrailingSlash, "http://localhost:5000/mcp"];
999-
1092+
10001093
Builder.Services.Configure<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
10011094
{
10021095
options.ResourceMetadata = new ProtectedResourceMetadata

0 commit comments

Comments
 (0)