Files
jobtrackingapp/JobTrackerApi/Services/MicrosoftTokenValidator.cs
T

152 lines
6.1 KiB
C#

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<MicrosoftTokenPrincipal> 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<OpenIdConnectConfiguration> _configManager;
private readonly MicrosoftTenantPolicy _tenantPolicy;
public MicrosoftTokenValidator(IConfiguration cfg)
{
_cfg = cfg;
_tenantPolicy = MicrosoftTenantPolicy.Parse(cfg["Auth:MicrosoftTenant"], production: false);
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
$"https://login.microsoftonline.com/{_tenantPolicy.DiscoveryTenant}/v2.0/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever());
}
public MicrosoftTokenValidator(IConfiguration cfg, IConfigurationManager<OpenIdConnectConfiguration> configManager)
{
_cfg = cfg;
_tenantPolicy = MicrosoftTenantPolicy.Parse(cfg["Auth:MicrosoftTenant"], production: false);
_configManager = configManager;
}
public async Task<MicrosoftTokenPrincipal> 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);
}
}