This commit is contained in:
2026-06-20 06:27:57 +02:00
parent 6cc78245be
commit 52b9be6495
14 changed files with 654 additions and 47 deletions
@@ -1,7 +1,9 @@
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
@@ -252,5 +254,205 @@ namespace EonaCat.SecureToken.Tests
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();
}
}
}