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