using System.IdentityModel.Tokens.Jwt; using System.Security.Cryptography; using System.Text; using EntitlementBLL.Auth; using EntitlementBLL.Options; using GB5Shared.Auth.Jwt; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Moq; using Xunit; namespace EntitlementTests; /// /// Covers EntitlementBLL.Auth.ClientJwtService — the client-facing JWT issuance service backing /// gb-ent-client Surface B (mirrors DXPJwtService's HMAC-SHA256/refresh-token mechanics, but with /// the simpler ClientUserId/ClientId/Role claim set since a client user belongs to exactly one /// client). Several of these tests specifically target the config-drift bug found in DXP's /// AuthBLL.IssueTokensAsync (a second, hardcoded lifetime independent of the JWT's own `exp` /// claim / the config value) — ClientJwtService computes each lifetime exactly once and must /// report that same value everywhere it's used. /// public class ClientJwtServiceTests { private const string SigningKey = "test-signing-key-at-least-32-bytes-long-for-hmacsha256!!"; private const string TestIssuer = "GB5-Entitlement-Test"; private const string TestAudience = "GB5-Entitlement-Client-Test"; private static ClientJwtService BuildService(int accessMinutes = 15, int refreshDays = 30) { var signingKeyResolver = new Mock(); signingKeyResolver.Setup(s => s.GetSigningKeyAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(SigningKey); var issuer = new JwtAccessTokenIssuer(signingKeyResolver.Object); var options = Options.Create(new ClientJwtOptions { Issuer = TestIssuer, Audience = TestAudience, AccessTokenMinutes = accessMinutes, RefreshTokenDays = refreshDays }); return new ClientJwtService(issuer, options); } [Fact] public async Task Test_IssueTokenPairAsync_ProducesJwt_WithExpectedClaimsIssuerAudience() { var service = BuildService(); var claims = new ClientAccessTokenClaims { ClientUserId = 42, ClientId = 7, Role = "CLIENT_ADMIN" }; var pair = await service.IssueTokenPairAsync(claims, CancellationToken.None); var token = new JwtSecurityTokenHandler().ReadJwtToken(pair.AccessToken); Assert.Equal(TestIssuer, token.Issuer); Assert.Contains(TestAudience, token.Audiences); Assert.Equal("42", token.Claims.Single(c => c.Type == ClientJwtClaimTypes.ClientUserId).Value); Assert.Equal("7", token.Claims.Single(c => c.Type == ClientJwtClaimTypes.ClientId).Value); Assert.Equal("CLIENT_ADMIN", token.Claims.Single(c => c.Type == ClientJwtClaimTypes.Role).Value); } [Fact] public async Task Test_IssueTokenPairAsync_AccessTokenExpiresOn_MatchesJwtExpClaim_NoDrift() { // Regression test for the DXP drift bug: the token pair's reported AccessTokenExpiresOn // must be the exact same value used for the JWT's own `exp` claim, and both must reflect // the configured AccessTokenMinutes — not an independently re-derived/hardcoded number. var service = BuildService(accessMinutes: 7); var claims = new ClientAccessTokenClaims { ClientUserId = 1, ClientId = 1, Role = "CLIENT_USER" }; var pair = await service.IssueTokenPairAsync(claims, CancellationToken.None); var token = new JwtSecurityTokenHandler().ReadJwtToken(pair.AccessToken); // JWT `exp` is seconds-since-epoch (sub-second truncated), so allow a small tolerance. Assert.True(Math.Abs((token.ValidTo - pair.AccessTokenExpiresOn).TotalSeconds) < 2); Assert.True(Math.Abs((pair.AccessTokenExpiresOn - DateTime.UtcNow.AddMinutes(7)).TotalSeconds) < 5); } [Fact] public async Task Test_IssueTokenPairAsync_RefreshTokenExpiresOn_ReflectsConfiguredDays_NoDrift() { var service = BuildService(refreshDays: 45); var claims = new ClientAccessTokenClaims { ClientUserId = 1, ClientId = 1, Role = "CLIENT_USER" }; var pair = await service.IssueTokenPairAsync(claims, CancellationToken.None); Assert.True(Math.Abs((pair.RefreshTokenExpiresOn - DateTime.UtcNow.AddDays(45)).TotalSeconds) < 5); } [Fact] public async Task Test_IssueTokenPairAsync_SignatureVerifies_WithSameSigningKey() { var service = BuildService(); var claims = new ClientAccessTokenClaims { ClientUserId = 1, ClientId = 1, Role = "CLIENT_USER" }; var pair = await service.IssueTokenPairAsync(claims, 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(pair.AccessToken, validationParameters, out var validatedToken); Assert.NotNull(principal); Assert.NotNull(validatedToken); } [Fact] public async Task Test_IssueTokenPairAsync_SignatureRejected_WithWrongKey() { var service = BuildService(); var claims = new ClientAccessTokenClaims { ClientUserId = 1, ClientId = 1, Role = "CLIENT_USER" }; var pair = await service.IssueTokenPairAsync(claims, 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(pair.AccessToken, wrongKeyParameters, out _)); } [Fact] public async Task Test_IssueTokenPairAsync_RefreshTokenHash_MatchesSha256OfRawToken() { var service = BuildService(); var claims = new ClientAccessTokenClaims { ClientUserId = 1, ClientId = 1, Role = "CLIENT_USER" }; var pair = await service.IssueTokenPairAsync(claims, CancellationToken.None); var expectedHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(pair.RefreshToken))); Assert.Equal(expectedHash, pair.RefreshTokenHash); Assert.NotEqual(pair.RefreshToken, pair.RefreshTokenHash); } [Fact] public void Test_GenerateRefreshToken_ProducesUrlSafeToken_AndMatchingHash() { var service = BuildService(); var (raw, hash) = service.GenerateRefreshToken(); Assert.DoesNotContain("+", raw); Assert.DoesNotContain("/", raw); Assert.DoesNotContain("=", raw); Assert.Equal(service.HashRefreshToken(raw), hash); } [Fact] public void Test_GenerateRefreshToken_TwoCalls_ProduceDifferentTokens() { var service = BuildService(); var (raw1, _) = service.GenerateRefreshToken(); var (raw2, _) = service.GenerateRefreshToken(); Assert.NotEqual(raw1, raw2); } }