| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,282 @@ | |||
| 1 | + using Microsoft.AspNetCore.Authentication; | ||
| 2 | + using Microsoft.AspNetCore.Builder; | ||
| 3 | + using Microsoft.Extensions.DependencyInjection; | ||
| 4 | + using Microsoft.Extensions.Logging; | ||
| 5 | + using Microsoft.Extensions.Options; | ||
| 6 | + using ModelContextProtocol.AspNetCore.Authentication; | ||
| 7 | + using ModelContextProtocol.AspNetCore.Tests.Utils; | ||
| 8 | + using ModelContextProtocol.Authentication; | ||
| 9 | + using ModelContextProtocol.Protocol; | ||
| 10 | + using ModelContextProtocol.Server; | ||
| 11 | + using ModelContextProtocol.Tests.Utils; | ||
| 12 | + using System.ComponentModel; | ||
| 13 | + using System.Net; | ||
| 14 | + using System.Net.Http.Json; | ||
| 15 | + using System.Net.ServerSentEvents; | ||
| 16 | + using System.Security.Claims; | ||
| 17 | + using System.Text; | ||
| 18 | + using System.Text.Encodings.Web; | ||
| 19 | + using System.Text.Json; | ||
| 20 | + using System.Text.Json.Nodes; | ||
| 21 | + using System.Text.Json.Serialization.Metadata; | ||
| 22 | + | ||
| 23 | + namespace ModelContextProtocol.AspNetCore.Tests; | ||
| 24 | + | ||
| 25 | + /// <summary> | ||
| 26 | + /// End-to-end coverage for protecting the MCP endpoint itself with <c>RequireAuthorization()</c>. | ||
| 27 | + /// </summary> | ||
| 28 | + /// <remarks> | ||
| 29 | + /// <para> | ||
| 30 | + /// <see cref="AuthorizeAttributeTests"/> covers tool-level <c>[Authorize]</c>: the request reaches the MCP | ||
| 31 | + /// handler and the tool collection is filtered per user. This file covers the endpoint-level challenge | ||
| 32 | + /// instead, which has to happen before the request reaches the handler at all — for the initialize request, | ||
| 33 | + /// for follow-up requests inside an established session, and in either session mode. | ||
| 34 | + /// </para> | ||
| 35 | + /// <para> | ||
| 36 | + /// The last test covers the OAuth interaction: the protected-resource metadata document must stay reachable | ||
| 37 | + /// while the MCP endpoint itself requires credentials, and the challenge must point clients at it. | ||
| 38 | + /// </para> | ||
| 39 | + /// </remarks> | ||
| 40 | + [McpServerToolType] | ||
| 41 | + public class MapMcpAuthorizationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable | ||
| 42 | + { | ||
| 43 | + private const string TestScheme = "TestScheme"; | ||
| 44 | + private const string UserHeaderName = "X-Test-User"; | ||
| 45 | + private const string McpEndpointUrl = "http://localhost:5000"; | ||
| 46 | + private const string AuthorizationServerUrl = "https://localhost:7029"; | ||
| 47 | + | ||
| 48 | + private WebApplication? _app; | ||
| 49 | + | ||
| 50 | + [McpServerTool(Name = "echo")] | ||
| 51 | + [Description("Echoes the supplied message back to the caller.")] | ||
| 52 | + public static string Echo(string message) => $"Echo: {message}"; | ||
| 53 | + | ||
| 54 | + private async Task StartAsync( | ||
| 55 | + HttpServerSessionMode sessionMode = HttpServerSessionMode.Stateful, | ||
| 56 | + bool advertiseResourceMetadata = false, | ||
| 57 | + string path = "") | ||
| 58 | + { | ||
| 59 | + Builder.Services.AddMcpServer() | ||
| 60 | + .WithTools<MapMcpAuthorizationTests>() | ||
| 61 | + .WithHttpTransport(options => options.SessionMode = sessionMode); | ||
| 62 | + | ||
| 63 | + var authentication = Builder.Services.AddAuthentication(options => | ||
| 64 | + { | ||
| 65 | + options.DefaultScheme = TestScheme; | ||
| 66 | + options.DefaultAuthenticateScheme = TestScheme; | ||
| 67 | + | ||
| 68 | + if (advertiseResourceMetadata) | ||
| 69 | + { | ||
| 70 | + // A real OAuth-protected server challenges with the MCP scheme so that the 401 advertises | ||
| 71 | + // the protected-resource metadata document, while authentication itself is done per token. | ||
| 72 | + options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme; | ||
| 73 | + } | ||
| 74 | + }); | ||
| 75 | + | ||
| 76 | + authentication.AddScheme<AuthenticationSchemeOptions, HeaderUserAuthenticationHandler>(TestScheme, options => { }); | ||
| 77 | + | ||
| 78 | + if (advertiseResourceMetadata) | ||
| 79 | + { | ||
| 80 | + authentication.AddScheme<McpAuthenticationOptions, McpAuthenticationHandler>( | ||
| 81 | + McpAuthenticationDefaults.AuthenticationScheme, | ||
| 82 | + options => options.ResourceMetadata = new ProtectedResourceMetadata | ||
| 83 | + { | ||
| 84 | + AuthorizationServers = { AuthorizationServerUrl }, | ||
| 85 | + ScopesSupported = { "mcp:tools" }, | ||
| 86 | + }); | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + Builder.Services.AddAuthorization(); | ||
| 90 | + | ||
| 91 | + _app = Builder.Build(); | ||
| 92 | + | ||
| 93 | + _app.MapMcp(path).RequireAuthorization(); | ||
| 94 | + | ||
| 95 | + await _app.StartAsync(TestContext.Current.CancellationToken); | ||
| 96 | + | ||
| 97 | + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); | ||
| 98 | + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + public async ValueTask DisposeAsync() | ||
| 102 | + { | ||
| 103 | + if (_app is not null) | ||
| 104 | + { | ||
| 105 | + await _app.DisposeAsync(); | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + base.Dispose(); | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + [Theory] | ||
| 112 | + [InlineData(HttpServerSessionMode.Stateful)] | ||
| 113 | + [InlineData(HttpServerSessionMode.StatefulForInitializeClients)] | ||
| 114 | + [InlineData(HttpServerSessionMode.Stateless)] | ||
| 115 | + public async Task Initialize_WithoutCredentials_IsChallenged(HttpServerSessionMode sessionMode) | ||
| 116 | + { | ||
| 117 | + await StartAsync(sessionMode); | ||
| 118 | + | ||
| 119 | + using var response = await PostAsync(InitializeRequest); | ||
| 120 | + | ||
| 121 | + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + [Theory] | ||
| 125 | + [InlineData(HttpServerSessionMode.Stateful)] | ||
| 126 | + [InlineData(HttpServerSessionMode.StatefulForInitializeClients)] | ||
| 127 | + [InlineData(HttpServerSessionMode.Stateless)] | ||
| 128 | + public async Task Initialize_WithCredentials_Succeeds(HttpServerSessionMode sessionMode) | ||
| 129 | + { | ||
| 130 | + await StartAsync(sessionMode); | ||
| 131 | + | ||
| 132 | + using var response = await PostAsync(InitializeRequest, user: "alice"); | ||
| 133 | + | ||
| 134 | + Assert.Equal(HttpStatusCode.OK, response.StatusCode); | ||
| 135 | + | ||
| 136 | + // Stateless mode keeps the endpoint free of server state, so only the session-based modes mint an id. | ||
| 137 | + if (sessionMode is HttpServerSessionMode.Stateless) | ||
| 138 | + { | ||
| 139 | + Assert.False(response.Headers.Contains("mcp-session-id")); | ||
| 140 | + } | ||
| 141 | + else | ||
| 142 | + { | ||
| 143 | + Assert.False(string.IsNullOrEmpty(GetSessionId(response))); | ||
| 144 | + } | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + [Fact] | ||
| 148 | + public async Task StatefulSession_FollowUpRequests_RequireCredentials() | ||
| 149 | + { | ||
| 150 | + await StartAsync(HttpServerSessionMode.Stateful); | ||
| 151 | + | ||
| 152 | + using var initializeResponse = await PostAsync(InitializeRequest, user: "alice"); | ||
| 153 | + var sessionId = GetSessionId(initializeResponse); | ||
| 154 | + Assert.False(string.IsNullOrEmpty(sessionId)); | ||
| 155 | + | ||
| 156 | + using var authorizedResponse = await PostAsync(ListToolsRequest, user: "alice", sessionId: sessionId); | ||
| 157 | + var result = AssertType<ListToolsResult>((await ReadSingleSseResponseAsync(authorizedResponse)).Result); | ||
| 158 | + Assert.Contains(result.Tools, tool => tool.Name == "echo"); | ||
| 159 | + | ||
| 160 | + // The session id identifies the session; it must not authenticate the caller. Without credentials | ||
| 161 | + // the authorization middleware has to reject the request before the MCP handler sees the session. | ||
| 162 | + using var unauthenticatedResponse = await PostAsync(ListToolsRequest, sessionId: sessionId); | ||
| 163 | + Assert.Equal(HttpStatusCode.Unauthorized, unauthenticatedResponse.StatusCode); | ||
| 164 | + } | ||
| 165 | + | ||
| 166 | + [Fact] | ||
| 167 | + public async Task ResourceMetadata_RemainsReachable_WhileTheMcpEndpointIsChallenged() | ||
| 168 | + { | ||
| 169 | + // Mount the endpoint at /mcp, the shape a deployment behind an ingress typically uses: the metadata | ||
| 170 | + // document then mirrors the resource path, and the challenge advertises that mirrored document. | ||
| 171 | + await StartAsync(HttpServerSessionMode.Stateful, advertiseResourceMetadata: true, path: "/mcp"); | ||
| 172 | + | ||
| 173 | + // Discovery is what an unauthenticated client does first, so the metadata document has to be served | ||
| 174 | + // without credentials even though every request to the MCP endpoint is challenged. | ||
| 175 | + using var metadataResponse = await HttpClient.GetAsync( | ||
| 176 | + "/.well-known/oauth-protected-resource/mcp", TestContext.Current.CancellationToken); | ||
| 177 | + | ||
| 178 | + Assert.Equal(HttpStatusCode.OK, metadataResponse.StatusCode); | ||
| 179 | + | ||
| 180 | + var metadata = await metadataResponse.Content.ReadFromJsonAsync<ProtectedResourceMetadata>( | ||
| 181 | + McpJsonUtilities.DefaultOptions, TestContext.Current.CancellationToken); | ||
| 182 | + | ||
| 183 | + Assert.NotNull(metadata); | ||
| 184 | + Assert.Equal($"{McpEndpointUrl}/mcp", metadata.Resource); | ||
| 185 | + Assert.Equal([AuthorizationServerUrl], metadata.AuthorizationServers); | ||
| 186 | + | ||
| 187 | + // The challenge has to tell the client where the metadata document lives, otherwise it cannot | ||
| 188 | + // discover the authorization server from the 401 alone. | ||
| 189 | + using var challengedResponse = await PostAsync(InitializeRequest, path: "/mcp"); | ||
| 190 | + | ||
| 191 | + Assert.Equal(HttpStatusCode.Unauthorized, challengedResponse.StatusCode); | ||
| 192 | + var challenge = Assert.Single(challengedResponse.Headers.WwwAuthenticate); | ||
| 193 | + Assert.Equal("Bearer", challenge.Scheme); | ||
| 194 | + Assert.Contains( | ||
| 195 | + $"resource_metadata=\"{McpEndpointUrl}/.well-known/oauth-protected-resource/mcp\"", | ||
| 196 | + challenge.Parameter); | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + private Task<HttpResponseMessage> PostAsync(string json, string? user = null, string? sessionId = null, string path = "") | ||
| 200 | + { | ||
| 201 | + var request = new HttpRequestMessage(HttpMethod.Post, path) | ||
| 202 | + { | ||
| 203 | + Content = new StringContent(json, Encoding.UTF8, "application/json"), | ||
| 204 | + }; | ||
| 205 | + | ||
| 206 | + if (user is not null) | ||
| 207 | + { | ||
| 208 | + request.Headers.Add(UserHeaderName, user); | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + if (sessionId is not null) | ||
| 212 | + { | ||
| 213 | + request.Headers.Add("mcp-session-id", sessionId); | ||
| 214 | + } | ||
| 215 | + | ||
| 216 | + return HttpClient.SendAsync(request, TestContext.Current.CancellationToken); | ||
| 217 | + } | ||
| 218 | + | ||
| 219 | + private static string? GetSessionId(HttpResponseMessage response) | ||
| 220 | + => response.Headers.TryGetValues("mcp-session-id", out var values) ? Assert.Single(values) : null; | ||
| 221 | + | ||
| 222 | + private static async Task<JsonRpcResponse> ReadSingleSseResponseAsync(HttpResponseMessage response) | ||
| 223 | + { | ||
| 224 | + Assert.Equal(HttpStatusCode.OK, response.StatusCode); | ||
| 225 | + Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType); | ||
| 226 | + | ||
| 227 | + var responseStream = await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken); | ||
| 228 | + var payloads = new List<string>(); | ||
| 229 | + | ||
| 230 | + await foreach (var sseItem in SseParser.Create(responseStream).EnumerateAsync(TestContext.Current.CancellationToken)) | ||
| 231 | + { | ||
| 232 | + Assert.Equal("message", sseItem.EventType); | ||
| 233 | + payloads.Add(sseItem.Data); | ||
| 234 | + } | ||
| 235 | + | ||
| 236 | + var jsonRpcResponse = JsonSerializer.Deserialize( | ||
| 237 | + Assert.Single(payloads), | ||
| 238 | + (JsonTypeInfo<JsonRpcResponse>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcResponse))); | ||
| 239 | + | ||
| 240 | + Assert.NotNull(jsonRpcResponse); | ||
| 241 | + return jsonRpcResponse; | ||
| 242 | + } | ||
| 243 | + | ||
| 244 | + private static T AssertType<T>(JsonNode? jsonNode) | ||
| 245 | + { | ||
| 246 | + var value = JsonSerializer.Deserialize( | ||
| 247 | + jsonNode, (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T))); | ||
| 248 | + | ||
| 249 | + Assert.NotNull(value); | ||
| 250 | + return value; | ||
| 251 | + } | ||
| 252 | + | ||
| 253 | + private static string InitializeRequest => """ | ||
| 254 | + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"MapMcpAuthorizationTests","version":"1.0.0"}}} | ||
| 255 | + """; | ||
| 256 | + | ||
| 257 | + private static string ListToolsRequest => """ | ||
| 258 | + {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} | ||
| 259 | + """; | ||
| 260 | + | ||
| 261 | + /// <summary> | ||
| 262 | + /// Authenticates a caller from a header, so tests can assert both the challenged and the authorized path | ||
| 263 | + /// without standing up a token issuer. | ||
| 264 | + /// </summary> | ||
| 265 | + private sealed class HeaderUserAuthenticationHandler( | ||
| 266 | + IOptionsMonitor<AuthenticationSchemeOptions> options, | ||
| 267 | + ILoggerFactory logger, | ||
| 268 | + UrlEncoder encoder) | ||
| 269 | + : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) | ||
| 270 | + { | ||
| 271 | + protected override Task<AuthenticateResult> HandleAuthenticateAsync() | ||
| 272 | + { | ||
| 273 | + if (!Request.Headers.TryGetValue(UserHeaderName, out var user) || string.IsNullOrEmpty(user)) | ||
| 274 | + { | ||
| 275 | + return Task.FromResult(AuthenticateResult.NoResult()); | ||
| 276 | + } | ||
| 277 | + | ||
| 278 | + var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, user.ToString())], TestScheme); | ||
| 279 | + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), TestScheme))); | ||
| 280 | + } | ||
| 281 | + } | ||
| 282 | + } | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments