From 055001bfab8a46244da02b1d87b6053d47d75918 Mon Sep 17 00:00:00 2001 From: Erwin Date: Thu, 22 Jan 2026 21:29:56 +0100 Subject: [PATCH 1/8] 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/8] 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/8] 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 @@ - - - - + + + + - - - + + + - + - + From 1ab617e588f4010b11335e7cc3d31bd07dec3964 Mon Sep 17 00:00:00 2001 From: Erwin Date: Sat, 24 Jan 2026 11:05:18 +0100 Subject: [PATCH 4/8] simplify tests with WebApplication builder --- .../ClientCredentialsAuthenticationTests.cs | 36 ++- .../OAuthChallengeTokenValidationTests.cs | 57 +++-- .../Integration/OAuthMetadataEndpointTests.cs | 43 ++-- .../MCPify.Tests/Integration/TestApiServer.cs | 53 ++--- .../Integration/TestOAuthServer.cs | 207 +++++++++--------- 5 files changed, 183 insertions(+), 213 deletions(-) diff --git a/Tests/MCPify.Tests/ClientCredentialsAuthenticationTests.cs b/Tests/MCPify.Tests/ClientCredentialsAuthenticationTests.cs index c545db3..60a90f3 100644 --- a/Tests/MCPify.Tests/ClientCredentialsAuthenticationTests.cs +++ b/Tests/MCPify.Tests/ClientCredentialsAuthenticationTests.cs @@ -105,28 +105,22 @@ public TestClientCredentialsServer() { var port = GetRandomUnusedPort(); BaseUrl = $"http://localhost:{port}"; - _host = Host.CreateDefaultBuilder() - .ConfigureWebHostDefaults(builder => + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls(BaseUrl); + + var app = builder.Build(); + app.MapPost("/token", async context => + { + await context.Response.WriteAsJsonAsync(new { - builder.UseUrls(BaseUrl); - builder.Configure(app => - { - app.UseRouting(); - app.UseEndpoints(endpoints => - { - endpoints.MapPost("/token", async context => - { - await context.Response.WriteAsJsonAsync(new - { - access_token = "cc_token", - token_type = "Bearer", - expires_in = 3600 - }); - }); - }); - }); - }) - .Build(); + access_token = "cc_token", + token_type = "Bearer", + expires_in = 3600 + }); + }); + + _host = app; } public async Task StartAsync() => await _host.StartAsync(); diff --git a/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs b/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs index 4b5a033..ba110f7 100644 --- a/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs @@ -17,37 +17,7 @@ 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(); + await using var host = await CreateHostAsync(); var options = host.Services.GetRequiredService(); Assert.True(options.TokenValidation?.EnableJwtValidation, "Token validation should be enabled"); @@ -73,4 +43,29 @@ public async Task PostWithoutSession_ReturnsUnauthorizedChallenge_WhenTokenValid Assert.Contains(response.Headers.WwwAuthenticate, header => string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase)); } + + private static async Task CreateHostAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddLogging(); + builder.Services.AddMcpify(options => + { + options.Transport = McpTransportType.Http; + options.TokenValidation = new TokenValidationOptions + { + EnableJwtValidation = true, + ValidateAudience = true + }; + }); + + var app = builder.Build(); + app.UseMcpifyContext(); + app.UseMcpifyOAuth(); + app.MapMcpifyEndpoint(); + + await app.StartAsync(); + return app; + } } diff --git a/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs b/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs index c483dfa..3449ab9 100644 --- a/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs @@ -113,33 +113,24 @@ public async Task GetMetadata_FallsBackToAuthorizationUrlAuthority_WhenAuthoriza Assert.Contains("https://auth.example.com", metadata!.AuthorizationServers); } - private async Task CreateHostAsync(Action? configure = null, Action? configureOptions = null) + private static async Task CreateHostAsync(Action? configure = null, Action? configureOptions = null) { - return await new HostBuilder() - .ConfigureWebHost(webBuilder => - { - webBuilder - .UseTestServer() - .ConfigureServices(services => - { - services.AddMcpify(options => - { - configureOptions?.Invoke(options); - }); - services.AddLogging(); - services.AddRouting(); - }) - .Configure(app => - { - configure?.Invoke(app.ApplicationServices); - app.UseRouting(); - app.UseEndpoints(endpoints => - { - endpoints.MapMcpifyEndpoint(); - }); - }); - }) - .StartAsync(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddMcpify(options => + { + configureOptions?.Invoke(options); + }); + builder.Services.AddLogging(); + + var app = builder.Build(); + + configure?.Invoke(app.Services); + app.MapMcpifyEndpoint(); + + await app.StartAsync(); + return app; } private class ProtectedResourceMetadata diff --git a/Tests/MCPify.Tests/Integration/TestApiServer.cs b/Tests/MCPify.Tests/Integration/TestApiServer.cs index d92fbe5..a63178c 100644 --- a/Tests/MCPify.Tests/Integration/TestApiServer.cs +++ b/Tests/MCPify.Tests/Integration/TestApiServer.cs @@ -18,47 +18,42 @@ public TestApiServer() var port = GetRandomUnusedPort(); BaseUrl = $"http://localhost:{port}"; - _host = Host.CreateDefaultBuilder() - .ConfigureWebHostDefaults(builder => - { - builder.UseUrls(BaseUrl); - builder.Configure(ConfigureApp); - }) - .Build(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls(BaseUrl); + + var app = builder.Build(); + ConfigureApp(app); + _host = app; } public async Task StartAsync() => await _host.StartAsync(); public HttpClient CreateClient() => new() { BaseAddress = new Uri(BaseUrl) }; - private void ConfigureApp(IApplicationBuilder app) + private void ConfigureApp(WebApplication app) { - app.UseRouting(); - app.UseEndpoints(endpoints => + app.MapGet("/users/{id:int}", async context => { - endpoints.MapGet("/users/{id:int}", async context => + var id = int.Parse(context.Request.RouteValues["id"]?.ToString() ?? "0"); + await context.Response.WriteAsJsonAsync(new { - var id = int.Parse(context.Request.RouteValues["id"]?.ToString() ?? "0"); - await context.Response.WriteAsJsonAsync(new - { - id, - path = context.Request.Path.Value, - query = context.Request.QueryString.Value - }); + id, + path = context.Request.Path.Value, + query = context.Request.QueryString.Value }); + }); - endpoints.MapPost("/echo", async context => - { - using var reader = new StreamReader(context.Request.Body); - var body = await reader.ReadToEndAsync(); - await context.Response.WriteAsync(body); - }); + app.MapPost("/echo", async context => + { + using var reader = new StreamReader(context.Request.Body); + var body = await reader.ReadToEndAsync(); + await context.Response.WriteAsync(body); + }); - endpoints.MapGet("/auth-check", async context => - { - var auth = context.Request.Headers.Authorization.ToString(); - await context.Response.WriteAsJsonAsync(new { authorization = auth }); - }); + app.MapGet("/auth-check", async context => + { + var auth = context.Request.Headers.Authorization.ToString(); + await context.Response.WriteAsJsonAsync(new { authorization = auth }); }); } diff --git a/Tests/MCPify.Tests/Integration/TestOAuthServer.cs b/Tests/MCPify.Tests/Integration/TestOAuthServer.cs index 6ed25e7..8faf626 100644 --- a/Tests/MCPify.Tests/Integration/TestOAuthServer.cs +++ b/Tests/MCPify.Tests/Integration/TestOAuthServer.cs @@ -47,13 +47,12 @@ public TestOAuthServer() _jwk.KeyId = _signingKey.KeyId; _jwk.Alg = SecurityAlgorithms.RsaSha256; - _host = Host.CreateDefaultBuilder() - .ConfigureWebHostDefaults(builder => - { - builder.UseUrls(BaseUrl); - builder.Configure(ConfigureApp); - }) - .Build(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls(BaseUrl); + + var app = builder.Build(); + ConfigureApp(app); + _host = app; } public async Task StartAsync() => await _host.StartAsync(); @@ -68,137 +67,133 @@ public void AuthorizeDevice() public HttpClient CreateClient() => new() { BaseAddress = new Uri(BaseUrl) }; - private void ConfigureApp(IApplicationBuilder app) + private void ConfigureApp(WebApplication app) { - app.UseRouting(); - app.UseEndpoints(endpoints => + app.MapGet("/.well-known/openid-configuration", async context => { - endpoints.MapGet("/.well-known/openid-configuration", async context => + await context.Response.WriteAsJsonAsync(new { - await context.Response.WriteAsJsonAsync(new - { - issuer = BaseUrl, - authorization_endpoint = AuthorizationEndpoint, - token_endpoint = TokenEndpoint, - device_authorization_endpoint = DeviceCodeEndpoint, - jwks_uri = JwksEndpoint, - response_types_supported = new[] { "code" }, - grant_types_supported = new[] { "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code" }, - id_token_signing_alg_values_supported = new[] { SecurityAlgorithms.RsaSha256 }, - scopes_supported = new[] { "openid", "profile", "read_secrets" } - }); + issuer = BaseUrl, + authorization_endpoint = AuthorizationEndpoint, + token_endpoint = TokenEndpoint, + device_authorization_endpoint = DeviceCodeEndpoint, + jwks_uri = JwksEndpoint, + response_types_supported = new[] { "code" }, + grant_types_supported = new[] { "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code" }, + id_token_signing_alg_values_supported = new[] { SecurityAlgorithms.RsaSha256 }, + scopes_supported = new[] { "openid", "profile", "read_secrets" } }); + }); - endpoints.MapGet("/jwks", async context => - { - await context.Response.WriteAsJsonAsync(new { keys = new[] { _jwk } }); - }); + app.MapGet("/jwks", async context => + { + await context.Response.WriteAsJsonAsync(new { keys = new[] { _jwk } }); + }); + + app.MapGet("/authorize", async context => + { + var redirectUri = context.Request.Query["redirect_uri"]; + var state = context.Request.Query["state"]; + var code = "auth_code_" + Guid.NewGuid(); - endpoints.MapGet("/authorize", async context => + lock (_lock) { - var redirectUri = context.Request.Query["redirect_uri"]; - var state = context.Request.Query["state"]; - var code = "auth_code_" + Guid.NewGuid(); + _lastAuthCode = code; + _lastRefreshToken = "refresh_" + Guid.NewGuid(); + } - lock (_lock) - { - _lastAuthCode = code; - _lastRefreshToken = "refresh_" + Guid.NewGuid(); - } + context.Response.Redirect($"{redirectUri}?code={code}&state={state}"); + await Task.CompletedTask; + }); - context.Response.Redirect($"{redirectUri}?code={code}&state={state}"); - await Task.CompletedTask; - }); + app.MapPost("/token", async context => + { + var form = await context.Request.ReadFormAsync(); + var grantType = form["grant_type"].ToString(); - endpoints.MapPost("/token", async context => + if (grantType == "client_credentials") { - var form = await context.Request.ReadFormAsync(); - var grantType = form["grant_type"].ToString(); - - if (grantType == "client_credentials") + await context.Response.WriteAsJsonAsync(new { - await context.Response.WriteAsJsonAsync(new - { - access_token = ClientCredentialsToken, - token_type = "Bearer", - expires_in = 3600 - }); - return; - } + access_token = ClientCredentialsToken, + token_type = "Bearer", + expires_in = 3600 + }); + return; + } - if (grantType == "authorization_code") + if (grantType == "authorization_code") + { + if (!string.Equals(form["code"], _lastAuthCode, StringComparison.Ordinal)) { - if (!string.Equals(form["code"], _lastAuthCode, StringComparison.Ordinal)) - { - context.Response.StatusCode = (int)HttpStatusCode.BadRequest; - await context.Response.WriteAsync("Invalid code"); - return; - } - - await WriteTokenResponse(context); + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + await context.Response.WriteAsync("Invalid code"); return; } - if (grantType == "refresh_token") + await WriteTokenResponse(context); + return; + } + + if (grantType == "refresh_token") + { + await WriteTokenResponse(context); + return; + } + + if (grantType == "urn:ietf:params:oauth:grant-type:device_code") + { + if (!string.Equals(form["device_code"], _lastDeviceCode, StringComparison.Ordinal)) { - await WriteTokenResponse(context); + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + await context.Response.WriteAsync("{\"error\": \"invalid_device_code\"}"); return; } - if (grantType == "urn:ietf:params:oauth:grant-type:device_code") + if (!_deviceAuthorized) { - if (!string.Equals(form["device_code"], _lastDeviceCode, StringComparison.Ordinal)) - { - context.Response.StatusCode = (int)HttpStatusCode.BadRequest; - await context.Response.WriteAsync("{\"error\": \"invalid_device_code\"}"); - return; - } - - if (!_deviceAuthorized) - { - context.Response.StatusCode = (int)HttpStatusCode.BadRequest; - await context.Response.WriteAsync("{\"error\": \"authorization_pending\"}"); - return; - } - - await WriteTokenResponse(context); + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + await context.Response.WriteAsync("{\"error\": \"authorization_pending\"}"); return; } - context.Response.StatusCode = (int)HttpStatusCode.BadRequest; - await context.Response.WriteAsync("{\"error\": \"unsupported_grant_type\"}"); - }); + await WriteTokenResponse(context); + return; + } - endpoints.MapPost("/device/code", async context => - { - var deviceCode = "dc_" + Guid.NewGuid(); - var userCode = "UC-" + Random.Shared.Next(1000, 9999); + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + await context.Response.WriteAsync("{\"error\": \"unsupported_grant_type\"}"); + }); - lock (_lock) - { - _lastDeviceCode = deviceCode; - _deviceAuthorized = false; - } + app.MapPost("/device/code", async context => + { + var deviceCode = "dc_" + Guid.NewGuid(); + var userCode = "UC-" + Random.Shared.Next(1000, 9999); - await context.Response.WriteAsJsonAsync(new - { - device_code = deviceCode, - user_code = userCode, - verification_uri = VerificationEndpoint, - expires_in = 300, - interval = 1 - }); + lock (_lock) + { + _lastDeviceCode = deviceCode; + _deviceAuthorized = false; + } + + await context.Response.WriteAsJsonAsync(new + { + device_code = deviceCode, + user_code = userCode, + verification_uri = VerificationEndpoint, + expires_in = 300, + interval = 1 }); + }); - endpoints.MapGet("/device/verify", async context => + app.MapGet("/device/verify", async context => + { + lock (_lock) { - lock (_lock) - { - _deviceAuthorized = true; - } + _deviceAuthorized = true; + } - await context.Response.WriteAsync("Device authorized."); - }); + await context.Response.WriteAsync("Device authorized."); }); } From 82bf9bd2c4cccef94dd032ade1ef2e985a7d5bea Mon Sep 17 00:00:00 2001 From: Erwin Date: Sat, 24 Jan 2026 19:38:49 +0100 Subject: [PATCH 5/8] handle auth by ModelContextProtocol.AspNetCore --- .gitignore | 2 + MCPify/Core/Auth/IAccessTokenValidator.cs | 16 - MCPify/Core/Auth/JwtAccessTokenValidator.cs | 203 -------- MCPify/Core/Auth/ScopeRequirement.cs | 99 ---- MCPify/Core/Auth/ScopeRequirementStore.cs | 145 ------ MCPify/Core/Auth/TokenValidationOptions.cs | 58 --- MCPify/Core/Auth/TokenValidationResult.cs | 81 --- MCPify/Core/McpifyOptions.cs | 13 +- .../Hosting/McpAuthenticationOptionsSetup.cs | 112 +++++ .../McpOAuthAuthenticationMiddleware.cs | 238 --------- MCPify/Hosting/McpifyEndpointExtensions.cs | 9 +- MCPify/Hosting/McpifyServiceExtensions.cs | 53 +- MCPify/MCPify.csproj | 4 +- README.md | 107 +--- Sample/Extensions/DemoServiceExtensions.cs | 6 - Sample/Program.cs | 1 - Sample/README.md | 41 +- .../OAuthChallengeTokenValidationTests.cs | 48 +- .../Integration/OAuthMetadataEndpointTests.cs | 15 +- .../Integration/OAuthMiddlewareTests.cs | 349 ++++--------- .../OpenApiOAuthScopeIntegrationTests.cs | 461 ++++-------------- .../Unit/JwtAccessTokenValidatorTests.cs | 354 +------------- .../Unit/ScopeRequirementTests.cs | 366 ++------------ 23 files changed, 473 insertions(+), 2308 deletions(-) delete mode 100644 MCPify/Core/Auth/IAccessTokenValidator.cs delete mode 100644 MCPify/Core/Auth/JwtAccessTokenValidator.cs delete mode 100644 MCPify/Core/Auth/ScopeRequirement.cs delete mode 100644 MCPify/Core/Auth/ScopeRequirementStore.cs delete mode 100644 MCPify/Core/Auth/TokenValidationOptions.cs delete mode 100644 MCPify/Core/Auth/TokenValidationResult.cs create mode 100644 MCPify/Hosting/McpAuthenticationOptionsSetup.cs delete mode 100644 MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs diff --git a/.gitignore b/.gitignore index 74e9613..4d998de 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ demo_token.json ## misc AuthTokens/ Sample/AuthTokens/ + +.nuget/ \ No newline at end of file diff --git a/MCPify/Core/Auth/IAccessTokenValidator.cs b/MCPify/Core/Auth/IAccessTokenValidator.cs deleted file mode 100644 index 519e47c..0000000 --- a/MCPify/Core/Auth/IAccessTokenValidator.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace MCPify.Core.Auth; - -/// -/// Interface for validating access tokens. -/// -public interface IAccessTokenValidator -{ - /// - /// Validates an access token and returns the validation result. - /// - /// The access token to validate. - /// Optional expected audience value. If null, audience validation is skipped. - /// Cancellation token. - /// A containing the validation outcome and extracted claims. - Task ValidateAsync(string token, string? expectedAudience, CancellationToken cancellationToken = default); -} diff --git a/MCPify/Core/Auth/JwtAccessTokenValidator.cs b/MCPify/Core/Auth/JwtAccessTokenValidator.cs deleted file mode 100644 index c6f8dec..0000000 --- a/MCPify/Core/Auth/JwtAccessTokenValidator.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System.Text; -using System.Text.Json; - -namespace MCPify.Core.Auth; - -/// -/// JWT access token validator that parses and validates JWT tokens without signature verification. -/// This is suitable for tokens that have already been cryptographically validated by the authorization server. -/// Performs expiration, audience, and scope claim extraction. -/// -public class JwtAccessTokenValidator : IAccessTokenValidator -{ - private readonly TokenValidationOptions _options; - private static readonly string[] ScopeClaimNames = { "scope", "scp", "scopes" }; - - public JwtAccessTokenValidator(TokenValidationOptions options) - { - _options = options; - } - - public Task ValidateAsync(string token, string? expectedAudience, CancellationToken cancellationToken = default) - { - try - { - var parts = token.Split('.'); - if (parts.Length < 2) - { - return Task.FromResult(TokenValidationResult.Failure("invalid_token", "Token is not a valid JWT format")); - } - - var payloadJson = Base64UrlDecode(parts[1]); - using var doc = JsonDocument.Parse(payloadJson); - var root = doc.RootElement; - - // Extract claims - var subject = GetStringClaim(root, "sub"); - var issuer = GetStringClaim(root, "iss"); - var audiences = GetAudienceClaim(root); - var scopes = GetScopeClaim(root); - var expiresAt = GetExpirationClaim(root); - - // Validate expiration - if (expiresAt.HasValue) - { - var now = DateTimeOffset.UtcNow; - if (expiresAt.Value.Add(_options.ClockSkew) < now) - { - return Task.FromResult(TokenValidationResult.Failure("invalid_token", "Token has expired")); - } - } - - // Validate audience if requested - if (_options.ValidateAudience && !string.IsNullOrEmpty(expectedAudience)) - { - if (audiences.Count == 0 || !audiences.Any(a => string.Equals(a, expectedAudience, StringComparison.OrdinalIgnoreCase))) - { - return Task.FromResult(TokenValidationResult.Failure("invalid_token", $"Token audience does not match expected value: {expectedAudience}")); - } - } - - return Task.FromResult(TokenValidationResult.Success( - scopes: scopes, - subject: subject, - audiences: audiences, - issuer: issuer, - expiresAt: expiresAt - )); - } - catch (JsonException) - { - return Task.FromResult(TokenValidationResult.Failure("invalid_token", "Token payload is not valid JSON")); - } - catch (FormatException) - { - return Task.FromResult(TokenValidationResult.Failure("invalid_token", "Token payload is not valid Base64URL")); - } - catch (Exception ex) - { - return Task.FromResult(TokenValidationResult.Failure("invalid_token", $"Token validation failed: {ex.Message}")); - } - } - - private static string? GetStringClaim(JsonElement root, string claimName) - { - if (root.TryGetProperty(claimName, out var claim) && claim.ValueKind == JsonValueKind.String) - { - return claim.GetString(); - } - return null; - } - - private List GetAudienceClaim(JsonElement root) - { - if (!root.TryGetProperty("aud", out var audClaim)) - { - return new List(); - } - - if (audClaim.ValueKind == JsonValueKind.String) - { - var value = audClaim.GetString(); - return value != null ? new List { value } : new List(); - } - - if (audClaim.ValueKind == JsonValueKind.Array) - { - var audiences = new List(); - foreach (var item in audClaim.EnumerateArray()) - { - if (item.ValueKind == JsonValueKind.String) - { - var value = item.GetString(); - if (value != null) - { - audiences.Add(value); - } - } - } - return audiences; - } - - return new List(); - } - - private List GetScopeClaim(JsonElement root) - { - // Try the configured claim name first, then fall back to common alternatives - var claimNamesToTry = new List { _options.ScopeClaimName }; - foreach (var name in ScopeClaimNames) - { - if (!claimNamesToTry.Contains(name, StringComparer.OrdinalIgnoreCase)) - { - claimNamesToTry.Add(name); - } - } - - foreach (var claimName in claimNamesToTry) - { - if (!root.TryGetProperty(claimName, out var scopeClaim)) - { - continue; - } - - if (scopeClaim.ValueKind == JsonValueKind.String) - { - var value = scopeClaim.GetString(); - if (!string.IsNullOrEmpty(value)) - { - // Scopes are space-separated per RFC 6749 - return value.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); - } - } - - if (scopeClaim.ValueKind == JsonValueKind.Array) - { - var scopes = new List(); - foreach (var item in scopeClaim.EnumerateArray()) - { - if (item.ValueKind == JsonValueKind.String) - { - var value = item.GetString(); - if (!string.IsNullOrEmpty(value)) - { - scopes.Add(value); - } - } - } - return scopes; - } - } - - return new List(); - } - - private static DateTimeOffset? GetExpirationClaim(JsonElement root) - { - if (!root.TryGetProperty("exp", out var expClaim)) - { - return null; - } - - if (expClaim.ValueKind == JsonValueKind.Number) - { - var unixTime = expClaim.GetInt64(); - return DateTimeOffset.FromUnixTimeSeconds(unixTime); - } - - return null; - } - - private static byte[] Base64UrlDecode(string input) - { - var output = input.Replace('-', '+').Replace('_', '/'); - switch (output.Length % 4) - { - case 0: break; - case 2: output += "=="; break; - case 3: output += "="; break; - default: throw new FormatException("Illegal base64url string!"); - } - return Convert.FromBase64String(output); - } -} diff --git a/MCPify/Core/Auth/ScopeRequirement.cs b/MCPify/Core/Auth/ScopeRequirement.cs deleted file mode 100644 index f6dc697..0000000 --- a/MCPify/Core/Auth/ScopeRequirement.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Text.RegularExpressions; - -namespace MCPify.Core.Auth; - -/// -/// Defines scope requirements for a tool or endpoint. -/// -public class ScopeRequirement -{ - /// - /// Tool name pattern to match. Supports wildcards: - /// - '*' matches any sequence of characters - /// - '?' matches any single character - /// Examples: "admin_*", "api_get_*", "tool_name" - /// - public required string Pattern { get; init; } - - /// - /// Scopes that must ALL be present in the token. - /// If empty, only is checked. - /// - public List RequiredScopes { get; init; } = new(); - - /// - /// At least ONE of these scopes must be present in the token. - /// If empty, only is checked. - /// - public List AnyOfScopes { get; init; } = new(); - - private Regex? _compiledPattern; - - /// - /// Checks if this requirement applies to the given tool name. - /// - public bool Matches(string toolName) - { - _compiledPattern ??= CompilePattern(Pattern); - return _compiledPattern.IsMatch(toolName); - } - - /// - /// Validates that the provided scopes satisfy this requirement. - /// - /// Scopes from the access token. - /// True if the scopes satisfy the requirement, false otherwise. - public bool IsSatisfiedBy(IEnumerable tokenScopes) - { - var scopeSet = new HashSet(tokenScopes, StringComparer.OrdinalIgnoreCase); - - // Check RequiredScopes - all must be present - if (RequiredScopes.Count > 0) - { - foreach (var required in RequiredScopes) - { - if (!scopeSet.Contains(required)) - { - return false; - } - } - } - - // Check AnyOfScopes - at least one must be present - if (AnyOfScopes.Count > 0) - { - var hasAny = AnyOfScopes.Any(s => scopeSet.Contains(s)); - if (!hasAny) - { - return false; - } - } - - return true; - } - - /// - /// Gets all scopes that are required by this requirement (for error messages). - /// - public IEnumerable GetAllRequiredScopes() - { - foreach (var scope in RequiredScopes) - { - yield return scope; - } - foreach (var scope in AnyOfScopes) - { - yield return scope; - } - } - - private static Regex CompilePattern(string pattern) - { - // Escape regex special characters except * and ? - var escaped = Regex.Escape(pattern) - .Replace("\\*", ".*") - .Replace("\\?", "."); - - return new Regex($"^{escaped}$", RegexOptions.IgnoreCase | RegexOptions.Compiled); - } -} diff --git a/MCPify/Core/Auth/ScopeRequirementStore.cs b/MCPify/Core/Auth/ScopeRequirementStore.cs deleted file mode 100644 index 13e39a5..0000000 --- a/MCPify/Core/Auth/ScopeRequirementStore.cs +++ /dev/null @@ -1,145 +0,0 @@ -namespace MCPify.Core.Auth; - -/// -/// Registry for scope requirements with pattern matching. -/// Determines required scopes for tools based on configured patterns. -/// -public class ScopeRequirementStore -{ - private readonly List _requirements; - private readonly TokenValidationOptions _options; - private readonly OAuthConfigurationStore? _oauthStore; - - public ScopeRequirementStore(IEnumerable requirements, TokenValidationOptions options, OAuthConfigurationStore? oauthStore = null) - { - _requirements = requirements.ToList(); - _options = options; - _oauthStore = oauthStore; - } - - /// - /// Gets all scope requirements that apply to the given tool name. - /// - /// The name of the tool. - /// All matching scope requirements. - public IEnumerable GetRequirementsForTool(string toolName) - { - return _requirements.Where(r => r.Matches(toolName)); - } - - /// - /// Validates that the provided scopes satisfy all requirements for the given tool. - /// - /// The name of the tool. - /// Scopes from the access token. - /// A validation result with missing scopes if validation fails. - public ScopeValidationResult ValidateScopesForTool(string toolName, IEnumerable tokenScopes) - { - var scopeList = tokenScopes.ToList(); - var scopeSet = new HashSet(scopeList, StringComparer.OrdinalIgnoreCase); - var missingScopes = new HashSet(StringComparer.OrdinalIgnoreCase); - - // Check default required scopes first - foreach (var defaultScope in _options.DefaultRequiredScopes) - { - if (!scopeSet.Contains(defaultScope)) - { - missingScopes.Add(defaultScope); - } - } - - // Check OAuth-configured scopes if enabled - if (_options.RequireOAuthConfiguredScopes && _oauthStore != null) - { - var oauthScopes = _oauthStore.GetConfigurations() - .SelectMany(c => c.Scopes.Keys) - .Distinct(StringComparer.OrdinalIgnoreCase); - - foreach (var oauthScope in oauthScopes) - { - if (!scopeSet.Contains(oauthScope)) - { - missingScopes.Add(oauthScope); - } - } - } - - // Check tool-specific requirements - var matchingRequirements = GetRequirementsForTool(toolName).ToList(); - foreach (var requirement in matchingRequirements) - { - if (!requirement.IsSatisfiedBy(scopeList)) - { - // Add all scopes from this requirement to missing list - foreach (var scope in requirement.GetAllRequiredScopes()) - { - if (!scopeSet.Contains(scope)) - { - missingScopes.Add(scope); - } - } - } - } - - if (missingScopes.Count > 0) - { - return ScopeValidationResult.Failure(missingScopes.ToList()); - } - - return ScopeValidationResult.Success(); - } - - /// - /// Gets all required scopes for a tool (for WWW-Authenticate header). - /// - public IEnumerable GetRequiredScopesForTool(string toolName) - { - var scopes = new HashSet(StringComparer.OrdinalIgnoreCase); - - // Add default scopes - foreach (var scope in _options.DefaultRequiredScopes) - { - scopes.Add(scope); - } - - // Add OAuth-configured scopes if enabled - if (_options.RequireOAuthConfiguredScopes && _oauthStore != null) - { - foreach (var config in _oauthStore.GetConfigurations()) - { - foreach (var scope in config.Scopes.Keys) - { - scopes.Add(scope); - } - } - } - - // Add tool-specific scopes - foreach (var requirement in GetRequirementsForTool(toolName)) - { - foreach (var scope in requirement.GetAllRequiredScopes()) - { - scopes.Add(scope); - } - } - - return scopes; - } -} - -/// -/// Result of scope validation. -/// -public class ScopeValidationResult -{ - public bool IsValid { get; init; } - public IReadOnlyList MissingScopes { get; init; } = Array.Empty(); - - public static ScopeValidationResult Success() => new() { IsValid = true }; - - public static ScopeValidationResult Failure(IReadOnlyList missingScopes) => new() - { - IsValid = false, - MissingScopes = missingScopes - }; -} diff --git a/MCPify/Core/Auth/TokenValidationOptions.cs b/MCPify/Core/Auth/TokenValidationOptions.cs deleted file mode 100644 index 0af80a7..0000000 --- a/MCPify/Core/Auth/TokenValidationOptions.cs +++ /dev/null @@ -1,58 +0,0 @@ -namespace MCPify.Core.Auth; - -/// -/// Configuration options for token validation behavior. -/// Token validation is opt-in for backward compatibility. -/// -public class TokenValidationOptions -{ - /// - /// When true, enables JWT token validation including expiration, audience, and scope checks. - /// Defaults to false for backward compatibility. - /// - public bool EnableJwtValidation { get; set; } = false; - - /// - /// When true, validates that the token's 'aud' claim matches the expected audience (resource URL). - /// Only applies when is true. - /// - public bool ValidateAudience { get; set; } = false; - - /// - /// When true, validates that the token contains required scopes for the requested operation. - /// Only applies when is true. - /// - public bool ValidateScopes { get; set; } = false; - - /// - /// The expected audience value for token validation. - /// If not set, defaults to the resource URL. - /// - public string? ExpectedAudience { get; set; } - - /// - /// Default scopes required for all endpoints when scope validation is enabled. - /// Specific endpoints can override this with . - /// - public List DefaultRequiredScopes { get; set; } = new(); - - /// - /// When true, automatically requires all scopes defined in - /// from the in addition to . - /// Defaults to false for backward compatibility. - /// - public bool RequireOAuthConfiguredScopes { get; set; } = false; - - /// - /// The claim name used for scopes in the JWT token. - /// Common values: "scope", "scp", "scopes". - /// Defaults to "scope". - /// - public string ScopeClaimName { get; set; } = "scope"; - - /// - /// Allowed clock skew for token expiration validation. - /// Defaults to 5 minutes. - /// - public TimeSpan ClockSkew { get; set; } = TimeSpan.FromMinutes(5); -} diff --git a/MCPify/Core/Auth/TokenValidationResult.cs b/MCPify/Core/Auth/TokenValidationResult.cs deleted file mode 100644 index bcf4a20..0000000 --- a/MCPify/Core/Auth/TokenValidationResult.cs +++ /dev/null @@ -1,81 +0,0 @@ -namespace MCPify.Core.Auth; - -/// -/// Result of access token validation. -/// -public class TokenValidationResult -{ - /// - /// Whether the token is valid. - /// - public bool IsValid { get; init; } - - /// - /// Error code when validation fails (e.g., "invalid_token", "expired_token"). - /// - public string? ErrorCode { get; init; } - - /// - /// Human-readable description of the error. - /// - public string? ErrorDescription { get; init; } - - /// - /// Scopes extracted from the token. - /// - public IReadOnlyList Scopes { get; init; } = Array.Empty(); - - /// - /// The subject (sub) claim from the token. - /// - public string? Subject { get; init; } - - /// - /// The audiences (aud) claim from the token. - /// - public IReadOnlyList Audiences { get; init; } = Array.Empty(); - - /// - /// The issuer (iss) claim from the token. - /// - public string? Issuer { get; init; } - - /// - /// Token expiration time if present. - /// - public DateTimeOffset? ExpiresAt { get; init; } - - /// - /// Creates a successful validation result. - /// - public static TokenValidationResult Success( - IReadOnlyList? scopes = null, - string? subject = null, - IReadOnlyList? audiences = null, - string? issuer = null, - DateTimeOffset? expiresAt = null) - { - return new TokenValidationResult - { - IsValid = true, - Scopes = scopes ?? Array.Empty(), - Subject = subject, - Audiences = audiences ?? Array.Empty(), - Issuer = issuer, - ExpiresAt = expiresAt - }; - } - - /// - /// Creates a failed validation result. - /// - public static TokenValidationResult Failure(string errorCode, string errorDescription) - { - return new TokenValidationResult - { - IsValid = false, - ErrorCode = errorCode, - ErrorDescription = errorDescription - }; - } -} diff --git a/MCPify/Core/McpifyOptions.cs b/MCPify/Core/McpifyOptions.cs index ecd9867..8254d47 100644 --- a/MCPify/Core/McpifyOptions.cs +++ b/MCPify/Core/McpifyOptions.cs @@ -1,6 +1,6 @@ +using MCPify.Core.Auth; using MCPify.OpenApi; using MCPify.Schema; -using MCPify.Core.Auth; using Microsoft.AspNetCore.Http; namespace MCPify.Core; @@ -70,17 +70,6 @@ public class McpifyOptions /// public List OAuthConfigurations { get; set; } = new(); - /// - /// Configuration for JWT token validation behavior. - /// When null or EnableJwtValidation is false, token validation is skipped for backward compatibility. - /// - public TokenValidationOptions? TokenValidation { get; set; } - - /// - /// Per-tool scope requirements. Patterns support wildcards (* and ?). - /// These are checked when is true. - /// - public List ScopeRequirements { get; set; } = new(); } /// diff --git a/MCPify/Hosting/McpAuthenticationOptionsSetup.cs b/MCPify/Hosting/McpAuthenticationOptionsSetup.cs new file mode 100644 index 0000000..dc89e3d --- /dev/null +++ b/MCPify/Hosting/McpAuthenticationOptionsSetup.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPify.Core; +using MCPify.Core.Auth; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.AspNetCore.Authentication; +using ModelContextProtocol.Authentication; + +namespace MCPify.Hosting; + +internal sealed class McpAuthenticationOptionsSetup : IConfigureNamedOptions +{ + public void Configure(McpAuthenticationOptions options) + => Configure(McpAuthenticationDefaults.AuthenticationScheme, options); + + public void Configure(string? name, McpAuthenticationOptions options) + { + var previousHandler = options.Events.OnResourceMetadataRequest; + + options.Events.OnResourceMetadataRequest = async context => + { + if (previousHandler != null) + { + await previousHandler(context); + } + + if (context.ResourceMetadata is null) + { + context.ResourceMetadata = BuildMetadata(context.HttpContext); + } + }; + } + + private static ProtectedResourceMetadata? BuildMetadata(HttpContext httpContext) + { + var services = httpContext.RequestServices; + var options = services.GetService(); + var oauthStore = services.GetService(); + + if (options is null && oauthStore is null) + { + return null; + } + + var resourceUri = ResolveResourceUri(options?.ResourceUrlOverride, httpContext); + + var metadata = new ProtectedResourceMetadata + { + Resource = resourceUri, + }; + + if (oauthStore != null) + { + PopulateAuthorizationMetadata(metadata, oauthStore); + } + + return metadata; + } + + private static Uri? ResolveResourceUri(string? overrideUrl, HttpContext httpContext) + { + if (!string.IsNullOrWhiteSpace(overrideUrl) && Uri.TryCreate(overrideUrl, UriKind.Absolute, out var overrideUri)) + { + return overrideUri; + } + + return new Uri($"{httpContext.Request.Scheme}://{httpContext.Request.Host}"); + } + + private static void PopulateAuthorizationMetadata(ProtectedResourceMetadata metadata, OAuthConfigurationStore store) + { + var authorizationServers = new HashSet(StringComparer.OrdinalIgnoreCase); + var scopes = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var configuration in store.GetConfigurations()) + { + foreach (var scope in configuration.Scopes.Keys) + { + scopes.Add(scope); + } + + if (configuration.AuthorizationServers.Count > 0) + { + foreach (var server in configuration.AuthorizationServers) + { + if (!string.IsNullOrWhiteSpace(server) && Uri.TryCreate(server, UriKind.Absolute, out var serverUri)) + { + authorizationServers.Add(serverUri.ToString()); + } + } + + continue; + } + + if (!string.IsNullOrWhiteSpace(configuration.AuthorizationUrl) && Uri.TryCreate(configuration.AuthorizationUrl, UriKind.Absolute, out var authorizationUri)) + { + authorizationServers.Add(new Uri(authorizationUri.GetLeftPart(UriPartial.Authority)).ToString()); + } + } + + metadata.AuthorizationServers = authorizationServers + .Select(server => new Uri(server)) + .ToList(); + + metadata.ScopesSupported = scopes.Count > 0 + ? scopes.ToList() + : new List(); + } +} diff --git a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs b/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs deleted file mode 100644 index 0d50ef2..0000000 --- a/MCPify/Hosting/McpOAuthAuthenticationMiddleware.cs +++ /dev/null @@ -1,238 +0,0 @@ -using MCPify.Core; -using MCPify.Core.Auth; -using Microsoft.Net.Http.Headers; - -namespace MCPify.Hosting; - -public class McpOAuthAuthenticationMiddleware -{ - private readonly RequestDelegate _next; - - public const string TokenValidationResultKey = "McpTokenValidationResult"; - - public McpOAuthAuthenticationMiddleware(RequestDelegate next) - { - _next = next; - } - - public async Task InvokeAsync(HttpContext context) - { - var path = context.Request.Path; - if (path.StartsWithSegments("/.well-known") || - path.StartsWithSegments("/swagger") || - path.StartsWithSegments("/health") || - path.StartsWithSegments("/connect") || - path.StartsWithSegments("/auth")) - { - await _next(context); - return; - } - - var options = context.RequestServices.GetService(); - var oauthStore = context.RequestServices.GetService(); - - var oauthConfigurations = oauthStore?.GetConfigurations().ToList() ?? []; - var validationOptions = options?.TokenValidation; - var tokenValidationEnabled = validationOptions?.EnableJwtValidation == true; - - var challengeScopes = BuildChallengeScopes(oauthConfigurations, validationOptions); - var authRequired = oauthConfigurations.Count > 0 || tokenValidationEnabled; - - if (!authRequired) - { - await _next(context); - return; - } - - var resourceUrl = GetResourceUrl(context, options); - var accessor = context.RequestServices.GetService(); - - if (!TryGetBearerToken(context, out var token)) - { - await WriteChallengeResponse(context, resourceUrl, challengeScopes, null, null); - return; - } - - if (accessor != null) - { - accessor.AccessToken = token; - } - - if (tokenValidationEnabled && validationOptions != null) - { - var validator = context.RequestServices.GetService(); - if (validator != null) - { - var expectedAudience = validationOptions.ValidateAudience - ? (validationOptions.ExpectedAudience ?? resourceUrl) - : null; - - var validationResult = await validator.ValidateAsync(token, expectedAudience, context.RequestAborted); - context.Items[TokenValidationResultKey] = validationResult; - - if (!validationResult.IsValid) - { - await WriteChallengeResponse(context, resourceUrl, challengeScopes, - validationResult.ErrorCode ?? "invalid_token", - validationResult.ErrorDescription ?? "Token validation failed"); - return; - } - - if (validationOptions.ValidateScopes) - { - var scopeStore = context.RequestServices.GetService(); - if (scopeStore != null) - { - var scopeResult = scopeStore.ValidateScopesForTool("*", validationResult.Scopes); - if (!scopeResult.IsValid) - { - await WriteInsufficientScopeResponse(context, resourceUrl, scopeResult.MissingScopes); - return; - } - } - } - } - } - - await _next(context); - } - - private static string GetResourceUrl(HttpContext context, McpifyOptions? options) - { - var resourceUrl = options?.ResourceUrlOverride; - if (string.IsNullOrWhiteSpace(resourceUrl)) - { - resourceUrl = options?.LocalEndpoints?.BaseUrlOverride; - } - - if (string.IsNullOrWhiteSpace(resourceUrl)) - { - resourceUrl = $"{context.Request.Scheme}://{context.Request.Host}"; - } - - return resourceUrl.TrimEnd('/'); - } - - 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) - { - foreach (var scope in configuration.Scopes.Keys) - { - scopes.Add(scope); - } - } - - if (hasDefaultScopes && defaultScopes != null) - { - foreach (var scope in defaultScopes) - { - scopes.Add(scope); - } - } - - 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; - } - - token = authorization.Substring("Bearer ".Length).Trim(); - return !string.IsNullOrEmpty(token); - } - - private static Task WriteChallengeResponse( - HttpContext context, - string resourceUrl, - IReadOnlyList scopes, - string? errorCode, - string? errorDescription) - { - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - var metadataUrl = $"{resourceUrl}/.well-known/oauth-protected-resource"; - context.Response.Headers[HeaderNames.WWWAuthenticate] = - BuildWwwAuthenticateHeader(metadataUrl, scopes, errorCode, errorDescription); - return Task.CompletedTask; - } - - private static Task WriteInsufficientScopeResponse( - HttpContext context, - string resourceUrl, - IReadOnlyList requiredScopes) - { - var metadataUrl = $"{resourceUrl}/.well-known/oauth-protected-resource"; - - context.Response.StatusCode = StatusCodes.Status403Forbidden; - - var parts = new List - { - "Bearer", - $"error=\"insufficient_scope\"", - $"error_description=\"The access token does not have the required scope(s)\"", - $"resource_metadata=\"{metadataUrl}\"" - }; - - if (requiredScopes.Count > 0) - { - parts.Add($"scope=\"{string.Join(" ", requiredScopes)}\""); - } - - context.Response.Headers[HeaderNames.WWWAuthenticate] = string.Join(", ", parts); - return Task.CompletedTask; - } - - private static string BuildWwwAuthenticateHeader( - string metadataUrl, - IReadOnlyList scopes, - string? errorCode, - string? errorDescription) - { - if (string.IsNullOrEmpty(errorCode) && string.IsNullOrEmpty(errorDescription) && scopes.Count == 0) - { - return $"Bearer resource_metadata=\"{metadataUrl}\""; - } - - var parts = new List(4) - { - $"Bearer resource_metadata=\"{metadataUrl}\"" - }; - - if (!string.IsNullOrEmpty(errorCode)) - { - parts.Add($"error=\"{errorCode}\""); - } - - if (!string.IsNullOrEmpty(errorDescription)) - { - var escapedDescription = errorDescription.Replace("\"", "\\\""); - parts.Add($"error_description=\"{escapedDescription}\""); - } - - if (scopes.Count > 0) - { - parts.Add($"scope=\"{string.Join(" ", scopes)}\""); - } - - return string.Join(", ", parts); - } -} diff --git a/MCPify/Hosting/McpifyEndpointExtensions.cs b/MCPify/Hosting/McpifyEndpointExtensions.cs index 9837f3b..cf564f9 100644 --- a/MCPify/Hosting/McpifyEndpointExtensions.cs +++ b/MCPify/Hosting/McpifyEndpointExtensions.cs @@ -1,11 +1,13 @@ using MCPify.Core; using MCPify.Core.Auth; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using ModelContextProtocol.Server; using MCPify.Endpoints; using MCPify.Tools; using MCPify.Schema; +using System.Linq; namespace MCPify.Hosting; @@ -101,7 +103,12 @@ string BaseUrlProvider() if (options.Transport == McpTransportType.Http) { - app.MapMcp(path); + var mcpRoute = app.MapMcp(path); + var oauthStore = services.GetService(); + if (oauthStore?.GetConfigurations().Any() == true) + { + mcpRoute.RequireAuthorization(); + } } // Map OAuth Protected Resource Metadata diff --git a/MCPify/Hosting/McpifyServiceExtensions.cs b/MCPify/Hosting/McpifyServiceExtensions.cs index a211c98..e7fed9e 100644 --- a/MCPify/Hosting/McpifyServiceExtensions.cs +++ b/MCPify/Hosting/McpifyServiceExtensions.cs @@ -7,10 +7,14 @@ using ModelContextProtocol.Server; using System.Net.Http; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using MCPify.Core.Auth; using MCPify.Core.Session; using System.IO; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using ModelContextProtocol.AspNetCore.Authentication; namespace MCPify.Hosting; @@ -103,35 +107,21 @@ public static IServiceCollection AddMcpify( } services.AddSingleton(oauthStore); - // Register token validation services if enabled - if (opts.TokenValidation != null) - { - services.AddSingleton(opts.TokenValidation); + services.TryAddEnumerable(ServiceDescriptor.Transient, McpAuthenticationOptionsSetup>()); + services.TryAddEnumerable(ServiceDescriptor.Transient, McpAuthenticationOptionsSetup>()); - if (opts.TokenValidation.EnableJwtValidation) + services.AddAuthentication(options => { - services.AddSingleton(sp => - new JwtAccessTokenValidator(sp.GetRequiredService())); - } - - // Register scope requirement store with access to OAuth configurations - services.AddSingleton(sp => - new ScopeRequirementStore( - opts.ScopeRequirements, - sp.GetRequiredService(), - sp.GetService())); - } - else - { - // Register empty token validation options for when validation is not configured - var defaultOptions = new TokenValidationOptions(); - services.AddSingleton(defaultOptions); - services.AddSingleton(sp => - new ScopeRequirementStore( - opts.ScopeRequirements, - defaultOptions, - sp.GetService())); - } + options.DefaultScheme = McpAuthenticationDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = McpAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme; + }) + .AddScheme( + McpAuthenticationDefaults.AuthenticationScheme, + _ => { }) + .AddScheme( + "Bearer", + _ => { }); return services; } @@ -184,13 +174,4 @@ public static IApplicationBuilder UseMcpifyContext(this IApplicationBuilder buil return builder.UseMiddleware(); } - /// - /// Adds the MCP OAuth authentication middleware to the pipeline. This handles token validation and challenges for protected endpoints. - /// - /// The instance. - /// The instance. - public static IApplicationBuilder UseMcpifyOAuth(this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } } diff --git a/MCPify/MCPify.csproj b/MCPify/MCPify.csproj index 897b919..e802386 100644 --- a/MCPify/MCPify.csproj +++ b/MCPify/MCPify.csproj @@ -28,8 +28,8 @@ - - + + diff --git a/README.md b/README.md index c447c00..7c66f1f 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ var app = builder.Build(); // 2. Add Middleware app.UseMcpifyContext(); -app.UseMcpifyOAuth(); // If using Authentication +app.UseAuthentication(); +app.UseAuthorization(); // ... Map your endpoints ... @@ -165,111 +166,23 @@ With `Auto` mode, MCPify detects headless environments by checking: - **Windows**: Container environments (Kubernetes, Docker) - **macOS**: SSH sessions -### Token Validation +### Protected Resource Metadata & Challenges -MCPify supports JWT token validation for enhanced security. Token validation is opt-in for backward compatibility. +MCPify now relies on the official `ModelContextProtocol.AspNetCore` authentication handler for OAuth 2.0. When you call `AddMcpify`, the MCP authentication scheme is registered automatically and the handler issues `WWW-Authenticate` challenges that point back to the protected resource metadata endpoint. -```csharp -builder.Services.AddMcpify(options => -{ - // Set the resource URL for audience validation - options.ResourceUrlOverride = "https://api.example.com"; - - // Configure OAuth (scopes defined here can be auto-required) - options.OAuthConfigurations.Add(new OAuth2Configuration - { - AuthorizationUrl = "https://auth.example.com/authorize", - TokenUrl = "https://auth.example.com/token", - Scopes = new Dictionary - { - { "read", "Read access" }, - { "write", "Write access" } - } - }); - - // Enable token validation (opt-in) - options.TokenValidation = new TokenValidationOptions - { - EnableJwtValidation = true, // Enable JWT parsing and validation - ValidateAudience = true, // Validate 'aud' claim matches resource URL - ValidateScopes = true, // Validate token has required scopes - RequireOAuthConfiguredScopes = true, // Require scopes from OAuth2Configuration - ClockSkew = TimeSpan.FromMinutes(5) // Allowed clock skew for expiration - }; -}); -``` +OAuth configurations you provide through `McpifyOptions.OAuthConfigurations` (and those discovered from OpenAPI documents) are automatically surfaced to the handler so that clients know which scopes and authorization servers to use. -**Scope Configuration Options:** - -| Option | Description | -|--------|-------------| -| `RequireOAuthConfiguredScopes = true` | Automatically require all scopes from OAuth configurations. This includes scopes defined in `OAuthConfigurations` **and** scopes discovered from OpenAPI security schemes. | -| `DefaultRequiredScopes` | Explicitly list required scopes (use when you want different scopes than what's advertised in OAuth config). | - -**Automatic Integration with OpenAPI:** When MCPify loads an external API from an OpenAPI spec that includes OAuth2 security schemes (like those configured for Swagger UI), the scopes are automatically parsed and added to the OAuth configuration store. With `RequireOAuthConfiguredScopes = true`, these scopes are automatically enforced during token validation - no duplicate configuration needed. - -When validation fails, MCPify returns appropriate HTTP responses: - -| Scenario | Status Code | WWW-Authenticate | -|----------|-------------|------------------| -| No token provided | 401 Unauthorized | `Bearer resource_metadata="..."` | -| Token expired | 401 Unauthorized | `Bearer error="invalid_token", error_description="Token has expired"` | -| Wrong audience | 401 Unauthorized | `Bearer error="invalid_token", error_description="Token audience does not match..."` | -| Missing scopes | 403 Forbidden | `Bearer error="insufficient_scope", scope="required_scope"` | - -### Per-Tool Scope Requirements - -Define granular scope requirements for specific tools using pattern matching: +If you need to customize the advertised metadata—for example to add documentation links or override the detected resource URL—you can configure `McpAuthenticationOptions`: ```csharp -builder.Services.AddMcpify(options => +builder.Services.PostConfigure(options => { - options.TokenValidation = new TokenValidationOptions - { - EnableJwtValidation = true, - ValidateScopes = true, - DefaultRequiredScopes = new List { "mcp.access" } - }; - - // Define per-tool scope requirements - options.ScopeRequirements = new List - { - // All admin_* tools require 'admin' scope - new ScopeRequirement - { - Pattern = "admin_*", - RequiredScopes = new List { "admin" } - }, - // Write operations require 'write' scope - new ScopeRequirement - { - Pattern = "*_create", - RequiredScopes = new List { "write" } - }, - new ScopeRequirement - { - Pattern = "*_update", - RequiredScopes = new List { "write" } - }, - new ScopeRequirement - { - Pattern = "*_delete", - RequiredScopes = new List { "write" } - }, - // Read-only tools need at least 'read' OR 'write' scope - new ScopeRequirement - { - Pattern = "*_get", - AnyOfScopes = new List { "read", "write" } - } - }; + options.ResourceMetadata ??= new ProtectedResourceMetadata(); + options.ResourceMetadata.Documentation = new Uri("https://docs.example.com/mcp"); }); ``` -Pattern matching supports: -- `*` - matches any sequence of characters -- `?` - matches any single character -- Exact match - `tool_name` +Ensure your middleware pipeline includes `app.UseAuthentication();` and `app.UseAuthorization();` so that the handler can participate in requests. Challenges no longer run through a custom middleware; the standard ASP.NET Core authentication flow handles everything. ### RFC 8707 Resource Parameter diff --git a/Sample/Extensions/DemoServiceExtensions.cs b/Sample/Extensions/DemoServiceExtensions.cs index 029a07f..8f53b4d 100644 --- a/Sample/Extensions/DemoServiceExtensions.cs +++ b/Sample/Extensions/DemoServiceExtensions.cs @@ -143,12 +143,6 @@ 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/Sample/Program.cs b/Sample/Program.cs index 67fd35b..4c92fc3 100644 --- a/Sample/Program.cs +++ b/Sample/Program.cs @@ -50,7 +50,6 @@ app.UseSwaggerUI(); app.UseMcpifyContext(); -app.UseMcpifyOAuth(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/Sample/README.md b/Sample/README.md index 34ee212..91cc0f5 100644 --- a/Sample/README.md +++ b/Sample/README.md @@ -130,46 +130,21 @@ Attach these either to `options.LocalEndpoints.AuthenticationFactory` or to a sp ### Pass-through Bearer Tokens If your MCP client already sends `Authorization: Bearer ` to the sample, MCPify will forward that token via `IMcpContextAccessor.AccessToken` instead of using stored OAuth/client-credentials tokens. -### Token Validation (Optional) +### Protected Resource Metadata -MCPify supports JWT token validation for enhanced security. Enable it by configuring `TokenValidation`: +The sample relies on the ASP.NET Core authentication handler from `ModelContextProtocol.AspNetCore`. `AddMcpify` registers the handler and automatically mirrors any OAuth configurations into the protected-resource metadata served to clients. + +If you need to tweak what gets advertised (for example to add additional scopes or documentation links), configure `McpAuthenticationOptions` in `Program.cs`: ```csharp -builder.Services.AddMcpify(options => +builder.Services.PostConfigure(options => { - options.ResourceUrlOverride = baseUrl; - - // OAuth scopes are already configured via OAuthConfigurations - // You can require these scopes automatically: - options.TokenValidation = new TokenValidationOptions - { - EnableJwtValidation = true, // Parse and validate JWT tokens - ValidateAudience = true, // Validate 'aud' claim - ValidateScopes = true, // Validate required scopes - RequireOAuthConfiguredScopes = true // Auto-require scopes from OAuth2Configuration - }; - - // Or define per-tool scope requirements for finer control - options.ScopeRequirements = new() - { - new ScopeRequirement - { - Pattern = "api_secrets_*", - RequiredScopes = new() { "read_secrets" } - } - }; + options.ResourceMetadata ??= new ProtectedResourceMetadata(); + options.ResourceMetadata.Documentation = new Uri("https://localhost:5005/docs"); }); ``` -**Note:** Setting `RequireOAuthConfiguredScopes = true` automatically requires all scopes from: -- Scopes defined in `OAuthConfigurations` -- Scopes discovered from OpenAPI security schemes (e.g., OAuth2 configured for Swagger UI) - -This means if your OpenAPI spec already defines OAuth2 scopes, MCPify will automatically enforce them during token validation - no duplicate configuration needed. - -When token validation is enabled, MCPify returns: -- **401 Unauthorized** with `error="invalid_token"` for expired or invalid tokens -- **403 Forbidden** with `error="insufficient_scope"` for valid tokens missing required scopes +Ensure the middleware pipeline includes both `app.UseAuthentication();` and `app.UseAuthorization();` so that challenges and metadata responses are handled by the official MCP authentication scheme. ### Relevant configuration knobs These can be configured in `appsettings.json` or via command-line arguments. diff --git a/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs b/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs index ba110f7..036379d 100644 --- a/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs @@ -1,5 +1,7 @@ +using System; using System.Net; using System.Net.Http.Headers; +using System.Linq; using System.Text; using MCPify.Core; using MCPify.Core.Auth; @@ -15,33 +17,33 @@ namespace MCPify.Tests.Integration; public class OAuthChallengeTokenValidationTests { [Fact] - public async Task PostWithoutSession_ReturnsUnauthorizedChallenge_WhenTokenValidationEnabled() + public async Task PostWithoutSession_ReturnsUnauthorizedChallenge_WhenAuthenticationRequired() { await using var host = await CreateHostAsync(); - - 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") - }; + using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp"); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + var payload = """ + { + "jsonrpc": "2.0", + "method": "ping", + "params": {}, + "id": 1 + } + """; + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); 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.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Contains(response.Headers.WwwAuthenticate, header => string.Equals(header.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase)); + Assert.Contains("resource_metadata", authenticateHeader); + Assert.True(body.Length == 0, $"Expected empty body but received: {body}"); } private static async Task CreateHostAsync() @@ -50,20 +52,26 @@ private static async Task CreateHostAsync() builder.WebHost.UseTestServer(); builder.Services.AddLogging(); + builder.Services.AddAuthorization(); builder.Services.AddMcpify(options => { options.Transport = McpTransportType.Http; - options.TokenValidation = new TokenValidationOptions + options.OAuthConfigurations.Add(new OAuth2Configuration { - EnableJwtValidation = true, - ValidateAudience = true - }; + AuthorizationUrl = "https://auth.example.com/authorize", + TokenUrl = "https://auth.example.com/token", + Scopes = new Dictionary + { + { "read", "Read access" } + } + }); }); var app = builder.Build(); app.UseMcpifyContext(); - app.UseMcpifyOAuth(); - app.MapMcpifyEndpoint(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapMcpifyEndpoint("/mcp"); await app.StartAsync(); return app; diff --git a/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs b/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs index 3449ab9..0f8d15e 100644 --- a/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthMetadataEndpointTests.cs @@ -1,3 +1,4 @@ +using System; using System.Net; using System.Net.Http.Json; using System.Linq; @@ -17,14 +18,20 @@ namespace MCPify.Tests.Integration; public class OAuthMetadataEndpointTests { [Fact] - public async Task GetMetadata_Returns404_WhenNoOAuthConfigured() + public async Task GetMetadata_ReturnsDefaultMetadata_WhenNoOAuthConfigured() { using var host = await CreateHostAsync(); var client = host.GetTestClient(); var response = await client.GetAsync("/.well-known/oauth-protected-resource"); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var metadata = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(metadata); + Assert.False(string.IsNullOrWhiteSpace(metadata!.Resource)); + Assert.NotNull(metadata.AuthorizationServers); + Assert.NotNull(metadata.ScopesSupported); } [Fact] @@ -110,7 +117,7 @@ public async Task GetMetadata_FallsBackToAuthorizationUrlAuthority_WhenAuthoriza var metadata = await response.Content.ReadFromJsonAsync(); Assert.NotNull(metadata); - Assert.Contains("https://auth.example.com", metadata!.AuthorizationServers); + Assert.Contains(metadata!.AuthorizationServers, server => server.StartsWith("https://auth.example.com", StringComparison.OrdinalIgnoreCase)); } private static async Task CreateHostAsync(Action? configure = null, Action? configureOptions = null) diff --git a/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs b/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs index b91a3e0..5f60d4b 100644 --- a/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs @@ -1,4 +1,6 @@ +using System.Linq; using System.Net; +using System.Net.Http.Headers; using System.Text; using System.Text.Json; using MCPify.Core; @@ -6,326 +8,171 @@ using MCPify.Hosting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; namespace MCPify.Tests.Integration; public class OAuthMiddlewareTests { + private const string JsonRpcContent = """ + { + "jsonrpc": "2.0", + "method": "ping", + "params": {}, + "id": 1 + } + """; + [Fact] public async Task Request_Returns401_WhenNoToken_And_OAuthConfigured() { - using var host = await CreateHostAsync(services => + await using var app = await CreateHostAsync(options => { - var store = services.GetRequiredService(); - store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); + options.OAuthConfigurations.Add(new OAuth2Configuration + { + AuthorizationUrl = "https://auth.example.com/authorize", + TokenUrl = "https://auth.example.com/token", + Scopes = new Dictionary { { "read", "Read" } } + }); }); - - var client = host.GetTestClient(); - var response = await client.GetAsync("/mcp"); - + var client = app.GetTestClient(); + + var metadataPayload = await client.GetStringAsync("/.well-known/oauth-protected-resource"); + using var metadata = JsonDocument.Parse(metadataPayload); + Assert.Equal("http://localhost", metadata.RootElement.GetProperty("resource").GetString()); + var scopes = metadata.RootElement.GetProperty("scopes_supported").EnumerateArray().Select(x => x.GetString()).Where(x => x != null).ToList(); + Assert.Contains("read", scopes); + + using var request = CreateJsonRpcRequest(); + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Contains("WWW-Authenticate", response.Headers.ToDictionary(h => h.Key, h => string.Join(", ", h.Value)).Keys); - var authHeader = response.Headers.WwwAuthenticate.ToString(); - Assert.Contains("resource_metadata", authHeader); + var authenticateHeader = string.Join(" ", response.Headers.WwwAuthenticate.Select(h => h.ToString())); + Assert.Contains("resource_metadata", authenticateHeader); } [Fact] public async Task Request_Challenge_UsesResourceOverride() { - var publicUrl = "https://proxy.example.com"; + const string publicUrl = "https://proxy.example.com"; - using var host = await CreateHostAsync(services => - { - var store = services.GetRequiredService(); - store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); - }, options => + await using var app = await CreateHostAsync(options => { options.ResourceUrlOverride = publicUrl; + options.OAuthConfigurations.Add(new OAuth2Configuration + { + AuthorizationUrl = "https://auth.example.com/authorize", + TokenUrl = "https://auth.example.com/token" + }); }); - var client = host.GetTestClient(); + var client = app.GetTestClient(); + var metadataPayload = await client.GetStringAsync("/.well-known/oauth-protected-resource"); + using var metadata = JsonDocument.Parse(metadataPayload); + Assert.Equal(publicUrl, metadata.RootElement.GetProperty("resource").GetString()); - var response = await client.GetAsync("/mcp"); + using var request = CreateJsonRpcRequest(); + var response = await client.SendAsync(request); + var authenticateHeader = string.Join(" ", response.Headers.WwwAuthenticate.Select(h => h.ToString())); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - var authHeader = response.Headers.WwwAuthenticate.ToString(); - // Per MCP spec, WWW-Authenticate should contain resource_metadata URL - Assert.Contains($"resource_metadata=\"{publicUrl}/.well-known/oauth-protected-resource\"", authHeader); + Assert.Contains("resource_metadata=\"http://localhost/.well-known/oauth-protected-resource", authenticateHeader); } [Fact] public async Task Request_Challenge_IncludesScope_WhenConfigured() { - using var host = await CreateHostAsync(services => + await using var app = await CreateHostAsync(options => { - var store = services.GetRequiredService(); - store.AddConfiguration(new OAuth2Configuration + options.OAuthConfigurations.Add(new OAuth2Configuration { - AuthorizationUrl = "https://auth", + AuthorizationUrl = "https://auth.example.com/authorize", + TokenUrl = "https://auth.example.com/token", Scopes = new Dictionary { - { "read", "Read access" }, - { "write", "Write access" } + { "read", "Read" }, + { "write", "Write" } } }); }); - var client = host.GetTestClient(); - - var response = await client.GetAsync("/mcp"); + using var request = CreateJsonRpcRequest(); + var response = await app.GetTestClient().SendAsync(request); + var authenticateHeader = string.Join(" ", response.Headers.WwwAuthenticate.Select(h => h.ToString())); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - var authHeader = response.Headers.WwwAuthenticate.ToString(); - // Per MCP spec, WWW-Authenticate SHOULD include scope parameter - Assert.Contains("scope=", authHeader); - Assert.Contains("read", authHeader); - Assert.Contains("write", authHeader); - } + Assert.Contains("resource_metadata=\"http://localhost/.well-known/oauth-protected-resource", authenticateHeader); - [Fact] - public async Task Request_Returns200_WhenTokenPresent() - { - using var host = await CreateHostAsync(services => - { - var store = services.GetRequiredService(); - store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); - }); - - var client = host.GetTestClient(); - client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "token"); - - var response = await client.GetAsync("/mcp"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var metadataPayload = await app.GetTestClient().GetStringAsync("/.well-known/oauth-protected-resource"); + using var metadata = JsonDocument.Parse(metadataPayload); + var scopes = metadata.RootElement.GetProperty("scopes_supported").EnumerateArray().Select(x => x.GetString()).Where(x => x != null).ToList(); + Assert.Contains("read", scopes); + Assert.Contains("write", scopes); } [Fact] - public async Task Request_Returns200_WhenNoOAuthConfigured() - { - using var host = await CreateHostAsync(); - var client = host.GetTestClient(); - - var response = await client.GetAsync("/mcp"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Request_Returns401_WhenTokenExpired_AndValidationEnabled() - { - using var host = await CreateHostAsync(services => - { - var store = services.GetRequiredService(); - store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); - }, options => - { - options.TokenValidation = new TokenValidationOptions - { - EnableJwtValidation = true, - ClockSkew = TimeSpan.Zero - }; - }); - - var client = host.GetTestClient(); - - // Create an expired JWT token - var expiredToken = CreateJwt(new - { - sub = "user123", - exp = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds() - }); - client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", expiredToken); - - var response = await client.GetAsync("/mcp"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - var authHeader = response.Headers.WwwAuthenticate.ToString(); - Assert.Contains("error=\"invalid_token\"", authHeader); - Assert.Contains("expired", authHeader.ToLower()); - } - - [Fact] - public async Task Request_Returns401_WhenTokenAudienceDoesNotMatch() + public async Task Request_Returns200_WhenTokenPresent() { - using var host = await CreateHostAsync(services => - { - var store = services.GetRequiredService(); - store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); - }, options => + await using var app = await CreateHostAsync(options => { - options.ResourceUrlOverride = "https://api.example.com"; - options.TokenValidation = new TokenValidationOptions + options.OAuthConfigurations.Add(new OAuth2Configuration { - EnableJwtValidation = true, - ValidateAudience = true - }; - }); - - var client = host.GetTestClient(); - - // Create a JWT token with wrong audience - var token = CreateJwt(new - { - sub = "user123", - aud = "https://other-api.example.com", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + AuthorizationUrl = "https://auth.example.com/authorize", + TokenUrl = "https://auth.example.com/token" + }); }); - client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - var response = await client.GetAsync("/mcp"); + using var request = CreateJsonRpcRequest(); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token"); + var response = await app.GetTestClient().SendAsync(request); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - var authHeader = response.Headers.WwwAuthenticate.ToString(); - Assert.Contains("error=\"invalid_token\"", authHeader); - Assert.Contains("audience", authHeader.ToLower()); + var authenticateHeader = string.Join(" ", response.Headers.WwwAuthenticate.Select(h => h.ToString())); + Assert.Contains("resource_metadata=\"http://localhost/.well-known/oauth-protected-resource", authenticateHeader); } [Fact] - public async Task Request_Returns403_WhenTokenHasInsufficientScopes() + public async Task Request_Returns200_WhenNoOAuthConfigured() { - using var host = await CreateHostAsync(services => - { - var store = services.GetRequiredService(); - store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); - }, options => - { - options.TokenValidation = new TokenValidationOptions - { - EnableJwtValidation = true, - ValidateScopes = true, - DefaultRequiredScopes = new List { "mcp.access" } - }; - }); - - var client = host.GetTestClient(); + await using var app = await CreateHostAsync(); - // Create a JWT token without required scope - var token = CreateJwt(new - { - sub = "user123", - scope = "read write", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - var response = await client.GetAsync("/mcp"); + using var request = CreateJsonRpcRequest(); + var response = await app.GetTestClient().SendAsync(request); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var authHeader = response.Headers.WwwAuthenticate.ToString(); - Assert.Contains("error=\"insufficient_scope\"", authHeader); - Assert.Contains("mcp.access", authHeader); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); } - [Fact] - public async Task Request_Succeeds_WhenTokenHasRequiredScopes() + private static HttpRequestMessage CreateJsonRpcRequest() { - using var host = await CreateHostAsync(services => - { - var store = services.GetRequiredService(); - store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); - }, options => - { - options.TokenValidation = new TokenValidationOptions - { - EnableJwtValidation = true, - ValidateScopes = true, - DefaultRequiredScopes = new List { "mcp.access" } - }; - }); - - var client = host.GetTestClient(); - - // Create a JWT token with required scope - var token = CreateJwt(new - { - sub = "user123", - scope = "mcp.access read write", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - var response = await client.GetAsync("/mcp"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var request = new HttpRequestMessage(HttpMethod.Post, "/mcp"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + request.Content = new StringContent(JsonRpcContent, Encoding.UTF8, "application/json"); + return request; } - [Fact] - public async Task Request_Succeeds_WhenTokenValidationDisabled() + private static async Task CreateHostAsync(Action? configureOptions = null) { - using var host = await CreateHostAsync(services => - { - var store = services.GetRequiredService(); - store.AddConfiguration(new OAuth2Configuration { AuthorizationUrl = "https://auth" }); - }); - - var client = host.GetTestClient(); + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); - // Create an expired JWT token - should still work when validation is disabled - var expiredToken = CreateJwt(new + builder.Services.AddLogging(); + builder.Services.AddAuthorization(); + builder.Services.AddMcpify(options => { - sub = "user123", - exp = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds() + options.Transport = McpTransportType.Http; + configureOptions?.Invoke(options); }); - client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", expiredToken); - - var response = await client.GetAsync("/mcp"); - - // Token validation is disabled by default, so expired token should be accepted - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - private static string CreateJwt(object payload) - { - var header = new { alg = "HS256", typ = "JWT" }; - var headerJson = JsonSerializer.Serialize(header); - var payloadJson = JsonSerializer.Serialize(payload); - - var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); - var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); - var signatureB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("dummy-signature")); - return $"{headerB64}.{payloadB64}.{signatureB64}"; - } + var app = builder.Build(); + app.UseMcpifyContext(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapMcpifyEndpoint("/mcp"); - private static string Base64UrlEncode(byte[] bytes) - { - return Convert.ToBase64String(bytes) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - } - - private async Task CreateHostAsync(Action? configure = null, Action? configureOptions = null) - { - return await new HostBuilder() - .ConfigureWebHost(webBuilder => - { - webBuilder - .UseTestServer() - .ConfigureServices(services => - { - services.AddMcpify(options => - { - configureOptions?.Invoke(options); - }); - services.AddLogging(); - }) - .Configure(app => - { - configure?.Invoke(app.ApplicationServices); - app.UseMcpifyOAuth(); - app.Map("/mcp", b => b.Run(async c => - { - c.Response.StatusCode = 200; - await c.Response.WriteAsync("OK"); - })); - }); - }) - .StartAsync(); + await app.StartAsync(); + return app; } } diff --git a/Tests/MCPify.Tests/Integration/OpenApiOAuthScopeIntegrationTests.cs b/Tests/MCPify.Tests/Integration/OpenApiOAuthScopeIntegrationTests.cs index c4f1949..673a576 100644 --- a/Tests/MCPify.Tests/Integration/OpenApiOAuthScopeIntegrationTests.cs +++ b/Tests/MCPify.Tests/Integration/OpenApiOAuthScopeIntegrationTests.cs @@ -1,4 +1,8 @@ +using System.Collections.Generic; +using System.Linq; using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Text.Json; using MCPify.Core; @@ -7,426 +11,177 @@ using MCPify.OpenApi; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.OpenApi.Models; namespace MCPify.Tests.Integration; -/// -/// Integration tests verifying that OAuth scopes from OpenAPI specs -/// are automatically enforced during token validation. -/// public class OpenApiOAuthScopeIntegrationTests { - #region Test Helpers - - private static string CreateJwt(object payload) - { - var header = new { alg = "HS256", typ = "JWT" }; - var headerJson = JsonSerializer.Serialize(header); - var payloadJson = JsonSerializer.Serialize(payload); - - var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); - var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); - var signatureB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("dummy-signature")); - - return $"{headerB64}.{payloadB64}.{signatureB64}"; - } - - private static string Base64UrlEncode(byte[] bytes) - { - return Convert.ToBase64String(bytes) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - } - - /// - /// Creates an OpenAPI document with OAuth2 security scheme containing specified scopes. - /// - private static OpenApiDocument CreateOpenApiDocWithOAuth(params string[] scopes) - { - var scopeDict = scopes.ToDictionary(s => s, s => $"{s} access"); - - return new OpenApiDocument - { - Info = new OpenApiInfo { Title = "Test API", Version = "1.0" }, - Paths = new OpenApiPaths - { - ["/test"] = new OpenApiPathItem - { - Operations = new Dictionary - { - [OperationType.Get] = new OpenApiOperation - { - OperationId = "getTest", - Responses = new OpenApiResponses - { - ["200"] = new OpenApiResponse { Description = "OK" } - } - } - } - } - }, - Components = new OpenApiComponents - { - SecuritySchemes = new Dictionary - { - ["oauth2"] = new OpenApiSecurityScheme - { - Type = SecuritySchemeType.OAuth2, - Flows = new OpenApiOAuthFlows - { - AuthorizationCode = new OpenApiOAuthFlow - { - AuthorizationUrl = new Uri("https://auth.example.com/authorize"), - TokenUrl = new Uri("https://auth.example.com/token"), - Scopes = scopeDict - } - } - } - } - } - }; - } - - #endregion - [Fact] public void OpenApiParser_ExtractsScopes_AndAddsToStore() { - // Arrange var parser = new OpenApiOAuthParser(); var store = new OAuthConfigurationStore(); var doc = CreateOpenApiDocWithOAuth("read", "write", "admin"); - // Act var config = parser.Parse(doc); if (config != null) { store.AddConfiguration(config); } - // Assert Assert.NotNull(config); - Assert.Equal(3, config.Scopes.Count); + Assert.Equal(3, config!.Scopes.Count); Assert.Contains("read", config.Scopes.Keys); Assert.Contains("write", config.Scopes.Keys); Assert.Contains("admin", config.Scopes.Keys); - var storedConfigs = store.GetConfigurations().ToList(); - Assert.Single(storedConfigs); - Assert.Equal(3, storedConfigs[0].Scopes.Count); + var stored = store.GetConfigurations().ToList(); + Assert.Single(stored); + Assert.Equal(3, stored[0].Scopes.Count); } [Fact] - public void ScopeRequirementStore_UsesOpenApiScopes_WhenRequireOAuthConfiguredScopesEnabled() + public async Task MetadataEndpoint_ReturnsScopes_FromOpenApiConfiguration() { - // Arrange var parser = new OpenApiOAuthParser(); - var oauthStore = new OAuthConfigurationStore(); - var doc = CreateOpenApiDocWithOAuth("api.read", "api.write"); - - var config = parser.Parse(doc); - oauthStore.AddConfiguration(config!); + var config = parser.Parse(CreateOpenApiDocWithOAuth("api.read", "api.write")); + Assert.NotNull(config); - var options = new TokenValidationOptions + await using var app = await CreateHostAsync(options => { - RequireOAuthConfiguredScopes = true - }; - var scopeStore = new ScopeRequirementStore(new List(), options, oauthStore); + options.OAuthConfigurations.Add(config!); + }); + + var client = app.GetTestClient(); + var response = await client.GetAsync("/.well-known/oauth-protected-resource"); - // Act & Assert - Token with all scopes passes - var validResult = scopeStore.ValidateScopesForTool("any_tool", new[] { "api.read", "api.write" }); - Assert.True(validResult.IsValid); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Token missing one scope fails - var invalidResult = scopeStore.ValidateScopesForTool("any_tool", new[] { "api.read" }); - Assert.False(invalidResult.IsValid); - Assert.Contains("api.write", invalidResult.MissingScopes); + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var scopes = payload.RootElement.GetProperty("scopes_supported").EnumerateArray() + .Select(element => element.GetString()) + .Where(scope => scope != null) + .ToList(); - // Token with no scopes fails - var emptyResult = scopeStore.ValidateScopesForTool("any_tool", Array.Empty()); - Assert.False(emptyResult.IsValid); - Assert.Contains("api.read", emptyResult.MissingScopes); - Assert.Contains("api.write", emptyResult.MissingScopes); + Assert.Contains("api.read", scopes); + Assert.Contains("api.write", scopes); } [Fact] - public async Task Middleware_Returns403_WhenTokenLacksOpenApiDefinedScopes() + public async Task Challenge_ListsScopes_FromOpenApiConfiguration() { - // This test simulates the full flow: - // 1. OAuth config with scopes is added to store (simulating OpenAPI parsing) - // 2. Token validation is enabled with RequireOAuthConfiguredScopes - // 3. Request with token missing scopes gets 403 - - using var host = await new HostBuilder() - .ConfigureWebHost(webBuilder => - { - webBuilder - .UseTestServer() - .ConfigureServices(services => - { - services.AddMcpify(options => - { - options.TokenValidation = new TokenValidationOptions - { - EnableJwtValidation = true, - ValidateScopes = true, - RequireOAuthConfiguredScopes = true - }; - }); - services.AddLogging(); - }) - .Configure(app => - { - // Simulate OAuth config from OpenAPI (normally done by McpifyServiceRegistrar) - var oauthStore = app.ApplicationServices.GetRequiredService(); - oauthStore.AddConfiguration(new OAuth2Configuration - { - AuthorizationUrl = "https://auth.example.com/authorize", - TokenUrl = "https://auth.example.com/token", - Scopes = new Dictionary - { - { "api.read", "Read API" }, - { "api.write", "Write API" } - } - }); - - app.UseMcpifyOAuth(); - app.Map("/mcp", b => b.Run(async c => - { - c.Response.StatusCode = 200; - await c.Response.WriteAsync("OK"); - })); - }); - }) - .StartAsync(); - - var client = host.GetTestClient(); + var parser = new OpenApiOAuthParser(); + var config = parser.Parse(CreateOpenApiDocWithOAuth("api.read", "api.write")); + Assert.NotNull(config); - // Token with only 'api.read' scope - missing 'api.write' - var token = CreateJwt(new + await using var app = await CreateHostAsync(options => { - sub = "user123", - scope = "api.read", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + options.OAuthConfigurations.Add(config!); }); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - // Act - var response = await client.GetAsync("/mcp"); + using var request = CreateJsonRpcRequest(); + var response = await app.GetTestClient().SendAsync(request); - // Assert - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - var authHeader = response.Headers.WwwAuthenticate.ToString(); - Assert.Contains("insufficient_scope", authHeader); - Assert.Contains("api.write", authHeader); - } + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var header = string.Join(" ", response.Headers.WwwAuthenticate.Select(h => h.ToString())); + Assert.Contains("resource_metadata=\"http://localhost/.well-known/oauth-protected-resource", header); - [Fact] - public async Task Middleware_Returns200_WhenTokenHasAllOpenApiDefinedScopes() - { - using var host = await new HostBuilder() - .ConfigureWebHost(webBuilder => - { - webBuilder - .UseTestServer() - .ConfigureServices(services => - { - services.AddMcpify(options => - { - options.TokenValidation = new TokenValidationOptions - { - EnableJwtValidation = true, - ValidateScopes = true, - RequireOAuthConfiguredScopes = true - }; - }); - services.AddLogging(); - }) - .Configure(app => - { - // Simulate OAuth config from OpenAPI - var oauthStore = app.ApplicationServices.GetRequiredService(); - oauthStore.AddConfiguration(new OAuth2Configuration - { - AuthorizationUrl = "https://auth.example.com/authorize", - TokenUrl = "https://auth.example.com/token", - Scopes = new Dictionary - { - { "api.read", "Read API" }, - { "api.write", "Write API" } - } - }); - - app.UseMcpifyOAuth(); - app.Map("/mcp", b => b.Run(async c => - { - c.Response.StatusCode = 200; - await c.Response.WriteAsync("OK"); - })); - }); - }) - .StartAsync(); - - var client = host.GetTestClient(); - - // Token with all required scopes - var token = CreateJwt(new - { - sub = "user123", - scope = "api.read api.write", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + var metadataPayload = await app.GetTestClient().GetStringAsync("/.well-known/oauth-protected-resource"); + using var metadata = JsonDocument.Parse(metadataPayload); + var scopes = metadata.RootElement.GetProperty("scopes_supported").EnumerateArray() + .Select(element => element.GetString()) + .Where(scope => scope != null) + .ToList(); - // Act - var response = await client.GetAsync("/mcp"); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("api.read", scopes); + Assert.Contains("api.write", scopes); } - [Fact] - public async Task Middleware_IgnoresOpenApiScopes_WhenRequireOAuthConfiguredScopesDisabled() + private static HttpRequestMessage CreateJsonRpcRequest() { - using var host = await new HostBuilder() - .ConfigureWebHost(webBuilder => - { - webBuilder - .UseTestServer() - .ConfigureServices(services => - { - services.AddMcpify(options => - { - options.TokenValidation = new TokenValidationOptions - { - EnableJwtValidation = true, - ValidateScopes = true, - RequireOAuthConfiguredScopes = false // Disabled - }; - }); - services.AddLogging(); - }) - .Configure(app => - { - // OAuth config exists but RequireOAuthConfiguredScopes is false - var oauthStore = app.ApplicationServices.GetRequiredService(); - oauthStore.AddConfiguration(new OAuth2Configuration - { - AuthorizationUrl = "https://auth.example.com/authorize", - Scopes = new Dictionary - { - { "api.read", "Read API" }, - { "api.write", "Write API" } - } - }); - - app.UseMcpifyOAuth(); - app.Map("/mcp", b => b.Run(async c => - { - c.Response.StatusCode = 200; - await c.Response.WriteAsync("OK"); - })); - }); - }) - .StartAsync(); - - var client = host.GetTestClient(); - - // Token with NO scopes - should still pass since RequireOAuthConfiguredScopes is false - var token = CreateJwt(new + var request = new HttpRequestMessage(HttpMethod.Post, "/mcp"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + request.Content = new StringContent(""" { - sub = "user123", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); - - // Act - var response = await client.GetAsync("/mcp"); - - // Assert - Should pass because OAuth scopes are not required - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + "jsonrpc": "2.0", + "method": "ping", + "params": {}, + "id": 1 + } + """, Encoding.UTF8, "application/json"); + return request; } - [Fact] - public async Task Middleware_CombinesOpenApiScopes_WithDefaultRequiredScopes() + private static OpenApiDocument CreateOpenApiDocWithOAuth(params string[] scopes) { - using var host = await new HostBuilder() - .ConfigureWebHost(webBuilder => + var scopeDict = scopes.ToDictionary(scope => scope, scope => $"{scope} access"); + + return new OpenApiDocument + { + Info = new OpenApiInfo { Title = "Test API", Version = "1.0" }, + Paths = new OpenApiPaths { - webBuilder - .UseTestServer() - .ConfigureServices(services => + ["/test"] = new OpenApiPathItem + { + Operations = new Dictionary { - services.AddMcpify(options => + [OperationType.Get] = new OpenApiOperation { - options.TokenValidation = new TokenValidationOptions + OperationId = "getTest", + Responses = new OpenApiResponses { - EnableJwtValidation = true, - ValidateScopes = true, - RequireOAuthConfiguredScopes = true, - DefaultRequiredScopes = new List { "mcp.access" } // Additional scope - }; - }); - services.AddLogging(); - }) - .Configure(app => + ["200"] = new OpenApiResponse { Description = "OK" } + } + } + } + } + }, + Components = new OpenApiComponents + { + SecuritySchemes = new Dictionary + { + ["oauth2"] = new OpenApiSecurityScheme { - var oauthStore = app.ApplicationServices.GetRequiredService(); - oauthStore.AddConfiguration(new OAuth2Configuration + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows { - AuthorizationUrl = "https://auth.example.com/authorize", - Scopes = new Dictionary + AuthorizationCode = new OpenApiOAuthFlow { - { "api.read", "Read API" } + AuthorizationUrl = new Uri("https://auth.example.com/authorize"), + TokenUrl = new Uri("https://auth.example.com/token"), + Scopes = scopeDict } - }); - - app.UseMcpifyOAuth(); - app.Map("/mcp", b => b.Run(async c => - { - c.Response.StatusCode = 200; - await c.Response.WriteAsync("OK"); - })); - }); - }) - .StartAsync(); + } + } + } + } + }; + } - var client = host.GetTestClient(); + private static async Task CreateHostAsync(Action? configureOptions = null) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); - // Token with OpenAPI scope but missing default scope - var tokenMissingDefault = CreateJwt(new + builder.Services.AddLogging(); + builder.Services.AddAuthorization(); + builder.Services.AddMcpify(options => { - sub = "user123", - scope = "api.read", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + options.Transport = McpTransportType.Http; + configureOptions?.Invoke(options); }); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenMissingDefault); - - var response1 = await client.GetAsync("/mcp"); - Assert.Equal(HttpStatusCode.Forbidden, response1.StatusCode); - Assert.Contains("mcp.access", response1.Headers.WwwAuthenticate.ToString()); - // Token with all scopes (OpenAPI + default) - var tokenComplete = CreateJwt(new - { - sub = "user123", - scope = "api.read mcp.access", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenComplete); + var app = builder.Build(); + app.UseMcpifyContext(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapMcpifyEndpoint("/mcp"); - var response2 = await client.GetAsync("/mcp"); - Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + await app.StartAsync(); + return app; } } diff --git a/Tests/MCPify.Tests/Unit/JwtAccessTokenValidatorTests.cs b/Tests/MCPify.Tests/Unit/JwtAccessTokenValidatorTests.cs index cd5ec6c..a2c9f81 100644 --- a/Tests/MCPify.Tests/Unit/JwtAccessTokenValidatorTests.cs +++ b/Tests/MCPify.Tests/Unit/JwtAccessTokenValidatorTests.cs @@ -1,351 +1,27 @@ -using System.Text; -using System.Text.Json; +using System.Linq; +using System.Threading.Tasks; using MCPify.Core.Auth; namespace MCPify.Tests.Unit; public class JwtAccessTokenValidatorTests { - private static string CreateJwt(object payload) - { - var header = new { alg = "HS256", typ = "JWT" }; - var headerJson = JsonSerializer.Serialize(header); - var payloadJson = JsonSerializer.Serialize(payload); - - var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); - var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); - - // Signature is not validated by JwtAccessTokenValidator, so we can use a dummy - var signatureB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("dummy-signature")); - - return $"{headerB64}.{payloadB64}.{signatureB64}"; - } - - private static string Base64UrlEncode(byte[] bytes) - { - return Convert.ToBase64String(bytes) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - } - - [Fact] - public async Task ValidateAsync_ExtractsScopesFromString() - { - var options = new TokenValidationOptions(); - var validator = new JwtAccessTokenValidator(options); - - var token = CreateJwt(new - { - sub = "user123", - scope = "read write admin", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - - var result = await validator.ValidateAsync(token, null); - - Assert.True(result.IsValid); - Assert.Equal(3, result.Scopes.Count); - Assert.Contains("read", result.Scopes); - Assert.Contains("write", result.Scopes); - Assert.Contains("admin", result.Scopes); - } - - [Fact] - public async Task ValidateAsync_ExtractsScopesFromArray() - { - var options = new TokenValidationOptions(); - var validator = new JwtAccessTokenValidator(options); - - var token = CreateJwt(new - { - sub = "user123", - scope = new[] { "read", "write", "admin" }, - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - - var result = await validator.ValidateAsync(token, null); - - Assert.True(result.IsValid); - Assert.Equal(3, result.Scopes.Count); - Assert.Contains("read", result.Scopes); - Assert.Contains("write", result.Scopes); - Assert.Contains("admin", result.Scopes); - } - - [Fact] - public async Task ValidateAsync_ExtractsScopesFromScpClaim() - { - var options = new TokenValidationOptions(); - var validator = new JwtAccessTokenValidator(options); - - var token = CreateJwt(new - { - sub = "user123", - scp = "read write", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - - var result = await validator.ValidateAsync(token, null); - - Assert.True(result.IsValid); - Assert.Equal(2, result.Scopes.Count); - Assert.Contains("read", result.Scopes); - Assert.Contains("write", result.Scopes); - } - - [Fact] - public async Task ValidateAsync_ExtractsAudienceFromString() - { - var options = new TokenValidationOptions(); - var validator = new JwtAccessTokenValidator(options); - - var token = CreateJwt(new - { - sub = "user123", - aud = "https://api.example.com", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - - var result = await validator.ValidateAsync(token, null); - - Assert.True(result.IsValid); - Assert.Single(result.Audiences); - Assert.Equal("https://api.example.com", result.Audiences[0]); - } - - [Fact] - public async Task ValidateAsync_ExtractsAudienceFromArray() - { - var options = new TokenValidationOptions(); - var validator = new JwtAccessTokenValidator(options); - - var token = CreateJwt(new - { - sub = "user123", - aud = new[] { "https://api1.example.com", "https://api2.example.com" }, - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - - var result = await validator.ValidateAsync(token, null); - - Assert.True(result.IsValid); - Assert.Equal(2, result.Audiences.Count); - Assert.Contains("https://api1.example.com", result.Audiences); - Assert.Contains("https://api2.example.com", result.Audiences); - } - - [Fact] - public async Task ValidateAsync_FailsWhenTokenExpired() - { - var options = new TokenValidationOptions { ClockSkew = TimeSpan.Zero }; - var validator = new JwtAccessTokenValidator(options); - - var token = CreateJwt(new - { - sub = "user123", - exp = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds() - }); - - var result = await validator.ValidateAsync(token, null); - - Assert.False(result.IsValid); - Assert.Equal("invalid_token", result.ErrorCode); - Assert.Contains("expired", result.ErrorDescription, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ValidateAsync_SucceedsWithClockSkew() - { - var options = new TokenValidationOptions { ClockSkew = TimeSpan.FromMinutes(10) }; - var validator = new JwtAccessTokenValidator(options); - - // Token expired 5 minutes ago, but clock skew is 10 minutes - var token = CreateJwt(new - { - sub = "user123", - exp = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeSeconds() - }); - - var result = await validator.ValidateAsync(token, null); - - Assert.True(result.IsValid); - } - - [Fact] - public async Task ValidateAsync_ValidatesAudienceWhenEnabled() - { - var options = new TokenValidationOptions { ValidateAudience = true }; - var validator = new JwtAccessTokenValidator(options); - - var token = CreateJwt(new - { - sub = "user123", - aud = "https://api.example.com", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - - var result = await validator.ValidateAsync(token, "https://other.example.com"); - - Assert.False(result.IsValid); - Assert.Equal("invalid_token", result.ErrorCode); - Assert.Contains("audience", result.ErrorDescription, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task ValidateAsync_PassesWhenAudienceMatches() - { - var options = new TokenValidationOptions { ValidateAudience = true }; - var validator = new JwtAccessTokenValidator(options); - - var token = CreateJwt(new - { - sub = "user123", - aud = "https://api.example.com", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - - var result = await validator.ValidateAsync(token, "https://api.example.com"); - - Assert.True(result.IsValid); - } - - [Fact] - public async Task ValidateAsync_PassesWhenAudienceMatchesInArray() - { - var options = new TokenValidationOptions { ValidateAudience = true }; - var validator = new JwtAccessTokenValidator(options); - - var token = CreateJwt(new - { - sub = "user123", - aud = new[] { "https://api1.example.com", "https://api2.example.com" }, - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - - var result = await validator.ValidateAsync(token, "https://api2.example.com"); - - Assert.True(result.IsValid); - } - - [Fact] - public async Task ValidateAsync_SkipsAudienceValidationWhenNull() - { - var options = new TokenValidationOptions { ValidateAudience = true }; - var validator = new JwtAccessTokenValidator(options); - - var token = CreateJwt(new - { - sub = "user123", - aud = "https://api.example.com", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - - // When expectedAudience is null, validation is skipped - var result = await validator.ValidateAsync(token, null); - - Assert.True(result.IsValid); - } - - [Fact] - public async Task ValidateAsync_ExtractsSubjectAndIssuer() - { - var options = new TokenValidationOptions(); - var validator = new JwtAccessTokenValidator(options); - - var token = CreateJwt(new - { - sub = "user123", - iss = "https://auth.example.com", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); - - var result = await validator.ValidateAsync(token, null); - - Assert.True(result.IsValid); - Assert.Equal("user123", result.Subject); - Assert.Equal("https://auth.example.com", result.Issuer); - } - - [Fact] - public async Task ValidateAsync_FailsForInvalidJwtFormat() - { - var options = new TokenValidationOptions(); - var validator = new JwtAccessTokenValidator(options); - - var result = await validator.ValidateAsync("not-a-jwt", null); - - Assert.False(result.IsValid); - Assert.Equal("invalid_token", result.ErrorCode); - } - - [Fact] - public async Task ValidateAsync_FailsForInvalidBase64() - { - var options = new TokenValidationOptions(); - var validator = new JwtAccessTokenValidator(options); - - var result = await validator.ValidateAsync("header.!!!invalid-base64!!!.signature", null); - - Assert.False(result.IsValid); - Assert.Equal("invalid_token", result.ErrorCode); - } - [Fact] - public async Task ValidateAsync_FailsForInvalidJson() + public void OAuthConfigurationStore_ReturnsCopyOfConfigurations() { - var options = new TokenValidationOptions(); - var validator = new JwtAccessTokenValidator(options); - - var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("{}")); - var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes("not-json")); - var token = $"{headerB64}.{payloadB64}.signature"; - - var result = await validator.ValidateAsync(token, null); - - Assert.False(result.IsValid); - Assert.Equal("invalid_token", result.ErrorCode); - } - - [Fact] - public async Task ValidateAsync_ExtractsExpirationTime() - { - var options = new TokenValidationOptions(); - var validator = new JwtAccessTokenValidator(options); - - var expTime = DateTimeOffset.UtcNow.AddHours(2); - var token = CreateJwt(new - { - sub = "user123", - exp = expTime.ToUnixTimeSeconds() - }); - - var result = await validator.ValidateAsync(token, null); - - Assert.True(result.IsValid); - Assert.NotNull(result.ExpiresAt); - // Allow 1 second tolerance for test execution time - Assert.True(Math.Abs((result.ExpiresAt.Value - expTime).TotalSeconds) < 1); - } - - [Fact] - public async Task ValidateAsync_UsesConfiguredScopeClaimName() - { - var options = new TokenValidationOptions { ScopeClaimName = "permissions" }; - var validator = new JwtAccessTokenValidator(options); - - var token = CreateJwt(new - { - sub = "user123", - permissions = "read write", - exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() - }); + var store = new OAuthConfigurationStore(); + var config = new OAuth2Configuration { AuthorizationUrl = "https://auth.example.com" }; - var result = await validator.ValidateAsync(token, null); + store.AddConfiguration(config); + var firstSnapshot = store.GetConfigurations().ToList(); + var secondSnapshot = store.GetConfigurations().ToList(); - Assert.True(result.IsValid); - Assert.Equal(2, result.Scopes.Count); - Assert.Contains("read", result.Scopes); - Assert.Contains("write", result.Scopes); + Assert.Single(firstSnapshot); + Assert.Single(secondSnapshot); + Assert.NotSame(firstSnapshot, secondSnapshot); + firstSnapshot.Clear(); + var thirdSnapshot = store.GetConfigurations().ToList(); + Assert.Single(thirdSnapshot); + Assert.Same(config, thirdSnapshot[0]); } } diff --git a/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs b/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs index 78d955b..f4c4d08 100644 --- a/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs +++ b/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs @@ -1,12 +1,45 @@ +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +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; namespace MCPify.Tests.Unit; public class ScopeRequirementTests { - #region ScopeRequirement Pattern Matching Tests + [Fact] + public async Task MetadataEndpoint_IncludesAuthorizationServers_FromOAuthConfiguration() + { + await using var app = await CreateHostAsync(options => + { + options.OAuthConfigurations.Add(new OAuth2Configuration + { + AuthorizationUrl = "https://auth.example.com/authorize", + TokenUrl = "https://auth.example.com/token" + }); + }); + + var response = await app.GetTestClient().GetAsync("/.well-known/oauth-protected-resource"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var servers = payload.RootElement.GetProperty("authorization_servers") + .EnumerateArray() + .Select(element => element.GetString()) + .Where(uri => !string.IsNullOrWhiteSpace(uri)) + .ToList(); + + Assert.Contains("https://auth.example.com/", servers); + } - [Theory] [InlineData("admin_users", "admin_users", true)] [InlineData("admin_users", "admin_roles", false)] [InlineData("admin_*", "admin_users", true)] @@ -23,324 +56,31 @@ public class ScopeRequirementTests [InlineData("tool_*_admin", "tool_admin", false)] public void Matches_WorksWithPatterns(string pattern, string toolName, bool expected) { - var requirement = new ScopeRequirement { Pattern = pattern }; - Assert.Equal(expected, requirement.Matches(toolName)); - } - - [Fact] - public void Matches_IsCaseInsensitive() - { - var requirement = new ScopeRequirement { Pattern = "Admin_*" }; - - Assert.True(requirement.Matches("admin_users")); - Assert.True(requirement.Matches("ADMIN_ROLES")); - Assert.True(requirement.Matches("Admin_Tools")); - } - - #endregion - - #region ScopeRequirement Scope Validation Tests - - [Fact] - public void IsSatisfiedBy_RequiresAllRequiredScopes() - { - var requirement = new ScopeRequirement - { - Pattern = "admin_*", - RequiredScopes = new List { "admin", "write" } - }; - - Assert.True(requirement.IsSatisfiedBy(new[] { "admin", "write", "read" })); - Assert.False(requirement.IsSatisfiedBy(new[] { "admin", "read" })); - Assert.False(requirement.IsSatisfiedBy(new[] { "write", "read" })); - Assert.False(requirement.IsSatisfiedBy(new[] { "read" })); + _ = pattern; + _ = toolName; + _ = expected; } - [Fact] - public void IsSatisfiedBy_RequiresAnyOfScopes() + private static async Task CreateHostAsync(Action? configureOptions = null) { - var requirement = new ScopeRequirement - { - Pattern = "api_*", - AnyOfScopes = new List { "read", "write" } - }; + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); - Assert.True(requirement.IsSatisfiedBy(new[] { "read" })); - Assert.True(requirement.IsSatisfiedBy(new[] { "write" })); - Assert.True(requirement.IsSatisfiedBy(new[] { "read", "write" })); - Assert.False(requirement.IsSatisfiedBy(new[] { "admin" })); - Assert.False(requirement.IsSatisfiedBy(Array.Empty())); - } - - [Fact] - public void IsSatisfiedBy_CombinesRequiredAndAnyOf() - { - var requirement = new ScopeRequirement + builder.Services.AddLogging(); + builder.Services.AddAuthorization(); + builder.Services.AddMcpify(options => { - Pattern = "admin_*", - RequiredScopes = new List { "admin" }, - AnyOfScopes = new List { "read", "write" } - }; - - Assert.True(requirement.IsSatisfiedBy(new[] { "admin", "read" })); - Assert.True(requirement.IsSatisfiedBy(new[] { "admin", "write" })); - Assert.True(requirement.IsSatisfiedBy(new[] { "admin", "read", "write" })); - Assert.False(requirement.IsSatisfiedBy(new[] { "admin" })); // Missing any of read/write - Assert.False(requirement.IsSatisfiedBy(new[] { "read" })); // Missing required admin - Assert.False(requirement.IsSatisfiedBy(new[] { "read", "write" })); // Missing required admin - } - - [Fact] - public void IsSatisfiedBy_IsCaseInsensitive() - { - var requirement = new ScopeRequirement - { - Pattern = "api_*", - RequiredScopes = new List { "Admin" }, - AnyOfScopes = new List { "Read" } - }; - - Assert.True(requirement.IsSatisfiedBy(new[] { "ADMIN", "read" })); - Assert.True(requirement.IsSatisfiedBy(new[] { "admin", "READ" })); - } - - [Fact] - public void IsSatisfiedBy_PassesWithEmptyRequirements() - { - var requirement = new ScopeRequirement { Pattern = "api_*" }; - - Assert.True(requirement.IsSatisfiedBy(Array.Empty())); - Assert.True(requirement.IsSatisfiedBy(new[] { "any", "scope" })); - } - - [Fact] - public void GetAllRequiredScopes_ReturnsAllScopes() - { - var requirement = new ScopeRequirement - { - Pattern = "api_*", - RequiredScopes = new List { "admin", "write" }, - AnyOfScopes = new List { "read", "execute" } - }; - - var allScopes = requirement.GetAllRequiredScopes().ToList(); - - Assert.Equal(4, allScopes.Count); - Assert.Contains("admin", allScopes); - Assert.Contains("write", allScopes); - Assert.Contains("read", allScopes); - Assert.Contains("execute", allScopes); - } - - #endregion - - #region ScopeRequirementStore Tests - - [Fact] - public void GetRequirementsForTool_ReturnsMatchingRequirements() - { - var options = new TokenValidationOptions(); - var requirements = new List - { - new() { Pattern = "admin_*", RequiredScopes = new List { "admin" } }, - new() { Pattern = "api_*", RequiredScopes = new List { "api" } }, - new() { Pattern = "*_users", RequiredScopes = new List { "users" } } - }; - var store = new ScopeRequirementStore(requirements, options); - - var adminUsersReqs = store.GetRequirementsForTool("admin_users").ToList(); - Assert.Equal(2, adminUsersReqs.Count); // Matches "admin_*" and "*_users" - - var apiGetReqs = store.GetRequirementsForTool("api_get").ToList(); - Assert.Single(apiGetReqs); // Matches only "api_*" - - var otherReqs = store.GetRequirementsForTool("other_tool").ToList(); - Assert.Empty(otherReqs); - } - - [Fact] - public void ValidateScopesForTool_ChecksDefaultScopes() - { - var options = new TokenValidationOptions - { - DefaultRequiredScopes = new List { "mcp.access" } - }; - var store = new ScopeRequirementStore(new List(), options); - - var result = store.ValidateScopesForTool("any_tool", new[] { "mcp.access" }); - Assert.True(result.IsValid); - - var failResult = store.ValidateScopesForTool("any_tool", new[] { "other" }); - Assert.False(failResult.IsValid); - Assert.Contains("mcp.access", failResult.MissingScopes); - } - - [Fact] - public void ValidateScopesForTool_ChecksToolSpecificScopes() - { - var options = new TokenValidationOptions(); - var requirements = new List - { - new() { Pattern = "admin_*", RequiredScopes = new List { "admin" } } - }; - var store = new ScopeRequirementStore(requirements, options); - - var result = store.ValidateScopesForTool("admin_users", new[] { "admin" }); - Assert.True(result.IsValid); - - var failResult = store.ValidateScopesForTool("admin_users", new[] { "user" }); - Assert.False(failResult.IsValid); - Assert.Contains("admin", failResult.MissingScopes); - } - - [Fact] - public void ValidateScopesForTool_CombinesDefaultAndToolSpecificScopes() - { - var options = new TokenValidationOptions - { - DefaultRequiredScopes = new List { "mcp.access" } - }; - var requirements = new List - { - new() { Pattern = "admin_*", RequiredScopes = new List { "admin" } } - }; - var store = new ScopeRequirementStore(requirements, options); - - var result = store.ValidateScopesForTool("admin_users", new[] { "mcp.access", "admin" }); - Assert.True(result.IsValid); - - var failResult = store.ValidateScopesForTool("admin_users", new[] { "mcp.access" }); - Assert.False(failResult.IsValid); - Assert.Contains("admin", failResult.MissingScopes); - - var failResult2 = store.ValidateScopesForTool("admin_users", new[] { "admin" }); - Assert.False(failResult2.IsValid); - Assert.Contains("mcp.access", failResult2.MissingScopes); - } - - [Fact] - public void GetRequiredScopesForTool_ReturnsAllRequiredScopes() - { - var options = new TokenValidationOptions - { - DefaultRequiredScopes = new List { "mcp.access" } - }; - var requirements = new List - { - new() { Pattern = "admin_*", RequiredScopes = new List { "admin" }, AnyOfScopes = new List { "read", "write" } } - }; - var store = new ScopeRequirementStore(requirements, options); - - var scopes = store.GetRequiredScopesForTool("admin_users").ToList(); - - Assert.Contains("mcp.access", scopes); - Assert.Contains("admin", scopes); - Assert.Contains("read", scopes); - Assert.Contains("write", scopes); - } - - [Fact] - public void ValidateScopesForTool_HandlesMultipleMatchingRequirements() - { - var options = new TokenValidationOptions(); - var requirements = new List - { - new() { Pattern = "admin_*", RequiredScopes = new List { "admin" } }, - new() { Pattern = "*_users", RequiredScopes = new List { "users.manage" } } - }; - var store = new ScopeRequirementStore(requirements, options); - - // admin_users matches both patterns, so both scopes are required - var result = store.ValidateScopesForTool("admin_users", new[] { "admin", "users.manage" }); - Assert.True(result.IsValid); - - var failResult = store.ValidateScopesForTool("admin_users", new[] { "admin" }); - Assert.False(failResult.IsValid); - Assert.Contains("users.manage", failResult.MissingScopes); - } - - [Fact] - public void ValidateScopesForTool_UsesOAuthConfiguredScopes_WhenEnabled() - { - var oauthStore = new OAuthConfigurationStore(); - oauthStore.AddConfiguration(new OAuth2Configuration - { - AuthorizationUrl = "https://auth", - Scopes = new Dictionary - { - { "read", "Read access" }, - { "write", "Write access" } - } + options.Transport = McpTransportType.Http; + configureOptions?.Invoke(options); }); - var options = new TokenValidationOptions - { - RequireOAuthConfiguredScopes = true - }; - var store = new ScopeRequirementStore(new List(), options, oauthStore); - - // Token with all OAuth-configured scopes should pass - var result = store.ValidateScopesForTool("any_tool", new[] { "read", "write" }); - Assert.True(result.IsValid); + var app = builder.Build(); + app.UseMcpifyContext(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapMcpifyEndpoint("/mcp"); - // Token missing one OAuth-configured scope should fail - var failResult = store.ValidateScopesForTool("any_tool", new[] { "read" }); - Assert.False(failResult.IsValid); - Assert.Contains("write", failResult.MissingScopes); + await app.StartAsync(); + return app; } - - [Fact] - public void ValidateScopesForTool_IgnoresOAuthScopes_WhenDisabled() - { - var oauthStore = new OAuthConfigurationStore(); - oauthStore.AddConfiguration(new OAuth2Configuration - { - AuthorizationUrl = "https://auth", - Scopes = new Dictionary - { - { "read", "Read access" }, - { "write", "Write access" } - } - }); - - var options = new TokenValidationOptions - { - RequireOAuthConfiguredScopes = false // disabled by default - }; - var store = new ScopeRequirementStore(new List(), options, oauthStore); - - // Token with no scopes should pass when OAuth scope checking is disabled - var result = store.ValidateScopesForTool("any_tool", Array.Empty()); - Assert.True(result.IsValid); - } - - [Fact] - public void GetRequiredScopesForTool_IncludesOAuthScopes_WhenEnabled() - { - var oauthStore = new OAuthConfigurationStore(); - oauthStore.AddConfiguration(new OAuth2Configuration - { - AuthorizationUrl = "https://auth", - Scopes = new Dictionary - { - { "api.read", "Read API" }, - { "api.write", "Write API" } - } - }); - - var options = new TokenValidationOptions - { - RequireOAuthConfiguredScopes = true, - DefaultRequiredScopes = new List { "mcp.access" } - }; - var store = new ScopeRequirementStore(new List(), options, oauthStore); - - var scopes = store.GetRequiredScopesForTool("any_tool").ToList(); - - Assert.Contains("mcp.access", scopes); - Assert.Contains("api.read", scopes); - Assert.Contains("api.write", scopes); - } - - #endregion } From 58d94b8f2be5765f4e9aef32af3a067689f34bc4 Mon Sep 17 00:00:00 2001 From: Erwin Date: Sat, 24 Jan 2026 20:51:15 +0100 Subject: [PATCH 6/8] add scope requirements back --- MCPify/Core/Auth/ScopeRequirement.cs | 69 +++++++++++++++++ MCPify/Core/Auth/ScopeRequirementHandler.cs | 75 +++++++++++++++++++ MCPify/Hosting/McpifyServiceExtensions.cs | 3 + MCPify/Tools/OpenApiProxyTool.cs | 41 +++++++++- .../Unit/ScopeRequirementTests.cs | 45 ++++++++++- 5 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 MCPify/Core/Auth/ScopeRequirement.cs create mode 100644 MCPify/Core/Auth/ScopeRequirementHandler.cs diff --git a/MCPify/Core/Auth/ScopeRequirement.cs b/MCPify/Core/Auth/ScopeRequirement.cs new file mode 100644 index 0000000..d4cecc7 --- /dev/null +++ b/MCPify/Core/Auth/ScopeRequirement.cs @@ -0,0 +1,69 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Authorization; + +namespace MCPify.Core.Auth; + +/// +/// Authorization requirement that enforces the presence of a matching OAuth scope. +/// Supports simple glob-style patterns ("*" and "?"). +/// +public sealed class ScopeRequirement : IAuthorizationRequirement, IAuthorizationRequirementData +{ + private string _pattern = "*"; + + /// + /// Initializes a new instance of the class. + /// + public ScopeRequirement() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The scope pattern that must match the granted scopes. + public ScopeRequirement(string pattern) + { + Pattern = pattern; + } + + /// + /// Gets or sets the scope pattern required for the protected resource. + /// The pattern supports '*' (zero or more characters) and '?' (single character) wildcards. + /// + public string Pattern + { + get => _pattern; + init => _pattern = string.IsNullOrWhiteSpace(value) ? "*" : value; + } + + /// + /// Evaluates whether the supplied scope value satisfies this requirement. + /// + /// The scope value to evaluate. + public bool Matches(string? scope) + { + if (scope is null) + { + return false; + } + + if (_pattern == "*") + { + return true; + } + + var comparison = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; + var regexPattern = "^" + Regex.Escape(_pattern) + .Replace("\\*", ".*") + .Replace("\\?", ".") + "$"; + + return Regex.IsMatch(scope, regexPattern, comparison); + } + + /// + public IEnumerable GetRequirements() + { + yield return this; + } +} diff --git a/MCPify/Core/Auth/ScopeRequirementHandler.cs b/MCPify/Core/Auth/ScopeRequirementHandler.cs new file mode 100644 index 0000000..0cc6e0b --- /dev/null +++ b/MCPify/Core/Auth/ScopeRequirementHandler.cs @@ -0,0 +1,75 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; + +namespace MCPify.Core.Auth; + +/// +/// Evaluates instances against the authenticated user's scopes. +/// +public sealed class ScopeRequirementHandler : AuthorizationHandler +{ + private static readonly string[] ScopeClaimTypes = + { + "scope", + "scp", + "http://schemas.microsoft.com/identity/claims/scope" + }; + + /// + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ScopeRequirement requirement) + { + if (context.User is null) + { + return Task.CompletedTask; + } + + var scopes = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var claim in context.User.Claims) + { + if (!ScopeClaimTypes.Contains(claim.Type)) + { + continue; + } + + if (string.IsNullOrWhiteSpace(claim.Value)) + { + continue; + } + + foreach (var entry in SplitScopes(claim.Value)) + { + scopes.Add(entry); + } + } + + if (scopes.Any(requirement.Matches)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + + private static IEnumerable SplitScopes(string value) + { + if (value.Contains(' ')) + { + foreach (var part in value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + yield return part; + } + } + else if (value.Contains(',')) + { + foreach (var part in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + yield return part; + } + } + else + { + yield return value.Trim(); + } + } +} diff --git a/MCPify/Hosting/McpifyServiceExtensions.cs b/MCPify/Hosting/McpifyServiceExtensions.cs index e7fed9e..4dac3c5 100644 --- a/MCPify/Hosting/McpifyServiceExtensions.cs +++ b/MCPify/Hosting/McpifyServiceExtensions.cs @@ -15,6 +15,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using ModelContextProtocol.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; namespace MCPify.Hosting; @@ -107,6 +108,8 @@ public static IServiceCollection AddMcpify( } services.AddSingleton(oauthStore); + services.AddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Transient, McpAuthenticationOptionsSetup>()); services.TryAddEnumerable(ServiceDescriptor.Transient, McpAuthenticationOptionsSetup>()); diff --git a/MCPify/Tools/OpenApiProxyTool.cs b/MCPify/Tools/OpenApiProxyTool.cs index 51ef522..54b2207 100644 --- a/MCPify/Tools/OpenApiProxyTool.cs +++ b/MCPify/Tools/OpenApiProxyTool.cs @@ -21,6 +21,7 @@ public class OpenApiProxyTool : McpServerTool private readonly OpenApiOperationDescriptor _descriptor; private readonly McpifyOptions _options; private readonly Func? _authenticationFactory; + private IReadOnlyList? _metadata; public OpenApiProxyTool( OpenApiOperationDescriptor descriptor, @@ -56,7 +57,7 @@ public OpenApiProxyTool( InputSchema = BuildInputSchema() }; - public override IReadOnlyList Metadata => Array.Empty(); + public override IReadOnlyList Metadata => _metadata ??= BuildMetadata(); public override async ValueTask InvokeAsync( RequestContext context, @@ -124,6 +125,44 @@ private JsonElement BuildInputSchema() return JsonSerializer.SerializeToElement(schemaNode); } + private IReadOnlyList BuildMetadata() + { + if (_descriptor.Operation.Security is null || _descriptor.Operation.Security.Count == 0) + { + return Array.Empty(); + } + + var patterns = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var requirement in _descriptor.Operation.Security) + { + foreach (var scopes in requirement.Values) + { + if (scopes is null) + { + continue; + } + + foreach (var scope in scopes) + { + if (string.IsNullOrWhiteSpace(scope)) + { + continue; + } + + patterns.Add(scope); + } + } + } + + if (patterns.Count == 0) + { + return Array.Empty(); + } + + return patterns.Select(pattern => (object)new ScopeRequirement(pattern)).ToArray(); + } + private HttpRequestMessage BuildHttpRequest(Dictionary? argsDict) { var route = _descriptor.Route; diff --git a/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs b/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs index f4c4d08..3e4bae4 100644 --- a/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs +++ b/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs @@ -1,12 +1,14 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Security.Claims; using System.Text.Json; using System.Threading.Tasks; using MCPify.Core; using MCPify.Core.Auth; using MCPify.Hosting; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; @@ -54,11 +56,46 @@ public async Task MetadataEndpoint_IncludesAuthorizationServers_FromOAuthConfigu [InlineData("tool_*_admin", "tool_user_admin", true)] [InlineData("tool_*_admin", "tool_role_admin", true)] [InlineData("tool_*_admin", "tool_admin", false)] - public void Matches_WorksWithPatterns(string pattern, string toolName, bool expected) + public void Matches_WorksWithPatterns(string pattern, string candidate, bool expected) { - _ = pattern; - _ = toolName; - _ = expected; + var requirement = new ScopeRequirement { Pattern = pattern }; + Assert.Equal(expected, requirement.Matches(candidate)); + } + + [Fact] + public void Matches_IsCaseInsensitive() + { + var requirement = new ScopeRequirement { Pattern = "Admin_*" }; + + Assert.True(requirement.Matches("admin_users")); + Assert.True(requirement.Matches("ADMIN_ROLES")); + Assert.True(requirement.Matches("Admin_Tools")); + } + + [Fact] + public async Task Handler_Succeeds_WhenScopeMatches() + { + var requirement = new ScopeRequirement("api.read"); + var handler = new ScopeRequirementHandler(); + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("scope", "api.read api.write") }, "test")); + + var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null); + await handler.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Fact] + public async Task Handler_Fails_WhenScopeMissing() + { + var requirement = new ScopeRequirement("api.admin"); + var handler = new ScopeRequirementHandler(); + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("scp", "api.read") }, "test")); + + var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null); + await handler.HandleAsync(context); + + Assert.False(context.HasSucceeded); } private static async Task CreateHostAsync(Action? configureOptions = null) From 315ac19073edbf487cdc6cddc803d35b291ca54f Mon Sep 17 00:00:00 2001 From: Erwin Date: Sat, 24 Jan 2026 20:56:13 +0100 Subject: [PATCH 7/8] readme --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index c7fd545..85a73f8 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,7 @@ var app = builder.Build(); // Add Middleware (order matters!) app.UseMcpifyContext(); // Must be first -app.UseMcpifyOAuth(); // Must come before UseAuthentication -app.UseAuthentication(); // If using ASP.NET Core auth +app.UseAuthentication(); app.UseAuthorization(); // ... Map your endpoints ... From 3213b35ea87515d972071bf0f627f38b4f6c2c23 Mon Sep 17 00:00:00 2001 From: Erwin Date: Sat, 24 Jan 2026 22:00:58 +0100 Subject: [PATCH 8/8] leverage SessionId from mcp sdk --- .gitignore | 3 +- MCPify/Core/McpContextAccessor.cs | 94 +------------- MCPify/Hosting/McpContextMiddleware.cs | 85 ------------ MCPify/Hosting/McpifyServiceExtensions.cs | 10 -- MCPify/Hosting/SessionAwareToolDecorator.cs | 121 ++---------------- MCPify/Tools/LoginTool.cs | 7 +- MCPify/Tools/SessionManagementTool.cs | 12 +- README.md | 1 - Sample/Program.cs | 1 - .../OAuthChallengeTokenValidationTests.cs | 1 - .../Integration/OAuthMiddlewareTests.cs | 1 - .../OpenApiOAuthScopeIntegrationTests.cs | 1 - .../Unit/ScopeRequirementTests.cs | 1 - 13 files changed, 31 insertions(+), 307 deletions(-) delete mode 100644 MCPify/Hosting/McpContextMiddleware.cs diff --git a/.gitignore b/.gitignore index 4d998de..f53e0f3 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,5 @@ demo_token.json AuthTokens/ Sample/AuthTokens/ -.nuget/ \ No newline at end of file +.nuget/ +temp/ \ No newline at end of file diff --git a/MCPify/Core/McpContextAccessor.cs b/MCPify/Core/McpContextAccessor.cs index 00a653a..c97685a 100644 --- a/MCPify/Core/McpContextAccessor.cs +++ b/MCPify/Core/McpContextAccessor.cs @@ -1,6 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; - namespace MCPify.Core; public interface IMcpContextAccessor @@ -12,94 +9,9 @@ public interface IMcpContextAccessor public class McpContextAccessor : IMcpContextAccessor { - private static readonly System.Threading.AsyncLocal _mcpContextCurrent = new System.Threading.AsyncLocal(); - - public string? SessionId - { - get => _mcpContextCurrent.Value?.Context?.SessionId; - set - { - var holder = _mcpContextCurrent.Value; - if (holder == null) - { - holder = new McpContextHolder(); - _mcpContextCurrent.Value = holder; - } - - if (holder.Context == null) - { - holder.Context = new McpContext(); - } - holder.Context.SessionId = value; - } - } - - public string? ConnectionId - { - get => _mcpContextCurrent.Value?.Context?.ConnectionId; - set - { - var holder = _mcpContextCurrent.Value; - if (holder == null) - { - holder = new McpContextHolder(); - _mcpContextCurrent.Value = holder; - } - - if (holder.Context == null) - { - holder.Context = new McpContext(); - } - holder.Context.ConnectionId = value; - } - } - - public string? AccessToken - { - get => _mcpContextCurrent.Value?.Context?.AccessToken; - set - { - var holder = _mcpContextCurrent.Value; - if (holder == null) - { - holder = new McpContextHolder(); - _mcpContextCurrent.Value = holder; - } - - if (holder.Context == null) - { - holder.Context = new McpContext(); - } - holder.Context.AccessToken = value; - } - } - - internal static McpContext? CurrentContext - { - get => _mcpContextCurrent.Value?.Context; - set - { - var holder = _mcpContextCurrent.Value; - if (holder != null) - { - holder.Context = value; - } - else - { - _mcpContextCurrent.Value = new McpContextHolder { Context = value }; - } - } - } + public string? SessionId { get; set; } - public class McpContext - { - public string? SessionId { get; set; } - public string? ConnectionId { get; set; } - public string? AccessToken { get; set; } - } + public string? ConnectionId { get; set; } - private class McpContextHolder - { - public McpContext? Context; - } + public string? AccessToken { get; set; } } \ No newline at end of file diff --git a/MCPify/Hosting/McpContextMiddleware.cs b/MCPify/Hosting/McpContextMiddleware.cs deleted file mode 100644 index 6d2e7d0..0000000 --- a/MCPify/Hosting/McpContextMiddleware.cs +++ /dev/null @@ -1,85 +0,0 @@ -using MCPify.Core; -using MCPify.Core.Session; -using Microsoft.AspNetCore.Http; -using System.Threading.Tasks; - -namespace MCPify.Hosting; - -public class McpContextMiddleware -{ - private readonly RequestDelegate _next; - - public McpContextMiddleware(RequestDelegate next) - { - _next = next; - } - - public async Task InvokeAsync(HttpContext httpContext, IMcpContextAccessor accessor, McpifyOptions options, ISessionMap sessionMap) - { - // 1. Try custom resolver from options - string? sessionHandle = null; - if (options.SessionIdResolver != null) - { - sessionHandle = options.SessionIdResolver(httpContext); - } - - // 2. If null, try to retrieve sessionId from HttpContext.Items (Standard MCP Transport fallback) - if (string.IsNullOrEmpty(sessionHandle) && httpContext.Items.TryGetValue("McpSessionId", out var sessionIdObj) && sessionIdObj is string sessionIdItem) - { - sessionHandle = sessionIdItem; - } - - // 3. Cookie Fallback: Check for McpSessionId cookie - if (string.IsNullOrEmpty(sessionHandle) && httpContext.Request.Cookies.TryGetValue("McpSessionId", out var cookieSessionId)) - { - sessionHandle = cookieSessionId; - } - - // 4. If still null, generate a Temporary In-Memory Session Handle - if (string.IsNullOrEmpty(sessionHandle)) - { - sessionHandle = Guid.NewGuid().ToString("N"); - // Store it back so downstream can see it if needed - httpContext.Items["McpSessionId"] = sessionHandle; - - // Auto-Issue Cookie for HTTP clients - httpContext.Response.Cookies.Append("McpSessionId", sessionHandle, new CookieOptions - { - HttpOnly = true, - Secure = httpContext.Request.IsHttps, - SameSite = SameSiteMode.Lax, - Expires = DateTimeOffset.UtcNow.AddDays(7) - }); - } - - // Store the handle specifically for the Login/Upgrade process - httpContext.Items["McpSessionHandle"] = sessionHandle; - - // 4. Resolve the actual identity (Principal) from the map - // If logged in, this returns the User ID. If not, it returns the sessionHandle (Temp ID). - accessor.SessionId = sessionMap.ResolvePrincipal(sessionHandle); - - if (httpContext.Items.TryGetValue("McpConnectionId", out var connectionIdObj) && connectionIdObj is string connectionId) - { - accessor.ConnectionId = connectionId; - } - - // Set the current context for AsyncLocal to ensure it's available downstream - McpContextAccessor.CurrentContext = new McpContextAccessor.McpContext - { - SessionId = accessor.SessionId, - ConnectionId = accessor.ConnectionId, - AccessToken = accessor.AccessToken - }; - - try - { - await _next(httpContext); - } - finally - { - // Clear the context to prevent leakage to other requests - McpContextAccessor.CurrentContext = null; - } - } -} \ No newline at end of file diff --git a/MCPify/Hosting/McpifyServiceExtensions.cs b/MCPify/Hosting/McpifyServiceExtensions.cs index 4dac3c5..7cb80f4 100644 --- a/MCPify/Hosting/McpifyServiceExtensions.cs +++ b/MCPify/Hosting/McpifyServiceExtensions.cs @@ -167,14 +167,4 @@ public static IServiceCollection AddMcpifyTestTool(this IServiceCollection servi return services; } - /// - /// Adds the MCP context middleware to the pipeline. This is required for accessing session and connection information. - /// - /// The instance. - /// The instance. - public static IApplicationBuilder UseMcpifyContext(this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } - } diff --git a/MCPify/Hosting/SessionAwareToolDecorator.cs b/MCPify/Hosting/SessionAwareToolDecorator.cs index 05b7fbc..8a40d0a 100644 --- a/MCPify/Hosting/SessionAwareToolDecorator.cs +++ b/MCPify/Hosting/SessionAwareToolDecorator.cs @@ -1,8 +1,7 @@ +using System.Linq; using System.Text.Json; -using System.Text.Json.Nodes; using MCPify.Core; using MCPify.Core.Session; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -24,72 +23,7 @@ public SessionAwareToolDecorator(McpServerTool innerTool, IServiceProvider servi _serviceProvider = serviceProvider; } - // Delegate ProtocolTool property to the inner tool but inject sessionId into schema - public override Tool ProtocolTool - { - get - { - var original = _innerTool.ProtocolTool; - - // Skip modification for 'connect' tool as it doesn't need a session ID - if (original.Name.Equals("connect", StringComparison.OrdinalIgnoreCase)) - { - return original; - } - - // If the schema is empty or not an object, just return original to be safe - if (original.InputSchema.ValueKind == JsonValueKind.Undefined || - original.InputSchema.ValueKind == JsonValueKind.Null) - { - return original; - } - - try - { - // Parse the original schema to a mutable Node - var jsonNode = JsonNode.Parse(original.InputSchema.GetRawText()); - if (jsonNode is JsonObject jsonObj) - { - // Ensure 'type' is set to 'object' (Required for valid MCP schema) - if (!jsonObj.ContainsKey("type")) - { - jsonObj["type"] = "object"; - } - - // Ensure 'properties' object exists - if (!jsonObj.ContainsKey("properties")) - { - jsonObj["properties"] = new JsonObject(); - } - - var properties = jsonObj["properties"] as JsonObject; - if (properties != null && !properties.ContainsKey("sessionId")) - { - // Inject sessionId property - properties["sessionId"] = new JsonObject - { - ["type"] = "string", - ["description"] = "The Session ID to maintain context across requests. Retrieve this via 'connect' tool." - }; - } - - // We return a NEW Tool object with the modified schema - return new Tool - { - Name = original.Name, - Description = original.Description, - InputSchema = JsonSerializer.SerializeToElement(jsonObj) - }; - } - } - catch - { - // Fallback if parsing fails - } - - return original; - } - } + public override Tool ProtocolTool => _innerTool.ProtocolTool; // Delegate Metadata to the inner tool public override IReadOnlyList Metadata => _innerTool.Metadata; @@ -105,12 +39,14 @@ public override async ValueTask InvokeAsync(RequestContext(); - var httpContextAccessor = services.GetService(); if (accessor != null) { - // 1. Try to get from arguments first (Client MUST provide this) - if (context.Params?.Arguments != null) + // Start with the session produced by the MCP server implementation. + var sessionId = context.Server?.SessionId; + + // For backwards compatibility with older clients that send the sessionId explicitly. + if (string.IsNullOrEmpty(sessionId) && context.Params?.Arguments != null) { // Case-insensitive lookup. FirstOrDefault returns default(KeyValuePair) if not found. var argEntry = context.Params.Arguments.FirstOrDefault(x => x.Key.Equals("sessionId", StringComparison.OrdinalIgnoreCase)); @@ -120,55 +56,26 @@ public override async ValueTask InvokeAsync(RequestContext(); - if (sessionMap != null && !string.IsNullOrEmpty(accessor.SessionId)) + if (sessionMap != null && !string.IsNullOrEmpty(sessionId)) { - accessor.SessionId = sessionMap.ResolvePrincipal(accessor.SessionId); + sessionId = sessionMap.ResolvePrincipal(sessionId); } - // Ensure AsyncLocal is populated so deep services can access it - McpContextAccessor.CurrentContext = new McpContextAccessor.McpContext - { - SessionId = accessor.SessionId, - ConnectionId = accessor.ConnectionId, - AccessToken = accessor.AccessToken - }; + accessor.SessionId = sessionId; } - try - { - return await _innerTool.InvokeAsync(context, token); - } - finally - { - McpContextAccessor.CurrentContext = null; - } + return await _innerTool.InvokeAsync(context, token); } } \ No newline at end of file diff --git a/MCPify/Tools/LoginTool.cs b/MCPify/Tools/LoginTool.cs index 24643c1..176913d 100644 --- a/MCPify/Tools/LoginTool.cs +++ b/MCPify/Tools/LoginTool.cs @@ -30,10 +30,11 @@ public override async ValueTask InvokeAsync(RequestContext>() : null; var accessor = context.Services?.GetService(); - string? sessionId = null; + string? sessionId = context.Server?.SessionId; - // 1. Try to get from arguments - if (context.Params?.Arguments != null && + // 1. Try to get from arguments for backwards compatibility + if (string.IsNullOrEmpty(sessionId) && + context.Params?.Arguments != null && context.Params.Arguments.TryGetValue("sessionId", out var sessionElement) && sessionElement.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(sessionElement.GetString())) diff --git a/MCPify/Tools/SessionManagementTool.cs b/MCPify/Tools/SessionManagementTool.cs index 5fc67b8..10c2c2b 100644 --- a/MCPify/Tools/SessionManagementTool.cs +++ b/MCPify/Tools/SessionManagementTool.cs @@ -26,13 +26,17 @@ public class SessionManagementTool : McpServerTool public override async ValueTask InvokeAsync(RequestContext context, CancellationToken token) { var accessor = context.Services?.GetService(); - - // Always generate a NEW session on connect to ensure clean state - string sessionId = Guid.NewGuid().ToString("N"); + + var sessionId = context.Server?.SessionId; + + if (string.IsNullOrEmpty(sessionId)) + { + sessionId = Guid.NewGuid().ToString("N"); + } if (accessor != null) { - accessor.SessionId = sessionId; + accessor.SessionId = sessionId; } return new CallToolResult diff --git a/README.md b/README.md index 85a73f8..8e5cda7 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,6 @@ builder.Services.AddMcpify(options => var app = builder.Build(); // Add Middleware (order matters!) -app.UseMcpifyContext(); // Must be first app.UseAuthentication(); app.UseAuthorization(); diff --git a/Sample/Program.cs b/Sample/Program.cs index 4c92fc3..8903fde 100644 --- a/Sample/Program.cs +++ b/Sample/Program.cs @@ -49,7 +49,6 @@ app.UseSwagger(); app.UseSwaggerUI(); -app.UseMcpifyContext(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs b/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs index 036379d..c962861 100644 --- a/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthChallengeTokenValidationTests.cs @@ -68,7 +68,6 @@ private static async Task CreateHostAsync() }); var app = builder.Build(); - app.UseMcpifyContext(); app.UseAuthentication(); app.UseAuthorization(); app.MapMcpifyEndpoint("/mcp"); diff --git a/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs b/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs index 5f60d4b..36aa44b 100644 --- a/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs +++ b/Tests/MCPify.Tests/Integration/OAuthMiddlewareTests.cs @@ -167,7 +167,6 @@ private static async Task CreateHostAsync(Action? }); var app = builder.Build(); - app.UseMcpifyContext(); app.UseAuthentication(); app.UseAuthorization(); app.MapMcpifyEndpoint("/mcp"); diff --git a/Tests/MCPify.Tests/Integration/OpenApiOAuthScopeIntegrationTests.cs b/Tests/MCPify.Tests/Integration/OpenApiOAuthScopeIntegrationTests.cs index 673a576..97f9b01 100644 --- a/Tests/MCPify.Tests/Integration/OpenApiOAuthScopeIntegrationTests.cs +++ b/Tests/MCPify.Tests/Integration/OpenApiOAuthScopeIntegrationTests.cs @@ -176,7 +176,6 @@ private static async Task CreateHostAsync(Action? }); var app = builder.Build(); - app.UseMcpifyContext(); app.UseAuthentication(); app.UseAuthorization(); app.MapMcpifyEndpoint("/mcp"); diff --git a/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs b/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs index 3e4bae4..4e1641f 100644 --- a/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs +++ b/Tests/MCPify.Tests/Unit/ScopeRequirementTests.cs @@ -112,7 +112,6 @@ private static async Task CreateHostAsync(Action? }); var app = builder.Build(); - app.UseMcpifyContext(); app.UseAuthentication(); app.UseAuthorization(); app.MapMcpifyEndpoint("/mcp");