diff --git a/.gitignore b/.gitignore
index 8b12e1a..36191f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,5 @@ obj/
qodana.yaml
src/Plugins/Solutions/DevTokens/manifest.json
src/Plugins/Solutions/DevTools/manifest.json
+issues/
+*.DotSettings.user
diff --git a/AuthKit.slnx b/AuthKit.slnx
index b4122e7..2ea0aba 100644
--- a/AuthKit.slnx
+++ b/AuthKit.slnx
@@ -11,6 +11,7 @@
+
diff --git a/Dockerfile b/Dockerfile
index b623cd2..4bdce5e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,6 +19,8 @@ COPY ["src/Plugins/Solutions/DevTokens/DevTokens.csproj", "src/Plugins/Solutions
COPY ["src/Plugins/Solutions/DevTools/DevTools.csproj", "src/Plugins/Solutions/DevTools/"]
COPY ["src/Plugins/Integrations/AuthKit.Plugins.Integrations.csproj", "src/Plugins/Integrations/"]
+COPY ["src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj", "src/Plugins/Solutions/ExamplePlugin/"]
+
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/"]
@@ -41,6 +43,9 @@ COPY src/Plugins/Solutions/DevTokens/manifest.json /app/publish/plugins/DevToken
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 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
+
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
diff --git a/src/Host/Configuration/Grpc/GrpcConfiguration.cs b/src/Host/Configuration/Grpc/GrpcConfiguration.cs
index 5295716..f60a0e0 100644
--- a/src/Host/Configuration/Grpc/GrpcConfiguration.cs
+++ b/src/Host/Configuration/Grpc/GrpcConfiguration.cs
@@ -39,7 +39,8 @@ public static IServiceCollection AddGrpcServices(this IServiceCollection service
/// The configured for method chaining.
public static WebApplication MapGrpcEndpoints(this WebApplication app)
{
- app.MapGrpcService();
+ app.MapGrpcService();
+ app.MapGrpcService();
return app;
}
}
diff --git a/src/Host/Configuration/Pipeline/EndpointConfiguration.cs b/src/Host/Configuration/Pipeline/EndpointConfiguration.cs
index c4f1a6f..54add8c 100644
--- a/src/Host/Configuration/Pipeline/EndpointConfiguration.cs
+++ b/src/Host/Configuration/Pipeline/EndpointConfiguration.cs
@@ -1,9 +1,10 @@
-using System.Diagnostics;
-using AuthKit.Plugins.Abstractions.Models;
-using Core.KeyManagement.Interfaces;
+using AuthKit.Plugins.Abstractions.Models;
+using Host.Monitoring;
using Host.Plugins.Loading;
using Host.Plugins.Configuration;
-using Host.Plugins.Health;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
namespace Host.Configuration.Pipeline;
@@ -59,41 +60,22 @@ public static WebApplication MapAppEndpoints(
PluginApplicationConfiguration.MapEndpoints(app, plugins);
app.MapGet("/health", async (
- HttpContext context,
- IJwtKeyStore keyStore,
- IReadOnlyList plugins,
- PluginHealthExecutor healthExecutor) =>
+ [FromServices] HealthReportService healthReportService) =>
{
- var keyStoreHealthy = keyStore.GetPublicJwks().Any();
+ var report = await healthReportService.BuildAsync();
+ var healthy = report.Status == nameof(PluginHealthStatus.Healthy);
- var pluginResults = new Dictionary>();
- foreach (var lp in plugins)
- pluginResults[lp.Plugin.Name] = (await healthExecutor.ExecuteAsync(
- lp,
- context.RequestAborted)).Results.ToArray();
-
- 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 = status.ToString(),
- time = DateTime.UtcNow,
- jwtKeyStore = keyStoreHealthy ? "Healthy" : "Unhealthy",
- plugins = pluginResults
- }, statusCode: healthy ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable);
+ return Results.Json(
+ report,
+ statusCode: healthy ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable);
})
.WithName("HealthCheck")
.WithTags("Monitoring");
- app.MapGet("/metrics", () => Results.Json(new { uptime = (DateTime.UtcNow - Process.GetCurrentProcess().StartTime).TotalSeconds }))
+ app.MapGet("/metrics", () => Results.Json(new
+ {
+ uptime = MetricsReportService.Build().UptimeSeconds
+ }))
.WithName("Metrics")
.WithTags("Monitoring");
diff --git a/src/Host/Grpc/GreeterService.cs b/src/Host/Grpc/GreeterService.cs
deleted file mode 100644
index cf65dee..0000000
--- a/src/Host/Grpc/GreeterService.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using Grpc.Core;
-
-namespace Host.Grpc;
-
-public class GreeterService(ILogger logger) : Greeter.GreeterBase
-{
- public override Task SayHello(HelloRequest request, ServerCallContext context)
- {
- logger.LogInformation("The message is received from {Name}", request.Name);
-
- return Task.FromResult(new HelloReply
- {
- Message = "Hello " + request.Name
- });
- }
-}
\ No newline at end of file
diff --git a/src/Host/Grpc/JwksService.cs b/src/Host/Grpc/JwksService.cs
new file mode 100644
index 0000000..3a168e8
--- /dev/null
+++ b/src/Host/Grpc/JwksService.cs
@@ -0,0 +1,117 @@
+using Core.KeyManagement.Interfaces;
+using Google.Protobuf.WellKnownTypes;
+using Grpc.Core;
+
+namespace Host.Grpc;
+
+public class JwksService(
+ ILogger logger,
+ IJwtKeyStore keyStore) : Jwks.JwksBase
+{
+ public override Task GetJwks(Empty request, ServerCallContext context)
+ {
+ var response = new JwksResponse();
+
+ foreach (var key in keyStore.GetPublicJwks())
+ {
+ response.Keys.Add(new Jwk
+ {
+ Kty = key.Kty,
+ Use = key.Use,
+ Kid = key.Kid,
+ Alg = key.Alg,
+ N = key.N,
+ E = key.E,
+ X5C = { key.X5c }
+ });
+ }
+
+ logger.LogDebug("Returning {KeyCount} public keys in gRPC JWKS response", response.Keys.Count);
+
+ return Task.FromResult(response);
+ }
+
+ public override Task GetKeyByKid(KeyLookupRequest request, ServerCallContext context)
+ {
+ var key = keyStore.GetPublicJwks()
+ .FirstOrDefault(k => k.Kid == request.Kid);
+
+ if (key is null)
+ {
+ throw new RpcException(new Status(StatusCode.NotFound, $"Key with kid '{request.Kid}' was not found."));
+ }
+
+ var metadata = keyStore.GetMetadata(request.Kid);
+
+ var response = new SingleKeyResponse
+ {
+ Key = new Jwk
+ {
+ Kty = key.Kty,
+ Use = key.Use,
+ Kid = key.Kid,
+ Alg = key.Alg,
+ N = key.N,
+ E = key.E,
+ X5C = { key.X5c }
+ },
+ Metadata = metadata is null ? null : new KeyMetadata
+ {
+ Kid = metadata.Kid,
+ CreatedAt = metadata.CreatedAt.ToString("O"),
+ Revoked = metadata.Revoked,
+ Algorithm = metadata.Algorithm,
+ Purpose = metadata.Purpose
+ }
+ };
+
+ return Task.FromResult(response);
+ }
+
+ public override Task GetJwksHealth(Empty request, ServerCallContext context)
+ {
+ var publicKeys = keyStore.GetPublicJwks().ToList();
+ var activeCredentials = keyStore.GetActiveSigningCredentials();
+ var activeKid = activeCredentials.Kid;
+ var activeMetadata = activeKid is null ? null : keyStore.GetMetadata(activeKid);
+ var isHealthy = publicKeys.Count > 0 && !string.IsNullOrWhiteSpace(activeKid);
+
+ var response = new HealthResponse
+ {
+ Status = isHealthy ? "healthy" : "degraded",
+ AvailableKeys = publicKeys.Count,
+ ActiveKeyId = activeKid ?? string.Empty,
+ ActiveKeyCreatedAt = activeMetadata?.CreatedAt.ToString("O") ?? string.Empty,
+ Timestamp = DateTime.UtcNow.ToString("O")
+ };
+
+ response.Details.Add("has_active_key", (activeKid is not null).ToString());
+ response.Details.Add("key_ids", string.Join(",", publicKeys.Select(k => k.Kid)));
+
+ return Task.FromResult(response);
+ }
+
+ public override Task GetJwksStats(Empty request, ServerCallContext context)
+ {
+ var publicKeys = keyStore.GetPublicJwks().ToList();
+ var metadata = publicKeys
+ .Select(k => keyStore.GetMetadata(k.Kid))
+ .Where(m => m is not null)
+ .ToList();
+
+ var oldest = metadata.MinBy(m => m!.CreatedAt);
+ var newest = metadata.MaxBy(m => m!.CreatedAt);
+
+ var response = new KeyStoreStatsResponse
+ {
+ TotalKeys = publicKeys.Count,
+ OldestKeyAge = oldest is null ? string.Empty : (DateTime.UtcNow - oldest.CreatedAt.UtcDateTime).ToString(),
+ NewestKeyAge = newest is null ? string.Empty : (DateTime.UtcNow - newest.CreatedAt.UtcDateTime).ToString(),
+ Timestamp = DateTime.UtcNow.ToString("O")
+ };
+
+ response.KeyIds.AddRange(publicKeys.Select(k => k.Kid));
+
+ return Task.FromResult(response);
+ }
+}
\ No newline at end of file
diff --git a/src/Host/Grpc/MonitoringService.cs b/src/Host/Grpc/MonitoringService.cs
new file mode 100644
index 0000000..7098008
--- /dev/null
+++ b/src/Host/Grpc/MonitoringService.cs
@@ -0,0 +1,80 @@
+using AuthKit.Plugins.Abstractions.Models;
+using Google.Protobuf.WellKnownTypes;
+using Grpc.Core;
+using Host.Monitoring;
+
+namespace Host.Grpc;
+
+///
+/// Exposes host and plugin health status and basic process metrics over gRPC.
+///
+///
+/// Mirrors the REST /health and /metrics endpoints
+/// (EndpointConfiguration). Aggregation lives in
+/// and ;
+/// this service only maps the shared reports to the gRPC wire format.
+///
+public class MonitoringService(
+ ILogger logger,
+ HealthReportService healthReportService) : Monitoring.MonitoringBase
+{
+ ///
+ /// Returns the aggregate health status of the JWT key store and every
+ /// loaded plugin.
+ ///
+ public override async Task GetHealth(Empty request, ServerCallContext context)
+ {
+ var report = await healthReportService.BuildAsync(context.CancellationToken);
+
+ var response = new MonitoringHealthResponse
+ {
+ Status = report.Status,
+ Time = report.Time.ToString("O"),
+ JwtKeyStoreHealthy = report.JwtKeyStore == "Healthy"
+ };
+
+ foreach (var (name, results) in report.Plugins)
+ {
+ var pluginHealth = new PluginHealth { Name = name };
+ foreach (var result in results)
+ {
+ var entry = new PluginHealthCheck
+ {
+ Status = result.Status.ToString(),
+ Reason = result.Reason ?? string.Empty
+ };
+
+ if (result.Tags is not null)
+ entry.Tags.AddRange(result.Tags);
+
+ if (result.Data is not null)
+ {
+ foreach (var (key, value) in result.Data)
+ entry.Data[key] = value.ToString() ?? string.Empty;
+ }
+
+ pluginHealth.Results.Add(entry);
+ }
+
+ response.Plugins.Add(pluginHealth);
+ }
+
+ logger.LogDebug("gRPC health reported {Status} across {PluginCount} plugin(s).", response.Status, response.Plugins.Count);
+ return response;
+ }
+
+ ///
+ /// Returns basic process metrics such as uptime.
+ ///
+ public override Task GetMetrics(Empty request, ServerCallContext context)
+ {
+ var report = MetricsReportService.Build();
+
+ return Task.FromResult(new MetricsResponse
+ {
+ UptimeSeconds = report.UptimeSeconds,
+ ProcessStartUnixTime = report.ProcessStartUnixTime,
+ Time = report.Time.ToString("O")
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/Host/Grpc/protos/greet.proto b/src/Host/Grpc/protos/greet.proto
deleted file mode 100644
index 2be63fa..0000000
--- a/src/Host/Grpc/protos/greet.proto
+++ /dev/null
@@ -1,21 +0,0 @@
-syntax = "proto3";
-
-option csharp_namespace = "Host";
-
-package greet;
-
-// The greeting service definition.
-service Greeter {
- // Sends a greeting
- rpc SayHello (HelloRequest) returns (HelloReply);
-}
-
-// The request message containing the user's name.
-message HelloRequest {
- string name = 1;
-}
-
-// The response message containing the greetings.
-message HelloReply {
- string message = 1;
-}
diff --git a/src/Host/Grpc/protos/jwks.proto b/src/Host/Grpc/protos/jwks.proto
new file mode 100644
index 0000000..7f27ad7
--- /dev/null
+++ b/src/Host/Grpc/protos/jwks.proto
@@ -0,0 +1,67 @@
+syntax = "proto3";
+option csharp_namespace = "Host.Grpc";
+package jwks;
+import "google/protobuf/empty.proto";
+
+service Jwks
+{
+ // Returns all active public signing keys in JWKS format.
+ rpc GetJwks (google.protobuf.Empty) returns (JwksResponse);
+
+ // Returns single key by key id.
+ rpc GetKeyByKid (KeyLookupRequest) returns (SingleKeyResponse);
+
+ // Returns the health status of the JWKS key store.
+ rpc GetJwksHealth (google.protobuf.Empty) returns (HealthResponse);
+
+ // Returns key store statistics used for monitoring.
+ rpc GetJwksStats (google.protobuf.Empty) returns (KeyStoreStatsResponse);
+}
+
+message KeyLookupRequest {
+ string kid = 1;
+}
+
+message JwksResponse {
+ repeated Jwk keys = 1;
+}
+
+message Jwk {
+ string kty = 1;
+ string use = 2;
+ string kid = 3;
+ string alg = 4;
+ string n = 5;
+ string e = 6;
+ repeated string x5c = 7;
+}
+
+message SingleKeyResponse {
+ Jwk key = 1;
+ KeyMetadata metadata = 2;
+}
+
+message KeyMetadata {
+ string kid = 1;
+ string created_at = 2;
+ bool revoked = 3;
+ string algorithm = 4;
+ string purpose = 5;
+}
+
+message HealthResponse {
+ string status = 1;
+ int32 available_keys = 2;
+ string active_key_id = 3;
+ string active_key_created_at = 4;
+ map details = 5;
+ string timestamp = 6;
+}
+
+message KeyStoreStatsResponse {
+ int32 total_keys = 1;
+ string oldest_key_age = 2;
+ string newest_key_age = 3;
+ repeated string key_ids = 4;
+ string timestamp = 5;
+}
\ No newline at end of file
diff --git a/src/Host/Grpc/protos/monitoring.proto b/src/Host/Grpc/protos/monitoring.proto
new file mode 100644
index 0000000..e0aac74
--- /dev/null
+++ b/src/Host/Grpc/protos/monitoring.proto
@@ -0,0 +1,38 @@
+syntax = "proto3";
+option csharp_namespace = "Host.Grpc";
+package monitoring;
+import "google/protobuf/empty.proto";
+
+service Monitoring
+{
+ // Returns the aggregate health status of the host and its plugins.
+ rpc GetHealth (google.protobuf.Empty) returns (MonitoringHealthResponse);
+
+ // Returns basic process metrics such as uptime.
+ rpc GetMetrics (google.protobuf.Empty) returns (MetricsResponse);
+}
+
+message MonitoringHealthResponse {
+ string status = 1;
+ string time = 2;
+ bool jwt_key_store_healthy = 3;
+ repeated PluginHealth plugins = 4;
+}
+
+message PluginHealth {
+ string name = 1;
+ repeated PluginHealthCheck results = 2;
+}
+
+message PluginHealthCheck {
+ string status = 1;
+ string reason = 2;
+ repeated string tags = 3;
+ map data = 4;
+}
+
+message MetricsResponse {
+ double uptime_seconds = 1;
+ int64 process_start_unix_time = 2;
+ string time = 3;
+}
diff --git a/src/Host/Monitoring/HealthReportService.cs b/src/Host/Monitoring/HealthReportService.cs
new file mode 100644
index 0000000..9665392
--- /dev/null
+++ b/src/Host/Monitoring/HealthReportService.cs
@@ -0,0 +1,60 @@
+using AuthKit.Plugins.Abstractions.Models;
+using Core.KeyManagement.Interfaces;
+using Host.Plugins.Health;
+using Host.Plugins.Loading;
+
+namespace Host.Monitoring;
+
+///
+/// Builds consistent host and plugin health reports for every transport.
+///
+///
+/// Aggregates the health of the JWT key store and every loaded plugin using
+/// the key store and plugin health infrastructure shared with the REST
+/// endpoint. The result is transport agnostic and is serialized by the REST
+/// /health endpoint and the gRPC Monitoring.GetHealth service.
+///
+public sealed class HealthReportService(
+ IJwtKeyStore keyStore,
+ IReadOnlyList plugins,
+ PluginHealthExecutor healthExecutor)
+{
+ ///
+ /// Builds transport agnostic health response that can be serialized
+ /// to JSON by the REST endpoint and mapped to protobuf by the gRPC
+ /// Monitoring service.
+ ///
+ /// A token that cancels the plugin health checks.
+ public async Task BuildAsync(CancellationToken cancellationToken = default)
+ {
+ var keyStoreHealthy = keyStore.GetPublicJwks().Any();
+
+ var pluginResults = new Dictionary>();
+ foreach (var plugin in plugins)
+ {
+ var results = (await healthExecutor.ExecuteAsync(plugin, cancellationToken))
+ .Results
+ .Select(r => new PluginHealthResultResponse(
+ r.Status,
+ r.Reason,
+ r.Tags,
+ r.Data))
+ .ToArray();
+
+ pluginResults[plugin.Plugin.Name] = results;
+ }
+
+ var aggregated = pluginResults.Values
+ .SelectMany(results => results)
+ .Select(r => r.Status)
+ .DefaultIfEmpty(PluginHealthStatus.Healthy)
+ .Max();
+ var status = keyStoreHealthy ? aggregated : PluginHealthStatus.Unhealthy;
+
+ return new HealthResponse(
+ status.ToString(),
+ DateTime.UtcNow,
+ keyStoreHealthy ? "Healthy" : "Unhealthy",
+ pluginResults);
+ }
+}
\ No newline at end of file
diff --git a/src/Host/Monitoring/HealthResponse.cs b/src/Host/Monitoring/HealthResponse.cs
new file mode 100644
index 0000000..42f9ff0
--- /dev/null
+++ b/src/Host/Monitoring/HealthResponse.cs
@@ -0,0 +1,39 @@
+using AuthKit.Plugins.Abstractions.Models;
+
+namespace Host.Monitoring;
+
+///
+/// Transport agnostic representation of the host health response.
+///
+///
+/// Directly serializable to JSON by the REST /health endpoint.
+/// Also consumed by the gRPC monitoring.GetHealth service, which
+/// maps it to the protobuf wire format.
+///
+/// Aggregate status string.
+/// UTC timestamp of the report.
+/// Key store status text.
+/// Per plugin health results keyed by plugin name.
+public sealed record HealthResponse(
+ string Status,
+ DateTime Time,
+ string JwtKeyStore,
+ IReadOnlyDictionary> Plugins);
+
+///
+/// Single structured health result for plugin.
+///
+///
+/// is serialized as the
+/// numeric enum value (int) by the default System.Text.Json
+/// configuration, matching the existing REST contract.
+///
+/// Health status level.
+/// Readable explanation.
+/// Optional labels for observability.
+/// Optional structured diagnostic data.
+public sealed record PluginHealthResultResponse(
+ PluginHealthStatus Status,
+ string? Reason,
+ IReadOnlyCollection? Tags,
+ IReadOnlyDictionary? Data);
diff --git a/src/Host/Monitoring/MetricsReport.cs b/src/Host/Monitoring/MetricsReport.cs
new file mode 100644
index 0000000..60e5dbe
--- /dev/null
+++ b/src/Host/Monitoring/MetricsReport.cs
@@ -0,0 +1,16 @@
+namespace Host.Monitoring;
+
+///
+/// Transport agnostic snapshot of basic process metrics.
+///
+///
+/// Shared by the REST /metrics endpoint and the gRPC
+/// Monitoring.GetMetrics service.
+///
+/// Seconds elapsed since the process started.
+/// Unix timestamp of the process start time.
+/// The UTC time the report was produced.
+public sealed record MetricsReport(
+ double UptimeSeconds,
+ long ProcessStartUnixTime,
+ DateTime Time);
\ No newline at end of file
diff --git a/src/Host/Monitoring/MetricsReportService.cs b/src/Host/Monitoring/MetricsReportService.cs
new file mode 100644
index 0000000..314f943
--- /dev/null
+++ b/src/Host/Monitoring/MetricsReportService.cs
@@ -0,0 +1,28 @@
+using System.Diagnostics;
+
+namespace Host.Monitoring;
+
+///
+/// Produces consistent process metrics for every transport.
+///
+///
+/// Returns the uptime and process start time of the current process so both
+/// the REST and gRPC monitoring surfaces report the same values.
+///
+public static class MetricsReportService
+{
+ ///
+ /// Builds the current process metrics report.
+ ///
+ public static MetricsReport Build()
+ {
+ var process = Process.GetCurrentProcess();
+ var startedAt = DateTimeOffset.FromUnixTimeSeconds(
+ new DateTimeOffset(process.StartTime.ToUniversalTime()).ToUnixTimeSeconds());
+
+ return new MetricsReport(
+ UptimeSeconds: (DateTime.UtcNow - process.StartTime).TotalSeconds,
+ ProcessStartUnixTime: startedAt.ToUnixTimeSeconds(),
+ Time: DateTime.UtcNow);
+ }
+}
\ No newline at end of file
diff --git a/src/Host/Program.cs b/src/Host/Program.cs
index 17b0e87..5aff8ab 100644
--- a/src/Host/Program.cs
+++ b/src/Host/Program.cs
@@ -36,6 +36,7 @@
builder.Services.Configure(
builder.Configuration.GetSection("Health"));
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddAuthKitCore();
builder.Services.ConfigureApp(builder.Configuration, plugins)
diff --git a/src/Host/appsettings.json b/src/Host/appsettings.json
index d5bf9c6..55ce215 100644
--- a/src/Host/appsettings.json
+++ b/src/Host/appsettings.json
@@ -5,6 +5,10 @@
"Plugins": {
"authkit.devtokens": {
"MaxDeveloperTokens": 3
+ },
+ "authkit.example": {
+ "Greeting": "Hello from the AuthKit Example plugin!",
+ "EnableBackgroundService": true
}
},
"Encryption": {
diff --git a/src/Plugins/Solutions/DevTools/Options/DevToolsOptions.cs b/src/Plugins/Solutions/DevTools/Options/DevToolsOptions.cs
index a7f09c3..d50ee52 100644
--- a/src/Plugins/Solutions/DevTools/Options/DevToolsOptions.cs
+++ b/src/Plugins/Solutions/DevTools/Options/DevToolsOptions.cs
@@ -48,13 +48,23 @@ public sealed class DevToolsOptions
///
/// The resolved target URL, for example https://localhost:5001.
///
+ ///
+ /// The scheme mirrors the host's certificate fallback: HTTPS when the
+ /// configured certificate exists, plain HTTP/2 (h2c) otherwise so the
+ /// in-process gRPC UI can reach the host without a TLS certificate.
+ /// The same DEV_CERT_PATH and DEV_CERT_PORT_GRPC environment
+ /// variables that configure the host's Kestrel listeners are honored here.
+ ///
public string ResolveGrpcTarget() =>
- GrpcTarget ??
- (Environment.GetEnvironmentVariable("GRPC_UI_TARGET")
- ?? $"https://localhost:{GrpcPortFromEnvironment()}");
+ GrpcTarget
+ ?? Environment.GetEnvironmentVariable("GRPC_UI_TARGET")
+ ?? $"{(UsesHttpsCertificate() ? "https" : "http")}://localhost:{GrpcPortFromEnvironment()}";
private static int GrpcPortFromEnvironment() =>
int.TryParse(Environment.GetEnvironmentVariable("DEV_CERT_PORT_GRPC"), out var port)
? port
: 5001;
+
+ private static bool UsesHttpsCertificate() =>
+ File.Exists(Environment.GetEnvironmentVariable("DEV_CERT_PATH") ?? "/root/certs/devcert.pfx");
}
diff --git a/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs b/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs
index 06edcaa..8b4d76f 100644
--- a/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs
+++ b/src/Plugins/Solutions/DevTools/Runtime/GrpcDynamicInvoker.cs
@@ -140,7 +140,7 @@ private async Task InvokeUnaryAsync(
foreach (var (key, value) in headers)
metadata.Add(key, value);
- var request = input.Parser.ParseJson(requestJson);
+ var request = input.Parser.ParseJson(string.IsNullOrWhiteSpace(requestJson) ? "{}" : requestJson);
var options = new CallOptions(metadata, cancellationToken: cancellationToken);
var invoker = _channel.Value.CreateCallInvoker();
diff --git a/src/Plugins/Solutions/ExamplePlugin/Authentication/ExampleApiKeyAuthenticationHandler.cs b/src/Plugins/Solutions/ExamplePlugin/Authentication/ExampleApiKeyAuthenticationHandler.cs
new file mode 100644
index 0000000..7afb3cd
--- /dev/null
+++ b/src/Plugins/Solutions/ExamplePlugin/Authentication/ExampleApiKeyAuthenticationHandler.cs
@@ -0,0 +1,58 @@
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace ExamplePlugin.Authentication;
+
+///
+/// Reference authentication handler demonstrating how a plugin contributes its own
+/// authentication scheme without replacing the host's default handlers.
+///
+///
+/// The handler is intentionally minimal: it accepts requests carrying the
+/// X-Example-Api-Key header and authenticates them as the ExampleUser
+/// principal. Real plugins validate the credential against their own storage or
+/// signing infrastructure.
+///
+public sealed class ExampleApiKeyAuthenticationHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder)
+ : AuthenticationHandler(options, logger, encoder)
+{
+ public const string SchemeName = "Example.ApiKey";
+
+ private const string ApiKeyHeader = "X-Example-Api-Key";
+
+ ///
+ protected override Task HandleAuthenticateAsync()
+ {
+ if (!Request.Headers.TryGetValue(ApiKeyHeader, out var header) || string.IsNullOrWhiteSpace(header))
+ return Task.FromResult(AuthenticateResult.NoResult());
+
+ var principal = new ClaimsPrincipal(
+ new ClaimsIdentity(
+ [new Claim(ClaimTypes.Name, header!)],
+ SchemeName));
+
+ var ticket = new AuthenticationTicket(principal, SchemeName);
+
+ Logger.LogDebug("Authenticated example API key for {Scheme}.", SchemeName);
+
+ return Task.FromResult(AuthenticateResult.Success(ticket));
+ }
+
+ ///
+ protected override Task HandleChallengeAsync(AuthenticationProperties properties)
+ {
+ Response.StatusCode = StatusCodes.Status401Unauthorized;
+ Response.ContentType = "application/json";
+ return Response.WriteAsync(
+ JsonSerializer.Serialize(new { error = "A valid API key is required." }),
+ Context.RequestAborted);
+ }
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.cs b/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.cs
new file mode 100644
index 0000000..6926c25
--- /dev/null
+++ b/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.cs
@@ -0,0 +1,254 @@
+using AuthKit.Plugins.Abstractions;
+using AuthKit.Plugins.Abstractions.Contracts;
+using AuthKit.Plugins.Abstractions.Contracts.Plugins;
+using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes;
+using AuthKit.Plugins.Abstractions.Models;
+using ExamplePlugin.Authentication;
+using ExamplePlugin.Grpc;
+using ExamplePlugin.Hosting;
+using ExamplePlugin.Middleware;
+using ExamplePlugin.Options;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin;
+
+namespace ExamplePlugin;
+
+///
+/// End-to-end example plugin exercising the full IAuthKitPlugin contract.
+///
+///
+///
+/// This plugin is a living reference: it implements every hook the contract exposes so
+/// authors can copy the parts their plugin needs. It is intentionally small and has no
+/// external dependencies beyond ASP.NET Core and the AuthKit abstractions.
+///
+///
+/// - Metadata through (identity, capabilities, dependencies).
+/// - Configuration through ConfigureServices(IServiceCollection, AuthKitPluginContext).
+/// - Middleware through and ConfigureApplication.
+/// - Endpoints through MapEndpoints.
+/// - Security schemes, authentication, and authorization.
+/// - Structured health checks through CheckHealthAsync.
+/// - Lifecycle hooks and a plugin-owned hosted service.
+///
+///
+[PluginMetadata(
+ id: "authkit.example",
+ version: "1.0.0",
+ tags: ["example", "reference", "template"],
+ dependsOn: [],
+ capabilities: ["example", "reference"],
+ name: "ExamplePlugin",
+ displayName: "Example Plugin",
+ description: "Living reference implementing the full IAuthKitPlugin contract.",
+ author: "AuthKit Contributors",
+ license: "MIT",
+ licenseUrl: "https://opensource.org/licenses/MIT",
+ homepage: "https://example.org/example",
+ repositoryUrl: "https://example.org/example.git",
+ priority: 100,
+ isEnabled: true,
+ minHostVersion: "0.5.0"
+)]
+public sealed class ExamplePlugin : IAuthKitPlugin
+{
+ ///
+ /// Registers the plugin's options, services, and hosted services.
+ ///
+ /// The used to register plugin services.
+ /// Stable plugin context including the plugin-scoped configuration section.
+ ///
+ /// The plugin binds its options from , which is
+ /// scoped to Plugins:authkit.example (or the plugin name when the ID section is absent).
+ /// The same section is available to any plugin services through the options infrastructure.
+ ///
+ public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context)
+ {
+ services.Configure(context.Configuration);
+
+ services.AddSingleton(TimeProvider.System);
+ }
+
+ ///
+ /// The legacy middleware entry point. The host inserts this type at the plugin
+ /// middleware slot when the plugin does not implement ConfigureApplication
+ /// or ConfigurePipeline.
+ ///
+ ///
+ /// See ExampleProtocolMiddleware for the conventional middleware contract.
+ ///
+ public Type MiddlewareType => typeof(ExampleProtocolMiddleware);
+
+ ///
+ /// Registers the reference endpoints on the application's route builder.
+ ///
+ /// The application's endpoint route builder.
+ ///
+ /// Endpoints protected by participate in the host's
+ /// request-aware security resolution. The scheme name must match a key returned by
+ /// .
+ ///
+ public void MapEndpoints(IEndpointRouteBuilder endpoints)
+ {
+ endpoints.MapGet("/example/hello", ([FromServices] IOptions options) =>
+ Results.Ok(new { options.Value.Greeting }))
+ .WithMetadata(new SecuritySchemeAttribute("example-api-key"));
+
+ endpoints.MapGrpcService();
+ }
+
+ ///
+ /// Configures the plugin application middleware on the actual host application.
+ ///
+ /// The application's pipeline builder.
+ ///
+ /// When implemented, this hook takes precedence over .
+ ///
+ public void ConfigureApplication(IApplicationBuilder application)
+ {
+ application.Use(async (HttpContext context, RequestDelegate next) =>
+ {
+ context.Response.Headers.Append("X-Example-Plugin", "1.0.0");
+ await next(context);
+ });
+ }
+
+ ///
+ /// Demonstrates the plugin pipeline positioning hook.
+ ///
+ public PluginPipelinePosition PipelinePosition => PluginPipelinePosition.BeforeAuthentication;
+
+ ///
+ /// Registers a custom pipeline hook at the selected stage.
+ ///
+ public void ConfigurePipeline(IApplicationBuilder application, PluginPipelinePosition position)
+ {
+ if (position != PluginPipelinePosition.BeforeAuthentication)
+ return;
+
+ application.Use(async (context, next) =>
+ {
+ context.Items["example.pipeline.position"] = position;
+ await next(context);
+ });
+ }
+
+ ///
+ /// Performs a structured health check of the plugin's dependencies.
+ ///
+ /// The root service provider of the host application.
+ /// A token that can cancel the health check.
+ /// Structured health results for each checked capability.
+ ///
+ /// The reference implementation always reports healthy to keep the example self-contained;
+ /// real plugins resolve their dependencies from and report
+ /// degraded or unhealthy states with matching reasons, data, and tags.
+ ///
+ public Task> CheckHealthAsync(
+ IServiceProvider services,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var timeProvider = services.GetService() ?? TimeProvider.System;
+
+ return Task.FromResult>(
+ [
+ new(
+ PluginHealthStatus.Healthy,
+ "ExamplePlugin is operational.",
+ new Dictionary
+ {
+ ["capability"] = "example",
+ ["utcNow"] = timeProvider.GetUtcNow().ToString("O")
+ },
+ ["example", "readiness"])
+ ]);
+ }
+
+ ///
+ /// Contributes the plugin's OpenAPI security scheme metadata.
+ ///
+ ///
+ /// A readonly dictionary keyed by security scheme name. Keys must match the
+ /// descriptor's .
+ ///
+ public IReadOnlyDictionary GetSecuritySchemes() =>
+ new Dictionary
+ {
+ ["example-api-key"] = new()
+ {
+ Name = "example-api-key",
+ Type = AuthKitSecuritySchemeType.ApiKey,
+ In = AuthKitApiKeyLocation.Header,
+ CredentialName = "X-Example-Api-Key",
+ Description = "Reference API key scheme contributed by ExamplePlugin."
+ }
+ };
+
+ ///
+ /// Registers the reference API key authentication scheme on the host builder.
+ ///
+ /// The host authentication builder.
+ ///
+ /// The scheme is registered without changing the host default scheme. See
+ /// for the minimal handler.
+ ///
+ public void ConfigureAuthentication(AuthenticationBuilder builder)
+ {
+ builder.AddScheme(
+ ExampleApiKeyAuthenticationHandler.SchemeName,
+ _ => { });
+ }
+
+ ///
+ /// Registers a reference authorization policy used by plugin endpoints.
+ ///
+ /// The host authorization options.
+ ///
+ /// Policy names are globally significant; use namespaced names to avoid collisions
+ /// with the host or other plugins.
+ ///
+ public void ConfigureAuthorization(AuthorizationOptions options)
+ {
+ options.AddPolicy(
+ "example.read",
+ policy => policy
+ .RequireAuthenticatedUser()
+ .AddAuthenticationSchemes(ExampleApiKeyAuthenticationHandler.SchemeName));
+ }
+
+ ///
+ /// Initializes plugin runtime resources before the host is considered started.
+ ///
+ public Task OnStartingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ /// Notifies the plugin after the host has started successfully.
+ ///
+ public Task OnStartedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ /// Releases plugin runtime resources during a graceful host shutdown.
+ ///
+ public Task OnStoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ /// Returns the plugin-owned hosted services registered in the host DI container.
+ ///
+ ///
+ /// The host registers each service returned here as a singleton
+ /// and starts and stops it with the application.
+ ///
+ public IReadOnlyList GetHostedServices() => [new ExampleBackgroundService(
+ new Microsoft.Extensions.Logging.Abstractions.NullLogger(),
+ TimeProvider.System)];
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj b/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj
new file mode 100644
index 0000000..4d2a662
--- /dev/null
+++ b/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj
@@ -0,0 +1,26 @@
+
+
+ net10.0
+ preview
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+ all
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Plugins/Solutions/ExamplePlugin/Grpc/ExampleGreeterService.cs b/src/Plugins/Solutions/ExamplePlugin/Grpc/ExampleGreeterService.cs
new file mode 100644
index 0000000..0af67e9
--- /dev/null
+++ b/src/Plugins/Solutions/ExamplePlugin/Grpc/ExampleGreeterService.cs
@@ -0,0 +1,29 @@
+using ExamplePlugin.Options;
+using Grpc.Core;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace ExamplePlugin.Grpc;
+
+///
+/// Demonstrates a plugin-owned gRPC service.
+///
+///
+/// The service resolves the plugin's through the
+/// options infrastructure and echoes the configured greeting, mirroring the
+/// host's GreeterService pattern.
+///
+public sealed class ExampleGreeterService(IOptions options, ILogger logger)
+ : ExampleGreeter.ExampleGreeterBase
+{
+ ///
+ public override Task SayHello(ExampleHelloRequest request, ServerCallContext context)
+ {
+ logger.LogInformation("Example gRPC greeting received from {Name}", request.Name);
+
+ return Task.FromResult(new ExampleHelloReply
+ {
+ Message = $"{options.Value.Greeting} {request.Name}"
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/ExamplePlugin/Grpc/protos/example.proto b/src/Plugins/Solutions/ExamplePlugin/Grpc/protos/example.proto
new file mode 100644
index 0000000..5810ca7
--- /dev/null
+++ b/src/Plugins/Solutions/ExamplePlugin/Grpc/protos/example.proto
@@ -0,0 +1,21 @@
+syntax = "proto3";
+
+option csharp_namespace = "ExamplePlugin";
+
+package authkit.example;
+
+// The example greeting service definition.
+service ExampleGreeter {
+ // Sends a greeting using the plugin's configured ExampleOptions.
+ rpc SayHello (ExampleHelloRequest) returns (ExampleHelloReply);
+}
+
+// The request message containing the user's name.
+message ExampleHelloRequest {
+ string name = 1;
+}
+
+// The response message containing the greetings.
+message ExampleHelloReply {
+ string message = 1;
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/ExamplePlugin/Hosting/ExampleBackgroundService.cs b/src/Plugins/Solutions/ExamplePlugin/Hosting/ExampleBackgroundService.cs
new file mode 100644
index 0000000..fd68b41
--- /dev/null
+++ b/src/Plugins/Solutions/ExamplePlugin/Hosting/ExampleBackgroundService.cs
@@ -0,0 +1,40 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace ExamplePlugin.Hosting;
+
+///
+/// Reference background service returned through GetHostedServices.
+///
+///
+/// The host registers every service returned by a plugin's GetHostedServices
+/// as a singleton and starts and stops it with the
+/// application. The service simply counts its polling cycles to demonstrate that
+/// it was started and stopped by the host.
+///
+public sealed class ExampleBackgroundService(
+ ILogger logger,
+ TimeProvider timeProvider) : BackgroundService
+{
+ private long _cycles;
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ logger.LogInformation("Example background service started.");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ Interlocked.Increment(ref _cycles);
+ logger.LogDebug("Example background service cycle {Cycle}.", _cycles);
+ await Task.Delay(TimeSpan.FromSeconds(30), timeProvider, stoppingToken);
+ }
+ }
+
+ ///
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Example background service stopping after {Cycles} cycles.", _cycles);
+ await base.StopAsync(cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/ExamplePlugin/Middleware/ExampleProtocolMiddleware.cs b/src/Plugins/Solutions/ExamplePlugin/Middleware/ExampleProtocolMiddleware.cs
new file mode 100644
index 0000000..6b374be
--- /dev/null
+++ b/src/Plugins/Solutions/ExamplePlugin/Middleware/ExampleProtocolMiddleware.cs
@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace ExamplePlugin.Middleware;
+
+///
+/// Reference middleware following the conventional ASP.NET Core middleware pattern.
+///
+///
+/// The constructor accepts and the public
+/// InvokeAsync method takes and returns
+/// . Additional dependencies are supplied through DI.
+///
+public sealed class ExampleProtocolMiddleware(RequestDelegate next, ILogger logger)
+{
+ ///
+ /// Adds a protocol header to every response and forwards to the next delegate.
+ ///
+ public async Task InvokeAsync(HttpContext context)
+ {
+ context.Response.OnStarting(() =>
+ {
+ context.Response.Headers["X-Example-Plugin"] = "1.0.0";
+ return Task.CompletedTask;
+ });
+
+ logger.LogDebug("ExamplePlugin middleware handling {Method} {Path}.", context.Request.Method, context.Request.Path);
+ await next(context);
+ }
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/ExamplePlugin/Options/ExampleOptions.cs b/src/Plugins/Solutions/ExamplePlugin/Options/ExampleOptions.cs
new file mode 100644
index 0000000..b102ce5
--- /dev/null
+++ b/src/Plugins/Solutions/ExamplePlugin/Options/ExampleOptions.cs
@@ -0,0 +1,21 @@
+namespace ExamplePlugin.Options;
+
+///
+/// Configuration root for the ExamplePlugin.
+///
+///
+/// Values are bound from the Plugins:authkit.example configuration section
+/// through .
+///
+public sealed class ExampleOptions
+{
+ ///
+ /// A message the plugin echoes from its reference endpoint.
+ ///
+ public string Greeting { get; set; } = "Hello from ExamplePlugin!";
+
+ ///
+ /// Enables the demo background service.
+ ///
+ public bool EnableBackgroundService { get; set; } = true;
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/ExamplePlugin/Taskfile.yml b/src/Plugins/Solutions/ExamplePlugin/Taskfile.yml
new file mode 100644
index 0000000..4624ee1
--- /dev/null
+++ b/src/Plugins/Solutions/ExamplePlugin/Taskfile.yml
@@ -0,0 +1,56 @@
+version: '3'
+
+vars:
+ PROJECT_NAME: ExamplePlugin # Name of the project
+ CONFIGURATION: Debug # Build configuration (Debug/Release)
+ TARGET_FRAMEWORK: net10.0 # Target .NET framework
+ OUTPUT_DIR: bin/{{.CONFIGURATION}}/{{.TARGET_FRAMEWORK}} # Output directory for builds
+ PROJECT_FILE: '{{.PROJECT_NAME}}.csproj' # Path to the project file
+ ASSEMBLY_FILE: '{{.USER_WORKING_DIR}}/{{.OUTPUT_DIR}}/{{.PROJECT_NAME}}.dll' # Path to the compiled DLL
+ MANIFEST_OUTPUT: '{{.USER_WORKING_DIR}}/manifest.json' # Output path for the generated manifest
+ MANIFEST_GENERATOR: ../../../../tools/AuthKit.ManifestGenerator # Path to the manifest generator tool
+
+env:
+ DOTNET_CLI_TELEMETRY_OPTOUT: '1' # Disables .NET CLI telemetry
+
+tasks:
+ default:
+ desc: Lists available tasks
+ cmds:
+ - task --list
+
+ build:
+ desc: Builds the ExamplePlugin project without restoring NuGet packages
+ cmds:
+ - dotnet build {{.PROJECT_FILE}} --configuration {{.CONFIGURATION}} --no-restore
+
+ clean:
+ desc: Cleans the ExamplePlugin project (deletes build artifacts)
+ cmds:
+ - dotnet clean {{.PROJECT_FILE}} --configuration {{.CONFIGURATION}}
+
+ generate-manifest:
+ desc: Generates a manifest for ExamplePlugin using AuthKit.ManifestGenerator
+ deps:
+ - build # Ensures the project is built before generating the manifest
+ - build-manifest-generator # Ensures the manifest generator is built
+ cmds:
+ - cd {{.MANIFEST_GENERATOR}} && dotnet run --project CLI/AuthKit.ManifestGenerator.CLI.csproj --configuration {{.CONFIGURATION}} --no-build -- --input {{.ASSEMBLY_FILE}} --output {{.MANIFEST_OUTPUT}}
+ - echo "Manifest generated at {{.MANIFEST_OUTPUT}}"
+
+ build-manifest-generator:
+ desc: Builds the AuthKit.ManifestGenerator tool
+ cmds:
+ - cd {{.MANIFEST_GENERATOR}} && dotnet build --configuration {{.CONFIGURATION}} --no-restore
+
+ validate:
+ desc: Cleans and builds the project
+ cmds:
+ - task: clean
+ - task: build
+
+ ci:
+ desc: Full validation of the project and manifest generation (CI-friendly)
+ cmds:
+ - task: validate
+ - task: generate-manifest
diff --git a/src/Plugins/Solutions/ExamplePlugin/manifest.json b/src/Plugins/Solutions/ExamplePlugin/manifest.json
new file mode 100644
index 0000000..f8a13e0
--- /dev/null
+++ b/src/Plugins/Solutions/ExamplePlugin/manifest.json
@@ -0,0 +1,25 @@
+{
+ "Id": "authkit.example",
+ "Name": "ExamplePlugin",
+ "DisplayName": "Example Plugin",
+ "Description": "Living reference implementing the full IAuthKitPlugin contract.",
+ "Version": "1.0.0",
+ "Author": "AuthKit Contributors",
+ "License": "MIT",
+ "LicenseUrl": "https://opensource.org/licenses/MIT",
+ "Homepage": "https://example.org/example",
+ "RepositoryUrl": "https://example.org/example.git",
+ "Tags": [
+ "example",
+ "reference",
+ "template"
+ ],
+ "Priority": 100,
+ "IsEnabled": true,
+ "Capabilities": [
+ "example",
+ "reference"
+ ],
+ "MinHostVersion": "0.5.0",
+ "DependsOn": []
+}
\ No newline at end of file
diff --git a/tests/Host/PluginHealthEndpointTests.cs b/tests/Host/PluginHealthEndpointTests.cs
index cef0ec7..8ace3e5 100644
--- a/tests/Host/PluginHealthEndpointTests.cs
+++ b/tests/Host/PluginHealthEndpointTests.cs
@@ -5,6 +5,7 @@
using Core.KeyManagement.DTO;
using Core.KeyManagement.Interfaces;
using Host.Configuration.Pipeline;
+using Host.Monitoring;
using Host.Plugins.Loading;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -130,6 +131,7 @@ private static async Task BuildHostAsync(
builder.Services.AddSingleton(plugins);
builder.Services.Configure(_ => { });
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
var app = builder.Build();
app.MapAppEndpoints(plugins);
diff --git a/tools/AuthKit.ManifestGenerator/Core/Providers/PluginTypeResolver.cs b/tools/AuthKit.ManifestGenerator/Core/Providers/PluginTypeResolver.cs
index 2a14e8c..5f5f0f0 100644
--- a/tools/AuthKit.ManifestGenerator/Core/Providers/PluginTypeResolver.cs
+++ b/tools/AuthKit.ManifestGenerator/Core/Providers/PluginTypeResolver.cs
@@ -2,6 +2,7 @@
using System.Linq;
using System.Reflection;
using AuthKit.Plugins.Abstractions.Contracts;
+using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin;
namespace AuthKit.ManifestGenerator.Core.Providers;