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 const string TenantA = "11111111-1111-1111-1111-111111111111"; private const string TenantB = "22222222-2222-2222-2222-222222222222"; private const string ObjectId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; [Fact] public async Task Valid_common_token_returns_the_tenant_object_pair_and_unverified_email_metadata() { var (validator, key) = BuildHarness("common"); var result = await validator.ValidateAsync(CreateToken(key, TenantA, ObjectId)); Assert.Equal(TenantA, result.TenantId); Assert.Equal(ObjectId, result.ObjectId); Assert.Equal(ObjectId, result.Subject); // Legacy consumer until SEC-004 switches persistence. Assert.Equal("demo@example.com", result.Email); Assert.False(result.EmailVerified); } [Fact] public void Production_requires_an_explicit_tenant_policy() { Assert.Throws(() => MicrosoftTenantPolicy.Parse(null, production: true)); Assert.Equal("common", MicrosoftTenantPolicy.Parse(null, production: false).Mode); Assert.Throws(() => MicrosoftTenantPolicy.Parse("not-a-tenant", production: false)); } [Theory] [InlineData("common", TenantA, true)] [InlineData("common", MicrosoftTenantPolicy.ConsumerTenantId, true)] [InlineData("organizations", TenantA, true)] [InlineData("organizations", MicrosoftTenantPolicy.ConsumerTenantId, false)] [InlineData("consumers", MicrosoftTenantPolicy.ConsumerTenantId, true)] [InlineData("consumers", TenantA, false)] [InlineData(TenantA, TenantA, true)] [InlineData(TenantA, TenantB, false)] public async Task Tenant_modes_accept_only_their_documented_accounts(string mode, string tokenTenant, bool accepted) { var (validator, key) = BuildHarness(mode); var token = CreateToken(key, tokenTenant, ObjectId); if (accepted) Assert.Equal(tokenTenant, (await validator.ValidateAsync(token)).TenantId); else await Assert.ThrowsAnyAsync(() => validator.ValidateAsync(token)); } [Theory] [InlineData(null, ObjectId)] [InlineData("not-a-guid", ObjectId)] [InlineData(TenantA, null)] [InlineData(TenantA, "not-a-guid")] public async Task Tid_and_oid_are_required_guids(string? tenantId, string? objectId) { var (validator, key) = BuildHarness("common"); await Assert.ThrowsAnyAsync(() => validator.ValidateAsync(CreateToken(key, tenantId, objectId))); } [Fact] public async Task Issuer_must_exactly_match_the_signed_tid() { var (validator, key) = BuildHarness("common"); var token = CreateToken(key, TenantA, ObjectId, issuer: $"https://login.microsoftonline.com/{TenantB}/v2.0"); await Assert.ThrowsAnyAsync(() => validator.ValidateAsync(token)); } [Fact] public async Task Audience_signature_and_lifetime_remain_enforced() { var (validator, key) = BuildHarness("common"); var otherKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("different-signing-key-different-signing-key")); await Assert.ThrowsAnyAsync(() => validator.ValidateAsync(CreateToken(key, TenantA, ObjectId, audience: "wrong-client"))); await Assert.ThrowsAnyAsync(() => validator.ValidateAsync(CreateToken(otherKey, TenantA, ObjectId))); await Assert.ThrowsAnyAsync(() => validator.ValidateAsync(CreateToken(key, TenantA, ObjectId, expires: DateTime.UtcNow.AddMinutes(-10)))); } [Fact] public async Task Same_oid_in_two_tenants_produces_two_distinct_stable_pairs() { var (validator, key) = BuildHarness("common"); var first = await validator.ValidateAsync(CreateToken(key, TenantA, ObjectId)); var second = await validator.ValidateAsync(CreateToken(key, TenantB, ObjectId)); Assert.NotEqual(first.TenantId, second.TenantId); Assert.Equal(first.ObjectId, second.ObjectId); } private static (MicrosoftTokenValidator Validator, SymmetricSecurityKey Key) BuildHarness(string tenantMode) { var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Auth:MicrosoftClientId"] = "client-123", ["Auth:MicrosoftTenant"] = tenantMode, }).Build(); var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super-secret-signing-key-super-secret")); var oidc = new OpenIdConnectConfiguration(); oidc.SigningKeys.Add(key); var manager = new Mock>(); manager.Setup(x => x.GetConfigurationAsync(It.IsAny())).ReturnsAsync(oidc); return (new MicrosoftTokenValidator(config, manager.Object), key); } private static string CreateToken( SecurityKey key, string? tenantId, string? objectId, string? issuer = null, string audience = "client-123", DateTime? expires = null) { var claims = new List { new("email", "demo@example.com"), new("given_name", "Demo"), new("family_name", "User"), }; if (tenantId is not null) claims.Add(new Claim("tid", tenantId)); if (objectId is not null) claims.Add(new Claim("oid", objectId)); issuer ??= $"https://login.microsoftonline.com/{tenantId}/v2.0"; return new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( issuer, audience, claims, expires: expires ?? DateTime.UtcNow.AddMinutes(10), signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256))); } }