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: