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.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Security.Claims;
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
private readonly Dictionary> _configManagerCache;
private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1, 1);
// Track if we're currently initializing a tenant to prevent race conditions
private readonly HashSet _initializingTenants = new HashSet();
private readonly object _initLock = new object();
public AuthenticationMiddleware(
ILogger logger,
IConfiguration configuration)
{
_logger = logger;
_configuration = configuration;
_tokenHandler = new JwtSecurityTokenHandler();
_configManagerCache = new Dictionary>();
// 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)
{
// Extract Authorization header
var authHeader = httpContext.Request.Headers.Authorization.ToString();
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var token = authHeader.Substring("Bearer ".Length).Trim();
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;
// Determine which endpoint to use based on the issuer (v1.0 vs v2.0)
var isV1Token = issuer.Contains("sts.windows.net");
var tenantSpecificAuthority = tenantId != null
? (isV1Token
? $"https://login.microsoftonline.com/{tenantId}" // v1.0
: $"https://login.microsoftonline.com/{tenantId}/v2.0") // v2.0
: "https://login.microsoftonline.com/common/v2.0";
// Get or create cached configuration manager for this tenant
IConfigurationManager? tenantConfigManager = null;
await _cacheLock.WaitAsync();
try
{
if (!_configManagerCache.TryGetValue(tenantSpecificAuthority, out tenantConfigManager))
{
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)
};
_configManagerCache[tenantSpecificAuthority] = tenantConfigManager;
_logger.LogInformation($"[Auth Middleware] Created new config manager for {tenantSpecificAuthority}");
}
}
finally
{
_cacheLock.Release();
}
if (tenantConfigManager == null)
{
throw new InvalidOperationException("Failed to get or create configuration manager");
}
var openIdConfig = await tenantConfigManager.GetConfigurationAsync(CancellationToken.None);
// Set up token validation parameters with OIDC signing keys
var validationParameters = new TokenValidationParameters
{
// For multi-tenant, validate issuer format but accept any tenant
ValidateIssuer = true,
IssuerValidator = (issuer, token, parameters) =>
{
// Accept issuers from any Azure AD tenant
if (issuer.StartsWith("https://login.microsoftonline.com/") ||
issuer.StartsWith("https://sts.windows.net/"))
{
return issuer;
}
throw new SecurityTokenInvalidIssuerException($"Invalid issuer: {issuer}");
},
ValidateAudience = true,
ValidAudiences = new[]
{
_configuration["AzureAd:ClientId"],
$"api://{_configuration["AzureAd:ClientId"]}"
},
ValidateLifetime = true,
// Signature validation enabled
// Token is requested for backend API (api:///access_as_user)
ValidateIssuerSigningKey = true,
RequireSignedTokens = true,
// Use key resolver for dynamic key resolution
IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
{
var matchedKeys = openIdConfig.SigningKeys.Where(k => k.KeyId == kid).ToList();
return matchedKeys.Any() ? matchedKeys : openIdConfig.SigningKeys;
},
// Set algorithm validators to ensure we only accept RS256
ValidAlgorithms = new[] { "RS256" }
};
// Validate the token using JwtSecurityTokenHandler (synchronous API)
try
{
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.LogInformation($"[Auth Middleware] ✅ Authenticated: {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;
}
catch (SecurityTokenException ex)
{
_logger.LogWarning($"[Auth Middleware] ❌ Token validation failed: {ex.Message}");
throw;
}
}
catch (SecurityTokenValidationException ex)
{
_logger.LogWarning($"[Auth Middleware] ❌ Token validation failed: {ex.Message}");
}
catch (Exception ex)
{
_logger.LogError(ex, "[Auth Middleware] ❌ Error validating token");
}
}
else
{
_logger.LogDebug("[Auth Middleware] No Bearer token found in Authorization header");
}
}
await next(context);
}
}