|
1 | 1 | using Microsoft.AspNetCore.Authentication; |
2 | 2 | using Microsoft.AspNetCore.Authentication.JwtBearer; |
| 3 | +using Microsoft.AspNetCore.Authorization; |
3 | 4 | using Microsoft.AspNetCore.Builder; |
4 | 5 | using Microsoft.AspNetCore.Http; |
5 | 6 | using Microsoft.AspNetCore.WebUtilities; |
|
8 | 9 | using ModelContextProtocol.AspNetCore.Authentication; |
9 | 10 | using ModelContextProtocol.Authentication; |
10 | 11 | using ModelContextProtocol.Client; |
| 12 | +using ModelContextProtocol.Protocol; |
11 | 13 | using ModelContextProtocol.Server; |
12 | 14 | using System.Net; |
13 | 15 | using System.Net.Http.Json; |
14 | 16 | using System.Security.Claims; |
| 17 | +using System.Text.Json; |
15 | 18 | using Xunit.Sdk; |
16 | 19 |
|
17 | 20 | namespace ModelContextProtocol.AspNetCore.Tests.OAuth; |
@@ -211,32 +214,66 @@ public async Task CanAuthenticate_WithTokenRefresh() |
211 | 214 | { |
212 | 215 | var hasForcedRefresh = false; |
213 | 216 |
|
214 | | - Builder.Services.AddHttpContextAccessor(); |
215 | 217 | Builder.Services.AddMcpServer(options => |
| 218 | + { |
| 219 | + options.ToolCollection = new(); |
| 220 | + }); |
| 221 | + |
| 222 | + // Wait for the OAuth server to be ready |
| 223 | + await TestOAuthServer.ServerStarted.WaitAsync(TestContext.Current.CancellationToken); |
| 224 | + |
| 225 | + Builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options => |
| 226 | + { |
| 227 | + options.TokenValidationParameters.ValidAudience = McpServerUrl; |
| 228 | + }); |
| 229 | + |
| 230 | + var app = Builder.Build(); |
| 231 | + |
| 232 | + // Add middleware to intercept list tools requests and force a token refresh on the first call |
| 233 | + app.Use(async (context, next) => |
| 234 | + { |
| 235 | + if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/" && !hasForcedRefresh) |
216 | 236 | { |
217 | | - options.ToolCollection = new(); |
218 | | - }) |
219 | | - .AddListToolsFilter(next => |
220 | | - { |
221 | | - return async (mcpContext, cancellationToken) => |
| 237 | + // Enable buffering so we can read the request body multiple times |
| 238 | + context.Request.EnableBuffering(); |
| 239 | + |
| 240 | + try |
222 | 241 | { |
223 | | - if (!hasForcedRefresh) |
| 242 | + // Read the request body to check if it's calling tools/list |
| 243 | + var message = await JsonSerializer.DeserializeAsync( |
| 244 | + context.Request.Body, |
| 245 | + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), |
| 246 | + context.RequestAborted) as JsonRpcMessage; |
| 247 | + |
| 248 | + // Reset the request body position so MapMcp can read it |
| 249 | + context.Request.Body.Position = 0; |
| 250 | + |
| 251 | + // Check if this is a tools/list request |
| 252 | + if (message is JsonRpcRequest request && request.Method == "tools/list") |
224 | 253 | { |
225 | 254 | hasForcedRefresh = true; |
226 | 255 |
|
227 | | - var httpContext = mcpContext.Services!.GetRequiredService<IHttpContextAccessor>().HttpContext!; |
228 | | - await httpContext.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme); |
229 | | - await httpContext.Response.CompleteAsync(); |
230 | | - throw new Exception("This exception will not impact the client because the response has already been completed."); |
| 256 | + // Return 401 to force token refresh |
| 257 | + await context.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme); |
| 258 | + await context.Response.StartAsync(context.RequestAborted); |
| 259 | + await context.Response.Body.FlushAsync(context.RequestAborted); |
| 260 | + return; // Short-circuit, don't call next() |
231 | 261 | } |
232 | | - else |
233 | | - { |
234 | | - return await next(mcpContext, cancellationToken); |
235 | | - } |
236 | | - }; |
237 | | - }); |
| 262 | + } |
| 263 | + catch (JsonException) |
| 264 | + { |
| 265 | + // If we can't deserialize, let MapMcp handle it |
| 266 | + context.Request.Body.Position = 0; |
| 267 | + } |
| 268 | + } |
238 | 269 |
|
239 | | - await using var app = await StartMcpServerAsync(); |
| 270 | + await next(context); |
| 271 | + }); |
| 272 | + |
| 273 | + app.MapMcp().RequireAuthorization(new AuthorizeAttribute()); |
| 274 | + await app.StartAsync(TestContext.Current.CancellationToken); |
| 275 | + |
| 276 | + await using var _ = app; |
240 | 277 |
|
241 | 278 | await using var transport = new HttpClientTransport(new() |
242 | 279 | { |
@@ -451,29 +488,86 @@ public async Task AuthorizationFlow_UsesScopeFromForbiddenHeader() |
451 | 488 | { |
452 | 489 | var adminScopes = "admin:read admin:write"; |
453 | 490 |
|
454 | | - Builder.Services.AddHttpContextAccessor(); |
455 | 491 | Builder.Services.AddMcpServer() |
456 | 492 | .WithTools([ |
457 | 493 | McpServerTool.Create([McpServerTool(Name = "admin-tool")] |
458 | | - async (IServiceProvider serviceProvider, ClaimsPrincipal user) => |
| 494 | + (ClaimsPrincipal user) => |
459 | 495 | { |
460 | | - if (!user.HasClaim("scope", adminScopes)) |
461 | | - { |
462 | | - var httpContext = serviceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext!; |
463 | | - httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; |
464 | | - httpContext.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{adminScopes}\""; |
465 | | - await httpContext.Response.CompleteAsync(); |
466 | | - |
467 | | - throw new Exception("This exception will not impact the client because the response has already been completed."); |
468 | | - } |
469 | | - |
| 496 | + // Tool now just checks if user has the required scopes |
| 497 | + // If they don't, it shouldn't get here due to middleware |
| 498 | + Assert.True(user.HasClaim("scope", adminScopes), "User should have admin scopes when tool executes"); |
470 | 499 | return "Admin tool executed."; |
471 | 500 | }), |
472 | 501 | ]); |
473 | 502 |
|
474 | 503 | string? requestedScope = null; |
475 | 504 |
|
476 | | - await using var app = await StartMcpServerAsync(); |
| 505 | + // Wait for the OAuth server to be ready |
| 506 | + await TestOAuthServer.ServerStarted.WaitAsync(TestContext.Current.CancellationToken); |
| 507 | + |
| 508 | + Builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options => |
| 509 | + { |
| 510 | + options.TokenValidationParameters.ValidAudience = McpServerUrl; |
| 511 | + }); |
| 512 | + |
| 513 | + var app = Builder.Build(); |
| 514 | + |
| 515 | + // Add middleware to intercept requests and check for admin-tool calls |
| 516 | + app.Use(async (context, next) => |
| 517 | + { |
| 518 | + if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/") |
| 519 | + { |
| 520 | + // Enable buffering so we can read the request body multiple times |
| 521 | + context.Request.EnableBuffering(); |
| 522 | + |
| 523 | + try |
| 524 | + { |
| 525 | + // Read the request body to check if it's calling admin-tool |
| 526 | + var message = await JsonSerializer.DeserializeAsync( |
| 527 | + context.Request.Body, |
| 528 | + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), |
| 529 | + context.RequestAborted) as JsonRpcMessage; |
| 530 | + |
| 531 | + // Reset the request body position so MapMcp can read it |
| 532 | + context.Request.Body.Position = 0; |
| 533 | + |
| 534 | + // Check if this is a tools/call request for admin-tool |
| 535 | + if (message is JsonRpcRequest request && request.Method == "tools/call") |
| 536 | + { |
| 537 | + var toolCallParams = JsonSerializer.Deserialize( |
| 538 | + request.Params, |
| 539 | + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams; |
| 540 | + |
| 541 | + if (toolCallParams?.Name == "admin-tool") |
| 542 | + { |
| 543 | + // Check if user has required scopes |
| 544 | + var user = context.User; |
| 545 | + if (!user.HasClaim("scope", adminScopes)) |
| 546 | + { |
| 547 | + // User lacks required scopes, return 403 before MapMcp processes the request |
| 548 | + context.Response.StatusCode = StatusCodes.Status403Forbidden; |
| 549 | + context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{adminScopes}\""; |
| 550 | + await context.Response.StartAsync(context.RequestAborted); |
| 551 | + await context.Response.Body.FlushAsync(context.RequestAborted); |
| 552 | + return; // Short-circuit, don't call next() |
| 553 | + } |
| 554 | + } |
| 555 | + } |
| 556 | + } |
| 557 | + catch (JsonException) |
| 558 | + { |
| 559 | + // If we can't deserialize, let MapMcp handle it |
| 560 | + context.Request.Body.Position = 0; |
| 561 | + } |
| 562 | + } |
| 563 | + |
| 564 | + await next(context); |
| 565 | + }); |
| 566 | + |
| 567 | + app.MapMcp().RequireAuthorization(new AuthorizeAttribute()); |
| 568 | + await app.StartAsync(TestContext.Current.CancellationToken); |
| 569 | + |
| 570 | + await using var _ = app; |
477 | 571 |
|
478 | 572 | await using var transport = new HttpClientTransport(new() |
479 | 573 | { |
|
0 commit comments