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;