240 lines
9.0 KiB
C#
240 lines
9.0 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Cryptography;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using SIGCM2.Domain.Entities;
|
|
using SIGCM2.Infrastructure.Security;
|
|
|
|
namespace SIGCM2.Application.Tests.Infrastructure;
|
|
|
|
public class JwtServiceTests : IDisposable
|
|
{
|
|
private readonly RSA _rsa;
|
|
private readonly JwtOptions _options;
|
|
private readonly JwtService _jwtService;
|
|
|
|
public JwtServiceTests()
|
|
{
|
|
// Generate a test RSA key pair inline (no files needed for unit tests)
|
|
_rsa = RSA.Create(2048);
|
|
_options = new JwtOptions
|
|
{
|
|
Issuer = "sigcm2.api",
|
|
Audience = "sigcm2.web",
|
|
AccessTokenMinutes = 60
|
|
};
|
|
_jwtService = new JwtService(_rsa, _options);
|
|
}
|
|
|
|
public void Dispose() => _rsa.Dispose();
|
|
|
|
// Scenario: generated token uses RS256 algorithm
|
|
[Fact]
|
|
public void GenerateAccessToken_UsesRS256Algorithm()
|
|
{
|
|
var usuario = MakeUsuario();
|
|
var token = _jwtService.GenerateAccessToken(usuario);
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var parsed = handler.ReadJwtToken(token);
|
|
|
|
Assert.Equal("RS256", parsed.Header.Alg);
|
|
}
|
|
|
|
// Scenario: claims contain expected values
|
|
[Fact]
|
|
public void GenerateAccessToken_ContainsExpectedClaims()
|
|
{
|
|
var usuario = MakeUsuario();
|
|
var token = _jwtService.GenerateAccessToken(usuario);
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var parsed = handler.ReadJwtToken(token);
|
|
|
|
Assert.Equal("1", parsed.Subject); // sub = user ID
|
|
Assert.Equal("sigcm2.api", parsed.Issuer); // iss
|
|
Assert.Contains("sigcm2.web", parsed.Audiences); // aud
|
|
Assert.Contains(parsed.Claims, c => c.Type == "name" && c.Value == "admin");
|
|
Assert.Contains(parsed.Claims, c => c.Type == "rol" && c.Value == "admin");
|
|
|
|
// J-01 (UDT-009): token must NOT contain 'permisos' claim post-UDT-009
|
|
Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
|
|
}
|
|
|
|
// J-01: token post-UDT-009 does NOT have 'permisos' claim
|
|
[Fact]
|
|
public void GenerateAccessToken_DoesNotContainPermisosClaim()
|
|
{
|
|
var usuario = MakeUsuario();
|
|
var token = _jwtService.GenerateAccessToken(usuario);
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var parsed = handler.ReadJwtToken(token);
|
|
|
|
Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
|
|
}
|
|
|
|
// J-02: claims present are sub, jti, name, rol (+ iat/exp/nbf) — no extras
|
|
[Fact]
|
|
public void GenerateAccessToken_HasExactlyExpectedClaims_NoPermisos()
|
|
{
|
|
var usuario = MakeUsuario();
|
|
var token = _jwtService.GenerateAccessToken(usuario);
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var parsed = handler.ReadJwtToken(token);
|
|
|
|
// Must have sub, name, rol, jti
|
|
Assert.Contains(parsed.Claims, c => c.Type == "sub");
|
|
Assert.Contains(parsed.Claims, c => c.Type == "name");
|
|
Assert.Contains(parsed.Claims, c => c.Type == "rol");
|
|
Assert.Contains(parsed.Claims, c => c.Type == "jti");
|
|
|
|
// Must NOT have permisos
|
|
Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
|
|
}
|
|
|
|
// J-03: MakeUsuario with '["*"]' PermisosJson → token still has no 'permisos' claim
|
|
[Fact]
|
|
public void GenerateAccessToken_WithLegacyPermisosJson_NoPermisosClaim()
|
|
{
|
|
// MakeUsuario already uses '[\"*\"]' — this explicitly tests J-03
|
|
var usuario = MakeUsuario();
|
|
Assert.Equal("[\"*\"]", usuario.PermisosJson); // verify the helper
|
|
|
|
var token = _jwtService.GenerateAccessToken(usuario);
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var parsed = handler.ReadJwtToken(token);
|
|
|
|
// Post-UDT-009: JwtService ignores PermisosJson entirely — no claim emitted
|
|
Assert.DoesNotContain(parsed.Claims, c => c.Type == "permisos");
|
|
}
|
|
|
|
// Scenario: token is verifiable with the public key
|
|
[Fact]
|
|
public void GenerateAccessToken_IsVerifiableWithPublicKey()
|
|
{
|
|
var usuario = MakeUsuario();
|
|
var token = _jwtService.GenerateAccessToken(usuario);
|
|
|
|
var publicKey = RSA.Create();
|
|
publicKey.ImportRSAPublicKey(_rsa.ExportRSAPublicKey(), out _);
|
|
|
|
var validationParams = new TokenValidationParameters
|
|
{
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = new RsaSecurityKey(publicKey),
|
|
ValidIssuer = "sigcm2.api",
|
|
ValidAudience = "sigcm2.web",
|
|
ValidateLifetime = true,
|
|
ClockSkew = TimeSpan.Zero
|
|
};
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var principal = handler.ValidateToken(token, validationParams, out var validatedToken);
|
|
|
|
Assert.NotNull(principal);
|
|
Assert.IsType<JwtSecurityToken>(validatedToken);
|
|
}
|
|
|
|
// Scenario: expiry is 60 minutes from now
|
|
[Fact]
|
|
public void GenerateAccessToken_ExpiryIs60MinutesFromNow()
|
|
{
|
|
var usuario = MakeUsuario();
|
|
var before = DateTime.UtcNow;
|
|
var token = _jwtService.GenerateAccessToken(usuario);
|
|
var after = DateTime.UtcNow;
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var parsed = handler.ReadJwtToken(token);
|
|
|
|
var expectedMinExpiry = before.AddMinutes(59).AddSeconds(55);
|
|
var expectedMaxExpiry = after.AddMinutes(60).AddSeconds(5);
|
|
|
|
Assert.True(parsed.ValidTo >= expectedMinExpiry, $"exp {parsed.ValidTo} < expected min {expectedMinExpiry}");
|
|
Assert.True(parsed.ValidTo <= expectedMaxExpiry, $"exp {parsed.ValidTo} > expected max {expectedMaxExpiry}");
|
|
}
|
|
|
|
// Triangulation: different user produces token with different sub claim
|
|
[Fact]
|
|
public void GenerateAccessToken_DifferentUser_DifferentSubClaim()
|
|
{
|
|
var user1 = MakeUsuario(id: 1, username: "admin");
|
|
var user2 = MakeUsuario(id: 2, username: "vendedor");
|
|
|
|
var token1 = _jwtService.GenerateAccessToken(user1);
|
|
var token2 = _jwtService.GenerateAccessToken(user2);
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var parsed1 = handler.ReadJwtToken(token1);
|
|
var parsed2 = handler.ReadJwtToken(token2);
|
|
|
|
Assert.NotEqual(parsed1.Subject, parsed2.Subject);
|
|
Assert.Equal("1", parsed1.Subject);
|
|
Assert.Equal("2", parsed2.Subject);
|
|
}
|
|
|
|
// T-040: GetPrincipalFromExpiredToken
|
|
|
|
[Fact]
|
|
public void GetPrincipalFromExpiredToken_ValidSignatureExpired_ReturnsPrincipal()
|
|
{
|
|
// Generate a token that will expire in 1 second, then manually create an expired JWT
|
|
// using JwtSecurityTokenHandler directly (bypassing the service to control timestamps).
|
|
var signingKey = new RsaSecurityKey(_rsa);
|
|
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
|
|
|
|
var past = DateTime.UtcNow.AddHours(-2);
|
|
var descriptor = new SecurityTokenDescriptor
|
|
{
|
|
Subject = new System.Security.Claims.ClaimsIdentity(
|
|
[new System.Security.Claims.Claim("sub", "1")]),
|
|
Issuer = "sigcm2.api",
|
|
Audience = "sigcm2.web",
|
|
IssuedAt = past,
|
|
NotBefore = past,
|
|
Expires = past.AddMinutes(60), // expired 1h ago
|
|
SigningCredentials = credentials,
|
|
};
|
|
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var token = handler.CreateToken(descriptor);
|
|
var expiredToken = handler.WriteToken(token);
|
|
|
|
// Now use the service — it must validate the signature but ignore expiry
|
|
var principal = _jwtService.GetPrincipalFromExpiredToken(expiredToken);
|
|
|
|
Assert.NotNull(principal);
|
|
// The JWT handler maps "sub" to ClaimTypes.NameIdentifier by default,
|
|
// but our JwtService uses a custom "sub" claim. Check both.
|
|
var sub = principal.FindFirst("sub")?.Value
|
|
?? principal.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
Assert.Equal("1", sub);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetPrincipalFromExpiredToken_InvalidSignature_Throws()
|
|
{
|
|
// Sign with a different RSA key
|
|
using var otherRsa = System.Security.Cryptography.RSA.Create(2048);
|
|
var otherOptions = new JwtOptions { Issuer = "sigcm2.api", Audience = "sigcm2.web", AccessTokenMinutes = 60 };
|
|
var otherService = new JwtService(otherRsa, otherOptions);
|
|
var tokenFromOtherKey = otherService.GenerateAccessToken(MakeUsuario());
|
|
|
|
// Validating with the correct key should throw
|
|
Assert.Throws<Microsoft.IdentityModel.Tokens.SecurityTokenSignatureKeyNotFoundException>(
|
|
() => _jwtService.GetPrincipalFromExpiredToken(tokenFromOtherKey));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetPrincipalFromExpiredToken_MalformedToken_Throws()
|
|
{
|
|
Assert.ThrowsAny<Exception>(
|
|
() => _jwtService.GetPrincipalFromExpiredToken("not.a.valid.jwt"));
|
|
}
|
|
|
|
private static Usuario MakeUsuario(int id = 1, string username = "admin")
|
|
=> new(id, username, "$2a$12$hash", "Administrador", "Sistema", null, "admin", "[\"*\"]", true);
|
|
}
|