using StellaOps.Auth.Abstractions; using StellaOps.Router.Common.Identity; using System.Security.Claims; using System.Text.Json; namespace StellaOps.Gateway.WebService.Middleware; /// /// 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 /// /// /// This middleware replaces the legacy ClaimsPropagationMiddleware and TenantMiddleware /// which used "set-if-missing" semantics that allowed client header spoofing. /// public sealed class IdentityHeaderPolicyMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly IdentityHeaderPolicyOptions _options; private static readonly char[] TenantClaimDelimiters = [' ', ',', ';', '\t', '\r', '\n']; private static readonly string[] TenantRequestHeaders = ["X-StellaOps-Tenant", "X-Stella-Tenant", "X-Tenant-Id"]; /// /// Reserved identity headers that must never be trusted from external clients. /// These are stripped from incoming requests and overwritten from validated claims. /// 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", // Headers used by downstream services in header-based auth mode "X-Scopes", "X-Tenant-Id", // Gateway-issued signed identity envelope headers "X-StellaOps-Identity-Envelope", "X-StellaOps-Identity-Envelope-Signature", "X-StellaOps-Identity-Envelope-Algorithm", // Raw claim headers (internal/legacy pass-through) "sub", "tid", "scope", "scp", "cnf", "cnf.jkt", // Auth headers consumed by the gateway — strip before proxying // so backends trust identity headers instead of re-validating JWT. "Authorization", "DPoP" ]; public IdentityHeaderPolicyMiddleware( RequestDelegate next, ILogger 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; } var requestedTenant = CaptureRequestedTenant(context.Request.Headers); var clientSuppliedTenantHeader = HasClientSuppliedTenantHeader(context.Request.Headers); // Step 1: Strip all reserved identity headers from incoming request StripReservedHeaders(context, ShouldPreserveAuthHeaders(context.Request.Path)); // Step 2: Extract identity from validated principal var identity = ExtractIdentity(context); if (clientSuppliedTenantHeader) { LogTenantHeaderTelemetry( context, identity, requestedTenant); } if (!identity.IsAnonymous && !string.IsNullOrWhiteSpace(requestedTenant) && !string.IsNullOrWhiteSpace(identity.Tenant) && !string.Equals(requestedTenant, identity.Tenant, StringComparison.Ordinal)) { if (!TryApplyTenantOverride(context, identity, requestedTenant)) { await context.Response.WriteAsJsonAsync( new { error = "tenant_override_forbidden", message = "Requested tenant override is not permitted for this principal." }, cancellationToken: context.RequestAborted).ConfigureAwait(false); return; } } // 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, bool preserveAuthHeaders) { foreach (var header in ReservedHeaders) { // Preserve Authorization/DPoP for routes that need JWT pass-through if (preserveAuthHeaders && (header == "Authorization" || header == "DPoP")) { continue; } 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 bool ShouldPreserveAuthHeaders(PathString path) { if (_options.JwtPassthroughPrefixes.Count == 0) { return false; } var configuredMatch = _options.JwtPassthroughPrefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase)); if (!configuredMatch) { return false; } if (_options.ApprovedAuthPassthroughPrefixes.Count == 0) { return false; } var approvedMatch = _options.ApprovedAuthPassthroughPrefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase)); if (approvedMatch) { return true; } _logger.LogWarning( "Gateway route {Path} requested Authorization/DPoP passthrough but prefix is not in approved allow-list. Headers will be stripped.", path.Value); return false; } private static bool HasClientSuppliedTenantHeader(IHeaderDictionary headers) => TenantRequestHeaders.Any(headers.ContainsKey); private static string? CaptureRequestedTenant(IHeaderDictionary headers) { foreach (var header in TenantRequestHeaders) { if (!headers.TryGetValue(header, out var value)) { continue; } var normalized = NormalizeTenant(value.ToString()); if (!string.IsNullOrWhiteSpace(normalized)) { return normalized; } } return null; } private void LogTenantHeaderTelemetry(HttpContext context, IdentityContext identity, string? requestedTenant) { var resolvedTenant = identity.Tenant; var actor = identity.Actor ?? "unknown"; if (string.IsNullOrWhiteSpace(requestedTenant)) { _logger.LogInformation( "Gateway stripped client-supplied tenant headers with empty value. Route={Route} Actor={Actor} ResolvedTenant={ResolvedTenant}", context.Request.Path.Value, actor, resolvedTenant); return; } if (string.IsNullOrWhiteSpace(resolvedTenant)) { _logger.LogWarning( "Gateway stripped tenant override attempt but authenticated principal has no resolved tenant. Route={Route} Actor={Actor} RequestedTenant={RequestedTenant}", context.Request.Path.Value, actor, requestedTenant); return; } if (!string.Equals(requestedTenant, resolvedTenant, StringComparison.Ordinal)) { _logger.LogWarning( "Gateway stripped tenant override attempt. Route={Route} Actor={Actor} RequestedTenant={RequestedTenant} ResolvedTenant={ResolvedTenant}", context.Request.Path.Value, actor, requestedTenant, resolvedTenant); return; } _logger.LogInformation( "Gateway stripped client-supplied tenant header that matched resolved tenant. Route={Route} Actor={Actor} Tenant={Tenant}", context.Request.Path.Value, actor, resolvedTenant); } private bool TryApplyTenantOverride(HttpContext context, IdentityContext identity, string requestedTenant) { if (!_options.EnableTenantOverride) { _logger.LogWarning( "Tenant override rejected because feature is disabled. Route={Route} Actor={Actor} RequestedTenant={RequestedTenant} ResolvedTenant={ResolvedTenant}", context.Request.Path.Value, identity.Actor ?? "unknown", requestedTenant, identity.Tenant); context.Response.StatusCode = StatusCodes.Status403Forbidden; return false; } var allowedTenants = ResolveAllowedTenants(context.User); if (!allowedTenants.Contains(requestedTenant)) { _logger.LogWarning( "Tenant override rejected because requested tenant is not in allow-list. Route={Route} Actor={Actor} RequestedTenant={RequestedTenant} AllowedTenants={AllowedTenants}", context.Request.Path.Value, identity.Actor ?? "unknown", requestedTenant, string.Join(",", allowedTenants.OrderBy(static tenant => tenant, StringComparer.Ordinal))); context.Response.StatusCode = StatusCodes.Status403Forbidden; return false; } identity.Tenant = requestedTenant; _logger.LogInformation( "Tenant override accepted. Route={Route} Actor={Actor} SelectedTenant={SelectedTenant}", context.Request.Path.Value, identity.Actor ?? "unknown", identity.Tenant); return true; } private static HashSet ResolveAllowedTenants(ClaimsPrincipal principal) { var tenants = new HashSet(StringComparer.Ordinal); foreach (var claim in principal.FindAll(StellaOpsClaimTypes.AllowedTenants)) { if (string.IsNullOrWhiteSpace(claim.Value)) { continue; } foreach (var raw in claim.Value.Split(TenantClaimDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var normalized = NormalizeTenant(raw); if (!string.IsNullOrWhiteSpace(normalized)) { tenants.Add(normalized); } } } var selectedTenant = NormalizeTenant(principal.FindFirstValue(StellaOpsClaimTypes.Tenant) ?? principal.FindFirstValue("tid")); if (!string.IsNullOrWhiteSpace(selectedTenant)) { tenants.Add(selectedTenant); } return tenants; } private static string? NormalizeTenant(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); 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 from validated claims. Legacy 'tid' remains compatibility-only. var tenant = NormalizeTenant(principal.FindFirstValue(StellaOpsClaimTypes.Tenant) ?? principal.FindFirstValue("tid")); if (string.IsNullOrWhiteSpace(tenant)) { _logger.LogWarning( "Authenticated request {TraceId} missing tenant claim; downstream tenant headers will be omitted.", context.TraceIdentifier); } // 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); var roles = principal.FindAll(ClaimTypes.Role) .Select(claim => claim.Value) .Where(value => !string.IsNullOrWhiteSpace(value)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(value => value, StringComparer.Ordinal) .ToArray(); // 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, Roles = roles, CnfJson = cnfJson, DpopThumbprint = dpopThumbprint }; } private static HashSet ExtractScopes(ClaimsPrincipal principal) { var scopes = new HashSet(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); } } } } // Expand coarse OIDC scopes to fine-grained service scopes. // This bridges the gap between Authority-registered scopes (e.g. "scheduler:read") // and the fine-grained scopes that downstream services expect (e.g. "scheduler.runs.read"). ExpandCoarseScopes(scopes); return scopes; } /// /// Expands coarse OIDC scopes into fine-grained service scopes. /// Pattern: "{service}:{action}" expands to "{service}.{resource}.{action}" for known resources. /// private static void ExpandCoarseScopes(HashSet scopes) { // scheduler:read -> scheduler.schedules.read, scheduler.runs.read // scheduler:operate -> scheduler.schedules.write, scheduler.runs.write, scheduler.runs.preview, scheduler.runs.manage if (scopes.Contains("scheduler:read")) { scopes.Add("scheduler.schedules.read"); scopes.Add("scheduler.runs.read"); } if (scopes.Contains("scheduler:operate")) { scopes.Add("scheduler.schedules.write"); scopes.Add("scheduler.runs.write"); scopes.Add("scheduler.runs.preview"); scopes.Add("scheduler.runs.manage"); } } 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; headers["X-Tenant-Id"] = 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; headers["X-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; headers["X-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; } if (_options.EmitIdentityEnvelope && !string.IsNullOrWhiteSpace(_options.IdentityEnvelopeSigningKey)) { var envelope = new GatewayIdentityEnvelope { Issuer = _options.IdentityEnvelopeIssuer, Subject = identity.Actor ?? "anonymous", Tenant = identity.Tenant, Project = identity.Project, Scopes = identity.Scopes.OrderBy(scope => scope, StringComparer.Ordinal).ToArray(), Roles = identity.Roles, SenderConfirmation = identity.DpopThumbprint, CorrelationId = context.TraceIdentifier, IssuedAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.Add(_options.IdentityEnvelopeTtl) }; var signature = GatewayIdentityEnvelopeCodec.Sign(envelope, _options.IdentityEnvelopeSigningKey!); headers["X-StellaOps-Identity-Envelope"] = signature.Payload; headers["X-StellaOps-Identity-Envelope-Signature"] = signature.Signature; headers["X-StellaOps-Identity-Envelope-Algorithm"] = signature.Algorithm; } } 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; set; } public string? Project { get; init; } public HashSet Scopes { get; init; } = []; public IReadOnlyList Roles { get; init; } = []; public string? CnfJson { get; init; } public string? DpopThumbprint { get; init; } } } /// /// Configuration options for the identity header policy middleware. /// public sealed class IdentityHeaderPolicyOptions { /// /// Enable legacy X-Stella-* headers in addition to X-StellaOps-* headers. /// Default: true (for migration compatibility). /// public bool EnableLegacyHeaders { get; set; } = true; /// /// Scopes to assign to anonymous requests. /// Default: empty (no scopes). /// public HashSet? AnonymousScopes { get; set; } /// /// Allow client-provided scope headers in offline/pre-prod mode. /// Default: false (forbidden for security). /// public bool AllowScopeHeaderOverride { get; set; } = false; /// /// When true, emit a signed identity envelope headers for downstream trust. /// public bool EmitIdentityEnvelope { get; set; } = true; /// /// Shared signing key used to sign identity envelopes. /// public string? IdentityEnvelopeSigningKey { get; set; } /// /// Identity envelope issuer identifier. /// public string IdentityEnvelopeIssuer { get; set; } = "stellaops-gateway-router"; /// /// Identity envelope validity window. /// public TimeSpan IdentityEnvelopeTtl { get; set; } = TimeSpan.FromMinutes(2); /// /// Route prefixes where Authorization and DPoP headers should be preserved /// (passed through to the upstream service) instead of stripped. /// Use this for upstream services that require JWT validation themselves /// (e.g., Authority admin API at /console). /// Default: empty (strip auth headers for all routes). /// public List JwtPassthroughPrefixes { get; set; } = []; /// /// Approved route prefixes where auth passthrough is allowed when configured. /// public List ApprovedAuthPassthroughPrefixes { get; set; } = [ "/connect", "/console", "/api/admin" ]; /// /// Enables per-request tenant override using tenant headers and allow-list claims. /// Default: false. /// public bool EnableTenantOverride { get; set; } = false; }