Files

257 lines
8.9 KiB
C#

using FluentAssertions;
using EonaCat.SecureToken.Core;
using EonaCat.SecureToken.Cryptography;
using EonaCat.SecureToken.Validation;
using Xunit;
namespace EonaCat.SecureToken.Tests
{
public sealed class TokenServiceTests
{
private static ITokenService CreateService(SigningKeyStore? store = null) =>
new TokenService(store ?? SigningKeyStore.CreateNew());
// Issue & Validate
[Fact]
public void Issue_And_Validate_ReturnsSuccess()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-123")
.IssuedBy("my-app")
.ForAudience("api")
.WithRole("admin")
.WithClaim("email", "user@example.com"));
var result = svc.Validate(token, TokenValidationOptions.AccessToken("my-app", "api"));
result.IsSuccess.Should().BeTrue();
var claims = result.UnwrapClaims();
claims.Subject.Should().Be("user-123");
claims.Roles.Should().Contain("admin");
claims.Custom["email"].Should().Be("user@example.com");
}
[Fact]
public void Validate_TamperedToken_ReturnsInvalidSignature()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create().ForSubject("user-1").IssuedBy("app").ForAudience("api"));
// Flip one character in the payload section
var parts = token.Split('.');
var corrupted = parts[0] + "." + parts[1].Substring(0, parts[1].Length - 1) + "X" + "." + parts[2];
var result = svc.Validate(corrupted, TokenValidationOptions.AccessToken("app", "api"));
result.Should().BeOfType<TokenResult.InvalidSignature>();
}
[Fact]
public void Validate_ExpiredToken_ReturnsExpired()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1")
.IssuedBy("app")
.ForAudience("api")
.WithLifetime(TimeSpan.FromMilliseconds(1)));
Thread.Sleep(50); // let it expire
var result = svc.Validate(token, new TokenValidationOptions
{
ValidIssuer = "app",
ValidAudience = "api",
RequiredTokenType = TokenTypeConstants.Access,
ClockSkew = TimeSpan.Zero,
});
result.Should().BeOfType<TokenResult.Expired>();
}
[Fact]
public void Validate_WrongAudience_ReturnsWrongAudience()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1")
.IssuedBy("app")
.ForAudience("service-a"));
var result = svc.Validate(token, TokenValidationOptions.AccessToken("app", "service-b"));
result.Should().BeOfType<TokenResult.WrongAudience>();
}
[Fact]
public void Validate_WrongTokenType_ReturnsWrongTokenType()
{
var svc = CreateService();
var refreshToken = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1")
.IssuedBy("app")
.AsRefreshToken());
// Try to use refresh token where access token is expected
var result = svc.Validate(refreshToken, TokenValidationOptions.AccessToken("app", "api"));
result.Should().BeOfType<TokenResult.WrongTokenType>();
}
[Fact]
public void Validate_BindingContextMismatch_ReturnsBindingMismatch()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1")
.IssuedBy("app")
.ForAudience("api")
.BoundTo("192.168.1.1"));
var result = svc.Validate(token, new TokenValidationOptions
{
ValidIssuer = "app",
ValidAudience = "api",
RequiredTokenType = TokenTypeConstants.Access,
BindingContext = "10.0.0.1", // different IP
});
result.Should().BeOfType<TokenResult.BindingMismatch>();
}
[Fact]
public void Validate_CorrectBindingContext_ReturnsSuccess()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1")
.IssuedBy("app")
.ForAudience("api")
.BoundTo("192.168.1.1"));
var result = svc.Validate(token, new TokenValidationOptions
{
ValidIssuer = "app",
ValidAudience = "api",
RequiredTokenType = TokenTypeConstants.Access,
BindingContext = "192.168.1.1",
});
result.IsSuccess.Should().BeTrue();
}
// Key Rotation
[Fact]
public void KeyRotation_OldTokensRemainValid()
{
var store = SigningKeyStore.CreateNew();
var svc = CreateService(store);
var oldToken = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1").IssuedBy("app").ForAudience("api"));
// Rotate the signing key
store.Rotate();
// Old token must still validate
var result = svc.Validate(oldToken, TokenValidationOptions.AccessToken("app", "api"));
result.IsSuccess.Should().BeTrue();
// New tokens use the new key
var newToken = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-2").IssuedBy("app").ForAudience("api"));
var claims = svc.Inspect(newToken);
claims!.KeyGeneration.Should().Be(2);
}
[Fact]
public void KeyRotation_NewTokensUseNewKey()
{
var store = SigningKeyStore.CreateNew();
var svc = CreateService(store);
store.Rotate();
store.Rotate();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1").IssuedBy("app").ForAudience("api"));
var claims = svc.Inspect(token);
claims!.KeyGeneration.Should().Be(3);
var result = svc.Validate(token, TokenValidationOptions.AccessToken("app", "api"));
result.IsSuccess.Should().BeTrue();
}
// Token Pair
[Fact]
public void IssueTokenPair_ProducesValidPair()
{
var svc = CreateService();
var pair = svc.IssueTokenPair("user-1", "app", "api", roles: new[] { "user" });
var accessResult = svc.Validate(pair.AccessToken, TokenValidationOptions.AccessToken("app", "api"));
var refreshResult = svc.Validate(pair.RefreshToken, TokenValidationOptions.RefreshToken("app"));
accessResult.IsSuccess.Should().BeTrue();
refreshResult.IsSuccess.Should().BeTrue();
}
[Fact]
public void IssueTokenPair_RefreshToken_CannotBeUsedAsAccessToken()
{
var svc = CreateService();
var pair = svc.IssueTokenPair("user-1", "app", "api");
// Try to validate refresh token as access token
var result = svc.Validate(pair.RefreshToken, TokenValidationOptions.AccessToken("app", "api"));
result.Should().BeOfType<TokenResult.WrongTokenType>();
}
// Revocation
[Fact]
public async Task RevocationCheck_RevokedToken_ReturnsRevoked()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1").IssuedBy("app").ForAudience("api"));
var claims = svc.Inspect(token)!;
var revokedIds = new HashSet<string> { claims.TokenId };
var options = new TokenValidationOptions
{
ValidIssuer = "app",
ValidAudience = "api",
RequiredTokenType = TokenTypeConstants.Access,
RevocationCheck = (id, _) => Task.FromResult(revokedIds.Contains(id)),
};
var result = await svc.ValidateAsync(token, options);
result.Should().BeOfType<TokenResult.Revoked>();
}
// Inspect (no signature check)
[Fact]
public void Inspect_MalformedToken_ReturnsNull()
{
var svc = CreateService();
svc.Inspect("not.a.valid.token").Should().BeNull();
}
[Fact]
public void Inspect_ValidToken_ReturnsClaims()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-42").IssuedBy("app").ForAudience("api"));
var claims = svc.Inspect(token);
claims.Should().NotBeNull();
claims!.Subject.Should().Be("user-42");
}
}
}