diff --git a/.gitignore b/.gitignore index 9a85c06..147f6c1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ src/Plugins/Solutions/DevTools/manifest.json issues/ *.DotSettings.user Docs/package-lock.json +TestResults diff --git a/Dockerfile b/Dockerfile index e30b37e..48e721b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,14 +47,14 @@ FROM build AS publish WORKDIR /src RUN dotnet publish "src/Host/Host.csproj" -c Release -o /app/publish RUN dotnet publish "src/Plugins/Solutions/DevTokens/DevTokens.csproj" -c Release -o /app/publish/plugins/DevTokens -COPY src/Plugins/Solutions/DevTokens/manifest.json /app/publish/plugins/DevTokens/manifest.json +RUN if [ -f src/Plugins/Solutions/DevTokens/manifest.json ]; then cp src/Plugins/Solutions/DevTokens/manifest.json /app/publish/plugins/DevTokens/manifest.json; else echo "DevTokens manifest.json not in context, skipping"; fi COPY --from=ui /ui/dist/ui.html src/Plugins/Solutions/DevTools/UI/dist/ui.html RUN dotnet publish "src/Plugins/Solutions/DevTools/DevTools.csproj" -c Release -o /app/publish/plugins/DevTools -COPY src/Plugins/Solutions/DevTools/manifest.json /app/publish/plugins/DevTools/manifest.json +RUN if [ -f src/Plugins/Solutions/DevTools/manifest.json ]; then cp src/Plugins/Solutions/DevTools/manifest.json /app/publish/plugins/DevTools/manifest.json; else echo "DevTools manifest.json not in context, skipping"; fi RUN dotnet publish "src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj" -c Release -o /app/publish/plugins/ExamplePlugin -COPY src/Plugins/Solutions/ExamplePlugin/manifest.json /app/publish/plugins/ExamplePlugin/manifest.json +RUN if [ -f src/Plugins/Solutions/ExamplePlugin/manifest.json ]; then cp src/Plugins/Solutions/ExamplePlugin/manifest.json /app/publish/plugins/ExamplePlugin/manifest.json; else echo "ExamplePlugin manifest.json not in context, skipping"; fi FROM base AS final WORKDIR /app diff --git a/src/Host/Configuration/Grpc/GrpcConfiguration.cs b/src/Host/Configuration/Grpc/GrpcConfiguration.cs index f60a0e0..82d069b 100644 --- a/src/Host/Configuration/Grpc/GrpcConfiguration.cs +++ b/src/Host/Configuration/Grpc/GrpcConfiguration.cs @@ -1,4 +1,7 @@ using Host.Grpc; +using Host.Plugins.Configuration; +using Host.Plugins.Loading; +using Microsoft.Extensions.Logging; namespace Host.Configuration.Grpc; @@ -18,17 +21,25 @@ namespace Host.Configuration.Grpc; public static class GrpcConfiguration { /// - /// Registers gRPC services with the dependency injection container. + /// Registers gRPC services with the dependency injection container, + /// including plugin contributed interceptors. /// /// The service collection used to register gRPC services. + /// The plugins loaded during application startup. + /// Logger for gRPC composition diagnostics. /// The configured instance. - public static IServiceCollection AddGrpcServices(this IServiceCollection services) + public static IServiceCollection AddGrpcServices( + this IServiceCollection services, + IReadOnlyList plugins, + ILogger logger) { services.AddGrpc(options => { //options.Interceptors.Add(); }); + services.AddPluginGrpcInterceptors(plugins, logger); + return services; } diff --git a/src/Host/Configuration/Pipeline/AppMiddlewareConfiguration.cs b/src/Host/Configuration/Pipeline/AppMiddlewareConfiguration.cs index f0b2f63..f57e2f6 100644 --- a/src/Host/Configuration/Pipeline/AppMiddlewareConfiguration.cs +++ b/src/Host/Configuration/Pipeline/AppMiddlewareConfiguration.cs @@ -2,9 +2,7 @@ using Host.Plugins.Configuration; using Host.Restful.Middleware.Exceptions; using Host.Security.Middleware; -using AuthKit.Plugins.Abstractions; -using AuthKit.Plugins.Abstractions.Contracts; -using AuthKit.Plugins.Abstractions.Contracts.PluginContract; +using AuthKit.Plugins.Abstractions.Pipeline; namespace Host.Configuration.Pipeline; @@ -14,7 +12,7 @@ namespace Host.Configuration.Pipeline; /// /// /// -/// Configures routing, validation and exception handling, plugin-provided +/// Configures routing, validation and exception handling, plugin provided /// middleware, and authentication and authorization. /// /// @@ -40,25 +38,43 @@ public static WebApplication ConfigureMiddleware( IReadOnlyList plugins) { PluginApplicationConfiguration.ConfigureApplications(app, plugins); - PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeRouting); + ConfigurePluginSlot(PluginPipelinePosition.BeforeRouting, PipelinePosition.BeforeRouting); app.UseRouting(); - PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.AfterRouting); + ConfigurePluginSlot(PluginPipelinePosition.AfterRouting, PipelinePosition.AfterRouting); app.UseMiddleware(); app.UseMiddleware(); PluginApplicationConfiguration.ConfigureLegacyMiddleware(app, plugins); - PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeAuthentication); + ConfigurePluginSlot(PluginPipelinePosition.BeforeAuthentication, PipelinePosition.BeforeAuthentication); app.UseMiddleware(); app.UseAuthentication(); - PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.AfterAuthentication); - PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeAuthorization); + ConfigurePluginSlot(PluginPipelinePosition.AfterAuthentication); + ConfigurePluginSlot(PluginPipelinePosition.BeforeAuthorization); app.UseAuthorization(); - PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.AfterAuthorization); - PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeEndpoints); + ConfigurePluginSlot(PluginPipelinePosition.AfterAuthorization, PipelinePosition.AfterAuthorization); + ConfigurePluginSlot(PluginPipelinePosition.BeforeEndpoints, PipelinePosition.BeforeEndpoints); + + // AfterEndpointExecution is post endpoint (response) execution: registered here, before + // endpoint mapping, so each middleware wraps the endpoint and its post-next code runs + // after the endpoint has executed. + ConfigureMiddlewareSlot(PipelinePosition.AfterEndpointExecution); return app; + + void ConfigurePluginSlot( + PluginPipelinePosition pipelinePosition, + PipelinePosition? middlewarePosition = null) + { + PluginApplicationConfiguration.ConfigurePipeline(app, plugins, pipelinePosition); + + if (middlewarePosition is { } position) + ConfigureMiddlewareSlot(position); + } + + void ConfigureMiddlewareSlot(PipelinePosition middlewarePosition) => + PluginApplicationConfiguration.ConfigurePluginMiddlewares(app, plugins, middlewarePosition); } } diff --git a/src/Host/Plugins/Configuration/PluginApplicationConfiguration.cs b/src/Host/Plugins/Configuration/PluginApplicationConfiguration.cs index cc199f7..6dccdc7 100644 --- a/src/Host/Plugins/Configuration/PluginApplicationConfiguration.cs +++ b/src/Host/Plugins/Configuration/PluginApplicationConfiguration.cs @@ -1,6 +1,7 @@ using System.Reflection; -using AuthKit.Plugins.Abstractions; using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.PluginContract; +using AuthKit.Plugins.Abstractions.Pipeline; using Host.Plugins.Loading; using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; @@ -11,15 +12,15 @@ namespace Host.Plugins.Configuration; /// /// /// -/// Plugins are invoked in a stable order regardless of the order they were +/// Plugins are invoked in stable order regardless of the order they were /// discovered: first by and then by /// plugin identifier using an ordinal comparison. /// /// -/// Plugins that do not implement a given hook are skipped. The newer +/// Plugins that do not implement given hook are skipped. The newer /// ConfigureApplication and ConfigurePipeline hooks take /// precedence over the legacy MiddlewareType entry point, which is -/// applied only as a compatibility fallback. +/// applied only as compatibility fallback. /// /// internal static class PluginApplicationConfiguration @@ -56,12 +57,12 @@ public static void ConfigureApplications(IApplicationBuilder application, IReadO /// The plugins loaded during application startup. /// The pipeline position to run hooks for. /// - /// Thrown when is not a defined + /// Thrown when is not defined /// value. /// /// - /// Thrown when a plugin that implements the pipeline hook declares a - /// PipelinePosition that is not a defined enum value. + /// Thrown when plugin that implements the pipeline hook declares a + /// PipelinePosition that is not defined enum value. /// public static void ConfigurePipeline( IApplicationBuilder application, @@ -134,6 +135,92 @@ public static void ConfigureLegacyMiddleware(WebApplication application, IReadOn } } + /// + /// Inserts declarative entries for one + /// in deterministic order + /// (Order -> stable plugin Id -> declaration index). + /// Disabled entries are skipped without side effects. + /// and + /// implementations are resolved from the request + /// service provider (single request scope); other types use + /// UseMiddleware activation. + /// + public static void ConfigurePluginMiddlewares( + IApplicationBuilder application, + IReadOnlyList plugins, + PipelinePosition position) + { + if (!Enum.IsDefined(position)) + throw new ArgumentOutOfRangeException(nameof(position), position, "Unsupported pipeline position."); + + var ordered = plugins + .SelectMany(lp => (lp.Plugin.Middlewares ?? []) + .Select((mw, index) => (Plugin: lp.Plugin, Entry: mw, Index: index))) + .Where(x => x.Entry.Position == position && x.Entry.IsMiddlewareEnabled + && x.Entry.Transport == AuthKitTransport.Http) + .OrderBy(x => x.Entry.Order) + .ThenBy(x => x.Plugin.Id, StringComparer.Ordinal) + .ThenBy(x => x.Index) + .ToList(); + + foreach (var (plugin, entry, _) in ordered) + { + if (entry.MiddlewareType is null) + throw new InvalidOperationException($"Plugin '{plugin.Id}' declares middleware with null type."); + + try + { + RegisterPluginMiddleware(application, entry.MiddlewareType); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Plugin '{plugin.Id}' failed to register middleware '{entry.MiddlewareType?.Name ?? entry.Name}'.", ex); + } + } + } + + private static void RegisterPluginMiddleware(IApplicationBuilder application, Type middlewareType) => + application.UseWhen( + static context => !IsGrpcRequest(context), + branch => RegisterHttpMiddleware(branch, middlewareType)); + + private static bool IsGrpcRequest(HttpContext context) => + context.Request.ContentType?.StartsWith("application/grpc", StringComparison.OrdinalIgnoreCase) == true; + + private static void RegisterHttpMiddleware(IApplicationBuilder application, Type middlewareType) + { + if (typeof(IAuthKitMiddleware).IsAssignableFrom(middlewareType)) + { + application.Use(async (context, next) => + { + var middleware = ResolvePluginMiddleware(context, middlewareType); + await middleware.InvokeAsync(context, next); + }); + + return; + } + + if (typeof(AuthKitMiddlewareBase).IsAssignableFrom(middlewareType)) + { + application.Use(async (context, next) => + { + var middleware = ResolvePluginMiddleware(context, middlewareType); + await middleware.InvokeAsync(context, next); + }); + + return; + } + + application.UseMiddleware(middlewareType); + } + + private static TMiddleware ResolvePluginMiddleware(HttpContext context, Type middlewareType) + where TMiddleware : class => + context.RequestServices.GetService(middlewareType) as TMiddleware + ?? ActivatorUtilities.CreateInstance(context.RequestServices, middlewareType) as TMiddleware + ?? throw new InvalidOperationException( + $"Middleware type '{middlewareType.FullName}' must be assignable to '{typeof(TMiddleware).FullName}'."); + /// /// Orders plugins by pipeline position and then by plugin identifier. /// @@ -165,14 +252,14 @@ private static void ValidatePluginPositions(IReadOnlyList plugins) } /// - /// Determines whether a plugin provides a concrete implementation of the given + /// Determines whether plugin provides concrete implementation of the given /// hook rather than inheriting the interface's default implementation. /// /// The plugin to inspect. /// The name of the interface method to look up. /// The parameter types that identify the overload. /// - /// true when the plugin overrides the hook; otherwise, false. + /// true when the plugin overrides the hook otherwise, false. /// private static bool HasImplementation(IAuthKitPlugin plugin, string methodName, params Type[] parameterTypes) { @@ -185,4 +272,4 @@ private static bool HasImplementation(IAuthKitPlugin plugin, string methodName, return method is not null && method.DeclaringType != typeof(IAuthKitPlugin); } -} \ No newline at end of file +} diff --git a/src/Host/Plugins/Configuration/PluginGrpcConfiguration.cs b/src/Host/Plugins/Configuration/PluginGrpcConfiguration.cs new file mode 100644 index 0000000..2ec6d0a --- /dev/null +++ b/src/Host/Plugins/Configuration/PluginGrpcConfiguration.cs @@ -0,0 +1,107 @@ +using AuthKit.Plugins.Abstractions.Contracts.PluginContract; +using AuthKit.Plugins.Abstractions.Pipeline; +using Grpc.Core.Interceptors; +using Host.Plugins.Loading; +using Microsoft.Extensions.Logging; + +namespace Host.Plugins.Configuration; + +/// +/// Composes plugin contributed gRPC interceptors into the host interceptor chain. +/// +/// +/// +/// Only entries declared with +/// are composed here. The host never guesses +/// transport by reflection: entries targeting +/// are skipped on the gRPC transport with an explicit warning (no automatic +/// HttpContext -> ServerCallContext bridge). +/// +/// +/// values are AuthKit semantic positions, not native +/// ASP.NET Core gRPC insertion points: the single interceptor chain is ordered +/// BeforeRouting -> … -> BeforeEndpoints, so each interceptor wraps the downstream +/// call in that order; AfterEndpointExecution interceptors perform +/// post-processing after the downstream call (the natural interceptor tail shape). +/// Within one position entries are ordered deterministically by +/// Order -> stable plugin Id -> declaration index. +/// +/// +/// Interceptors are registered scoped and resolved per call from the request +/// service provider (single scope, mirroring). Streaming (unary, client, +/// server and duplex streaming) is supported through the base +/// overloads no custom streaming pipeline exists. +/// +/// +internal static class PluginGrpcConfiguration +{ + /// + /// Registers every enabled gRPC transport interceptor and composes the + /// interceptor chain in semantic position order. + /// + public static IServiceCollection AddPluginGrpcInterceptors( + this IServiceCollection services, + IReadOnlyList plugins, + ILogger logger) + { + WarnForHttpOnlyMiddleware(plugins, logger); + + var ordered = OrderGrpcInterceptors(plugins); + + foreach (var (_, entry, _) in ordered) + { + if (entry.MiddlewareType is null) + throw new InvalidOperationException("Plugin declares PluginMiddleware with a null MiddlewareType."); + + if (!typeof(Interceptor).IsAssignableFrom(entry.MiddlewareType)) + throw new InvalidOperationException( + $"Plugin middleware '{entry.MiddlewareType.FullName ?? entry.MiddlewareType.Name}' targets the gRPC transport " + + $"but is not an Interceptor subclass."); + + services.AddScoped(entry.MiddlewareType); + } + + services.Configure(options => + { + foreach (var (_, entry, _) in ordered) + options.Interceptors.Add(entry.MiddlewareType!); + }); + + return services; + } + + /// + /// Orders enabled gRPC transport entries by semantic position, then + /// Order -> stable plugin Id -> declaration index. + /// + internal static IReadOnlyList<(string PluginId, PluginMiddleware Entry, int Index)> OrderGrpcInterceptors( + IReadOnlyList plugins) => + plugins + .SelectMany(lp => (lp.Plugin.Middlewares ?? []) + .Select((mw, index) => (PluginId: lp.Plugin.Id, Entry: mw, Index: index))) + .Where(x => x.Entry.IsMiddlewareEnabled && x.Entry.Transport == AuthKitTransport.Grpc) + .OrderBy(x => x.Entry.Position) + .ThenBy(x => x.Entry.Order) + .ThenBy(x => x.PluginId, StringComparer.Ordinal) + .ThenBy(x => x.Index) + .ToList(); + + private static void WarnForHttpOnlyMiddleware(IReadOnlyList plugins, ILogger logger) + { + foreach (var loadedPlugin in plugins) + { + foreach (var entry in loadedPlugin.Plugin.Middlewares ?? []) + { + if (!entry.IsMiddlewareEnabled || entry.Transport != AuthKitTransport.Http) + continue; + + logger.LogWarning( + "Plugin '{PluginId}' middleware '{MiddlewareName}' targets the HTTP transport " + + "and is skipped on the gRPC transport. Declare Transport = Grpc with an " + + "Interceptor MiddlewareType to run on gRPC; no automatic HttpContext bridge is provided.", + loadedPlugin.Plugin.Id, + entry.Name ?? entry.MiddlewareType?.FullName ?? entry.MiddlewareType?.Name ?? ""); + } + } + } +} diff --git a/src/Host/Program.cs b/src/Host/Program.cs index 5aff8ab..82f0ccd 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -11,10 +11,10 @@ using Host.Cli; using Host.Security; using Host.Security.Registrations; -using AuthKit.Plugins.Abstractions; using System.Reflection; using AuthKit.Plugins.Abstractions.Models; using Host.Plugins.Health; +using AuthKit.Plugins.Abstractions.Pipeline; var builder = WebApplication.CreateBuilder(args); @@ -30,6 +30,7 @@ var plugins = PluginLoader.LoadPlugins(pluginsPath, pluginLogger, hostVersion); var restfulLogger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger("RestfulConfiguration"); +var grpcLogger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger("GrpcConfiguration"); // === Core Config === builder.Services.AddSingleton(plugins); @@ -40,7 +41,7 @@ builder.Services.AddAuthKitCore(); builder.Services.ConfigureApp(builder.Configuration, plugins) - .AddGrpcServices() + .AddGrpcServices(plugins, grpcLogger) .AddRestfulServices(plugins, builder.Configuration, restfulLogger) .AddApiKeyCredentialExtraction() .AddKeycloakServices(plugins); diff --git a/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj index e045dc1..ce92f4e 100644 --- a/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj +++ b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj @@ -20,7 +20,7 @@ + - \ No newline at end of file diff --git a/src/Plugins/Abstractions/Contracts/PluginContract/AuthKitMiddlewareBase.cs b/src/Plugins/Abstractions/Contracts/PluginContract/AuthKitMiddlewareBase.cs new file mode 100644 index 0000000..a54bcf2 --- /dev/null +++ b/src/Plugins/Abstractions/Contracts/PluginContract/AuthKitMiddlewareBase.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Http; + +namespace AuthKit.Plugins.Abstractions.Contracts.PluginContract; + +/// +/// Convention based convenience base class for plugin middleware. +/// Plugins implement directly without needing DI. +/// +public abstract class AuthKitMiddlewareBase +{ + public abstract Task InvokeAsync(HttpContext context, RequestDelegate next); +} diff --git a/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitMiddleware.cs b/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitMiddleware.cs new file mode 100644 index 0000000..558072e --- /dev/null +++ b/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitMiddleware.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Http; + +namespace AuthKit.Plugins.Abstractions.Contracts.PluginContract; + +/// +/// DI-aware middleware contract. The host resolves the implementation from the +/// request service provider so scoped services share the single request scope. +/// +public interface IAuthKitMiddleware +{ + Task InvokeAsync(HttpContext context, RequestDelegate next); +} diff --git a/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitPlugin.Middlewares.cs b/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitPlugin.Middlewares.cs new file mode 100644 index 0000000..03ea808 --- /dev/null +++ b/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitPlugin.Middlewares.cs @@ -0,0 +1,15 @@ +using AuthKit.Plugins.Abstractions.Pipeline; + +namespace AuthKit.Plugins.Abstractions.Contracts.PluginContract; + +public partial interface IAuthKitPlugin +{ + /// + /// Gets the middleware registrations contributed by the plugin. + /// The plugin declares the middleware type and position; the host owns + /// validation, deterministic ordering (Order → stable PluginId → DeclarationIndex), + /// activation, and connection to the ASP.NET Core pipeline. + /// Defaults to empty (no middleware). + /// + IReadOnlyList Middlewares => []; +} diff --git a/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitPlugin.Pipeline.cs b/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitPlugin.Pipeline.cs index 3510699..dac227a 100644 --- a/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitPlugin.Pipeline.cs +++ b/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitPlugin.Pipeline.cs @@ -1,3 +1,4 @@ +using AuthKit.Plugins.Abstractions.Pipeline; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; diff --git a/src/Plugins/Abstractions/Pipeline/AuthKitTransport.cs b/src/Plugins/Abstractions/Pipeline/AuthKitTransport.cs new file mode 100644 index 0000000..32f4c6f --- /dev/null +++ b/src/Plugins/Abstractions/Pipeline/AuthKitTransport.cs @@ -0,0 +1,14 @@ +namespace AuthKit.Plugins.Abstractions.Pipeline; + +/// +/// Selects the transport a entry targets. +/// The host never guesses transport by reflection; it follows this declaration. +/// +public enum AuthKitTransport +{ + /// ASP.NET Core HTTP middleware pipeline (default). + Http = 0, + + /// gRPC interceptor chain (concrete Interceptor subclass). + Grpc = 1 +} diff --git a/src/Plugins/Abstractions/Pipeline/PipelinePosition.cs b/src/Plugins/Abstractions/Pipeline/PipelinePosition.cs new file mode 100644 index 0000000..706a5ae --- /dev/null +++ b/src/Plugins/Abstractions/Pipeline/PipelinePosition.cs @@ -0,0 +1,29 @@ +namespace AuthKit.Plugins.Abstractions.Pipeline; + +/// +/// Declares where plugin middleware should be inserted in the AuthKit host pipeline. +/// +/// +/// Each value maps to well defined point in the standard ASP.NET Core pipeline: +/// BeforeRouting -> before UseRouting; AfterRouting -> after UseRouting, before authentication; +/// BeforeAuthentication -> before UseAuthentication; AfterAuthorization -> after UseAuthorization +/// (ie. after authentication AND authorization) BeforeEndpoints -> before endpoints +/// AfterEndpointExecution -> post endpoint execution (response post-processing, not merely +/// registration after UseEndpoints). +/// There is intentionally no AfterAuthentication position. +/// +/// +/// For the gRPC transport these values are AuthKit semantic positions composed by the +/// host into the single gRPC interceptor chain (before call handling, around the host +/// authentication/authorization interceptors, directly before the service method, and +/// post processing after it) not six native ASP.NET Core gRPC insertion points. +/// +public enum PipelinePosition +{ + BeforeRouting = 0, + AfterRouting = 10, + BeforeAuthentication = 20, + AfterAuthorization = 30, + BeforeEndpoints = 40, + AfterEndpointExecution = 50 +} diff --git a/src/Plugins/Abstractions/Pipeline/PluginMiddleware.cs b/src/Plugins/Abstractions/Pipeline/PluginMiddleware.cs new file mode 100644 index 0000000..3ef0c5f --- /dev/null +++ b/src/Plugins/Abstractions/Pipeline/PluginMiddleware.cs @@ -0,0 +1,25 @@ +namespace AuthKit.Plugins.Abstractions.Pipeline; + +/// +/// Declares middleware type contributed by plugin. The plugin declares WHAT; +/// the host owns activation and connection to the ASP.NET Core pipeline. +/// +/// The middleware type. Must follow the conventional +/// ASP.NET Core pattern or implement +/// or derive from . +/// Where the middleware should be inserted. +/// Ordering key within single position (ascending). +/// When false, the host skips the entry without side effects. +/// Optional diagnostic name. +/// Target transport. The host never guesses transport; +/// entries run in the HTTP pipeline, +/// entries must be Interceptor +/// subclasses composed into the gRPC interceptor chain. HTTP only middleware +/// is skipped on the gRPC transport with an explicit warning (no auto-bridge). +public sealed record PluginMiddleware( + Type MiddlewareType, + PipelinePosition Position, + int Order = 0, + bool IsMiddlewareEnabled = true, + string? Name = null, + AuthKitTransport Transport = AuthKitTransport.Http); diff --git a/src/Plugins/Abstractions/PluginPipelinePosition.cs b/src/Plugins/Abstractions/Pipeline/PluginPipelinePosition.cs similarity index 94% rename from src/Plugins/Abstractions/PluginPipelinePosition.cs rename to src/Plugins/Abstractions/Pipeline/PluginPipelinePosition.cs index f72ced4..3e2e1db 100644 --- a/src/Plugins/Abstractions/PluginPipelinePosition.cs +++ b/src/Plugins/Abstractions/Pipeline/PluginPipelinePosition.cs @@ -1,4 +1,4 @@ -namespace AuthKit.Plugins.Abstractions; +namespace AuthKit.Plugins.Abstractions.Pipeline; /// /// Defines the supported locations for plugin application middleware. diff --git a/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.cs b/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.cs index 6926c25..7e521b8 100644 --- a/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.cs +++ b/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.cs @@ -3,6 +3,7 @@ using AuthKit.Plugins.Abstractions.Contracts.Plugins; using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using AuthKit.Plugins.Abstractions.Models; +using AuthKit.Plugins.Abstractions.Pipeline; using ExamplePlugin.Authentication; using ExamplePlugin.Grpc; using ExamplePlugin.Hosting; @@ -75,6 +76,9 @@ public void ConfigureServices(IServiceCollection services, AuthKitPluginContext services.Configure(context.Configuration); services.AddSingleton(TimeProvider.System); + // Registered so the host can resolve ExampleScopedMiddleware (IAuthKitMiddleware) + // from the request service provider within the single request scope. + services.AddScoped(); } /// @@ -87,6 +91,44 @@ public void ConfigureServices(IServiceCollection services, AuthKitPluginContext /// public Type MiddlewareType => typeof(ExampleProtocolMiddleware); + /// + /// Declarative middleware registrations (issue #19, C1–C5). The plugin declares + /// WHAT middleware it needs; the host owns activation, deterministic ordering + /// (Order → stable PluginId → DeclarationIndex), and pipeline insertion. + /// + public IReadOnlyList Middlewares => + [ + // Convention-based middleware, ordered first at its position. + new PluginMiddleware( + typeof(ExampleHeaderMiddleware), + AuthKit.Plugins.Abstractions.Pipeline.PipelinePosition.BeforeAuthentication, + Order: 0, + IsMiddlewareEnabled: true, + Name: "example-header"), + // DI-aware middleware (scoped services from the request scope). + new PluginMiddleware( + typeof(ExampleScopedMiddleware), + AuthKit.Plugins.Abstractions.Pipeline.PipelinePosition.AfterAuthorization, + Order: 10, + IsMiddlewareEnabled: true, + Name: "example-scoped"), + // Disabled entry: host skips it without side effects or ordering impact. + new PluginMiddleware( + typeof(ExampleHeaderMiddleware), + AuthKit.Plugins.Abstractions.Pipeline.PipelinePosition.BeforeEndpoints, + Order: 0, + IsMiddlewareEnabled: false, + Name: "example-disabled"), + // gRPC interceptor: composed into the host interceptor chain. + new PluginMiddleware( + typeof(ExampleLoggingInterceptor), + AuthKit.Plugins.Abstractions.Pipeline.PipelinePosition.BeforeEndpoints, + Order: 0, + IsMiddlewareEnabled: true, + Name: "example-grpc-logging", + Transport: AuthKitTransport.Grpc), + ]; + /// /// Registers the reference endpoints on the application's route builder. /// diff --git a/src/Plugins/Solutions/ExamplePlugin/Grpc/ExampleLoggingInterceptor.cs b/src/Plugins/Solutions/ExamplePlugin/Grpc/ExampleLoggingInterceptor.cs new file mode 100644 index 0000000..5504036 --- /dev/null +++ b/src/Plugins/Solutions/ExamplePlugin/Grpc/ExampleLoggingInterceptor.cs @@ -0,0 +1,26 @@ +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.Logging; + +namespace ExamplePlugin.Grpc; + +/// +/// Reference gRPC interceptor contributed declaratively. +/// Streaming RPCs are supported through the base +/// overloads; only unary is customized here. +/// +public sealed class ExampleLoggingInterceptor(ILogger logger) : Interceptor +{ + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + logger.LogDebug("Example gRPC call started: {Method}.", context.Method); + var response = await continuation(request, context); + + // Post endpoint processing (AfterEndpointExecution semantic position). + logger.LogDebug("Example gRPC call finished: {Method}.", context.Method); + return response; + } +} diff --git a/src/Plugins/Solutions/ExamplePlugin/Middleware/ExampleHeaderMiddleware.cs b/src/Plugins/Solutions/ExamplePlugin/Middleware/ExampleHeaderMiddleware.cs new file mode 100644 index 0000000..205bd51 --- /dev/null +++ b/src/Plugins/Solutions/ExamplePlugin/Middleware/ExampleHeaderMiddleware.cs @@ -0,0 +1,18 @@ +using AuthKit.Plugins.Abstractions.Contracts.PluginContract; +using Microsoft.AspNetCore.Http; + +namespace ExamplePlugin.Middleware; + +/// +/// Reference convention based middleware. Implements +/// directly without needing DI. +/// +public sealed class ExampleHeaderMiddleware : AuthKitMiddlewareBase +{ + public override async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + context.Items["example.header.middleware"] = true; + context.Response.Headers.Append("X-Example-Middleware", "header"); + await next(context); + } +} diff --git a/src/Plugins/Solutions/ExamplePlugin/Middleware/ExampleScopedMiddleware.cs b/src/Plugins/Solutions/ExamplePlugin/Middleware/ExampleScopedMiddleware.cs new file mode 100644 index 0000000..954e72d --- /dev/null +++ b/src/Plugins/Solutions/ExamplePlugin/Middleware/ExampleScopedMiddleware.cs @@ -0,0 +1,20 @@ +using AuthKit.Plugins.Abstractions.Contracts.PluginContract; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace ExamplePlugin.Middleware; + +/// +/// Reference DI aware middleware. The host resolves it from the request service +/// provider so scoped services share the single request scope. +/// +public sealed class ExampleScopedMiddleware(ILogger logger, TimeProvider timeProvider) : IAuthKitMiddleware +{ + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + logger.LogDebug("ExampleScopedMiddleware handling request at {Now}.", timeProvider.GetUtcNow()); + context.Items["example.scoped.middleware"] = timeProvider.GetUtcNow().ToString("O"); + await next(context); + // Post-endpoint response processing goes here (after next). + } +} diff --git a/tests/Host/AuthKit.Host.Tests.csproj b/tests/Host/AuthKit.Host.Tests.csproj index d1fa546..ee6adf3 100644 --- a/tests/Host/AuthKit.Host.Tests.csproj +++ b/tests/Host/AuthKit.Host.Tests.csproj @@ -27,6 +27,7 @@ + - \ No newline at end of file + diff --git a/tests/Host/GrpcInterceptorStreamingTests.cs b/tests/Host/GrpcInterceptorStreamingTests.cs new file mode 100644 index 0000000..59f29ed --- /dev/null +++ b/tests/Host/GrpcInterceptorStreamingTests.cs @@ -0,0 +1,137 @@ +using Grpc.Core; +using Grpc.Core.Interceptors; +using Xunit; + +namespace AuthKit.Host.Tests; + +/// +/// Verifies that a plugin-contributed (C7) handles all +/// four gRPC call patterns through the native base-class overloads — no custom +/// streaming pipeline is required. +/// +public sealed class GrpcInterceptorStreamingTests +{ + [Fact] + public async Task UnaryServerHandler_InvokesContinuationAndPostProcesses() + { + var interceptor = new RecordingInterceptor(); + var context = new FakeServerCallContext(); + + var response = await interceptor.UnaryServerHandler( + "request", context, (req, _) => Task.FromResult(req + "-response")); + + Assert.Equal("request-response", response); + Assert.True(interceptor.UnaryPostProcessed); + } + + [Fact] + public async Task ServerStreamingServerHandler_DispatchesToContinuation() + { + var interceptor = new RecordingInterceptor(); + var context = new FakeServerCallContext(); + var writer = new RecordingStreamWriter(); + var dispatched = false; + + await interceptor.ServerStreamingServerHandler( + "request", writer, context, + (req, stream, _) => + { + dispatched = true; + return stream.WriteAsync(req + "-chunk"); + }); + + Assert.True(dispatched); + Assert.Equal(["request-chunk"], writer.Written); + } + + [Fact] + public async Task ClientStreamingServerHandler_DispatchesToContinuation() + { + var interceptor = new RecordingInterceptor(); + var context = new FakeServerCallContext(); + var reader = new RecordingStreamReader(["a", "b"]); + + var response = await interceptor.ClientStreamingServerHandler( + reader, context, (_, _) => Task.FromResult("done")); + + Assert.Equal("done", response); + } + + [Fact] + public async Task DuplexStreamingServerHandler_DispatchesToContinuation() + { + var interceptor = new RecordingInterceptor(); + var context = new FakeServerCallContext(); + var reader = new RecordingStreamReader(["a"]); + var writer = new RecordingStreamWriter(); + var dispatched = false; + + await interceptor.DuplexStreamingServerHandler( + reader, writer, context, + (_, _, _) => + { + dispatched = true; + return Task.CompletedTask; + }); + + Assert.True(dispatched); + } + + private sealed class RecordingInterceptor : Interceptor + { + public bool UnaryPostProcessed { get; private set; } + + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + var response = await base.UnaryServerHandler(request, context, continuation); + UnaryPostProcessed = true; + return response; + } + } + + private sealed class FakeServerCallContext : ServerCallContext + { + protected override string MethodCore => "authkit.example.ExampleGreeter/SayHello"; + protected override string HostCore => "localhost"; + protected override string PeerCore => "test-peer"; + protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); + protected override Metadata RequestHeadersCore => []; + protected override CancellationToken CancellationTokenCore => CancellationToken.None; + protected override Metadata ResponseTrailersCore => []; + protected override Status StatusCore { get => new(); set { } } + protected override WriteOptions? WriteOptionsCore { get => null; set { } } + protected override AuthContext AuthContextCore => new("test", new Dictionary>()); + + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => + Task.CompletedTask; + + protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => + throw new NotImplementedException("Propagation is not used by these tests."); + } + + private sealed class RecordingStreamReader(IReadOnlyList items) : IAsyncStreamReader + { + private int _index = -1; + + public T Current => items[_index]; + + public Task MoveNext(CancellationToken cancellationToken = default) => + Task.FromResult(++_index < items.Count); + } + + private sealed class RecordingStreamWriter : IServerStreamWriter + { + public List Written { get; } = []; + + public WriteOptions? WriteOptions { get; set; } + + public Task WriteAsync(T message) + { + Written.Add(message); + return Task.CompletedTask; + } + } +} diff --git a/tests/Host/MiddlewareContractRuleTests.cs b/tests/Host/MiddlewareContractRuleTests.cs new file mode 100644 index 0000000..c1c110c --- /dev/null +++ b/tests/Host/MiddlewareContractRuleTests.cs @@ -0,0 +1,399 @@ +using AuthKit.PluginContractValidator.Rules; +using AuthKit.Plugins.Abstractions.Contracts.PluginContract; +using AuthKit.Plugins.Abstractions.Pipeline; +using Microsoft.AspNetCore.Http; +using Xunit; +using ValidatorLoadedPlugin = AuthKit.PluginContractValidator.Core.LoadedPlugin; + +namespace AuthKit.Host.Tests; + +public sealed class MiddlewareContractRuleTests +{ + private readonly MiddlewareRule _rule = new(); + + [Fact] + public void RuleName_IsMiddleware() => Assert.Equal("Middleware", _rule.Name); + + [Theory] + [InlineData(typeof(ConventionMiddleware))] + [InlineData(typeof(BaseMiddleware))] + [InlineData(typeof(InterfaceMiddleware))] + public async Task ValidMiddlewareModels_AreAccepted(Type middlewareType) + { + var errors = await ValidateAsync(middlewareType); + + Assert.Empty(errors); + } + + [Fact] + public async Task ConventionMiddleware_WithoutRequestDelegateConstructor_IsRejected() + { + var errors = await ValidateAsync(typeof(MissingRequestDelegateMiddleware)); + + Assert.Contains(errors, error => + error.Contains("TestPlugin", StringComparison.Ordinal) + && error.Contains(nameof(MissingRequestDelegateMiddleware), StringComparison.Ordinal) + && error.Contains("RequestDelegate", StringComparison.Ordinal)); + } + + [Fact] + public async Task MiddlewareMatchingBothAuthKitModels_IsRejectedAsAmbiguous() + { + var errors = await ValidateAsync(typeof(AmbiguousMiddleware)); + + Assert.Contains(errors, error => + error.Contains(nameof(AmbiguousMiddleware), StringComparison.Ordinal) + && error.Contains("both AuthKitMiddlewareBase and IAuthKitMiddleware", StringComparison.Ordinal)); + } + + [Fact] + public async Task StaticInvokeMethod_IsRejected() + { + var errors = await ValidateAsync(typeof(StaticInvokeMiddleware)); + + Assert.Contains(errors, error => + error.Contains(nameof(StaticInvokeMiddleware), StringComparison.Ordinal) + && error.Contains("static Invoke", StringComparison.Ordinal)); + } + + [Fact] + public async Task GenericMiddlewareType_IsRejected() + { + var errors = await ValidateAsync(typeof(GenericMiddleware<>)); + + Assert.Contains(errors, error => + error.Contains(nameof(GenericMiddleware), StringComparison.Ordinal) + && error.Contains("generic", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task VoidReturningInvokeMethod_IsRejected() + { + var errors = await ValidateAsync(typeof(VoidInvokeMiddleware)); + + Assert.Contains(errors, error => + error.Contains(nameof(VoidInvokeMiddleware), StringComparison.Ordinal) + && error.Contains("Task", StringComparison.Ordinal)); + } + + [Fact] + public async Task MultipleRequestDelegateConstructors_AreRejected() + { + var errors = await ValidateAsync(typeof(MultiCtorMiddleware)); + + Assert.Contains(errors, error => + error.Contains(nameof(MultiCtorMiddleware), StringComparison.Ordinal) + && error.Contains("multiple public constructors", StringComparison.Ordinal)); + } + + [Fact] + public async Task AbstractMiddlewareType_IsRejected() + { + var errors = await ValidateAsync(typeof(AbstractMiddleware)); + + Assert.Contains(errors, error => + error.Contains(nameof(AbstractMiddleware), StringComparison.Ordinal) + && error.Contains("abstract", StringComparison.OrdinalIgnoreCase)); + } + + private async Task> ValidateAsync(Type middlewareType) + { + var plugin = new TestPlugin(middlewareType); + var loadedPlugin = new ValidatorLoadedPlugin(plugin, typeof(MiddlewareContractRuleTests).Assembly); + + return await _rule.ValidateAsync(loadedPlugin); + } + + private async Task> ValidateGrpcAsync(Type middlewareType) + { + var plugin = new GrpcTestPlugin(middlewareType); + var loadedPlugin = new ValidatorLoadedPlugin(plugin, typeof(MiddlewareContractRuleTests).Assembly); + + return await _rule.ValidateAsync(loadedPlugin); + } + + [Fact] + public async Task ValidGrpcInterceptor_IsAccepted() + { + var errors = await ValidateGrpcAsync(typeof(ValidGrpcInterceptor)); + + Assert.Empty(errors); + } + + [Fact] + public async Task NonInterceptorGrpcType_IsRejected() + { + var errors = await ValidateGrpcAsync(typeof(ConventionMiddleware)); + + Assert.Contains(errors, error => + error.Contains(nameof(ConventionMiddleware), StringComparison.Ordinal) + && error.Contains("Interceptor", StringComparison.Ordinal)); + } + + [Fact] + public async Task AbstractGrpcInterceptor_IsRejected() + { + var errors = await ValidateGrpcAsync(typeof(AbstractGrpcInterceptor)); + + Assert.Contains(errors, error => + error.Contains(nameof(AbstractGrpcInterceptor), StringComparison.Ordinal) + && error.Contains("abstract", StringComparison.OrdinalIgnoreCase)); + } + + private sealed class GrpcTestPlugin(Type middlewareType) : IAuthKitPlugin + { + public string Name => "TestPlugin"; + + public IReadOnlyList Middlewares => + [ + new(middlewareType, PipelinePosition.BeforeAuthentication, Transport: AuthKitTransport.Grpc) + ]; + } + + public sealed class ValidGrpcInterceptor : Grpc.Core.Interceptors.Interceptor + { + } + + public abstract class AbstractGrpcInterceptor : Grpc.Core.Interceptors.Interceptor + { + } + + [Fact] + public async Task LegacyMiddlewareType_IsValidated() + { + var plugin = new LegacyPlugin(typeof(ConventionMiddleware)); + var loadedPlugin = new ValidatorLoadedPlugin(plugin, typeof(MiddlewareContractRuleTests).Assembly); + + Assert.Empty(await _rule.ValidateAsync(loadedPlugin)); + } + + [Fact] + public async Task LegacyMiddlewareType_InvalidIsRejected() + { + var plugin = new LegacyPlugin(typeof(MissingRequestDelegateMiddleware)); + var loadedPlugin = new ValidatorLoadedPlugin(plugin, typeof(MiddlewareContractRuleTests).Assembly); + + var errors = await _rule.ValidateAsync(loadedPlugin); + + Assert.Contains(errors, error => + error.Contains(nameof(MissingRequestDelegateMiddleware), StringComparison.Ordinal)); + } + + [Fact] + public async Task NullMiddlewareTypeEntry_IsRejected() + { + var plugin = new RawListPlugin([new PluginMiddleware(null!, PipelinePosition.BeforeAuthentication)]); + var loadedPlugin = new ValidatorLoadedPlugin(plugin, typeof(MiddlewareContractRuleTests).Assembly); + + var errors = await _rule.ValidateAsync(loadedPlugin); + + Assert.Contains(errors, error => error.Contains("null MiddlewareType", StringComparison.Ordinal)); + } + + [Fact] + public async Task UndefinedTransport_IsRejected() + { + var plugin = new RawListPlugin([new PluginMiddleware(typeof(ConventionMiddleware), PipelinePosition.BeforeAuthentication, Transport: (AuthKitTransport)99)]); + var loadedPlugin = new ValidatorLoadedPlugin(plugin, typeof(MiddlewareContractRuleTests).Assembly); + + var errors = await _rule.ValidateAsync(loadedPlugin); + + Assert.Contains(errors, error => error.Contains("undefined transport", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task UndefinedPosition_IsRejected() + { + var plugin = new RawListPlugin([new PluginMiddleware(typeof(ConventionMiddleware), (PipelinePosition)99)]); + var loadedPlugin = new ValidatorLoadedPlugin(plugin, typeof(MiddlewareContractRuleTests).Assembly); + + var errors = await _rule.ValidateAsync(loadedPlugin); + + Assert.Contains(errors, error => error.Contains("undefined position", StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData(typeof(BaseMiddleware))] + [InlineData(typeof(InterfaceMiddleware))] + public async Task LegacyAuthKitModel_IsRejected(Type middlewareType) + { + var plugin = new LegacyPlugin(middlewareType); + var loadedPlugin = new ValidatorLoadedPlugin(plugin, typeof(MiddlewareContractRuleTests).Assembly); + + var errors = await _rule.ValidateAsync(loadedPlugin); + + Assert.Contains(errors, error => error.Contains("legacy MiddlewareType supports only convention middleware", StringComparison.Ordinal)); + } + + [Fact] + public async Task NonPublicMiddlewareType_IsRejected() + { + var errors = await ValidateAsync(typeof(PrivateMiddleware)); + + Assert.Contains(errors, error => + error.Contains("must be public", StringComparison.Ordinal)); + } + + [Fact] + public async Task NonClassMiddlewareType_IsRejected() + { + var errors = await ValidateAsync(typeof(StructMiddleware)); + + Assert.Contains(errors, error => + error.Contains("concrete class", StringComparison.Ordinal)); + } + + [Fact] + public async Task BaseMiddlewareWithoutPublicConstructor_IsRejected() + { + var errors = await ValidateAsync(typeof(PrivateCtorBaseMiddleware)); + + Assert.Contains(errors, error => + error.Contains("at least one public constructor", StringComparison.Ordinal)); + } + + [Fact] + public async Task AbstractBaseMiddlewareWithoutOverride_IsRejected() + { + var errors = await ValidateAsync(typeof(NoOverrideBaseMiddleware)); + + Assert.Contains(errors, error => + error.Contains("must override InvokeAsync", StringComparison.Ordinal)); + } + + private sealed class LegacyPlugin(Type middlewareType) : IAuthKitPlugin + { + public string Name => "TestPlugin"; + + public Type? MiddlewareType => middlewareType; + } + + private sealed class RawListPlugin(IReadOnlyList middlewares) : IAuthKitPlugin + { + public string Name => "TestPlugin"; + + public IReadOnlyList Middlewares => middlewares; + } + + private sealed class PrivateMiddleware + { + public PrivateMiddleware(RequestDelegate next) + { + _ = next; + } + + public Task InvokeAsync(HttpContext context, RequestDelegate next) => next(context); + } + + public struct StructMiddleware + { + public Task InvokeAsync(HttpContext context, RequestDelegate next) => next(context); + } + + public sealed class PrivateCtorBaseMiddleware : AuthKitMiddlewareBase + { + private PrivateCtorBaseMiddleware() + { + } + + public override Task InvokeAsync(HttpContext context, RequestDelegate next) => next(context); + } + + public abstract class NoOverrideBaseMiddleware : AuthKitMiddlewareBase + { + } + + private sealed class TestPlugin(Type middlewareType) : IAuthKitPlugin + { + public string Name => "TestPlugin"; + + public IReadOnlyList Middlewares => + [ + new(middlewareType, PipelinePosition.BeforeAuthentication) + ]; + } + + public sealed class ConventionMiddleware(RequestDelegate next) + { + private readonly RequestDelegate _next = next; + + public Task InvokeAsync(HttpContext context) => + _next(context); + } + + public sealed class BaseMiddleware : AuthKitMiddlewareBase + { + public override Task InvokeAsync(HttpContext context, RequestDelegate next) => next(context); + } + + public sealed class InterfaceMiddleware : IAuthKitMiddleware + { + public Task InvokeAsync(HttpContext context, RequestDelegate next) => next(context); + } + + public sealed class MissingRequestDelegateMiddleware + { + public Task InvokeAsync(HttpContext context, RequestDelegate next) => next(context); + } + + public sealed class AmbiguousMiddleware : AuthKitMiddlewareBase, IAuthKitMiddleware + { + public override Task InvokeAsync(HttpContext context, RequestDelegate next) => next(context); + } + + public sealed class StaticInvokeMiddleware + { + public StaticInvokeMiddleware(RequestDelegate next) + { + _ = next; + } + + public static Task InvokeAsync(HttpContext context, RequestDelegate next) => next(context); + } + + public sealed class GenericMiddleware + { + public GenericMiddleware(RequestDelegate next) + { + _ = next; + } + + public Task InvokeAsync(HttpContext context, RequestDelegate next) => next(context); + } + + public sealed class VoidInvokeMiddleware + { + public VoidInvokeMiddleware(RequestDelegate next) + { + _ = next; + } + + public void InvokeAsync(HttpContext context, RequestDelegate next) => next(context).GetAwaiter().GetResult(); + } + + public sealed class MultiCtorMiddleware + { + public MultiCtorMiddleware(RequestDelegate next) + { + _ = next; + } + + public MultiCtorMiddleware(RequestDelegate next, string name) + { + _ = next; + _ = name; + } + + public Task InvokeAsync(HttpContext context, RequestDelegate next) => next(context); + } + + public abstract class AbstractMiddleware + { + protected AbstractMiddleware(RequestDelegate next) + { + _ = next; + } + + public Task InvokeAsync(HttpContext context, RequestDelegate next) => next(context); + } +} diff --git a/tests/Host/PluginApplicationConfigurationTests.cs b/tests/Host/PluginApplicationConfigurationTests.cs index 3673ea7..cf6293b 100644 --- a/tests/Host/PluginApplicationConfigurationTests.cs +++ b/tests/Host/PluginApplicationConfigurationTests.cs @@ -1,6 +1,6 @@ -using AuthKit.Plugins.Abstractions; using AuthKit.Plugins.Abstractions.Contracts; using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using AuthKit.Plugins.Abstractions.Pipeline; using Host.Plugins.Configuration; using Host.Plugins.Loading; using Microsoft.AspNetCore.Builder; @@ -192,4 +192,149 @@ public void ConfigureApplication(IApplicationBuilder application) { } } + + [Fact] + public void ConfigurePluginMiddlewares_SkipsGrpcTransportEntries() + { + var app = new ApplicationBuilder(new ServiceCollection().BuildServiceProvider()); + + // Must not throw at Build(): a gRPC Interceptor has no Invoke/InvokeAsync + // and would fail UseMiddleware activation if picked up by the HTTP pipeline. + PluginApplicationConfiguration.ConfigurePluginMiddlewares( + app, [Load(new GrpcOnlyPlugin())], PipelinePosition.BeforeEndpoints); + + var pipeline = app.Build(); + Assert.NotNull(pipeline); + } + + [PluginMetadata("grpc-only-plugin", "1.0.0", [], [], [], description: "gRPC-only middleware test")] + private sealed class GrpcOnlyPlugin : IAuthKitPlugin + { + public IReadOnlyList Middlewares => + [ + new(typeof(GrpcOnlyInterceptor), PipelinePosition.BeforeEndpoints, Transport: AuthKitTransport.Grpc) + ]; + } + + public sealed class GrpcOnlyInterceptor : Grpc.Core.Interceptors.Interceptor + { + } + + [Fact] + public void ConfigurePluginMiddlewares_InvalidPosition_Throws() + { + var app = new ApplicationBuilder(new ServiceCollection().BuildServiceProvider()); + + Assert.Throws(() => + PluginApplicationConfiguration.ConfigurePluginMiddlewares( + app, [], (PipelinePosition)999)); + } + + [Fact] + public void ConfigurePluginMiddlewares_NullMiddlewareType_Throws() + { + var app = new ApplicationBuilder(new ServiceCollection().BuildServiceProvider()); + var plugin = new DeclarativePlugin( + "null-plugin", [new PluginMiddleware(null!, PipelinePosition.BeforeEndpoints)]); + + var ex = Assert.Throws(() => + PluginApplicationConfiguration.ConfigurePluginMiddlewares( + app, [Load(plugin)], PipelinePosition.BeforeEndpoints)); + Assert.Contains("null type", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ConfigurePluginMiddlewares_UnsupportedEntryPosition_IsIgnored() + { + var app = new ApplicationBuilder(new ServiceCollection().BuildServiceProvider()); + var plugin = new DeclarativePlugin( + "bad-position-plugin", + [new PluginMiddleware(typeof(RecordedInterfaceMiddleware), (PipelinePosition)999)]); + + // Entry positions outside the enum can never match a validated request + // position, so they are filtered out rather than registered. + PluginApplicationConfiguration.ConfigurePluginMiddlewares( + app, [Load(plugin)], PipelinePosition.BeforeEndpoints); + + Assert.NotNull(app.Build()); + } + + [Fact] + public void ConfigurePluginMiddlewares_RegistrationFailure_NamesPlugin() + { + var app = new ApplicationBuilder(new ServiceCollection().BuildServiceProvider()); + var plugin = new DeclarativePlugin( + "broken-plugin", [new PluginMiddleware(typeof(int), PipelinePosition.BeforeEndpoints)]); + + var ex = Assert.Throws(() => + PluginApplicationConfiguration.ConfigurePluginMiddlewares( + app, [Load(plugin)], PipelinePosition.BeforeEndpoints)); + Assert.Contains("broken-plugin", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ConfigurePluginMiddlewares_ExecutesAllHttpModelsInOrder() + { + var markers = new List(); + var services = new ServiceCollection(); + services.AddSingleton(markers); + var app = new ApplicationBuilder(services.BuildServiceProvider()); + var plugin = new DeclarativePlugin("http-plugin", + [ + new PluginMiddleware(typeof(RecordedConventionMiddleware), PipelinePosition.BeforeEndpoints, Order: 2), + new PluginMiddleware(typeof(RecordedInterfaceMiddleware), PipelinePosition.BeforeEndpoints, Order: 0), + new PluginMiddleware(typeof(RecordedBaseMiddleware), PipelinePosition.BeforeEndpoints, Order: 1), + ]); + + PluginApplicationConfiguration.ConfigurePluginMiddlewares( + app, [Load(plugin)], PipelinePosition.BeforeEndpoints); + + var pipeline = app.Build(); + var context = new DefaultHttpContext + { + RequestServices = app.ApplicationServices, + }; + await pipeline(context); + + Assert.Equal(["interface", "base", "convention"], markers); + } + + [PluginMetadata("declarative-plugin", "1.0.0", [], [], [], description: "Declarative middleware test")] + private sealed class DeclarativePlugin(string id, IReadOnlyList middlewares) : IAuthKitPlugin + { + public string Id => id; + + public IReadOnlyList Middlewares => middlewares; + } + + public sealed class RecordedInterfaceMiddleware(List markers) + : AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitMiddleware + { + public Task InvokeAsync(HttpContext context, RequestDelegate next) + { + markers.Add("interface"); + return next(context); + } + } + + public sealed class RecordedBaseMiddleware(List markers) + : AuthKit.Plugins.Abstractions.Contracts.PluginContract.AuthKitMiddlewareBase + { + public override Task InvokeAsync(HttpContext context, RequestDelegate next) + { + markers.Add("base"); + return next(context); + } + } + + public sealed class RecordedConventionMiddleware(RequestDelegate next, List markers) + { + private readonly RequestDelegate _next = next; + + public Task InvokeAsync(HttpContext context) + { + markers.Add("convention"); + return _next(context); + } + } } \ No newline at end of file diff --git a/tests/Host/PluginContractValidatorTests.cs b/tests/Host/PluginContractValidatorTests.cs index 9c09a4f..cad070b 100644 --- a/tests/Host/PluginContractValidatorTests.cs +++ b/tests/Host/PluginContractValidatorTests.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Xunit; +using HostPluginContractValidator = Host.Plugins.Contract.PluginContractValidator; using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; namespace AuthKit.Host.Tests; @@ -71,7 +72,7 @@ public void Log( [InlineData(AuthKitSecuritySchemeType.OpenIdConnect)] public void ImplementedSchemeTypes_AreAccepted(AuthKitSecuritySchemeType type) { - PluginContractValidator.Validate(new FakePlugin(Describe(type)), Logger); + HostPluginContractValidator.Validate(new FakePlugin(Describe(type)), Logger); } [Theory] @@ -82,7 +83,7 @@ public void ImplementedSchemeTypes_AreAccepted(AuthKitSecuritySchemeType type) [InlineData(AuthKitApiKeyLocation.Body)] public void HostLocations_AreAccepted(AuthKitApiKeyLocation location) { - PluginContractValidator.Validate( + HostPluginContractValidator.Validate( new FakePlugin(Describe(AuthKitSecuritySchemeType.ApiKey, location)), Logger); } @@ -94,14 +95,14 @@ public void HostLocations_AreAccepted(AuthKitApiKeyLocation location) public void UnimplementedSchemeTypes_AreExplicitlyRejected(AuthKitSecuritySchemeType type) { Assert.Throws(() => - PluginContractValidator.Validate(new FakePlugin(Describe(type)), Logger)); + HostPluginContractValidator.Validate(new FakePlugin(Describe(type)), Logger)); } [Fact] public void UnknownSchemeType_IsExplicitlyRejected() { var ex = Assert.Throws(() => - PluginContractValidator.Validate( + HostPluginContractValidator.Validate( new FakePlugin(Describe((AuthKitSecuritySchemeType)999)), Logger)); Assert.Contains("unknown", ex.Message, StringComparison.OrdinalIgnoreCase); @@ -112,7 +113,7 @@ public void UnknownSchemeType_IsExplicitlyRejected() public void UnknownApiKeyLocation_IsExplicitlyRejected() { var ex = Assert.Throws(() => - PluginContractValidator.Validate( + HostPluginContractValidator.Validate( new FakePlugin(Describe(AuthKitSecuritySchemeType.ApiKey, (AuthKitApiKeyLocation)999)), Logger)); @@ -123,10 +124,10 @@ public void UnknownApiKeyLocation_IsExplicitlyRejected() [Fact] public void CustomValidator_CanEnableAdditionalSchemeTypes() { - var custom = PluginContractValidator.CreateCustom( + var custom = HostPluginContractValidator.CreateCustom( supportedSchemeTypes: [ - .. PluginContractValidator.SupportedSchemeTypes, + .. HostPluginContractValidator.SupportedSchemeTypes, AuthKitSecuritySchemeType.Basic ]); @@ -136,7 +137,7 @@ public void CustomValidator_CanEnableAdditionalSchemeTypes() [Fact] public void CustomValidator_CanRestrictLocations() { - var custom = PluginContractValidator.CreateCustom( + var custom = HostPluginContractValidator.CreateCustom( supportedApiKeyLocations: [AuthKitApiKeyLocation.Header]); Assert.Throws(() => @@ -149,11 +150,11 @@ public void CustomValidator_CanRestrictLocations() public void NoFallback_CustomAndSessionAreNotTreatedAsSupported() { Assert.Throws(() => - PluginContractValidator.Validate( + HostPluginContractValidator.Validate( new FakePlugin(Describe(AuthKitSecuritySchemeType.Session)), Logger)); Assert.Throws(() => - PluginContractValidator.Validate( + HostPluginContractValidator.Validate( new FakePlugin(Describe(AuthKitSecuritySchemeType.Custom)), Logger)); } @@ -161,10 +162,10 @@ public void NoFallback_CustomAndSessionAreNotTreatedAsSupported() public void CustomScheme_WithoutUsageDocumentation_EmitsWarning() { var logger = new RecordingLogger(); - var custom = PluginContractValidator.CreateCustom( + var custom = HostPluginContractValidator.CreateCustom( supportedSchemeTypes: [ - .. PluginContractValidator.SupportedSchemeTypes, + .. HostPluginContractValidator.SupportedSchemeTypes, AuthKitSecuritySchemeType.Custom ]); @@ -180,10 +181,10 @@ public void CustomScheme_WithoutUsageDocumentation_EmitsWarning() public void CustomScheme_WithUsageDocumentation_DoesNotEmitWarning() { var logger = new RecordingLogger(); - var custom = PluginContractValidator.CreateCustom( + var custom = HostPluginContractValidator.CreateCustom( supportedSchemeTypes: [ - .. PluginContractValidator.SupportedSchemeTypes, + .. HostPluginContractValidator.SupportedSchemeTypes, AuthKitSecuritySchemeType.Custom ]); @@ -199,10 +200,10 @@ public void CustomScheme_WithUsageDocumentation_DoesNotEmitWarning() public void CustomWarning_NeverMapsCustomToAnotherScheme() { var logger = new RecordingLogger(); - var custom = PluginContractValidator.CreateCustom( + var custom = HostPluginContractValidator.CreateCustom( supportedSchemeTypes: [ - .. PluginContractValidator.SupportedSchemeTypes, + .. HostPluginContractValidator.SupportedSchemeTypes, AuthKitSecuritySchemeType.Custom ]); @@ -211,4 +212,4 @@ public void CustomWarning_NeverMapsCustomToAnotherScheme() Assert.Contains(logger.Warnings, w => w.Contains("never mapped", StringComparison.OrdinalIgnoreCase)); } -} \ No newline at end of file +} diff --git a/tests/Host/PluginGrpcConfigurationTests.cs b/tests/Host/PluginGrpcConfigurationTests.cs new file mode 100644 index 0000000..8a14422 --- /dev/null +++ b/tests/Host/PluginGrpcConfigurationTests.cs @@ -0,0 +1,137 @@ +using AuthKit.Plugins.Abstractions.Contracts.PluginContract; +using AuthKit.Plugins.Abstractions.Pipeline; +using Grpc.AspNetCore.Server; +using Host.Plugins.Configuration; +using Host.Plugins.Loading; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace AuthKit.Host.Tests; + +public sealed class PluginGrpcConfigurationTests +{ + [Fact] + public void GrpcEntries_AreOrderedByPositionThenOrderThenPluginIdThenIndex() + { + var plugins = new List + { + Load("b-plugin", + new(typeof(FakeInterceptor), PipelinePosition.BeforeEndpoints, Order: 5, Transport: AuthKitTransport.Grpc), + new(typeof(FakeInterceptor), PipelinePosition.BeforeRouting, Order: 5, Transport: AuthKitTransport.Grpc)), + Load("a-plugin", + new(typeof(FakeInterceptor), PipelinePosition.BeforeEndpoints, Order: 5, Transport: AuthKitTransport.Grpc), + new(typeof(FakeInterceptor), PipelinePosition.BeforeEndpoints, Order: 1, Transport: AuthKitTransport.Grpc)), + }; + + var ordered = PluginGrpcConfiguration.OrderGrpcInterceptors(plugins); + + Assert.Equal(4, ordered.Count); + // Semantic position first. + Assert.Equal(PipelinePosition.BeforeRouting, ordered[0].Entry.Position); + // Then Order within BeforeEndpoints. + Assert.Equal("a-plugin", ordered[1].PluginId); + Assert.Equal(1, ordered[1].Entry.Order); + // Same position+order: stable PluginId, then declaration index. + Assert.Equal("a-plugin", ordered[2].PluginId); + Assert.Equal("b-plugin", ordered[3].PluginId); + } + + [Fact] + public void HttpEntriesAndDisabledEntries_AreExcluded() + { + var plugins = new List + { + Load("http-plugin", + new(typeof(FakeInterceptor), PipelinePosition.BeforeRouting, Transport: AuthKitTransport.Http), + new(typeof(FakeInterceptor), PipelinePosition.BeforeRouting, IsMiddlewareEnabled: false, Transport: AuthKitTransport.Grpc)), + }; + + var ordered = PluginGrpcConfiguration.OrderGrpcInterceptors(plugins); + + Assert.Empty(ordered); + } + + private static LoadedPlugin Load(string id, params PluginMiddleware[] middlewares) => + new(new FakePlugin(id, middlewares), typeof(PluginGrpcConfigurationTests).Assembly, "/test"); + + [Fact] + public void AddPluginGrpcInterceptors_RegistersAndComposesChain() + { + var services = new ServiceCollection(); + var plugins = new List + { + Load("grpc-plugin", + new PluginMiddleware(typeof(FakeInterceptor), PipelinePosition.BeforeAuthentication, Transport: AuthKitTransport.Grpc)), + }; + + PluginGrpcConfiguration.AddPluginGrpcInterceptors(services, plugins, NullLogger.Instance); + + var provider = services.BuildServiceProvider(); + Assert.NotNull(provider.GetService(typeof(FakeInterceptor))); + var options = provider.GetRequiredService>().Value; + Assert.Contains(typeof(FakeInterceptor), options.Interceptors.Select(r => r.Type)); + } + + [Fact] + public void AddPluginGrpcInterceptors_NullMiddlewareType_Throws() + { + var services = new ServiceCollection(); + var plugins = new List + { + Load("bad-plugin", + new PluginMiddleware(null!, PipelinePosition.BeforeRouting, Transport: AuthKitTransport.Grpc)), + }; + + Assert.Throws(() => + PluginGrpcConfiguration.AddPluginGrpcInterceptors(services, plugins, NullLogger.Instance)); + } + + [Fact] + public void AddPluginGrpcInterceptors_NonInterceptorType_Throws() + { + var services = new ServiceCollection(); + var plugins = new List + { + Load("bad-plugin", + new PluginMiddleware(typeof(string), PipelinePosition.BeforeRouting, Transport: AuthKitTransport.Grpc)), + }; + + var ex = Assert.Throws(() => + PluginGrpcConfiguration.AddPluginGrpcInterceptors(services, plugins, NullLogger.Instance)); + Assert.Contains("Interceptor", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void AddPluginGrpcInterceptors_SkipsHttpEntriesWithWarning() + { + var services = new ServiceCollection(); + var plugins = new List + { + Load("http-plugin", + new PluginMiddleware(typeof(FakeInterceptor), PipelinePosition.BeforeRouting, Name: "named-http"), + new PluginMiddleware(typeof(FakeInterceptor), PipelinePosition.BeforeRouting), + new PluginMiddleware(typeof(FakeInterceptor), PipelinePosition.BeforeRouting, IsMiddlewareEnabled: false)), + }; + + PluginGrpcConfiguration.AddPluginGrpcInterceptors(services, plugins, NullLogger.Instance); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + Assert.Empty(options.Interceptors); + } + + private sealed class FakePlugin(string id, IReadOnlyList middlewares) : IAuthKitPlugin + { + public string Id => id; + + public string Name => id; + + public IReadOnlyList Middlewares => middlewares; + } + + public sealed class FakeInterceptor : Grpc.Core.Interceptors.Interceptor + { + } +} diff --git a/tools/AuthKit.PluginContractValidator/AuthKit.PluginContractValidator.csproj b/tools/AuthKit.PluginContractValidator/AuthKit.PluginContractValidator.csproj index feed372..a179266 100644 --- a/tools/AuthKit.PluginContractValidator/AuthKit.PluginContractValidator.csproj +++ b/tools/AuthKit.PluginContractValidator/AuthKit.PluginContractValidator.csproj @@ -11,6 +11,7 @@ + diff --git a/tools/AuthKit.PluginContractValidator/src/Rules/MiddlewareRule.cs b/tools/AuthKit.PluginContractValidator/src/Rules/MiddlewareRule.cs index 44d8227..6eafb5e 100644 --- a/tools/AuthKit.PluginContractValidator/src/Rules/MiddlewareRule.cs +++ b/tools/AuthKit.PluginContractValidator/src/Rules/MiddlewareRule.cs @@ -1,23 +1,24 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using AuthKit.PluginContractValidator.Core; using AuthKit.Plugins.Abstractions.Contracts; using AuthKit.Plugins.Abstractions.Contracts.PluginContract; +using AuthKit.Plugins.Abstractions.Pipeline; using Microsoft.AspNetCore.Http; namespace AuthKit.PluginContractValidator.Rules; /// -/// Ensures a contributed middleware type follows the AuthKit middleware convention. +/// Ensures contributed middleware types follow one of the supported AuthKit +/// middleware models. /// /// -/// The middleware type must expose a constructor accepting -/// and a public InvokeAsync method -/// whose first parameter is and whose return type is -/// . +/// The validator distinguishes conventional ASP.NET Core middleware, +/// , and . /// public sealed class MiddlewareRule : IPluginContractRule { @@ -25,61 +26,243 @@ public sealed class MiddlewareRule : IPluginContractRule public string Name => "Middleware"; /// - /// Validates the middleware type contributed by - /// , if any. + /// Validates every legacy and declarative middleware type contributed by + /// the plugin. /// public Task> ValidateAsync( LoadedPlugin plugin, CancellationToken cancellationToken = default) { var errors = new List(); - var middlewareType = plugin.Instance.MiddlewareType; + var pluginName = plugin.Instance.Name; - if (middlewareType is null) - return Task.FromResult>(errors); + if (plugin.Instance.MiddlewareType is { } legacyMiddlewareType) + ValidateLegacyMiddlewareType(pluginName, legacyMiddlewareType, errors); - if (!HasRequestDelegateConstructor(middlewareType)) + foreach (var middleware in plugin.Instance.Middlewares ?? []) { - errors.Add($"middleware: MiddlewareType '{middlewareType.Name}' " + - "must have constructor accepting RequestDelegate."); + if (middleware.MiddlewareType is null) + { + errors.Add($"middleware: Plugin '{pluginName}' declares PluginMiddleware with a null MiddlewareType."); + continue; + } + + if (!Enum.IsDefined(middleware.Transport)) + { + errors.Add($"middleware: Plugin '{pluginName}' declares middleware '{FormatTypeName(middleware.MiddlewareType)}' with undefined transport '{(int)middleware.Transport}'."); + continue; + } + + if (!Enum.IsDefined(middleware.Position)) + { + errors.Add($"middleware: Plugin '{pluginName}' declares middleware '{FormatTypeName(middleware.MiddlewareType)}' with undefined position '{(int)middleware.Position}'."); + continue; + } + + if (middleware.Transport == AuthKitTransport.Grpc) + ValidateGrpcInterceptorType(pluginName, middleware.MiddlewareType, errors); + else + ValidateMiddlewareType(pluginName, middleware.MiddlewareType, errors); } - var invokeMethod = middlewareType.GetMethod( - "InvokeAsync", - System.Reflection.BindingFlags.Public | - System.Reflection.BindingFlags.Instance); + return Task.FromResult>(errors); + } + + private static void ValidateGrpcInterceptorType(string pluginName, Type middlewareType, List errors) + { + var typeErrors = new List(); + + ValidateCommonShape(middlewareType, typeErrors); - if (invokeMethod is null) + if (!typeof(Grpc.Core.Interceptors.Interceptor).IsAssignableFrom(middlewareType)) { - errors.Add($"middleware: MiddlewareType '{middlewareType.Name}' " + - "must expose public instance InvokeAsync method."); + typeErrors.Add( + "targets the gRPC transport but is not a Grpc.Core.Interceptors.Interceptor subclass. " + + "HTTP middleware (IAuthKitMiddleware, AuthKitMiddlewareBase, convention middleware) " + + "cannot run on gRPC; declare Transport = Http or provide an Interceptor instead. " + + "No automatic HttpContext bridge is provided."); + } + + errors.AddRange(typeErrors.Select(error => + $"middleware: Plugin '{pluginName}' gRPC interceptor '{FormatTypeName(middlewareType)}' is invalid: {error}")); + } + + private static void ValidateLegacyMiddlewareType(string pluginName, Type middlewareType, List errors) + { + var typeErrors = new List(); + ValidateCommonShape(middlewareType, typeErrors); + + if (typeof(AuthKitMiddlewareBase).IsAssignableFrom(middlewareType) + || typeof(IAuthKitMiddleware).IsAssignableFrom(middlewareType)) + typeErrors.Add("legacy MiddlewareType supports only convention middleware; declare AuthKit models through Middlewares."); + else + ValidateConventionMiddleware(middlewareType, typeErrors); + + errors.AddRange(typeErrors.Select(error => + $"middleware: Plugin '{pluginName}' legacy middleware '{FormatTypeName(middlewareType)}' is invalid: {error}")); + } - return Task.FromResult>(errors); + private static void ValidateMiddlewareType(string pluginName, Type middlewareType, List errors) + { + var typeErrors = new List(); + + ValidateCommonShape(middlewareType, typeErrors); + + var isBaseClassModel = typeof(AuthKitMiddlewareBase).IsAssignableFrom(middlewareType); + var isInterfaceModel = typeof(IAuthKitMiddleware).IsAssignableFrom(middlewareType); + + if (isBaseClassModel && isInterfaceModel) + { + typeErrors.Add("matches both AuthKitMiddlewareBase and IAuthKitMiddleware models; choose one model."); + } + else if (isBaseClassModel) + { + ValidateDiActivatedMiddleware(middlewareType, nameof(AuthKitMiddlewareBase), typeErrors); + ValidateAuthKitBaseMiddleware(middlewareType, typeErrors); + } + else if (isInterfaceModel) + { + ValidateDiActivatedMiddleware(middlewareType, nameof(IAuthKitMiddleware), typeErrors); + ValidateInterfaceMiddleware(middlewareType, typeErrors); } + else + { + ValidateConventionMiddleware(middlewareType, typeErrors); + } + + errors.AddRange(typeErrors.Select(error => + $"middleware: Plugin '{pluginName}' middleware '{FormatTypeName(middlewareType)}' is invalid: {error}")); + } + + private static void ValidateCommonShape(Type middlewareType, List errors) + { + if (!IsPubliclyVisible(middlewareType)) + errors.Add("type must be public."); + + if (!middlewareType.IsClass) + errors.Add("type must be a concrete class."); + + if (middlewareType.IsAbstract) + errors.Add("type must not be abstract."); - if (invokeMethod.ReturnType != typeof(Task)) + if (middlewareType.ContainsGenericParameters || middlewareType.IsGenericType) + errors.Add("generic middleware types are not supported."); + } + + private static void ValidateConventionMiddleware(Type middlewareType, List errors) + { + var activationConstructors = middlewareType.GetConstructors() + .Where(constructor => ConstructorStartsWith(constructor)) + .ToArray(); + + if (activationConstructors.Length == 0) { - errors.Add($"middleware: InvokeAsync on '{middlewareType.Name}' " + - "must return Task."); + errors.Add("does not implement AuthKitMiddlewareBase or IAuthKitMiddleware, " + + "and its constructor does not take RequestDelegate as the first parameter."); + } + else if (activationConstructors.Length > 1) + { + errors.Add("declares multiple public constructors with RequestDelegate as the first parameter."); } - var parameters = invokeMethod.GetParameters(); + ValidateInvokeMethod( + middlewareType, + static method => method.Name is "InvokeAsync" or "Invoke", + IsValidConventionInvokeMethod, + "must expose public instance InvokeAsync(HttpContext, ...dependencies) or Invoke(HttpContext, ...dependencies) returning Task.", + errors); + } - if (parameters.Length == 0 || - parameters[0].ParameterType != typeof(HttpContext)) + private static void ValidateDiActivatedMiddleware(Type middlewareType, string modelName, List errors) + { + if (middlewareType.GetConstructors().Length == 0) + errors.Add($"{modelName} middleware must expose at least one public constructor for DI activation."); + } + + private static void ValidateAuthKitBaseMiddleware(Type middlewareType, List errors) + { + var invokeAsync = middlewareType.GetMethod( + "InvokeAsync", + BindingFlags.Public | BindingFlags.Instance, + binder: null, + types: [typeof(HttpContext), typeof(RequestDelegate)], + modifiers: null); + + if (invokeAsync is null || invokeAsync.DeclaringType == typeof(AuthKitMiddlewareBase)) { - errors.Add($"middleware: InvokeAsync on '{middlewareType.Name}' " - + "must take HttpContext as its first parameter."); + errors.Add("must override InvokeAsync(HttpContext, RequestDelegate)."); + return; } - return Task.FromResult>(errors); + ValidateInvokeMethod( + middlewareType, + static method => method.Name == "InvokeAsync", + IsValidAuthKitInvokeMethod, + "must override InvokeAsync(HttpContext, RequestDelegate) returning Task.", + errors); } - private static bool HasRequestDelegateConstructor(Type middlewareType) + private static void ValidateInterfaceMiddleware(Type middlewareType, List errors) => + ValidateInvokeMethod( + middlewareType, + static method => method.Name == "InvokeAsync", + IsValidAuthKitInvokeMethod, + "must provide public instance InvokeAsync(HttpContext, RequestDelegate) returning Task.", + errors); + + private static void ValidateInvokeMethod( + Type middlewareType, + Func namePredicate, + Func signaturePredicate, + string missingMessage, + List errors) { - return middlewareType - .GetConstructors() - .Any(constructor => constructor.GetParameters() - .Any(parameter => parameter.ParameterType == typeof(RequestDelegate))); + var allCandidates = middlewareType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static) + .Where(namePredicate) + .ToArray(); + + if (allCandidates.Any(method => method.IsStatic)) + errors.Add("static Invoke or InvokeAsync methods are not valid middleware entry points."); + + var validMethod = allCandidates.Any(method => !method.IsStatic && signaturePredicate(method)); + + if (!validMethod) + errors.Add(missingMessage); + } + + private static bool ConstructorStartsWith(ConstructorInfo constructor) + { + var parameters = constructor.GetParameters(); + return parameters.Length > 0 && parameters[0].ParameterType == typeof(TParameter); + } + + private static bool HasExactParameters(MethodInfo method, params Type[] expected) => + method.GetParameters().Select(parameter => parameter.ParameterType).SequenceEqual(expected); + + private static bool IsValidConventionInvokeMethod(MethodInfo method) + { + var parameters = method.GetParameters(); + + return method.ReturnType == typeof(Task) + && parameters.Length > 0 + && parameters[0].ParameterType == typeof(HttpContext) + && parameters.Skip(1).All(p => p.ParameterType != typeof(RequestDelegate)); + } + + private static bool IsValidAuthKitInvokeMethod(MethodInfo method) => + method.ReturnType == typeof(Task) + && HasExactParameters(method, typeof(HttpContext), typeof(RequestDelegate)); + + private static bool IsPubliclyVisible(Type type) + { + if (type.IsPublic) + return true; + + if (!type.IsNestedPublic) + return false; + + return type.DeclaringType is not null && IsPubliclyVisible(type.DeclaringType); } -} \ No newline at end of file + + private static string FormatTypeName(Type type) => type.FullName ?? type.Name; +}