using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; namespace JobTrackerApi.Services; public sealed record MicrosoftTokenPrincipal( string Subject, string? Email, bool EmailVerified, string? GivenName, string? FamilyName, string? Name, string? TenantId = null, string? ObjectId = null); public interface IMicrosoftTokenValidator { Task ValidateAsync(string idToken, CancellationToken cancellationToken = default); } public sealed class MicrosoftTenantPolicy { public const string ConsumerTenantId = "9188040d-6c67-4c5b-b112-36a304b66dad"; private MicrosoftTenantPolicy(string mode, string discoveryTenant) { Mode = mode; DiscoveryTenant = discoveryTenant; } public string Mode { get; } public string DiscoveryTenant { get; } public static MicrosoftTenantPolicy Parse(string? configured, bool production) { var value = configured?.Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(value)) { if (production) throw new InvalidOperationException("Auth:MicrosoftTenant is required in Production when Microsoft sign-in is enabled."); value = "common"; } if (value is "common" or "organizations" or "consumers") return new MicrosoftTenantPolicy(value, value); if (!Guid.TryParse(value, out var tenantId)) throw new InvalidOperationException("Auth:MicrosoftTenant must be common, organizations, consumers, or a tenant GUID."); var normalized = tenantId.ToString("D"); return new MicrosoftTenantPolicy(normalized, normalized); } public string Validate(string issuer, string? tenantClaim) { if (!Guid.TryParse(tenantClaim, out var tenantId)) throw new SecurityTokenInvalidIssuerException("Microsoft token is missing a GUID-shaped tid claim."); var normalizedTenant = tenantId.ToString("D"); var expectedIssuer = $"https://login.microsoftonline.com/{normalizedTenant}/v2.0"; if (!string.Equals(issuer, expectedIssuer, StringComparison.OrdinalIgnoreCase)) throw new SecurityTokenInvalidIssuerException("Microsoft token issuer does not match its tid claim."); var allowed = Mode switch { "common" => true, "organizations" => normalizedTenant != ConsumerTenantId, "consumers" => normalizedTenant == ConsumerTenantId, _ => normalizedTenant == Mode, }; if (!allowed) throw new SecurityTokenInvalidIssuerException("Microsoft token tenant is not allowed by Auth:MicrosoftTenant."); return normalizedTenant; } } public sealed class MicrosoftTokenValidator : IMicrosoftTokenValidator { private readonly IConfiguration _cfg; private readonly IConfigurationManager _configManager; private readonly MicrosoftTenantPolicy _tenantPolicy; public MicrosoftTokenValidator(IConfiguration cfg) { _cfg = cfg; _tenantPolicy = MicrosoftTenantPolicy.Parse(cfg["Auth:MicrosoftTenant"], production: false); _configManager = new ConfigurationManager( $"https://login.microsoftonline.com/{_tenantPolicy.DiscoveryTenant}/v2.0/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever()); } public MicrosoftTokenValidator(IConfiguration cfg, IConfigurationManager configManager) { _cfg = cfg; _tenantPolicy = MicrosoftTenantPolicy.Parse(cfg["Auth:MicrosoftTenant"], production: false); _configManager = configManager; } public async Task ValidateAsync(string idToken, CancellationToken cancellationToken = default) { var audience = (_cfg["Auth:MicrosoftClientId"] ?? "").Trim(); if (string.IsNullOrWhiteSpace(audience)) throw new InvalidOperationException("Microsoft sign-in is not configured."); var config = await _configManager.GetConfigurationAsync(cancellationToken); var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; var principal = handler.ValidateToken(idToken, new TokenValidationParameters { ValidateIssuer = true, IssuerValidator = (issuer, token, _) => { var tid = (token as JwtSecurityToken)?.Claims.FirstOrDefault(x => x.Type == "tid")?.Value; _tenantPolicy.Validate(issuer, tid); return issuer; }, ValidateAudience = true, ValidAudience = audience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKeys = config.SigningKeys, ClockSkew = TimeSpan.FromMinutes(2), }, out var validatedToken); var jwt = validatedToken as JwtSecurityToken ?? throw new SecurityTokenException("Microsoft token was not a JWT."); var tenantId = _tenantPolicy.Validate(jwt.Issuer, principal.FindFirst("tid")?.Value); var objectClaim = principal.FindFirst("oid")?.Value?.Trim(); if (!Guid.TryParse(objectClaim, out var objectId)) throw new SecurityTokenException("Microsoft token is missing a GUID-shaped oid claim."); var normalizedObjectId = objectId.ToString("D"); var email = principal.FindFirst("email")?.Value?.Trim() ?? principal.FindFirst(ClaimTypes.Email)?.Value?.Trim() ?? principal.FindFirst("preferred_username")?.Value?.Trim(); return new MicrosoftTokenPrincipal( Subject: normalizedObjectId, Email: email, EmailVerified: false, GivenName: principal.FindFirst("given_name")?.Value?.Trim(), FamilyName: principal.FindFirst("family_name")?.Value?.Trim(), Name: principal.FindFirst("name")?.Value?.Trim() ?? principal.Identity?.Name?.Trim(), TenantId: tenantId, ObjectId: normalizedObjectId); } }