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