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);
}
}