From 0eaa7f30c00a77842c5246d60b5158ac40c766f0 Mon Sep 17 00:00:00 2001
From: yuwk <1729065730@qq.com>
Date: Fri, 11 Sep 2026 14:16:06 +0800
Subject: [PATCH] test(aspnetcore): cover RequireAuthorization() on the MCP
endpoint across session modes
---
.../MapMcpAuthorizationTests.cs | 282 ++++++++++++++++++
1 file changed, 282 insertions(+)
create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/MapMcpAuthorizationTests.cs
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpAuthorizationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpAuthorizationTests.cs
new file mode 100644
index 000000000..8ec47425b
--- /dev/null
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpAuthorizationTests.cs
@@ -0,0 +1,282 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Builder;
+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 ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
+using System.ComponentModel;
+using System.Net;
+using System.Net.Http.Json;
+using System.Net.ServerSentEvents;
+using System.Security.Claims;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization.Metadata;
+
+namespace ModelContextProtocol.AspNetCore.Tests;
+
+///
+/// End-to-end coverage for protecting the MCP endpoint itself with RequireAuthorization().
+///
+///
+///
+/// covers tool-level [Authorize]: the request reaches the MCP
+/// handler and the tool collection is filtered per user. This file covers the endpoint-level challenge
+/// instead, which has to happen before the request reaches the handler at all — for the initialize request,
+/// for follow-up requests inside an established session, and in either session mode.
+///
+///
+/// The last test covers the OAuth interaction: the protected-resource metadata document must stay reachable
+/// while the MCP endpoint itself requires credentials, and the challenge must point clients at it.
+///
+///
+[McpServerToolType]
+public class MapMcpAuthorizationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable
+{
+ private const string TestScheme = "TestScheme";
+ private const string UserHeaderName = "X-Test-User";
+ private const string McpEndpointUrl = "http://localhost:5000";
+ private const string AuthorizationServerUrl = "https://localhost:7029";
+
+ private WebApplication? _app;
+
+ [McpServerTool(Name = "echo")]
+ [Description("Echoes the supplied message back to the caller.")]
+ public static string Echo(string message) => $"Echo: {message}";
+
+ private async Task StartAsync(
+ HttpServerSessionMode sessionMode = HttpServerSessionMode.Stateful,
+ bool advertiseResourceMetadata = false,
+ string path = "")
+ {
+ Builder.Services.AddMcpServer()
+ .WithTools()
+ .WithHttpTransport(options => options.SessionMode = sessionMode);
+
+ var authentication = Builder.Services.AddAuthentication(options =>
+ {
+ options.DefaultScheme = TestScheme;
+ options.DefaultAuthenticateScheme = TestScheme;
+
+ if (advertiseResourceMetadata)
+ {
+ // A real OAuth-protected server challenges with the MCP scheme so that the 401 advertises
+ // the protected-resource metadata document, while authentication itself is done per token.
+ options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
+ }
+ });
+
+ authentication.AddScheme(TestScheme, options => { });
+
+ if (advertiseResourceMetadata)
+ {
+ authentication.AddScheme(
+ McpAuthenticationDefaults.AuthenticationScheme,
+ options => options.ResourceMetadata = new ProtectedResourceMetadata
+ {
+ AuthorizationServers = { AuthorizationServerUrl },
+ ScopesSupported = { "mcp:tools" },
+ });
+ }
+
+ Builder.Services.AddAuthorization();
+
+ _app = Builder.Build();
+
+ _app.MapMcp(path).RequireAuthorization();
+
+ await _app.StartAsync(TestContext.Current.CancellationToken);
+
+ HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
+ HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream"));
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_app is not null)
+ {
+ await _app.DisposeAsync();
+ }
+
+ base.Dispose();
+ }
+
+ [Theory]
+ [InlineData(HttpServerSessionMode.Stateful)]
+ [InlineData(HttpServerSessionMode.StatefulForInitializeClients)]
+ [InlineData(HttpServerSessionMode.Stateless)]
+ public async Task Initialize_WithoutCredentials_IsChallenged(HttpServerSessionMode sessionMode)
+ {
+ await StartAsync(sessionMode);
+
+ using var response = await PostAsync(InitializeRequest);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Theory]
+ [InlineData(HttpServerSessionMode.Stateful)]
+ [InlineData(HttpServerSessionMode.StatefulForInitializeClients)]
+ [InlineData(HttpServerSessionMode.Stateless)]
+ public async Task Initialize_WithCredentials_Succeeds(HttpServerSessionMode sessionMode)
+ {
+ await StartAsync(sessionMode);
+
+ using var response = await PostAsync(InitializeRequest, user: "alice");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ // Stateless mode keeps the endpoint free of server state, so only the session-based modes mint an id.
+ if (sessionMode is HttpServerSessionMode.Stateless)
+ {
+ Assert.False(response.Headers.Contains("mcp-session-id"));
+ }
+ else
+ {
+ Assert.False(string.IsNullOrEmpty(GetSessionId(response)));
+ }
+ }
+
+ [Fact]
+ public async Task StatefulSession_FollowUpRequests_RequireCredentials()
+ {
+ await StartAsync(HttpServerSessionMode.Stateful);
+
+ using var initializeResponse = await PostAsync(InitializeRequest, user: "alice");
+ var sessionId = GetSessionId(initializeResponse);
+ Assert.False(string.IsNullOrEmpty(sessionId));
+
+ using var authorizedResponse = await PostAsync(ListToolsRequest, user: "alice", sessionId: sessionId);
+ var result = AssertType((await ReadSingleSseResponseAsync(authorizedResponse)).Result);
+ Assert.Contains(result.Tools, tool => tool.Name == "echo");
+
+ // The session id identifies the session; it must not authenticate the caller. Without credentials
+ // the authorization middleware has to reject the request before the MCP handler sees the session.
+ using var unauthenticatedResponse = await PostAsync(ListToolsRequest, sessionId: sessionId);
+ Assert.Equal(HttpStatusCode.Unauthorized, unauthenticatedResponse.StatusCode);
+ }
+
+ [Fact]
+ public async Task ResourceMetadata_RemainsReachable_WhileTheMcpEndpointIsChallenged()
+ {
+ // Mount the endpoint at /mcp, the shape a deployment behind an ingress typically uses: the metadata
+ // document then mirrors the resource path, and the challenge advertises that mirrored document.
+ await StartAsync(HttpServerSessionMode.Stateful, advertiseResourceMetadata: true, path: "/mcp");
+
+ // Discovery is what an unauthenticated client does first, so the metadata document has to be served
+ // without credentials even though every request to the MCP endpoint is challenged.
+ using var metadataResponse = await HttpClient.GetAsync(
+ "/.well-known/oauth-protected-resource/mcp", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, metadataResponse.StatusCode);
+
+ var metadata = await metadataResponse.Content.ReadFromJsonAsync(
+ McpJsonUtilities.DefaultOptions, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(metadata);
+ Assert.Equal($"{McpEndpointUrl}/mcp", metadata.Resource);
+ Assert.Equal([AuthorizationServerUrl], metadata.AuthorizationServers);
+
+ // The challenge has to tell the client where the metadata document lives, otherwise it cannot
+ // discover the authorization server from the 401 alone.
+ using var challengedResponse = await PostAsync(InitializeRequest, path: "/mcp");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, challengedResponse.StatusCode);
+ var challenge = Assert.Single(challengedResponse.Headers.WwwAuthenticate);
+ Assert.Equal("Bearer", challenge.Scheme);
+ Assert.Contains(
+ $"resource_metadata=\"{McpEndpointUrl}/.well-known/oauth-protected-resource/mcp\"",
+ challenge.Parameter);
+ }
+
+ private Task PostAsync(string json, string? user = null, string? sessionId = null, string path = "")
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, path)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json"),
+ };
+
+ if (user is not null)
+ {
+ request.Headers.Add(UserHeaderName, user);
+ }
+
+ if (sessionId is not null)
+ {
+ request.Headers.Add("mcp-session-id", sessionId);
+ }
+
+ return HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+ }
+
+ private static string? GetSessionId(HttpResponseMessage response)
+ => response.Headers.TryGetValues("mcp-session-id", out var values) ? Assert.Single(values) : null;
+
+ private static async Task ReadSingleSseResponseAsync(HttpResponseMessage response)
+ {
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType);
+
+ var responseStream = await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken);
+ var payloads = new List();
+
+ await foreach (var sseItem in SseParser.Create(responseStream).EnumerateAsync(TestContext.Current.CancellationToken))
+ {
+ Assert.Equal("message", sseItem.EventType);
+ payloads.Add(sseItem.Data);
+ }
+
+ var jsonRpcResponse = JsonSerializer.Deserialize(
+ Assert.Single(payloads),
+ (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcResponse)));
+
+ Assert.NotNull(jsonRpcResponse);
+ return jsonRpcResponse;
+ }
+
+ private static T AssertType(JsonNode? jsonNode)
+ {
+ var value = JsonSerializer.Deserialize(
+ jsonNode, (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)));
+
+ Assert.NotNull(value);
+ return value;
+ }
+
+ private static string InitializeRequest => """
+ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"MapMcpAuthorizationTests","version":"1.0.0"}}}
+ """;
+
+ private static string ListToolsRequest => """
+ {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
+ """;
+
+ ///
+ /// Authenticates a caller from a header, so tests can assert both the challenged and the authorized path
+ /// without standing up a token issuer.
+ ///
+ private sealed class HeaderUserAuthenticationHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder)
+ : AuthenticationHandler(options, logger, encoder)
+ {
+ protected override Task HandleAuthenticateAsync()
+ {
+ if (!Request.Headers.TryGetValue(UserHeaderName, out var user) || string.IsNullOrEmpty(user))
+ {
+ return Task.FromResult(AuthenticateResult.NoResult());
+ }
+
+ var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, user.ToString())], TestScheme);
+ return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), TestScheme)));
+ }
+ }
+}