diff --git a/EonaCat.SecureToken.Tests/EonaCat.SecureToken.Tests.csproj b/EonaCat.SecureToken.Tests/EonaCat.SecureToken.Tests.csproj
index b57563e..d234b61 100644
--- a/EonaCat.SecureToken.Tests/EonaCat.SecureToken.Tests.csproj
+++ b/EonaCat.SecureToken.Tests/EonaCat.SecureToken.Tests.csproj
@@ -12,6 +12,7 @@
+
diff --git a/EonaCat.SecureToken.Tests/TokenServiceTests.cs b/EonaCat.SecureToken.Tests/TokenServiceTests.cs
index 8c6ba9c..c6c9db4 100644
--- a/EonaCat.SecureToken.Tests/TokenServiceTests.cs
+++ b/EonaCat.SecureToken.Tests/TokenServiceTests.cs
@@ -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();
+ }
+
+ // 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();
+ }
+
+ // 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();
+ }
+
+ // 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();
+ 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();
+ }
+
+ // 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();
+ var keyStore = provider.GetRequiredService();
+
+ 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();
+ }
}
}
diff --git a/EonaCat.SecureToken/EonaCat.SecureToken.csproj b/EonaCat.SecureToken/EonaCat.SecureToken.csproj
index 6ffd8c3..17b4a1f 100644
--- a/EonaCat.SecureToken/EonaCat.SecureToken.csproj
+++ b/EonaCat.SecureToken/EonaCat.SecureToken.csproj
@@ -10,11 +10,11 @@
https://www.nuget.org/packages/EonaCat.SecureToken/
EonaCat.SecureToken provides a safer alternative to rolling your own authentication tokens.
Secure, modern token library for .NET with key rotation, signing isolation and validation rules
- Public release version
+
EonaCat (Jeroen Saey)
- EonaCat;authentication;api;micro;services;microservices;rotation;validation;jwt;security;secure;token;paseto;security;auth;jwt-alternative;Jeroen;Saey
+ EonaCat;authentication;api;micro;services;microservices;rotation;secure;token;validation;jwt;security;secure;token;paseto;security;auth;jwt-alternative;Jeroen;Saey
- 0.0.2
+ 0.0.3
README.md
True
LICENSE
@@ -25,7 +25,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
- 0.0.2+{chash:10}.{c:ymd}
+ 0.0.3+{chash:10}.{c:ymd}
true
true
v[0-9]*
@@ -36,7 +36,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
- 0.0.2
+ 0.0.3
EonaCat.SecureToken
EonaCat.SecureToken
https://git.saey.me/EonaCat/EonaCat.SecureToken
diff --git a/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs b/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs
index cd21be8..6dc1c4c 100644
--- a/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs
+++ b/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using EonaCat.SecureToken.Cryptography;
+using EonaCat.SecureToken.Validation;
using System;
namespace EonaCat.SecureToken.Extensions
@@ -26,9 +27,7 @@ namespace EonaCat.SecureToken.Extensions
///
/// Adds SecureToken services with a supplied key store.
///
- public static IServiceCollection AddSecureTokens(
- this IServiceCollection services,
- SigningKeyStore keyStore)
+ public static IServiceCollection AddSecureTokens(this IServiceCollection services, SigningKeyStore keyStore)
{
services.AddSingleton(keyStore);
services.AddSingleton();
@@ -39,13 +38,32 @@ namespace EonaCat.SecureToken.Extensions
/// Adds SecureToken services with a factory function to configure the key store.
/// Useful when loading keys from environment variables or a secret manager.
///
- public static IServiceCollection AddSecureTokens(
- this IServiceCollection services,
- Func keyStoreFactory)
+ public static IServiceCollection AddSecureTokens(this IServiceCollection services, Func keyStoreFactory)
{
- services.AddSingleton(keyStoreFactory);
+ if (keyStoreFactory is null)
+ {
+ throw new ArgumentNullException(nameof(keyStoreFactory));
+ }
+
+ // Register SigningKeyStore as the singleton, invoking the factory exactly once
+ // on first resolution. Registering the Func<> itself (the original behavior)
+ // left nothing in the container that satisfies TokenService's SigningKeyStore
+ // dependency, so resolution of ITokenService would fail at runtime.
+ services.AddSingleton(sp => keyStoreFactory(sp));
services.AddSingleton();
return services;
}
+
+ ///
+ /// Registers a process-local (backed by
+ /// ) for detecting replayed one-time-use
+ /// tokens. For multi-instance deployments, register your own
+ /// implementation backed by a shared store instead of calling this method.
+ ///
+ public static IServiceCollection AddSecureTokenReplayProtection(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ return services;
+ }
}
}
diff --git a/EonaCat.SecureToken/Middleware/SecureTokenMiddleware.cs b/EonaCat.SecureToken/Middleware/SecureTokenMiddleware.cs
index aa6f6da..9b12467 100644
--- a/EonaCat.SecureToken/Middleware/SecureTokenMiddleware.cs
+++ b/EonaCat.SecureToken/Middleware/SecureTokenMiddleware.cs
@@ -26,10 +26,7 @@ namespace EonaCat.SecureToken.Extensions
/// HttpContext.Items key where validated claims are stored.
public const string ClaimsItemKey = "SecureToken.Claims";
- public SecureTokenMiddleware(
- RequestDelegate next,
- TokenValidationOptions options,
- ILogger logger)
+ public SecureTokenMiddleware(RequestDelegate next, TokenValidationOptions options, ILogger logger)
{
_next = next;
_options = options;
@@ -38,13 +35,10 @@ namespace EonaCat.SecureToken.Extensions
public async Task InvokeAsync(HttpContext context, ITokenService tokenService)
{
- var authHeader = context.Request.Headers[HeaderNames.Authorization]
- .FirstOrDefault();
-
+ var authHeader = context.Request.Headers[HeaderNames.Authorization].FirstOrDefault();
if (authHeader?.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) == true)
{
var token = authHeader.Substring("Bearer ".Length).Trim();
-
var result = await tokenService.ValidateAsync(token, _options, context.RequestAborted);
if (result.IsSuccess)
diff --git a/EonaCat.SecureToken/Models/IReplayCache.cs b/EonaCat.SecureToken/Models/IReplayCache.cs
new file mode 100644
index 0000000..dea4069
--- /dev/null
+++ b/EonaCat.SecureToken/Models/IReplayCache.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace EonaCat.SecureToken.Validation
+{
+ // This file is part of the EonaCat project(s) which is released under the Apache License.
+ // See the LICENSE file or go to https://EonaCat.com/License for full license details.
+
+ ///
+ /// Tracks token IDs (jti) that have already been consumed, so a token that is
+ /// meant to be used exactly once (password reset, email verification, invitation,
+ /// single-use refresh rotation) cannot be replayed even if an attacker captures it
+ /// before its expiry.
+ ///
+ /// Unlike (which answers "has someone
+ /// explicitly revoked this?"), a replay cache answers "has this exact token already been
+ /// consumed once?" and records that fact atomically as part of the check.
+ ///
+ public interface IReplayCache
+ {
+ ///
+ /// Atomically checks whether has been seen before and,
+ /// if not, records it as seen. Returns true if this is the first time the
+ /// token has been presented (i.e. it is safe to honor), or false if it has
+ /// already been consumed (i.e. this is a replay).
+ ///
+ /// The token's jti.
+ ///
+ /// The token's expiry. Implementations may use this to bound how long the jti needs
+ /// to be remembered for - there is no value in tracking a jti past the point where
+ /// the token would be rejected for expiry anyway.
+ ///
+ Task TryConsumeAsync(string tokenId, DateTimeOffset expiresAt, CancellationToken ct = default);
+ }
+
+ ///
+ /// Simple process-local backed by a .
+ ///
+ /// Suitable for single-instance deployments or as a reference implementation. Multi-instance
+ /// deployments should back this interface with a shared store (Redis, a database table with
+ /// a unique constraint on jti, etc.) so replay detection works across all instances.
+ ///
+ /// Expired entries are swept lazily on each call so memory does not grow unbounded; for
+ /// high-traffic services, call periodically from a background timer instead.
+ ///
+ public sealed class InMemoryReplayCache : IReplayCache
+ {
+ private readonly ConcurrentDictionary _seen = new ConcurrentDictionary();
+ private long _opsSinceSweep;
+
+ /// Sweep the cache after this many operations, amortizing cleanup cost.
+ private const long SweepInterval = 256;
+
+ public Task TryConsumeAsync(string tokenId, DateTimeOffset expiresAt, CancellationToken ct = default)
+ {
+ if (string.IsNullOrEmpty(tokenId))
+ {
+ // No identifier to track - fail closed by treating it as already consumed
+ // rather than silently skipping replay protection.
+ return Task.FromResult(false);
+ }
+
+ var firstUse = _seen.TryAdd(tokenId, expiresAt);
+
+ if (Interlocked.Increment(ref _opsSinceSweep) >= SweepInterval)
+ {
+ Interlocked.Exchange(ref _opsSinceSweep, 0);
+ Sweep();
+ }
+
+ return Task.FromResult(firstUse);
+ }
+
+ /// Removes entries for tokens that have already expired.
+ public void Sweep()
+ {
+ var now = DateTimeOffset.UtcNow;
+ foreach (var kv in _seen)
+ {
+ if (kv.Value <= now)
+ {
+ _seen.TryRemove(kv.Key, out _);
+ }
+ }
+ }
+
+ /// Number of token IDs currently tracked. Exposed for diagnostics/tests.
+ public int Count => _seen.Count;
+ }
+}
diff --git a/EonaCat.SecureToken/Models/SigningKeyStore.cs b/EonaCat.SecureToken/Models/SigningKeyStore.cs
index b5e9bb7..66e8af3 100644
--- a/EonaCat.SecureToken/Models/SigningKeyStore.cs
+++ b/EonaCat.SecureToken/Models/SigningKeyStore.cs
@@ -77,8 +77,7 @@ namespace EonaCat.SecureToken.Cryptography
ThrowIfDisposed();
if (newKeyMaterial != null && newKeyMaterial.Length < MinimumKeyBytes)
{
- throw new ArgumentException(
- $"Key material must be at least {MinimumKeyBytes} bytes ({MinimumKeyBytes * 8} bits).");
+ throw new ArgumentException($"Key material must be at least {MinimumKeyBytes} bytes ({MinimumKeyBytes * 8} bits).");
}
var material = newKeyMaterial ?? GenerateKey(MinimumKeyBytes);
diff --git a/EonaCat.SecureToken/Models/TokenDescriptor.cs b/EonaCat.SecureToken/Models/TokenDescriptor.cs
index 00dca68..b685d3d 100644
--- a/EonaCat.SecureToken/Models/TokenDescriptor.cs
+++ b/EonaCat.SecureToken/Models/TokenDescriptor.cs
@@ -228,8 +228,7 @@ namespace EonaCat.SecureToken.Core
if (value.Length > MaxStringLength)
{
- throw new ArgumentException(
- $"'{paramName}' must not exceed {MaxStringLength} characters.", paramName);
+ throw new ArgumentException($"'{paramName}' must not exceed {MaxStringLength} characters.", paramName);
}
}
}
diff --git a/EonaCat.SecureToken/Models/TokenPair.cs b/EonaCat.SecureToken/Models/TokenPair.cs
index a4040ee..c7b0283 100644
--- a/EonaCat.SecureToken/Models/TokenPair.cs
+++ b/EonaCat.SecureToken/Models/TokenPair.cs
@@ -36,16 +36,9 @@ namespace EonaCat.SecureToken
/// Issues a standard access + refresh token pair.
/// Access token is short-lived; refresh token is long-lived but type-tagged.
///
- public static TokenPair IssueTokenPair(
- this ITokenService service,
- string subject,
- string issuer,
- string audience,
- IEnumerable? roles = null,
- IEnumerable>? claims = null,
- TimeSpan? accessTokenLifetime = null,
- TimeSpan? refreshTokenLifetime = null,
- string? bindingContext = null)
+ public static TokenPair IssueTokenPair(this ITokenService service, string subject, string issuer, string audience,
+ IEnumerable? roles = null, IEnumerable>? claims = null,
+ TimeSpan? accessTokenLifetime = null, TimeSpan? refreshTokenLifetime = null, string? bindingContext = null)
{
var accessLifetime = accessTokenLifetime ?? TimeSpan.FromMinutes(15);
var refreshLifetime = refreshTokenLifetime ?? TimeSpan.FromDays(30);
@@ -91,9 +84,7 @@ namespace EonaCat.SecureToken
var accessToken = service.Issue(accessDescriptor);
var refreshToken = service.Issue(refreshDescriptor);
- return new TokenPair(
- accessToken,
- refreshToken,
+ return new TokenPair(accessToken, refreshToken,
now + accessLifetime,
now + refreshLifetime
);
diff --git a/EonaCat.SecureToken/Models/TokenResult.cs b/EonaCat.SecureToken/Models/TokenResult.cs
index 6d70fa1..c9e8500 100644
--- a/EonaCat.SecureToken/Models/TokenResult.cs
+++ b/EonaCat.SecureToken/Models/TokenResult.cs
@@ -38,6 +38,19 @@ namespace EonaCat.SecureToken.Core
public override string ToString() => $"Expired(at={ExpiredAt:u})";
}
+ ///
+ /// Token's age (now - iat) exceeds .
+ /// This is a defense-in-depth backstop distinct from : it fires
+ /// even if the token's own exp claim would still consider it valid.
+ ///
+ public sealed class TooOld : TokenResult
+ {
+ public DateTimeOffset IssuedAt { get; }
+ public TimeSpan MaxAge { get; }
+ public TooOld(DateTimeOffset issuedAt, TimeSpan maxAge) { IssuedAt = issuedAt; MaxAge = maxAge; }
+ public override string ToString() => $"TooOld(iat={IssuedAt:u}, maxAge={MaxAge})";
+ }
+
/// HMAC signature does not match the payload.
public sealed class InvalidSignature : TokenResult
{
@@ -107,6 +120,19 @@ namespace EonaCat.SecureToken.Core
public override string ToString() => $"Revoked(jti={TokenId})";
}
+ ///
+ /// Token has already been consumed once before, per the configured
+ /// . Indicates either a genuine
+ /// replay attack or a client retrying with a one-time-use token it should have discarded
+ /// after the first successful use.
+ ///
+ public sealed class Replayed : TokenResult
+ {
+ public string TokenId { get; }
+ public Replayed(string tokenId) { TokenId = tokenId; }
+ public override string ToString() => $"Replayed(jti={TokenId})";
+ }
+
/// Token string is structurally invalid (wrong prefix, bad Base64, corrupt payload).
public sealed class Malformed : TokenResult
{
diff --git a/EonaCat.SecureToken/Models/TokenService.cs b/EonaCat.SecureToken/Models/TokenService.cs
index 16bd337..4c7ea75 100644
--- a/EonaCat.SecureToken/Models/TokenService.cs
+++ b/EonaCat.SecureToken/Models/TokenService.cs
@@ -4,6 +4,7 @@ using EonaCat.SecureToken.Tokens;
using EonaCat.SecureToken.Validation;
using System;
using System.Linq;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -23,7 +24,11 @@ namespace EonaCat.SecureToken
/// Validates a token string and returns a typed result.
Task ValidateAsync(string token, TokenValidationOptions options, CancellationToken ct = default);
- /// Validates synchronously (no revocation check).
+ ///
+ /// Validates synchronously. Does not perform
+ /// or consumption, since both are inherently
+ /// async (and the replay check has a side effect) - use for those.
+ ///
TokenResult Validate(string token, TokenValidationOptions options);
///
@@ -71,12 +76,29 @@ namespace EonaCat.SecureToken
{
var result = Validate(token, options);
- if (result is TokenResult.Success success && options.RevocationCheck != null)
+ if (result is TokenResult.Success success)
{
- var isRevoked = await options.RevocationCheck(success.Claims.TokenId, ct);
- if (isRevoked)
+ if (options.RevocationCheck != null)
{
- return new TokenResult.Revoked(success.Claims.TokenId);
+ var isRevoked = await options.RevocationCheck(success.Claims.TokenId, ct);
+ if (isRevoked)
+ {
+ result = new TokenResult.Revoked(success.Claims.TokenId);
+ EmitValidatedEvent(options, result);
+ return result;
+ }
+ }
+
+ if (options.ReplayCache != null)
+ {
+ var firstUse = await options.ReplayCache.TryConsumeAsync(
+ success.Claims.TokenId, success.Claims.ExpiresAt, ct);
+ if (!firstUse)
+ {
+ result = new TokenResult.Replayed(success.Claims.TokenId);
+ EmitValidatedEvent(options, result);
+ return result;
+ }
}
}
@@ -85,6 +107,13 @@ namespace EonaCat.SecureToken
///
public TokenResult Validate(string token, TokenValidationOptions options)
+ {
+ var result = ValidateInternal(token, options);
+ EmitValidatedEvent(options, result);
+ return result;
+ }
+
+ private TokenResult ValidateInternal(string token, TokenValidationOptions options)
{
if (!TokenSerializer.TryDecode(token, out var payload, out var signature))
{
@@ -134,19 +163,42 @@ namespace EonaCat.SecureToken
return new TokenResult.NotYetValid(claims.NotBefore);
}
+ // Maximum-age backstop: catches tokens whose exp claim was set unusually far
+ // ahead of iat, independent of whatever ExpiresAt itself says.
+ if (options.MaxTokenAge.HasValue)
+ {
+ var age = now - claims.IssuedAt;
+ if (age > options.MaxTokenAge.Value + skew)
+ {
+ return new TokenResult.TooOld(claims.IssuedAt, options.MaxTokenAge.Value);
+ }
+ }
+
// Issuer check
if (options.ValidIssuer != null && claims.Issuer != options.ValidIssuer)
{
return new TokenResult.UntrustedIssuer(claims.Issuer);
}
- // Audience check
- if (options.ValidAudience != null && !claims.Audiences.Contains(options.ValidAudience))
+ // Audience check. ValidAudiences (an accept-list) takes precedence over the
+ // single ValidAudience when both are set; the token passes if any of its own
+ // audiences intersects with the configured set.
+ if (options.ValidAudiences != null)
+ {
+ if (!claims.Audiences.Any(a => options.ValidAudiences.Contains(a)))
+ {
+ return new TokenResult.WrongAudience(
+ string.Join(",", options.ValidAudiences), claims.Audiences);
+ }
+ }
+ else if (options.ValidAudience != null && !claims.Audiences.Contains(options.ValidAudience))
{
return new TokenResult.WrongAudience(options.ValidAudience, claims.Audiences);
}
- // Binding check
+ // Binding check. Compared in constant time: the binding context is often derived
+ // from sensitive context (device fingerprint, session identifier) and a variable-time
+ // comparison would leak how many leading characters an attacker's guess got right.
if (options.BindingContext != null)
{
if (claims.BindingContext is null)
@@ -154,7 +206,7 @@ namespace EonaCat.SecureToken
return new TokenResult.BindingMismatch("Token has no binding context.");
}
- if (!string.Equals(claims.BindingContext, options.BindingContext, StringComparison.Ordinal))
+ if (!FixedTimeStringEquals(claims.BindingContext, options.BindingContext))
{
return new TokenResult.BindingMismatch("Binding context mismatch.");
}
@@ -163,6 +215,39 @@ namespace EonaCat.SecureToken
return new TokenResult.Success(token, claims);
}
+ ///
+ /// Constant-time comparison for UTF-8 string content, mirroring the byte-array
+ /// comparison already used for signature verification. Falls back to comparing
+ /// against a fixed-size buffer when lengths differ, so the comparison time does
+ /// not vary based on how much of a guess is correct OR on the guess's length.
+ ///
+ private static bool FixedTimeStringEquals(string a, string b)
+ {
+ var bytesA = Encoding.UTF8.GetBytes(a);
+ var bytesB = Encoding.UTF8.GetBytes(b);
+ return CryptographicOperations.FixedTimeEquals(bytesA, bytesB);
+ }
+
+ private static void EmitValidatedEvent(TokenValidationOptions options, TokenResult result)
+ {
+ var callback = options.OnValidated;
+ if (callback is null)
+ {
+ return;
+ }
+
+ string? subject = null;
+ string? tokenId = null;
+
+ if (result is TokenResult.Success success)
+ {
+ subject = success.Claims.Subject;
+ tokenId = success.Claims.TokenId;
+ }
+
+ callback(new TokenValidationEvent(result, subject, tokenId));
+ }
+
///
public TokenClaims? Inspect(string token)
{
diff --git a/EonaCat.SecureToken/Models/TokenValidationOptions.cs b/EonaCat.SecureToken/Models/TokenValidationOptions.cs
index 02084c6..9242f52 100644
--- a/EonaCat.SecureToken/Models/TokenValidationOptions.cs
+++ b/EonaCat.SecureToken/Models/TokenValidationOptions.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using EonaCat.SecureToken.Core;
@@ -22,9 +23,19 @@ namespace EonaCat.SecureToken.Validation
///
/// Expected audience (aud). Null disables audience validation - not recommended.
+ /// If is also set, that takes precedence and this is ignored.
///
public string? ValidAudience { get; set; }
+ ///
+ /// Accept-list of audiences. The token is valid if any one of its own audiences
+ /// matches any one of these. Use this instead of when the
+ /// same access token must be accepted by more than one downstream service (e.g. a
+ /// gateway that fans out to several internal APIs sharing one audience pool).
+ /// When set, this takes precedence over .
+ ///
+ public IReadOnlyCollection? ValidAudiences { get; set; }
+
///
/// Required token-type tag. Null disables type validation - not recommended outside
/// of special diagnostic scenarios.
@@ -66,6 +77,24 @@ namespace EonaCat.SecureToken.Validation
///
public TimeSpan? MaxTokenAge { get; set; }
+ ///
+ /// Optional replay cache. When set, will mark the
+ /// token's jti as consumed and reject the token (with )
+ /// if it has already been presented before. Use this for one-time-use tokens such as
+ /// password-reset or email-verification links. Not checked by the synchronous
+ /// overload, since consuming a single-use token is inherently a side effect and should go
+ /// through the async path.
+ ///
+ public IReplayCache? ReplayCache { get; set; }
+
+ ///
+ /// Optional callback invoked with the outcome of every validation attempt, regardless of
+ /// success or failure. Intended for audit logging, metrics, or SIEM integration - not for
+ /// authorization decisions. Exceptions thrown by this callback are not caught; keep it fast
+ /// and non-throwing, or wrap your own logic in a try/catch.
+ ///
+ public Action? OnValidated { get; set; }
+
///
/// Strict access-token options: issuer, audience, type, and optional binding context.
/// Suitable for most API endpoint authorization.
@@ -82,6 +111,21 @@ namespace EonaCat.SecureToken.Validation
BindingContext = bindingContext,
};
+ ///
+ /// Access-token options accepting any one of several audiences. See .
+ ///
+ public static TokenValidationOptions AccessToken(
+ string issuer,
+ IReadOnlyCollection audiences,
+ string? bindingContext = null) =>
+ new TokenValidationOptions
+ {
+ ValidIssuer = issuer,
+ ValidAudiences = audiences,
+ RequiredTokenType = TokenTypeConstants.Access,
+ BindingContext = bindingContext,
+ };
+
///
/// Refresh-token options. Audience is intentionally not required here because
/// refresh tokens are typically consumed at the authorization server, not forwarded.
@@ -109,4 +153,39 @@ namespace EonaCat.SecureToken.Validation
MaxTokenAge = maxAge,
};
}
+
+ ///
+ /// Snapshot of a single validation attempt, passed to .
+ /// Carries enough context for audit logging without forcing callers to re-derive it from the
+ /// shape.
+ ///
+ public sealed class TokenValidationEvent
+ {
+ /// The validation outcome.
+ public TokenResult Result { get; }
+
+ ///
+ /// The subject of the token. Only populated when is a
+ /// , since claims are not trusted until the
+ /// signature has verified - earlier failure paths (e.g. )
+ /// intentionally leave this null rather than surface unverified data.
+ ///
+ public string? Subject { get; }
+
+ /// The token ID (jti). Only populated on success, for the same reason as .
+ public string? TokenId { get; }
+
+ /// UTC instant the validation attempt occurred.
+ public DateTimeOffset Timestamp { get; }
+
+ public TokenValidationEvent(TokenResult result, string? subject, string? tokenId)
+ {
+ Result = result;
+ Subject = subject;
+ TokenId = tokenId;
+ Timestamp = DateTimeOffset.UtcNow;
+ }
+
+ public override string ToString() =>$"TokenValidationEvent(sub={Subject ?? "?"}, jti={TokenId ?? "?"}, result={Result})";
+ }
}
\ No newline at end of file
diff --git a/EonaCat.SecureToken/TokenSerializer.cs b/EonaCat.SecureToken/TokenSerializer.cs
index 08b520d..b71dfde 100644
--- a/EonaCat.SecureToken/TokenSerializer.cs
+++ b/EonaCat.SecureToken/TokenSerializer.cs
@@ -37,6 +37,15 @@ namespace EonaCat.SecureToken.Tokens
private const byte TagKeyGeneration = 0x22;
private const byte TagBindingContext = 0x30;
+ // Defense-in-depth caps. These are deliberately generous relative to anything
+ // TokenDescriptor would ever produce (its own limits are much tighter), so they
+ // never reject a legitimately-issued token. They exist purely to bound the cost
+ // of decoding an attacker-supplied string before signature verification has run.
+ private const int MaxTokenStringLength = 16 * 1024; // raw "stv1.x.y" string
+ private const int MaxPayloadBytes = 64 * 1024; // decoded payload buffer
+ private const int MaxFieldCount = 512; // total tagged fields
+ private const int MaxFieldStringLength = 8192; // bytes per individual string field
+
public static byte[] Serialize(TokenClaims claims)
{
using var ms = new System.IO.MemoryStream();
@@ -83,6 +92,11 @@ namespace EonaCat.SecureToken.Tokens
public static TokenClaims? Deserialize(byte[] data)
{
+ if (data is null || data.Length == 0 || data.Length > MaxPayloadBytes)
+ {
+ return null;
+ }
+
try
{
using var ms = new System.IO.MemoryStream(data);
@@ -95,9 +109,17 @@ namespace EonaCat.SecureToken.Tokens
var audiences = new List();
var roles = new List();
var custom = new Dictionary();
+ var fieldCount = 0;
while (ms.Position < ms.Length)
{
+ if (++fieldCount > MaxFieldCount)
+ {
+ // A legitimately-issued token never carries this many fields;
+ // treat it as malformed rather than keep allocating.
+ return null;
+ }
+
var tag = br.ReadByte();
switch (tag)
{
@@ -155,6 +177,11 @@ namespace EonaCat.SecureToken.Tokens
payload = new byte[0];
signature = new byte[0];
+ if (string.IsNullOrEmpty(token) || token.Length > MaxTokenStringLength)
+ {
+ return false;
+ }
+
var parts = token.Split('.');
if (parts.Length != 3 || parts[0] != Prefix)
{
@@ -164,6 +191,11 @@ namespace EonaCat.SecureToken.Tokens
try
{
payload = Base64UrlDecode(parts[1]);
+ if (payload.Length > MaxPayloadBytes)
+ {
+ return false;
+ }
+
signature = Base64UrlDecode(parts[2]);
return true;
}
@@ -189,7 +221,20 @@ namespace EonaCat.SecureToken.Tokens
private static string ReadRawString(System.IO.BinaryReader br)
{
var len = br.ReadUInt16();
+ if (len > MaxFieldStringLength)
+ {
+ // Caught by Deserialize's surrounding try/catch and reported as malformed.
+ throw new FormatException("Field exceeds maximum allowed length.");
+ }
+
var bytes = br.ReadBytes(len);
+ if (bytes.Length != len)
+ {
+ // BinaryReader.ReadBytes returns fewer bytes than requested when the
+ // stream is truncated, instead of throwing. Treat that as malformed.
+ throw new System.IO.EndOfStreamException();
+ }
+
return Encoding.UTF8.GetString(bytes);
}
diff --git a/README.md b/README.md
index 215eec3..87dfac7 100644
--- a/README.md
+++ b/README.md
@@ -18,9 +18,20 @@ EonaCat.SecureToken provides a safer alternative to rolling your own authenticat
- HMAC based token signing
- HKDF derived context-specific keys
-- Constant-time signature verification
+- Constant-time signature verification (including binding-context comparison)
- Tamper detection
- Strong random key generation
+- Bounded decoding: oversized or field-flooded tokens are rejected before signature
+ verification, so malformed input cannot be used to exhaust memory
+
+### Validation
+
+- Issuer, audience (single or multi-audience accept-list), and token-type checks
+- Expiry, not-before, and an independent `MaxTokenAge` backstop measured from issuance
+- Token binding (IP, device fingerprint, TLS channel hash, etc.)
+- Pluggable revocation check
+- Pluggable replay-cache for one-time-use tokens (password reset, email verification, invitations)
+- `OnValidated` audit hook fired for every validation attempt
### Key rotation
@@ -205,6 +216,64 @@ var options = new TokenValidationOptions
};
```
+# Replay protection for one-time-use tokens
+
+Revocation answers "has someone explicitly blocked this token?" Replay protection
+answers a different question: "has this exact token already been used once?" Use it
+for password-reset links, email-verification links, and invitations - anything that
+should only ever be redeemed a single time, even before it expires.
+
+```csharp
+var replayCache = new InMemoryReplayCache(); // register as a singleton in DI
+
+var options = TokenValidationOptions.OneTimeUse(
+ issuer: "api",
+ tokenType: TokenTypeConstants.PasswordReset,
+ maxAge: TimeSpan.FromMinutes(15));
+
+options.ReplayCache = replayCache;
+
+var result = await tokens.ValidateAsync(token, options);
+// Second call with the same token returns TokenResult.Replayed instead of Success.
+```
+
+`InMemoryReplayCache` is process-local and fine for a single instance. For multi-instance
+deployments, implement `IReplayCache` against a shared store (Redis, or a database table
+with a unique constraint on the token ID) so replay detection works across all instances.
+
+Replay consumption only happens through `ValidateAsync`, since it has a side effect
+(recording the token as used) - the synchronous `Validate` never touches the replay cache.
+
+# Multiple audiences
+
+Use `ValidAudiences` when the same access token needs to be accepted by more than one
+downstream service:
+
+```csharp
+var options = TokenValidationOptions.AccessToken(
+ issuer: "api",
+ audiences: new[] { "service-a", "service-b", "service-c" });
+```
+
+The token is accepted if any one of its own audiences matches any one of the configured set.
+
+# Audit and introspection hook
+
+`OnValidated` is invoked for every validation attempt, success or failure, and is intended
+for audit logging or metrics - not for authorization decisions:
+
+```csharp
+var options = TokenValidationOptions.AccessToken("api", "web");
+
+options.OnValidated = e =>
+{
+ logger.LogInformation("Token validation: {Result} sub={Subject}", e.Result, e.Subject);
+};
+```
+
+`Subject` and `TokenId` on the event are only populated when the result is a success,
+since claims are never trusted before the signature has verified.
+
# ASP.NET Core dependency injection
```csharp
@@ -225,6 +294,13 @@ builder.Services.AddSecureTokens(
});
```
+To enable replay protection via DI:
+
+```csharp
+builder.Services.AddSecureTokenReplayProtection();
+```
+
+
# Security design
The library separates cryptographic purposes: