257 lines
8.9 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|