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
@@ -12,6 +12,7 @@
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
@@ -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();
}
}
}
@@ -10,11 +10,11 @@
<PackageProjectUrl>https://www.nuget.org/packages/EonaCat.SecureToken/</PackageProjectUrl>
<Description>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</Description>
<PackageReleaseNotes>Public release version</PackageReleaseNotes>
<PackageReleaseNotes></PackageReleaseNotes>
<Copyright>EonaCat (Jeroen Saey)</Copyright>
<PackageTags>EonaCat;authentication;api;micro;services;microservices;rotation;validation;jwt;security;secure;token;paseto;security;auth;jwt-alternative;Jeroen;Saey</PackageTags>
<PackageTags>EonaCat;authentication;api;micro;services;microservices;rotation;secure;token;validation;jwt;security;secure;token;paseto;security;auth;jwt-alternative;Jeroen;Saey</PackageTags>
<PackageIconUrl />
<FileVersion>0.0.2</FileVersion>
<FileVersion>0.0.3</FileVersion>
<PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
@@ -25,7 +25,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
</PropertyGroup>
<PropertyGroup>
<EVRevisionFormat>0.0.2+{chash:10}.{c:ymd}</EVRevisionFormat>
<EVRevisionFormat>0.0.3+{chash:10}.{c:ymd}</EVRevisionFormat>
<EVDefault>true</EVDefault>
<EVInfo>true</EVInfo>
<EVTagMatch>v[0-9]*</EVTagMatch>
@@ -36,7 +36,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
</PropertyGroup>
<PropertyGroup>
<Version>0.0.2</Version>
<Version>0.0.3</Version>
<PackageId>EonaCat.SecureToken</PackageId>
<Product>EonaCat.SecureToken</Product>
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.SecureToken</RepositoryUrl>
@@ -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
/// <summary>
/// Adds SecureToken services with a supplied key store.
/// </summary>
public static IServiceCollection AddSecureTokens(
this IServiceCollection services,
SigningKeyStore keyStore)
public static IServiceCollection AddSecureTokens(this IServiceCollection services, SigningKeyStore keyStore)
{
services.AddSingleton(keyStore);
services.AddSingleton<ITokenService, TokenService>();
@@ -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.
/// </summary>
public static IServiceCollection AddSecureTokens(
this IServiceCollection services,
Func<IServiceProvider, SigningKeyStore> keyStoreFactory)
public static IServiceCollection AddSecureTokens(this IServiceCollection services, Func<IServiceProvider, SigningKeyStore> 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<ITokenService, TokenService>();
return services;
}
/// <summary>
/// Registers a process-local <see cref="IReplayCache"/> (backed by
/// <see cref="InMemoryReplayCache"/>) for detecting replayed one-time-use
/// tokens. For multi-instance deployments, register your own <see cref="IReplayCache"/>
/// implementation backed by a shared store instead of calling this method.
/// </summary>
public static IServiceCollection AddSecureTokenReplayProtection(this IServiceCollection services)
{
services.AddSingleton<IReplayCache, InMemoryReplayCache>();
return services;
}
}
}
@@ -26,10 +26,7 @@ namespace EonaCat.SecureToken.Extensions
/// <summary>HttpContext.Items key where validated claims are stored.</summary>
public const string ClaimsItemKey = "SecureToken.Claims";
public SecureTokenMiddleware(
RequestDelegate next,
TokenValidationOptions options,
ILogger<SecureTokenMiddleware> logger)
public SecureTokenMiddleware(RequestDelegate next, TokenValidationOptions options, ILogger<SecureTokenMiddleware> 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)
@@ -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.
/// <summary>
/// Tracks token IDs (<c>jti</c>) 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 <see cref="TokenValidationOptions.RevocationCheck"/> (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.
/// </summary>
public interface IReplayCache
{
/// <summary>
/// Atomically checks whether <paramref name="tokenId"/> has been seen before and,
/// if not, records it as seen. Returns <c>true</c> if this is the first time the
/// token has been presented (i.e. it is safe to honor), or <c>false</c> if it has
/// already been consumed (i.e. this is a replay).
/// </summary>
/// <param name="tokenId">The token's <c>jti</c>.</param>
/// <param name="expiresAt">
/// 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.
/// </param>
Task<bool> TryConsumeAsync(string tokenId, DateTimeOffset expiresAt, CancellationToken ct = default);
}
/// <summary>
/// Simple process-local <see cref="IReplayCache"/> backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/>.
///
/// 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 <see cref="Sweep"/> periodically from a background timer instead.
/// </summary>
public sealed class InMemoryReplayCache : IReplayCache
{
private readonly ConcurrentDictionary<string, DateTimeOffset> _seen = new ConcurrentDictionary<string, DateTimeOffset>();
private long _opsSinceSweep;
/// <summary>Sweep the cache after this many operations, amortizing cleanup cost.</summary>
private const long SweepInterval = 256;
public Task<bool> 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);
}
/// <summary>Removes entries for tokens that have already expired.</summary>
public void Sweep()
{
var now = DateTimeOffset.UtcNow;
foreach (var kv in _seen)
{
if (kv.Value <= now)
{
_seen.TryRemove(kv.Key, out _);
}
}
}
/// <summary>Number of token IDs currently tracked. Exposed for diagnostics/tests.</summary>
public int Count => _seen.Count;
}
}
@@ -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);
@@ -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);
}
}
}
+4 -13
View File
@@ -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.
/// </summary>
public static TokenPair IssueTokenPair(
this ITokenService service,
string subject,
string issuer,
string audience,
IEnumerable<string>? roles = null,
IEnumerable<KeyValuePair<string, string>>? 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<string>? roles = null, IEnumerable<KeyValuePair<string, string>>? 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
);
+26
View File
@@ -38,6 +38,19 @@ namespace EonaCat.SecureToken.Core
public override string ToString() => $"Expired(at={ExpiredAt:u})";
}
/// <summary>
/// Token's age (now - iat) exceeds <see cref="EonaCat.SecureToken.Validation.TokenValidationOptions.MaxTokenAge"/>.
/// This is a defense-in-depth backstop distinct from <see cref="Expired"/>: it fires
/// even if the token's own <c>exp</c> claim would still consider it valid.
/// </summary>
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})";
}
/// <summary>HMAC signature does not match the payload.</summary>
public sealed class InvalidSignature : TokenResult
{
@@ -107,6 +120,19 @@ namespace EonaCat.SecureToken.Core
public override string ToString() => $"Revoked(jti={TokenId})";
}
/// <summary>
/// Token has already been consumed once before, per the configured
/// <see cref="EonaCat.SecureToken.Validation.IReplayCache"/>. 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.
/// </summary>
public sealed class Replayed : TokenResult
{
public string TokenId { get; }
public Replayed(string tokenId) { TokenId = tokenId; }
public override string ToString() => $"Replayed(jti={TokenId})";
}
/// <summary>Token string is structurally invalid (wrong prefix, bad Base64, corrupt payload).</summary>
public sealed class Malformed : TokenResult
{
+92 -7
View File
@@ -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
/// <summary>Validates a token string and returns a typed result.</summary>
Task<TokenResult> ValidateAsync(string token, TokenValidationOptions options, CancellationToken ct = default);
/// <summary>Validates synchronously (no revocation check).</summary>
/// <summary>
/// Validates synchronously. Does not perform <see cref="TokenValidationOptions.RevocationCheck"/>
/// or <see cref="TokenValidationOptions.ReplayCache"/> consumption, since both are inherently
/// async (and the replay check has a side effect) - use <see cref="ValidateAsync"/> for those.
/// </summary>
TokenResult Validate(string token, TokenValidationOptions options);
/// <summary>
@@ -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)
{
if (options.RevocationCheck != null)
{
var isRevoked = await options.RevocationCheck(success.Claims.TokenId, ct);
if (isRevoked)
{
return new TokenResult.Revoked(success.Claims.TokenId);
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
/// <inheritdoc />
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);
}
/// <summary>
/// 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.
/// </summary>
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));
}
/// <inheritdoc />
public TokenClaims? Inspect(string token)
{
@@ -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
/// <summary>
/// Expected audience (<c>aud</c>). Null disables audience validation - not recommended.
/// If <see cref="ValidAudiences"/> is also set, that takes precedence and this is ignored.
/// </summary>
public string? ValidAudience { get; set; }
/// <summary>
/// Accept-list of audiences. The token is valid if any one of its own audiences
/// matches any one of these. Use this instead of <see cref="ValidAudience"/> 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 <see cref="ValidAudience"/>.
/// </summary>
public IReadOnlyCollection<string>? ValidAudiences { get; set; }
/// <summary>
/// Required token-type tag. Null disables type validation - not recommended outside
/// of special diagnostic scenarios.
@@ -66,6 +77,24 @@ namespace EonaCat.SecureToken.Validation
/// </summary>
public TimeSpan? MaxTokenAge { get; set; }
/// <summary>
/// Optional replay cache. When set, <see cref="ITokenService.ValidateAsync"/> will mark the
/// token's <c>jti</c> as consumed and reject the token (with <see cref="TokenResult.Replayed"/>)
/// 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 <see cref="ITokenService.Validate"/>
/// overload, since consuming a single-use token is inherently a side effect and should go
/// through the async path.
/// </summary>
public IReplayCache? ReplayCache { get; set; }
/// <summary>
/// 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.
/// </summary>
public Action<TokenValidationEvent>? OnValidated { get; set; }
/// <summary>
/// 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,
};
/// <summary>
/// Access-token options accepting any one of several audiences. See <see cref="ValidAudiences"/>.
/// </summary>
public static TokenValidationOptions AccessToken(
string issuer,
IReadOnlyCollection<string> audiences,
string? bindingContext = null) =>
new TokenValidationOptions
{
ValidIssuer = issuer,
ValidAudiences = audiences,
RequiredTokenType = TokenTypeConstants.Access,
BindingContext = bindingContext,
};
/// <summary>
/// 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,
};
}
/// <summary>
/// Snapshot of a single validation attempt, passed to <see cref="TokenValidationOptions.OnValidated"/>.
/// Carries enough context for audit logging without forcing callers to re-derive it from the
/// <see cref="TokenResult"/> shape.
/// </summary>
public sealed class TokenValidationEvent
{
/// <summary>The validation outcome.</summary>
public TokenResult Result { get; }
/// <summary>
/// The subject of the token. Only populated when <see cref="Result"/> is a
/// <see cref="TokenResult.Success"/>, since claims are not trusted until the
/// signature has verified - earlier failure paths (e.g. <see cref="TokenResult.InvalidSignature"/>)
/// intentionally leave this null rather than surface unverified data.
/// </summary>
public string? Subject { get; }
/// <summary>The token ID (<c>jti</c>). Only populated on success, for the same reason as <see cref="Subject"/>.</summary>
public string? TokenId { get; }
/// <summary>UTC instant the validation attempt occurred.</summary>
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})";
}
}
+45
View File
@@ -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<string>();
var roles = new List<string>();
var custom = new Dictionary<string, string>();
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);
}
+77 -1
View File
@@ -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: