147 lines
6.1 KiB
C#
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)));
|
|
}
|
|
}
|