From c46cc52d76b158664720e1e50b846e99cd702c61 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Sun, 13 Sep 2026 22:30:24 +0200 Subject: [PATCH 1/7] feat(plugin): add health models and tests --- .../Abstractions/Models/PluginHealthResult.cs | 58 +++++++++++++++++++ .../Abstractions/Models/PluginHealthStatus.cs | 33 +++++++++++ .../Abstractions/PluginHealthResultTests.cs | 49 ++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 src/Plugins/Abstractions/Models/PluginHealthResult.cs create mode 100644 src/Plugins/Abstractions/Models/PluginHealthStatus.cs create mode 100644 tests/Plugins/Abstractions/PluginHealthResultTests.cs diff --git a/src/Plugins/Abstractions/Models/PluginHealthResult.cs b/src/Plugins/Abstractions/Models/PluginHealthResult.cs new file mode 100644 index 0000000..1542c1f --- /dev/null +++ b/src/Plugins/Abstractions/Models/PluginHealthResult.cs @@ -0,0 +1,58 @@ +namespace AuthKit.Plugins.Abstractions.Models; + +/// +/// Represents structured health observation reported by an AuthKit plugin. +/// +/// +/// +/// provides the strongly typed health classification. The +/// optional and members add context but +/// must not redefine or override that classification. +/// +/// +/// is owned by the plugin and may contain plugin-specific +/// diagnostic values such as dependency names, endpoint information, or queue +/// depth. The host may serialize this data without assigning it health semantics. +/// +/// +public sealed record PluginHealthResult +{ + /// + /// Initializes a structured plugin health result. + /// + /// The strongly typed operational health state. + /// An optional human-readable explanation of the health state. + /// Optional plugin-owned diagnostic data. + public PluginHealthResult( + PluginHealthStatus status, + string? reason = null, + IReadOnlyDictionary? data = null) + { + Status = status; + Reason = reason; + Data = data; + } + + /// + /// Gets the strongly typed operational health state. + /// + public PluginHealthStatus Status { get; init; } + + /// + /// Gets the optional human-readable explanation of the health state. + /// + /// + /// A reason is not required when is + /// . + /// + public string? Reason { get; init; } + + /// + /// Gets optional plugin-owned diagnostic data. + /// + /// + /// Diagnostic data is extensible plugin-specific information. It must not + /// be used to replace the strongly typed value of . + /// + public IReadOnlyDictionary? Data { get; init; } +} \ No newline at end of file diff --git a/src/Plugins/Abstractions/Models/PluginHealthStatus.cs b/src/Plugins/Abstractions/Models/PluginHealthStatus.cs new file mode 100644 index 0000000..83c89ca --- /dev/null +++ b/src/Plugins/Abstractions/Models/PluginHealthStatus.cs @@ -0,0 +1,33 @@ +namespace AuthKit.Plugins.Abstractions.Models; + +/// +/// Represents the operational health state reported by an AuthKit plugin. +/// +/// +/// +/// The status is the authoritative health classification consumed by the host. +/// It must not be inferred from the optional reason or diagnostic data. +/// +/// +/// indicates that the plugin remains operational while +/// one or more non-critical capabilities or dependencies are impaired. +/// +/// +public enum PluginHealthStatus +{ + /// + /// The plugin is operating normally. + /// + Healthy = 0, + + /// + /// The plugin remains operational, but one or more capabilities or + /// non-critical dependencies are degraded. + /// + Degraded = 1, + + /// + /// The plugin cannot operate correctly, or a critical dependency has failed. + /// + Unhealthy = 2 +} \ No newline at end of file diff --git a/tests/Plugins/Abstractions/PluginHealthResultTests.cs b/tests/Plugins/Abstractions/PluginHealthResultTests.cs new file mode 100644 index 0000000..1fd8780 --- /dev/null +++ b/tests/Plugins/Abstractions/PluginHealthResultTests.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using AuthKit.Plugins.Abstractions.Models; +using Xunit; + +namespace AuthKit.Plugins.Abstractions.Tests; + +public sealed class PluginHealthResultTests +{ + [Fact] + public void Statuses_AreExplicitlyDistinct() + { + Assert.NotEqual(PluginHealthStatus.Healthy, PluginHealthStatus.Degraded); + Assert.NotEqual(PluginHealthStatus.Degraded, PluginHealthStatus.Unhealthy); + Assert.NotEqual(PluginHealthStatus.Healthy, PluginHealthStatus.Unhealthy); + } + + [Fact] + public void HealthyResult_AllowsOptionalReasonAndData() + { + var result = new PluginHealthResult(PluginHealthStatus.Healthy); + + Assert.Equal(PluginHealthStatus.Healthy, result.Status); + Assert.Null(result.Reason); + Assert.Null(result.Data); + } + + [Fact] + public void Result_PreservesReasonAndDiagnosticDataThroughSerialization() + { + var result = new PluginHealthResult( + PluginHealthStatus.Degraded, + "Cache is unavailable", + new Dictionary + { + ["dependency"] = "cache", + ["retry_count"] = 2 + }); + + var json = JsonSerializer.Serialize(result); + var restored = JsonSerializer.Deserialize(json); + + Assert.NotNull(restored); + Assert.Equal(PluginHealthStatus.Degraded, restored.Status); + Assert.Equal("Cache is unavailable", restored.Reason); + Assert.NotNull(restored.Data); + Assert.Equal("cache", restored.Data["dependency"].ToString()); + Assert.Equal("2", restored.Data["retry_count"].ToString()); + } +} \ No newline at end of file From e9b339f5b0e5092d9fdc1479523332a9d2063fc9 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Sun, 13 Sep 2026 22:51:00 +0200 Subject: [PATCH 2/7] chore(build): adjust plugin validation and Docker build --- .github/workflows/plugin-validation.yml | 1 + Dockerfile | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/plugin-validation.yml b/.github/workflows/plugin-validation.yml index da9ae5e..003048e 100644 --- a/.github/workflows/plugin-validation.yml +++ b/.github/workflows/plugin-validation.yml @@ -42,6 +42,7 @@ jobs: mkdir -p "${{ env.PLUGINS_STAGING }}" for proj in $(find src/Plugins -name '*.csproj' \ -not -path '*/Abstractions/*' \ + -not -path '*/Integrations/*' \ -not -path '*/obj/*' \ -not -path '*/bin/*'); do name=$(basename "$proj" .csproj) diff --git a/Dockerfile b/Dockerfile index 93e6368..5ec53a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ COPY ["src/Plugins/Solutions/DevTokens/DevTokens.csproj", "src/Plugins/Solutions COPY ["src/Plugins/Solutions/DevTools/DevTools.csproj", "src/Plugins/Solutions/DevTools/"] COPY ["tests/Host/AuthKit.Host.Tests.csproj", "tests/Host/"] +COPY ["tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj", "tests/Host.IntegrationTests/"] COPY ["tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj", "tests/Plugins/Abstractions/"] COPY ["tools/AuthKit.PluginContractValidator/AuthKit.PluginContractValidator.csproj", "tools/AuthKit.PluginContractValidator/"] From 9de76e5e2812be319d2587233362080963e26fda Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Sun, 13 Sep 2026 23:03:41 +0200 Subject: [PATCH 3/7] refactor(health): adopt structured plugin health results Co-Authored-By: Zarin <55444728+sean6224@users.noreply.github.com> --- .../Configuration/EndpointConfiguration.cs | 19 +++++++++++--- .../Abstractions/Contracts/IAuthKitPlugin.cs | 25 +++++++++++-------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/Host/Configuration/EndpointConfiguration.cs b/src/Host/Configuration/EndpointConfiguration.cs index b11dcf2..7268893 100644 --- a/src/Host/Configuration/EndpointConfiguration.cs +++ b/src/Host/Configuration/EndpointConfiguration.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using AuthKit.Plugins.Abstractions.Models; using Core.KeyManagement.Interfaces; using Host.Plugins; @@ -59,15 +60,25 @@ public static WebApplication MapAppEndpoints( { var keyStoreHealthy = keyStore.GetPublicJwks().Any(); - var pluginResults = new Dictionary(); + var pluginResults = new Dictionary>(); foreach (var lp in plugins) - pluginResults[lp.Plugin.Name] = await lp.Plugin.CheckHealthAsync(context.RequestServices); + pluginResults[lp.Plugin.Name] = await lp.Plugin.CheckHealthAsync( + context.RequestServices, + context.RequestAborted); - var healthy = keyStoreHealthy && pluginResults.Values.All(ok => ok); + var pluginStatus = pluginResults.Values + .SelectMany(results => results) + .Select(result => result.Status) + .DefaultIfEmpty(PluginHealthStatus.Healthy) + .Max(); + var status = keyStoreHealthy + ? pluginStatus + : PluginHealthStatus.Unhealthy; + var healthy = status == PluginHealthStatus.Healthy; return Results.Json(new { - status = healthy ? "Healthy" : "Unhealthy", + status = status.ToString(), time = DateTime.UtcNow, jwtKeyStore = keyStoreHealthy ? "Healthy" : "Unhealthy", plugins = pluginResults diff --git a/src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs b/src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs index c39ba13..113411a 100644 --- a/src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs +++ b/src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs @@ -213,12 +213,12 @@ void ConfigureServices( ConfigureServices(services, context.Configuration); /// - /// Performs an optional health check for the plugin. + /// Performs an optional structured health check for the plugin. /// /// The root service provider of the host application. + /// A token that can cancel the health check. /// - /// true when the plugin is currently able to serve requests; - /// otherwise, false. + /// One or more structured health results reported by the plugin. /// /// /// @@ -228,18 +228,21 @@ void ConfigureServices( /// dependencies. /// /// - /// A plugin should return false when a required dependency is - /// unavailable, such as when its database or external service cannot - /// currently be reached. + /// A plugin may return separate results for independent dependencies or + /// capabilities. Cancellation must be propagated to cancellable operations + /// and is not converted into a fabricated health result. /// /// - /// The default implementation reports the plugin as healthy. Plugins - /// that do not require custom health validation therefore do not need - /// to implement this member. + /// The default implementation reports the plugin as healthy. Existing + /// plugins that do not require custom health validation therefore do not + /// need to implement this member. /// /// - Task CheckHealthAsync(IServiceProvider services) => - Task.FromResult(true); + Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) => + Task.FromResult>( + [new PluginHealthResult(PluginHealthStatus.Healthy)]); /// /// Gets the optional ASP.NET Core middleware type contributed by the plugin. From acf077c1a93954636c390f369c11e9dec6b27131 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Sun, 13 Sep 2026 23:07:51 +0200 Subject: [PATCH 4/7] feat(plugin): add structured plugin health checks --- .../Solutions/DevTokens/DevTokensPlugin.cs | 20 ++++++++++++++----- .../Solutions/DevTools/DevToolsPlugin.cs | 16 +++++++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs b/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs index 661847f..c2353db 100644 --- a/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs +++ b/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs @@ -3,6 +3,7 @@ using AuthKit.Plugins.Abstractions.Contracts; using AuthKit.Plugins.Abstractions.Contracts.Plugins; using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using AuthKit.Plugins.Abstractions.Models; using FluentValidation; using DevTokens.Interfaces; using DevTokens.Middleware; @@ -74,20 +75,29 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config public Type MiddlewareType => typeof(DeveloperTokenMiddleware); - public async Task CheckHealthAsync(IServiceProvider services) + public async Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var store = services.GetService(); - if (store is null) return false; + if (store is null) + return [new(PluginHealthStatus.Unhealthy, "Document store is unavailable.")]; try { await using var session = store.LightweightSession(); - await session.Query().Take(1).ToListAsync(); - return true; + await session.Query().Take(1).ToListAsync(token: cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return [new(PluginHealthStatus.Healthy, "Developer token store is available.")]; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch { - return false; + return [new(PluginHealthStatus.Unhealthy, "Developer token store is unavailable.")]; } } diff --git a/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs b/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs index e067dbd..cb07bee 100644 --- a/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs +++ b/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs @@ -2,6 +2,7 @@ using AuthKit.Plugins.Abstractions; using AuthKit.Plugins.Abstractions.Contracts; using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using AuthKit.Plugins.Abstractions.Models; using DevTools.Catalog; using DevTools.Middleware; using DevTools.Runtime; @@ -72,19 +73,26 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config /// /// The root service provider of the host application. /// true when the catalog is available; otherwise, false. - public Task CheckHealthAsync(IServiceProvider services) + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var catalog = services.GetService(); - if (catalog is null) return Task.FromResult(false); + if (catalog is null) + return Task.FromResult>( + [new(PluginHealthStatus.Unhealthy, "gRPC service catalog is unavailable.")]); try { _ = catalog.GetServices(); - return Task.FromResult(true); + return Task.FromResult>( + [new(PluginHealthStatus.Healthy, "gRPC service catalog is available.")]); } catch { - return Task.FromResult(false); + return Task.FromResult>( + [new(PluginHealthStatus.Unhealthy, "gRPC service catalog is unavailable.")]); } } } \ No newline at end of file From 2f01e482a234b44c60d53fe758775a370b046d63 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Sun, 13 Sep 2026 23:11:52 +0200 Subject: [PATCH 5/7] test(plugin): cover health checks and status aggregation --- .../PluginHealthEndpointIntegrationTests.cs | 47 +++++ tests/Host/AuthKit.Host.Tests.csproj | 1 + tests/Host/PluginHealthEndpointTests.cs | 178 ++++++++++++++++++ .../Abstractions/PluginHealthResultTests.cs | 55 ++++++ 4 files changed, 281 insertions(+) create mode 100644 tests/Host.IntegrationTests/PluginHealthEndpointIntegrationTests.cs create mode 100644 tests/Host/PluginHealthEndpointTests.cs diff --git a/tests/Host.IntegrationTests/PluginHealthEndpointIntegrationTests.cs b/tests/Host.IntegrationTests/PluginHealthEndpointIntegrationTests.cs new file mode 100644 index 0000000..a627055 --- /dev/null +++ b/tests/Host.IntegrationTests/PluginHealthEndpointIntegrationTests.cs @@ -0,0 +1,47 @@ +using System.Net; +using System.Text.Json; +using Xunit; + +namespace AuthKit.Host.IntegrationTests; + +/// +/// Verifies that the real host executes the plugin health contract through its +/// /health endpoint: per-plugin CheckHealthAsync invocation, result +/// collection, status aggregation, and serialization of the structured results. +/// +public sealed class PluginHealthEndpointIntegrationTests : IClassFixture +{ + private readonly AuthKitWebApplicationFactory _factory; + + public PluginHealthEndpointIntegrationTests(AuthKitWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Health_ExecutesPluginHealthChecksThroughTheRealHost() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.True( + response.StatusCode is HttpStatusCode.OK or HttpStatusCode.ServiceUnavailable, + $"Expected 200 or 503, got {(int)response.StatusCode}."); + + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = body.RootElement; + + Assert.Contains( + root.GetProperty("status").GetString(), + new[] { "Healthy", "Degraded", "Unhealthy" }); + + Assert.Equal("Healthy", root.GetProperty("jwtKeyStore").GetString()); + + var plugins = root.GetProperty("plugins"); + Assert.Single(plugins.EnumerateObject()); + Assert.True(plugins.TryGetProperty("DevTokens", out var devTokens)); + Assert.True(devTokens.GetArrayLength() >= 1); + Assert.InRange(devTokens[0].GetProperty("status").GetInt32(), 0, 2); + } +} \ No newline at end of file diff --git a/tests/Host/AuthKit.Host.Tests.csproj b/tests/Host/AuthKit.Host.Tests.csproj index c3138fd..aaa033d 100644 --- a/tests/Host/AuthKit.Host.Tests.csproj +++ b/tests/Host/AuthKit.Host.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/tests/Host/PluginHealthEndpointTests.cs b/tests/Host/PluginHealthEndpointTests.cs new file mode 100644 index 0000000..0204592 --- /dev/null +++ b/tests/Host/PluginHealthEndpointTests.cs @@ -0,0 +1,178 @@ +using System.Text.Json; +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Models; +using Core.KeyManagement.DTO; +using Core.KeyManagement.Interfaces; +using Host.Configuration; +using Host.Plugins; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using PluginMetadataAttribute = AuthKit.Plugins.Abstractions.Contracts.Plugins.PluginMetadataAttribute; +using Xunit; + +namespace AuthKit.Host.Tests; + +/// +/// Verifies the health endpoint aggregation semantics implemented by +/// : collection of multiple +/// structured results, maximum-status aggregation, default behavior for plugins +/// without custom health checks, and key store integration. +/// +public sealed class PluginHealthEndpointTests +{ + [Fact] + public async Task HealthEndpoint_AggregatesMultipleResultsByMaximumStatus() + { + var healthy = new SingleResultPlugin( + new PluginHealthResult(PluginHealthStatus.Healthy, "accepted connections.")); + var degraded = new MultiResultPlugin( + new PluginHealthResult(PluginHealthStatus.Healthy, "database is available."), + new PluginHealthResult( + PluginHealthStatus.Degraded, + "cache is responding slowly.", + new Dictionary { ["cache-latency-ms"] = "3200" })); + + await using var app = await BuildHostAsync( + [Load(healthy), Load(degraded)], + keyStoreHealthy: true); + + using var response = await app.Client.GetAsync("/health"); + + Assert.Equal(StatusCodes.Status503ServiceUnavailable, (int)response.StatusCode); + + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = body.RootElement; + + Assert.Equal("Degraded", root.GetProperty("status").GetString()); + Assert.Equal("Healthy", root.GetProperty("jwtKeyStore").GetString()); + + var plugins = root.GetProperty("plugins"); + + var singleEntry = plugins.GetProperty("healthy-single"); + Assert.Equal(1, singleEntry.GetArrayLength()); + Assert.Equal((int)PluginHealthStatus.Healthy, singleEntry[0].GetProperty("status").GetInt32()); + Assert.Equal("accepted connections.", singleEntry[0].GetProperty("reason").GetString()); + + var multiEntry = plugins.GetProperty("healthy-degraded"); + Assert.Equal(2, multiEntry.GetArrayLength()); + Assert.Equal((int)PluginHealthStatus.Healthy, multiEntry[0].GetProperty("status").GetInt32()); + Assert.Equal("database is available.", multiEntry[0].GetProperty("reason").GetString()); + Assert.Equal((int)PluginHealthStatus.Degraded, multiEntry[1].GetProperty("status").GetInt32()); + Assert.Equal("3200", multiEntry[1].GetProperty("data").GetProperty("cache-latency-ms").GetString()); + } + + [Fact] + public async Task HealthEndpoint_ReturnsHealthyWhenAllPluginsReportHealthy() + { + await using var app = await BuildHostAsync( + [Load(new SingleResultPlugin(new PluginHealthResult(PluginHealthStatus.Healthy, "ok")))], + keyStoreHealthy: true); + + using var response = await app.Client.GetAsync("/health"); + + Assert.Equal(StatusCodes.Status200OK, (int)response.StatusCode); + + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal("Healthy", body.RootElement.GetProperty("status").GetString()); + } + + [Fact] + public async Task HealthEndpoint_TreatsPluginsWithoutCustomHealthChecksAsHealthy() + { + await using var app = await BuildHostAsync( + [Load(new DefaultHealthPlugin())], + keyStoreHealthy: true); + + using var response = await app.Client.GetAsync("/health"); + + Assert.Equal(StatusCodes.Status200OK, (int)response.StatusCode); + + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var entry = body.RootElement.GetProperty("plugins").GetProperty("default-behavior"); + Assert.Equal(1, entry.GetArrayLength()); + Assert.Equal((int)PluginHealthStatus.Healthy, entry[0].GetProperty("status").GetInt32()); + } + + [Fact] + public async Task HealthEndpoint_ReportsUnhealthyKeyStoreDespiteHealthyPlugins() + { + await using var app = await BuildHostAsync( + [Load(new SingleResultPlugin(new PluginHealthResult(PluginHealthStatus.Healthy, "ok")))], + keyStoreHealthy: false); + + using var response = await app.Client.GetAsync("/health"); + + Assert.Equal(StatusCodes.Status503ServiceUnavailable, (int)response.StatusCode); + + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = body.RootElement; + Assert.Equal("Unhealthy", root.GetProperty("status").GetString()); + Assert.Equal("Unhealthy", root.GetProperty("jwtKeyStore").GetString()); + var entry = root.GetProperty("plugins").GetProperty("healthy-single"); + Assert.Equal((int)PluginHealthStatus.Healthy, entry[0].GetProperty("status").GetInt32()); + } + + private static async Task BuildHostAsync( + IReadOnlyList plugins, + bool keyStoreHealthy) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddControllers(); + builder.Services.AddSingleton(new StubKeyStore(keyStoreHealthy)); + builder.Services.AddSingleton(plugins); + + var app = builder.Build(); + app.MapAppEndpoints(plugins); + await app.StartAsync(); + + return new AppUnderTest(app, app.GetTestClient()); + } + + private sealed record AppUnderTest(WebApplication App, HttpClient Client) : IAsyncDisposable + { + public ValueTask DisposeAsync() => App.DisposeAsync(); + } + + private sealed class StubKeyStore(bool healthy) : IJwtKeyStore + { + public IEnumerable GetPublicJwks() => + healthy ? new[] { new PublicJwkDto() } : Array.Empty(); + + public Task InitializeAsync() => Task.CompletedTask; + public SigningCredentials GetActiveSigningCredentials() => throw new NotSupportedException(); + public SigningCredentials? GetSigningCredentialsByKid(string kid) => throw new NotSupportedException(); + public Task RotateAsync(int rsaBits = 4096) => throw new NotSupportedException(); + public Task RevokeAsync(string kid) => throw new NotSupportedException(); + public KeyMetadata? GetMetadata(string kid) => throw new NotSupportedException(); + } + + private static LoadedPlugin Load(IAuthKitPlugin plugin) => + new(plugin, plugin.GetType().Assembly, "test"); + + [PluginMetadataAttribute("healthy-single", "1.0.0", [], [], [], description: "Single result health plugin")] + private sealed class SingleResultPlugin(PluginHealthResult result) : IAuthKitPlugin + { + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) => + Task.FromResult>([result]); + } + + [PluginMetadataAttribute("healthy-degraded", "1.0.0", [], [], [], description: "Multi result health plugin")] + private sealed class MultiResultPlugin(params PluginHealthResult[] results) : IAuthKitPlugin + { + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) => + Task.FromResult>(results); + } + + [PluginMetadataAttribute("default-behavior", "1.0.0", [], [], [], description: "Default health plugin")] + private sealed class DefaultHealthPlugin : IAuthKitPlugin; +} \ No newline at end of file diff --git a/tests/Plugins/Abstractions/PluginHealthResultTests.cs b/tests/Plugins/Abstractions/PluginHealthResultTests.cs index 1fd8780..91b7f2a 100644 --- a/tests/Plugins/Abstractions/PluginHealthResultTests.cs +++ b/tests/Plugins/Abstractions/PluginHealthResultTests.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; using AuthKit.Plugins.Abstractions.Models; using Xunit; @@ -46,4 +48,57 @@ public void Result_PreservesReasonAndDiagnosticDataThroughSerialization() Assert.Equal("cache", restored.Data["dependency"].ToString()); Assert.Equal("2", restored.Data["retry_count"].ToString()); } + + [Fact] + public async Task PluginHealthContract_PreservesMultipleResultsAndCancellationToken() + { + var plugin = new StructuredHealthPlugin(); + using var cancellation = new CancellationTokenSource(); + + var results = await plugin.CheckHealthAsync( + new ServiceProviderStub(), + cancellation.Token); + + Assert.Equal(2, results.Count); + Assert.Equal(PluginHealthStatus.Healthy, results[0].Status); + Assert.Equal(PluginHealthStatus.Degraded, results[1].Status); + Assert.Equal(cancellation.Token, plugin.ReceivedToken); + } + + [Fact] + public async Task PluginHealthContract_DefaultImplementationReturnsHealthyResult() + { + IAuthKitPlugin plugin = new DefaultHealthPlugin(); + + var results = await plugin.CheckHealthAsync(new ServiceProviderStub()); + + var result = Assert.Single(results); + Assert.Equal(PluginHealthStatus.Healthy, result.Status); + } + + [PluginMetadata("structured-health", "1.0.0", [], [], [], description: "Structured health test")] + private sealed class StructuredHealthPlugin : IAuthKitPlugin + { + public CancellationToken ReceivedToken { get; private set; } + + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + ReceivedToken = cancellationToken; + return Task.FromResult>( + [ + new(PluginHealthStatus.Healthy, "Database is available."), + new(PluginHealthStatus.Degraded, "Cache is responding slowly.") + ]); + } + } + + [PluginMetadata("default-health", "1.0.0", [], [], [], description: "Default health test")] + private sealed class DefaultHealthPlugin : IAuthKitPlugin; + + private sealed class ServiceProviderStub : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } } \ No newline at end of file From bdf5586eff7b8cd91b979fd4d19a6677f6bc86ff Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Sun, 13 Sep 2026 23:12:47 +0200 Subject: [PATCH 6/7] fix(plugin): improve health checks and console startup --- Directory.Packages.props | 1 + .../Security/JwtKeyStoreInitializer.cs | 19 ++++++++++++------ .../examples/ExamplePlugin/Program.cs | 20 +++++++++++-------- .../src/Rules/HealthRule.cs | 6 +++++- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 42440a4..09cd0b0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs b/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs index 6009392..112ea11 100644 --- a/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs +++ b/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs @@ -54,12 +54,19 @@ public async Task StartAsync(CancellationToken cancellationToken) var stopwatch = Stopwatch.StartNew(); - await AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .SpinnerStyle(Style.Parse("green")) - .StartAsync( - "Initializing JWT KeyStore...", - async _ => await store.InitializeAsync()); + if (System.Console.IsOutputRedirected) + { + await store.InitializeAsync(); + } + else + { + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green")) + .StartAsync( + "Initializing JWT KeyStore...", + async _ => await store.InitializeAsync()); + } stopwatch.Stop(); diff --git a/tools/AuthKit.ManifestGenerator/examples/ExamplePlugin/Program.cs b/tools/AuthKit.ManifestGenerator/examples/ExamplePlugin/Program.cs index 3bc1e4b..eca8d1a 100644 --- a/tools/AuthKit.ManifestGenerator/examples/ExamplePlugin/Program.cs +++ b/tools/AuthKit.ManifestGenerator/examples/ExamplePlugin/Program.cs @@ -65,13 +65,17 @@ public void ConfigureServices( } /// - /// Performs a health check for the plugin. + /// Performs a structured, cancellable health check for the plugin. /// - /// The used to resolve services required for the health check. - /// - /// A task containing when the plugin is healthy; - /// otherwise, . - /// - public Task CheckHealthAsync(IServiceProvider services) - => Task.FromResult(true); + /// The service provider used to resolve health check dependencies. + /// A token that can cancel the health check. + /// A task containing the plugin health results. + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult>( + new[] { new PluginHealthResult(PluginHealthStatus.Healthy) }); + } } diff --git a/tools/AuthKit.PluginContractValidator/src/Rules/HealthRule.cs b/tools/AuthKit.PluginContractValidator/src/Rules/HealthRule.cs index 4eb2a76..142a569 100644 --- a/tools/AuthKit.PluginContractValidator/src/Rules/HealthRule.cs +++ b/tools/AuthKit.PluginContractValidator/src/Rules/HealthRule.cs @@ -6,6 +6,7 @@ using AuthKit.Plugins.Abstractions.Contracts; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace AuthKit.PluginContractValidator.Rules; @@ -41,6 +42,7 @@ public async Task> ValidateAsync( { var errors = new List(); var services = new ServiceCollection(); + services.AddLogging(); var configuration = new ConfigurationBuilder().Build(); try @@ -56,7 +58,9 @@ public async Task> ValidateAsync( try { await using var provider = services.BuildServiceProvider(); - await plugin.Instance.CheckHealthAsync(provider); + var results = await plugin.Instance.CheckHealthAsync(provider, cancellationToken); + if (results is null || results.Count == 0) + errors.Add("health: CheckHealthAsync returned no health results."); } catch (Exception ex) { From 7c9a386a9d2179b58ae708b03967012032b09191 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Sun, 13 Sep 2026 23:32:40 +0200 Subject: [PATCH 7/7] docs(adr): add structured plugin health decision --- .../025-structured-plugin-health-contract.md | 74 +++++++++++++++++++ Docs/ADR/README.md | 1 + 2 files changed, 75 insertions(+) create mode 100644 Docs/ADR/025-structured-plugin-health-contract.md diff --git a/Docs/ADR/025-structured-plugin-health-contract.md b/Docs/ADR/025-structured-plugin-health-contract.md new file mode 100644 index 0000000..4e225b0 --- /dev/null +++ b/Docs/ADR/025-structured-plugin-health-contract.md @@ -0,0 +1,74 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./024-plugin-lifecycle-and-hosted-services.md) | [Next]() + +# [ADR-025] Expose Structured And Cancellable Plugin Health Results + +*2026-09* | Status: accepted + +**Tag:** #adr_025 + +**Date:** 2026-09-13 + +**Scope:** AuthKit.Plugins.Abstractions + Host + +## Context + +Plugins may depend on several independent components, such as database, cache, +external API, or message queue. A single boolean health value cannot preserve +which component is degraded or why check failed. Health checks may also perform +asynchronous I/O and need to stop when the request or host is cancelled. + +## Problem + +The original plugin contract returned `Task CheckHealthAsync(IServiceProvider)`. +That shape loses intermediate health states, diagnostic information, multiple +observations, and cancellation. Replacing it without a migration path would +break existing plugin implementations. + +## Decision + +`IAuthKitPlugin.CheckHealthAsync` returns: + +```csharp +Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) +``` + +`PluginHealthResult` contains the strongly typed `PluginHealthStatus`, an optional +reason, and optional plugin owned diagnostic data. `Healthy`, `Degraded`, and +`Unhealthy` remain distinct. A plugin may return one result or multiple results, +with each result representing an independent observation. + +The default interface implementation returns one `Healthy` result so plugins that +do not provide custom check remain valid. The host preserves the result list and +uses the supplied request cancellation token. Cancellation is propagated rather +than converted into fabricated health result. + +## Rejected + +- Keeping `bool` would discard degraded state and diagnostics. +- Collapsing multiple results inside the plugin would make host aggregation lossy. +- Inferring status from `Reason` or `Data` would make the contract weakly typed. +- Silently replacing cancellation with `Healthy` or `Unhealthy` would hide an + incomplete check. +- Adding second health method would leave two competing public contracts. + +## Consequences + +Health consumers must handle list of structured results and define aggregation +explicitly. The host health endpoint reports the highest severity status while +preserving every plugin result in the response. Plugin-specific diagnostic keys +remain extensible, but they do not override `Status`. + +The change is source breaking for plugins that implement the old `Task` +method; shipped plugins and the manifest example are migrated together. The +contract validator invokes the new method and rejects an empty result collection. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - plugin contract and dynamic loading +- [ADR-024](./024-plugin-lifecycle-and-hosted-services.md) - plugin lifecycle integration +- [Issue #14](https://github.com/AuthKits/AuthKit.Server/issues/14) - structured plugin health result +- [Issue #15](https://github.com/AuthKits/AuthKit.Server/issues/15) - multiple results and cancellation + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./024-plugin-lifecycle-and-hosted-services.md) | [Next]() diff --git a/Docs/ADR/README.md b/Docs/ADR/README.md index 2654f47..1a22a66 100644 --- a/Docs/ADR/README.md +++ b/Docs/ADR/README.md @@ -75,6 +75,7 @@ The table below shows the architecture areas and their current scope. | [ADR-022](./022-plugin-configuration-context-and-builder.md) | Extend Plugin Configuration With The Host Builder And Scoped Context | Plugins | accepted | 2026-09-12 | | [ADR-023](./023-plugin-application-pipeline-hooks.md) | Integrate Plugin Endpoints And Middleware Through Explicit Host Pipeline Hooks | Plugins | accepted | 2026-09-12 | | [ADR-024](./024-plugin-lifecycle-and-hosted-services.md) | Bridge Plugin Lifecycle Hooks To The Standard .NET Host Lifecycle | Plugins | accepted | 2026-09-12 | +| [ADR-025](./025-structured-plugin-health-contract.md) | Expose Structured And Cancellable Plugin Health Results | Plugins | accepted | 2026-09-13 | ## Relationships Between Areas