From 055001bfab8a46244da02b1d87b6053d47d75918 Mon Sep 17 00:00:00 2001 From: Erwin Date: Thu, 22 Jan 2026 21:29:56 +0100 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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."); }); }