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.
78 lines
3.1 KiB
C#
78 lines
3.1 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using JobTrackerApi.Services;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.IdentityModel.Protocols;
|
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
public sealed class MicrosoftTokenValidatorTests
|
|
{
|
|
private static (IConfiguration Config, Mock<IConfigurationManager<OpenIdConnectConfiguration>> ConfigManager, SymmetricSecurityKey Key) BuildHarness()
|
|
{
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:MicrosoftClientId"] = "client-123" })
|
|
.Build();
|
|
|
|
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super-secret-signing-key-super-secret"));
|
|
var oidc = new OpenIdConnectConfiguration();
|
|
oidc.SigningKeys.Add(signingKey);
|
|
|
|
var configManager = new Mock<IConfigurationManager<OpenIdConnectConfiguration>>();
|
|
configManager.Setup(x => x.GetConfigurationAsync(It.IsAny<CancellationToken>())).ReturnsAsync(oidc);
|
|
|
|
return (config, configManager, signingKey);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_accepts_tenant_scoped_issuer_and_maps_oid_to_subject()
|
|
{
|
|
var (config, configManager, signingKey) = BuildHarness();
|
|
|
|
var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
|
|
issuer: "https://login.microsoftonline.com/9f2c1e3a-tenant/v2.0",
|
|
audience: "client-123",
|
|
claims: new[]
|
|
{
|
|
new Claim("oid", "ms-subject-1"),
|
|
new Claim("email", "demo@example.com"),
|
|
new Claim("given_name", "Demo"),
|
|
new Claim("family_name", "User"),
|
|
new Claim("name", "Demo User"),
|
|
},
|
|
expires: DateTime.UtcNow.AddMinutes(10),
|
|
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)));
|
|
|
|
var validator = new MicrosoftTokenValidator(config, configManager.Object);
|
|
var result = await validator.ValidateAsync(token);
|
|
|
|
Assert.Equal("ms-subject-1", result.Subject);
|
|
Assert.Equal("demo@example.com", result.Email);
|
|
Assert.True(result.EmailVerified);
|
|
Assert.Equal("Demo", result.GivenName);
|
|
Assert.Equal("User", result.FamilyName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_rejects_non_microsoft_issuer()
|
|
{
|
|
var (config, configManager, signingKey) = BuildHarness();
|
|
|
|
var token = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
|
|
issuer: "https://evil.example.com/v2.0",
|
|
audience: "client-123",
|
|
claims: new[] { new Claim("oid", "ms-subject-1") },
|
|
expires: DateTime.UtcNow.AddMinutes(10),
|
|
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)));
|
|
|
|
var validator = new MicrosoftTokenValidator(config, configManager.Object);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => validator.ValidateAsync(token));
|
|
}
|
|
}
|