Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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