diff --git a/EonaCat.SecureToken/Core/TokenIntrospector.cs b/EonaCat.SecureToken/Core/TokenIntrospector.cs
new file mode 100644
index 0000000..1abc947
--- /dev/null
+++ b/EonaCat.SecureToken/Core/TokenIntrospector.cs
@@ -0,0 +1,239 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace EonaCat.SecureToken.Core
+{
+ // 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.
+
+ ///
+ /// Detailed information about a token, extracted without signature verification.
+ /// Use only for diagnostic and audit purposes, never for authorization.
+ ///
+ public sealed class TokenIntrospectionResult
+ {
+ ///
+ /// Whether the token format is valid (can be parsed).
+ ///
+ public bool IsValidFormat { get; set; }
+
+ ///
+ /// The token claims if the format is valid; otherwise null.
+ ///
+ public TokenClaims? Claims { get; set; }
+
+ ///
+ /// Error message if parsing failed.
+ ///
+ public string? ErrorMessage { get; set; }
+
+ ///
+ /// Additional diagnostic information.
+ ///
+ public Dictionary Diagnostics { get; set; } = new Dictionary();
+
+ ///
+ /// Creates a successful introspection result.
+ ///
+ public static TokenIntrospectionResult Success(TokenClaims claims)
+ {
+ return new TokenIntrospectionResult
+ {
+ IsValidFormat = true,
+ Claims = claims,
+ ErrorMessage = null
+ };
+ }
+
+ ///
+ /// Creates a failure introspection result.
+ ///
+ public static TokenIntrospectionResult Failure(string errorMessage)
+ {
+ return new TokenIntrospectionResult
+ {
+ IsValidFormat = false,
+ Claims = null,
+ ErrorMessage = errorMessage
+ };
+ }
+ }
+
+ ///
+ /// Provides detailed token introspection for diagnostic and audit purposes.
+ /// This is useful for debugging token issues and understanding token structure.
+ ///
+ public interface ITokenIntrospector
+ {
+ ///
+ /// Analyzes a token without verifying the signature.
+ /// Returns detailed information about the token structure and claims.
+ /// Use only for diagnostics - never for authorization decisions.
+ ///
+ /// The token to introspect
+ /// Introspection result with claims or error information
+ TokenIntrospectionResult Introspect(string token);
+
+ ///
+ /// Analyzes multiple tokens and returns their information.
+ ///
+ /// Tokens to introspect
+ /// Introspection results for each token
+ IReadOnlyList IntrospectMultiple(params string[] tokens);
+
+ ///
+ /// Extracts and analyzes the raw token parts (header, payload, signature) for debugging.
+ ///
+ /// The token to analyze
+ /// The Base64-encoded payload if parsing succeeds
+ /// The Base64-encoded signature if parsing succeeds
+ /// True if the token format is valid; false otherwise
+ bool TryGetRawParts(string token, out string? encodedPayload, out string? encodedSignature);
+
+ ///
+ /// Gets a human-readable summary of token claims for debugging.
+ ///
+ /// The token to summarize
+ /// Formatted summary string
+ string GetClaimsSummary(string token);
+ }
+
+ ///
+ /// Default implementation of token introspection for diagnostics.
+ ///
+ public sealed class TokenIntrospector : ITokenIntrospector
+ {
+ private readonly ITokenService _tokenService;
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The token service to use for introspection
+ public TokenIntrospector(ITokenService tokenService)
+ {
+ _tokenService = tokenService ?? throw new ArgumentNullException(nameof(tokenService));
+ }
+
+ ///
+ public TokenIntrospectionResult Introspect(string token)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ return TokenIntrospectionResult.Failure("Token cannot be null or empty.");
+ }
+
+ var claims = _tokenService.Inspect(token);
+ if (claims is null)
+ {
+ return TokenIntrospectionResult.Failure("Token format is invalid or payload could not be decoded.");
+ }
+
+ var result = TokenIntrospectionResult.Success(claims);
+
+ // Add diagnostic info
+ var now = DateTimeOffset.UtcNow;
+ result.Diagnostics["IsExpired"] = (now > claims.ExpiresAt).ToString();
+ result.Diagnostics["ExpiresAt"] = claims.ExpiresAt.ToString("O");
+ result.Diagnostics["IssuedAt"] = claims.IssuedAt.ToString("O");
+ result.Diagnostics["TokenAge"] = (now - claims.IssuedAt).TotalSeconds.ToString("F0");
+ result.Diagnostics["TokenType"] = claims.TokenType;
+ result.Diagnostics["KeyGeneration"] = claims.KeyGeneration.ToString();
+
+ return result;
+ }
+
+ ///
+ public IReadOnlyList IntrospectMultiple(params string[] tokens)
+ {
+ if (tokens is null)
+ {
+ throw new ArgumentNullException(nameof(tokens));
+ }
+
+ var results = new List(tokens.Length);
+ foreach (var token in tokens)
+ {
+ results.Add(Introspect(token));
+ }
+
+ return results.AsReadOnly();
+ }
+
+ ///
+ public bool TryGetRawParts(string token, out string? encodedPayload, out string? encodedSignature)
+ {
+ encodedPayload = null;
+ encodedSignature = null;
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ return false;
+ }
+
+ var parts = token.Split('.');
+ if (parts.Length != 3)
+ {
+ return false;
+ }
+
+ encodedPayload = parts[1];
+ encodedSignature = parts[2];
+ return true;
+ }
+
+ ///
+ public string GetClaimsSummary(string token)
+ {
+ var result = Introspect(token);
+ if (!result.IsValidFormat || result.Claims is null)
+ {
+ return $"INVALID: {result.ErrorMessage}";
+ }
+
+ var claims = result.Claims;
+ var now = DateTimeOffset.UtcNow;
+ var isExpired = now > claims.ExpiresAt;
+ var timeRemaining = claims.ExpiresAt - now;
+
+ var summary = new System.Text.StringBuilder();
+ summary.AppendLine("=== Token Claims Summary ===");
+ summary.AppendLine($"Token ID: {claims.TokenId}");
+ summary.AppendLine($"Subject: {claims.Subject}");
+ summary.AppendLine($"Issuer: {claims.Issuer}");
+ summary.AppendLine($"Audiences: {string.Join(", ", claims.Audiences)}");
+ summary.AppendLine($"Token Type: {claims.TokenType}");
+ summary.AppendLine($"Key Generation: {claims.KeyGeneration}");
+ summary.AppendLine();
+ summary.AppendLine($"Issued At: {claims.IssuedAt:O}");
+ summary.AppendLine($"Not Before: {claims.NotBefore:O}");
+ summary.AppendLine($"Expires At: {claims.ExpiresAt:O}");
+ summary.AppendLine($"Status: {(isExpired ? "EXPIRED" : "VALID")}");
+ if (!isExpired)
+ {
+ summary.AppendLine($"Expires In: {timeRemaining.TotalSeconds:F0}s ({timeRemaining.TotalMinutes:F2} min)");
+ }
+
+ if (!string.IsNullOrEmpty(claims.BindingContext))
+ {
+ summary.AppendLine($"Binding Context: {claims.BindingContext}");
+ }
+
+ if (claims.Roles.Count > 0)
+ {
+ summary.AppendLine($"Roles: {string.Join(", ", claims.Roles)}");
+ }
+
+ if (claims.Custom.Count > 0)
+ {
+ summary.AppendLine("Custom Claims:");
+ foreach (var claim in claims.Custom)
+ {
+ summary.AppendLine($" {claim.Key}: {claim.Value}");
+ }
+ }
+
+ return summary.ToString();
+ }
+ }
+}
diff --git a/EonaCat.SecureToken/EonaCat.SecureToken.csproj b/EonaCat.SecureToken/EonaCat.SecureToken.csproj
index 17b4a1f..e00ea35 100644
--- a/EonaCat.SecureToken/EonaCat.SecureToken.csproj
+++ b/EonaCat.SecureToken/EonaCat.SecureToken.csproj
@@ -14,7 +14,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
EonaCat (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.3
+ 0.0.4
README.md
True
LICENSE
@@ -25,7 +25,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
- 0.0.3+{chash:10}.{c:ymd}
+ 0.0.4+{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.3
+ 0.0.4
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 6dc1c4c..04aa907 100644
--- a/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs
+++ b/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs
@@ -1,6 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using EonaCat.SecureToken.Cryptography;
using EonaCat.SecureToken.Validation;
+using EonaCat.SecureToken.Core;
+using EonaCat.SecureToken.Security;
using System;
namespace EonaCat.SecureToken.Extensions
@@ -10,6 +12,7 @@ namespace EonaCat.SecureToken.Extensions
///
/// Extension methods for registering SecureToken services with the DI container.
+ /// Includes support for token service, introspection, rate limiting, blacklisting, and caching.
///
public static class ServiceCollectionExtensions
{
@@ -65,5 +68,75 @@ namespace EonaCat.SecureToken.Extensions
services.AddSingleton();
return services;
}
+
+ ///
+ /// Adds token introspection service for diagnostic and audit purposes.
+ /// Allows detailed examination of token claims without signature verification.
+ ///
+ public static IServiceCollection AddSecureTokenIntrospection(this IServiceCollection services)
+ {
+ services.AddSingleton(sp =>
+ new TokenIntrospector(sp.GetRequiredService()));
+ return services;
+ }
+
+ ///
+ /// Adds token rate limiting for failed validation attempt throttling.
+ /// Protects against brute-force attacks on token validation.
+ ///
+ /// The service collection
+ /// Optional rate limiting policy; uses defaults if null
+ public static IServiceCollection AddSecureTokenRateLimiting(
+ this IServiceCollection services,
+ RateLimitPolicy? policy = null)
+ {
+ services.AddSingleton(
+ _ => new InMemoryTokenRateLimiter(policy));
+ return services;
+ }
+
+ ///
+ /// Adds token blacklist for revocation management.
+ /// Supports immediate revocation of tokens before their natural expiration.
+ ///
+ public static IServiceCollection AddSecureTokenBlacklist(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ return services;
+ }
+
+ ///
+ /// Adds token caching for reducing validation computational overhead.
+ /// Validates tokens once and caches the result for frequent lookups.
+ ///
+ public static IServiceCollection AddSecureTokenCache(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ return services;
+ }
+
+ ///
+ /// Adds all security features (introspection, rate limiting, blacklist, and cache).
+ /// Recommended for most production applications.
+ ///
+ public static IServiceCollection AddSecureTokenWithAllFeatures(this IServiceCollection services)
+ {
+ return services
+ .AddSecureTokenIntrospection()
+ .AddSecureTokenRateLimiting()
+ .AddSecureTokenBlacklist()
+ .AddSecureTokenCache();
+ }
+
+ ///
+ /// Adds a subset of security features (rate limiting and blacklist only).
+ /// Suitable for high-performance scenarios where caching is not needed.
+ ///
+ public static IServiceCollection AddSecureTokenWithMinimalSecurity(this IServiceCollection services)
+ {
+ return services
+ .AddSecureTokenRateLimiting()
+ .AddSecureTokenBlacklist();
+ }
}
}
diff --git a/EonaCat.SecureToken/Extensions/TokenServiceExtensions.cs b/EonaCat.SecureToken/Extensions/TokenServiceExtensions.cs
new file mode 100644
index 0000000..31c66cd
--- /dev/null
+++ b/EonaCat.SecureToken/Extensions/TokenServiceExtensions.cs
@@ -0,0 +1,390 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using EonaCat.SecureToken.Core;
+using EonaCat.SecureToken.Security;
+using EonaCat.SecureToken.Tokens;
+using EonaCat.SecureToken.Validation;
+
+namespace EonaCat.SecureToken.Extensions
+{
+ // 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.
+
+ ///
+ /// Extension methods for advanced token operations including batch processing,
+ /// security checks, and validation with additional features.
+ ///
+ public static class TokenServiceExtensions
+ {
+ ///
+ /// Validates multiple tokens concurrently.
+ ///
+ /// The token service
+ /// Tokens to validate
+ /// Validation options
+ /// Cancellation token
+ /// Collection of validation results in the same order as input tokens
+ public static async Task> ValidateManyAsync(
+ this ITokenService service,
+ IEnumerable tokens,
+ TokenValidationOptions options,
+ CancellationToken ct = default)
+ {
+ if (service is null)
+ {
+ throw new ArgumentNullException(nameof(service));
+ }
+
+ if (tokens is null)
+ {
+ throw new ArgumentNullException(nameof(tokens));
+ }
+
+ if (options is null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
+ var tokenList = tokens.ToList();
+ var results = new List(tokenList.Count);
+
+ foreach (var token in tokenList)
+ {
+ results.Add(await service.ValidateAsync(token, options, ct).ConfigureAwait(false));
+ }
+
+ return results.AsReadOnly();
+ }
+
+ ///
+ /// Validates a token and checks the blacklist.
+ ///
+ /// The token service
+ /// Token to validate
+ /// Validation options
+ /// The blacklist to check
+ /// Cancellation token
+ /// Validation result (or Revoked if token is blacklisted)
+ public static async Task ValidateWithBlacklistAsync(
+ this ITokenService service,
+ string token,
+ TokenValidationOptions options,
+ ITokenBlacklist blacklist,
+ CancellationToken ct = default)
+ {
+ if (service is null)
+ {
+ throw new ArgumentNullException(nameof(service));
+ }
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+ }
+
+ if (blacklist is null)
+ {
+ throw new ArgumentNullException(nameof(blacklist));
+ }
+
+ // First validate the token normally
+ var result = await service.ValidateAsync(token, options, ct).ConfigureAwait(false);
+
+ // If validation succeeded, check blacklist
+ if (result is TokenResult.Success success)
+ {
+ var isBlacklisted = await blacklist.IsBlacklistedAsync(success.Claims.TokenId).ConfigureAwait(false);
+ if (isBlacklisted)
+ {
+ return new TokenResult.Revoked(success.Claims.TokenId);
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Validates a token with rate limiting on the subject.
+ ///
+ /// The token service
+ /// Token to validate
+ /// Validation options
+ /// The rate limiter
+ /// Subject to rate limit (e.g., user ID, IP address)
+ /// Cancellation token
+ /// Validation result (or failure if rate limited)
+ public static async Task ValidateWithRateLimitingAsync(
+ this ITokenService service,
+ string token,
+ TokenValidationOptions options,
+ ITokenRateLimiter rateLimiter,
+ string subject,
+ CancellationToken ct = default)
+ {
+ if (service is null)
+ {
+ throw new ArgumentNullException(nameof(service));
+ }
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+ }
+
+ if (rateLimiter is null)
+ {
+ throw new ArgumentNullException(nameof(rateLimiter));
+ }
+
+ if (string.IsNullOrWhiteSpace(subject))
+ {
+ throw new ArgumentException("Subject cannot be null or empty.", nameof(subject));
+ }
+
+ // Check if subject is already rate limited
+ var isLocked = await rateLimiter.IsLockedOutAsync(subject).ConfigureAwait(false);
+ if (isLocked)
+ {
+ var timeRemaining = await rateLimiter.GetLockoutTimeRemainingAsync(subject).ConfigureAwait(false);
+ return new TokenResult.Malformed(
+ $"Rate limit exceeded. Try again in {timeRemaining?.TotalSeconds:F0} seconds.");
+ }
+
+ // Validate the token
+ var result = await service.ValidateAsync(token, options, ct).ConfigureAwait(false);
+
+ // If validation failed, record the failure
+ if (!result.IsSuccess)
+ {
+ var recorded = await rateLimiter.TryRecordFailureAsync(subject).ConfigureAwait(false);
+ if (!recorded)
+ {
+ return new TokenResult.Malformed("Rate limit exceeded for invalid token attempts.");
+ }
+ }
+ else
+ {
+ // Reset the failure count on successful validation
+ await rateLimiter.ResetAsync(subject).ConfigureAwait(false);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Validates a token and caches the result for subsequent lookups.
+ /// Reduces computational overhead for frequently-validated tokens.
+ ///
+ /// The token service
+ /// Token to validate
+ /// Validation options
+ /// The cache to store validated tokens
+ /// Cancellation token
+ /// Validation result
+ public static async Task ValidateWithCachingAsync(
+ this ITokenService service,
+ string token,
+ TokenValidationOptions options,
+ ISecurityTokenCache cache,
+ CancellationToken ct = default)
+ {
+ if (service is null)
+ {
+ throw new ArgumentNullException(nameof(service));
+ }
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+ }
+
+ if (cache is null)
+ {
+ throw new ArgumentNullException(nameof(cache));
+ }
+
+ // Try to decode token without verification to get the token ID
+ if (!TokenSerializer.TryDecode(token, out var payload, out _))
+ {
+ return new TokenResult.Malformed("Token format is invalid.");
+ }
+
+ var claims = TokenSerializer.Deserialize(payload);
+ if (claims is null)
+ {
+ return new TokenResult.Malformed("Token payload could not be decoded.");
+ }
+
+ // Check cache first
+ var cached = await cache.TryGetAsync(claims.TokenId).ConfigureAwait(false);
+ if (cached)
+ {
+ return new TokenResult.Success(token, claims);
+ }
+
+ // If not in cache, validate normally
+ var result = await service.ValidateAsync(token, options, ct).ConfigureAwait(false);
+
+ // If successful, add to cache
+ if (result is TokenResult.Success success)
+ {
+ await cache.SetAsync(success.Claims.TokenId, success.Claims.ExpiresAt).ConfigureAwait(false);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Validates a token with all available security checks (rate limiting, blacklist, caching).
+ ///
+ /// The token service
+ /// Token to validate
+ /// Validation options
+ /// Security configuration with rate limiter, blacklist, and/or cache
+ /// Optional subject for rate limiting (IP, user ID, etc.)
+ /// Cancellation token
+ /// Validation result with all security checks applied
+ public static async Task ValidateWithSecurityAsync(
+ this ITokenService service,
+ string token,
+ TokenValidationOptions options,
+ SecureTokenConfiguration configuration,
+ string? rateLimitSubject = null,
+ CancellationToken ct = default)
+ {
+ if (service is null)
+ {
+ throw new ArgumentNullException(nameof(service));
+ }
+
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new ArgumentException("Token cannot be null or empty.", nameof(token));
+ }
+
+ if (configuration is null)
+ {
+ throw new ArgumentNullException(nameof(configuration));
+ }
+
+ var result = await service.ValidateAsync(token, options, ct).ConfigureAwait(false);
+
+ // Check blacklist if configured
+ if (configuration.Blacklist is not null && result is TokenResult.Success success)
+ {
+ var isBlacklisted = await configuration.Blacklist.IsBlacklistedAsync(
+ success.Claims.TokenId).ConfigureAwait(false);
+ if (isBlacklisted)
+ {
+ result = new TokenResult.Revoked(success.Claims.TokenId);
+ }
+ }
+
+ // Apply rate limiting if configured
+ if (configuration.RateLimiter is not null && !string.IsNullOrWhiteSpace(rateLimitSubject))
+ {
+ if (!result.IsSuccess)
+ {
+ var recorded = await configuration.RateLimiter.TryRecordFailureAsync(rateLimitSubject).ConfigureAwait(false);
+ if (!recorded)
+ {
+ return new TokenResult.Malformed("Rate limit exceeded.");
+ }
+ }
+ else
+ {
+ await configuration.RateLimiter.ResetAsync(rateLimitSubject).ConfigureAwait(false);
+ }
+ }
+
+ // Cache successful validations if configured
+ if (configuration.Cache is not null && result is TokenResult.Success cacheSuccess)
+ {
+ await configuration.Cache.SetAsync(cacheSuccess.Claims.TokenId, cacheSuccess.Claims.ExpiresAt).ConfigureAwait(false);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Gets a summary of all failed validations for a subject during the current rate limit window.
+ /// Useful for security auditing and debugging.
+ ///
+ /// The rate limiter
+ /// The subject to check
+ /// Summary information including failure count and lockout status
+ public static async Task GetFailureSummaryAsync(
+ this ITokenRateLimiter rateLimiter,
+ string subject)
+ {
+ if (rateLimiter is null)
+ {
+ throw new ArgumentNullException(nameof(rateLimiter));
+ }
+
+ if (string.IsNullOrWhiteSpace(subject))
+ {
+ throw new ArgumentException("Subject cannot be null or empty.", nameof(subject));
+ }
+
+ var failureCount = await rateLimiter.GetFailureCountAsync(subject).ConfigureAwait(false);
+ var lockoutTime = await rateLimiter.GetLockoutTimeRemainingAsync(subject).ConfigureAwait(false);
+
+ if (lockoutTime.HasValue)
+ {
+ return $"Subject '{subject}' is locked out. {failureCount} failures recorded. " +
+ $"Time remaining: {lockoutTime.Value.TotalSeconds:F0} seconds.";
+ }
+
+ return $"Subject '{subject}' has {failureCount} failures recorded (not locked out).";
+ }
+
+ ///
+ /// Introspects and compares multiple tokens for diagnostic purposes.
+ ///
+ /// The token introspector
+ /// Tokens to compare
+ /// Formatted comparison of token claims
+ public static string CompareTokens(
+ this ITokenIntrospector introspector,
+ params string[] tokens)
+ {
+ if (introspector is null)
+ {
+ throw new ArgumentNullException(nameof(introspector));
+ }
+
+ if (tokens is null || tokens.Length == 0)
+ {
+ throw new ArgumentException("At least one token must be provided.", nameof(tokens));
+ }
+
+ var results = introspector.IntrospectMultiple(tokens);
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine("=== Token Comparison ===");
+
+ for (int i = 0; i < results.Count; i++)
+ {
+ sb.AppendLine($"\n--- Token {i + 1} ---");
+ if (results[i].IsValidFormat && results[i].Claims is not null)
+ {
+ var claims = results[i].Claims;
+ sb.AppendLine($"Subject: {claims.Subject}");
+ sb.AppendLine($"Issuer: {claims.Issuer}");
+ sb.AppendLine($"Audiences: {string.Join(", ", claims.Audiences)}");
+ sb.AppendLine($"Token Type: {claims.TokenType}");
+ sb.AppendLine($"Expires: {(DateTimeOffset.UtcNow > claims.ExpiresAt ? "EXPIRED" : "VALID")}");
+ }
+ else
+ {
+ sb.AppendLine($"ERROR: {results[i].ErrorMessage}");
+ }
+ }
+
+ return sb.ToString();
+ }
+ }
+}
diff --git a/EonaCat.SecureToken/Models/SecurityTokenCache.cs b/EonaCat.SecureToken/Models/SecurityTokenCache.cs
new file mode 100644
index 0000000..f866c22
--- /dev/null
+++ b/EonaCat.SecureToken/Models/SecurityTokenCache.cs
@@ -0,0 +1,174 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace EonaCat.SecureToken
+{
+ // 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.
+
+ ///
+ /// Provides in-memory caching and lifecycle management for validated tokens.
+ /// This interface allows for extensible cache implementations (in-memory, distributed, database, etc.).
+ ///
+ public interface ISecurityTokenCache
+ {
+ ///
+ /// Records a validated token in the cache.
+ ///
+ /// The token identifier
+ /// When the token expires
+ Task SetAsync(string tokenId, DateTimeOffset expiresAt);
+
+ ///
+ /// Retrieves a token from the cache if it exists and has not expired.
+ ///
+ /// The token identifier
+ /// True if the token is in cache and valid; otherwise false
+ Task TryGetAsync(string tokenId);
+
+ ///
+ /// Removes a token from the cache (e.g., after revocation).
+ ///
+ /// The token identifier
+ Task RemoveAsync(string tokenId);
+
+ ///
+ /// Clears all tokens from the cache. Returns the count of items removed.
+ ///
+ Task ClearAsync();
+
+ ///
+ /// Gets the current count of cached tokens.
+ ///
+ Task CountAsync();
+
+ ///
+ /// Removes all expired tokens from the cache. Returns the count of items removed.
+ ///
+ Task RemoveExpiredAsync();
+ }
+
+ ///
+ /// Thread-safe in-memory implementation of .
+ /// Automatically removes expired tokens during operations and periodically.
+ /// Suitable for single-server deployments; for distributed systems, use a distributed cache.
+ ///
+ public sealed class InMemorySecurityTokenCache : ISecurityTokenCache
+ {
+ private readonly ConcurrentDictionary _cache =
+ new ConcurrentDictionary();
+
+ private readonly object _cleanupLock = new object();
+ private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow;
+ private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ public InMemorySecurityTokenCache()
+ {
+ }
+
+ ///
+ public Task SetAsync(string tokenId, DateTimeOffset expiresAt)
+ {
+ if (string.IsNullOrWhiteSpace(tokenId))
+ {
+ throw new ArgumentException("Token ID cannot be null or empty.", nameof(tokenId));
+ }
+
+ _cache.AddOrUpdate(tokenId, expiresAt, (_, _) => expiresAt);
+ TriggerCleanupIfNeeded();
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task TryGetAsync(string tokenId)
+ {
+ if (string.IsNullOrWhiteSpace(tokenId))
+ {
+ return Task.FromResult(false);
+ }
+
+ if (_cache.TryGetValue(tokenId, out var expiresAt))
+ {
+ if (DateTimeOffset.UtcNow <= expiresAt)
+ {
+ return Task.FromResult(true);
+ }
+
+ // Token has expired; remove it
+ _cache.TryRemove(tokenId, out _);
+ }
+
+ return Task.FromResult(false);
+ }
+
+ ///
+ public Task RemoveAsync(string tokenId)
+ {
+ if (!string.IsNullOrWhiteSpace(tokenId))
+ {
+ _cache.TryRemove(tokenId, out _);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task ClearAsync()
+ {
+ var count = _cache.Count;
+ _cache.Clear();
+ return Task.FromResult(count);
+ }
+
+ ///
+ public Task CountAsync()
+ {
+ return Task.FromResult(_cache.Count);
+ }
+
+ ///
+ public Task RemoveExpiredAsync()
+ {
+ var now = DateTimeOffset.UtcNow;
+ var expiredTokens = _cache
+ .Where(kvp => now > kvp.Value)
+ .Select(kvp => kvp.Key)
+ .ToList();
+
+ int removed = 0;
+ foreach (var token in expiredTokens)
+ {
+ if (_cache.TryRemove(token, out _))
+ {
+ removed++;
+ }
+ }
+
+ return Task.FromResult(removed);
+ }
+
+ private void TriggerCleanupIfNeeded()
+ {
+ var now = DateTimeOffset.UtcNow;
+ if (now - _lastCleanup >= _cleanupInterval)
+ {
+ lock (_cleanupLock)
+ {
+ // Double-check after acquiring lock
+ if (now - _lastCleanup >= _cleanupInterval)
+ {
+ _lastCleanup = now;
+ // Fire-and-forget cleanup
+ _ = RemoveExpiredAsync();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/EonaCat.SecureToken/Models/TokenPairDescriptor.cs b/EonaCat.SecureToken/Models/TokenPairDescriptor.cs
new file mode 100644
index 0000000..54c97fe
--- /dev/null
+++ b/EonaCat.SecureToken/Models/TokenPairDescriptor.cs
@@ -0,0 +1,185 @@
+using System;
+
+namespace EonaCat.SecureToken.Core
+{
+ // 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.
+
+ ///
+ /// Represents an access token and refresh token pair.
+ ///
+ public sealed class AccessTokenRefreshTokenPair
+ {
+ ///
+ /// The access token (short-lived, typically for API requests).
+ ///
+ public string AccessToken { get; set; }
+
+ ///
+ /// The refresh token (long-lived, used to obtain new access tokens).
+ ///
+ public string RefreshToken { get; set; }
+
+ ///
+ /// Creates a new token pair.
+ ///
+ public AccessTokenRefreshTokenPair(string accessToken, string refreshToken)
+ {
+ if (string.IsNullOrWhiteSpace(accessToken))
+ {
+ throw new ArgumentException("Access token cannot be null or empty.", nameof(accessToken));
+ }
+
+ if (string.IsNullOrWhiteSpace(refreshToken))
+ {
+ throw new ArgumentException("Refresh token cannot be null or empty.", nameof(refreshToken));
+ }
+
+ AccessToken = accessToken;
+ RefreshToken = refreshToken;
+ }
+ }
+
+ ///
+ /// Helper for issuing paired access and refresh tokens with optimal defaults.
+ ///
+ public static class TokenPairDescriptor
+ {
+ ///
+ /// Default lifetime for access tokens: 15 minutes.
+ ///
+ public static readonly TimeSpan DefaultAccessTokenLifetime = TimeSpan.FromMinutes(15);
+
+ ///
+ /// Default lifetime for refresh tokens: 30 days.
+ ///
+ public static readonly TimeSpan DefaultRefreshTokenLifetime = TimeSpan.FromDays(30);
+
+ ///
+ /// Creates an access token and refresh token pair with secure defaults.
+ ///
+ /// The token service
+ /// The subject (user ID, etc.)
+ /// The issuer
+ /// The audience
+ /// Custom access token lifetime; defaults to 15 minutes
+ /// Custom refresh token lifetime; defaults to 30 days
+ /// A pair of access and refresh tokens
+ public static AccessTokenRefreshTokenPair CreatePair(
+ ITokenService service,
+ string subject,
+ string issuer,
+ string audience,
+ TimeSpan? accessTokenLifetime = null,
+ TimeSpan? refreshTokenLifetime = null)
+ {
+ if (service is null)
+ {
+ throw new ArgumentNullException(nameof(service));
+ }
+
+ if (string.IsNullOrWhiteSpace(subject))
+ {
+ throw new ArgumentException("Subject cannot be null or empty.", nameof(subject));
+ }
+
+ if (string.IsNullOrWhiteSpace(issuer))
+ {
+ throw new ArgumentException("Issuer cannot be null or empty.", nameof(issuer));
+ }
+
+ if (string.IsNullOrWhiteSpace(audience))
+ {
+ throw new ArgumentException("Audience cannot be null or empty.", nameof(audience));
+ }
+
+ var accessLifetime = accessTokenLifetime ?? DefaultAccessTokenLifetime;
+ var refreshLifetime = refreshTokenLifetime ?? DefaultRefreshTokenLifetime;
+
+ var accessToken = service.Issue(
+ TokenDescriptor.Create()
+ .ForSubject(subject)
+ .IssuedBy(issuer)
+ .ForAudience(audience)
+ .WithLifetime(accessLifetime));
+
+ var refreshToken = service.Issue(
+ TokenDescriptor.Create()
+ .ForSubject(subject)
+ .IssuedBy(issuer)
+ .ForAudience(audience)
+ .AsRefreshToken()
+ .WithLifetime(refreshLifetime));
+
+ return new AccessTokenRefreshTokenPair(accessToken, refreshToken);
+ }
+
+ ///
+ /// Creates an access token and refresh token pair with custom claims and roles.
+ ///
+ /// The token service
+ /// The subject (user ID, etc.)
+ /// The issuer
+ /// The audience
+ /// Optional action to configure additional claims and roles
+ /// Custom access token lifetime; defaults to 15 minutes
+ /// Custom refresh token lifetime; defaults to 30 days
+ /// A pair of access and refresh tokens
+ public static AccessTokenRefreshTokenPair CreatePair(
+ ITokenService service,
+ string subject,
+ string issuer,
+ string audience,
+ Action? configure = null,
+ TimeSpan? accessTokenLifetime = null,
+ TimeSpan? refreshTokenLifetime = null)
+ {
+ if (service is null)
+ {
+ throw new ArgumentNullException(nameof(service));
+ }
+
+ if (string.IsNullOrWhiteSpace(subject))
+ {
+ throw new ArgumentException("Subject cannot be null or empty.", nameof(subject));
+ }
+
+ if (string.IsNullOrWhiteSpace(issuer))
+ {
+ throw new ArgumentException("Issuer cannot be null or empty.", nameof(issuer));
+ }
+
+ if (string.IsNullOrWhiteSpace(audience))
+ {
+ throw new ArgumentException("Audience cannot be null or empty.", nameof(audience));
+ }
+
+ var accessLifetime = accessTokenLifetime ?? DefaultAccessTokenLifetime;
+ var refreshLifetime = refreshTokenLifetime ?? DefaultRefreshTokenLifetime;
+
+ var baseAccessDescriptor = TokenDescriptor.Create()
+ .ForSubject(subject)
+ .IssuedBy(issuer)
+ .ForAudience(audience)
+ .WithLifetime(accessLifetime);
+
+ configure?.Invoke(baseAccessDescriptor);
+
+ var accessToken = service.Issue(baseAccessDescriptor);
+
+ // Refresh token uses same claims (roles, custom claims) but longer lifetime
+ var baseRefreshDescriptor = TokenDescriptor.Create()
+ .ForSubject(subject)
+ .IssuedBy(issuer)
+ .ForAudience(audience)
+ .AsRefreshToken()
+ .WithLifetime(refreshLifetime);
+
+ configure?.Invoke(baseRefreshDescriptor);
+
+ var refreshToken = service.Issue(baseRefreshDescriptor);
+
+ return new AccessTokenRefreshTokenPair(accessToken, refreshToken);
+ }
+ }
+}
diff --git a/EonaCat.SecureToken/SecureTokenConfigurationBuilder.cs b/EonaCat.SecureToken/SecureTokenConfigurationBuilder.cs
new file mode 100644
index 0000000..5989b4b
--- /dev/null
+++ b/EonaCat.SecureToken/SecureTokenConfigurationBuilder.cs
@@ -0,0 +1,234 @@
+using System;
+using EonaCat.SecureToken.Security;
+
+namespace EonaCat.SecureToken
+{
+ // 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.
+
+ ///
+ /// Configuration for SecureToken with security and caching features.
+ ///
+ public sealed class SecureTokenConfiguration
+ {
+ ///
+ /// Gets or sets the token rate limiter for failed validation attempt throttling.
+ ///
+ public ITokenRateLimiter? RateLimiter { get; set; }
+
+ ///
+ /// Gets or sets the token blacklist for revocation management.
+ ///
+ public ITokenBlacklist? Blacklist { get; set; }
+
+ ///
+ /// Gets or sets the token cache for validated token tracking.
+ ///
+ public ISecurityTokenCache? Cache { get; set; }
+
+ ///
+ /// Gets or sets rate limit policy (used only if RateLimiter is not set).
+ ///
+ public RateLimitPolicy? RateLimitPolicy { get; set; }
+
+ ///
+ /// Gets or sets whether to enable automatic cleanup of expired entries.
+ ///
+ public bool EnableAutomaticCleanup { get; set; } = true;
+ }
+
+ ///
+ /// Fluent builder for SecureToken configuration with sensible defaults.
+ /// Simplifies setup of token service with security and caching features.
+ ///
+ public sealed class SecureTokenConfigurationBuilder
+ {
+ private ITokenRateLimiter? _rateLimiter;
+ private ITokenBlacklist? _blacklist;
+ private ISecurityTokenCache? _cache;
+ private RateLimitPolicy? _rateLimitPolicy;
+ private bool _enableAutoCleanup = true;
+
+ ///
+ /// Configures a rate limiter with default policy (5 attempts in 5 minutes, 15-minute lockout).
+ ///
+ public SecureTokenConfigurationBuilder WithRateLimiter()
+ {
+ _rateLimiter = new InMemoryTokenRateLimiter();
+ return this;
+ }
+
+ ///
+ /// Configures a rate limiter with a custom policy.
+ ///
+ /// The rate limiting policy
+ public SecureTokenConfigurationBuilder WithRateLimiter(RateLimitPolicy policy)
+ {
+ if (policy is null)
+ {
+ throw new ArgumentNullException(nameof(policy));
+ }
+
+ _rateLimiter = new InMemoryTokenRateLimiter(policy);
+ _rateLimitPolicy = policy;
+ return this;
+ }
+
+ ///
+ /// Configures a custom rate limiter implementation.
+ ///
+ /// The rate limiter implementation
+ public SecureTokenConfigurationBuilder WithRateLimiter(ITokenRateLimiter rateLimiter)
+ {
+ _rateLimiter = rateLimiter ?? throw new ArgumentNullException(nameof(rateLimiter));
+ return this;
+ }
+
+ ///
+ /// Configures a token blacklist for revocation management.
+ ///
+ public SecureTokenConfigurationBuilder WithBlacklist()
+ {
+ _blacklist = new InMemoryTokenBlacklist();
+ return this;
+ }
+
+ ///
+ /// Configures a custom blacklist implementation.
+ ///
+ /// The blacklist implementation
+ public SecureTokenConfigurationBuilder WithBlacklist(ITokenBlacklist blacklist)
+ {
+ _blacklist = blacklist ?? throw new ArgumentNullException(nameof(blacklist));
+ return this;
+ }
+
+ ///
+ /// Configures a token cache for tracking validated tokens.
+ ///
+ public SecureTokenConfigurationBuilder WithCache()
+ {
+ _cache = new InMemorySecurityTokenCache();
+ return this;
+ }
+
+ ///
+ /// Configures a custom cache implementation.
+ ///
+ /// The cache implementation
+ public SecureTokenConfigurationBuilder WithCache(ISecurityTokenCache cache)
+ {
+ _cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ return this;
+ }
+
+ ///
+ /// Disables automatic cleanup of expired entries. Cleanup will need to be done manually.
+ ///
+ public SecureTokenConfigurationBuilder DisableAutomaticCleanup()
+ {
+ _enableAutoCleanup = false;
+ return this;
+ }
+
+ ///
+ /// Configures all security features with defaults (rate limiter, blacklist, cache).
+ /// Recommended for most applications.
+ ///
+ public SecureTokenConfigurationBuilder WithAllSecurityFeatures()
+ {
+ WithRateLimiter();
+ WithBlacklist();
+ WithCache();
+ return this;
+ }
+
+ ///
+ /// Configures minimal security features (rate limiter and blacklist, no cache).
+ /// Suitable for high-performance scenarios where caching is not needed.
+ ///
+ public SecureTokenConfigurationBuilder WithMinimalSecurity()
+ {
+ WithRateLimiter();
+ WithBlacklist();
+ return this;
+ }
+
+ ///
+ /// Resets the builder to initial state.
+ ///
+ public SecureTokenConfigurationBuilder Reset()
+ {
+ _rateLimiter = null;
+ _blacklist = null;
+ _cache = null;
+ _rateLimitPolicy = null;
+ _enableAutoCleanup = true;
+ return this;
+ }
+
+ ///
+ /// Builds the configuration.
+ ///
+ public SecureTokenConfiguration Build()
+ {
+ return new SecureTokenConfiguration
+ {
+ RateLimiter = _rateLimiter,
+ Blacklist = _blacklist,
+ Cache = _cache,
+ RateLimitPolicy = _rateLimitPolicy,
+ EnableAutomaticCleanup = _enableAutoCleanup
+ };
+ }
+ }
+
+ ///
+ /// Helper for quick configuration of the most common SecureToken setup scenarios.
+ ///
+ public static class SecureTokenPresets
+ {
+ ///
+ /// Configuration for maximum security with all features enabled.
+ /// Suitable for high-security applications where performance is not a primary concern.
+ ///
+ public static SecureTokenConfiguration MaximumSecurity()
+ {
+ return new SecureTokenConfigurationBuilder()
+ .WithAllSecurityFeatures()
+ .Build();
+ }
+
+ ///
+ /// Configuration for balanced security and performance.
+ /// Recommended for most production applications.
+ ///
+ public static SecureTokenConfiguration Balanced()
+ {
+ return new SecureTokenConfigurationBuilder()
+ .WithRateLimiter()
+ .WithBlacklist()
+ .Build();
+ }
+
+ ///
+ /// Configuration for high-performance scenarios with basic security.
+ /// Only enables rate limiting; no caching or blacklist overhead.
+ ///
+ public static SecureTokenConfiguration HighPerformance()
+ {
+ return new SecureTokenConfigurationBuilder()
+ .WithRateLimiter()
+ .Build();
+ }
+
+ ///
+ /// Configuration with no security features enabled.
+ /// Only recommended for testing or internal environments.
+ ///
+ public static SecureTokenConfiguration NoSecurity()
+ {
+ return new SecureTokenConfiguration();
+ }
+ }
+}
diff --git a/EonaCat.SecureToken/Security/TokenBlacklist.cs b/EonaCat.SecureToken/Security/TokenBlacklist.cs
new file mode 100644
index 0000000..7a7c053
--- /dev/null
+++ b/EonaCat.SecureToken/Security/TokenBlacklist.cs
@@ -0,0 +1,224 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace EonaCat.SecureToken.Security
+{
+ // 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.
+
+ ///
+ /// Provides token blacklisting/revocation functionality.
+ /// Supports immediate revocation of tokens before their natural expiration.
+ ///
+ public interface ITokenBlacklist
+ {
+ ///
+ /// Adds a token to the blacklist, immediately revoking it.
+ ///
+ /// The token identifier to blacklist
+ /// Optional reason for blacklisting
+ /// When the blacklist entry expires (usually token expiration time)
+ Task BlacklistAsync(string tokenId, string? reason = null, DateTimeOffset? expiresAt = null);
+
+ ///
+ /// Checks if a token is blacklisted.
+ ///
+ /// The token identifier
+ /// True if the token is blacklisted and not yet expired; false otherwise
+ Task IsBlacklistedAsync(string tokenId);
+
+ ///
+ /// Gets the reason why a token was blacklisted, if available.
+ ///
+ /// The token identifier
+ /// The blacklist reason, or null if not blacklisted or no reason provided
+ Task GetBlacklistReasonAsync(string tokenId);
+
+ ///
+ /// Removes a token from the blacklist (e.g., after review).
+ ///
+ /// The token identifier
+ Task RemoveFromBlacklistAsync(string tokenId);
+
+ ///
+ /// Removes all expired blacklist entries. Returns count of entries removed.
+ ///
+ Task RemoveExpiredAsync();
+
+ ///
+ /// Gets the total count of currently blacklisted tokens.
+ ///
+ Task CountAsync();
+
+ ///
+ /// Clears the entire blacklist. Returns count of entries removed.
+ ///
+ Task ClearAsync();
+ }
+
+ ///
+ /// Thread-safe in-memory implementation of .
+ /// Tracks revoked tokens with optional expiration cleanup.
+ ///
+ public sealed class InMemoryTokenBlacklist : ITokenBlacklist
+ {
+ private sealed class BlacklistEntry
+ {
+ public DateTimeOffset BlacklistedAt { get; set; }
+ public DateTimeOffset? ExpiresAt { get; set; }
+ public string? Reason { get; set; }
+ }
+
+ private readonly ConcurrentDictionary _blacklist =
+ new ConcurrentDictionary();
+
+ private readonly object _cleanupLock = new object();
+ private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow;
+ private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Default expiration duration for blacklist entries if not specified.
+ /// Set to 7 days to allow time for cache updates in distributed systems.
+ ///
+ private static readonly TimeSpan DefaultBlacklistExpiration = TimeSpan.FromDays(7);
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ public InMemoryTokenBlacklist()
+ {
+ }
+
+ ///
+ public Task BlacklistAsync(string tokenId, string? reason = null, DateTimeOffset? expiresAt = null)
+ {
+ if (string.IsNullOrWhiteSpace(tokenId))
+ {
+ throw new ArgumentException("Token ID cannot be null or empty.", nameof(tokenId));
+ }
+
+ var entry = new BlacklistEntry
+ {
+ BlacklistedAt = DateTimeOffset.UtcNow,
+ ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.Add(DefaultBlacklistExpiration),
+ Reason = reason
+ };
+
+ _blacklist.AddOrUpdate(tokenId, entry, (_, _) => entry);
+ TriggerCleanupIfNeeded();
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task IsBlacklistedAsync(string tokenId)
+ {
+ if (string.IsNullOrWhiteSpace(tokenId))
+ {
+ return Task.FromResult(false);
+ }
+
+ if (_blacklist.TryGetValue(tokenId, out var entry))
+ {
+ // Check if the blacklist entry has expired
+ if (entry.ExpiresAt.HasValue && DateTimeOffset.UtcNow > entry.ExpiresAt)
+ {
+ // Remove the expired entry
+ _blacklist.TryRemove(tokenId, out _);
+ return Task.FromResult(false);
+ }
+
+ return Task.FromResult(true);
+ }
+
+ return Task.FromResult(false);
+ }
+
+ ///
+ public Task GetBlacklistReasonAsync(string tokenId)
+ {
+ if (string.IsNullOrWhiteSpace(tokenId))
+ {
+ return Task.FromResult(null);
+ }
+
+ if (_blacklist.TryGetValue(tokenId, out var entry))
+ {
+ // Check if the blacklist entry has expired
+ if (entry.ExpiresAt.HasValue && DateTimeOffset.UtcNow > entry.ExpiresAt)
+ {
+ _blacklist.TryRemove(tokenId, out _);
+ return Task.FromResult(null);
+ }
+
+ return Task.FromResult(entry.Reason);
+ }
+
+ return Task.FromResult(null);
+ }
+
+ ///
+ public Task RemoveFromBlacklistAsync(string tokenId)
+ {
+ if (!string.IsNullOrWhiteSpace(tokenId))
+ {
+ _blacklist.TryRemove(tokenId, out _);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task RemoveExpiredAsync()
+ {
+ var now = DateTimeOffset.UtcNow;
+ var expiredEntries = _blacklist
+ .Where(kvp => kvp.Value.ExpiresAt.HasValue && now > kvp.Value.ExpiresAt)
+ .Select(kvp => kvp.Key)
+ .ToList();
+
+ int removed = 0;
+ foreach (var tokenId in expiredEntries)
+ {
+ if (_blacklist.TryRemove(tokenId, out _))
+ {
+ removed++;
+ }
+ }
+
+ return Task.FromResult(removed);
+ }
+
+ ///
+ public Task CountAsync()
+ {
+ return Task.FromResult(_blacklist.Count);
+ }
+
+ ///
+ public Task ClearAsync()
+ {
+ var count = _blacklist.Count;
+ _blacklist.Clear();
+ return Task.FromResult(count);
+ }
+
+ private void TriggerCleanupIfNeeded()
+ {
+ var now = DateTimeOffset.UtcNow;
+ if (now - _lastCleanup >= _cleanupInterval)
+ {
+ lock (_cleanupLock)
+ {
+ if (now - _lastCleanup >= _cleanupInterval)
+ {
+ _lastCleanup = now;
+ _ = RemoveExpiredAsync();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/EonaCat.SecureToken/Security/TokenRateLimiter.cs b/EonaCat.SecureToken/Security/TokenRateLimiter.cs
new file mode 100644
index 0000000..6a88b60
--- /dev/null
+++ b/EonaCat.SecureToken/Security/TokenRateLimiter.cs
@@ -0,0 +1,269 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace EonaCat.SecureToken.Security
+{
+ // 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.
+
+ ///
+ /// Represents a rate limiting policy for token validation attempts.
+ ///
+ public sealed class RateLimitPolicy
+ {
+ ///
+ /// Maximum number of failed attempts allowed within the time window.
+ ///
+ public int MaxFailedAttempts { get; set; } = 5;
+
+ ///
+ /// Time window during which failed attempts are counted.
+ ///
+ public TimeSpan TimeWindow { get; set; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Duration to block the subject after exceeding the limit.
+ ///
+ public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(15);
+ }
+
+ ///
+ /// Tracks failed validation attempts and enforces rate limiting.
+ /// Prevents brute-force attacks by throttling invalid token attempts.
+ ///
+ public interface ITokenRateLimiter
+ {
+ ///
+ /// Records a failed validation attempt for a subject (e.g., user ID, IP address).
+ ///
+ /// The subject identifier (user, IP, etc.)
+ /// True if the attempt was recorded; false if subject is locked out
+ Task TryRecordFailureAsync(string subject);
+
+ ///
+ /// Checks if a subject is currently locked out due to too many failures.
+ ///
+ /// The subject identifier
+ /// True if locked out; false otherwise
+ Task IsLockedOutAsync(string subject);
+
+ ///
+ /// Resets the failure count for a subject (e.g., after successful authentication).
+ ///
+ /// The subject identifier
+ Task ResetAsync(string subject);
+
+ ///
+ /// Manually unlocks a subject regardless of lockout status.
+ ///
+ /// The subject identifier
+ Task UnlockAsync(string subject);
+
+ ///
+ /// Gets the current failure count for a subject.
+ ///
+ /// The subject identifier
+ /// The number of failures within the current time window
+ Task GetFailureCountAsync(string subject);
+
+ ///
+ /// Gets the time remaining until a locked-out subject is unlocked.
+ ///
+ /// The subject identifier
+ /// Time remaining; null if not locked out
+ Task GetLockoutTimeRemainingAsync(string subject);
+
+ ///
+ /// Clears all rate limit state. Returns count of subjects cleared.
+ ///
+ Task ClearAsync();
+ }
+
+ ///
+ /// Thread-safe in-memory implementation of .
+ /// Tracks failed validation attempts per subject and enforces configurable rate limiting.
+ ///
+ public sealed class InMemoryTokenRateLimiter : ITokenRateLimiter
+ {
+ private sealed class FailureTracker
+ {
+ public List Failures { get; } = new List();
+ public DateTimeOffset? LockedUntil { get; set; }
+ }
+
+ private readonly ConcurrentDictionary _trackers =
+ new ConcurrentDictionary();
+
+ private readonly RateLimitPolicy _policy;
+ private readonly object _cleanupLock = new object();
+ private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow;
+ private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(10);
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The rate limiting policy; uses defaults if null
+ public InMemoryTokenRateLimiter(RateLimitPolicy? policy = null)
+ {
+ _policy = policy ?? new RateLimitPolicy();
+ }
+
+ ///
+ public Task TryRecordFailureAsync(string subject)
+ {
+ if (string.IsNullOrWhiteSpace(subject))
+ {
+ return Task.FromResult(false);
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var tracker = _trackers.GetOrAdd(subject, _ => new FailureTracker());
+
+ // Check if subject is locked out
+ if (tracker.LockedUntil.HasValue && now < tracker.LockedUntil)
+ {
+ return Task.FromResult(false);
+ }
+
+ // Reset lockout if time has passed
+ if (tracker.LockedUntil.HasValue && now >= tracker.LockedUntil)
+ {
+ tracker.LockedUntil = null;
+ tracker.Failures.Clear();
+ }
+
+ // Remove failures outside the time window
+ tracker.Failures.RemoveAll(f => now - f > _policy.TimeWindow);
+
+ // Record the new failure
+ tracker.Failures.Add(now);
+
+ // Check if limit exceeded
+ if (tracker.Failures.Count > _policy.MaxFailedAttempts)
+ {
+ tracker.LockedUntil = now.Add(_policy.LockoutDuration);
+ return Task.FromResult(false);
+ }
+
+ TriggerCleanupIfNeeded();
+ return Task.FromResult(true);
+ }
+
+ ///
+ public Task IsLockedOutAsync(string subject)
+ {
+ if (string.IsNullOrWhiteSpace(subject))
+ {
+ return Task.FromResult(false);
+ }
+
+ if (_trackers.TryGetValue(subject, out var tracker))
+ {
+ if (tracker.LockedUntil.HasValue && DateTimeOffset.UtcNow < tracker.LockedUntil)
+ {
+ return Task.FromResult(true);
+ }
+
+ // Reset if lockout has expired
+ if (tracker.LockedUntil.HasValue && DateTimeOffset.UtcNow >= tracker.LockedUntil)
+ {
+ tracker.LockedUntil = null;
+ tracker.Failures.Clear();
+ }
+ }
+
+ return Task.FromResult(false);
+ }
+
+ ///
+ public Task ResetAsync(string subject)
+ {
+ if (!string.IsNullOrWhiteSpace(subject) && _trackers.TryGetValue(subject, out var tracker))
+ {
+ tracker.Failures.Clear();
+ tracker.LockedUntil = null;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task UnlockAsync(string subject)
+ {
+ if (!string.IsNullOrWhiteSpace(subject) && _trackers.TryGetValue(subject, out var tracker))
+ {
+ tracker.LockedUntil = null;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task GetFailureCountAsync(string subject)
+ {
+ if (string.IsNullOrWhiteSpace(subject) || !_trackers.TryGetValue(subject, out var tracker))
+ {
+ return Task.FromResult(0);
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var recentFailures = tracker.Failures.Count(f => now - f <= _policy.TimeWindow);
+ return Task.FromResult(recentFailures);
+ }
+
+ ///
+ public Task GetLockoutTimeRemainingAsync(string subject)
+ {
+ if (string.IsNullOrWhiteSpace(subject) || !_trackers.TryGetValue(subject, out var tracker))
+ {
+ return Task.FromResult(null);
+ }
+
+ if (!tracker.LockedUntil.HasValue || DateTimeOffset.UtcNow >= tracker.LockedUntil)
+ {
+ return Task.FromResult(null);
+ }
+
+ var remaining = tracker.LockedUntil.Value - DateTimeOffset.UtcNow;
+ return Task.FromResult(remaining > TimeSpan.Zero ? remaining : null);
+ }
+
+ ///
+ public Task ClearAsync()
+ {
+ var count = _trackers.Count;
+ _trackers.Clear();
+ return Task.FromResult(count);
+ }
+
+ private void TriggerCleanupIfNeeded()
+ {
+ var now = DateTimeOffset.UtcNow;
+ if (now - _lastCleanup >= _cleanupInterval)
+ {
+ lock (_cleanupLock)
+ {
+ if (now - _lastCleanup >= _cleanupInterval)
+ {
+ _lastCleanup = now;
+ // Remove subjects with no recent failures and unlocked status
+ var toRemove = _trackers
+ .Where(kvp =>
+ kvp.Value.Failures.Count == 0 &&
+ (!kvp.Value.LockedUntil.HasValue || now > kvp.Value.LockedUntil.Value))
+ .Select(kvp => kvp.Key)
+ .ToList();
+
+ foreach (var key in toRemove)
+ {
+ _trackers.TryRemove(key, out _);
+ }
+ }
+ }
+ }
+ }
+ }
+}