feat/Update_Controllers_to_Allow_for_Premium_Membership

This commit is contained in:
cesnimda
2026-08-03 09:17:28 +02:00
parent de937d25dc
commit c3f4a57195
187 changed files with 26062 additions and 991 deletions
@@ -6,30 +6,98 @@ using Microsoft.IdentityModel.Tokens;
namespace JobTrackerApi.Services;
public sealed record MicrosoftTokenPrincipal(string Subject, string? Email, bool EmailVerified, string? GivenName, string? FamilyName, string? Name);
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;
// "common" endpoint: accepts both personal Microsoft accounts and work/school (Entra ID) tenants.
_tenantPolicy = MicrosoftTenantPolicy.Parse(cfg["Auth:MicrosoftTenant"], production: false);
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
$"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;
}
@@ -37,24 +105,19 @@ public sealed class MicrosoftTokenValidator : IMicrosoftTokenValidator
{
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 handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
var principal = handler.ValidateToken(idToken, new TokenValidationParameters
{
ValidateIssuer = false,
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,
@@ -63,38 +126,26 @@ public sealed class MicrosoftTokenValidator : IMicrosoftTokenValidator
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 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: subject,
Subject: normalizedObjectId,
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),
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()
);
Name: principal.FindFirst("name")?.Value?.Trim() ?? principal.Identity?.Name?.Trim(),
TenantId: tenantId,
ObjectId: normalizedObjectId);
}
private static bool IsMicrosoftIssuer(string issuer)
=> issuer.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase)
&& issuer.EndsWith("/v2.0", StringComparison.OrdinalIgnoreCase);
}