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); public interface IMicrosoftTokenValidator { Task ValidateAsync(string idToken, CancellationToken cancellationToken = default); } public sealed class MicrosoftTokenValidator : IMicrosoftTokenValidator { private readonly IConfiguration _cfg; private readonly IConfigurationManager _configManager; public MicrosoftTokenValidator(IConfiguration cfg) { _cfg = cfg; // "common" endpoint: accepts both personal Microsoft accounts and work/school (Entra ID) tenants. _configManager = new ConfigurationManager( "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever()); } public MicrosoftTokenValidator(IConfiguration cfg, IConfigurationManager configManager) { _cfg = cfg; _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 { // The handler's default inbound claim map rewrites "oid"/"tid" to long Microsoft // schema URIs (an AAD-specific quirk not shared by Google's OIDC claims) -- keep // claim names as issued so FindFirst("oid") below actually matches. MapInboundClaims = false, }; // ponytail: multi-tenant "common" app -- each tenant's issuer embeds its own tenant id // (https://login.microsoftonline.com/{tenantId}/v2.0), so issuer is checked by shape below // rather than pinned to one value. Signature/audience/lifetime are still fully validated. var principal = handler.ValidateToken(idToken, new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = true, ValidAudience = audience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKeys = config.SigningKeys, ClockSkew = TimeSpan.FromMinutes(2), }, out var validatedToken); var issuer = (validatedToken as JwtSecurityToken)?.Issuer ?? principal.FindFirst("iss")?.Value ?? ""; if (!IsMicrosoftIssuer(issuer)) { throw new InvalidOperationException("Microsoft token has an unexpected issuer."); } var subject = principal.FindFirst("oid")?.Value?.Trim() ?? principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value?.Trim() ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim(); if (string.IsNullOrWhiteSpace(subject)) { throw new InvalidOperationException("Microsoft token is missing a subject."); } var email = principal.FindFirst("email")?.Value?.Trim() ?? principal.FindFirst(ClaimTypes.Email)?.Value?.Trim() ?? principal.FindFirst("preferred_username")?.Value?.Trim(); return new MicrosoftTokenPrincipal( Subject: subject, Email: email, // Microsoft ID tokens don't carry an email_verified claim; presence of an email claim // from a signature-validated token is treated as verified, same trust level Microsoft's // own APIs give it. EmailVerified: !string.IsNullOrWhiteSpace(email), GivenName: principal.FindFirst("given_name")?.Value?.Trim(), FamilyName: principal.FindFirst("family_name")?.Value?.Trim(), Name: principal.FindFirst("name")?.Value?.Trim() ?? principal.Identity?.Name?.Trim() ); } private static bool IsMicrosoftIssuer(string issuer) => issuer.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase) && issuer.EndsWith("/v2.0", StringComparison.OrdinalIgnoreCase); }