using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using System.Threading; using System.Threading.Tasks; using GB5Shared.Auth.Jwt; using Microsoft.IdentityModel.Tokens; using Moq; using Xunit; namespace GB5SharedTests; /// /// Covers GB5Shared.Auth.Jwt.JwtAccessTokenIssuer — the shared HMAC-SHA256 issuance mechanics /// that DXPJwtService and ClientJwtService both delegate to after the consolidation pass. Uses a /// minimal fake IJwtClaimsSource, since the whole point of this abstraction is that it doesn't /// need to know anything about a specific consumer's claim shape. /// public class JwtAccessTokenIssuerTests { private const string SigningKey = "test-signing-key-at-least-32-bytes-long-for-hmacsha256!!"; private const string TestIssuer = "GB5-Shared-Test"; private const string TestAudience = "GB5-Shared-Test-Audience"; private sealed class FakeClaimsSource : IJwtClaimsSource { public int SubjectId { get; init; } public string Role { get; init; } = string.Empty; public IEnumerable ToClaims() => new[] { new Claim("subject_id", SubjectId.ToString()), new Claim("role", Role) }; } private static JwtAccessTokenIssuer BuildIssuer() { var resolver = new Mock(); resolver.Setup(r => r.GetSigningKeyAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(SigningKey); return new JwtAccessTokenIssuer(resolver.Object); } [Fact] public async Task Test_IssueAsync_ProducesJwt_WithExpectedClaimsIssuerAudience() { var issuer = BuildIssuer(); var claims = new FakeClaimsSource { SubjectId = 99, Role = "SOME_ROLE" }; var result = await issuer.IssueAsync(claims, "some/vault/path", TestIssuer, TestAudience, 15, CancellationToken.None); var jwt = new JwtSecurityTokenHandler().ReadJwtToken(result.AccessToken); Assert.Equal(TestIssuer, jwt.Issuer); Assert.Contains(TestAudience, jwt.Audiences); Assert.Equal("99", jwt.Claims.Single(c => c.Type == "subject_id").Value); Assert.Equal("SOME_ROLE", jwt.Claims.Single(c => c.Type == "role").Value); } [Fact] public async Task Test_IssueAsync_ExpiresOn_MatchesJwtExpClaim_AndReflectsAccessTokenMinutes() { var issuer = BuildIssuer(); var claims = new FakeClaimsSource { SubjectId = 1, Role = "R" }; var result = await issuer.IssueAsync(claims, "some/vault/path", TestIssuer, TestAudience, 7, CancellationToken.None); var jwt = new JwtSecurityTokenHandler().ReadJwtToken(result.AccessToken); // The one thing this whole extraction exists to guarantee: the reported ExpiresOn and the // JWT's own exp claim can never drift apart, because both come from one computed value. Assert.True(Math.Abs((jwt.ValidTo - result.ExpiresOn).TotalSeconds) < 2); Assert.True(Math.Abs((result.ExpiresOn - DateTime.UtcNow.AddMinutes(7)).TotalSeconds) < 5); } [Fact] public async Task Test_IssueAsync_SignatureVerifies_WithSameSigningKey() { var issuer = BuildIssuer(); var claims = new FakeClaimsSource { SubjectId = 1, Role = "R" }; var result = await issuer.IssueAsync(claims, "some/vault/path", TestIssuer, TestAudience, 15, CancellationToken.None); var validationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = TestIssuer, ValidateAudience = true, ValidAudience = TestAudience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey)), ClockSkew = TimeSpan.FromSeconds(30) }; var principal = new JwtSecurityTokenHandler().ValidateToken(result.AccessToken, validationParameters, out var validatedToken); Assert.NotNull(principal); Assert.NotNull(validatedToken); } [Fact] public async Task Test_IssueAsync_SignatureRejected_WithWrongKey() { var issuer = BuildIssuer(); var claims = new FakeClaimsSource { SubjectId = 1, Role = "R" }; var result = await issuer.IssueAsync(claims, "some/vault/path", TestIssuer, TestAudience, 15, CancellationToken.None); var wrongKeyParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = TestIssuer, ValidateAudience = true, ValidAudience = TestAudience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-completely-different-signing-key-32-bytes-min")), ClockSkew = TimeSpan.FromSeconds(30) }; Assert.ThrowsAny(() => new JwtSecurityTokenHandler().ValidateToken(result.AccessToken, wrongKeyParameters, out _)); } [Fact] public async Task Test_IssueAsync_UsesSigningKeyResolver_WithThePassedVaultPath() { var resolver = new Mock(); resolver.Setup(r => r.GetSigningKeyAsync("exact/vault/path", It.IsAny())) .ReturnsAsync(SigningKey); var issuer = new JwtAccessTokenIssuer(resolver.Object); await issuer.IssueAsync(new FakeClaimsSource { SubjectId = 1, Role = "R" }, "exact/vault/path", TestIssuer, TestAudience, 15, CancellationToken.None); resolver.Verify(r => r.GetSigningKeyAsync("exact/vault/path", It.IsAny()), Times.Once); } }