3081d99355
Wave 7. Mirrors the existing Google ID-token-exchange pattern (Program.cs smart-scheme dispatch, JWT bearer scheme, AuthController exchange/link/ unlink endpoints, ApplicationUser fields, reconciler columns) for Microsoft Entra ID + personal accounts via the multi-tenant "common" endpoint. Google/Microsoft sign-in previously only worked for accounts already linked to an existing local user -- there was no way to actually sign up via OAuth. Both exchange endpoints now create a new user when no match is found and Auth:AllowRegistration is true, same gate as email/password registration. Frontend: new MicrosoftAuthCard (MSAL popup flow -- Microsoft has no vanilla-JS equivalent to Google's Identity Services script) wired into the login page's provider tabs and the profile page's account-linking section. REACT_APP_MICROSOFT_CLIENT_ID env var, Auth:MicrosoftClientId config gate on the backend.
101 lines
4.5 KiB
C#
101 lines
4.5 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);
|
|
|
|
public interface IMicrosoftTokenValidator
|
|
{
|
|
Task<MicrosoftTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public sealed class MicrosoftTokenValidator : IMicrosoftTokenValidator
|
|
{
|
|
private readonly IConfiguration _cfg;
|
|
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configManager;
|
|
|
|
public MicrosoftTokenValidator(IConfiguration cfg)
|
|
{
|
|
_cfg = cfg;
|
|
// "common" endpoint: accepts both personal Microsoft accounts and work/school (Entra ID) tenants.
|
|
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
|
|
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
|
|
new OpenIdConnectConfigurationRetriever());
|
|
}
|
|
|
|
public MicrosoftTokenValidator(IConfiguration cfg, IConfigurationManager<OpenIdConnectConfiguration> configManager)
|
|
{
|
|
_cfg = cfg;
|
|
_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
|
|
{
|
|
// 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);
|
|
}
|