Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
Expand Down
60 changes: 53 additions & 7 deletions tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -807,8 +807,14 @@ await McpClient.CreateAsync(
Assert.Contains("does not match", ex.Message);
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath()
public async Task CanAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPath()
{
const string requestedResourcePath = "/mcp/tools";

Expand Down Expand Up @@ -839,12 +845,52 @@ public async Task CannotAuthenticate_WhenWwwAuthenticateResourceMetadataIsRootPa
},
}, HttpClient, LoggerFactory);

var ex = await Assert.ThrowsAsync<McpException>(async () =>
await using var client = await McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task CannotAuthenticate_WhenResourceMetadataUriDoesNotMatch()
{
const string requestedResourcePath = "/mcp/tools";
const string differentResourceUri = "http://different-server.example.com";

Builder.Services.Configure<McpAuthenticationOptions>(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<McpException>(() => McpClient.CreateAsync(
transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));

Assert.Contains("does not match", ex.Message);
}

Expand All @@ -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();

Expand Down Expand Up @@ -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<McpAuthenticationOptions>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata
Expand Down