Files
EonaCat.SecureToken/EonaCat.SecureToken/Extensions/TokenServiceExtensions.cs
T

391 lines
16 KiB
C#

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();
}
}
}