Merge pull request 'feat(auth): Microsoft OAuth sign-in/link + self-serve signup via Google/Microsoft' (#22) from feature/wave7-oauth-signup into main
This commit was merged in pull request #22.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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);
|
||||
}
|
||||
@@ -241,6 +241,9 @@ public static class StartupInitializationExtensions
|
||||
`GoogleSubject` longtext NULL,
|
||||
`GoogleEmail` longtext NULL,
|
||||
`GoogleLinkedAt` datetime(6) NULL,
|
||||
`MicrosoftSubject` longtext NULL,
|
||||
`MicrosoftEmail` longtext NULL,
|
||||
`MicrosoftLinkedAt` datetime(6) NULL,
|
||||
PRIMARY KEY (`Id`)
|
||||
) CHARACTER SET=utf8mb4;
|
||||
|
||||
@@ -353,7 +356,10 @@ public static class StartupInitializationExtensions
|
||||
"AvatarImageDataUrl" TEXT NULL,
|
||||
"GoogleSubject" TEXT NULL,
|
||||
"GoogleEmail" TEXT NULL,
|
||||
"GoogleLinkedAt" TEXT NULL
|
||||
"GoogleLinkedAt" TEXT NULL,
|
||||
"MicrosoftSubject" TEXT NULL,
|
||||
"MicrosoftEmail" TEXT NULL,
|
||||
"MicrosoftLinkedAt" TEXT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
@@ -431,6 +437,9 @@ public static class StartupInitializationExtensions
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftSubject TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftEmail TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftLinkedAt TEXT NULL;");
|
||||
|
||||
static void EnsureUserRuleSettingsTable(DbConnection c)
|
||||
{
|
||||
@@ -757,6 +766,9 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftSubject` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftEmail` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftLinkedAt` datetime NULL;");
|
||||
|
||||
if (!HasMySqlTable(conn, "RuleSettings"))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user