Updated
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user