Files
jobtrackingapp/JobTrackerApi.Tests/MicrosoftTokenValidatorTests.cs
T

147 lines
6.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 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<InvalidOperationException>(() => MicrosoftTenantPolicy.Parse(null, production: true));
Assert.Equal("common", MicrosoftTenantPolicy.Parse(null, production: false).Mode);
Assert.Throws<InvalidOperationException>(() => 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<SecurityTokenException>(() => 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<Exception>(() => 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<SecurityTokenException>(() => 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<SecurityTokenException>(() => validator.ValidateAsync(CreateToken(key, TenantA, ObjectId, audience: "wrong-client")));
await Assert.ThrowsAnyAsync<SecurityTokenException>(() => validator.ValidateAsync(CreateToken(otherKey, TenantA, ObjectId)));
await Assert.ThrowsAnyAsync<SecurityTokenException>(() => 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<string, string?>
{
["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<IConfigurationManager<OpenIdConnectConfiguration>>();
manager.Setup(x => x.GetConfigurationAsync(It.IsAny<CancellationToken>())).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<Claim>
{
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)));
}
}