Added extra token security and token rateLimiting

This commit is contained in:
2026-06-22 07:39:20 +02:00
committed by Jeroen Saey
parent 52b9be6495
commit 6482f0c94c
9 changed files with 1791 additions and 3 deletions
@@ -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.
/// <summary>
/// Detailed information about a token, extracted without signature verification.
/// Use only for diagnostic and audit purposes, never for authorization.
/// </summary>
public sealed class TokenIntrospectionResult
{
/// <summary>
/// Whether the token format is valid (can be parsed).
/// </summary>
public bool IsValidFormat { get; set; }
/// <summary>
/// The token claims if the format is valid; otherwise null.
/// </summary>
public TokenClaims? Claims { get; set; }
/// <summary>
/// Error message if parsing failed.
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Additional diagnostic information.
/// </summary>
public Dictionary<string, string> Diagnostics { get; set; } = new Dictionary<string, string>();
/// <summary>
/// Creates a successful introspection result.
/// </summary>
public static TokenIntrospectionResult Success(TokenClaims claims)
{
return new TokenIntrospectionResult
{
IsValidFormat = true,
Claims = claims,
ErrorMessage = null
};
}
/// <summary>
/// Creates a failure introspection result.
/// </summary>
public static TokenIntrospectionResult Failure(string errorMessage)
{
return new TokenIntrospectionResult
{
IsValidFormat = false,
Claims = null,
ErrorMessage = errorMessage
};
}
}
/// <summary>
/// Provides detailed token introspection for diagnostic and audit purposes.
/// This is useful for debugging token issues and understanding token structure.
/// </summary>
public interface ITokenIntrospector
{
/// <summary>
/// Analyzes a token without verifying the signature.
/// Returns detailed information about the token structure and claims.
/// Use only for diagnostics - never for authorization decisions.
/// </summary>
/// <param name="token">The token to introspect</param>
/// <returns>Introspection result with claims or error information</returns>
TokenIntrospectionResult Introspect(string token);
/// <summary>
/// Analyzes multiple tokens and returns their information.
/// </summary>
/// <param name="tokens">Tokens to introspect</param>
/// <returns>Introspection results for each token</returns>
IReadOnlyList<TokenIntrospectionResult> IntrospectMultiple(params string[] tokens);
/// <summary>
/// Extracts and analyzes the raw token parts (header, payload, signature) for debugging.
/// </summary>
/// <param name="token">The token to analyze</param>
/// <param name="encodedPayload">The Base64-encoded payload if parsing succeeds</param>
/// <param name="encodedSignature">The Base64-encoded signature if parsing succeeds</param>
/// <returns>True if the token format is valid; false otherwise</returns>
bool TryGetRawParts(string token, out string? encodedPayload, out string? encodedSignature);
/// <summary>
/// Gets a human-readable summary of token claims for debugging.
/// </summary>
/// <param name="token">The token to summarize</param>
/// <returns>Formatted summary string</returns>
string GetClaimsSummary(string token);
}
/// <summary>
/// Default implementation of token introspection for diagnostics.
/// </summary>
public sealed class TokenIntrospector : ITokenIntrospector
{
private readonly ITokenService _tokenService;
/// <summary>
/// Initializes a new instance of <see cref="TokenIntrospector"/>.
/// </summary>
/// <param name="tokenService">The token service to use for introspection</param>
public TokenIntrospector(ITokenService tokenService)
{
_tokenService = tokenService ?? throw new ArgumentNullException(nameof(tokenService));
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public IReadOnlyList<TokenIntrospectionResult> IntrospectMultiple(params string[] tokens)
{
if (tokens is null)
{
throw new ArgumentNullException(nameof(tokens));
}
var results = new List<TokenIntrospectionResult>(tokens.Length);
foreach (var token in tokens)
{
results.Add(Introspect(token));
}
return results.AsReadOnly();
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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();
}
}
}
@@ -14,7 +14,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
<Copyright>EonaCat (Jeroen Saey)</Copyright> <Copyright>EonaCat (Jeroen Saey)</Copyright>
<PackageTags>EonaCat;authentication;api;micro;services;microservices;rotation;secure;token;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 /> <PackageIconUrl />
<FileVersion>0.0.3</FileVersion> <FileVersion>0.0.4</FileVersion>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>True</GenerateDocumentationFile> <GenerateDocumentationFile>True</GenerateDocumentationFile>
<PackageLicenseFile>LICENSE</PackageLicenseFile> <PackageLicenseFile>LICENSE</PackageLicenseFile>
@@ -25,7 +25,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<EVRevisionFormat>0.0.3+{chash:10}.{c:ymd}</EVRevisionFormat> <EVRevisionFormat>0.0.4+{chash:10}.{c:ymd}</EVRevisionFormat>
<EVDefault>true</EVDefault> <EVDefault>true</EVDefault>
<EVInfo>true</EVInfo> <EVInfo>true</EVInfo>
<EVTagMatch>v[0-9]*</EVTagMatch> <EVTagMatch>v[0-9]*</EVTagMatch>
@@ -36,7 +36,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<Version>0.0.3</Version> <Version>0.0.4</Version>
<PackageId>EonaCat.SecureToken</PackageId> <PackageId>EonaCat.SecureToken</PackageId>
<Product>EonaCat.SecureToken</Product> <Product>EonaCat.SecureToken</Product>
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.SecureToken</RepositoryUrl> <RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.SecureToken</RepositoryUrl>
@@ -1,6 +1,8 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using EonaCat.SecureToken.Cryptography; using EonaCat.SecureToken.Cryptography;
using EonaCat.SecureToken.Validation; using EonaCat.SecureToken.Validation;
using EonaCat.SecureToken.Core;
using EonaCat.SecureToken.Security;
using System; using System;
namespace EonaCat.SecureToken.Extensions namespace EonaCat.SecureToken.Extensions
@@ -10,6 +12,7 @@ namespace EonaCat.SecureToken.Extensions
/// <summary> /// <summary>
/// Extension methods for registering SecureToken services with the DI container. /// Extension methods for registering SecureToken services with the DI container.
/// Includes support for token service, introspection, rate limiting, blacklisting, and caching.
/// </summary> /// </summary>
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
@@ -65,5 +68,75 @@ namespace EonaCat.SecureToken.Extensions
services.AddSingleton<IReplayCache, InMemoryReplayCache>(); services.AddSingleton<IReplayCache, InMemoryReplayCache>();
return services; return services;
} }
/// <summary>
/// Adds token introspection service for diagnostic and audit purposes.
/// Allows detailed examination of token claims without signature verification.
/// </summary>
public static IServiceCollection AddSecureTokenIntrospection(this IServiceCollection services)
{
services.AddSingleton<ITokenIntrospector>(sp =>
new TokenIntrospector(sp.GetRequiredService<ITokenService>()));
return services;
}
/// <summary>
/// Adds token rate limiting for failed validation attempt throttling.
/// Protects against brute-force attacks on token validation.
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="policy">Optional rate limiting policy; uses defaults if null</param>
public static IServiceCollection AddSecureTokenRateLimiting(
this IServiceCollection services,
RateLimitPolicy? policy = null)
{
services.AddSingleton<ITokenRateLimiter>(
_ => new InMemoryTokenRateLimiter(policy));
return services;
}
/// <summary>
/// Adds token blacklist for revocation management.
/// Supports immediate revocation of tokens before their natural expiration.
/// </summary>
public static IServiceCollection AddSecureTokenBlacklist(this IServiceCollection services)
{
services.AddSingleton<ITokenBlacklist, InMemoryTokenBlacklist>();
return services;
}
/// <summary>
/// Adds token caching for reducing validation computational overhead.
/// Validates tokens once and caches the result for frequent lookups.
/// </summary>
public static IServiceCollection AddSecureTokenCache(this IServiceCollection services)
{
services.AddSingleton<ISecurityTokenCache, InMemorySecurityTokenCache>();
return services;
}
/// <summary>
/// Adds all security features (introspection, rate limiting, blacklist, and cache).
/// Recommended for most production applications.
/// </summary>
public static IServiceCollection AddSecureTokenWithAllFeatures(this IServiceCollection services)
{
return services
.AddSecureTokenIntrospection()
.AddSecureTokenRateLimiting()
.AddSecureTokenBlacklist()
.AddSecureTokenCache();
}
/// <summary>
/// Adds a subset of security features (rate limiting and blacklist only).
/// Suitable for high-performance scenarios where caching is not needed.
/// </summary>
public static IServiceCollection AddSecureTokenWithMinimalSecurity(this IServiceCollection services)
{
return services
.AddSecureTokenRateLimiting()
.AddSecureTokenBlacklist();
}
} }
} }
@@ -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.
/// <summary>
/// Extension methods for advanced token operations including batch processing,
/// security checks, and validation with additional features.
/// </summary>
public static class TokenServiceExtensions
{
/// <summary>
/// Validates multiple tokens concurrently.
/// </summary>
/// <param name="service">The token service</param>
/// <param name="tokens">Tokens to validate</param>
/// <param name="options">Validation options</param>
/// <param name="ct">Cancellation token</param>
/// <returns>Collection of validation results in the same order as input tokens</returns>
public static async Task<IReadOnlyList<TokenResult>> ValidateManyAsync(
this ITokenService service,
IEnumerable<string> 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<TokenResult>(tokenList.Count);
foreach (var token in tokenList)
{
results.Add(await service.ValidateAsync(token, options, ct).ConfigureAwait(false));
}
return results.AsReadOnly();
}
/// <summary>
/// Validates a token and checks the blacklist.
/// </summary>
/// <param name="service">The token service</param>
/// <param name="token">Token to validate</param>
/// <param name="options">Validation options</param>
/// <param name="blacklist">The blacklist to check</param>
/// <param name="ct">Cancellation token</param>
/// <returns>Validation result (or Revoked if token is blacklisted)</returns>
public static async Task<TokenResult> 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;
}
/// <summary>
/// Validates a token with rate limiting on the subject.
/// </summary>
/// <param name="service">The token service</param>
/// <param name="token">Token to validate</param>
/// <param name="options">Validation options</param>
/// <param name="rateLimiter">The rate limiter</param>
/// <param name="subject">Subject to rate limit (e.g., user ID, IP address)</param>
/// <param name="ct">Cancellation token</param>
/// <returns>Validation result (or failure if rate limited)</returns>
public static async Task<TokenResult> 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;
}
/// <summary>
/// Validates a token and caches the result for subsequent lookups.
/// Reduces computational overhead for frequently-validated tokens.
/// </summary>
/// <param name="service">The token service</param>
/// <param name="token">Token to validate</param>
/// <param name="options">Validation options</param>
/// <param name="cache">The cache to store validated tokens</param>
/// <param name="ct">Cancellation token</param>
/// <returns>Validation result</returns>
public static async Task<TokenResult> 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;
}
/// <summary>
/// Validates a token with all available security checks (rate limiting, blacklist, caching).
/// </summary>
/// <param name="service">The token service</param>
/// <param name="token">Token to validate</param>
/// <param name="options">Validation options</param>
/// <param name="configuration">Security configuration with rate limiter, blacklist, and/or cache</param>
/// <param name="rateLimitSubject">Optional subject for rate limiting (IP, user ID, etc.)</param>
/// <param name="ct">Cancellation token</param>
/// <returns>Validation result with all security checks applied</returns>
public static async Task<TokenResult> 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;
}
/// <summary>
/// Gets a summary of all failed validations for a subject during the current rate limit window.
/// Useful for security auditing and debugging.
/// </summary>
/// <param name="rateLimiter">The rate limiter</param>
/// <param name="subject">The subject to check</param>
/// <returns>Summary information including failure count and lockout status</returns>
public static async Task<string> 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).";
}
/// <summary>
/// Introspects and compares multiple tokens for diagnostic purposes.
/// </summary>
/// <param name="introspector">The token introspector</param>
/// <param name="tokens">Tokens to compare</param>
/// <returns>Formatted comparison of token claims</returns>
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();
}
}
}
@@ -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.
/// <summary>
/// Provides in-memory caching and lifecycle management for validated tokens.
/// This interface allows for extensible cache implementations (in-memory, distributed, database, etc.).
/// </summary>
public interface ISecurityTokenCache
{
/// <summary>
/// Records a validated token in the cache.
/// </summary>
/// <param name="tokenId">The token identifier</param>
/// <param name="expiresAt">When the token expires</param>
Task SetAsync(string tokenId, DateTimeOffset expiresAt);
/// <summary>
/// Retrieves a token from the cache if it exists and has not expired.
/// </summary>
/// <param name="tokenId">The token identifier</param>
/// <returns>True if the token is in cache and valid; otherwise false</returns>
Task<bool> TryGetAsync(string tokenId);
/// <summary>
/// Removes a token from the cache (e.g., after revocation).
/// </summary>
/// <param name="tokenId">The token identifier</param>
Task RemoveAsync(string tokenId);
/// <summary>
/// Clears all tokens from the cache. Returns the count of items removed.
/// </summary>
Task<int> ClearAsync();
/// <summary>
/// Gets the current count of cached tokens.
/// </summary>
Task<int> CountAsync();
/// <summary>
/// Removes all expired tokens from the cache. Returns the count of items removed.
/// </summary>
Task<int> RemoveExpiredAsync();
}
/// <summary>
/// Thread-safe in-memory implementation of <see cref="ISecurityTokenCache"/>.
/// Automatically removes expired tokens during operations and periodically.
/// Suitable for single-server deployments; for distributed systems, use a distributed cache.
/// </summary>
public sealed class InMemorySecurityTokenCache : ISecurityTokenCache
{
private readonly ConcurrentDictionary<string, DateTimeOffset> _cache =
new ConcurrentDictionary<string, DateTimeOffset>();
private readonly object _cleanupLock = new object();
private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(5);
/// <summary>
/// Initializes a new instance of <see cref="InMemorySecurityTokenCache"/>.
/// </summary>
public InMemorySecurityTokenCache()
{
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public Task<bool> 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);
}
/// <inheritdoc />
public Task RemoveAsync(string tokenId)
{
if (!string.IsNullOrWhiteSpace(tokenId))
{
_cache.TryRemove(tokenId, out _);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> ClearAsync()
{
var count = _cache.Count;
_cache.Clear();
return Task.FromResult(count);
}
/// <inheritdoc />
public Task<int> CountAsync()
{
return Task.FromResult(_cache.Count);
}
/// <inheritdoc />
public Task<int> 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();
}
}
}
}
}
}
@@ -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.
/// <summary>
/// Represents an access token and refresh token pair.
/// </summary>
public sealed class AccessTokenRefreshTokenPair
{
/// <summary>
/// The access token (short-lived, typically for API requests).
/// </summary>
public string AccessToken { get; set; }
/// <summary>
/// The refresh token (long-lived, used to obtain new access tokens).
/// </summary>
public string RefreshToken { get; set; }
/// <summary>
/// Creates a new token pair.
/// </summary>
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;
}
}
/// <summary>
/// Helper for issuing paired access and refresh tokens with optimal defaults.
/// </summary>
public static class TokenPairDescriptor
{
/// <summary>
/// Default lifetime for access tokens: 15 minutes.
/// </summary>
public static readonly TimeSpan DefaultAccessTokenLifetime = TimeSpan.FromMinutes(15);
/// <summary>
/// Default lifetime for refresh tokens: 30 days.
/// </summary>
public static readonly TimeSpan DefaultRefreshTokenLifetime = TimeSpan.FromDays(30);
/// <summary>
/// Creates an access token and refresh token pair with secure defaults.
/// </summary>
/// <param name="service">The token service</param>
/// <param name="subject">The subject (user ID, etc.)</param>
/// <param name="issuer">The issuer</param>
/// <param name="audience">The audience</param>
/// <param name="accessTokenLifetime">Custom access token lifetime; defaults to 15 minutes</param>
/// <param name="refreshTokenLifetime">Custom refresh token lifetime; defaults to 30 days</param>
/// <returns>A pair of access and refresh tokens</returns>
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);
}
/// <summary>
/// Creates an access token and refresh token pair with custom claims and roles.
/// </summary>
/// <param name="service">The token service</param>
/// <param name="subject">The subject (user ID, etc.)</param>
/// <param name="issuer">The issuer</param>
/// <param name="audience">The audience</param>
/// <param name="configure">Optional action to configure additional claims and roles</param>
/// <param name="accessTokenLifetime">Custom access token lifetime; defaults to 15 minutes</param>
/// <param name="refreshTokenLifetime">Custom refresh token lifetime; defaults to 30 days</param>
/// <returns>A pair of access and refresh tokens</returns>
public static AccessTokenRefreshTokenPair CreatePair(
ITokenService service,
string subject,
string issuer,
string audience,
Action<TokenDescriptor>? 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);
}
}
}
@@ -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.
/// <summary>
/// Configuration for SecureToken with security and caching features.
/// </summary>
public sealed class SecureTokenConfiguration
{
/// <summary>
/// Gets or sets the token rate limiter for failed validation attempt throttling.
/// </summary>
public ITokenRateLimiter? RateLimiter { get; set; }
/// <summary>
/// Gets or sets the token blacklist for revocation management.
/// </summary>
public ITokenBlacklist? Blacklist { get; set; }
/// <summary>
/// Gets or sets the token cache for validated token tracking.
/// </summary>
public ISecurityTokenCache? Cache { get; set; }
/// <summary>
/// Gets or sets rate limit policy (used only if RateLimiter is not set).
/// </summary>
public RateLimitPolicy? RateLimitPolicy { get; set; }
/// <summary>
/// Gets or sets whether to enable automatic cleanup of expired entries.
/// </summary>
public bool EnableAutomaticCleanup { get; set; } = true;
}
/// <summary>
/// Fluent builder for SecureToken configuration with sensible defaults.
/// Simplifies setup of token service with security and caching features.
/// </summary>
public sealed class SecureTokenConfigurationBuilder
{
private ITokenRateLimiter? _rateLimiter;
private ITokenBlacklist? _blacklist;
private ISecurityTokenCache? _cache;
private RateLimitPolicy? _rateLimitPolicy;
private bool _enableAutoCleanup = true;
/// <summary>
/// Configures a rate limiter with default policy (5 attempts in 5 minutes, 15-minute lockout).
/// </summary>
public SecureTokenConfigurationBuilder WithRateLimiter()
{
_rateLimiter = new InMemoryTokenRateLimiter();
return this;
}
/// <summary>
/// Configures a rate limiter with a custom policy.
/// </summary>
/// <param name="policy">The rate limiting policy</param>
public SecureTokenConfigurationBuilder WithRateLimiter(RateLimitPolicy policy)
{
if (policy is null)
{
throw new ArgumentNullException(nameof(policy));
}
_rateLimiter = new InMemoryTokenRateLimiter(policy);
_rateLimitPolicy = policy;
return this;
}
/// <summary>
/// Configures a custom rate limiter implementation.
/// </summary>
/// <param name="rateLimiter">The rate limiter implementation</param>
public SecureTokenConfigurationBuilder WithRateLimiter(ITokenRateLimiter rateLimiter)
{
_rateLimiter = rateLimiter ?? throw new ArgumentNullException(nameof(rateLimiter));
return this;
}
/// <summary>
/// Configures a token blacklist for revocation management.
/// </summary>
public SecureTokenConfigurationBuilder WithBlacklist()
{
_blacklist = new InMemoryTokenBlacklist();
return this;
}
/// <summary>
/// Configures a custom blacklist implementation.
/// </summary>
/// <param name="blacklist">The blacklist implementation</param>
public SecureTokenConfigurationBuilder WithBlacklist(ITokenBlacklist blacklist)
{
_blacklist = blacklist ?? throw new ArgumentNullException(nameof(blacklist));
return this;
}
/// <summary>
/// Configures a token cache for tracking validated tokens.
/// </summary>
public SecureTokenConfigurationBuilder WithCache()
{
_cache = new InMemorySecurityTokenCache();
return this;
}
/// <summary>
/// Configures a custom cache implementation.
/// </summary>
/// <param name="cache">The cache implementation</param>
public SecureTokenConfigurationBuilder WithCache(ISecurityTokenCache cache)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
return this;
}
/// <summary>
/// Disables automatic cleanup of expired entries. Cleanup will need to be done manually.
/// </summary>
public SecureTokenConfigurationBuilder DisableAutomaticCleanup()
{
_enableAutoCleanup = false;
return this;
}
/// <summary>
/// Configures all security features with defaults (rate limiter, blacklist, cache).
/// Recommended for most applications.
/// </summary>
public SecureTokenConfigurationBuilder WithAllSecurityFeatures()
{
WithRateLimiter();
WithBlacklist();
WithCache();
return this;
}
/// <summary>
/// Configures minimal security features (rate limiter and blacklist, no cache).
/// Suitable for high-performance scenarios where caching is not needed.
/// </summary>
public SecureTokenConfigurationBuilder WithMinimalSecurity()
{
WithRateLimiter();
WithBlacklist();
return this;
}
/// <summary>
/// Resets the builder to initial state.
/// </summary>
public SecureTokenConfigurationBuilder Reset()
{
_rateLimiter = null;
_blacklist = null;
_cache = null;
_rateLimitPolicy = null;
_enableAutoCleanup = true;
return this;
}
/// <summary>
/// Builds the configuration.
/// </summary>
public SecureTokenConfiguration Build()
{
return new SecureTokenConfiguration
{
RateLimiter = _rateLimiter,
Blacklist = _blacklist,
Cache = _cache,
RateLimitPolicy = _rateLimitPolicy,
EnableAutomaticCleanup = _enableAutoCleanup
};
}
}
/// <summary>
/// Helper for quick configuration of the most common SecureToken setup scenarios.
/// </summary>
public static class SecureTokenPresets
{
/// <summary>
/// Configuration for maximum security with all features enabled.
/// Suitable for high-security applications where performance is not a primary concern.
/// </summary>
public static SecureTokenConfiguration MaximumSecurity()
{
return new SecureTokenConfigurationBuilder()
.WithAllSecurityFeatures()
.Build();
}
/// <summary>
/// Configuration for balanced security and performance.
/// Recommended for most production applications.
/// </summary>
public static SecureTokenConfiguration Balanced()
{
return new SecureTokenConfigurationBuilder()
.WithRateLimiter()
.WithBlacklist()
.Build();
}
/// <summary>
/// Configuration for high-performance scenarios with basic security.
/// Only enables rate limiting; no caching or blacklist overhead.
/// </summary>
public static SecureTokenConfiguration HighPerformance()
{
return new SecureTokenConfigurationBuilder()
.WithRateLimiter()
.Build();
}
/// <summary>
/// Configuration with no security features enabled.
/// Only recommended for testing or internal environments.
/// </summary>
public static SecureTokenConfiguration NoSecurity()
{
return new SecureTokenConfiguration();
}
}
}
@@ -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.
/// <summary>
/// Provides token blacklisting/revocation functionality.
/// Supports immediate revocation of tokens before their natural expiration.
/// </summary>
public interface ITokenBlacklist
{
/// <summary>
/// Adds a token to the blacklist, immediately revoking it.
/// </summary>
/// <param name="tokenId">The token identifier to blacklist</param>
/// <param name="reason">Optional reason for blacklisting</param>
/// <param name="expiresAt">When the blacklist entry expires (usually token expiration time)</param>
Task BlacklistAsync(string tokenId, string? reason = null, DateTimeOffset? expiresAt = null);
/// <summary>
/// Checks if a token is blacklisted.
/// </summary>
/// <param name="tokenId">The token identifier</param>
/// <returns>True if the token is blacklisted and not yet expired; false otherwise</returns>
Task<bool> IsBlacklistedAsync(string tokenId);
/// <summary>
/// Gets the reason why a token was blacklisted, if available.
/// </summary>
/// <param name="tokenId">The token identifier</param>
/// <returns>The blacklist reason, or null if not blacklisted or no reason provided</returns>
Task<string?> GetBlacklistReasonAsync(string tokenId);
/// <summary>
/// Removes a token from the blacklist (e.g., after review).
/// </summary>
/// <param name="tokenId">The token identifier</param>
Task RemoveFromBlacklistAsync(string tokenId);
/// <summary>
/// Removes all expired blacklist entries. Returns count of entries removed.
/// </summary>
Task<int> RemoveExpiredAsync();
/// <summary>
/// Gets the total count of currently blacklisted tokens.
/// </summary>
Task<int> CountAsync();
/// <summary>
/// Clears the entire blacklist. Returns count of entries removed.
/// </summary>
Task<int> ClearAsync();
}
/// <summary>
/// Thread-safe in-memory implementation of <see cref="ITokenBlacklist"/>.
/// Tracks revoked tokens with optional expiration cleanup.
/// </summary>
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<string, BlacklistEntry> _blacklist =
new ConcurrentDictionary<string, BlacklistEntry>();
private readonly object _cleanupLock = new object();
private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(5);
/// <summary>
/// Default expiration duration for blacklist entries if not specified.
/// Set to 7 days to allow time for cache updates in distributed systems.
/// </summary>
private static readonly TimeSpan DefaultBlacklistExpiration = TimeSpan.FromDays(7);
/// <summary>
/// Initializes a new instance of <see cref="InMemoryTokenBlacklist"/>.
/// </summary>
public InMemoryTokenBlacklist()
{
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public Task<bool> 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);
}
/// <inheritdoc />
public Task<string?> GetBlacklistReasonAsync(string tokenId)
{
if (string.IsNullOrWhiteSpace(tokenId))
{
return Task.FromResult<string?>(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<string?>(null);
}
return Task.FromResult(entry.Reason);
}
return Task.FromResult<string?>(null);
}
/// <inheritdoc />
public Task RemoveFromBlacklistAsync(string tokenId)
{
if (!string.IsNullOrWhiteSpace(tokenId))
{
_blacklist.TryRemove(tokenId, out _);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> 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);
}
/// <inheritdoc />
public Task<int> CountAsync()
{
return Task.FromResult(_blacklist.Count);
}
/// <inheritdoc />
public Task<int> 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();
}
}
}
}
}
}
@@ -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.
/// <summary>
/// Represents a rate limiting policy for token validation attempts.
/// </summary>
public sealed class RateLimitPolicy
{
/// <summary>
/// Maximum number of failed attempts allowed within the time window.
/// </summary>
public int MaxFailedAttempts { get; set; } = 5;
/// <summary>
/// Time window during which failed attempts are counted.
/// </summary>
public TimeSpan TimeWindow { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Duration to block the subject after exceeding the limit.
/// </summary>
public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(15);
}
/// <summary>
/// Tracks failed validation attempts and enforces rate limiting.
/// Prevents brute-force attacks by throttling invalid token attempts.
/// </summary>
public interface ITokenRateLimiter
{
/// <summary>
/// Records a failed validation attempt for a subject (e.g., user ID, IP address).
/// </summary>
/// <param name="subject">The subject identifier (user, IP, etc.)</param>
/// <returns>True if the attempt was recorded; false if subject is locked out</returns>
Task<bool> TryRecordFailureAsync(string subject);
/// <summary>
/// Checks if a subject is currently locked out due to too many failures.
/// </summary>
/// <param name="subject">The subject identifier</param>
/// <returns>True if locked out; false otherwise</returns>
Task<bool> IsLockedOutAsync(string subject);
/// <summary>
/// Resets the failure count for a subject (e.g., after successful authentication).
/// </summary>
/// <param name="subject">The subject identifier</param>
Task ResetAsync(string subject);
/// <summary>
/// Manually unlocks a subject regardless of lockout status.
/// </summary>
/// <param name="subject">The subject identifier</param>
Task UnlockAsync(string subject);
/// <summary>
/// Gets the current failure count for a subject.
/// </summary>
/// <param name="subject">The subject identifier</param>
/// <returns>The number of failures within the current time window</returns>
Task<int> GetFailureCountAsync(string subject);
/// <summary>
/// Gets the time remaining until a locked-out subject is unlocked.
/// </summary>
/// <param name="subject">The subject identifier</param>
/// <returns>Time remaining; null if not locked out</returns>
Task<TimeSpan?> GetLockoutTimeRemainingAsync(string subject);
/// <summary>
/// Clears all rate limit state. Returns count of subjects cleared.
/// </summary>
Task<int> ClearAsync();
}
/// <summary>
/// Thread-safe in-memory implementation of <see cref="ITokenRateLimiter"/>.
/// Tracks failed validation attempts per subject and enforces configurable rate limiting.
/// </summary>
public sealed class InMemoryTokenRateLimiter : ITokenRateLimiter
{
private sealed class FailureTracker
{
public List<DateTimeOffset> Failures { get; } = new List<DateTimeOffset>();
public DateTimeOffset? LockedUntil { get; set; }
}
private readonly ConcurrentDictionary<string, FailureTracker> _trackers =
new ConcurrentDictionary<string, FailureTracker>();
private readonly RateLimitPolicy _policy;
private readonly object _cleanupLock = new object();
private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(10);
/// <summary>
/// Initializes a new instance of <see cref="InMemoryTokenRateLimiter"/>.
/// </summary>
/// <param name="policy">The rate limiting policy; uses defaults if null</param>
public InMemoryTokenRateLimiter(RateLimitPolicy? policy = null)
{
_policy = policy ?? new RateLimitPolicy();
}
/// <inheritdoc />
public Task<bool> 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);
}
/// <inheritdoc />
public Task<bool> 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);
}
/// <inheritdoc />
public Task ResetAsync(string subject)
{
if (!string.IsNullOrWhiteSpace(subject) && _trackers.TryGetValue(subject, out var tracker))
{
tracker.Failures.Clear();
tracker.LockedUntil = null;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UnlockAsync(string subject)
{
if (!string.IsNullOrWhiteSpace(subject) && _trackers.TryGetValue(subject, out var tracker))
{
tracker.LockedUntil = null;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> 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);
}
/// <inheritdoc />
public Task<TimeSpan?> GetLockoutTimeRemainingAsync(string subject)
{
if (string.IsNullOrWhiteSpace(subject) || !_trackers.TryGetValue(subject, out var tracker))
{
return Task.FromResult<TimeSpan?>(null);
}
if (!tracker.LockedUntil.HasValue || DateTimeOffset.UtcNow >= tracker.LockedUntil)
{
return Task.FromResult<TimeSpan?>(null);
}
var remaining = tracker.LockedUntil.Value - DateTimeOffset.UtcNow;
return Task.FromResult<TimeSpan?>(remaining > TimeSpan.Zero ? remaining : null);
}
/// <inheritdoc />
public Task<int> 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 _);
}
}
}
}
}
}
}