Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class ClaimsPropagationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ClaimsPropagationMiddleware> _logger;
|
||||
|
||||
public ClaimsPropagationMiddleware(RequestDelegate next, ILogger<ClaimsPropagationMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (GatewayRoutes.IsSystemPath(context.Request.Path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var principal = context.User;
|
||||
|
||||
SetHeaderIfMissing(context, "sub", principal.FindFirstValue("sub"));
|
||||
SetHeaderIfMissing(context, "tid", principal.FindFirstValue("tid"));
|
||||
|
||||
var scopes = principal.FindAll("scope").Select(c => c.Value).ToArray();
|
||||
if (scopes.Length > 0)
|
||||
{
|
||||
SetHeaderIfMissing(context, "scope", string.Join(" ", scopes));
|
||||
}
|
||||
|
||||
var cnfJson = principal.FindFirstValue("cnf");
|
||||
if (!string.IsNullOrWhiteSpace(cnfJson))
|
||||
{
|
||||
context.Items[GatewayContextKeys.CnfJson] = cnfJson;
|
||||
|
||||
if (TryParseCnf(cnfJson, out var jkt))
|
||||
{
|
||||
context.Items[GatewayContextKeys.DpopThumbprint] = jkt;
|
||||
SetHeaderIfMissing(context, "cnf.jkt", jkt);
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private void SetHeaderIfMissing(HttpContext context, string name, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.ContainsKey(name))
|
||||
{
|
||||
context.Request.Headers[name] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Request header {Header} already set; skipping claim propagation", name);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseCnf(string json, out string? jkt)
|
||||
{
|
||||
jkt = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.TryGetProperty("jkt", out var jktElement) &&
|
||||
jktElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
jkt = jktElement.GetString();
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(jkt);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class CorrelationIdMiddleware
|
||||
{
|
||||
public const string HeaderName = "X-Correlation-Id";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public CorrelationIdMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue(HeaderName, out var headerValue) &&
|
||||
!string.IsNullOrWhiteSpace(headerValue))
|
||||
{
|
||||
context.TraceIdentifier = headerValue.ToString();
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(context.TraceIdentifier))
|
||||
{
|
||||
context.TraceIdentifier = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
context.Response.Headers[HeaderName] = context.TraceIdentifier;
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public static class GatewayContextKeys
|
||||
{
|
||||
public const string TenantId = "Gateway.TenantId";
|
||||
public const string ProjectId = "Gateway.ProjectId";
|
||||
public const string Actor = "Gateway.Actor";
|
||||
public const string Scopes = "Gateway.Scopes";
|
||||
public const string DpopThumbprint = "Gateway.DpopThumbprint";
|
||||
public const string MtlsThumbprint = "Gateway.MtlsThumbprint";
|
||||
public const string CnfJson = "Gateway.CnfJson";
|
||||
public const string IsAnonymous = "Gateway.IsAnonymous";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public static class GatewayRoutes
|
||||
{
|
||||
private static readonly HashSet<string> SystemPaths = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/health",
|
||||
"/health/live",
|
||||
"/health/ready",
|
||||
"/health/startup",
|
||||
"/metrics",
|
||||
"/openapi.json",
|
||||
"/openapi.yaml",
|
||||
"/.well-known/openapi"
|
||||
};
|
||||
|
||||
public static bool IsSystemPath(PathString path)
|
||||
{
|
||||
var value = path.Value ?? string.Empty;
|
||||
return SystemPaths.Contains(value);
|
||||
}
|
||||
|
||||
public static bool IsHealthPath(PathString path)
|
||||
{
|
||||
var value = path.Value ?? string.Empty;
|
||||
return value.StartsWith("/health", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool IsMetricsPath(PathString path)
|
||||
{
|
||||
var value = path.Value ?? string.Empty;
|
||||
return string.Equals(value, "/metrics", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Gateway.WebService.Services;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class HealthCheckMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public HealthCheckMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, GatewayServiceStatus status, GatewayMetrics metrics)
|
||||
{
|
||||
if (GatewayRoutes.IsMetricsPath(context.Request.Path))
|
||||
{
|
||||
await WriteMetricsAsync(context, metrics);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!GatewayRoutes.IsHealthPath(context.Request.Path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
if (path.Equals("/health/live", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await WriteHealthAsync(context, StatusCodes.Status200OK, "live", status);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.Equals("/health/ready", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var readyStatus = status.IsReady ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
|
||||
await WriteHealthAsync(context, readyStatus, "ready", status);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.Equals("/health/startup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var startupStatus = status.IsStarted ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
|
||||
await WriteHealthAsync(context, startupStatus, "startup", status);
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteHealthAsync(context, StatusCodes.Status200OK, "ok", status);
|
||||
}
|
||||
|
||||
private static Task WriteHealthAsync(HttpContext context, int statusCode, string status, GatewayServiceStatus serviceStatus)
|
||||
{
|
||||
context.Response.StatusCode = statusCode;
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
status,
|
||||
started = serviceStatus.IsStarted,
|
||||
ready = serviceStatus.IsReady,
|
||||
traceId = context.TraceIdentifier
|
||||
};
|
||||
|
||||
return context.Response.WriteAsJsonAsync(payload, JsonOptions, context.RequestAborted);
|
||||
}
|
||||
|
||||
private static Task WriteMetricsAsync(HttpContext context, GatewayMetrics metrics)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.ContentType = "text/plain; version=0.0.4";
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# TYPE gateway_active_connections gauge");
|
||||
builder.Append("gateway_active_connections ").AppendLine(metrics.GetActiveConnections().ToString());
|
||||
builder.AppendLine("# TYPE gateway_registered_endpoints gauge");
|
||||
builder.Append("gateway_registered_endpoints ").AppendLine(metrics.GetRegisteredEndpoints().ToString());
|
||||
|
||||
return context.Response.WriteAsync(builder.ToString(), context.RequestAborted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware that enforces the Gateway identity header policy:
|
||||
/// 1. Strips all reserved identity headers from incoming requests (prevents spoofing)
|
||||
/// 2. Computes effective identity from validated principal claims
|
||||
/// 3. Writes downstream identity headers for microservice consumption
|
||||
/// 4. Stores normalized identity context in HttpContext.Items
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This middleware replaces the legacy ClaimsPropagationMiddleware and TenantMiddleware
|
||||
/// which used "set-if-missing" semantics that allowed client header spoofing.
|
||||
/// </remarks>
|
||||
public sealed class IdentityHeaderPolicyMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<IdentityHeaderPolicyMiddleware> _logger;
|
||||
private readonly IdentityHeaderPolicyOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Reserved identity headers that must never be trusted from external clients.
|
||||
/// These are stripped from incoming requests and overwritten from validated claims.
|
||||
/// </summary>
|
||||
private static readonly string[] ReservedHeaders =
|
||||
[
|
||||
// StellaOps canonical headers
|
||||
"X-StellaOps-Tenant",
|
||||
"X-StellaOps-Project",
|
||||
"X-StellaOps-Actor",
|
||||
"X-StellaOps-Scopes",
|
||||
"X-StellaOps-Client",
|
||||
// Legacy Stella headers (compatibility)
|
||||
"X-Stella-Tenant",
|
||||
"X-Stella-Project",
|
||||
"X-Stella-Actor",
|
||||
"X-Stella-Scopes",
|
||||
// Raw claim headers (internal/legacy pass-through)
|
||||
"sub",
|
||||
"tid",
|
||||
"scope",
|
||||
"scp",
|
||||
"cnf",
|
||||
"cnf.jkt"
|
||||
];
|
||||
|
||||
public IdentityHeaderPolicyMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<IdentityHeaderPolicyMiddleware> logger,
|
||||
IdentityHeaderPolicyOptions options)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Skip processing for system paths (health, metrics, openapi, etc.)
|
||||
if (GatewayRoutes.IsSystemPath(context.Request.Path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Strip all reserved identity headers from incoming request
|
||||
StripReservedHeaders(context);
|
||||
|
||||
// Step 2: Extract identity from validated principal
|
||||
var identity = ExtractIdentity(context);
|
||||
|
||||
// Step 3: Store normalized identity in HttpContext.Items
|
||||
StoreIdentityContext(context, identity);
|
||||
|
||||
// Step 4: Write downstream identity headers
|
||||
WriteDownstreamHeaders(context, identity);
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private void StripReservedHeaders(HttpContext context)
|
||||
{
|
||||
foreach (var header in ReservedHeaders)
|
||||
{
|
||||
if (context.Request.Headers.ContainsKey(header))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Stripped reserved identity header {Header} from request {TraceId}",
|
||||
header,
|
||||
context.TraceIdentifier);
|
||||
context.Request.Headers.Remove(header);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IdentityContext ExtractIdentity(HttpContext context)
|
||||
{
|
||||
var principal = context.User;
|
||||
var isAuthenticated = principal.Identity?.IsAuthenticated == true;
|
||||
|
||||
if (!isAuthenticated)
|
||||
{
|
||||
return new IdentityContext
|
||||
{
|
||||
IsAnonymous = true,
|
||||
Actor = "anonymous",
|
||||
Scopes = _options.AnonymousScopes ?? []
|
||||
};
|
||||
}
|
||||
|
||||
// Extract subject (actor)
|
||||
var actor = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
|
||||
|
||||
// Extract tenant - try canonical claim first, then legacy 'tid'
|
||||
var tenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
|
||||
?? principal.FindFirstValue("tid");
|
||||
|
||||
// Extract project (optional)
|
||||
var project = principal.FindFirstValue(StellaOpsClaimTypes.Project);
|
||||
|
||||
// Extract scopes - try 'scp' claims first (individual items), then 'scope' (space-separated)
|
||||
var scopes = ExtractScopes(principal);
|
||||
|
||||
// Extract cnf (confirmation claim) for DPoP/sender constraint
|
||||
var cnfJson = principal.FindFirstValue("cnf");
|
||||
string? dpopThumbprint = null;
|
||||
if (!string.IsNullOrWhiteSpace(cnfJson))
|
||||
{
|
||||
TryParseCnfThumbprint(cnfJson, out dpopThumbprint);
|
||||
}
|
||||
|
||||
return new IdentityContext
|
||||
{
|
||||
IsAnonymous = false,
|
||||
Actor = actor,
|
||||
Tenant = tenant,
|
||||
Project = project,
|
||||
Scopes = scopes,
|
||||
CnfJson = cnfJson,
|
||||
DpopThumbprint = dpopThumbprint
|
||||
};
|
||||
}
|
||||
|
||||
private static HashSet<string> ExtractScopes(ClaimsPrincipal principal)
|
||||
{
|
||||
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// First try individual scope claims (scp)
|
||||
var scpClaims = principal.FindAll(StellaOpsClaimTypes.ScopeItem);
|
||||
foreach (var claim in scpClaims)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
scopes.Add(claim.Value.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
// If no scp claims, try space-separated scope claim
|
||||
if (scopes.Count == 0)
|
||||
{
|
||||
var scopeClaims = principal.FindAll(StellaOpsClaimTypes.Scope);
|
||||
foreach (var claim in scopeClaims)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
scopes.Add(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
private void StoreIdentityContext(HttpContext context, IdentityContext identity)
|
||||
{
|
||||
context.Items[GatewayContextKeys.IsAnonymous] = identity.IsAnonymous;
|
||||
|
||||
if (!string.IsNullOrEmpty(identity.Actor))
|
||||
{
|
||||
context.Items[GatewayContextKeys.Actor] = identity.Actor;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(identity.Tenant))
|
||||
{
|
||||
context.Items[GatewayContextKeys.TenantId] = identity.Tenant;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(identity.Project))
|
||||
{
|
||||
context.Items[GatewayContextKeys.ProjectId] = identity.Project;
|
||||
}
|
||||
|
||||
if (identity.Scopes.Count > 0)
|
||||
{
|
||||
context.Items[GatewayContextKeys.Scopes] = identity.Scopes;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(identity.CnfJson))
|
||||
{
|
||||
context.Items[GatewayContextKeys.CnfJson] = identity.CnfJson;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(identity.DpopThumbprint))
|
||||
{
|
||||
context.Items[GatewayContextKeys.DpopThumbprint] = identity.DpopThumbprint;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteDownstreamHeaders(HttpContext context, IdentityContext identity)
|
||||
{
|
||||
var headers = context.Request.Headers;
|
||||
|
||||
// Actor header
|
||||
if (!string.IsNullOrEmpty(identity.Actor))
|
||||
{
|
||||
headers["X-StellaOps-Actor"] = identity.Actor;
|
||||
if (_options.EnableLegacyHeaders)
|
||||
{
|
||||
headers["X-Stella-Actor"] = identity.Actor;
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant header
|
||||
if (!string.IsNullOrEmpty(identity.Tenant))
|
||||
{
|
||||
headers["X-StellaOps-Tenant"] = identity.Tenant;
|
||||
if (_options.EnableLegacyHeaders)
|
||||
{
|
||||
headers["X-Stella-Tenant"] = identity.Tenant;
|
||||
}
|
||||
}
|
||||
|
||||
// Project header (optional)
|
||||
if (!string.IsNullOrEmpty(identity.Project))
|
||||
{
|
||||
headers["X-StellaOps-Project"] = identity.Project;
|
||||
if (_options.EnableLegacyHeaders)
|
||||
{
|
||||
headers["X-Stella-Project"] = identity.Project;
|
||||
}
|
||||
}
|
||||
|
||||
// Scopes header (space-delimited, sorted for determinism)
|
||||
if (identity.Scopes.Count > 0)
|
||||
{
|
||||
var sortedScopes = identity.Scopes.OrderBy(s => s, StringComparer.Ordinal);
|
||||
var scopesValue = string.Join(" ", sortedScopes);
|
||||
headers["X-StellaOps-Scopes"] = scopesValue;
|
||||
if (_options.EnableLegacyHeaders)
|
||||
{
|
||||
headers["X-Stella-Scopes"] = scopesValue;
|
||||
}
|
||||
}
|
||||
else if (identity.IsAnonymous)
|
||||
{
|
||||
// Explicit empty scopes for anonymous to prevent ambiguity
|
||||
headers["X-StellaOps-Scopes"] = string.Empty;
|
||||
if (_options.EnableLegacyHeaders)
|
||||
{
|
||||
headers["X-Stella-Scopes"] = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
// DPoP thumbprint (if present)
|
||||
if (!string.IsNullOrEmpty(identity.DpopThumbprint))
|
||||
{
|
||||
headers["cnf.jkt"] = identity.DpopThumbprint;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseCnfThumbprint(string json, out string? jkt)
|
||||
{
|
||||
jkt = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.TryGetProperty("jkt", out var jktElement) &&
|
||||
jktElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
jkt = jktElement.GetString();
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(jkt);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class IdentityContext
|
||||
{
|
||||
public bool IsAnonymous { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public string? Tenant { get; init; }
|
||||
public string? Project { get; init; }
|
||||
public HashSet<string> Scopes { get; init; } = [];
|
||||
public string? CnfJson { get; init; }
|
||||
public string? DpopThumbprint { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the identity header policy middleware.
|
||||
/// </summary>
|
||||
public sealed class IdentityHeaderPolicyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable legacy X-Stella-* headers in addition to X-StellaOps-* headers.
|
||||
/// Default: true (for migration compatibility).
|
||||
/// </summary>
|
||||
public bool EnableLegacyHeaders { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Scopes to assign to anonymous requests.
|
||||
/// Default: empty (no scopes).
|
||||
/// </summary>
|
||||
public HashSet<string>? AnonymousScopes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow client-provided scope headers in offline/pre-prod mode.
|
||||
/// Default: false (forbidden for security).
|
||||
/// </summary>
|
||||
public bool AllowScopeHeaderOverride { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class RequestRoutingMiddleware
|
||||
{
|
||||
private readonly TransportDispatchMiddleware _dispatchMiddleware;
|
||||
|
||||
public RequestRoutingMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<RequestRoutingMiddleware> logger,
|
||||
ILogger<TransportDispatchMiddleware> dispatchLogger)
|
||||
{
|
||||
_dispatchMiddleware = new TransportDispatchMiddleware(next, dispatchLogger);
|
||||
}
|
||||
|
||||
public Task InvokeAsync(HttpContext context, ITransportClient transportClient, IGlobalRoutingState routingState)
|
||||
{
|
||||
return _dispatchMiddleware.Invoke(context, transportClient, routingState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Gateway.WebService.Configuration;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class SenderConstraintMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IOptions<GatewayOptions> _options;
|
||||
private readonly IDpopProofValidator _dpopValidator;
|
||||
private readonly ILogger<SenderConstraintMiddleware> _logger;
|
||||
|
||||
public SenderConstraintMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptions<GatewayOptions> options,
|
||||
IDpopProofValidator dpopValidator,
|
||||
ILogger<SenderConstraintMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_options = options;
|
||||
_dpopValidator = dpopValidator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (GatewayRoutes.IsSystemPath(context.Request.Path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var authOptions = _options.Value.Auth;
|
||||
if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
if (authOptions.AllowAnonymous)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteUnauthorizedAsync(context, "unauthenticated", "Authentication required.");
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmation = ParseConfirmation(context.User.FindFirstValue("cnf"));
|
||||
if (confirmation.Raw is not null)
|
||||
{
|
||||
context.Items[GatewayContextKeys.CnfJson] = confirmation.Raw;
|
||||
}
|
||||
|
||||
var requireDpop = authOptions.DpopEnabled && (!authOptions.MtlsEnabled || !string.IsNullOrWhiteSpace(confirmation.Jkt));
|
||||
var requireMtls = authOptions.MtlsEnabled && (!authOptions.DpopEnabled || !string.IsNullOrWhiteSpace(confirmation.X5tS256));
|
||||
|
||||
if (authOptions.DpopEnabled && authOptions.MtlsEnabled &&
|
||||
string.IsNullOrWhiteSpace(confirmation.Jkt) && string.IsNullOrWhiteSpace(confirmation.X5tS256))
|
||||
{
|
||||
requireDpop = true;
|
||||
requireMtls = true;
|
||||
}
|
||||
|
||||
if (requireDpop && !await ValidateDpopAsync(context, confirmation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireMtls && !await ValidateMtlsAsync(context, confirmation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateDpopAsync(HttpContext context, ConfirmationClaim confirmation)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("DPoP", out var proofHeader) ||
|
||||
string.IsNullOrWhiteSpace(proofHeader))
|
||||
{
|
||||
_logger.LogWarning("Missing DPoP proof for request {TraceId}", context.TraceIdentifier);
|
||||
await WriteUnauthorizedAsync(context, "dpop_missing", "DPoP proof is required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var proof = proofHeader.ToString();
|
||||
var requestUri = new Uri(context.Request.GetDisplayUrl());
|
||||
|
||||
var result = await _dpopValidator.ValidateAsync(
|
||||
proof,
|
||||
context.Request.Method,
|
||||
requestUri,
|
||||
cancellationToken: context.RequestAborted);
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
_logger.LogWarning("DPoP validation failed for {TraceId}: {Error}", context.TraceIdentifier, result.ErrorDescription);
|
||||
await WriteUnauthorizedAsync(context, result.ErrorCode ?? "dpop_invalid", result.ErrorDescription ?? "DPoP proof invalid.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.PublicKey is not JsonWebKey jwk)
|
||||
{
|
||||
_logger.LogWarning("DPoP validation failed for {TraceId}: JWK missing", context.TraceIdentifier);
|
||||
await WriteUnauthorizedAsync(context, "dpop_key_invalid", "DPoP proof must include a valid JWK.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var thumbprint = ComputeJwkThumbprint(jwk);
|
||||
context.Items[GatewayContextKeys.DpopThumbprint] = thumbprint;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(confirmation.Jkt) &&
|
||||
!string.Equals(confirmation.Jkt, thumbprint, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("DPoP thumbprint mismatch for {TraceId}", context.TraceIdentifier);
|
||||
await WriteUnauthorizedAsync(context, "dpop_thumbprint_mismatch", "DPoP proof does not match token confirmation.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateMtlsAsync(HttpContext context, ConfirmationClaim confirmation)
|
||||
{
|
||||
var certificate = context.Connection.ClientCertificate;
|
||||
if (certificate is null)
|
||||
{
|
||||
_logger.LogWarning("mTLS required but no client certificate provided for {TraceId}", context.TraceIdentifier);
|
||||
await WriteUnauthorizedAsync(context, "mtls_required", "Client certificate required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var hash = certificate.GetCertHash(HashAlgorithmName.SHA256);
|
||||
var thumbprint = Base64UrlEncoder.Encode(hash);
|
||||
context.Items[GatewayContextKeys.MtlsThumbprint] = thumbprint;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(confirmation.X5tS256) &&
|
||||
!string.Equals(confirmation.X5tS256, thumbprint, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("mTLS thumbprint mismatch for {TraceId}", context.TraceIdentifier);
|
||||
await WriteUnauthorizedAsync(context, "mtls_thumbprint_mismatch", "Client certificate does not match token confirmation.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ComputeJwkThumbprint(JsonWebKey jwk)
|
||||
{
|
||||
object rawThumbprint = jwk.ComputeJwkThumbprint();
|
||||
return rawThumbprint switch
|
||||
{
|
||||
string thumbprint => thumbprint,
|
||||
byte[] bytes => Base64UrlEncoder.Encode(bytes),
|
||||
_ => throw new InvalidOperationException("Unable to compute JWK thumbprint.")
|
||||
};
|
||||
}
|
||||
|
||||
private static ConfirmationClaim ParseConfirmation(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return ConfirmationClaim.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
root.TryGetProperty("jkt", out var jktElement);
|
||||
root.TryGetProperty("x5t#S256", out var x5tElement);
|
||||
|
||||
return new ConfirmationClaim(
|
||||
json,
|
||||
jktElement.ValueKind == JsonValueKind.String ? jktElement.GetString() : null,
|
||||
x5tElement.ValueKind == JsonValueKind.String ? x5tElement.GetString() : null);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ConfirmationClaim.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static Task WriteUnauthorizedAsync(HttpContext context, string error, string message)
|
||||
{
|
||||
if (context.Response.HasStarted)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
error,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
};
|
||||
|
||||
return context.Response.WriteAsJsonAsync(payload, context.RequestAborted);
|
||||
}
|
||||
|
||||
private sealed record ConfirmationClaim(string? Raw, string? Jkt, string? X5tS256)
|
||||
{
|
||||
public static ConfirmationClaim Empty { get; } = new(null, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.Gateway.WebService.Middleware;
|
||||
|
||||
public sealed class TenantMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<TenantMiddleware> _logger;
|
||||
|
||||
public TenantMiddleware(RequestDelegate next, ILogger<TenantMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (GatewayRoutes.IsSystemPath(context.Request.Path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = context.User.FindFirstValue("tid");
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
context.Items[GatewayContextKeys.TenantId] = tenantId;
|
||||
if (!context.Request.Headers.ContainsKey("tid"))
|
||||
{
|
||||
context.Request.Headers["tid"] = tenantId;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No tenant claim found on request {TraceId}", context.TraceIdentifier);
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user