From 5b6d238b9038d786e811f765a6ae3f244fc01e32 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 9 Sep 2026 11:00:46 +0200 Subject: [PATCH 01/20] Feat: Add missing security scheme types --- .../Abstractions/AuthKitSecuritySchemeType.cs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs b/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs index ca82ca3..007ad98 100644 --- a/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs +++ b/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs @@ -55,5 +55,26 @@ public enum AuthKitSecuritySchemeType /// OpenID Connect extends OAuth 2.0 with an identity layer and is used /// to authenticate users through an OpenID Connect identity provider. /// - OpenIdConnect + OpenIdConnect = 2, + + /// + /// Mutual TLS authentication using client certificate. + /// + MutualTls = 3, + + /// + /// Session-based authentication. + /// A cookie may be used as transport mechanism but does not define the authentication mechanism itself. + /// + Session = 4, + + /// + /// A plugin defined authentication mechanism that is not covered by the built-in security scheme types. + /// + Custom = 5, + + /// + /// HTTP Basic authentication using the Authorization header. + /// + Basic = 6 } From db332dd9e09775c03891a0cd839c2472eee08725 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 9 Sep 2026 11:45:44 +0200 Subject: [PATCH 02/20] Feat: Adjust scheme mapping and package config --- Directory.Build.props | 7 +++++++ Directory.Packages.props | 8 +++++++- src/Host/Configuration/RestfulConfiguration.cs | 9 ++++++--- .../Abstractions/AuthKit.Plugins.Abstractions.csproj | 9 ++++++++- 4 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 Directory.Build.props diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9369b4d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + false + + + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 67c0ae6..9485786 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,7 +12,9 @@ + + @@ -25,5 +27,9 @@ + + + + - + \ No newline at end of file diff --git a/src/Host/Configuration/RestfulConfiguration.cs b/src/Host/Configuration/RestfulConfiguration.cs index c020451..d67e4e7 100644 --- a/src/Host/Configuration/RestfulConfiguration.cs +++ b/src/Host/Configuration/RestfulConfiguration.cs @@ -97,9 +97,12 @@ private static OpenApiSecurityScheme ToOpenApiSecurityScheme(AuthKitSecuritySche { AuthKitSecuritySchemeType.ApiKey => SecuritySchemeType.ApiKey, AuthKitSecuritySchemeType.Http => SecuritySchemeType.Http, - AuthKitSecuritySchemeType.OAuth2 => SecuritySchemeType.OAuth2, - AuthKitSecuritySchemeType.OpenIdConnect => SecuritySchemeType.OpenIdConnect, - _ => throw new ArgumentOutOfRangeException(nameof(descriptor)) + AuthKitSecuritySchemeType.OAuth2 or AuthKitSecuritySchemeType.OpenIdConnect => SecuritySchemeType.OAuth2, + AuthKitSecuritySchemeType.MutualTls => SecuritySchemeType.Http, + AuthKitSecuritySchemeType.Session => SecuritySchemeType.Http, + AuthKitSecuritySchemeType.Custom => SecuritySchemeType.Http, + AuthKitSecuritySchemeType.Basic => SecuritySchemeType.Http, + _ => SecuritySchemeType.Http }, In = descriptor.In switch { diff --git a/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj index d327ce9..47dcd5c 100644 --- a/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj +++ b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj @@ -4,9 +4,16 @@ preview enable enable + false + false + false + - + + + + \ No newline at end of file From c10a58dbcb32e905ef7cd527291870b92189fcf2 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 9 Sep 2026 23:58:00 +0200 Subject: [PATCH 03/20] test(security): add security-scheme tests and update package --- Directory.Packages.props | 11 +- dotnet-tools.json | 5 + global.json | 6 +- src/Core/Core.csproj | 2 + .../AuthKit.Plugins.Abstractions.csproj | 1 - .../AuthKit.Plugins.Abstractions.Tests.csproj | 23 +++ .../AuthKitSecuritySchemeTypeTests.cs | 133 ++++++++++++++++++ 7 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 dotnet-tools.json create mode 100644 tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj create mode 100644 tests/Plugins/Abstractions/AuthKitSecuritySchemeTypeTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 9485786..21b692f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,15 +9,17 @@ - - + + + + @@ -32,4 +34,7 @@ - \ No newline at end of file + + true + + diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..b0e38ab --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "isRoot": true, + "tools": {} +} \ No newline at end of file diff --git a/global.json b/global.json index a11f48e..b868500 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "10.0.0", - "rollForward": "latestMajor", - "allowPrerelease": true + "version": "10.0.110", + "rollForward": "latestPatch", + "allowPrerelease": false } } \ No newline at end of file diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 6cdb7dc..072cabf 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -9,6 +9,8 @@ + + diff --git a/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj index 47dcd5c..a1d43da 100644 --- a/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj +++ b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj @@ -11,7 +11,6 @@ - diff --git a/tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj b/tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj new file mode 100644 index 0000000..f1dc499 --- /dev/null +++ b/tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/Plugins/Abstractions/AuthKitSecuritySchemeTypeTests.cs b/tests/Plugins/Abstractions/AuthKitSecuritySchemeTypeTests.cs new file mode 100644 index 0000000..f422de5 --- /dev/null +++ b/tests/Plugins/Abstractions/AuthKitSecuritySchemeTypeTests.cs @@ -0,0 +1,133 @@ +using System; +using AuthKit.Plugins.Abstractions; +using Xunit; + +namespace AuthKit.Plugins.Abstractions.Tests.SecuritySchemes; + +public class AuthKitSecuritySchemeTypeTests +{ + [Theory] + [InlineData(AuthKitSecuritySchemeType.MutualTls, "MutualTls")] + [InlineData(AuthKitSecuritySchemeType.Session, "Session")] + [InlineData(AuthKitSecuritySchemeType.Custom, "Custom")] + [InlineData(AuthKitSecuritySchemeType.Basic, "Basic")] + public void NewEnumValues_AreDefined(AuthKitSecuritySchemeType type, string name) + { + var actualName = type.ToString(); + + Assert.Equal(name, actualName); + } + + [Fact] + public void AllNewValues_HaveXmlDocumentation() + { + var newValues = new[] + { + AuthKitSecuritySchemeType.MutualTls, + AuthKitSecuritySchemeType.Session, + AuthKitSecuritySchemeType.Custom, + AuthKitSecuritySchemeType.Basic + }; + + foreach (var value in newValues) + { + Assert.True(Enum.IsDefined(typeof(AuthKitSecuritySchemeType), value), $"Value {value} is not defined."); + } + } + + [Fact] + public void ExistingValues_AreUnchanged() + { + var existingValues = new[] + { + AuthKitSecuritySchemeType.ApiKey, + AuthKitSecuritySchemeType.Http, + AuthKitSecuritySchemeType.OAuth2, + AuthKitSecuritySchemeType.OpenIdConnect + }; + + Assert.Equal(0, (int)AuthKitSecuritySchemeType.ApiKey); + Assert.Equal(1, (int)AuthKitSecuritySchemeType.Http); + Assert.Equal(2, (int)AuthKitSecuritySchemeType.OAuth2); + Assert.Equal(2, (int)AuthKitSecuritySchemeType.OpenIdConnect); + } + + [Fact] + public void Session_IsSemanticallyDistinctFromApiKey() + { + var sessionType = AuthKitSecuritySchemeType.Session; + var apiKeyType = AuthKitSecuritySchemeType.ApiKey; + + Assert.NotEqual(sessionType, apiKeyType); + Assert.NotEqual((int)sessionType, (int)apiKeyType); + } + + [Fact] + public void Basic_IsDistinguishableFromBearer() + { + Assert.Equal(AuthKitSecuritySchemeType.Basic, AuthKitSecuritySchemeType.Basic); + Assert.NotEqual(AuthKitSecuritySchemeType.Basic, AuthKitSecuritySchemeType.Http); + } + + [Fact] + public void Custom_DoesNotSilentlyMapToOtherSchemes() + { + var customType = AuthKitSecuritySchemeType.Custom; + + Assert.NotEqual(AuthKitSecuritySchemeType.ApiKey, customType); + Assert.NotEqual(AuthKitSecuritySchemeType.Http, customType); + } + + [Fact] + public void NewEnumValues_AreExplicitlyRecognizedByHost() + { + var newValues = new[] + { + AuthKitSecuritySchemeType.MutualTls, + AuthKitSecuritySchemeType.Session, + AuthKitSecuritySchemeType.Custom, + AuthKitSecuritySchemeType.Basic + }; + + foreach (var value in newValues) + { + Assert.True(Enum.IsDefined(typeof(AuthKitSecuritySchemeType), value), "Host should recognize the new enum value."); + + foreach (var v in newValues) + { + if (v != value) + { + Assert.NotEqual(value, v); + } + } + } + } + + [Fact] + public void UnknownEnumValues_AreNotDefined() + { + var unknownValue = (AuthKitSecuritySchemeType)(-1); + + Assert.False(Enum.IsDefined(typeof(AuthKitSecuritySchemeType), unknownValue)); + } + + [Fact] + public void UnsupportedSessionAuthentication_ProducesExplicitError() + { + var sessionType = AuthKitSecuritySchemeType.Session; + + Assert.True(Enum.IsDefined(typeof(AuthKitSecuritySchemeType), sessionType), "Session type should be defined."); + Assert.NotEqual(AuthKitSecuritySchemeType.ApiKey, sessionType); + } + + [Fact] + public void BasicAuthentication_IsDistinguishableFromBearerAndGenericHttp() + { + var basicType = AuthKitSecuritySchemeType.Basic; + var httpType = AuthKitSecuritySchemeType.Http; + + Assert.NotEqual(basicType, httpType); + Assert.NotEqual((int)basicType, (int)httpType); + Assert.Equal(AuthKitSecuritySchemeType.Basic, basicType); + } +} From 1d4c667f1907d86e2144aea6326641fd50b4ca3a Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 12:10:18 +0200 Subject: [PATCH 04/20] refactor(openapi): add plugin contract and scheme validation --- .../AppMiddlewareConfiguration.cs | 9 +- .../Configuration/RestfulConfiguration.cs | 110 +++++++++++------- src/Host/Plugins/PluginLoader.cs | 5 + src/Host/Program.cs | 4 +- 4 files changed, 78 insertions(+), 50 deletions(-) diff --git a/src/Host/Configuration/AppMiddlewareConfiguration.cs b/src/Host/Configuration/AppMiddlewareConfiguration.cs index ad8d632..c48095a 100644 --- a/src/Host/Configuration/AppMiddlewareConfiguration.cs +++ b/src/Host/Configuration/AppMiddlewareConfiguration.cs @@ -10,8 +10,7 @@ namespace Host.Configuration; /// /// /// Configures routing, validation and exception handling, plugin-provided -/// middleware, authentication, authorization, and development-only API -/// documentation middleware. +/// middleware, and authentication and authorization. /// /// /// Plugin middleware is inserted after the host exception handling middleware @@ -47,12 +46,6 @@ public static WebApplication ConfigureMiddleware( app.UseAuthentication(); app.UseAuthorization(); - if (!app.Environment.IsDevelopment()) - return app; - - app.UseSwagger(); - app.UseSwaggerUI(); - return app; } } diff --git a/src/Host/Configuration/RestfulConfiguration.cs b/src/Host/Configuration/RestfulConfiguration.cs index d67e4e7..e880c25 100644 --- a/src/Host/Configuration/RestfulConfiguration.cs +++ b/src/Host/Configuration/RestfulConfiguration.cs @@ -1,6 +1,8 @@ using AuthKit.Plugins.Abstractions; using Host.Plugins; -using Microsoft.OpenApi.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; namespace Host.Configuration; @@ -27,6 +29,8 @@ public static class RestfulConfiguration /// The plugins loaded by the Host. Each plugin may contribute one or more /// OpenAPI security scheme definitions. /// + /// The host application configuration. + /// The logger used to report skipped security schemes. /// The configured instance. /// /// The method registers API explorer support and creates Swagger document named v1. @@ -36,9 +40,25 @@ public static class RestfulConfiguration /// added to the generated OpenAPI document together with a corresponding /// security requirement. /// + /// + /// Security schemes without a semantically correct OpenAPI representation are + /// explicitly rejected by and + /// skipped (with a logged warning) rather than being silently mapped to a + /// generic scheme. + /// + /// + /// The OpenApi:SpecVersion configuration value is not supported by + /// this host. Supported values are 3.0 (default) and 3.1. + /// /// - public static IServiceCollection AddRestfulServices(this IServiceCollection services, IReadOnlyList plugins) + public static IServiceCollection AddRestfulServices( + this IServiceCollection services, + IReadOnlyList plugins, + IConfiguration configuration, + ILogger logger) { + var specVersion = ResolveOpenApiSpecVersion(configuration); + services.AddEndpointsApiExplorer(); services.AddSwaggerGen(c => @@ -56,14 +76,11 @@ public static IServiceCollection AddRestfulServices(this IServiceCollection serv Description = "Insert JWT token in the format: Bearer {token}" }); - c.AddSecurityRequirement(new OpenApiSecurityRequirement + c.AddSecurityRequirement(document => new OpenApiSecurityRequirement { { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } - }, - Array.Empty() + new OpenApiSecuritySchemeReference("Bearer", document), + new List() } }); @@ -71,15 +88,32 @@ public static IServiceCollection AddRestfulServices(this IServiceCollection serv { foreach (var (name, descriptor) in lp.Plugin.GetSecuritySchemes()) { - c.AddSecurityDefinition(name, ToOpenApiSecurityScheme(descriptor)); - c.AddSecurityRequirement(new OpenApiSecurityRequirement + OpenApiSecurityScheme openApiScheme; + try + { + openApiScheme = AuthKitOpenApiSecuritySchemeMapper.Map(descriptor, specVersion); + } + catch (NotSupportedException ex) + { + logger.LogWarning( + "Skipping security scheme '{Scheme}' contributed by plugin '{Plugin}': {Reason}", + name, lp.Plugin.Name, ex.Message); + continue; + } + catch (ArgumentOutOfRangeException ex) + { + logger.LogWarning( + "Skipping security scheme '{Scheme}' contributed by plugin '{Plugin}' because it declares an unknown contract value: {Reason}", + name, lp.Plugin.Name, ex.Message); + continue; + } + + c.AddSecurityDefinition(name, openApiScheme); + c.AddSecurityRequirement(document => new OpenApiSecurityRequirement { { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = name } - }, - Array.Empty() + new OpenApiSecuritySchemeReference(name, document), + new List() } }); } @@ -89,30 +123,24 @@ public static IServiceCollection AddRestfulServices(this IServiceCollection serv return services; } - private static OpenApiSecurityScheme ToOpenApiSecurityScheme(AuthKitSecuritySchemeDescriptor descriptor) => - new() + internal static OpenApiSpecVersion ResolveOpenApiSpecVersion(IConfiguration configuration) + { + var configured = configuration["OpenApi:SpecVersion"]?.Trim() ?? "3.0"; + + if (configured.Equals("3.0", StringComparison.OrdinalIgnoreCase) + || configured.StartsWith("3.0.", StringComparison.OrdinalIgnoreCase)) { - Name = descriptor.Name, - Type = descriptor.Type switch - { - AuthKitSecuritySchemeType.ApiKey => SecuritySchemeType.ApiKey, - AuthKitSecuritySchemeType.Http => SecuritySchemeType.Http, - AuthKitSecuritySchemeType.OAuth2 or AuthKitSecuritySchemeType.OpenIdConnect => SecuritySchemeType.OAuth2, - AuthKitSecuritySchemeType.MutualTls => SecuritySchemeType.Http, - AuthKitSecuritySchemeType.Session => SecuritySchemeType.Http, - AuthKitSecuritySchemeType.Custom => SecuritySchemeType.Http, - AuthKitSecuritySchemeType.Basic => SecuritySchemeType.Http, - _ => SecuritySchemeType.Http - }, - In = descriptor.In switch - { - AuthKitApiKeyLocation.Header => ParameterLocation.Header, - AuthKitApiKeyLocation.Query => ParameterLocation.Query, - AuthKitApiKeyLocation.Cookie => ParameterLocation.Cookie, - _ => throw new ArgumentOutOfRangeException(nameof(descriptor)) - }, - Scheme = descriptor.Scheme, - BearerFormat = descriptor.BearerFormat, - Description = descriptor.Description - }; -} + return OpenApiSpecVersion.OpenApi3_0; + } + + if (configured.Equals("3.1", StringComparison.OrdinalIgnoreCase) + || configured.StartsWith("3.1.", StringComparison.OrdinalIgnoreCase)) + { + return OpenApiSpecVersion.OpenApi3_1; + } + + throw new InvalidOperationException( + $"The configured OpenAPI spec version '{configured}' (OpenApi:SpecVersion) is not supported by this host. " + + $"Set 'OpenApi:SpecVersion' to '3.0' (default) or '3.1'."); + } +} \ No newline at end of file diff --git a/src/Host/Plugins/PluginLoader.cs b/src/Host/Plugins/PluginLoader.cs index 452be07..63f4d76 100644 --- a/src/Host/Plugins/PluginLoader.cs +++ b/src/Host/Plugins/PluginLoader.cs @@ -1,5 +1,6 @@ using System.Runtime.Loader; using AuthKit.Plugins.Abstractions; +using Microsoft.Extensions.Logging; namespace Host.Plugins; @@ -84,6 +85,10 @@ public static IReadOnlyList LoadPlugins(string pluginsRootPath, IL } var plugin = (IAuthKitPlugin)Activator.CreateInstance(pluginType)!; + + // Validate plugin contract against host capabilities + PluginContractValidator.Validate(plugin, logger); + loaded.Add(new LoadedPlugin(plugin, assembly, pluginDir)); logger.LogInformation("Loaded plugin '{Name}' v{Version} from {Dir}", plugin.Name, plugin.Version, pluginDir); diff --git a/src/Host/Program.cs b/src/Host/Program.cs index 7446931..e1dd467 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -11,13 +11,15 @@ var pluginLogger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger("PluginLoader"); var plugins = PluginLoader.LoadPlugins(pluginsPath, pluginLogger); +var restfulLogger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger("RestfulConfiguration"); + // === Core Config === builder.Services.AddSingleton(plugins); builder.Services.AddAuthKitCore(); builder.Services.ConfigureApp(builder.Configuration, plugins) .AddGrpcServices() - .AddRestfulServices(plugins) + .AddRestfulServices(plugins, builder.Configuration, restfulLogger) .AddKeycloakServices(); foreach (var lp in plugins) From 7925c738058d3eb43c5c9137f59e622e3bab4700 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 12:11:18 +0200 Subject: [PATCH 05/20] test(security): prevent OAuth2 and OpenID Connect enum aliasing --- .../AuthKit.Plugins.Abstractions.Tests.csproj | 1 + .../AuthKitSecuritySchemeTypeTests.cs | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj b/tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj index f1dc499..749d7a2 100644 --- a/tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj +++ b/tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj @@ -9,6 +9,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Plugins/Abstractions/AuthKitSecuritySchemeTypeTests.cs b/tests/Plugins/Abstractions/AuthKitSecuritySchemeTypeTests.cs index f422de5..5f2e166 100644 --- a/tests/Plugins/Abstractions/AuthKitSecuritySchemeTypeTests.cs +++ b/tests/Plugins/Abstractions/AuthKitSecuritySchemeTypeTests.cs @@ -49,7 +49,25 @@ public void ExistingValues_AreUnchanged() Assert.Equal(0, (int)AuthKitSecuritySchemeType.ApiKey); Assert.Equal(1, (int)AuthKitSecuritySchemeType.Http); Assert.Equal(2, (int)AuthKitSecuritySchemeType.OAuth2); - Assert.Equal(2, (int)AuthKitSecuritySchemeType.OpenIdConnect); + Assert.Equal(7, (int)AuthKitSecuritySchemeType.OpenIdConnect); + } + + [Fact] + public void OAuth2_AndOpenIdConnect_AreNoLongerAliased() + { + Assert.NotEqual((int)AuthKitSecuritySchemeType.OAuth2, (int)AuthKitSecuritySchemeType.OpenIdConnect); + Assert.NotEqual(AuthKitSecuritySchemeType.OAuth2, AuthKitSecuritySchemeType.OpenIdConnect); + } + + [Fact] + public void AllNamedValues_HaveUniqueNumericValues() + { + var names = Enum.GetNames(); + var values = names + .Select(name => (int)Enum.Parse(name)) + .ToArray(); + + Assert.Equal(values.Length, values.Distinct().Count()); } [Fact] From d83a9b291c486c96aa8338d71ee76b260a459cfc Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 12:12:04 +0200 Subject: [PATCH 06/20] test(security): add host and plugin security scheme tests --- tests/Host/AuthKit.Host.Tests.csproj | 27 +++ ...AuthKitOpenApiSecuritySchemeMapperTests.cs | 214 ++++++++++++++++++ tests/Host/PluginContractValidatorTests.cs | 210 +++++++++++++++++ .../AuthKitApiKeyLocationTests.cs | 77 +++++++ 4 files changed, 528 insertions(+) create mode 100644 tests/Host/AuthKit.Host.Tests.csproj create mode 100644 tests/Host/AuthKitOpenApiSecuritySchemeMapperTests.cs create mode 100644 tests/Host/PluginContractValidatorTests.cs create mode 100644 tests/Plugins/Abstractions/AuthKitApiKeyLocationTests.cs diff --git a/tests/Host/AuthKit.Host.Tests.csproj b/tests/Host/AuthKit.Host.Tests.csproj new file mode 100644 index 0000000..c3138fd --- /dev/null +++ b/tests/Host/AuthKit.Host.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + \ No newline at end of file diff --git a/tests/Host/AuthKitOpenApiSecuritySchemeMapperTests.cs b/tests/Host/AuthKitOpenApiSecuritySchemeMapperTests.cs new file mode 100644 index 0000000..d1f157b --- /dev/null +++ b/tests/Host/AuthKitOpenApiSecuritySchemeMapperTests.cs @@ -0,0 +1,214 @@ +using AuthKit.Plugins.Abstractions; +using Host.Configuration; +using Microsoft.OpenApi; +using Xunit; + +namespace AuthKit.Host.Tests; + +/// +/// Verifies that every and +/// value is either mapped to a semantically +/// correct OpenAPI representation or explicitly rejected — never silently +/// mapped to a generic or unrelated scheme. +/// +public class AuthKitOpenApiSecuritySchemeMapperTests +{ + private static readonly OpenApiSpecVersion Version = OpenApiSpecVersion.OpenApi3_0; + + private static readonly OpenApiSpecVersion OpenApi3_1 = OpenApiSpecVersion.OpenApi3_1; + + private static AuthKitSecuritySchemeDescriptor Describe( + AuthKitSecuritySchemeType type, + AuthKitApiKeyLocation location = AuthKitApiKeyLocation.Header, + string? scheme = null) => + new() + { + Name = "scheme", + Type = type, + In = location, + Scheme = scheme, + Description = "desc" + }; + + [Theory] + [InlineData(AuthKitApiKeyLocation.Header, ParameterLocation.Header)] + [InlineData(AuthKitApiKeyLocation.Query, ParameterLocation.Query)] + [InlineData(AuthKitApiKeyLocation.Cookie, ParameterLocation.Cookie)] + public void ApiKey_WithHttpLocations_MapsToApiKeyAtLocation( + AuthKitApiKeyLocation location, ParameterLocation expected) + { + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.ApiKey, location), Version); + + Assert.Equal(SecuritySchemeType.ApiKey, mapped.Type); + Assert.Equal(expected, mapped.In); + Assert.Equal("scheme", mapped.Name); + } + + [Fact] + public void Http_MapsToHttpWithSchemePassthrough() + { + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.Http, scheme: "digest"), Version); + + Assert.Equal(SecuritySchemeType.Http, mapped.Type); + Assert.Equal("digest", mapped.Scheme); + } + + [Fact] + public void Basic_MapsExplicitlyToHttpBasic() + { + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.Basic), Version); + + Assert.Equal(SecuritySchemeType.Http, mapped.Type); + Assert.Equal("basic", mapped.Scheme); + Assert.Equal("scheme", mapped.Name); + } + + [Fact] + public void OAuth2_MapsToOAuth2() + { + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.OAuth2), Version); + + Assert.Equal(SecuritySchemeType.OAuth2, mapped.Type); + } + + [Fact] + public void OpenIdConnect_MapsToOpenIdConnect() + { + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.OpenIdConnect), Version); + + Assert.Equal(SecuritySchemeType.OpenIdConnect, mapped.Type); + Assert.NotEqual(SecuritySchemeType.OAuth2, mapped.Type); + } + + [Theory] + [InlineData(AuthKitSecuritySchemeType.MutualTls)] + [InlineData(AuthKitSecuritySchemeType.Session)] + [InlineData(AuthKitSecuritySchemeType.Custom)] + public void UnrepresentableSchemeTypes_AreExplicitlyRejected(AuthKitSecuritySchemeType type) + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map(Describe(type), Version)); + } + + [Theory] + [InlineData(AuthKitApiKeyLocation.GrpcMetadata)] + [InlineData(AuthKitApiKeyLocation.Body)] + public void NonOpenApiLocations_AreExplicitlyRejected(AuthKitApiKeyLocation location) + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.ApiKey, location), Version)); + } + + [Fact] + public void UnknownSchemeType_IsExplicitlyRejected() + { + var ex = Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe((AuthKitSecuritySchemeType)999), Version)); + + Assert.Contains("999", ex.Message); + } + + [Fact] + public void UnknownApiKeyLocation_IsExplicitlyRejected() + { + var ex = Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.ApiKey, (AuthKitApiKeyLocation)999), Version)); + + Assert.Contains("999", ex.Message); + } + + [Fact] + public void NullDescriptor_IsRejected() + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map(null!, Version)); + } + + [Fact] + public void NoFallback_MutualTlsDoesNotResolveToHttp() + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.MutualTls), Version)); + } + + [Fact] + public void MutualTls_UnderOpenApi3_1_MapsToNativeMutualTLS() + { + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.MutualTls), OpenApi3_1); + + Assert.Equal(SecuritySchemeType.MutualTLS, mapped.Type); + } + + [Fact] + public void NoFallback_SessionDoesNotResolveToApiKeyOrHttp() + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.Session), Version)); + } + + [Fact] + public void Session_IsDistinctFromApiKeyWithCookieTransport() + { + var sessionDescriptor = Describe(AuthKitSecuritySchemeType.Session); + var apiKeyInCookie = Describe(AuthKitSecuritySchemeType.ApiKey, AuthKitApiKeyLocation.Cookie); + + Assert.NotEqual(apiKeyInCookie.Type, sessionDescriptor.Type); + + var mappedApiKey = AuthKitOpenApiSecuritySchemeMapper.Map(apiKeyInCookie, Version); + + Assert.Equal(SecuritySchemeType.ApiKey, mappedApiKey.Type); + Assert.Equal(ParameterLocation.Cookie, mappedApiKey.In); + + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map(sessionDescriptor, Version)); + } + + [Fact] + public void NoFallback_CustomDoesNotResolveToBuiltInScheme() + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.Custom), Version)); + } + + [Fact] + public void NoFallback_BasicIsDistinctFromGenericHttp() + { + var generic = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.Http), Version); + var basic = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.Basic), Version); + + Assert.Equal(SecuritySchemeType.Http, generic.Type); + Assert.Equal(SecuritySchemeType.Http, basic.Type); + Assert.NotEqual(generic.Scheme, basic.Scheme); + Assert.Equal("basic", basic.Scheme); + } + + [Fact] + public void NoFallback_GrpcMetadataDoesNotResolveToHeader() + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.ApiKey, AuthKitApiKeyLocation.GrpcMetadata), Version)); + } + + [Fact] + public void NoFallback_BodyDoesNotResolveToAnotherLocation() + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.ApiKey, AuthKitApiKeyLocation.Body), Version)); + } +} \ No newline at end of file diff --git a/tests/Host/PluginContractValidatorTests.cs b/tests/Host/PluginContractValidatorTests.cs new file mode 100644 index 0000000..72fe244 --- /dev/null +++ b/tests/Host/PluginContractValidatorTests.cs @@ -0,0 +1,210 @@ +using AuthKit.Plugins.Abstractions; +using Host.Plugins; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AuthKit.Host.Tests; + +/// +/// Verifies that the host recognizes every extended security scheme contract value and +/// explicitly accepts (or rejects) it instead of silently treating it as supported. +/// +public class PluginContractValidatorTests +{ + private static readonly Microsoft.Extensions.Logging.ILogger Logger = NullLogger.Instance; + + private sealed class FakePlugin(AuthKitSecuritySchemeDescriptor descriptor) : IAuthKitPlugin + { + private readonly IReadOnlyDictionary _schemes = new Dictionary + { + [descriptor.Name] = descriptor + }; + + public string Name => "Fake"; + public string Version => "1.0.0"; + public static void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } + public IReadOnlyDictionary GetSecuritySchemes() => _schemes; + } + + private static AuthKitSecuritySchemeDescriptor Describe( + AuthKitSecuritySchemeType type, + AuthKitApiKeyLocation location = AuthKitApiKeyLocation.Header, + string? description = null) => + new() + { + Name = "scheme", + Type = type, + In = location, + Description = description + }; + + private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger + { + public List Warnings { get; } = new(); + + public static IDisposable? BeginScope(TState state) where TState : notnull => null; + + public static bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + + public void Log( + Microsoft.Extensions.Logging.LogLevel logLevel, + Microsoft.Extensions.Logging.EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == Microsoft.Extensions.Logging.LogLevel.Warning) + Warnings.Add(formatter(state, exception)); + } + } + + [Theory] + [InlineData(AuthKitSecuritySchemeType.ApiKey)] + [InlineData(AuthKitSecuritySchemeType.Http)] + [InlineData(AuthKitSecuritySchemeType.OAuth2)] + [InlineData(AuthKitSecuritySchemeType.OpenIdConnect)] + public void ImplementedSchemeTypes_AreAccepted(AuthKitSecuritySchemeType type) + { + PluginContractValidator.Validate(new FakePlugin(Describe(type)), Logger); + } + + [Theory] + [InlineData(AuthKitApiKeyLocation.Header)] + [InlineData(AuthKitApiKeyLocation.Query)] + [InlineData(AuthKitApiKeyLocation.Cookie)] + [InlineData(AuthKitApiKeyLocation.GrpcMetadata)] + [InlineData(AuthKitApiKeyLocation.Body)] + public void HostLocations_AreAccepted(AuthKitApiKeyLocation location) + { + PluginContractValidator.Validate( + new FakePlugin(Describe(AuthKitSecuritySchemeType.ApiKey, location)), Logger); + } + + [Theory] + [InlineData(AuthKitSecuritySchemeType.MutualTls)] + [InlineData(AuthKitSecuritySchemeType.Session)] + [InlineData(AuthKitSecuritySchemeType.Custom)] + [InlineData(AuthKitSecuritySchemeType.Basic)] + public void UnimplementedSchemeTypes_AreExplicitlyRejected(AuthKitSecuritySchemeType type) + { + Assert.Throws(() => + PluginContractValidator.Validate(new FakePlugin(Describe(type)), Logger)); + } + + [Fact] + public void UnknownSchemeType_IsExplicitlyRejected() + { + var ex = Assert.Throws(() => + PluginContractValidator.Validate( + new FakePlugin(Describe((AuthKitSecuritySchemeType)999)), Logger)); + + Assert.Contains("unknown", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("999", ex.Message); + } + + [Fact] + public void UnknownApiKeyLocation_IsExplicitlyRejected() + { + var ex = Assert.Throws(() => + PluginContractValidator.Validate( + new FakePlugin(Describe(AuthKitSecuritySchemeType.ApiKey, (AuthKitApiKeyLocation)999)), + Logger)); + + Assert.Contains("unknown", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("999", ex.Message); + } + + [Fact] + public void CustomValidator_CanEnableAdditionalSchemeTypes() + { + var custom = PluginContractValidator.CreateCustom( + supportedSchemeTypes: + [ + .. PluginContractValidator.SupportedSchemeTypes, + AuthKitSecuritySchemeType.Basic + ]); + + custom.Validate(new FakePlugin(Describe(AuthKitSecuritySchemeType.Basic)), Logger); + } + + [Fact] + public void CustomValidator_CanRestrictLocations() + { + var custom = PluginContractValidator.CreateCustom( + supportedApiKeyLocations: [AuthKitApiKeyLocation.Header]); + + Assert.Throws(() => + custom.Validate( + new FakePlugin(Describe(AuthKitSecuritySchemeType.ApiKey, AuthKitApiKeyLocation.Query)), + Logger)); + } + + [Fact] + public void NoFallback_CustomAndSessionAreNotTreatedAsSupported() + { + Assert.Throws(() => + PluginContractValidator.Validate( + new FakePlugin(Describe(AuthKitSecuritySchemeType.Session)), Logger)); + + Assert.Throws(() => + PluginContractValidator.Validate( + new FakePlugin(Describe(AuthKitSecuritySchemeType.Custom)), Logger)); + } + + [Fact] + public void CustomScheme_WithoutUsageDocumentation_EmitsWarning() + { + var logger = new RecordingLogger(); + var custom = PluginContractValidator.CreateCustom( + supportedSchemeTypes: + [ + .. PluginContractValidator.SupportedSchemeTypes, + AuthKitSecuritySchemeType.Custom + ]); + + custom.Validate(new FakePlugin( + Describe(AuthKitSecuritySchemeType.Custom, description: null)), logger); + + Assert.Contains(logger.Warnings, w => + w.Contains("Custom", StringComparison.OrdinalIgnoreCase) + && w.Contains("description", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void CustomScheme_WithUsageDocumentation_DoesNotEmitWarning() + { + var logger = new RecordingLogger(); + var custom = PluginContractValidator.CreateCustom( + supportedSchemeTypes: + [ + .. PluginContractValidator.SupportedSchemeTypes, + AuthKitSecuritySchemeType.Custom + ]); + + custom.Validate(new FakePlugin( + Describe(AuthKitSecuritySchemeType.Custom, + description: "Plugin-defined API key verified against a plugin database.")), + logger); + + Assert.Empty(logger.Warnings); + } + + [Fact] + public void CustomWarning_NeverMapsCustomToAnotherScheme() + { + var logger = new RecordingLogger(); + var custom = PluginContractValidator.CreateCustom( + supportedSchemeTypes: + [ + .. PluginContractValidator.SupportedSchemeTypes, + AuthKitSecuritySchemeType.Custom + ]); + + custom.Validate(new FakePlugin( + Describe(AuthKitSecuritySchemeType.Custom, description: null)), logger); + + Assert.Contains(logger.Warnings, w => w.Contains("never mapped", StringComparison.OrdinalIgnoreCase)); + } +} \ No newline at end of file diff --git a/tests/Plugins/Abstractions/AuthKitApiKeyLocationTests.cs b/tests/Plugins/Abstractions/AuthKitApiKeyLocationTests.cs new file mode 100644 index 0000000..73446ad --- /dev/null +++ b/tests/Plugins/Abstractions/AuthKitApiKeyLocationTests.cs @@ -0,0 +1,77 @@ +using System; +using AuthKit.Plugins.Abstractions; +using Xunit; + +namespace AuthKit.Plugins.Abstractions.Tests.SecuritySchemes; + +public class AuthKitApiKeyLocationTests +{ + [Theory] + [InlineData(AuthKitApiKeyLocation.GrpcMetadata, "GrpcMetadata")] + [InlineData(AuthKitApiKeyLocation.Body, "Body")] + public void NewEnumValues_AreDefined(AuthKitApiKeyLocation location, string name) + { + var actualName = location.ToString(); + Assert.Equal(name, actualName); + } + + [Fact] + public void GrpcMetadata_HasCorrectNumericValue() + { + Assert.Equal(3, (int)AuthKitApiKeyLocation.GrpcMetadata); + } + + [Fact] + public void Body_HasCorrectNumericValue() + { + Assert.Equal(4, (int)AuthKitApiKeyLocation.Body); + } + + [Fact] + public void ExistingValues_AreUnchanged() + { + Assert.Equal(0, (int)AuthKitApiKeyLocation.Header); + Assert.Equal(1, (int)AuthKitApiKeyLocation.Query); + Assert.Equal(2, (int)AuthKitApiKeyLocation.Cookie); + } + + [Fact] + public void GrpcMetadata_IsDistinctFromHeader() + { + Assert.NotEqual(AuthKitApiKeyLocation.GrpcMetadata, AuthKitApiKeyLocation.Header); + Assert.NotEqual((int)AuthKitApiKeyLocation.GrpcMetadata, (int)AuthKitApiKeyLocation.Header); + } + + [Fact] + public void Body_IsDistinctFromAllExistingLocations() + { + Assert.NotEqual(AuthKitApiKeyLocation.Body, AuthKitApiKeyLocation.Header); + Assert.NotEqual(AuthKitApiKeyLocation.Body, AuthKitApiKeyLocation.Query); + Assert.NotEqual(AuthKitApiKeyLocation.Body, AuthKitApiKeyLocation.Cookie); + Assert.NotEqual(AuthKitApiKeyLocation.Body, AuthKitApiKeyLocation.GrpcMetadata); + + Assert.NotEqual((int)AuthKitApiKeyLocation.Body, (int)AuthKitApiKeyLocation.Header); + Assert.NotEqual((int)AuthKitApiKeyLocation.Body, (int)AuthKitApiKeyLocation.Query); + Assert.NotEqual((int)AuthKitApiKeyLocation.Body, (int)AuthKitApiKeyLocation.Cookie); + Assert.NotEqual((int)AuthKitApiKeyLocation.Body, (int)AuthKitApiKeyLocation.GrpcMetadata); + } + + [Fact] + public void UnknownEnumValues_AreNotDefined() + { + var unknownValue = (AuthKitApiKeyLocation)(-1); + Assert.False(Enum.IsDefined(typeof(AuthKitApiKeyLocation), unknownValue)); + } + + [Fact] + public void NewValues_HaveUniqueNumericValues() + { + var newValues = new[] + { + AuthKitApiKeyLocation.GrpcMetadata, + AuthKitApiKeyLocation.Body + }; + + Assert.Equal(newValues.Length, newValues.Distinct().Count()); + } +} \ No newline at end of file From dd69b2b95d19d8da84252a38f5b0ebbcc4621b51 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 12:13:50 +0200 Subject: [PATCH 07/20] refactor(security): stabilize auth scheme enums and metadata --- .../ServiceDiscoveryFilter.cs | 10 ++- .../AuthKit.Plugins.Abstractions.csproj | 1 + .../Abstractions/AuthKitApiKeyLocation.cs | 69 +++++++++++++++++-- .../Abstractions/AuthKitSecuritySchemeType.cs | 43 ++++++++---- src/Plugins/Abstractions/IAuthKitPlugin.cs | 40 ++++++++++- .../Solutions/DevTokens/DevTokensPlugin.cs | 35 +++++----- 6 files changed, 156 insertions(+), 42 deletions(-) diff --git a/src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs b/src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs index 83e8032..1eeff7e 100644 --- a/src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs +++ b/src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs @@ -1,5 +1,7 @@ namespace Host.ServiceDiscovery; +using System.Reflection; + /// /// Filters types during service discovery for automatic DI registration. /// @@ -88,11 +90,17 @@ private static string GetFeature(string ns) return parts.Length > 1 ? parts[1] : "Global"; } + private static bool IsRecord(Type type) + => type.GetMethod( + "$", + BindingFlags.Public | BindingFlags.Instance) is not null; + /// /// Applies rules to decide inclusion. /// private static bool ShouldInclude(Type type, string ns, string layer, ServiceDiscoveryOptions opts) - => !(opts.SkipInterfaces && type.IsInterface) + => !IsRecord(type) + && !(opts.SkipInterfaces && type.IsInterface) && !(opts.SkipExceptions && typeof(Exception).IsAssignableFrom(type)) && !opts.ExcludedTypes.Contains(type) && !opts.ExcludedNamespaces.Any(ns.Contains) diff --git a/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj index a1d43da..90d4a1e 100644 --- a/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj +++ b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj @@ -14,5 +14,6 @@ + \ No newline at end of file diff --git a/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs b/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs index dcc9c01..0b7af4f 100644 --- a/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs +++ b/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs @@ -9,11 +9,15 @@ namespace AuthKit.Plugins.Abstractions; /// on the transport used by the incoming request, such as HTTP or gRPC. /// /// -/// For HTTP requests, API keys may be provided through request headers, query -/// string parameters, or cookies. For gRPC requests, API keys are typically -/// provided through request metadata, which is represented by -/// . -/// + /// For HTTP requests, API keys may be provided through request headers, query + /// string parameters, or cookies. For gRPC requests, API keys are typically + /// provided through gRPC request metadata. + /// + /// + /// Every value describes a separate transport. In particular, + /// is explicit gRPC metadata and is distinct from + /// ; a host must never silently convert one to the other. + /// /// /// The selected location determines where the AuthKit authentication pipeline /// searches for the API key before attempting validation. @@ -30,7 +34,11 @@ public enum AuthKitApiKeyLocation /// header, such as X-Api-Key. /// /// - /// For gRPC requests, the API key is retrieved from the request metadata. + /// For gRPC requests, the API key may also be retrieved from the request + /// metadata for backward-compatible plugins. New gRPC configurations should + /// prefer the explicit location. This value + /// remains distinct from : a host must never + /// silently convert to . /// /// Header, @@ -61,5 +69,52 @@ public enum AuthKitApiKeyLocation /// and is not available for native gRPC calls. /// /// - Cookie + Cookie, + + /// + /// Retrieves the API key from gRPC metadata. + /// + /// + /// + /// The API key is expected to be supplied through gRPC request metadata + /// (key-value pairs in the gRPC metadata headers). + /// + /// + /// This location is distinct from and is specifically + /// for gRPC transport. It must not be silently treated as equivalent to + /// even though both use metadata-like structures. + /// + /// + /// A host that does not support gRPC metadata credential extraction must + /// explicitly reject this configuration. + /// + /// + GrpcMetadata = 3, + + /// + /// Retrieves the API key from the request body. + /// + /// + /// + /// The API key is expected to be supplied within the request body payload. + /// The exact format (e.g., JSON field, form field) is determined by the + /// host implementation. + /// + /// + /// This location is distinct from , , + /// and . It must not be silently converted to any + /// other location. + /// + /// + /// A host that does not support body-based credential extraction must + /// explicitly reject this configuration. + /// + /// + /// This value defines only the credential transport location. It does not + /// define or change the authentication mechanism. For example, when used + /// with , it means the API + /// key credential is transported through the request body. + /// + /// + Body = 4 } diff --git a/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs b/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs index 007ad98..34d5ea7 100644 --- a/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs +++ b/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs @@ -14,6 +14,12 @@ namespace AuthKit.Plugins.Abstractions; /// level. It does not itself perform authentication, validate credentials, /// or establish an authentication session. /// +/// +/// Numeric values are part of the plugin contract and MUST NOT be reused or +/// renumbered. Each value must remain unique so an authentication mechanism +/// can never be confused with a different mechanism that happens to share a +/// value. +/// /// public enum AuthKitSecuritySchemeType { @@ -25,7 +31,7 @@ public enum AuthKitSecuritySchemeType /// . /// Common locations include an HTTP header, query parameter, or cookie. /// - ApiKey, + ApiKey = 0, /// /// Authentication using an HTTP authentication scheme. @@ -36,7 +42,7 @@ public enum AuthKitSecuritySchemeType /// Examples include Basic, Bearer, and other HTTP /// authentication schemes. /// - Http, + Http = 1, /// /// Authentication using the OAuth 2.0 authorization framework. @@ -46,16 +52,7 @@ public enum AuthKitSecuritySchemeType /// obtains an access token from an authorization server and presents /// that token when accessing protected resources. /// - OAuth2, - - /// - /// Authentication using the OpenID Connect identity layer. - /// - /// - /// OpenID Connect extends OAuth 2.0 with an identity layer and is used - /// to authenticate users through an OpenID Connect identity provider. - /// - OpenIdConnect = 2, + OAuth2 = 2, /// /// Mutual TLS authentication using client certificate. @@ -76,5 +73,25 @@ public enum AuthKitSecuritySchemeType /// /// HTTP Basic authentication using the Authorization header. /// - Basic = 6 + Basic = 6, + + /// + /// Authentication using the OpenID Connect identity layer. + /// + /// + /// + /// OpenID Connect extends OAuth 2.0 with an identity layer and is used + /// to authenticate users through an OpenID Connect identity provider. + /// + /// + /// The value 7 is intentional: the host already occupies values + /// 0 (), 1 (), and + /// 2 (), while values 3..6 belong + /// to , , , + /// and . Historically + /// aliased at value 2; it now has a distinct + /// value so OAuth 2.0 and OpenID Connect can never be confused. + /// + /// + OpenIdConnect = 7 } diff --git a/src/Plugins/Abstractions/IAuthKitPlugin.cs b/src/Plugins/Abstractions/IAuthKitPlugin.cs index f20698f..c4592de 100644 --- a/src/Plugins/Abstractions/IAuthKitPlugin.cs +++ b/src/Plugins/Abstractions/IAuthKitPlugin.cs @@ -1,5 +1,7 @@ +using AuthKit.Plugins.Abstractions.Contracts.Plugins; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using System.Reflection; namespace AuthKit.Plugins.Abstractions; @@ -30,7 +32,19 @@ public interface IAuthKitPlugin /// The name is used to identify the plugin in host diagnostics, /// startup output, and other plugin-related metadata. /// - string Name { get; } + /// + /// By default the name is read from the . + /// Plugin classes may override this member, but the attribute is the + /// preferred place to declare the plugin identity. + /// + string Name + { + get + { + var metadata = GetPluginMetadata(); + return metadata is null ? GetType().Name : metadata.Name; + } + } /// /// Gets the version of the plugin. @@ -39,7 +53,17 @@ public interface IAuthKitPlugin /// The version is exposed as plugin metadata and may be used by the host /// for diagnostics, compatibility checks, or administrative surfaces. /// - string Version { get; } + /// + /// By default the version is read from the . + /// + string Version + { + get + { + var metadata = GetPluginMetadata(); + return metadata is null ? "0.0.0" : metadata.Version; + } + } /// /// Gets an optional human-readable description of the plugin. @@ -48,7 +72,10 @@ public interface IAuthKitPlugin /// The description may be displayed by the host in startup output, /// diagnostics, administrative interfaces, or other status surfaces. /// - string? Description => null; + /// + /// By default the description is read from the . + /// + string? Description => GetPluginMetadata()?.Description; /// /// Registers the plugin's services in the host dependency injection container. @@ -138,4 +165,11 @@ Task CheckHealthAsync(IServiceProvider services) => /// IReadOnlyDictionary GetSecuritySchemes() => new Dictionary(); + + /// + /// Reads the declared on the plugin + /// implementation class. + /// + private PluginMetadataAttribute? GetPluginMetadata() => + ((object)this).GetType().GetCustomAttribute(); } diff --git a/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs b/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs index a2023f4..5138db6 100644 --- a/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs +++ b/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs @@ -1,6 +1,6 @@ -using DevTokens.DeveloperTokens; using DevTokens.Options; using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; using FluentValidation; using DevTokens.Interfaces; using DevTokens.Middleware; @@ -31,24 +31,23 @@ namespace DevTokens; /// infrastructure. /// /// +[PluginMetadata( + id: "authkit.devtokens", + version: "1.0.0", + tags: ["auth", "tokens", "sdk"], + dependsOn: [], + capabilities: ["auth"], + name: "DevTokens", + displayName: "Developer Tokens", + description: "Issues and validates developer tokens for SDK access.", + author: "AuthKit Contributors", + license: "MIT", + licenseUrl: "https://opensource.org/licenses/MIT", + homepage: "https://example.org/devtokens", + repositoryUrl: "https://example.org/devtokens.git" +)] public sealed class DevTokensPlugin : IAuthKitPlugin { - /// - /// Gets the unique name of the plugin. - /// - public string Name => "DevTokens"; - - /// - /// Gets the current version of the plugin. - /// - public string Version => "1.0.0"; - - /// - /// Gets a description of the functionality provided by the plugin. - /// - public string Description => - "Issues and validates developer tokens for SDK access."; - /// /// Registers developer token services, repositories, validators, /// and authorization components in the dependency injection container. @@ -101,4 +100,4 @@ public IReadOnlyDictionary GetSecurityS Description = "AuthKit developer JWT token" } }; -} +} \ No newline at end of file From edc6bfa74d95b4d00b393fc36e923e17840859a7 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 12:29:02 +0200 Subject: [PATCH 08/20] feat(security): support JSON and form API key extraction --- .../FormUrlEncodedApiKeyBodyParser.cs | 69 ++++++++++++++++++ .../Security/BodyParsers/IApiKeyBodyParser.cs | 37 ++++++++++ .../BodyParsers/JsonApiKeyBodyParser.cs | 71 +++++++++++++++++++ tests/Host/PluginContractValidatorTests.cs | 6 +- 4 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 src/Host/Security/BodyParsers/FormUrlEncodedApiKeyBodyParser.cs create mode 100644 src/Host/Security/BodyParsers/IApiKeyBodyParser.cs create mode 100644 src/Host/Security/BodyParsers/JsonApiKeyBodyParser.cs diff --git a/src/Host/Security/BodyParsers/FormUrlEncodedApiKeyBodyParser.cs b/src/Host/Security/BodyParsers/FormUrlEncodedApiKeyBodyParser.cs new file mode 100644 index 0000000..b4100c3 --- /dev/null +++ b/src/Host/Security/BodyParsers/FormUrlEncodedApiKeyBodyParser.cs @@ -0,0 +1,69 @@ +namespace Host.Security.BodyParsers; + +/// +/// Extracts an API key field from an +/// application/x-www-form-urlencoded request body. +/// +/// +/// Form fields are matched by name using case-insensitive comparison. +/// +/// Values are decoded according to standard URL form encoding rules, +/// including restoring + characters as spaces. +/// +/// +public sealed class FormUrlEncodedApiKeyBodyParser : IApiKeyBodyParser +{ + /// + /// Determines whether this parser supports the specified content type. + /// + /// The request content type. + /// + /// true if the content type is + /// application/x-www-form-urlencoded; otherwise, false. + /// + public bool CanHandle(string? contentType) => + !string.IsNullOrEmpty(contentType) + && contentType.Contains( + "application/x-www-form-urlencoded", + StringComparison.OrdinalIgnoreCase); + + /// + /// Extracts an API key from the specified form field. + /// + /// The URL encoded form request body. + /// The name of the field containing the API key. + /// + /// The decoded API key, or null if the specified field is not present. + /// + public string? Parse(string body, string fieldName) + { + foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var separatorIndex = pair.IndexOf('='); + + if (separatorIndex <= 0) + continue; + + var name = FormDecode(pair[..separatorIndex]); + + if (!string.Equals(name, fieldName, StringComparison.OrdinalIgnoreCase)) + continue; + + var value = pair[(separatorIndex + 1)..]; + + return string.IsNullOrEmpty(value) + ? value + : FormDecode(value); + } + + return null; + } + + /// + /// Decodes URL encoded form value, restoring + characters as spaces. + /// + /// The encoded form value. + /// The decoded form value. + private static string FormDecode(string value) => + Uri.UnescapeDataString(value.Replace('+', ' ')); +} \ No newline at end of file diff --git a/src/Host/Security/BodyParsers/IApiKeyBodyParser.cs b/src/Host/Security/BodyParsers/IApiKeyBodyParser.cs new file mode 100644 index 0000000..212ca78 --- /dev/null +++ b/src/Host/Security/BodyParsers/IApiKeyBodyParser.cs @@ -0,0 +1,37 @@ +namespace Host.Security.BodyParsers; + +/// +/// Extracts an API key field from a request body payload of a specific format. +/// +/// +/// +/// Implementations are selected by content type, allowing new request body +/// formats to be supported without modifying existing code. +/// +/// +/// The parser is expected to return null when the requested field is +/// absent or the payload cannot be parsed. +/// +/// +public interface IApiKeyBodyParser +{ + /// + /// Determines whether this parser can handle the given content type. + /// + /// The request content type value. + /// + /// true when the parser can handle the content type; otherwise, + /// false. + /// + bool CanHandle(string? contentType); + + /// + /// Extracts the value of the named field from the request body. + /// + /// The raw request body payload. + /// The name of the field holding the API key. + /// + /// The extracted API key, or null when the field is absent. + /// + string? Parse(string body, string fieldName); +} \ No newline at end of file diff --git a/src/Host/Security/BodyParsers/JsonApiKeyBodyParser.cs b/src/Host/Security/BodyParsers/JsonApiKeyBodyParser.cs new file mode 100644 index 0000000..24e9f07 --- /dev/null +++ b/src/Host/Security/BodyParsers/JsonApiKeyBodyParser.cs @@ -0,0 +1,71 @@ +using System.Text.Json; + +namespace Host.Security.BodyParsers; + +/// +/// Extracts an API key field from a JSON request body. +/// +/// +/// +/// Field names are matched using case-insensitive comparison, and only +/// string-valued properties are considered. +/// +/// Invalid JSON payloads are ignored and result in null. +/// +public sealed class JsonApiKeyBodyParser( + ILogger logger) : IApiKeyBodyParser +{ + /// + /// Determines whether this parser supports the specified content type. + /// + /// The request content type. + /// true if the content type is application/json otherwise, false + public bool CanHandle(string? contentType) => + !string.IsNullOrEmpty(contentType) + && contentType.Contains( + "application/json", + StringComparison.OrdinalIgnoreCase); + + /// + /// Extracts an API key from the specified JSON field. + /// + /// The JSON request body. + /// The name of the field containing the API key. + /// + /// The API key value, or null if the field is not found or + /// the request body contains invalid or unsupported JSON. + /// + public string? Parse(string body, string fieldName) + { + if (!body.AsSpan().TrimStart().StartsWith('{')) + return null; + + try + { + using var doc = JsonDocument.Parse(body); + + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return null; + + foreach (var property in doc.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind == JsonValueKind.String + && string.Equals( + property.Name, + fieldName, + StringComparison.OrdinalIgnoreCase)) + { + return property.Value.GetString(); + } + } + } + catch (JsonException ex) + { + logger.LogDebug( + ex, + "Request body is not valid JSON; skipping JSON parsing"); + } + + return null; + } +} \ No newline at end of file diff --git a/tests/Host/PluginContractValidatorTests.cs b/tests/Host/PluginContractValidatorTests.cs index 72fe244..eed6dfc 100644 --- a/tests/Host/PluginContractValidatorTests.cs +++ b/tests/Host/PluginContractValidatorTests.cs @@ -24,7 +24,7 @@ private sealed class FakePlugin(AuthKitSecuritySchemeDescriptor descriptor) : IA public string Name => "Fake"; public string Version => "1.0.0"; - public static void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } public IReadOnlyDictionary GetSecuritySchemes() => _schemes; } @@ -44,9 +44,9 @@ private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger { public List Warnings { get; } = new(); - public static IDisposable? BeginScope(TState state) where TState : notnull => null; + public IDisposable? BeginScope(TState state) where TState : notnull => null; - public static bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; public void Log( Microsoft.Extensions.Logging.LogLevel logLevel, From 35fefd99cba042e7cd8be545c109b4aca0a11566 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 12:37:25 +0200 Subject: [PATCH 09/20] feat(security): introduce API key extraction pipeline --- .../ApiKeyLocationExtractorBase.cs | 63 +++++++++++ .../ApiKeyLocationExtractorRegistry.cs | 38 +++++++ .../ApiKeyValueNormalizer.cs | 44 ++++++++ .../BodyApiKeyLocationExtractor.cs | 105 ++++++++++++++++++ .../CookieApiKeyLocationExtractor.cs | 50 +++++++++ .../GrpcMetadataApiKeyLocationExtractor.cs | 63 +++++++++++ .../HeaderApiKeyLocationExtractor.cs | 55 +++++++++ .../IApiKeyLocationExtractor.cs | 23 ++++ .../IApiKeyLocationExtractorRegistry.cs | 20 ++++ .../QueryApiKeyLocationExtractor.cs | 46 ++++++++ 10 files changed, 507 insertions(+) create mode 100644 src/Host/Security/LocationExtractors/ApiKeyLocationExtractorBase.cs create mode 100644 src/Host/Security/LocationExtractors/ApiKeyLocationExtractorRegistry.cs create mode 100644 src/Host/Security/LocationExtractors/ApiKeyValueNormalizer.cs create mode 100644 src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs create mode 100644 src/Host/Security/LocationExtractors/CookieApiKeyLocationExtractor.cs create mode 100644 src/Host/Security/LocationExtractors/GrpcMetadataApiKeyLocationExtractor.cs create mode 100644 src/Host/Security/LocationExtractors/HeaderApiKeyLocationExtractor.cs create mode 100644 src/Host/Security/LocationExtractors/IApiKeyLocationExtractor.cs create mode 100644 src/Host/Security/LocationExtractors/IApiKeyLocationExtractorRegistry.cs create mode 100644 src/Host/Security/LocationExtractors/QueryApiKeyLocationExtractor.cs diff --git a/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorBase.cs b/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorBase.cs new file mode 100644 index 0000000..de4c7ea --- /dev/null +++ b/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorBase.cs @@ -0,0 +1,63 @@ +using AuthKit.Plugins.Abstractions; +using Microsoft.Extensions.Options; + +using Host.Security.Options; + +namespace Host.Security.LocationExtractors; + +/// +/// Base class for implementations that +/// resolve credential names using configured defaults. +/// +/// +/// +/// Derived classes declare the credential location they handle through +/// and implement credential extraction in +/// . +/// +/// +/// uses the configured default when the security +/// scheme does not provide an explicit credential name. +/// +/// +public abstract class ApiKeyLocationExtractorBase : IApiKeyLocationExtractor +{ + /// + /// Gets the options controlling credential extraction defaults and + /// request body buffering behavior. + /// + protected ApiKeyCredentialExtractorOptions Options { get; } + + /// + /// Initializes new instance of the + /// class. + /// + /// The options controlling credential extraction. + protected ApiKeyLocationExtractorBase( + IOptions options) => + Options = options.Value; + + /// + /// Gets the API key location handled by this extractor. + /// + public abstract AuthKitApiKeyLocation Location { get; } + + /// + /// Extracts an API key from the specified HTTP request. + /// + /// The current HTTP request context. + /// The security scheme describing the credential. + /// The extracted API key, or null if the credential is not present. + public abstract Task ExtractAsync(HttpContext context, AuthKitSecuritySchemeDescriptor scheme); + + /// + /// Resolves the credential name using the configured or provided default. + /// + /// The credential name declared by the scheme. + /// The default name used when the scheme is empty. + /// The resolved credential name. + protected static string ResolveName(string? schemeName, string defaultValue) => + string.IsNullOrWhiteSpace(schemeName) + ? defaultValue + : schemeName; +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorRegistry.cs b/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorRegistry.cs new file mode 100644 index 0000000..a93dff4 --- /dev/null +++ b/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorRegistry.cs @@ -0,0 +1,38 @@ +using AuthKit.Plugins.Abstractions; + +namespace Host.Security.LocationExtractors; + +/// +/// Resolves API key location extractors registered in the dependency injection container. +/// +/// +/// +/// Extractors are indexed by their declared . +/// Registering more than one extractor for the same location causes an exception. +/// +/// +/// Additional locations can be supported by implementing +/// and registering the implementation +/// in the dependency injection container. +/// +/// +public sealed class ApiKeyLocationExtractorRegistry( + IEnumerable extractors) : IApiKeyLocationExtractorRegistry +{ + private readonly IReadOnlyDictionary _extractors = + extractors.ToDictionary(e => e.Location); + + /// + /// Resolves the extractor registered for the specified API key location. + /// + /// The API key credential location. + /// The extractor responsible for the specified location. + /// + /// Thrown when no extractor is registered for the specified location. + /// + public IApiKeyLocationExtractor Resolve(AuthKitApiKeyLocation location) => + _extractors.TryGetValue(location, out var extractor) + ? extractor + : throw new NotSupportedException( + $"No API key credential extractor is registered for location '{location}'. Configure the host to support this location."); +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/ApiKeyValueNormalizer.cs b/src/Host/Security/LocationExtractors/ApiKeyValueNormalizer.cs new file mode 100644 index 0000000..6e2ab3a --- /dev/null +++ b/src/Host/Security/LocationExtractors/ApiKeyValueNormalizer.cs @@ -0,0 +1,44 @@ +namespace Host.Security.LocationExtractors; + +/// +/// Normalizes API key credential values extracted from HTTP requests. +/// +/// +/// +/// Credential values may contain surrounding whitespace or quotes introduced +/// by the transport. The normalizer removes these characters before validation. +/// +/// +/// additionally removes case-insensitive +/// Bearer prefix from the normalized value. +/// +/// +public static class ApiKeyValueNormalizer +{ + private const string BearerPrefix = "Bearer "; + + /// + /// Trims surrounding whitespace and wrapping double quotes. + /// + /// The raw credential value. + /// The normalized credential value. + public static string Normalize(string value) => + value.Trim().Trim('"'); + + /// + /// Normalizes the credential value and removes Bearer prefix, + /// if present. + /// + /// The raw credential value. + /// The normalized credential value without the bearer prefix. + public static string StripBearerPrefix(string value) + { + var normalized = Normalize(value); + + return normalized.StartsWith( + BearerPrefix, + StringComparison.OrdinalIgnoreCase) + ? normalized[BearerPrefix.Length..].Trim() + : normalized; + } +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs new file mode 100644 index 0000000..39c316d --- /dev/null +++ b/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs @@ -0,0 +1,105 @@ +using System.Text; +using AuthKit.Plugins.Abstractions; +using Microsoft.Extensions.Options; +using Host.Security.BodyParsers; +using Host.Security.Options; + +namespace Host.Security.LocationExtractors; + +/// +/// Extracts an API key from buffered request body using registered +/// that supports the request content type. +/// +/// +/// +/// The request body is buffered to allow reading and rewinding without +/// affecting downstream middleware or request handlers. +/// +/// +/// The body is read only when a compatible parser is registered and the +/// request size does not exceed the configured buffer threshold. +/// +/// +public sealed class BodyApiKeyLocationExtractor( + IEnumerable parsers, + IOptions options, + ILogger logger) : ApiKeyLocationExtractorBase(options) +{ + /// + /// Gets the API key location handled by this extractor. + /// + public override AuthKitApiKeyLocation Location => + AuthKitApiKeyLocation.Body; + + /// + /// Extracts an API key from the request body using a parser that supports + /// the request content type. + /// + /// The current HTTP request context. + /// The security scheme describing the credential. + /// + /// The extracted API key, or null when the body cannot be processed + /// or does not contain the configured credential field. + /// + public override async Task ExtractAsync( + HttpContext context, + AuthKitSecuritySchemeDescriptor scheme) + { + if (context.Request.ContentLength == 0) + return null; + + var parser = parsers.FirstOrDefault( + p => p.CanHandle(context.Request.ContentType)); + + if (parser is null) + { + logger.LogDebug("Skipping Body credential extraction for scheme {Scheme}: unsupported content type {ContentType}", + scheme.Name, + context.Request.ContentType); + + return null; + } + + if (context.Request.ContentLength is { } length + && length > Options.BufferThreshold) + { + logger.LogWarning("Skipping Body credential extraction for scheme {Scheme}: body of {Length} bytes exceeds configured buffer threshold of {Threshold} bytes", + scheme.Name, + length, + Options.BufferThreshold); + + return null; + } + + if (!context.Request.Body.CanSeek) + context.Request.EnableBuffering(Options.BufferThreshold); + + var originalPosition = context.Request.Body.Position; + + try + { + context.Request.Body.Position = 0; + + using var reader = new StreamReader( + context.Request.Body, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + leaveOpen: true); + + var body = await reader.ReadToEndAsync(); + + if (string.IsNullOrWhiteSpace(body)) + return null; + + var fieldName = ResolveName( + scheme.Name, + Options.DefaultQueryName); + + return parser.Parse(body, fieldName); + } + finally + { + context.Request.Body.Position = originalPosition; + } + } +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/CookieApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/CookieApiKeyLocationExtractor.cs new file mode 100644 index 0000000..1849ca0 --- /dev/null +++ b/src/Host/Security/LocationExtractors/CookieApiKeyLocationExtractor.cs @@ -0,0 +1,50 @@ +using AuthKit.Plugins.Abstractions; +using Microsoft.Extensions.Options; + +using Host.Security.Options; + +namespace Host.Security.LocationExtractors; + +/// +/// Extracts an API key from an HTTP request cookie. +/// +/// +/// +/// The cookie name is resolved from the security scheme and falls back to +/// when no +/// explicit name is provided. +/// +/// +public sealed class CookieApiKeyLocationExtractor( + IOptions options) + : ApiKeyLocationExtractorBase(options) +{ + /// + /// Gets the API key location handled by this extractor. + /// + public override AuthKitApiKeyLocation Location => + AuthKitApiKeyLocation.Cookie; + + /// + /// Extracts an API key from the request cookie collection. + /// + /// The current HTTP request context. + /// The security scheme describing the credential. + /// + /// The normalized API key, or null if the specified cookie is not present. + /// + public override Task ExtractAsync( + HttpContext context, + AuthKitSecuritySchemeDescriptor scheme) + { + var cookieName = ResolveName(scheme.Name, Options.DefaultCookieName); + + if (context.Request.Cookies.TryGetValue(cookieName, out var cookie)) + { + return Task.FromResult( + ApiKeyValueNormalizer.Normalize(cookie)); + } + + return Task.FromResult(null); + } +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/GrpcMetadataApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/GrpcMetadataApiKeyLocationExtractor.cs new file mode 100644 index 0000000..db94b83 --- /dev/null +++ b/src/Host/Security/LocationExtractors/GrpcMetadataApiKeyLocationExtractor.cs @@ -0,0 +1,63 @@ +using AuthKit.Plugins.Abstractions; +using Microsoft.Extensions.Options; + +using Host.Security.Options; + +namespace Host.Security.LocationExtractors; + +/// +/// Extracts an API key from gRPC request metadata represented as HTTP/2 headers. +/// +/// +/// +/// gRPC metadata keys are transmitted as lowercase HTTP/2 headers. The +/// configured credential name is therefore normalized to lowercase before +/// looking it up in the request headers. +/// +/// +/// Hosts that cannot represent gRPC metadata as HTTP headers should reject the +/// configuration rather than silently falling back to another location. +/// +/// +public sealed class GrpcMetadataApiKeyLocationExtractor( + IOptions options, + ILogger logger) + : ApiKeyLocationExtractorBase(options) +{ + /// + /// Gets the API key location handled by this extractor. + /// + public override AuthKitApiKeyLocation Location => + AuthKitApiKeyLocation.GrpcMetadata; + + /// + /// Extracts an API key from gRPC request metadata represented as an HTTP/2 header. + /// + /// The current HTTP request context. + /// The security scheme describing the credential. + /// The normalized API key, or null if the metadata entry is not present. + public override Task ExtractAsync( + HttpContext context, + AuthKitSecuritySchemeDescriptor scheme) + { + var headerName = string.IsNullOrWhiteSpace(scheme.Name) + ? Options.DefaultHeaderName.ToLowerInvariant() + : scheme.Name.ToLowerInvariant(); + + if(headerName == Options.DefaultHeaderName.ToLowerInvariant()) + { + logger.LogDebug( + "Reading gRPC metadata for scheme {Scheme} as HTTP header '{HeaderName}'", + scheme.Name, + headerName); + } + + if(context.Request.Headers.TryGetValue(headerName, out var header)) + { + return Task.FromResult( + ApiKeyValueNormalizer.Normalize(header.ToString())); + } + + return Task.FromResult(null); + } +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/HeaderApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/HeaderApiKeyLocationExtractor.cs new file mode 100644 index 0000000..ac1cbc9 --- /dev/null +++ b/src/Host/Security/LocationExtractors/HeaderApiKeyLocationExtractor.cs @@ -0,0 +1,55 @@ +using AuthKit.Plugins.Abstractions; +using Microsoft.Extensions.Options; + +using Host.Security.Options; + +namespace Host.Security.LocationExtractors; + +/// +/// Extracts an API key from an HTTP request header or transport metadata. +/// +/// +/// +/// The header name is resolved from the security scheme and falls back to +/// when no +/// explicit name is provided. +/// +/// +/// A case-insensitive Bearer prefix is removed from the extracted +/// value, allowing the header to contain either plain API key or bearer +/// token. +/// +/// +public sealed class HeaderApiKeyLocationExtractor( + IOptions options) + : ApiKeyLocationExtractorBase(options) +{ + /// + /// Gets the API key location handled by this extractor. + /// + public override AuthKitApiKeyLocation Location => + AuthKitApiKeyLocation.Header; + + /// + /// Extracts an API key from the specified request header. + /// + /// The current HTTP request context. + /// The security scheme describing the credential. + /// + /// The normalized API key, or null if the specified header is not present. + /// + public override Task ExtractAsync( + HttpContext context, + AuthKitSecuritySchemeDescriptor scheme) + { + var headerName = ResolveName(scheme.Name, Options.DefaultHeaderName); + + if (context.Request.Headers.TryGetValue(headerName, out var header)) + { + return Task.FromResult( + ApiKeyValueNormalizer.StripBearerPrefix(header.ToString())); + } + + return Task.FromResult(null); + } +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/IApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/IApiKeyLocationExtractor.cs new file mode 100644 index 0000000..049f104 --- /dev/null +++ b/src/Host/Security/LocationExtractors/IApiKeyLocationExtractor.cs @@ -0,0 +1,23 @@ +using AuthKit.Plugins.Abstractions; + +namespace Host.Security.LocationExtractors; + +/// +/// Extracts an API key credential from a request for a single +/// . +/// +public interface IApiKeyLocationExtractor +{ + /// + /// Gets the credential location this extractor handles. + /// + AuthKitApiKeyLocation Location { get; } + + /// + /// Attempts to extract the API key for the given scheme from the request. + /// + /// The current . + /// The security scheme describing where the credential lives. + /// The extracted API key, or null when none is present. + Task ExtractAsync(HttpContext context, AuthKitSecuritySchemeDescriptor scheme); +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/IApiKeyLocationExtractorRegistry.cs b/src/Host/Security/LocationExtractors/IApiKeyLocationExtractorRegistry.cs new file mode 100644 index 0000000..3a51bf7 --- /dev/null +++ b/src/Host/Security/LocationExtractors/IApiKeyLocationExtractorRegistry.cs @@ -0,0 +1,20 @@ +using AuthKit.Plugins.Abstractions; + +namespace Host.Security.LocationExtractors; + +/// +/// Resolves the responsible for a given +/// . +/// +public interface IApiKeyLocationExtractorRegistry +{ + /// + /// Returns the extractor registered for the given location. + /// + /// The credential location to resolve. + /// The extractor handling the requested location. + /// + /// Thrown when no extractor is registered for the location. + /// + IApiKeyLocationExtractor Resolve(AuthKitApiKeyLocation location); +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/QueryApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/QueryApiKeyLocationExtractor.cs new file mode 100644 index 0000000..76c30b3 --- /dev/null +++ b/src/Host/Security/LocationExtractors/QueryApiKeyLocationExtractor.cs @@ -0,0 +1,46 @@ +using AuthKit.Plugins.Abstractions; +using Microsoft.Extensions.Options; + +using Host.Security.Options; + +namespace Host.Security.LocationExtractors; + +/// +/// Extracts an API key from an HTTP request query parameter. +/// +/// +/// The query parameter name is resolved from the security scheme and falls back +/// to when no +/// explicit name is provided. +/// +public sealed class QueryApiKeyLocationExtractor( + IOptions options) + : ApiKeyLocationExtractorBase(options) +{ + /// + /// Gets the API key location handled by this extractor. + /// + public override AuthKitApiKeyLocation Location => + AuthKitApiKeyLocation.Query; + + /// + /// Extracts an API key from the specified request query parameter. + /// + /// The current HTTP request context. + /// The security scheme describing the credential. + /// The normalized API key, or null if the query parameter is not present. + public override Task ExtractAsync( + HttpContext context, + AuthKitSecuritySchemeDescriptor scheme) + { + var queryName = ResolveName(scheme.Name, Options.DefaultQueryName); + + if (context.Request.Query.TryGetValue(queryName, out var query)) + { + return Task.FromResult( + ApiKeyValueNormalizer.Normalize(query.ToString())); + } + + return Task.FromResult(null); + } +} \ No newline at end of file From 3fe9973d7ac82236ad7124466a1745e19ef9d638 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 12:40:07 +0200 Subject: [PATCH 10/20] feat(security): add API key extraction and plugin validation --- src/Host/Plugins/PluginContractValidator.cs | 188 ++++++++++++++++++ .../Middleware/ApiKeyCredentialExtractor.cs | 76 +++++++ .../ApiKeyCredentialExtractorOptions.cs | 45 +++++ .../SecurityServiceCollectionExtensions.cs | 52 +++++ .../Security/Validation/ApiKeyPrincipal.cs | 26 +++ .../Security/Validation/IApiKeyValidator.cs | 19 ++ .../Plugins/PluginMetadataAttribute.cs | 115 +++++++++++ 7 files changed, 521 insertions(+) create mode 100644 src/Host/Plugins/PluginContractValidator.cs create mode 100644 src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs create mode 100644 src/Host/Security/Options/ApiKeyCredentialExtractorOptions.cs create mode 100644 src/Host/Security/SecurityServiceCollectionExtensions.cs create mode 100644 src/Host/Security/Validation/ApiKeyPrincipal.cs create mode 100644 src/Host/Security/Validation/IApiKeyValidator.cs create mode 100644 src/Plugins/Abstractions/Contracts/Plugins/PluginMetadataAttribute.cs diff --git a/src/Host/Plugins/PluginContractValidator.cs b/src/Host/Plugins/PluginContractValidator.cs new file mode 100644 index 0000000..9e30149 --- /dev/null +++ b/src/Host/Plugins/PluginContractValidator.cs @@ -0,0 +1,188 @@ +using AuthKit.Plugins.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Host.Plugins; + +/// +/// Validates AuthKit plugin contracts against the host's supported capabilities. +/// +/// +/// Ensures plugins declare only supported security scheme types and API key locations. +/// Unknown or unsupported values cause explicit validation failures rather than silent fallbacks. +/// +public static class PluginContractValidator +{ + /// + /// Security scheme types the host implements and can therefore accept from plugins. + /// + /// + /// Mutual TLS, session, custom, and HTTP Basic authentication are not implemented by + /// this host. They are recognized explicitly and rejected rather than silently accepted + /// as if they were supported. Hosts that implement them extend the supported set through + /// . + /// + private static readonly HashSet _supportedSchemeTypes = + [ + AuthKitSecuritySchemeType.ApiKey, + AuthKitSecuritySchemeType.Http, + AuthKitSecuritySchemeType.OAuth2, + AuthKitSecuritySchemeType.OpenIdConnect + ]; + + /// + /// Credential locations the host's credential extraction strategies implement. + /// + /// + /// The host ships location extractor strategies for every value, including + /// and + /// . Note that gRPC metadata and body locations are + /// still rejected by the OpenAPI mapper (AuthKitOpenApiSecuritySchemeMapper) because + /// OpenAPI 3.0 cannot represent them — runtime support and OpenAPI representability are + /// validated independently. + /// + private static readonly HashSet _supportedApiKeyLocations = + [ + AuthKitApiKeyLocation.Header, + AuthKitApiKeyLocation.Query, + AuthKitApiKeyLocation.Cookie, + AuthKitApiKeyLocation.GrpcMetadata, + AuthKitApiKeyLocation.Body + ]; + + public static IReadOnlySet SupportedSchemeTypes => _supportedSchemeTypes; + public static IReadOnlySet SupportedApiKeyLocations => _supportedApiKeyLocations; + + /// + /// Validates a plugin's security scheme descriptors against host capabilities. + /// + /// The plugin to validate. + /// Logger for validation results. + /// + /// Thrown when the plugin declares unsupported or unknown scheme types/locations. + /// + public static void Validate(IAuthKitPlugin plugin, ILogger logger) + { + var schemes = plugin.GetSecuritySchemes(); + + foreach (var (name, descriptor) in schemes) + { + ValidateSchemeType(name, descriptor.Type, SupportedSchemeTypes, logger); + ValidateApiKeyLocation(name, descriptor.In, SupportedApiKeyLocations, logger); + WarnIfCustomWithoutUsageDocumentation(name, descriptor, SupportedSchemeTypes, logger); + } + } + + internal static void ValidateSchemeType( + string schemeName, + AuthKitSecuritySchemeType type, + IReadOnlySet supportedTypes, + ILogger logger) + { + if (supportedTypes.Contains(type)) + return; + + var msg = Enum.IsDefined(type) + ? $"Plugin declares security scheme type '{type}' for security scheme '{schemeName}', which is not implemented by this host. " + + $"MutualTls, Session, Custom, and Basic must be explicitly rejected. Supported types: {string.Join(", ", supportedTypes)}." + : $"Plugin declares unknown AuthKitSecuritySchemeType value '{(int)type}' for security scheme '{schemeName}'. " + + $"Unknown future values must be rejected rather than treated as a known type."; + + logger.LogError(msg); + throw new InvalidPluginContractException(msg); + } + + /// + /// Warns when a plugin declares a scheme without + /// usage documentation. + /// + /// + /// The warning is only emitted for hosts that actually support custom schemes (the default host + /// rejects them with an explicit error). It must never cause the custom scheme to be mapped to + /// another security scheme. + /// + internal static void WarnIfCustomWithoutUsageDocumentation( + string schemeName, + AuthKitSecuritySchemeDescriptor descriptor, + IReadOnlySet supportedTypes, + ILogger logger) + { + if (descriptor.Type != AuthKitSecuritySchemeType.Custom || !supportedTypes.Contains(descriptor.Type)) + return; + + if (string.IsNullOrWhiteSpace(descriptor.Description)) + { + logger.LogWarning( + "Security scheme '{Scheme}' declares AuthKitSecuritySchemeType.Custom but provides no description " + + "explaining how the custom authentication mechanism is intended to be used. Add a Description to the " + + "AuthKitSecuritySchemeDescriptor so clients and operators understand the mechanism. " + + "Custom schemes are never mapped to a built-in security scheme.", + schemeName); + } + } + + internal static void ValidateApiKeyLocation( + string schemeName, + AuthKitApiKeyLocation location, + IReadOnlySet supportedLocations, + ILogger logger) + { + if (supportedLocations.Contains(location)) + return; + + var msg = Enum.IsDefined(location) + ? $"Plugin declares API key location '{location}' for security scheme '{schemeName}', which is not supported by this host's " + + $"credential extraction strategies. Configure a host with {location} support or change the plugin configuration." + : $"Plugin declares unknown AuthKitApiKeyLocation value '{(int)location}' for security scheme '{schemeName}'. " + + $"Unknown future values must be rejected rather than treated as a known location."; + + logger.LogError(msg); + throw new InvalidPluginContractException(msg); + } + + /// + /// Creates a validator with custom supported locations (for hosts with extended capabilities). + /// + public static PluginContractValidatorCustom CreateCustom( + IEnumerable? supportedSchemeTypes = null, + IEnumerable? supportedApiKeyLocations = null) + { + return new PluginContractValidatorCustom(supportedSchemeTypes, supportedApiKeyLocations); + } +} + +/// +/// Customizable validator for hosts with extended capabilities (e.g., gRPC metadata, Body support). +/// +public sealed class PluginContractValidatorCustom +{ + private readonly HashSet _supportedSchemeTypes; + private readonly HashSet _supportedApiKeyLocations; + + public PluginContractValidatorCustom( + IEnumerable? supportedSchemeTypes = null, + IEnumerable? supportedApiKeyLocations = null) + { + _supportedSchemeTypes = new HashSet(supportedSchemeTypes ?? PluginContractValidator.SupportedSchemeTypes); + _supportedApiKeyLocations = new HashSet(supportedApiKeyLocations ?? PluginContractValidator.SupportedApiKeyLocations); + } + + public void Validate(IAuthKitPlugin plugin, ILogger logger) + { + var schemes = plugin.GetSecuritySchemes(); + + foreach (var (name, descriptor) in schemes) + { + PluginContractValidator.ValidateSchemeType(name, descriptor.Type, _supportedSchemeTypes, logger); + PluginContractValidator.ValidateApiKeyLocation(name, descriptor.In, _supportedApiKeyLocations, logger); + PluginContractValidator.WarnIfCustomWithoutUsageDocumentation(name, descriptor, _supportedSchemeTypes, logger); + } + } +} + +/// +/// Exception thrown when a plugin contract violates host capabilities. +/// +public sealed class InvalidPluginContractException : Exception +{ + public InvalidPluginContractException(string message) : base(message) { } +} \ No newline at end of file diff --git a/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs new file mode 100644 index 0000000..31055c1 --- /dev/null +++ b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs @@ -0,0 +1,76 @@ +using System.Security.Claims; +using AuthKit.Plugins.Abstractions; +using Host.Security.LocationExtractors; +using Host.Security.Validation; + +namespace Host.Security.Middleware; + +/// +/// Pipeline middleware that authenticates requests using an API key described by the +/// request's . +/// +/// +/// +/// The middleware resolves the correct credential location strategy from +/// , extracts the API key, and validates +/// it through . On success the principal's claims are +/// attached to as an authenticated identity. +/// +/// +/// When no scheme is declared for the request, or when extraction or validation does +/// not produce a principal, the pipeline continues to the next middleware so downstream +/// authentication can decide how to handle the request. +/// +/// +public sealed class ApiKeyCredentialExtractor( + RequestDelegate next, + IApiKeyLocationExtractorRegistry registry, + ILogger logger) +{ + private const string AuthenticationType = "ApiKey"; + + /// + /// Extracts and validates the API key declared for the current request. + /// + /// The current . + /// The validator used to authenticate the extracted API key. + public async Task InvokeAsync(HttpContext context, IApiKeyValidator validator) + { + var scheme = context.RequestServices.GetService(); + if (scheme is null) + { + await next(context); + return; + } + + string? apiKey = null; + + try + { + apiKey = await registry.Resolve(scheme.In).ExtractAsync(context, scheme); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to extract API key from {Location} for scheme {Scheme}", scheme.In, scheme.Name); + } + + if (!string.IsNullOrEmpty(apiKey)) + { + try + { + var principal = await validator.ValidateAsync(apiKey); + if (principal is not null) + { + context.User.AddIdentity(new ClaimsIdentity(principal.Claims, AuthenticationType)); + logger.LogDebug("API key validated for {Subject} via scheme {Scheme}", principal.Subject, scheme.Name); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to validate API key extracted from {Location} for scheme {Scheme}", scheme.In, scheme.Name); + } + } + + await next(context); + } +} \ No newline at end of file diff --git a/src/Host/Security/Options/ApiKeyCredentialExtractorOptions.cs b/src/Host/Security/Options/ApiKeyCredentialExtractorOptions.cs new file mode 100644 index 0000000..117f9cc --- /dev/null +++ b/src/Host/Security/Options/ApiKeyCredentialExtractorOptions.cs @@ -0,0 +1,45 @@ +using AuthKit.Plugins.Abstractions; + +namespace Host.Security.Options; + +/// +/// Options for configuring API key credential extraction. +/// +/// +/// +/// Defaults names are used by the location extractors whenever the declaring +/// scheme does not specify an explicit credential name. +/// +/// +/// bounds how much of the request body is +/// buffered in memory before it spills to a temporary file. +/// +/// +public sealed class ApiKeyCredentialExtractorOptions +{ + /// + /// Gets or sets the memory threshold, in bytes, used when buffering the + /// request body for extraction. + /// Bodies larger than this value spill to a temporary file (default: + /// 1 MB). + /// + public long BufferThreshold { get; set; } = 1_048_576; // 1 MB + + /// + /// Gets or sets the header name to use when the scheme does not specify + /// one (default: X-Api-Key). + /// + public string DefaultHeaderName { get; set; } = "X-Api-Key"; + + /// + /// Gets or sets the query parameter name to use when the scheme does not + /// specify one (default: api_key). + /// + public string DefaultQueryName { get; set; } = "api_key"; + + /// + /// Gets or sets the cookie name to use when the scheme does not specify + /// one (default: api_key). + /// + public string DefaultCookieName { get; set; } = "api_key"; +} \ No newline at end of file diff --git a/src/Host/Security/SecurityServiceCollectionExtensions.cs b/src/Host/Security/SecurityServiceCollectionExtensions.cs new file mode 100644 index 0000000..2c5fa12 --- /dev/null +++ b/src/Host/Security/SecurityServiceCollectionExtensions.cs @@ -0,0 +1,52 @@ +using AuthKit.Plugins.Abstractions; +using Host.Security.BodyParsers; +using Host.Security.LocationExtractors; +using Host.Security.Options; +using Microsoft.Extensions.DependencyInjection; + +namespace Host.Security; + +/// +/// Provides dependency injection configuration for API key credential extraction. +/// +/// +/// +/// Centralizes the registration of credential location strategies and request +/// body parsers used to authenticate API key schemes. +/// +/// +/// New locations or body formats are supported by registering additional +/// or +/// implementations — no existing code needs to change. +/// +/// +public static class SecurityServiceCollectionExtensions +{ + /// + /// Registers the API key credential extraction strategies and their options. + /// + /// The service collection to configure. + /// + /// An optional callback used to configure . + /// + /// The configured service collection. + public static IServiceCollection AddApiKeyCredentialExtraction( + this IServiceCollection services, + Action? configure = null) + { + services.Configure(configure ?? (_ => { })); + + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/src/Host/Security/Validation/ApiKeyPrincipal.cs b/src/Host/Security/Validation/ApiKeyPrincipal.cs new file mode 100644 index 0000000..7adaccd --- /dev/null +++ b/src/Host/Security/Validation/ApiKeyPrincipal.cs @@ -0,0 +1,26 @@ +using System.Security.Claims; + +namespace Host.Security.Validation; + +/// +/// Represents the principal produced after successful API key validation. +/// +/// +/// +/// Contains the stable subject identifier and claims associated with the +/// authenticated API key. +/// +/// +public sealed class ApiKeyPrincipal +{ + /// + /// Gets or sets the stable identifier of the subject that owns the + /// validated API key. + /// + public string Subject { get; set; } = string.Empty; + + /// + /// Gets or sets the claims associated with the authenticated identity. + /// + public IEnumerable Claims { get; set; } = []; +} \ No newline at end of file diff --git a/src/Host/Security/Validation/IApiKeyValidator.cs b/src/Host/Security/Validation/IApiKeyValidator.cs new file mode 100644 index 0000000..45075ac --- /dev/null +++ b/src/Host/Security/Validation/IApiKeyValidator.cs @@ -0,0 +1,19 @@ +namespace Host.Security.Validation; + +/// +/// Validates extracted API key credentials and produces authenticated principals. +/// +/// +/// Implementations determine how API keys are authenticated and may validate +/// them against a plugin database or another credential store. +/// +public interface IApiKeyValidator +{ + /// + /// Validates an API key and produces the authenticated principal. + /// + /// The raw API key credential. + /// An when the key is valid; otherwise, null + /// + Task ValidateAsync(string apiKey); +} \ No newline at end of file diff --git a/src/Plugins/Abstractions/Contracts/Plugins/PluginMetadataAttribute.cs b/src/Plugins/Abstractions/Contracts/Plugins/PluginMetadataAttribute.cs new file mode 100644 index 0000000..e4286be --- /dev/null +++ b/src/Plugins/Abstractions/Contracts/Plugins/PluginMetadataAttribute.cs @@ -0,0 +1,115 @@ +namespace AuthKit.Plugins.Abstractions.Contracts.Plugins; + +/// +/// Declares the identity, versioning, and cataloging metadata for an AuthKit plugin. +/// +/// +/// +/// The attribute is consumed by the IAuthKitPlugin contract to supply the +/// plugin's Name, Version, and Description surface, so plugin +/// classes only declare their metadata in one place. +/// +/// +/// is the stable machine identity of the plugin, +/// is the plugin name, is an optional UI label, and +/// describes the functionality provided by the plugin. +/// +/// +/// categorize the plugin, list the ids of +/// plugins this plugin requires to operate, and describe +/// the functional capabilities contributed to the host. +/// +/// +/// Publishing metadata (, , +/// , , ) +/// documents the plugin's origin and distribution surface. +/// +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class PluginMetadataAttribute : Attribute +{ + /// + /// Creates plugin metadata. + /// + /// The stable machine identity of the plugin. + /// The version of the plugin. + /// The name of the plugin. + /// Categories used to describe the plugin. + /// Ids of plugins this plugin depends on. + /// Functional capabilities contributed to the host. + /// Optional human-readable UI label. + /// Optional description of the plugin's functionality. + /// Optional plugin author or maintainer. + /// Optional license identifier. + /// Optional URL pointing to the license text. + /// Optional URL of the plugin's homepage. + /// Optional URL of the plugin's source repository. + public PluginMetadataAttribute( + string id, + string version, + string name, + string[]? tags = null, + string[]? dependsOn = null, + string[]? capabilities = null, + string? displayName = null, + string? description = null, + string? author = null, + string? license = null, + string? licenseUrl = null, + string? homepage = null, + string? repositoryUrl = null) + { + Id = id; + Version = version; + Name = name; + Tags = tags ?? []; + DependsOn = dependsOn ?? []; + Capabilities = capabilities ?? []; + DisplayName = displayName; + Description = description; + Author = author; + License = license; + LicenseUrl = licenseUrl; + Homepage = homepage; + RepositoryUrl = repositoryUrl; + } + + /// Gets the stable machine identity of the plugin. + public string Id { get; } + + /// Gets the version of the plugin. + public string Version { get; } + + /// Gets the name of the plugin. + public string Name { get; } + + /// Gets the functional capabilities contributed to the host. + public IReadOnlyList Tags { get; } + + /// Gets the ids of plugins this plugin depends on. + public IReadOnlyList DependsOn { get; } + + /// Gets the functional capabilities contributed to the host. + public IReadOnlyList Capabilities { get; } + + /// Gets the optional human-readable UI label. + public string? DisplayName { get; } + + /// Gets the optional description of the plugin's functionality. + public string? Description { get; } + + /// Gets the optional plugin author or maintainer. + public string? Author { get; } + + /// Gets the optional license identifier. + public string? License { get; } + + /// Gets the optional URL pointing to the license text. + public string? LicenseUrl { get; } + + /// Gets the optional URL of the plugin's homepage. + public string? Homepage { get; } + + /// Gets the optional URL of the plugin's source repository. + public string? RepositoryUrl { get; } +} \ No newline at end of file From 8325c98350ac0bba59d8aa79c8ea2bc37a863672 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 13:05:49 +0200 Subject: [PATCH 11/20] feat(security): add security scheme ADRs and OpenAPI mapper --- ...016-marten-and-wolverine-infrastructure.md | 2 +- ...pi-key-credential-extraction-strategies.md | 59 +++++++ ...urity-scheme-contract-explicit-handling.md | 59 +++++++ Docs/ADR/019-plugin-metadata-attribute.md | 53 ++++++ .../AuthKitOpenApiSecuritySchemeMapper.cs | 166 ++++++++++++++++++ 5 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 Docs/ADR/017-api-key-credential-extraction-strategies.md create mode 100644 Docs/ADR/018-security-scheme-contract-explicit-handling.md create mode 100644 Docs/ADR/019-plugin-metadata-attribute.md create mode 100644 src/Host/Configuration/AuthKitOpenApiSecuritySchemeMapper.cs diff --git a/Docs/ADR/016-marten-and-wolverine-infrastructure.md b/Docs/ADR/016-marten-and-wolverine-infrastructure.md index 06aa136..58f524a 100644 --- a/Docs/ADR/016-marten-and-wolverine-infrastructure.md +++ b/Docs/ADR/016-marten-and-wolverine-infrastructure.md @@ -48,4 +48,4 @@ Plugins and Core share one persistence and messaging model, which keeps handlers - [ADR-011](./011-keystore-persisted-as-singleton-marten-document.md) - keystore stored in Marten - [ADR-012](./012-token-key-bindings-persisted-in-marten.md) - bindings stored in Marten -[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./015-keycloak-external-jwt-authority.md) | [Next]() +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./015-keycloak-external-jwt-authority.md) | [Next](./017-api-key-credential-extraction-strategies.md) diff --git a/Docs/ADR/017-api-key-credential-extraction-strategies.md b/Docs/ADR/017-api-key-credential-extraction-strategies.md new file mode 100644 index 0000000..e8a0b66 --- /dev/null +++ b/Docs/ADR/017-api-key-credential-extraction-strategies.md @@ -0,0 +1,59 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./016-marten-and-wolverine-infrastructure.md) | [Next](./018-security-scheme-contract-explicit-handling.md) + +# [ADR-017] Extract API Key Credentials Through Location And Body Format Strategies + +*2026-09* | Status: accepted + +**Tag:** #adr_017 + +**Date:** 2026-09-10 + +**Scope:** Host.Security + +## Context + +Plugins expose authentication mechanisms as transport-agnostic `AuthKitSecuritySchemeDescriptor` metadata (ADR-009). For API key schemes the descriptor declares where the credential lives through `AuthKitApiKeyLocation` (header, query, cookie, gRPC metadata, or request body). The host must turn that metadata into retrieval of an actual credential from an incoming asp.net request, validate it, and establish an authenticated identity without coupling scheme authors to http mechanics. + +## Problem + +A single extractor/middleware that internally handles all five locations plus body buffering, JSON parsing, form parsing, and value normalization becomes an orchestrator and implementation god object. Adding a new location or a new body format requires modifying existing code, and the low-level transport mechanics (Buffering, JSON, form decoding) are entangled with the orchestration flow (extract -> validate -> identity). + +## Decision + +API key credential retrieval is split into small, single responsibility strategies registered in the host DI container under `Host.Security`: + +- **Location strategies** implement `IApiKeyLocationExtractor`, one per `AuthKitApiKeyLocation` (`Header`, `Query`, `Cookie`, `GrpcMetadata`, `Body`). Each strategy knows only how to read its own location and normalize the value. +- **`IApiKeyLocationExtractorRegistry`** (DI backed) maps `AuthKitApiKeyLocation` -> strategy. The registry throws `NotSupportedException` for locations without registered strategy, so unsupported configurations fail fast. +- **Body format parsers** implement `IApiKeyBodyParser` and are selected by request content type. Today `JsonApiKeyBodyParser` and `FormUrlEncodedApiKeyBodyParser` are registered; a new format is a new class plus registration. +- **`ApiKeyCredentialExtractor`** is a thin middleware that only orchestrates: resolve the scheme, extract via the registry, validate via `IApiKeyValidator`, and on success attach the principal's claims as an authenticated identity. It contains no parsing or buffering logic. +- **`IApiKeyValidator`** stays the plugin facing contract for validating an extracted key, keeping credential semantics out of the host. +- **`ApiKeyCredentialExtractorOptions`** centralizes defaults (header/query/cookie field names, body buffer threshold) and is consumed through `IOptions`. +- All of it is registered by `AddApiKeyCredentialExtraction(...)`. + +Body extraction buffers the request through `EnableBuffering`, reads the payload, and restores the stream position so downstream middleware and controllers still see the body intact. + +### Design Rationale + +- **Open/Closed**: a new location or body format adds class and a DI registration without editing any existing strategy, parser, or the middleware. +- **Single Responsibility**: the middleware owns orchestration; each strategy/parser owns exactly one mechanical concern. +- **Dependency Inversion**: the middleware depends on the registry and validator abstractions, not on `HttpContext` parsing or JSON. +- **Fail open orchestration**: when no credential or no principal is produced, the pipeline continues so downstream authentication decides the outcome extraction never aborts an unrelated request. + +## Rejected + +- A single switch/if extractor with inline body and JSON logic every new location or format modifies the orchestrator. +- Basing parsing decisions on the scheme inside the middleware couples orchestration to format mechanics. +- Requiring each plugin to implement its own extraction duplicates `HttpContext` parsing, buffering, and normalization across solutions. +- Silent conversion of unsupported locations (eg. treating `GrpcMetadata` as plain HTTP headers without registered strategy) hides misconfiguration. + +## Consequences + +`Host.Security` now contains many small types instead of one large one adding location or format costs files and registration, but never an edit to existing behavior. The registry makes genuinely unsupported locations fail fast at resolution. Body parsing is content type selective, so ambiguous payloads yield no credential (fail open) rather than an error validation stays plugin concern behind `IApiKeyValidator`, and the host only attaches claims after successful validation. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - descriptors and pluggable authentication contributed by plugins +- [ADR-010](./010-plugin-loading-from-directory.md) - plugin middleware slot where extraction runs +- [ADR-013](./013-dual-rest-and-grpc-transport.md) - gRPC metadata transport represented as lowercase HTTP headers + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./016-marten-and-wolverine-infrastructure.md) | [Next](./018-security-scheme-contract-explicit-handling.md) \ No newline at end of file diff --git a/Docs/ADR/018-security-scheme-contract-explicit-handling.md b/Docs/ADR/018-security-scheme-contract-explicit-handling.md new file mode 100644 index 0000000..590014a --- /dev/null +++ b/Docs/ADR/018-security-scheme-contract-explicit-handling.md @@ -0,0 +1,59 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./017-api-key-credential-extraction-strategies.md) | [Next](./019-plugin-metadata-attribute.md) + +# [ADR-018] Extend The Security Scheme Contract With Explicit Host And OpenAPI Handling + +*2026-09* | Status: accepted + +**Tag:** #adr_018 + +**Date:** 2026-09-10 + +**Scope:** AuthKit.Plugins.Abstractions + +## Context + +Plugins expose authentication mechanisms as transport agnostic `AuthKitSecuritySchemeDescriptor` metadata (ADR-009). The original contract enum `AuthKitSecuritySchemeType` covered only `ApiKey`, `Http`, `OAuth2`, and `OpenIdConnect` where `OpenIdConnect` was an alias of `OAuth2` sharing numeric value `2`. `AuthKitApiKeyLocation` covered `Header`, `Query`, and `Cookie`. Any other mechanism (mutual TLS, sessions, plugin defined schemes, HTTP Basic) or transport (gRPC metadata, request body) had to be approximated with strings or overloaded existing values. + +## Problem + +Approximation destroys type safety and lets the host confuse distinct mechanisms: session authentication with an API key carried in a cookie, HTTP Basic with generic HTTP authentication, gRPC metadata with HTTP headers, and body credential with one in a header or query parameter. In addition, the bundled OpenAPI serializer (Swashbuckle 9.x / Microsoft.OpenApi 1.6.x) cannot represent mutual TLS, sessions, custom schemes, gRPC metadata, or body locations in its OpenAPI 3.0 output. Substituting a generic scheme silently would not only hide misconfiguration, it would produce misleading documentation for clients and generated SDKs. + +## Decision + +Both enums grow additively, and every value is handled explicitly by the host and by the OpenAPI mapper: + +- `AuthKitSecuritySchemeType` gains `MutualTls = 3`, `Session = 4`, `Custom = 5`, `Basic = 6`. Existing values stay untouched (`ApiKey = 0`, `Http = 1`, `OAuth2 = 2`). The historical alias is removed: `OpenIdConnect` becomes a distinct value `7` with the deviation documented in its remarks renumbering existing values is forbidden because numeric values are part of the plugin contract (`ApiKey`..`Basic` already occupy `0`..`6`). +- `AuthKitApiKeyLocation` gains `GrpcMetadata = 3` and `Body = 4` existing values stay untouched. +- Numeric values are declared part of the plugin contract in both enums: they must never be reused or renumbered. +- The host enumerates its capabilities as `PluginContractValidator.SupportedSchemeTypes` and `SupportedApiKeyLocations`. `PluginContractValidator.Validate` checks every declared scheme type and location against these sets: supported values pass defined but unsupported and unknown (future) values raise `InvalidPluginContractException`. Unknown values are identified by their numeric identity and never resolved to `Custom` or any known value. +- `AuthKitOpenApiSecuritySchemeMapper` maps every value explicitly: semantically correct OpenAPI representations for `ApiKey`, `Http`, `OAuth2`, `OpenIdConnect`, and `Basic` (as HTTP authentication with scheme `basic`); `NotSupportedException` for values with no correct 3.0 representation (`MutualTls`, `Session`, `Custom`; `GrpcMetadata` and `Body` as API key locations); `ArgumentOutOfRangeException` for unknown values. +- Runtime support and OpenAPI representability are orthogonal: the host supports `GrpcMetadata` and `Body` credential extraction at runtime (ADR-017) even though OpenAPI 3.0 cannot describe them. `RestfulConfiguration` catches mapper failures and logs warning while skipping the definition it never emits a generic substitute and never crashes document generation. +- `Custom` requires a plugin-supplied `Description`; a missing description produces a validation warning (never a mapping to built-in scheme). +- The host only accepts `OpenApi:SpecVersion` `3.0`. `3.1` which would be needed for a real `mutualTLS` representation is rejected with `InvalidOperationException` instead of silently emitting 3.0 document. + +### Design Rationale + +- **Additive contract**: old plugins (including `DevTokens`) compile and load unmodified, and persisted descriptors keep their meaning. +- **Explicit over clever**: every contract value has a named case in exactly two places (host capability check and OpenAPI mapper), forcing authors to decide support or rejection for each new value. +- **Fail fast**: an unsupported scheme fails plugin validation at startup non representable one is omitted from Swagger with a logged reason. +- **No fake OpenAPI**: emitting `apiKey` for mutual TLS or `Header` for gRPC metadata would generate plausible looking but wrong client contracts. + +## Rejected + +- Renumbering existing enum values to match cleaner sequence breaks every shipped plugin and persisted descriptor forbidden by the must not change rule. +- Keeping the `OpenIdConnect` = `OAuth2` alias two distinct mechanisms cannot share numeric identity a plugin declaring an OIDC flow must not deserialize as OAuth2. +- Generic fallback in the mapper (unknown -> `Custom`, `MutualTls` -> HTTP, `GrpcMetadata` -> `Header`, `Body` -> `Header`/`Query`/`Cookie`) hides misconfiguration and emits misleading documentation. +- Automatically upgrading generated documents to OpenAPI 3.1 to gain `mutualTLS` the bundled serializer stack has no 3.1 support the host rejects the setting rather than silently downgrading. +- Treating `Session` as API-key-in-cookie and `Basic` as generic HTTP authentication reproduces the ambiguity this ADR removes. + +## Consequences + +Adding new authentication mechanism or transport is now additive: define the value, extend the host capability set, add mapper case, and cover it with positive and positive/negative no fallback tests. The OpenIdConnect value sits outside the natural `0`..`6` sequence (documented in the enum remarks). Plugins that declare unsupported mechanisms fail contract validation at startup per host extensions use `PluginContractValidator.CreateCustom`. Swagger output for plugin mixing representable and non representable schemes contains only the representable definitions, with logged warning per skipped scheme. Two explicit contract guarantees now exist: no silent fallback between schemes or locations, and unknown future values are always rejected. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - plugin security scheme descriptors +- [ADR-017](./017-api-key-credential-extraction-strategies.md) - runtime extraction for header/query/cookie/gRPC metadata/body +- [ADR-013](./013-dual-rest-and-grpc-transport.md) - gRPC transport surface + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./017-api-key-credential-extraction-strategies.md) | [Next](./019-plugin-metadata-attribute.md) \ No newline at end of file diff --git a/Docs/ADR/019-plugin-metadata-attribute.md b/Docs/ADR/019-plugin-metadata-attribute.md new file mode 100644 index 0000000..53d54f9 --- /dev/null +++ b/Docs/ADR/019-plugin-metadata-attribute.md @@ -0,0 +1,53 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./018-security-scheme-contract-explicit-handling.md) | [Next](./020-devtools-plugin.md) + +# [ADR-019] Declare Plugin Identity Through The PluginMetadata Attribute + +*2026-09* | Status: accepted + +**Tag:** #adr_019 + +**Date:** 2026-09-11 + +**Scope:** AuthKit.Plugins.Abstractions + +## Context + +Every `IAuthKitPlugin` implementation (ADR-009) exposed its identity by overriding `Name`, `Version`, and `Description` properties in code. The DevTokens and DevTools plugins duplicated that boilerplate, and richer cataloging information stable machine id, tags, capabilities, dependencies, author, license, repository URL had no contract surface at all. + +## Problem + +Repeating identity as c# code properties spreads the plugin's identity across the class body, makes version bumps code edit, and leaves no single structural place where future tool (loader diagnostics, admin UI) can read identity and cataloging metadata. Adding cataloging metadata would require new members on the contract interface, forcing every existing plugin to implement them. + +## Decision + +Plugin identity and cataloging metadata move to `[PluginMetadata]` attribute on the plugin class: + +- `PluginMetadataAttribute` (namespace `AuthKit.Plugins.Abstractions.Contracts.Plugins`) declares `Id`, `Version`, `Name`, `Tags`, `DependsOn`, `Capabilities`, `DisplayName`, `Description`, `Author`, `License`, `LicenseUrl`, `Homepage`, and `RepositoryUrl`. The constructor is `(id, version, name, ...)` with all fields after `name` optional. +- `IAuthKitPlugin.Name`, `Version`, and `Description` are now **default interface members** that read the attribute through a private helper `GetPluginMetadata()`. Plugins no longer need to implement them: `Name` falls back to the type name and `Version` to `0.0.0` when the class carries no attribute. +- Plugins may still override the three properties if they need computed identity, but the attribute is the preferred declaration point. The two shipped plugins (`DevTokens`, `DevTools`) declare their identity exclusively via `[PluginMetadata]`. +- `AttributeUsage` is `Class`, `Inherited = false`, `AllowMultiple = false` one attribute per plugin class. +- Array metadata (`Tags`, `DependsOn`, `Capabilities`) is exposed as `IReadOnlyList` and defaults to an empty list when absent, so consumers never see `null`. + +### Design Rationale + +- **Single declaration point**: identity and cataloging live in one attribute, next to the class it describes. +- **Backward compatible**: default interface members keep old plugin classes compilable and loadable no host, plugin, or `PluginLoader` change was required for discovery or startup output. +- **Additive cataloging**: tags/capabilities/dependencies/authoring metadata gains contract surface without touching the interface's member list (no breaking change to existing implementations). +- **Declarative over imperative**: metadata as an attribute is readable, discoverable via reflection, and usable by tools that never instantiate the plugin class. + +## Rejected + +- Adding `Tags`, `Capabilities`, `Dependencies`, etc. as new abstract members on `IAuthKitPlugin` breaks every existing plugin implementation (compile time contract break). +- Keeping identity purely as code properties side by side with separate cataloging attribute splits identity across two mechanisms. +- A metadata file (JSON/embedded resource) sidecar loses compile time checking and reflection discoverability for little gain. + +## Consequences + +New plugins declare one attribute and get correct `Name`/`Version`/`Description` for free; the host's plugin loader consumes the attribute through the default interface members, so `ServerHost` startup output ("Loaded plugin 'DevTools' v1.0.0") reflects attribute values. Cataloging fields are available to future administrative surfaces without further contract changes. Because default interface members can be overridden, a plugin that needs computed identity keeps that freedom but the two shipped plugins no longer use it. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - the `IAuthKitPlugin` contract extended by this ADR +- [ADR-010](./010-plugin-loading-from-directory.md) - the loader that reads plugin identity at startup + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./018-security-scheme-contract-explicit-handling.md) | [Next](./020-devtools-plugin.md) \ No newline at end of file diff --git a/src/Host/Configuration/AuthKitOpenApiSecuritySchemeMapper.cs b/src/Host/Configuration/AuthKitOpenApiSecuritySchemeMapper.cs new file mode 100644 index 0000000..93e0eeb --- /dev/null +++ b/src/Host/Configuration/AuthKitOpenApiSecuritySchemeMapper.cs @@ -0,0 +1,166 @@ +using AuthKit.Plugins.Abstractions; +using Microsoft.OpenApi; + +namespace Host.Configuration; + +/// +/// Maps AuthKit security scheme descriptors to OpenAPI security scheme definitions. +/// +/// +/// +/// Every value is handled explicitly. Values that have a +/// semantically correct OpenAPI representation are mapped to the OpenAPI security scheme type of the +/// document's spec version; values that cannot be represented by the configured OpenAPI version — or +/// whose semantics require host functionality that the current host does not implement — are rejected +/// with a descriptive exception. No value is ever silently mapped to a generic or unrelated security +/// scheme. +/// +/// +/// The current host serializes OpenAPI documents (Swashbuckle 10.2.x / Microsoft.OpenApi 2.7.x). +/// That stack exposes both and OpenAPI 3.1 serialization +/// (SwaggerOptions.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1), so +/// is represented natively — but only in +/// documents pinned to OpenAPI 3.1. Under OpenAPI 3.0 (the host default), mutualTLS has no +/// representation and mutual-TLS schemes are still explicitly rejected rather than rewritten as an +/// HTTP, bearer, or API key scheme. +/// +/// +/// API key locations and +/// are valid credential transports supported by this host at +/// runtime, but no OpenAPI version (3.0 or 3.1) can describe gRPC metadata or body locations. They +/// must never be silently rewritten to header, query, or cookie locations. +/// +/// +/// and +/// and any unknown (future) value are similarly rejected. +/// No generic or unrelated scheme is ever produced as a fallback. +/// +/// +public static class AuthKitOpenApiSecuritySchemeMapper +{ + /// + /// Maps the supplied descriptor to an . + /// + /// The AuthKit security scheme descriptor to map. + /// The OpenAPI spec version of the document being generated. + /// The equivalent OpenAPI security scheme definition. + /// is null. + /// + /// The descriptor declares a scheme type or API key location that has no semantically correct + /// OpenAPI representation in the configured spec version. + /// + /// + /// The descriptor declares an unknown (future) or + /// value. + /// + public static OpenApiSecurityScheme Map( + AuthKitSecuritySchemeDescriptor descriptor, + OpenApiSpecVersion specVersion) + { + ArgumentNullException.ThrowIfNull(descriptor); + + var scheme = new OpenApiSecurityScheme + { + Name = descriptor.Name, + BearerFormat = descriptor.BearerFormat, + Description = descriptor.Description + }; + + switch (descriptor.Type) + { + case AuthKitSecuritySchemeType.ApiKey: + scheme.Type = SecuritySchemeType.ApiKey; + scheme.In = MapApiKeyLocation(descriptor, specVersion); + break; + + case AuthKitSecuritySchemeType.Http: + scheme.Type = SecuritySchemeType.Http; + scheme.Scheme = descriptor.Scheme; + break; + + case AuthKitSecuritySchemeType.OAuth2: + scheme.Type = SecuritySchemeType.OAuth2; + break; + + case AuthKitSecuritySchemeType.OpenIdConnect: + scheme.Type = SecuritySchemeType.OpenIdConnect; + break; + + case AuthKitSecuritySchemeType.Basic: + scheme.Type = SecuritySchemeType.Http; + scheme.Scheme = "basic"; + break; + + case AuthKitSecuritySchemeType.MutualTls: + if (specVersion == OpenApiSpecVersion.OpenApi3_1) + { + scheme.Type = SecuritySchemeType.MutualTLS; + } + else + { + throw new NotSupportedException( + $"Security scheme '{descriptor.Name}' uses AuthKitSecuritySchemeType.MutualTls, " + + $"which cannot be represented in the configured OpenAPI {SpecVersionToLabel(specVersion)} " + + $"document. The current host (Swashbuckle 10.2.x / Microsoft.OpenApi 2.7.x) can serialize " + + $"mutual TLS natively only when the document is pinned to OpenAPI 3.1 " + + $"(OpenApi:SpecVersion = 3.1). Under {SpecVersionToLabel(specVersion)} it must be rejected " + + $"rather than rewritten as an HTTP, bearer, or API key scheme."); + } + break; + + case AuthKitSecuritySchemeType.Session: + throw new NotSupportedException( + $"Security scheme '{descriptor.Name}' uses AuthKitSecuritySchemeType.Session, which has no " + + $"semantically correct OpenAPI security scheme representation in any OpenAPI version. " + + $"Session authentication must not be described as an API key, HTTP, or bearer scheme."); + + case AuthKitSecuritySchemeType.Custom: + throw new NotSupportedException( + $"Security scheme '{descriptor.Name}' uses AuthKitSecuritySchemeType.Custom, which has no " + + $"built-in OpenAPI security scheme representation. Hosts must register an explicit " + + $"mapping for custom schemes; no default generic mapping is applied."); + + default: + throw new ArgumentOutOfRangeException( + nameof(descriptor), + $"Unknown AuthKitSecuritySchemeType value '{descriptor.Type}' for security scheme " + + $"'{descriptor.Name}' must be rejected rather than mapped to a generic scheme."); + } + + return scheme; + } + + private static ParameterLocation MapApiKeyLocation( + AuthKitSecuritySchemeDescriptor descriptor, + OpenApiSpecVersion specVersion) => + descriptor.In switch + { + AuthKitApiKeyLocation.Header => ParameterLocation.Header, + AuthKitApiKeyLocation.Query => ParameterLocation.Query, + AuthKitApiKeyLocation.Cookie => ParameterLocation.Cookie, + + AuthKitApiKeyLocation.GrpcMetadata => throw new NotSupportedException( + $"Security scheme '{descriptor.Name}' uses AuthKitApiKeyLocation.GrpcMetadata, which has no " + + $"OpenAPI representation in version {SpecVersionToLabel(specVersion)}. " + + $"It must not be silently mapped to a header location."), + + AuthKitApiKeyLocation.Body => throw new NotSupportedException( + $"Security scheme '{descriptor.Name}' uses AuthKitApiKeyLocation.Body, which has no " + + $"OpenAPI representation in any OpenAPI version. It must not be silently mapped to a header, " + + $"query, or cookie location."), + + _ => throw new ArgumentOutOfRangeException( + nameof(descriptor), + $"Unknown AuthKitApiKeyLocation value '{descriptor.In}' for security scheme " + + $"'{descriptor.Name}' must be rejected rather than mapped to another location.") + }; + + private static string SpecVersionToLabel(OpenApiSpecVersion specVersion) => + specVersion switch + { + OpenApiSpecVersion.OpenApi2_0 => "2.0 (Swagger)", + OpenApiSpecVersion.OpenApi3_0 => "3.0", + OpenApiSpecVersion.OpenApi3_1 => "3.1", + _ => specVersion.ToString() + }; +} From 65f469d5071b64011e3babfe18fdcff3c30c8c00 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 17:31:41 +0200 Subject: [PATCH 12/20] feat(security): separate scheme identity from credential field name Address PR review: Name must stay a transport-agnostic scheme identity and must not double as the credential transport field. Add CredentialName to AuthKitSecuritySchemeDescriptor; the OpenAPI mapper and every location extractor fall back to location-specific defaults (X-Api-Key, api_key). Also addressed by the same review: - MaxBodySize is a hard read limit for Body extraction, independent of the BufferThreshold spill threshold. - StripBearerPrefix is opt-in instead of unconditional. - Custom authentication wording in ADR-018 is a warning, not a mapping rule. - Bump Swashbuckle to 10.2.3 / Microsoft.OpenApi 2.7.x so the mapper can use OpenApi3_1 and SecuritySchemeType.MutualTLS; fix namespaces moved by the development merge. --- Directory.Packages.props | 8 +- ...urity-scheme-contract-explicit-handling.md | 2 +- .../AuthKitOpenApiSecuritySchemeMapper.cs | 19 +++- src/Host/Plugins/PluginContractValidator.cs | 2 + src/Host/Plugins/PluginLoader.cs | 35 +++++++- .../ApiKeyLocationExtractorBase.cs | 9 +- .../BodyApiKeyLocationExtractor.cs | 87 ++++++++++++++++--- .../CookieApiKeyLocationExtractor.cs | 8 +- .../GrpcMetadataApiKeyLocationExtractor.cs | 11 +-- .../HeaderApiKeyLocationExtractor.cs | 20 +++-- .../IApiKeyLocationExtractor.cs | 1 + .../QueryApiKeyLocationExtractor.cs | 8 +- .../Middleware/ApiKeyCredentialExtractor.cs | 1 + .../ApiKeyCredentialExtractorOptions.cs | 54 ++++++++++-- .../AuthKitSecuritySchemeDescriptor.cs | 21 +++++ ...AuthKitOpenApiSecuritySchemeMapperTests.cs | 33 +++++-- tests/Host/PluginContractValidatorTests.cs | 3 + 17 files changed, 268 insertions(+), 54 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a9b8788..093b472 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,10 +27,10 @@ - - - - + + + + diff --git a/Docs/ADR/018-security-scheme-contract-explicit-handling.md b/Docs/ADR/018-security-scheme-contract-explicit-handling.md index 590014a..1f7a78d 100644 --- a/Docs/ADR/018-security-scheme-contract-explicit-handling.md +++ b/Docs/ADR/018-security-scheme-contract-explicit-handling.md @@ -28,7 +28,7 @@ Both enums grow additively, and every value is handled explicitly by the host an - The host enumerates its capabilities as `PluginContractValidator.SupportedSchemeTypes` and `SupportedApiKeyLocations`. `PluginContractValidator.Validate` checks every declared scheme type and location against these sets: supported values pass defined but unsupported and unknown (future) values raise `InvalidPluginContractException`. Unknown values are identified by their numeric identity and never resolved to `Custom` or any known value. - `AuthKitOpenApiSecuritySchemeMapper` maps every value explicitly: semantically correct OpenAPI representations for `ApiKey`, `Http`, `OAuth2`, `OpenIdConnect`, and `Basic` (as HTTP authentication with scheme `basic`); `NotSupportedException` for values with no correct 3.0 representation (`MutualTls`, `Session`, `Custom`; `GrpcMetadata` and `Body` as API key locations); `ArgumentOutOfRangeException` for unknown values. - Runtime support and OpenAPI representability are orthogonal: the host supports `GrpcMetadata` and `Body` credential extraction at runtime (ADR-017) even though OpenAPI 3.0 cannot describe them. `RestfulConfiguration` catches mapper failures and logs warning while skipping the definition it never emits a generic substitute and never crashes document generation. -- `Custom` requires a plugin-supplied `Description`; a missing description produces a validation warning (never a mapping to built-in scheme). +- `Custom` should be accompanied by an optional plugin-supplied `Description`; a missing description produces a validation warning (never a mapping to a built-in scheme). - The host only accepts `OpenApi:SpecVersion` `3.0`. `3.1` which would be needed for a real `mutualTLS` representation is rejected with `InvalidOperationException` instead of silently emitting 3.0 document. ### Design Rationale diff --git a/src/Host/Configuration/AuthKitOpenApiSecuritySchemeMapper.cs b/src/Host/Configuration/AuthKitOpenApiSecuritySchemeMapper.cs index 93e0eeb..30bc1ba 100644 --- a/src/Host/Configuration/AuthKitOpenApiSecuritySchemeMapper.cs +++ b/src/Host/Configuration/AuthKitOpenApiSecuritySchemeMapper.cs @@ -1,4 +1,6 @@ using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Host.Security.Options; using Microsoft.OpenApi; namespace Host.Configuration; @@ -61,7 +63,7 @@ public static OpenApiSecurityScheme Map( var scheme = new OpenApiSecurityScheme { - Name = descriptor.Name, + Name = ResolveCredentialName(descriptor), BearerFormat = descriptor.BearerFormat, Description = descriptor.Description }; @@ -163,4 +165,19 @@ private static string SpecVersionToLabel(OpenApiSpecVersion specVersion) => OpenApiSpecVersion.OpenApi3_1 => "3.1", _ => specVersion.ToString() }; + + /// + /// Resolves the credential field name for the OpenAPI name property + /// from the descriptor's explicit + /// or the host's location-specific default. + /// + private static string ResolveCredentialName(AuthKitSecuritySchemeDescriptor descriptor) => + descriptor.CredentialName + ?? descriptor.In switch + { + AuthKitApiKeyLocation.Header => ApiKeyCredentialExtractorOptions.DefaultHeaderNameValue, + AuthKitApiKeyLocation.Query => ApiKeyCredentialExtractorOptions.DefaultQueryNameValue, + AuthKitApiKeyLocation.Cookie => ApiKeyCredentialExtractorOptions.DefaultCookieNameValue, + _ => descriptor.Name + }; } diff --git a/src/Host/Plugins/PluginContractValidator.cs b/src/Host/Plugins/PluginContractValidator.cs index 9e30149..72c74d6 100644 --- a/src/Host/Plugins/PluginContractValidator.cs +++ b/src/Host/Plugins/PluginContractValidator.cs @@ -1,4 +1,6 @@ using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Microsoft.Extensions.Logging; namespace Host.Plugins; diff --git a/src/Host/Plugins/PluginLoader.cs b/src/Host/Plugins/PluginLoader.cs index 63f4d76..3f2c726 100644 --- a/src/Host/Plugins/PluginLoader.cs +++ b/src/Host/Plugins/PluginLoader.cs @@ -1,5 +1,7 @@ using System.Runtime.Loader; using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Models; using Microsoft.Extensions.Logging; namespace Host.Plugins; @@ -31,13 +33,36 @@ public static class PluginLoader /// /// The root directory containing one subdirectory per plugin. /// The logger used to report plugin discovery, loading, and validation results. + /// The version of the host application, used to reject plugins that + /// require a newer host. /// A readonly collection containing all successfully loaded plugins. /// /// Each plugin directory is expected to contain an entry assembly whose file name /// matches the directory name. Directories without matching assembly or assemblies /// without valid implementation are skipped. /// - public static IReadOnlyList LoadPlugins(string pluginsRootPath, ILogger logger) + public static IReadOnlyList LoadPlugins( + string pluginsRootPath, + ILogger logger, + SemanticVersion hostVersion) => + LoadPluginsCore(pluginsRootPath, logger, hostVersion); + + /// + /// Discovers and loads all valid AuthKit plugins from the specified root directory. + /// + /// The root directory containing one subdirectory per plugin. + /// The logger used to report plugin discovery, loading, and validation results. + /// A readonly collection containing all successfully loaded plugins. + [Obsolete("Use the overload taking a host version to enforce plugin MinHostVersion compatibility.")] + public static IReadOnlyList LoadPlugins( + string pluginsRootPath, + ILogger logger) => + LoadPluginsCore(pluginsRootPath, logger, hostVersion: null); + + private static IReadOnlyList LoadPluginsCore( + string pluginsRootPath, + ILogger logger, + SemanticVersion? hostVersion) { if (!Directory.Exists(pluginsRootPath)) { @@ -86,6 +111,14 @@ public static IReadOnlyList LoadPlugins(string pluginsRootPath, IL var plugin = (IAuthKitPlugin)Activator.CreateInstance(pluginType)!; + if (hostVersion is { } hv && plugin.MinHostVersion is { } minVersion && hv < minVersion) + { + logger.LogError( + "Skipping plugin '{Dir}': host version {HostVersion} is lower than required minimum {MinVersion}.", + pluginDir, hv, minVersion); + continue; + } + // Validate plugin contract against host capabilities PluginContractValidator.Validate(plugin, logger); diff --git a/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorBase.cs b/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorBase.cs index de4c7ea..2369e88 100644 --- a/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorBase.cs +++ b/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorBase.cs @@ -1,4 +1,5 @@ using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Microsoft.Extensions.Options; using Host.Security.Options; @@ -53,11 +54,11 @@ protected ApiKeyLocationExtractorBase( /// /// Resolves the credential name using the configured or provided default. /// - /// The credential name declared by the scheme. + /// The credential field name declared by the scheme. /// The default name used when the scheme is empty. /// The resolved credential name. - protected static string ResolveName(string? schemeName, string defaultValue) => - string.IsNullOrWhiteSpace(schemeName) + protected static string ResolveName(string? credentialName, string defaultValue) => + string.IsNullOrWhiteSpace(credentialName) ? defaultValue - : schemeName; + : credentialName; } \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs index 39c316d..ab2f8e2 100644 --- a/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs +++ b/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs @@ -1,5 +1,6 @@ using System.Text; using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Microsoft.Extensions.Options; using Host.Security.BodyParsers; using Host.Security.Options; @@ -16,8 +17,16 @@ namespace Host.Security.LocationExtractors; /// affecting downstream middleware or request handlers. /// /// -/// The body is read only when a compatible parser is registered and the -/// request size does not exceed the configured buffer threshold. +/// controls how much of the +/// body is buffered in memory before spilling to a temporary file. It is not a size limit: +/// bodies larger than the threshold continue to be read. +/// +/// +/// is the optional hard limit on +/// how much of the request body is read for credential extraction. When set, bodies larger +/// than the limit are rejected instead of being read in full. Requests without a known +/// Content-Length are read through the same bounded path when +/// is configured. /// /// public sealed class BodyApiKeyLocationExtractor( @@ -61,12 +70,14 @@ public sealed class BodyApiKeyLocationExtractor( } if (context.Request.ContentLength is { } length - && length > Options.BufferThreshold) + && Options.MaxBodySize > 0 + && length > Options.MaxBodySize) { - logger.LogWarning("Skipping Body credential extraction for scheme {Scheme}: body of {Length} bytes exceeds configured buffer threshold of {Threshold} bytes", + logger.LogWarning( + "Skipping Body credential extraction for scheme {Scheme}: body of {Length} bytes exceeds configured MaxBodySize of {MaxBodySize} bytes", scheme.Name, length, - Options.BufferThreshold); + Options.MaxBodySize); return null; } @@ -80,19 +91,38 @@ public sealed class BodyApiKeyLocationExtractor( { context.Request.Body.Position = 0; - using var reader = new StreamReader( - context.Request.Body, - Encoding.UTF8, - detectEncodingFromByteOrderMarks: true, - leaveOpen: true); + string? body; + + if (Options.MaxBodySize > 0) + { + body = await ReadBoundedBodyAsync(context.Request.Body, Options.MaxBodySize); + } + else + { + using var reader = new StreamReader( + context.Request.Body, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + leaveOpen: true); + + body = await reader.ReadToEndAsync(); + } + + if (body is null) + { + logger.LogWarning( + "Skipping Body credential extraction for scheme {Scheme}: body exceeds configured MaxBodySize of {MaxBodySize} bytes", + scheme.Name, + Options.MaxBodySize); - var body = await reader.ReadToEndAsync(); + return null; + } if (string.IsNullOrWhiteSpace(body)) return null; var fieldName = ResolveName( - scheme.Name, + scheme.CredentialName, Options.DefaultQueryName); return parser.Parse(body, fieldName); @@ -102,4 +132,37 @@ public sealed class BodyApiKeyLocationExtractor( context.Request.Body.Position = originalPosition; } } + + /// + /// Reads up to bytes from the body and decodes them as UTF-8. + /// Returns null when the body is larger than the limit. + /// + private static async Task ReadBoundedBodyAsync(Stream body, long maxBytes) + { + var buffer = new byte[4096]; + + using var ms = new MemoryStream(); + + long total = 0; + int read; + while ((read = await body.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0) + { + total += read; + + if (total > maxBytes) + return null; + + ms.Write(buffer, 0, read); + } + + ms.Position = 0; + + using var reader = new StreamReader( + ms, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + leaveOpen: false); + + return await reader.ReadToEndAsync(); + } } \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/CookieApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/CookieApiKeyLocationExtractor.cs index 1849ca0..3894675 100644 --- a/src/Host/Security/LocationExtractors/CookieApiKeyLocationExtractor.cs +++ b/src/Host/Security/LocationExtractors/CookieApiKeyLocationExtractor.cs @@ -1,4 +1,5 @@ using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Microsoft.Extensions.Options; using Host.Security.Options; @@ -10,9 +11,10 @@ namespace Host.Security.LocationExtractors; /// /// /// -/// The cookie name is resolved from the security scheme and falls back to +/// The cookie name is resolved from the scheme's +/// and falls back to /// when no -/// explicit name is provided. +/// explicit credential name is provided. /// /// public sealed class CookieApiKeyLocationExtractor( @@ -37,7 +39,7 @@ public sealed class CookieApiKeyLocationExtractor( HttpContext context, AuthKitSecuritySchemeDescriptor scheme) { - var cookieName = ResolveName(scheme.Name, Options.DefaultCookieName); + var cookieName = ResolveName(scheme.CredentialName, Options.DefaultCookieName); if (context.Request.Cookies.TryGetValue(cookieName, out var cookie)) { diff --git a/src/Host/Security/LocationExtractors/GrpcMetadataApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/GrpcMetadataApiKeyLocationExtractor.cs index db94b83..2424b20 100644 --- a/src/Host/Security/LocationExtractors/GrpcMetadataApiKeyLocationExtractor.cs +++ b/src/Host/Security/LocationExtractors/GrpcMetadataApiKeyLocationExtractor.cs @@ -1,4 +1,5 @@ using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Microsoft.Extensions.Options; using Host.Security.Options; @@ -40,19 +41,19 @@ public sealed class GrpcMetadataApiKeyLocationExtractor( HttpContext context, AuthKitSecuritySchemeDescriptor scheme) { - var headerName = string.IsNullOrWhiteSpace(scheme.Name) + var credentialName = string.IsNullOrWhiteSpace(scheme.CredentialName) ? Options.DefaultHeaderName.ToLowerInvariant() - : scheme.Name.ToLowerInvariant(); + : scheme.CredentialName.ToLowerInvariant(); - if(headerName == Options.DefaultHeaderName.ToLowerInvariant()) + if (credentialName == Options.DefaultHeaderName.ToLowerInvariant()) { logger.LogDebug( "Reading gRPC metadata for scheme {Scheme} as HTTP header '{HeaderName}'", scheme.Name, - headerName); + credentialName); } - if(context.Request.Headers.TryGetValue(headerName, out var header)) + if (context.Request.Headers.TryGetValue(credentialName, out var header)) { return Task.FromResult( ApiKeyValueNormalizer.Normalize(header.ToString())); diff --git a/src/Host/Security/LocationExtractors/HeaderApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/HeaderApiKeyLocationExtractor.cs index ac1cbc9..614ef3c 100644 --- a/src/Host/Security/LocationExtractors/HeaderApiKeyLocationExtractor.cs +++ b/src/Host/Security/LocationExtractors/HeaderApiKeyLocationExtractor.cs @@ -1,4 +1,5 @@ using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Microsoft.Extensions.Options; using Host.Security.Options; @@ -10,14 +11,14 @@ namespace Host.Security.LocationExtractors; /// /// /// -/// The header name is resolved from the security scheme and falls back to -/// when no -/// explicit name is provided. +/// The header name is resolved from the scheme's +/// and falls back to when no +/// explicit credential name is provided. /// /// -/// A case-insensitive Bearer prefix is removed from the extracted -/// value, allowing the header to contain either plain API key or bearer -/// token. +/// When is enabled, a +/// case-insensitive Bearer prefix is removed from the extracted value, allowing the header +/// to contain either a plain API key or a bearer token. /// /// public sealed class HeaderApiKeyLocationExtractor( @@ -42,12 +43,15 @@ public sealed class HeaderApiKeyLocationExtractor( HttpContext context, AuthKitSecuritySchemeDescriptor scheme) { - var headerName = ResolveName(scheme.Name, Options.DefaultHeaderName); + var headerName = ResolveName(scheme.CredentialName, Options.DefaultHeaderName); if (context.Request.Headers.TryGetValue(headerName, out var header)) { + var value = header.ToString(); return Task.FromResult( - ApiKeyValueNormalizer.StripBearerPrefix(header.ToString())); + Options.StripBearerPrefix + ? ApiKeyValueNormalizer.StripBearerPrefix(value) + : ApiKeyValueNormalizer.Normalize(value)); } return Task.FromResult(null); diff --git a/src/Host/Security/LocationExtractors/IApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/IApiKeyLocationExtractor.cs index 049f104..f5194de 100644 --- a/src/Host/Security/LocationExtractors/IApiKeyLocationExtractor.cs +++ b/src/Host/Security/LocationExtractors/IApiKeyLocationExtractor.cs @@ -1,4 +1,5 @@ using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; namespace Host.Security.LocationExtractors; diff --git a/src/Host/Security/LocationExtractors/QueryApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/QueryApiKeyLocationExtractor.cs index 76c30b3..bf26f9d 100644 --- a/src/Host/Security/LocationExtractors/QueryApiKeyLocationExtractor.cs +++ b/src/Host/Security/LocationExtractors/QueryApiKeyLocationExtractor.cs @@ -1,4 +1,5 @@ using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Microsoft.Extensions.Options; using Host.Security.Options; @@ -9,9 +10,10 @@ namespace Host.Security.LocationExtractors; /// Extracts an API key from an HTTP request query parameter. /// /// -/// The query parameter name is resolved from the security scheme and falls back +/// The query parameter name is resolved from the scheme's +/// and falls back /// to when no -/// explicit name is provided. +/// explicit credential name is provided. /// public sealed class QueryApiKeyLocationExtractor( IOptions options) @@ -33,7 +35,7 @@ public sealed class QueryApiKeyLocationExtractor( HttpContext context, AuthKitSecuritySchemeDescriptor scheme) { - var queryName = ResolveName(scheme.Name, Options.DefaultQueryName); + var queryName = ResolveName(scheme.CredentialName, Options.DefaultQueryName); if (context.Request.Query.TryGetValue(queryName, out var query)) { diff --git a/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs index 31055c1..1b5fcd2 100644 --- a/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs +++ b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Host.Security.LocationExtractors; using Host.Security.Validation; diff --git a/src/Host/Security/Options/ApiKeyCredentialExtractorOptions.cs b/src/Host/Security/Options/ApiKeyCredentialExtractorOptions.cs index 117f9cc..964db70 100644 --- a/src/Host/Security/Options/ApiKeyCredentialExtractorOptions.cs +++ b/src/Host/Security/Options/ApiKeyCredentialExtractorOptions.cs @@ -7,16 +7,32 @@ namespace Host.Security.Options; /// /// /// -/// Defaults names are used by the location extractors whenever the declaring +/// Default names are used by the location extractors whenever the declaring /// scheme does not specify an explicit credential name. /// /// -/// bounds how much of the request body is -/// buffered in memory before it spills to a temporary file. +/// controls how much of the request body is +/// buffered in memory before it spills to a temporary file. It is not a hard +/// size limit — see for the optional extraction limit. /// /// public sealed class ApiKeyCredentialExtractorOptions { + /// + /// Default value for . + /// + public const string DefaultHeaderNameValue = "X-Api-Key"; + + /// + /// Default value for . + /// + public const string DefaultQueryNameValue = "api_key"; + + /// + /// Default value for . + /// + public const string DefaultCookieNameValue = "api_key"; + /// /// Gets or sets the memory threshold, in bytes, used when buffering the /// request body for extraction. @@ -25,21 +41,47 @@ public sealed class ApiKeyCredentialExtractorOptions /// public long BufferThreshold { get; set; } = 1_048_576; // 1 MB + /// + /// Gets or sets the maximum request body size, in bytes, read for + /// credential extraction. + /// Bodies larger than this value are rejected instead of being read in + /// full. Requests without a known Content-Length are read through + /// the same bounded path. A value of 0 disables the limit and reads + /// the entire body that fits within spill + /// behavior (default: 0, no limit). + /// + public long MaxBodySize { get; set; } + + /// + /// Gets or sets a value indicating whether a case-insensitive + /// Bearer prefix is removed from values extracted from the + /// before validation (default: + /// false). + /// + /// + /// When disabled, headers are normalized only (whitespace and wrapping + /// quotes are trimmed) and a Bearer prefix is passed through to the + /// validator unchanged. Enable this option only for schemes where callers + /// are expected to share the header between plain API keys and bearer + /// tokens. + /// + public bool StripBearerPrefix { get; set; } + /// /// Gets or sets the header name to use when the scheme does not specify /// one (default: X-Api-Key). /// - public string DefaultHeaderName { get; set; } = "X-Api-Key"; + public string DefaultHeaderName { get; set; } = DefaultHeaderNameValue; /// /// Gets or sets the query parameter name to use when the scheme does not /// specify one (default: api_key). /// - public string DefaultQueryName { get; set; } = "api_key"; + public string DefaultQueryName { get; set; } = DefaultQueryNameValue; /// /// Gets or sets the cookie name to use when the scheme does not specify /// one (default: api_key). /// - public string DefaultCookieName { get; set; } = "api_key"; + public string DefaultCookieName { get; set; } = DefaultCookieNameValue; } \ No newline at end of file diff --git a/src/Plugins/Abstractions/Contracts/SecuritySchemes/AuthKitSecuritySchemeDescriptor.cs b/src/Plugins/Abstractions/Contracts/SecuritySchemes/AuthKitSecuritySchemeDescriptor.cs index f23243f..5d9edbb 100644 --- a/src/Plugins/Abstractions/Contracts/SecuritySchemes/AuthKitSecuritySchemeDescriptor.cs +++ b/src/Plugins/Abstractions/Contracts/SecuritySchemes/AuthKitSecuritySchemeDescriptor.cs @@ -24,8 +24,29 @@ public sealed record AuthKitSecuritySchemeDescriptor /// /// The unique name used to identify the security scheme. /// + /// + /// + /// is a transport-agnostic scheme identity. It is used in logs, + /// validation messages, and as the scheme key in OpenAPI documents. It must not be + /// reused as the transport credential field name. + /// + /// public required string Name { get; init; } + /// + /// The transport-specific field name used to locate the credential, such as the + /// header, query parameter, cookie, gRPC metadata key, or body field name. + /// + /// + /// + /// is independent from : a scheme can + /// be identified as DevTokens while its credential travels as an + /// X-Api-Key header. When not set, hosts fall back to their configured default + /// field name for the selected location. + /// + /// + public string? CredentialName { get; init; } + /// /// The type of authentication mechanism implemented by the security scheme. /// diff --git a/tests/Host/AuthKitOpenApiSecuritySchemeMapperTests.cs b/tests/Host/AuthKitOpenApiSecuritySchemeMapperTests.cs index d1f157b..841a146 100644 --- a/tests/Host/AuthKitOpenApiSecuritySchemeMapperTests.cs +++ b/tests/Host/AuthKitOpenApiSecuritySchemeMapperTests.cs @@ -1,5 +1,7 @@ using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Host.Configuration; +using Host.Security.Options; using Microsoft.OpenApi; using Xunit; @@ -31,18 +33,37 @@ private static AuthKitSecuritySchemeDescriptor Describe( }; [Theory] - [InlineData(AuthKitApiKeyLocation.Header, ParameterLocation.Header)] - [InlineData(AuthKitApiKeyLocation.Query, ParameterLocation.Query)] - [InlineData(AuthKitApiKeyLocation.Cookie, ParameterLocation.Cookie)] + [InlineData(AuthKitApiKeyLocation.Header, ParameterLocation.Header, ApiKeyCredentialExtractorOptions.DefaultHeaderNameValue)] + [InlineData(AuthKitApiKeyLocation.Query, ParameterLocation.Query, ApiKeyCredentialExtractorOptions.DefaultQueryNameValue)] + [InlineData(AuthKitApiKeyLocation.Cookie, ParameterLocation.Cookie, ApiKeyCredentialExtractorOptions.DefaultCookieNameValue)] public void ApiKey_WithHttpLocations_MapsToApiKeyAtLocation( - AuthKitApiKeyLocation location, ParameterLocation expected) + AuthKitApiKeyLocation location, ParameterLocation expected, string expectedName) { var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( Describe(AuthKitSecuritySchemeType.ApiKey, location), Version); Assert.Equal(SecuritySchemeType.ApiKey, mapped.Type); Assert.Equal(expected, mapped.In); - Assert.Equal("scheme", mapped.Name); + Assert.Equal(expectedName, mapped.Name); + } + + [Fact] + public void ApiKey_WithExplicitCredentialName_WinsOverDefault() + { + var descriptor = new AuthKitSecuritySchemeDescriptor + { + Name = "DevTokens", + CredentialName = "X-Dev-Key", + Type = AuthKitSecuritySchemeType.ApiKey, + In = AuthKitApiKeyLocation.Header, + Description = "desc" + }; + + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map(descriptor, Version); + + Assert.Equal(SecuritySchemeType.ApiKey, mapped.Type); + Assert.Equal(ParameterLocation.Header, mapped.In); + Assert.Equal("X-Dev-Key", mapped.Name); } [Fact] @@ -63,7 +84,7 @@ public void Basic_MapsExplicitlyToHttpBasic() Assert.Equal(SecuritySchemeType.Http, mapped.Type); Assert.Equal("basic", mapped.Scheme); - Assert.Equal("scheme", mapped.Name); + Assert.Equal(ApiKeyCredentialExtractorOptions.DefaultHeaderNameValue, mapped.Name); } [Fact] diff --git a/tests/Host/PluginContractValidatorTests.cs b/tests/Host/PluginContractValidatorTests.cs index eed6dfc..f98aa70 100644 --- a/tests/Host/PluginContractValidatorTests.cs +++ b/tests/Host/PluginContractValidatorTests.cs @@ -1,4 +1,7 @@ using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using AuthKit.Plugins.Abstractions.Models; using Host.Plugins; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; From b150128b8ad3a905377c50ac61bdf69bf352fbff Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 17:53:02 +0200 Subject: [PATCH 13/20] feat(security): resolve scheme per request and fail closed on invalid credentials Replace ambient GetService() with request-aware resolution driven by endpoint metadata and a host-level scheme registry built from the security schemes contributed by loaded plugins. - Add SecuritySchemeAttribute endpoint metadata (Abstractions contract). - Add ISecuritySchemeRegistry/SecuritySchemeRegistry; duplicate scheme names across plugins are a startup configuration error. - Middleware now resolves the endpoint's scheme, rejects unknown schemes, and fails closed with 401 Unauthorized when a credential is present but invalid or cannot be extracted. Requests without a credential still continue so downstream middleware can decide how to handle them. - Register ApiKeyCredentialExtractor in the pipeline and wire AddApiKeyCredentialExtraction into host startup. Closes review feedback requesting request-aware scheme resolution and a no-credential vs. invalid-credential distinction. --- .../AppMiddlewareConfiguration.cs | 3 + src/Host/Program.cs | 2 + src/Host/Security/ISecuritySchemeRegistry.cs | 32 ++++ .../Middleware/ApiKeyCredentialExtractor.cs | 92 ++++++--- src/Host/Security/SecuritySchemeRegistry.cs | 58 ++++++ .../SecurityServiceCollectionExtensions.cs | 1 + .../SecuritySchemeAttribute.cs | 27 +++ tests/Host/ApiKeyCredentialExtractorTests.cs | 181 ++++++++++++++++++ 8 files changed, 369 insertions(+), 27 deletions(-) create mode 100644 src/Host/Security/ISecuritySchemeRegistry.cs create mode 100644 src/Host/Security/SecuritySchemeRegistry.cs create mode 100644 src/Plugins/Abstractions/Contracts/SecuritySchemes/SecuritySchemeAttribute.cs create mode 100644 tests/Host/ApiKeyCredentialExtractorTests.cs diff --git a/src/Host/Configuration/AppMiddlewareConfiguration.cs b/src/Host/Configuration/AppMiddlewareConfiguration.cs index c48095a..ab14c50 100644 --- a/src/Host/Configuration/AppMiddlewareConfiguration.cs +++ b/src/Host/Configuration/AppMiddlewareConfiguration.cs @@ -1,5 +1,6 @@ using Host.Plugins; using Host.Restful.Middleware.Exceptions; +using Host.Security.Middleware; namespace Host.Configuration; @@ -43,6 +44,8 @@ public static WebApplication ConfigureMiddleware( app.UseMiddleware(middlewareType); } + app.UseMiddleware(); + app.UseAuthentication(); app.UseAuthorization(); diff --git a/src/Host/Program.cs b/src/Host/Program.cs index 26aab38..8f21772 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -1,6 +1,7 @@ using Host.Configuration; using Host.Plugins; using Host.Cli; +using Host.Security; using AuthKit.Plugins.Abstractions; using System.Reflection; using AuthKit.Plugins.Abstractions.Models; @@ -27,6 +28,7 @@ builder.Services.ConfigureApp(builder.Configuration, plugins) .AddGrpcServices() .AddRestfulServices(plugins, builder.Configuration, restfulLogger) + .AddApiKeyCredentialExtraction() .AddKeycloakServices(); foreach (var lp in plugins) diff --git a/src/Host/Security/ISecuritySchemeRegistry.cs b/src/Host/Security/ISecuritySchemeRegistry.cs new file mode 100644 index 0000000..4606fc3 --- /dev/null +++ b/src/Host/Security/ISecuritySchemeRegistry.cs @@ -0,0 +1,32 @@ +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; + +namespace Host.Security; + +/// +/// Resolves the that protects the +/// current request. +/// +/// +/// +/// The registry is built from the security schemes contributed by every enabled +/// plugin at startup. It provides request-aware lookup by scheme name, replacing +/// any reliance on a single ambient descriptor registered in the container. +/// +/// +/// Lookups are case-insensitive because scheme names are identifiers referenced +/// by endpoint metadata and are expected to be treated consistently across the +/// host and plugins. +/// +/// +public interface ISecuritySchemeRegistry +{ + /// + /// Looks up the descriptor for the named security scheme. + /// + /// The scheme name declared on the endpoint. + /// + /// Receives the matching descriptor when found; otherwise the null reference. + /// + /// true when the scheme is registered; otherwise, false. + bool TryGet(string schemeName, out AuthKitSecuritySchemeDescriptor result); +} \ No newline at end of file diff --git a/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs index 1b5fcd2..6c31bff 100644 --- a/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs +++ b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs @@ -1,31 +1,34 @@ using System.Security.Claims; -using AuthKit.Plugins.Abstractions; using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Host.Security.LocationExtractors; using Host.Security.Validation; +using Microsoft.AspNetCore.Http.Features; namespace Host.Security.Middleware; /// -/// Pipeline middleware that authenticates requests using an API key described by the -/// request's . +/// Pipeline middleware that authenticates requests using the API key scheme +/// declared on the current request's endpoint. /// /// /// -/// The middleware resolves the correct credential location strategy from -/// , extracts the API key, and validates -/// it through . On success the principal's claims are -/// attached to as an authenticated identity. +/// The scheme is resolved per request from endpoint metadata +/// () and the host's +/// rather than from an ambient descriptor +/// registered in the container. Endpoints that do not declare a scheme are +/// passed through untouched. /// /// -/// When no scheme is declared for the request, or when extraction or validation does -/// not produce a principal, the pipeline continues to the next middleware so downstream -/// authentication can decide how to handle the request. +/// Authentication is fail-closed: a request that carries a credential for a +/// declared scheme and cannot be authenticated is rejected with +/// 401 Unauthorized. Only requests that carry no credential at all are +/// passed through so downstream middleware can decide how to handle them. /// /// public sealed class ApiKeyCredentialExtractor( RequestDelegate next, - IApiKeyLocationExtractorRegistry registry, + ISecuritySchemeRegistry registry, + IApiKeyLocationExtractorRegistry locationRegistry, ILogger logger) { private const string AuthenticationType = "ApiKey"; @@ -37,41 +40,76 @@ public sealed class ApiKeyCredentialExtractor( /// The validator used to authenticate the extracted API key. public async Task InvokeAsync(HttpContext context, IApiKeyValidator validator) { - var scheme = context.RequestServices.GetService(); + var scheme = ResolveScheme(context); if (scheme is null) { await next(context); return; } - string? apiKey = null; - + string? apiKey; try { - apiKey = await registry.Resolve(scheme.In).ExtractAsync(context, scheme); + apiKey = await locationRegistry.Resolve(scheme.In).ExtractAsync(context, scheme); } catch (Exception ex) { logger.LogWarning(ex, "Failed to extract API key from {Location} for scheme {Scheme}", scheme.In, scheme.Name); + WriteUnauthorized(context); + return; } - if (!string.IsNullOrEmpty(apiKey)) + if (string.IsNullOrEmpty(apiKey)) { - try - { - var principal = await validator.ValidateAsync(apiKey); - if (principal is not null) - { - context.User.AddIdentity(new ClaimsIdentity(principal.Claims, AuthenticationType)); - logger.LogDebug("API key validated for {Subject} via scheme {Scheme}", principal.Subject, scheme.Name); - } - } - catch (Exception ex) + await next(context); + return; + } + + try + { + var principal = await validator.ValidateAsync(apiKey); + if (principal is null) { - logger.LogWarning(ex, "Failed to validate API key extracted from {Location} for scheme {Scheme}", scheme.In, scheme.Name); + logger.LogWarning("API key rejected by validator for scheme {Scheme}", scheme.Name); + WriteUnauthorized(context); + return; } + + var identity = new ClaimsIdentity(principal.Claims, AuthenticationType); + context.User = new ClaimsPrincipal(identity); + logger.LogDebug("API key validated for {Subject} via scheme {Scheme}", principal.Subject, scheme.Name); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to validate API key for scheme {Scheme}", scheme.Name); + WriteUnauthorized(context); + return; } await next(context); } + + private AuthKitSecuritySchemeDescriptor? ResolveScheme(HttpContext context) + { + var schemeName = context.Features.Get()?.Endpoint + ?.Metadata.GetMetadata()?.SchemeName; + + if (string.IsNullOrEmpty(schemeName)) + return null; + + if (!registry.TryGet(schemeName, out var scheme)) + { + throw new InvalidOperationException( + $"Endpoint declares security scheme '{schemeName}', but no enabled plugin contributes a scheme " + + $"with that name. The request cannot be authenticated."); + } + + return scheme; + } + + private static void WriteUnauthorized(HttpContext context) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers.WWWAuthenticate = "ApiKey"; + } } \ No newline at end of file diff --git a/src/Host/Security/SecuritySchemeRegistry.cs b/src/Host/Security/SecuritySchemeRegistry.cs new file mode 100644 index 0000000..39e0c0d --- /dev/null +++ b/src/Host/Security/SecuritySchemeRegistry.cs @@ -0,0 +1,58 @@ +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Host.Plugins; + +namespace Host.Security; + +/// +/// The default built from the security +/// schemes contributed by all loaded plugins. +/// +/// +/// +/// Scheme names are unique across plugins. If two plugins declare the same +/// scheme name the registry treats it as a host configuration error rather +/// than silently resolving to either plugin's descriptor. +/// +/// +public sealed class SecuritySchemeRegistry : ISecuritySchemeRegistry +{ + private readonly IReadOnlyDictionary _schemes; + + /// + /// Creates a registry over the supplied loaded plugins. + /// + /// The plugins contributing security schemes. + /// + /// More than one plugin declares the same security scheme name. + /// + public SecuritySchemeRegistry(IReadOnlyList plugins) + { + ArgumentNullException.ThrowIfNull(plugins); + _schemes = BuildSchemeMap(plugins); + } + + private static IReadOnlyDictionary BuildSchemeMap( + IReadOnlyList plugins) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var plugin in plugins) + { + foreach (var (name, descriptor) in plugin.Plugin.GetSecuritySchemes()) + { + if (!map.TryAdd(name, descriptor)) + { + throw new InvalidOperationException( + $"Security scheme '{name}' is declared by more than one plugin. " + + $"Scheme names must be unique across all enabled plugins."); + } + } + } + + return map; + } + + /// + public bool TryGet(string schemeName, out AuthKitSecuritySchemeDescriptor result) => + _schemes.TryGetValue(schemeName, out result!); +} \ No newline at end of file diff --git a/src/Host/Security/SecurityServiceCollectionExtensions.cs b/src/Host/Security/SecurityServiceCollectionExtensions.cs index 2c5fa12..0201c4b 100644 --- a/src/Host/Security/SecurityServiceCollectionExtensions.cs +++ b/src/Host/Security/SecurityServiceCollectionExtensions.cs @@ -36,6 +36,7 @@ public static IServiceCollection AddApiKeyCredentialExtraction( { services.Configure(configure ?? (_ => { })); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Plugins/Abstractions/Contracts/SecuritySchemes/SecuritySchemeAttribute.cs b/src/Plugins/Abstractions/Contracts/SecuritySchemes/SecuritySchemeAttribute.cs new file mode 100644 index 0000000..3c50fb8 --- /dev/null +++ b/src/Plugins/Abstractions/Contracts/SecuritySchemes/SecuritySchemeAttribute.cs @@ -0,0 +1,27 @@ +namespace AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; + +/// +/// Declares the security scheme that protects an endpoint. +/// +/// +/// +/// The host applies this attribute to endpoint metadata so that request-aware +/// security resolution can select the matching +/// for the current request, +/// instead of relying on a single ambient registered descriptor. +/// +/// +/// The scheme name must match a key returned by an enabled plugin's +/// ; otherwise the host rejects +/// requests to the endpoint as a configuration error. +/// +/// +/// The unique name of the security scheme protecting the endpoint. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public sealed class SecuritySchemeAttribute(string schemeName) : Attribute +{ + /// + /// Gets the unique name of the security scheme protecting the endpoint. + /// + public string SchemeName { get; } = schemeName; +} \ No newline at end of file diff --git a/tests/Host/ApiKeyCredentialExtractorTests.cs b/tests/Host/ApiKeyCredentialExtractorTests.cs new file mode 100644 index 0000000..395375a --- /dev/null +++ b/tests/Host/ApiKeyCredentialExtractorTests.cs @@ -0,0 +1,181 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Host.Security; +using Host.Security.LocationExtractors; +using Host.Security.Middleware; +using Host.Security.Validation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AuthKit.Host.Tests; + +public class ApiKeyCredentialExtractorTests +{ + private const string SchemeName = "DevTokens"; + + private sealed class FakeRegistry(string registeredName = SchemeName) : ISecuritySchemeRegistry + { + private readonly AuthKitSecuritySchemeDescriptor _descriptor = new() + { + Name = registeredName, + Type = AuthKitSecuritySchemeType.ApiKey, + In = AuthKitApiKeyLocation.Header, + CredentialName = "X-Api-Key" + }; + + public bool TryGet(string schemeName, out AuthKitSecuritySchemeDescriptor result) + { + result = _descriptor; + return string.Equals(schemeName, _descriptor.Name, StringComparison.OrdinalIgnoreCase); + } + } + + private sealed class FakeLocationRegistry(IApiKeyLocationExtractor extractor) : IApiKeyLocationExtractorRegistry + { + public IApiKeyLocationExtractor Resolve(AuthKitApiKeyLocation location) => extractor; + } + + private sealed class FakeExtractor(string? value) : IApiKeyLocationExtractor + { + public AuthKitApiKeyLocation Location => AuthKitApiKeyLocation.Header; + + public Task ExtractAsync(HttpContext context, AuthKitSecuritySchemeDescriptor scheme) + => Task.FromResult(value); + } + + private sealed class FakeValidator(ApiKeyPrincipal? principal) : IApiKeyValidator + { + public Task ValidateAsync(string apiKey) => Task.FromResult(principal); + } + + private sealed class PipelineProbe + { + public bool NextCalled { get; private set; } + + public RequestDelegate Next => context => + { + NextCalled = true; + return Task.CompletedTask; + }; + } + + private sealed class EndpointFeature : IEndpointFeature + { + public Endpoint? Endpoint { get; set; } + } + + private static HttpContext CreateContext(string? schemeName = SchemeName) + { + var context = new DefaultHttpContext(); + + if (schemeName is not null) + { + var endpoint = new Endpoint( + requestDelegate: _ => Task.CompletedTask, + metadata: new EndpointMetadataCollection(new SecuritySchemeAttribute(schemeName)), + displayName: "test"); + context.Features.Set(new EndpointFeature { Endpoint = endpoint }); + } + + return context; + } + + private static ApiKeyCredentialExtractor CreateMiddleware( + ISecuritySchemeRegistry registry, + IApiKeyLocationExtractorRegistry locationRegistry, + RequestDelegate next) => + new(next, registry, locationRegistry, NullLogger.Instance); + + [Fact] + public async Task EndpointWithoutSchemeAttribute_ContinuesPipeline() + { + var state = new PipelineProbe(); + var middleware = CreateMiddleware( + new FakeRegistry(), + new FakeLocationRegistry(new FakeExtractor("secret")), + state.Next); + + var context = CreateContext(schemeName: null); + await middleware.InvokeAsync( + context, + new FakeValidator(new ApiKeyPrincipal { Subject = "s1" })); + + Assert.True(state.NextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + + [Fact] + public async Task ValidCredential_AuthenticatesPrincipalAndContinues() + { + var state = new PipelineProbe(); + var middleware = CreateMiddleware( + new FakeRegistry(), + new FakeLocationRegistry(new FakeExtractor("secret")), + state.Next); + + var context = CreateContext(); + var principal = new ApiKeyPrincipal { Subject = "s1" }; + await middleware.InvokeAsync(context, new FakeValidator(principal)); + + Assert.True(state.NextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + Assert.True(context.User.Identity?.IsAuthenticated); + Assert.Equal("ApiKey", context.User.Identity.AuthenticationType); + } + + [Fact] + public async Task MissingCredential_ContinuesPipeline() + { + var state = new PipelineProbe(); + var middleware = CreateMiddleware( + new FakeRegistry(), + new FakeLocationRegistry(new FakeExtractor(null)), + state.Next); + + var context = CreateContext(); + await middleware.InvokeAsync( + context, + new FakeValidator(new ApiKeyPrincipal { Subject = "s1" })); + + Assert.True(state.NextCalled); + Assert.NotEqual(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + [Fact] + public async Task InvalidCredential_FailsClosedWith401() + { + var state = new PipelineProbe(); + var middleware = CreateMiddleware( + new FakeRegistry(), + new FakeLocationRegistry(new FakeExtractor("wrong-key")), + state.Next); + + var context = CreateContext(); + await middleware.InvokeAsync(context, new FakeValidator(null)); + + Assert.False(state.NextCalled); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + Assert.Equal("ApiKey", context.Response.Headers.WWWAuthenticate); + } + + [Fact] + public async Task SchemeNotRegistered_FailsClosedAsConfigurationError() + { + var state = new PipelineProbe(); + var middleware = CreateMiddleware( + new FakeRegistry(), + new FakeLocationRegistry(new FakeExtractor("secret")), + state.Next); + + var context = CreateContext(schemeName: "UnknownScheme"); + + await Assert.ThrowsAsync(() => + middleware.InvokeAsync( + context, + new FakeValidator(new ApiKeyPrincipal { Subject = "s1" }))); + + Assert.False(state.NextCalled); + } +} \ No newline at end of file From 5c2d8d0ebc0ef24707d9be581cfd294d7fb4d2af Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 18:00:49 +0200 Subject: [PATCH 14/20] fix(build): allow rollForward to latest 10.0 feature band in Docker Docker base image mcr.microsoft.com/dotnet/sdk:10.0 resolves a newer 10.0 feature band (10.0.400) than the pinned global.json (10.0.110). latestPatch does not cross feature bands, so dotnet build in the container failed with 'Requested SDK version 10.0.110'. Use latestFeature, which stays within major.minor 10.0 but accepts any installed 10.0 feature band. --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index b868500..82b35d8 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { "version": "10.0.110", - "rollForward": "latestPatch", + "rollForward": "latestFeature", "allowPrerelease": false } } \ No newline at end of file From fe5b1d7c45db3358467b10287267a92eb22581bf Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 18:20:02 +0200 Subject: [PATCH 15/20] fix(security): resolve IApiKeyValidator lazily to avoid 500s on every request --- .../Middleware/ApiKeyCredentialExtractor.cs | 20 +++++++- tests/Host/ApiKeyCredentialExtractorTests.cs | 48 ++++++++++++++----- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs index 6c31bff..bf799d9 100644 --- a/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs +++ b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs @@ -24,6 +24,12 @@ namespace Host.Security.Middleware; /// 401 Unauthorized. Only requests that carry no credential at all are /// passed through so downstream middleware can decide how to handle them. /// +/// +/// An is resolved lazily from the request's +/// service provider, and only when a credential is actually present. A missing +/// validator registration is treated as a host configuration error and the +/// request fails closed with 401 Unauthorized. +/// /// public sealed class ApiKeyCredentialExtractor( RequestDelegate next, @@ -37,8 +43,7 @@ public sealed class ApiKeyCredentialExtractor( /// Extracts and validates the API key declared for the current request. /// /// The current . - /// The validator used to authenticate the extracted API key. - public async Task InvokeAsync(HttpContext context, IApiKeyValidator validator) + public async Task InvokeAsync(HttpContext context) { var scheme = ResolveScheme(context); if (scheme is null) @@ -65,6 +70,17 @@ public async Task InvokeAsync(HttpContext context, IApiKeyValidator validator) return; } + var validator = context.RequestServices.GetService(); + if (validator is null) + { + logger.LogError( + "Scheme {Scheme} is declared on an endpoint, but no IApiKeyValidator is registered. " + + "Register a validator during host or plugin configuration.", + scheme.Name); + WriteUnauthorized(context); + return; + } + try { var principal = await validator.ValidateAsync(apiKey); diff --git a/tests/Host/ApiKeyCredentialExtractorTests.cs b/tests/Host/ApiKeyCredentialExtractorTests.cs index 395375a..b484912 100644 --- a/tests/Host/ApiKeyCredentialExtractorTests.cs +++ b/tests/Host/ApiKeyCredentialExtractorTests.cs @@ -6,6 +6,7 @@ using Host.Security.Validation; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -82,6 +83,14 @@ private static HttpContext CreateContext(string? schemeName = SchemeName) return context; } + private static void WithValidator(HttpContext context, IApiKeyValidator validator) + { + var serviceProvider = new ServiceCollection() + .AddSingleton(validator) + .BuildServiceProvider(); + context.RequestServices = serviceProvider; + } + private static ApiKeyCredentialExtractor CreateMiddleware( ISecuritySchemeRegistry registry, IApiKeyLocationExtractorRegistry locationRegistry, @@ -98,9 +107,8 @@ public async Task EndpointWithoutSchemeAttribute_ContinuesPipeline() state.Next); var context = CreateContext(schemeName: null); - await middleware.InvokeAsync( - context, - new FakeValidator(new ApiKeyPrincipal { Subject = "s1" })); + WithValidator(context, new FakeValidator(new ApiKeyPrincipal { Subject = "s1" })); + await middleware.InvokeAsync(context); Assert.True(state.NextCalled); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); @@ -117,7 +125,8 @@ public async Task ValidCredential_AuthenticatesPrincipalAndContinues() var context = CreateContext(); var principal = new ApiKeyPrincipal { Subject = "s1" }; - await middleware.InvokeAsync(context, new FakeValidator(principal)); + WithValidator(context, new FakeValidator(principal)); + await middleware.InvokeAsync(context); Assert.True(state.NextCalled); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); @@ -135,9 +144,8 @@ public async Task MissingCredential_ContinuesPipeline() state.Next); var context = CreateContext(); - await middleware.InvokeAsync( - context, - new FakeValidator(new ApiKeyPrincipal { Subject = "s1" })); + WithValidator(context, new FakeValidator(new ApiKeyPrincipal { Subject = "s1" })); + await middleware.InvokeAsync(context); Assert.True(state.NextCalled); Assert.NotEqual(StatusCodes.Status401Unauthorized, context.Response.StatusCode); @@ -153,13 +161,31 @@ public async Task InvalidCredential_FailsClosedWith401() state.Next); var context = CreateContext(); - await middleware.InvokeAsync(context, new FakeValidator(null)); + WithValidator(context, new FakeValidator(null)); + await middleware.InvokeAsync(context); Assert.False(state.NextCalled); Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); Assert.Equal("ApiKey", context.Response.Headers.WWWAuthenticate); } + [Fact] + public async Task NoValidatorRegistered_FailsClosedWith401() + { + var state = new PipelineProbe(); + var middleware = CreateMiddleware( + new FakeRegistry(), + new FakeLocationRegistry(new FakeExtractor("secret")), + state.Next); + + var context = CreateContext(); + context.RequestServices = new ServiceCollection().BuildServiceProvider(); + await middleware.InvokeAsync(context); + + Assert.False(state.NextCalled); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + [Fact] public async Task SchemeNotRegistered_FailsClosedAsConfigurationError() { @@ -170,11 +196,9 @@ public async Task SchemeNotRegistered_FailsClosedAsConfigurationError() state.Next); var context = CreateContext(schemeName: "UnknownScheme"); + WithValidator(context, new FakeValidator(new ApiKeyPrincipal { Subject = "s1" })); - await Assert.ThrowsAsync(() => - middleware.InvokeAsync( - context, - new FakeValidator(new ApiKeyPrincipal { Subject = "s1" }))); + await Assert.ThrowsAsync(() => middleware.InvokeAsync(context)); Assert.False(state.NextCalled); } From a9d23248fb6e172af3155cc454b232e029790317 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 18:57:00 +0200 Subject: [PATCH 16/20] Potential fix for pull request finding 'CodeQL / Log entries created from user input' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../LocationExtractors/BodyApiKeyLocationExtractor.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs index ab2f8e2..ae19333 100644 --- a/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs +++ b/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs @@ -62,9 +62,13 @@ public sealed class BodyApiKeyLocationExtractor( if (parser is null) { + var sanitizedContentType = (context.Request.ContentType ?? string.Empty) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty); + logger.LogDebug("Skipping Body credential extraction for scheme {Scheme}: unsupported content type {ContentType}", scheme.Name, - context.Request.ContentType); + sanitizedContentType); return null; } From eeeb388806f1b9470ca6b04f50ee2d1b85bec2f9 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 18:57:09 +0200 Subject: [PATCH 17/20] Potential fix for pull request finding 'CodeQL / Call to 'System.IO.Path.Combine' may silently drop its earlier arguments' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/Host/Plugins/PluginLoader.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Host/Plugins/PluginLoader.cs b/src/Host/Plugins/PluginLoader.cs index 3f2c726..943e1ea 100644 --- a/src/Host/Plugins/PluginLoader.cs +++ b/src/Host/Plugins/PluginLoader.cs @@ -75,7 +75,7 @@ private static IReadOnlyList LoadPluginsCore( foreach (var pluginDir in Directory.GetDirectories(pluginsRootPath)) { var pluginName = Path.GetFileName(pluginDir); - var entryDllPath = Path.Combine(pluginDir, $"{pluginName}.dll"); + var entryDllPath = Path.Join(pluginDir, $"{pluginName}.dll"); if (!File.Exists(entryDllPath)) { From 2190bc73dbc857607466efe32d6c12c9b5d09a32 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 18:58:00 +0200 Subject: [PATCH 18/20] fix for finding 'CodeQL / Generic catch clause' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs index bf799d9..6d01e23 100644 --- a/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs +++ b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs @@ -57,7 +57,7 @@ public async Task InvokeAsync(HttpContext context) { apiKey = await locationRegistry.Resolve(scheme.In).ExtractAsync(context, scheme); } - catch (Exception ex) + catch (FormatException ex) { logger.LogWarning(ex, "Failed to extract API key from {Location} for scheme {Scheme}", scheme.In, scheme.Name); WriteUnauthorized(context); From ea43c79e9d94f338d24f786117ec611dc14abb3f Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 18:58:17 +0200 Subject: [PATCH 19/20] Potential fix for pull request finding 'CodeQL / Generic catch clause' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../Security/Middleware/ApiKeyCredentialExtractor.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs index 6d01e23..9846eb0 100644 --- a/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs +++ b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs @@ -1,3 +1,4 @@ +using System.Security; using System.Security.Claims; using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Host.Security.LocationExtractors; @@ -95,7 +96,13 @@ public async Task InvokeAsync(HttpContext context) context.User = new ClaimsPrincipal(identity); logger.LogDebug("API key validated for {Subject} via scheme {Scheme}", principal.Subject, scheme.Name); } - catch (Exception ex) + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Failed to validate API key for scheme {Scheme}", scheme.Name); + WriteUnauthorized(context); + return; + } + catch (SecurityException ex) { logger.LogWarning(ex, "Failed to validate API key for scheme {Scheme}", scheme.Name); WriteUnauthorized(context); From ffefa52cdb9f83556661bf836a181e669c52ef98 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Fri, 11 Sep 2026 18:59:00 +0200 Subject: [PATCH 20/20] Potential fix for pull request finding 'CodeQL / Generic catch clause' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/Host/Plugins/PluginLoader.cs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Host/Plugins/PluginLoader.cs b/src/Host/Plugins/PluginLoader.cs index 943e1ea..cc37605 100644 --- a/src/Host/Plugins/PluginLoader.cs +++ b/src/Host/Plugins/PluginLoader.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Runtime.Loader; using AuthKit.Plugins.Abstractions; using AuthKit.Plugins.Abstractions.Contracts; @@ -126,7 +127,35 @@ private static IReadOnlyList LoadPluginsCore( logger.LogInformation("Loaded plugin '{Name}' v{Version} from {Dir}", plugin.Name, plugin.Version, pluginDir); } - catch (Exception ex) + catch (FileNotFoundException ex) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + } + catch (FileLoadException ex) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + } + catch (BadImageFormatException ex) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + } + catch (ReflectionTypeLoadException ex) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + } + catch (TypeLoadException ex) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + } + catch (MissingMethodException ex) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + } + catch (TargetInvocationException ex) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + } + catch (InvalidCastException ex) { logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); }