csharp-sdk/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/McpAuthenticationHandlerTests.cs
Copilot dbf91bcb1a
Change ProtectedResourceMetadata URI properties to strings and build resource strings directly (#1264)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: halter73 <54385+halter73@users.noreply.github.com>
Co-authored-by: Stephen Halter <halter73@gmail.com>
2026-02-12 22:28:43 -05:00

203 lines
9.6 KiB
C#

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Authentication;
using System.Net;
using System.Net.Http.Json;
using System.Text.Encodings.Web;
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
public class McpAuthenticationHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper)
{
[Fact]
public async Task Challenge_WithRelativeResourceMetadataUri_SetsAbsoluteUrl()
{
const string metadataPath = "/.well-known/custom-relative";
await using var app = await StartAuthenticationServerAsync(options =>
{
options.ResourceMetadataUri = new Uri(metadataPath, UriKind.Relative);
options.ResourceMetadata!.Resource = "http://localhost:5000/challenge";
});
using var challengeResponse = await HttpClient.GetAsync(new Uri("/challenge", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, challengeResponse.StatusCode);
var header = Assert.Single(challengeResponse.Headers.WwwAuthenticate);
Assert.Equal("Bearer", header.Scheme);
Assert.Contains($"resource_metadata=\"http://localhost:5000{metadataPath}\"", header.Parameter);
using var metadataResponse = await HttpClient.GetAsync(new Uri(metadataPath, UriKind.Relative), TestContext.Current.CancellationToken);
metadataResponse.EnsureSuccessStatusCode();
}
[Fact]
public async Task MetadataRequest_CustomResourceMetadataUriWithoutResource_ThrowsInvalidOperationException()
{
const string metadataPath = "/.well-known/custom-metadata";
await using var app = await StartAuthenticationServerAsync(options =>
{
options.ResourceMetadataUri = new Uri(metadataPath, UriKind.Relative);
});
using var response = await HttpClient.GetAsync(new Uri(metadataPath, UriKind.Relative), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Error &&
log.Exception is InvalidOperationException &&
log.Exception.Message.Contains("ResourceMetadata.Resource could not be determined", StringComparison.Ordinal));
}
[Fact]
public async Task Challenge_WithAbsoluteResourceMetadataUri_SetsConfiguredUrl()
{
var metadataUri = new Uri("http://localhost:5000/.well-known/custom-absolute");
await using var app = await StartAuthenticationServerAsync(options =>
{
options.ResourceMetadataUri = metadataUri;
options.ResourceMetadata!.Resource = "http://localhost:5000/challenge";
});
using var challengeResponse = await HttpClient.GetAsync(new Uri("/challenge", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, challengeResponse.StatusCode);
var header = Assert.Single(challengeResponse.Headers.WwwAuthenticate);
Assert.Equal("Bearer", header.Scheme);
Assert.Contains($"resource_metadata=\"{metadataUri}\"", header.Parameter);
using var metadataResponse = await HttpClient.GetAsync(metadataUri, TestContext.Current.CancellationToken);
metadataResponse.EnsureSuccessStatusCode();
}
[Fact]
public async Task MetadataRequest_WithHostMismatch_LogsWarning()
{
var metadataUri = new Uri("http://expected-host:5000/.well-known/host-mismatch");
await using var app = await StartAuthenticationServerAsync(options =>
{
options.ResourceMetadataUri = metadataUri;
});
using var metadataRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost:5000/.well-known/host-mismatch"));
using var metadataResponse = await HttpClient.SendAsync(metadataRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, metadataResponse.StatusCode);
Assert.Contains(MockLoggerProvider.LogMessages, log =>
log.LogLevel == LogLevel.Warning &&
log.Message.Contains("Resource metadata request host", StringComparison.OrdinalIgnoreCase) &&
log.Message.Contains("expected-host", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Challenge_WithDefaultMetadata_ComposesResourceSpecificEndpoint()
{
await using var app = await StartAuthenticationServerAsync();
using var challengeResponse = await HttpClient.GetAsync(new Uri("/resource/tools/list", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, challengeResponse.StatusCode);
var header = Assert.Single(challengeResponse.Headers.WwwAuthenticate);
Assert.Equal("Bearer", header.Scheme);
Assert.Contains("resource_metadata=\"http://localhost:5000/.well-known/oauth-protected-resource/resource/tools/list\"", header.Parameter);
}
[Fact]
public async Task Challenge_WithDefaultMetadata_AndPathBase_ComposesResourceSpecificEndpoint()
{
await using var app = await StartAuthenticationServerAsync(pathBase: new PathString("/api"));
using var challengeResponse = await HttpClient.GetAsync(new Uri("/api/resource/tools/list", UriKind.Relative), HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, challengeResponse.StatusCode);
var header = Assert.Single(challengeResponse.Headers.WwwAuthenticate);
Assert.Equal("Bearer", header.Scheme);
Assert.Contains("resource_metadata=\"http://localhost:5000/api/.well-known/oauth-protected-resource/resource/tools/list\"", header.Parameter);
}
[Fact]
public async Task MetadataRequest_DefaultEndpoint_SetsResourceFromSuffix()
{
await using var app = await StartAuthenticationServerAsync();
using var metadataResponse = await HttpClient.GetAsync(new Uri("/.well-known/oauth-protected-resource/resource/tools", UriKind.Relative), TestContext.Current.CancellationToken);
metadataResponse.EnsureSuccessStatusCode();
var metadata = await metadataResponse.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
McpJsonUtilities.DefaultOptions,
TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
Assert.Equal("http://localhost:5000/resource/tools", metadata!.Resource);
}
[Fact]
public async Task MetadataRequest_DefaultEndpoint_WithPathBase_SetsResourceFromSuffix()
{
await using var app = await StartAuthenticationServerAsync(pathBase: new PathString("/api"));
using var metadataResponse = await HttpClient.GetAsync(new Uri("/api/.well-known/oauth-protected-resource/resource/tools", UriKind.Relative), TestContext.Current.CancellationToken);
metadataResponse.EnsureSuccessStatusCode();
var metadata = await metadataResponse.Content.ReadFromJsonAsync<ProtectedResourceMetadata>(
McpJsonUtilities.DefaultOptions,
TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
Assert.Equal("http://localhost:5000/api/resource/tools", metadata!.Resource);
}
private async Task<WebApplication> StartAuthenticationServerAsync(Action<McpAuthenticationOptions>? configureOptions = null, PathString? pathBase = null)
{
var authenticationBuilder = Builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
});
authenticationBuilder.AddScheme<McpAuthenticationOptions, McpAuthenticationHandler>(McpAuthenticationDefaults.AuthenticationScheme, options =>
{
options.ResourceMetadata = new()
{
AuthorizationServers = ["https://localhost:7029"],
ScopesSupported = ["mcp:tools"],
};
configureOptions?.Invoke(options);
});
authenticationBuilder.AddScheme<AuthenticationSchemeOptions, NoopBearerAuthenticationHandler>("Bearer", options => { });
Builder.Services.AddAuthorization();
var app = Builder.Build();
if (pathBase is PathString basePath && basePath.HasValue)
{
app.UsePathBase(basePath);
}
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/challenge", context => context.ChallengeAsync(McpAuthenticationDefaults.AuthenticationScheme));
app.MapGet("/resource/{*resourcePath}", context => context.ChallengeAsync(McpAuthenticationDefaults.AuthenticationScheme));
await app.StartAsync(TestContext.Current.CancellationToken);
return app;
}
private sealed class NoopBearerAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync() => Task.FromResult(AuthenticateResult.NoResult());
}
}