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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ obj/
qodana.yaml
src/Plugins/Solutions/DevTokens/manifest.json
src/Plugins/Solutions/DevTools/manifest.json
issues/
*.DotSettings.user
1 change: 1 addition & 0 deletions AuthKit.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<Folder Name="/Plugins/Solutions/">
<Project Path="src/Plugins/Solutions/DevTokens/DevTokens.csproj" />
<Project Path="src/Plugins/Solutions/DevTools/DevTools.csproj" />
<Project Path="src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.csproj" />
</Folder>
<Folder Name="/Plugins/Integrations/">
<Project Path="src/Plugins/Integrations/AuthKit.Plugins.Integrations.csproj" />
Expand Down
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/"]
Expand All @@ -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 .
Expand Down
3 changes: 2 additions & 1 deletion src/Host/Configuration/Grpc/GrpcConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public static IServiceCollection AddGrpcServices(this IServiceCollection service
/// <returns>The configured <see cref="WebApplication"/> for method chaining.</returns>
public static WebApplication MapGrpcEndpoints(this WebApplication app)
{
app.MapGrpcService<GreeterService>();
app.MapGrpcService<JwksService>();
app.MapGrpcService<MonitoringService>();
return app;
}
}
48 changes: 15 additions & 33 deletions src/Host/Configuration/Pipeline/EndpointConfiguration.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -59,41 +60,22 @@ public static WebApplication MapAppEndpoints(
PluginApplicationConfiguration.MapEndpoints(app, plugins);

app.MapGet("/health", async (
HttpContext context,
IJwtKeyStore keyStore,
IReadOnlyList<LoadedPlugin> 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<string, IReadOnlyList<PluginHealthResult>>();
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");

Expand Down
16 changes: 0 additions & 16 deletions src/Host/Grpc/GreeterService.cs

This file was deleted.

117 changes: 117 additions & 0 deletions src/Host/Grpc/JwksService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using Core.KeyManagement.Interfaces;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;

namespace Host.Grpc;

public class JwksService(
ILogger<JwksService> logger,
IJwtKeyStore keyStore) : Jwks.JwksBase
{
public override Task<JwksResponse> 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<SingleKeyResponse> 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<HealthResponse> 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<KeyStoreStatsResponse> 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);
}
}
80 changes: 80 additions & 0 deletions src/Host/Grpc/MonitoringService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using AuthKit.Plugins.Abstractions.Models;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Host.Monitoring;

namespace Host.Grpc;

/// <summary>
/// Exposes host and plugin health status and basic process metrics over gRPC.
/// </summary>
/// <remarks>
/// Mirrors the REST <c>/health</c> and <c>/metrics</c> endpoints
/// (<c>EndpointConfiguration</c>). Aggregation lives in
/// <see cref="HealthReportService"/> and <see cref="MetricsReportService"/>;
/// this service only maps the shared reports to the gRPC wire format.
/// </remarks>
public class MonitoringService(
ILogger<MonitoringService> logger,
HealthReportService healthReportService) : Monitoring.MonitoringBase
{
/// <summary>
/// Returns the aggregate health status of the JWT key store and every
/// loaded plugin.
/// </summary>
public override async Task<MonitoringHealthResponse> 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;
}

/// <summary>
/// Returns basic process metrics such as uptime.
/// </summary>
public override Task<MetricsResponse> 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")
});
}
}
21 changes: 0 additions & 21 deletions src/Host/Grpc/protos/greet.proto

This file was deleted.

Loading
Loading