Files
EonaCat.SecureToken/EonaCat.SecureToken.Tests/TokenServiceTests.cs
T
2026-06-20 06:27:57 +02:00

459 lines
17 KiB
C#

using FluentAssertions;
using EonaCat.SecureToken.Core;
using EonaCat.SecureToken.Cryptography;
using EonaCat.SecureToken.Extensions;
using EonaCat.SecureToken.Validation;
using Microsoft.Extensions.DependencyInjection;
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");
}
// MaxTokenAge backstop
[Fact]
public void Validate_WithinMaxTokenAge_ReturnsSuccess()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1").IssuedBy("app").ForAudience("api")
.WithLifetime(TimeSpan.FromHours(2)));
var result = svc.Validate(token, new TokenValidationOptions
{
ValidIssuer = "app",
ValidAudience = "api",
RequiredTokenType = TokenTypeConstants.Access,
MaxTokenAge = TimeSpan.FromHours(1),
});
// exp claim alone (2h lifetime) would normally accept this, but MaxTokenAge
// is a backstop measured from iat, independent of exp.
result.IsSuccess.Should().BeTrue();
}
[Fact]
public void Validate_ExceedsMaxTokenAge_ReturnsTooOld()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1").IssuedBy("app").ForAudience("api")
.WithLifetime(TimeSpan.FromMilliseconds(50)));
Thread.Sleep(60);
var result = svc.Validate(token, new TokenValidationOptions
{
ValidIssuer = "app",
ValidAudience = "api",
RequiredTokenType = TokenTypeConstants.Access,
ValidateExpiry = false, // isolate the MaxTokenAge check from the normal exp check
MaxTokenAge = TimeSpan.FromMilliseconds(10),
ClockSkew = TimeSpan.Zero,
});
result.Should().BeOfType<TokenResult.TooOld>();
}
// Multi-audience accept-list
[Fact]
public void Validate_MultiAudience_AcceptsAnyMatchingAudience()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1").IssuedBy("app").ForAudience("service-b"));
var result = svc.Validate(token,
TokenValidationOptions.AccessToken("app", new[] { "service-a", "service-b", "service-c" }));
result.IsSuccess.Should().BeTrue();
}
[Fact]
public void Validate_MultiAudience_RejectsNonMatchingAudience()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1").IssuedBy("app").ForAudience("service-z"));
var result = svc.Validate(token,
TokenValidationOptions.AccessToken("app", new[] { "service-a", "service-b" }));
result.Should().BeOfType<TokenResult.WrongAudience>();
}
// Replay cache
[Fact]
public async Task ValidateAsync_WithReplayCache_FirstUseSucceeds()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1").IssuedBy("app")
.OfType(TokenTypeConstants.PasswordReset)
.WithLifetime(TimeSpan.FromMinutes(10)));
var result = await svc.ValidateAsync(token, new TokenValidationOptions
{
ValidIssuer = "app",
RequiredTokenType = TokenTypeConstants.PasswordReset,
ReplayCache = new InMemoryReplayCache(),
});
result.IsSuccess.Should().BeTrue();
}
[Fact]
public async Task ValidateAsync_WithReplayCache_SecondUseIsReplayed()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1").IssuedBy("app")
.OfType(TokenTypeConstants.PasswordReset)
.WithLifetime(TimeSpan.FromMinutes(10)));
var options = new TokenValidationOptions
{
ValidIssuer = "app",
RequiredTokenType = TokenTypeConstants.PasswordReset,
ReplayCache = new InMemoryReplayCache(),
};
var first = await svc.ValidateAsync(token, options);
var second = await svc.ValidateAsync(token, options);
first.IsSuccess.Should().BeTrue();
second.Should().BeOfType<TokenResult.Replayed>();
}
// OnValidated introspection hook
[Fact]
public void Validate_OnValidatedHook_InvokedOnSuccessWithClaims()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-99").IssuedBy("app").ForAudience("api"));
TokenValidationEvent? captured = null;
var options = TokenValidationOptions.AccessToken("app", "api");
options.OnValidated = e => captured = e;
svc.Validate(token, options);
captured.Should().NotBeNull();
captured!.Result.IsSuccess.Should().BeTrue();
captured.Subject.Should().Be("user-99");
}
[Fact]
public void Validate_OnValidatedHook_InvokedOnFailureWithNullSubject()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1").IssuedBy("app").ForAudience("service-a"));
TokenValidationEvent? captured = null;
var options = TokenValidationOptions.AccessToken("app", "service-b"); // wrong audience
options.OnValidated = e => captured = e;
svc.Validate(token, options);
captured.Should().NotBeNull();
captured!.Result.Should().BeOfType<TokenResult.WrongAudience>();
captured.Subject.Should().BeNull();
}
// Constant-time binding comparison (functional correctness, not timing)
[Fact]
public void Validate_BindingContext_DifferentLengthMismatch_ReturnsBindingMismatch()
{
var svc = CreateService();
var token = svc.Issue(TokenDescriptor.Create()
.ForSubject("user-1").IssuedBy("app").ForAudience("api")
.BoundTo("short"));
var result = svc.Validate(token, new TokenValidationOptions
{
ValidIssuer = "app",
ValidAudience = "api",
RequiredTokenType = TokenTypeConstants.Access,
BindingContext = "a-much-longer-binding-context-value",
});
result.Should().BeOfType<TokenResult.BindingMismatch>();
}
// DI factory-overload regression test
[Fact]
public void ServiceCollection_AddSecureTokens_WithFactory_ResolvesTokenService()
{
var services = new ServiceCollection();
var presetKey = new byte[64];
new Random(42).NextBytes(presetKey);
services.AddSecureTokens(_ => SigningKeyStore.FromKeys(new[] { (1, presetKey) }));
using var provider = services.BuildServiceProvider();
// Before the fix, this threw because the factory overload registered the
// Func<> itself rather than invoking it to produce a SigningKeyStore, so
// TokenService's constructor dependency could never be satisfied.
var tokenService = provider.GetRequiredService<ITokenService>();
var keyStore = provider.GetRequiredService<SigningKeyStore>();
tokenService.Should().NotBeNull();
keyStore.Should().NotBeNull();
var token = tokenService.Issue(TokenDescriptor.Create()
.ForSubject("user-1").IssuedBy("app").ForAudience("api"));
var result = tokenService.Validate(token, TokenValidationOptions.AccessToken("app", "api"));
result.IsSuccess.Should().BeTrue();
}
}
}