using AutopilotMonitor.Shared;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using System.Diagnostics.CodeAnalysis;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.AspNetCore.Http;
using System.Net;
using System.Security.Claims;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Middleware;
///
/// Middleware to manually validate JWT tokens and populate ClaimsPrincipal
/// Required for Azure Functions .NET 8 Isolated Worker (Microsoft.Identity.Web doesn't work automatically)
///
public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
{
private readonly ILogger _logger;
private readonly IConfiguration _configuration;
private readonly JwtSecurityTokenHandler _tokenHandler;
// Cache configuration managers per tenant to avoid repeated OIDC metadata fetches
// Bounded with LRU eviction to prevent memory exhaustion from malicious tenant ID flooding
private const int MaxCacheSize = 500;
private readonly Dictionary Manager, DateTime LastAccessed)> _configManagerCache;
private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1, 1);
public AuthenticationMiddleware(
ILogger logger,
IConfiguration configuration)
{
_logger = logger;
_configuration = configuration;
_tokenHandler = new JwtSecurityTokenHandler();
_configManagerCache = new Dictionary Manager, DateTime LastAccessed)>();
// Disable PII logging in production for security
Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = false;
}
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
var httpContext = context.GetHttpContext();
if (httpContext == null)
{
// Non-HTTP trigger (e.g. timer, queue) — no auth needed
await next(context);
return;
}
// Allow JWT-exempt routes (public + device/bootstrap-authenticated) through without
// JWT validation. The exempt set is DERIVED from EndpointAccessPolicyCatalog — never a
// hand-kept parallel list — so a new anonymous/device route can't drift out of sync here.
var requestPath = httpContext.Request.Path.Value ?? string.Empty;
// Sanitized copies for logging only — never used for routing/authorization decisions.
var logPath = LogSanitizer.Clean(requestPath);
var logMethod = LogSanitizer.Clean(httpContext.Request.Method);
if (SkipsJwtValidation(httpContext.Request.Method, requestPath))
{
_logger.LogDebug("[Auth Middleware] JWT-exempt route: {Method} {Path}", logMethod, logPath);
await next(context);
return;
}
// Extract Authorization header
var authHeader = httpContext.Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("[Auth Middleware] Blocked unauthenticated request to {Path}", logPath);
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(new
{
success = false,
message = "Authentication required. Please provide a valid JWT token."
});
return;
}
var token = authHeader.Substring("Bearer ".Length).Trim();
bool authenticated = false;
try
{
// First, decode the token to get the tenant ID (without validation)
var jwtToken = _tokenHandler.ReadJwtToken(token);
var tenantId = jwtToken.Claims.FirstOrDefault(c => c.Type == "tid")?.Value;
var issuer = jwtToken.Issuer;
// Reject missing or non-GUID tenant IDs BEFORE building a tenant-specific authority.
// The tid here is unverified (signature is checked later), so a garbage value would
// otherwise drive an outbound OIDC-metadata fetch to a bogus authority — a DoS/cost
// surface on malformed-token floods. Every legitimate Entra token (including consumer
// MSA, tid 9188040d-…) carries a GUID tid, so this reject is loss-free.
if (!IsValidTenantId(tenantId))
{
_logger.LogWarning("[Auth Middleware] Rejected token with missing/non-GUID tid for {Path}", logPath);
throw new SecurityTokenValidationException("Token tid claim is missing or not a valid GUID");
}
// Determine which endpoint to use based on the issuer (v1.0 vs v2.0)
var tenantSpecificAuthority = BuildTenantAuthority(issuer, tenantId);
// Get or create cached configuration manager for this tenant
IConfigurationManager? tenantConfigManager = null;
if (!await _cacheLock.WaitAsync(TimeSpan.FromSeconds(5)))
{
_logger.LogError("[Auth Middleware] Semaphore timeout acquiring OIDC config cache lock");
throw new TimeoutException("OIDC configuration cache lock timeout");
}
try
{
if (_configManagerCache.TryGetValue(tenantSpecificAuthority, out var cached))
{
tenantConfigManager = cached.Manager;
_configManagerCache[tenantSpecificAuthority] = (cached.Manager, DateTime.UtcNow);
}
else
{
var tenantMetadataAddress = $"{tenantSpecificAuthority}/.well-known/openid-configuration";
tenantConfigManager = new ConfigurationManager(
tenantMetadataAddress,
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever() { RequireHttps = true })
{
// Cache configuration for 24 hours (signing keys rarely change)
AutomaticRefreshInterval = TimeSpan.FromHours(24),
RefreshInterval = TimeSpan.FromHours(24)
};
if (_configManagerCache.Count >= MaxCacheSize)
{
// Evict least-recently-used entry
var lruKey = _configManagerCache.MinBy(kvp => kvp.Value.LastAccessed).Key;
_configManagerCache.Remove(lruKey);
_logger.LogDebug("[Auth Middleware] Evicted LRU config manager for {Authority}", lruKey);
}
_configManagerCache[tenantSpecificAuthority] = (tenantConfigManager, DateTime.UtcNow);
_logger.LogDebug("[Auth Middleware] Created new config manager for {Authority}", tenantSpecificAuthority);
}
}
finally
{
_cacheLock.Release();
}
if (tenantConfigManager == null)
{
throw new InvalidOperationException("Failed to get or create configuration manager");
}
var openIdConfig = await tenantConfigManager.GetConfigurationAsync(context.CancellationToken);
// Build the security-critical validation parameters (issuer/audience/lifetime + the
// RS256/PS256 algorithm whitelist that hard-blocks alg:none and HS-family confusion).
// Extracted to a pure seam so those rejects are unit-testable against locally-minted
// tokens without a live OIDC ConfigurationManager.
//
// Audience = the primary app registration (EntraId:ClientId) PLUS the legacy app
// (EntraId:LegacyClientId — the dual app-registration parallel window, see
// EntraAppRegistry) PLUS any additional (secondary/rotated) client IDs from
// EntraId:AdditionalClientIds. Folding the legacy id in here means configuring the
// legacy pair alone is sufficient for inbound trust — no separate AdditionalClientIds
// entry required. All extra sources unset ⇒ exactly the primary id ⇒ zero change.
var additionalClientIdsRaw = CombineAdditionalClientIdSources(
_configuration["EntraId:LegacyClientId"],
_configuration["EntraId:AdditionalClientIds"]);
var clientIds = ResolveConfiguredClientIds(
_configuration["EntraId:ClientId"], additionalClientIdsRaw, out var rejectedAdditionalEntries);
AuditClientIdTrustSet(additionalClientIdsRaw, clientIds, rejectedAdditionalEntries);
var validationParameters = BuildTokenValidationParameters(
openIdConfig.SigningKeys, clientIds);
var principal = _tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
// Get user identifier for logging (same logic as TenantHelper.GetUserIdentifier)
var userIdentifier = principal.FindFirst("upn")?.Value ??
principal.FindFirst(ClaimTypes.Upn)?.Value ??
principal.FindFirst(ClaimTypes.Email)?.Value ??
principal.FindFirst("preferred_username")?.Value ??
principal.FindFirst(ClaimTypes.Name)?.Value ??
principal.FindFirst("name")?.Value ??
"Unknown";
_logger.LogDebug("[Auth Middleware] Authenticated: {User}", userIdentifier);
// Set the principal on both the HTTP context AND the FunctionContext
// This is critical for Azure Functions Isolated Worker (.NET 8)
httpContext.User = principal;
context.Items["ClaimsPrincipal"] = principal;
authenticated = true;
// NOTE: MCP usage tracking moved to McpQuotaEnforcementMiddleware (Codex finding,
// 2026-07-07): incrementing here — BEFORE the quota decision — raced the quota read
// (the exact-limit request depended on whether the fire-and-forget increment won),
// and denied (403/429) requests inflated the counters. The quota middleware now
// counts strictly check-then-increment, and only requests that are actually served.
}
catch (SecurityTokenValidationException ex)
{
_logger.LogWarning("[Auth Middleware] Token validation failed: {Error}", ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "[Auth Middleware] Error validating token");
}
if (!authenticated)
{
_logger.LogWarning("[Auth Middleware] Blocked request with invalid token to {Path}", logPath);
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(new
{
success = false,
message = "Authentication required. Please provide a valid JWT token."
});
return;
}
await next(context);
}
///
/// Whether a route is exempt from JWT validation in this middleware. SINGLE SOURCE OF TRUTH is
/// : a route is JWT-exempt iff its policy is
/// (fully public) or
/// (authenticated later, in-function, via
/// device certificate / bootstrap token — not JWT). Deriving from the catalog instead of a
/// hand-kept parallel allowlist prevents drift: a new anonymous/device route registered in the
/// catalog is automatically exempt here, so it can never be 401'd before
/// PolicyEnforcementMiddleware honors its policy (the bug that broke the ticket-gated
/// diagnostics/download route). Unregistered routes (FindPolicy == null) are fail-closed:
/// JWT required. Method-aware so e.g. an unexpected verb on a public path is not waved through.
///
internal static bool SkipsJwtValidation(string httpMethod, string path)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, path);
return entry is { Policy: EndpointPolicy.PublicAnonymous or EndpointPolicy.DeviceOrBootstrapAuth };
}
///
/// Pre-signature gate on the (unverified) tid claim: it must be a GUID. Rejecting a
/// missing/garbage tid here prevents an outbound OIDC-metadata fetch to a bogus authority on
/// malformed-token floods. Loss-free — every legitimate Entra token carries a GUID tid.
///
internal static bool IsValidTenantId([NotNullWhen(true)] string? tid) => Guid.TryParse(tid, out _);
///
/// Maps a token issuer + tenant id to the OIDC authority to fetch signing keys from. Entra v1.0
/// tokens carry an sts.windows.net issuer and use the bare authority; v2.0 tokens use the
/// /v2.0 authority. The tenant id is assumed already validated by .
///
internal static string BuildTenantAuthority(string issuer, string tenantId)
{
var isV1Token = issuer != null && issuer.Contains("sts.windows.net");
return isV1Token
? $"{Constants.EntraLoginBaseUrl}/{tenantId}" // v1.0
: $"{Constants.EntraLoginBaseUrl}/{tenantId}/v2.0"; // v2.0
}
///
/// Builds the token validation parameters the middleware validates against: multi-tenant issuer
/// format check, audience = {clientId} / api://{clientId} for each accepted client
/// ID, lifetime, and — the algorithm-confusion defence — RequireSignedTokens plus the
/// RS256/PS256 whitelist that hard-blocks alg:none and the HS family. Signing keys are supplied by
/// the caller (the tenant's OIDC config at runtime; a test key under unit test). Accepts one or
/// more client IDs (params) so a second/rotated app registration can be trusted alongside
/// the primary — see .
///
internal static TokenValidationParameters BuildTokenValidationParameters(
IEnumerable signingKeys, params string?[] clientIds)
{
var keys = signingKeys as ICollection ?? signingKeys?.ToList() ?? new List();
return new TokenValidationParameters
{
// For multi-tenant, validate issuer format but accept any tenant.
ValidateIssuer = true,
IssuerValidator = (iss, token, parameters) =>
{
if (iss.StartsWith(Constants.EntraLoginBaseUrl + "/") ||
iss.StartsWith("https://sts.windows.net/"))
{
return iss;
}
throw new SecurityTokenInvalidIssuerException($"Invalid issuer: {iss}");
},
ValidateAudience = true,
ValidAudiences = BuildValidAudiences(clientIds),
ValidateLifetime = true,
// Signature validation enabled (token minted for api:///access_as_user).
ValidateIssuerSigningKey = true,
RequireSignedTokens = true,
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
{
var matched = keys.Where(k => k.KeyId == kid).ToList();
return matched.Any() ? matched : keys;
},
// Algorithm whitelist: RS256 today, PS256 for Entra's announced rollout.
// Hard-blocks HS-family and "none" (algorithm-confusion defence).
ValidAlgorithms = new[] { "RS256", "PS256" }
};
}
///
/// Expands a set of app (client) IDs into the accepted token audiences: for each id, both the
/// bare {id} and the api://{id} form. Empty/whitespace ids are dropped and the
/// result is de-duplicated case-insensitively (order preserved). An empty input yields an empty
/// audience set, which — with ValidateAudience=true — rejects every token (fail-closed).
///
internal static string[] BuildValidAudiences(IEnumerable? clientIds)
{
return (clientIds ?? Array.Empty())
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.SelectMany(id => new[] { id, $"api://{id}" })
.ToArray();
}
///
/// Resolves the configured accepted app (client) IDs: the primary EntraId:ClientId plus an
/// optional comma/semicolon/whitespace-separated EntraId:AdditionalClientIds. Additional
/// entries must be GUIDs (a client ID is always a GUID); non-GUID entries are dropped —
/// fail-closed, they could never match a token's aud anyway — and reported via
/// so can make
/// the misconfiguration operator-visible. Accepted entries are normalized to the canonical
/// lowercase dashed GUID form (Entra aud claims are lowercase; audience comparison is
/// ordinal), so brace/case/no-dash variants an operator might paste still match. The result is
/// de-duplicated case-insensitively (primary first, verbatim — its handling is unchanged). When
/// AdditionalClientIds is unset the result is exactly the primary id — today's behaviour, zero
/// change — so adding a second app registration's client ID to that setting is the ONLY step
/// needed to trust a second app during a tenant-move window
/// (tasks/migration-cross-tenant-runbook.md).
///
///
/// Joins multiple "additional client id" config sources (e.g. EntraId:LegacyClientId and
/// EntraId:AdditionalClientIds) into one comma-separated string for
/// . Null/whitespace sources are skipped; all-empty
/// input yields null (identical to "no additional ids configured").
///
internal static string? CombineAdditionalClientIdSources(params string?[] sources)
{
var nonEmpty = sources.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s!.Trim()).ToArray();
return nonEmpty.Length > 0 ? string.Join(",", nonEmpty) : null;
}
internal static string[] ResolveConfiguredClientIds(
string? primaryClientId, string? additionalClientIds, out string[] rejectedAdditionalEntries)
{
var ids = new List { primaryClientId };
var rejected = new List();
if (!string.IsNullOrWhiteSpace(additionalClientIds))
{
foreach (var entry in additionalClientIds.Split(
new[] { ',', ';', ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries))
{
if (Guid.TryParse(entry.Trim(), out var clientId))
{
ids.Add(clientId.ToString("D"));
}
else
{
rejected.Add(entry.Trim());
}
}
}
rejectedAdditionalEntries = rejected.ToArray();
return ids
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
///
/// Sentinel-free "already audited" marker: the last EntraId:AdditionalClientIds raw value
/// this instance logged about. Initial null intentionally equals the unset-config case —
/// an unset seam has nothing to report, so skipping it is correct, and the first non-null value
/// (or any change) triggers a fresh audit.
///
private string? _auditedAdditionalClientIds;
///
/// Operator-visible audit of the audience trust set (daily-review follow-up, 2026-07-14):
/// expanding the set of trusted app registrations via EntraId:AdditionalClientIds is
/// security-relevant and used to be completely silent — a typo'd GUID was indistinguishable
/// from "the new app registration doesn't work" during a tenant-move window. Logs once per
/// distinct config value per instance, at Warning because nothing below Warning reaches
/// App Insights from the worker. Rejected entries are truncated before logging in case an
/// operator accidentally pastes a secret into the setting.
///
private void AuditClientIdTrustSet(
string? rawAdditionalClientIds, string[] clientIds, string[] rejectedAdditionalEntries)
{
if (string.Equals(_auditedAdditionalClientIds, rawAdditionalClientIds, StringComparison.Ordinal))
{
return;
}
// Benign race on concurrent first requests: at worst a duplicate audit line.
_auditedAdditionalClientIds = rawAdditionalClientIds;
if (rejectedAdditionalEntries.Length > 0)
{
_logger.LogWarning(
"[Auth Middleware] EntraId:AdditionalClientIds contains {Count} malformed (non-GUID) entries — ignored: {Entries}",
rejectedAdditionalEntries.Length,
string.Join(", ", rejectedAdditionalEntries.Select(TruncateForLog)));
}
if (clientIds.Length > 1)
{
_logger.LogWarning(
"[Auth Middleware] Token audience trust set expanded to {Count} client IDs via EntraId:AdditionalClientIds: {ClientIds}",
clientIds.Length,
string.Join(", ", clientIds));
}
}
///
/// Truncates a rejected config entry for logging: enough to identify the typo, never the full
/// value (an operator could accidentally paste a client secret into the setting).
///
internal static string TruncateForLog(string entry) =>
entry.Length <= 8 ? entry : $"{entry.Substring(0, 8)}…({entry.Length} chars)";
}