From 055001bfab8a46244da02b1d87b6053d47d75918 Mon Sep 17 00:00:00 2001 From: Erwin Date: Thu, 22 Jan 2026 21:29:56 +0100 Subject: [PATCH 1/3] fix --- .../McpOAuthAuthenticationMiddleware.cs | 133 +++++++++--------- Sample/Extensions/DemoServiceExtensions.cs | 6 + .../OAuthChallengeTokenValidationTests.cs | 76 ++++++++++ 3 files changed, 146 insertions(+), 69 deletions(-) create mode 100644 Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs diff --git a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs b/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs index b1d59a0..43e4268 100644 --- a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs +++ b/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Linq; using MCPify.Core; using MCPify.Core.Auth; using Microsoft.AspNetCore.Http; @@ -10,9 +12,6 @@ public class McpOAuthAuthenticationMiddleware { private readonly RequestDelegate _next; - /// - /// Key for storing token validation result in HttpContext.Items for downstream use. - /// public const string TokenValidationResultKey = "McpTokenValidationResult"; public McpOAuthAuthenticationMiddleware(RequestDelegate next) @@ -22,56 +21,46 @@ public McpOAuthAuthenticationMiddleware(RequestDelegate next) public async Task InvokeAsync(HttpContext context) { - // Skip check for metadata endpoint and other non-MCP endpoints var path = context.Request.Path; if (path.StartsWithSegments("/.well-known") || path.StartsWithSegments("/swagger") || path.StartsWithSegments("/health") || - path.StartsWithSegments("/connect") || // OpenIddict or Auth endpoints - path.StartsWithSegments("/auth")) // Callback paths + path.StartsWithSegments("/connect") || + path.StartsWithSegments("/auth")) { await _next(context); return; } - // Check if OAuth is configured - var oauthStore = context.RequestServices.GetService(); var options = context.RequestServices.GetService(); + var oauthStore = context.RequestServices.GetService(); + + var oauthConfigurations = oauthStore?.GetConfigurations().ToList() ?? new List(); + var validationOptions = options?.TokenValidation; + + var challengeScopes = BuildChallengeScopes(oauthConfigurations, validationOptions); + var authRequired = oauthConfigurations.Count > 0 || (validationOptions?.EnableJwtValidation == true); - if (oauthStore == null || !oauthStore.GetConfigurations().Any()) + if (!authRequired) { await _next(context); return; } - var accessor = context.RequestServices.GetService(); var resourceUrl = GetResourceUrl(context, options); + var accessor = context.RequestServices.GetService(); - // Check for Authorization header - string? authorization = context.Request.Headers[HeaderNames.Authorization]; - if (string.IsNullOrEmpty(authorization) || !authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - // No token - return 401 challenge - await WriteChallengeResponse(context, oauthStore, resourceUrl, null, null); - return; - } - - // Extract token - var token = authorization.Substring("Bearer ".Length).Trim(); - if (string.IsNullOrEmpty(token)) + if (!TryGetBearerToken(context, out var token)) { - await WriteChallengeResponse(context, oauthStore, resourceUrl, null, null); + await WriteChallengeResponse(context, resourceUrl, challengeScopes, null, null); return; } - // Set token on accessor for downstream use if (accessor != null) { accessor.AccessToken = token; } - // Perform token validation if enabled - var validationOptions = options?.TokenValidation; if (validationOptions?.EnableJwtValidation == true) { var validator = context.RequestServices.GetService(); @@ -82,31 +71,24 @@ public async Task InvokeAsync(HttpContext context) : null; var validationResult = await validator.ValidateAsync(token, expectedAudience, context.RequestAborted); - - // Store validation result for downstream use context.Items[TokenValidationResultKey] = validationResult; if (!validationResult.IsValid) { - // Token is invalid (expired, malformed, wrong audience) - return 401 - await WriteInvalidTokenResponse(context, oauthStore, resourceUrl, + await WriteChallengeResponse(context, resourceUrl, challengeScopes, validationResult.ErrorCode ?? "invalid_token", validationResult.ErrorDescription ?? "Token validation failed"); return; } - // Validate scopes if enabled if (validationOptions.ValidateScopes) { var scopeStore = context.RequestServices.GetService(); if (scopeStore != null) { - // Use default validation (no specific tool name available at middleware level) var scopeResult = scopeStore.ValidateScopesForTool("*", validationResult.Scopes); - if (!scopeResult.IsValid) { - // Token is valid but lacks required scopes - return 403 await WriteInsufficientScopeResponse(context, resourceUrl, scopeResult.MissingScopes); return; } @@ -134,50 +116,61 @@ private static string GetResourceUrl(HttpContext context, McpifyOptions? options return resourceUrl.TrimEnd('/'); } - private static async Task WriteChallengeResponse( - HttpContext context, - OAuthConfigurationStore oauthStore, - string resourceUrl, - string? errorCode, - string? errorDescription) + private static IReadOnlyList BuildChallengeScopes( + IReadOnlyCollection configurations, + TokenValidationOptions? validationOptions) { - var metadataUrl = $"{resourceUrl}/.well-known/oauth-protected-resource"; + var scopes = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var configuration in configurations) + { + foreach (var scope in configuration.Scopes.Keys) + { + scopes.Add(scope); + } + } - // Collect all scopes from OAuth configurations per MCP spec - var allScopes = oauthStore.GetConfigurations() - .SelectMany(c => c.Scopes.Keys) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + if (validationOptions?.DefaultRequiredScopes != null) + { + foreach (var scope in validationOptions.DefaultRequiredScopes) + { + scopes.Add(scope); + } + } - context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return scopes.ToList(); + } + + private static bool TryGetBearerToken(HttpContext context, out string token) + { + token = string.Empty; + string? authorization = context.Request.Headers[HeaderNames.Authorization]; + + if (string.IsNullOrEmpty(authorization) || + !authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return false; + } - // Build WWW-Authenticate header per MCP Authorization spec - var wwwAuthenticate = BuildWwwAuthenticateHeader(metadataUrl, allScopes, errorCode, errorDescription); - context.Response.Headers[HeaderNames.WWWAuthenticate] = wwwAuthenticate; + token = authorization.Substring("Bearer ".Length).Trim(); + return !string.IsNullOrEmpty(token); } - private static async Task WriteInvalidTokenResponse( + private static Task WriteChallengeResponse( HttpContext context, - OAuthConfigurationStore oauthStore, string resourceUrl, - string errorCode, - string errorDescription) + IReadOnlyList scopes, + string? errorCode, + string? errorDescription) { - var metadataUrl = $"{resourceUrl}/.well-known/oauth-protected-resource"; - - // Collect all scopes from OAuth configurations - var allScopes = oauthStore.GetConfigurations() - .SelectMany(c => c.Scopes.Keys) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - - var wwwAuthenticate = BuildWwwAuthenticateHeader(metadataUrl, allScopes, errorCode, errorDescription); - context.Response.Headers[HeaderNames.WWWAuthenticate] = wwwAuthenticate; + var metadataUrl = $"{resourceUrl}/.well-known/oauth-protected-resource"; + context.Response.Headers[HeaderNames.WWWAuthenticate] = + BuildWwwAuthenticateHeader(metadataUrl, scopes, errorCode, errorDescription); + return Task.CompletedTask; } - private static async Task WriteInsufficientScopeResponse( + private static Task WriteInsufficientScopeResponse( HttpContext context, string resourceUrl, IReadOnlyList requiredScopes) @@ -186,7 +179,6 @@ private static async Task WriteInsufficientScopeResponse( context.Response.StatusCode = StatusCodes.Status403Forbidden; - // Build WWW-Authenticate header for insufficient_scope per RFC 6750 Section 3.1 var parts = new List { "Bearer", @@ -201,6 +193,7 @@ private static async Task WriteInsufficientScopeResponse( } context.Response.Headers[HeaderNames.WWWAuthenticate] = string.Join(", ", parts); + return Task.CompletedTask; } private static string BuildWwwAuthenticateHeader( @@ -209,7 +202,10 @@ private static string BuildWwwAuthenticateHeader( string? errorCode, string? errorDescription) { - var parts = new List { $"Bearer resource_metadata=\"{metadataUrl}\"" }; + var parts = new List + { + $"Bearer resource_metadata=\"{metadataUrl}\"" + }; if (!string.IsNullOrEmpty(errorCode)) { @@ -218,7 +214,6 @@ private static string BuildWwwAuthenticateHeader( if (!string.IsNullOrEmpty(errorDescription)) { - // Escape quotes in description var escapedDescription = errorDescription.Replace("\"", "\\\""); parts.Add($"error_description=\"{escapedDescription}\""); } diff --git a/Sample/Extensions/DemoServiceExtensions.cs b/Sample/Extensions/DemoServiceExtensions.cs index 8f53b4d..029a07f 100644 --- a/Sample/Extensions/DemoServiceExtensions.cs +++ b/Sample/Extensions/DemoServiceExtensions.cs @@ -143,6 +143,12 @@ public static IServiceCollection AddDemoMcpify(this IServiceCollection services, options.Transport = transport; options.ResourceUrlOverride = baseUrl; + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, + ValidateAudience = true + }; + // Expose the local API (which is now the "Real" API) options.LocalEndpoints = new() { diff --git a/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs b/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs new file mode 100644 index 0000000..4b5a033 --- /dev/null +++ b/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs @@ -0,0 +1,76 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using MCPify.Core; +using MCPify.Core.Auth; +using MCPify.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MCPify.Tests.Integration; + +public class OAuthChallengeTokenValidationTests +{ + [Fact] + public async Task PostWithoutSession_ReturnsUnauthorizedChallenge_WhenTokenValidationEnabled() + { + using var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddLogging(); + services.AddRouting(); + services.AddMcpify(options => + { + options.Transport = McpTransportType.Http; + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, + ValidateAudience = true + }; + }); + }) + .Configure(app => + { + app.UseRouting(); + app.UseMcpifyContext(); + app.UseMcpifyOAuth(); + app.UseEndpoints(endpoints => + { + endpoints.MapMcpifyEndpoint(); + }); + }); + }) + .StartAsync(); + + var options = host.Services.GetRequiredService(); + Assert.True(options.TokenValidation?.EnableJwtValidation, "Token validation should be enabled"); + var validationOptions = host.Services.GetRequiredService(); + Assert.True(validationOptions.EnableJwtValidation, "TokenValidationOptions from DI should have EnableJwtValidation true"); + + var client = host.GetTestClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/") + { + Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\",\"params\":{}}", Encoding.UTF8, "application/json") + }; + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + var authenticateHeader = string.Join(" | ", response.Headers.WwwAuthenticate.Select(h => h.ToString())); + Assert.True(response.StatusCode == HttpStatusCode.Unauthorized, + $"Expected 401 challenge, got {(int)response.StatusCode} {response.StatusCode}. Headers: {authenticateHeader}. Body: {body}"); + + Assert.Contains(response.Headers.WwwAuthenticate, header => + string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase)); + } +} From 64040612f5e2dbd4abed3cf3421bed2394e2e7b6 Mon Sep 17 00:00:00 2001 From: Erwin Date: Thu, 22 Jan 2026 21:39:10 +0100 Subject: [PATCH 2/3] improvements --- .../McpOAuthAuthenticationMiddleware.cs | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs b/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs index 43e4268..0d50ef2 100644 --- a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs +++ b/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs @@ -1,9 +1,5 @@ -using System.Collections.Generic; -using System.Linq; using MCPify.Core; using MCPify.Core.Auth; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; namespace MCPify.Hosting; @@ -35,11 +31,12 @@ public async Task InvokeAsync(HttpContext context) var options = context.RequestServices.GetService(); var oauthStore = context.RequestServices.GetService(); - var oauthConfigurations = oauthStore?.GetConfigurations().ToList() ?? new List(); + var oauthConfigurations = oauthStore?.GetConfigurations().ToList() ?? []; var validationOptions = options?.TokenValidation; + var tokenValidationEnabled = validationOptions?.EnableJwtValidation == true; var challengeScopes = BuildChallengeScopes(oauthConfigurations, validationOptions); - var authRequired = oauthConfigurations.Count > 0 || (validationOptions?.EnableJwtValidation == true); + var authRequired = oauthConfigurations.Count > 0 || tokenValidationEnabled; if (!authRequired) { @@ -61,7 +58,7 @@ public async Task InvokeAsync(HttpContext context) accessor.AccessToken = token; } - if (validationOptions?.EnableJwtValidation == true) + if (tokenValidationEnabled && validationOptions != null) { var validator = context.RequestServices.GetService(); if (validator != null) @@ -120,6 +117,14 @@ private static IReadOnlyList BuildChallengeScopes( IReadOnlyCollection configurations, TokenValidationOptions? validationOptions) { + var defaultScopes = validationOptions?.DefaultRequiredScopes; + var hasDefaultScopes = defaultScopes is { Count: > 0 }; + + if (configurations.Count == 0 && !hasDefaultScopes) + { + return Array.Empty(); + } + var scopes = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var configuration in configurations) @@ -130,9 +135,9 @@ private static IReadOnlyList BuildChallengeScopes( } } - if (validationOptions?.DefaultRequiredScopes != null) + if (hasDefaultScopes && defaultScopes != null) { - foreach (var scope in validationOptions.DefaultRequiredScopes) + foreach (var scope in defaultScopes) { scopes.Add(scope); } @@ -202,7 +207,12 @@ private static string BuildWwwAuthenticateHeader( string? errorCode, string? errorDescription) { - var parts = new List + if (string.IsNullOrEmpty(errorCode) && string.IsNullOrEmpty(errorDescription) && scopes.Count == 0) + { + return $"Bearer resource_metadata=\"{metadataUrl}\""; + } + + var parts = new List(4) { $"Bearer resource_metadata=\"{metadataUrl}\"" }; From 18350b8c8f3c27439ee04eb5586b8f3a9d2dacdb Mon Sep 17 00:00:00 2001 From: Erwin Date: Thu, 22 Jan 2026 21:50:58 +0100 Subject: [PATCH 3/3] update packages --- Sample/MCPify.Sample.csproj | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Sample/MCPify.Sample.csproj b/Sample/MCPify.Sample.csproj index 96ff7af..83159b9 100644 --- a/Sample/MCPify.Sample.csproj +++ b/Sample/MCPify.Sample.csproj @@ -17,28 +17,28 @@ - - - - + + + + - - - + + + - + - +