FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

test(aspnetcore): cover RequireAuthorization() on the MCP endpoint ac… · modelcontextprotocol/csharp-sdk@d3571d6 · GitHub

Repository navigation

Commit d3571d6

Browse files
committed
test(aspnetcore): cover RequireAuthorization() on the MCP endpoint across session modes
1 parent c40ee04 commit d3571d6

1 file changed

Lines changed: 282 additions & 0 deletions

File tree

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff 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+
}

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL