Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ src/Plugins/Solutions/DevTools/manifest.json
issues/
*.DotSettings.user
Docs/package-lock.json
TestResults
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions src/Host/Configuration/Grpc/GrpcConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using Host.Grpc;
using Host.Plugins.Configuration;
using Host.Plugins.Loading;
using Microsoft.Extensions.Logging;

namespace Host.Configuration.Grpc;

Expand All @@ -18,17 +21,25 @@ namespace Host.Configuration.Grpc;
public static class GrpcConfiguration
{
/// <summary>
/// Registers gRPC services with the dependency injection container.
/// Registers gRPC services with the dependency injection container,
/// including plugin contributed interceptors.
/// </summary>
/// <param name="services">The service collection used to register gRPC services.</param>
/// <param name="plugins">The plugins loaded during application startup.</param>
/// <param name="logger">Logger for gRPC composition diagnostics.</param>
/// <returns>The configured <see cref="IServiceCollection"/> instance.</returns>
public static IServiceCollection AddGrpcServices(this IServiceCollection services)
public static IServiceCollection AddGrpcServices(
this IServiceCollection services,
IReadOnlyList<LoadedPlugin> plugins,
ILogger logger)
{
services.AddGrpc(options =>
{
//options.Interceptors.Add<ExceptionHandlingInterceptor>();
});

services.AddPluginGrpcInterceptors(plugins, logger);

return services;
}

Expand Down
38 changes: 27 additions & 11 deletions src/Host/Configuration/Pipeline/AppMiddlewareConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -14,7 +12,7 @@ namespace Host.Configuration.Pipeline;
/// </summary>
/// <remarks>
/// <para>
/// Configures routing, validation and exception handling, plugin-provided
/// Configures routing, validation and exception handling, plugin provided
/// middleware, and authentication and authorization.
/// </para>
/// <para>
Expand All @@ -40,25 +38,43 @@ public static WebApplication ConfigureMiddleware(
IReadOnlyList<LoadedPlugin> 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<ValidationExceptionMiddleware>();
app.UseMiddleware<ExceptionHandlingMiddleware>();

PluginApplicationConfiguration.ConfigureLegacyMiddleware(app, plugins);
PluginApplicationConfiguration.ConfigurePipeline(app, plugins, PluginPipelinePosition.BeforeAuthentication);
ConfigurePluginSlot(PluginPipelinePosition.BeforeAuthentication, PipelinePosition.BeforeAuthentication);

app.UseMiddleware<ApiKeyCredentialExtractor>();

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);
}
}
107 changes: 97 additions & 10 deletions src/Host/Plugins/Configuration/PluginApplicationConfiguration.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -11,15 +12,15 @@
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="PluginPipelinePosition"/> and then by
/// plugin identifier using an ordinal comparison.
/// </para>
/// <para>
/// Plugins that do not implement a given hook are skipped. The newer
/// Plugins that do not implement given hook are skipped. The newer
/// <c>ConfigureApplication</c> and <c>ConfigurePipeline</c> hooks take
/// precedence over the legacy <c>MiddlewareType</c> entry point, which is
/// applied only as a compatibility fallback.
/// applied only as compatibility fallback.
/// </para>
/// </remarks>
internal static class PluginApplicationConfiguration
Expand Down Expand Up @@ -56,12 +57,12 @@
/// <param name="plugins">The plugins loaded during application startup.</param>
/// <param name="position">The pipeline position to run hooks for.</param>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="position"/> is not a defined
/// Thrown when <paramref name="position"/> is not defined
/// <see cref="PluginPipelinePosition"/> value.
/// </exception>
/// <exception cref="InvalidOperationException">
/// Thrown when a plugin that implements the pipeline hook declares a
/// <c>PipelinePosition</c> that is not a defined enum value.
/// Thrown when plugin that implements the pipeline hook declares a
/// <c>PipelinePosition</c> that is not defined enum value.
/// </exception>
public static void ConfigurePipeline(
IApplicationBuilder application,
Expand Down Expand Up @@ -134,6 +135,92 @@
}
}

/// <summary>
/// Inserts declarative <see cref="PluginMiddleware"/> entries for one
/// <see cref="PipelinePosition"/> in deterministic order
/// (Order -> stable plugin Id -> declaration index).
/// Disabled entries are skipped without side effects.
/// <see cref="IAuthKitMiddleware"/> and <see cref="AuthKitMiddlewareBase"/>
/// implementations are resolved from the request
/// service provider (single request scope); other types use
/// <c>UseMiddleware</c> activation.
/// </summary>
public static void ConfigurePluginMiddlewares(
IApplicationBuilder application,
IReadOnlyList<LoadedPlugin> 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);
}
Comment on lines +175 to +178
}
}

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<IAuthKitMiddleware>(context, middlewareType);
await middleware.InvokeAsync(context, next);
});

return;
}

if (typeof(AuthKitMiddlewareBase).IsAssignableFrom(middlewareType))
{
application.Use(async (context, next) =>
{
var middleware = ResolvePluginMiddleware<AuthKitMiddlewareBase>(context, middlewareType);
await middleware.InvokeAsync(context, next);
});

return;
}

application.UseMiddleware(middlewareType);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private static TMiddleware ResolvePluginMiddleware<TMiddleware>(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}'.");

/// <summary>
/// Orders plugins by pipeline position and then by plugin identifier.
/// </summary>
Expand Down Expand Up @@ -165,14 +252,14 @@
}

/// <summary>
/// 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.
/// </summary>
/// <param name="plugin">The plugin to inspect.</param>
/// <param name="methodName">The name of the interface method to look up.</param>
/// <param name="parameterTypes">The parameter types that identify the overload.</param>
/// <returns>
/// <c>true</c> when the plugin overrides the hook; otherwise, <c>false</c>.
/// <c>true</c> when the plugin overrides the hook otherwise, <c>false</c>.
/// </returns>
private static bool HasImplementation(IAuthKitPlugin plugin, string methodName, params Type[] parameterTypes)
{
Expand All @@ -185,4 +272,4 @@

return method is not null && method.DeclaringType != typeof(IAuthKitPlugin);
}
}
}
107 changes: 107 additions & 0 deletions src/Host/Plugins/Configuration/PluginGrpcConfiguration.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Composes plugin contributed gRPC interceptors into the host interceptor chain.
/// </summary>
/// <remarks>
/// <para>
/// Only <see cref="PluginMiddleware"/> entries declared with
/// <see cref="AuthKitTransport.Grpc"/> are composed here. The host never guesses
/// transport by reflection: entries targeting <see cref="AuthKitTransport.Http"/>
/// are skipped on the gRPC transport with an explicit warning (no automatic
/// <c>HttpContext</c> -> <c>ServerCallContext</c> bridge).
/// </para>
/// <para>
/// <see cref="PipelinePosition"/> 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; <c>AfterEndpointExecution</c> 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.
/// </para>
/// <para>
/// 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
/// <see cref="Interceptor"/> overloads no custom streaming pipeline exists.
/// </para>
/// </remarks>
internal static class PluginGrpcConfiguration
{
/// <summary>
/// Registers every enabled gRPC transport interceptor and composes the
/// interceptor chain in semantic position order.
/// </summary>
public static IServiceCollection AddPluginGrpcInterceptors(
this IServiceCollection services,
IReadOnlyList<LoadedPlugin> 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<global::Grpc.AspNetCore.Server.GrpcServiceOptions>(options =>
{
foreach (var (_, entry, _) in ordered)
options.Interceptors.Add(entry.MiddlewareType!);
});

return services;
}

/// <summary>
/// Orders enabled gRPC transport entries by semantic position, then
/// Order -> stable plugin Id -> declaration index.
/// </summary>
internal static IReadOnlyList<(string PluginId, PluginMiddleware Entry, int Index)> OrderGrpcInterceptors(
IReadOnlyList<LoadedPlugin> 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<LoadedPlugin> 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 ?? "<unknown>");
}
Comment on lines +93 to +104
}
}
}
Loading
Loading