using System.Security.Cryptography; using System.Text.Json; using Microsoft.Extensions.Logging; using StellaOps.Messaging.Abstractions; using StackExchange.Redis; namespace StellaOps.Messaging.Transport.Valkey; /// /// Valkey/Redis implementation of . /// Uses Lua scripts for atomic compare-and-delete operations. /// public sealed class ValkeyAtomicTokenStore : IAtomicTokenStore { private readonly ValkeyConnectionFactory _connectionFactory; private readonly string _name; private readonly ILogger>? _logger; private readonly JsonSerializerOptions _jsonOptions; private readonly TimeProvider _timeProvider; // Lua script for atomic consume: GET, compare, DELETE if matches private const string ConsumeScript = @" local value = redis.call('GET', KEYS[1]) if not value then return {0, nil} end local data = cjson.decode(value) if data.token ~= ARGV[1] then return {2, value} end redis.call('DEL', KEYS[1]) return {1, value} "; public ValkeyAtomicTokenStore( ValkeyConnectionFactory connectionFactory, string name, ILogger>? logger = null, JsonSerializerOptions? jsonOptions = null, TimeProvider? timeProvider = null) { _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); _name = name ?? throw new ArgumentNullException(nameof(name)); _logger = logger; _jsonOptions = jsonOptions ?? new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; _timeProvider = timeProvider ?? TimeProvider.System; } /// public string ProviderName => "valkey"; /// public async ValueTask IssueAsync( string key, TPayload payload, TimeSpan ttl, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(key); var redisKey = BuildKey(key); var now = _timeProvider.GetUtcNow(); var expiresAt = now.Add(ttl); // Generate secure random token var tokenBytes = new byte[32]; RandomNumberGenerator.Fill(tokenBytes); var token = Convert.ToBase64String(tokenBytes); var entry = new TokenData { Token = token, Payload = payload, IssuedAt = now, ExpiresAt = expiresAt }; var serialized = JsonSerializer.Serialize(entry, _jsonOptions); var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false); await db.StringSetAsync(redisKey, serialized, ttl).ConfigureAwait(false); return TokenIssueResult.Succeeded(token, expiresAt); } /// public async ValueTask StoreAsync( string key, string token, TPayload payload, TimeSpan ttl, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(token); var redisKey = BuildKey(key); var now = _timeProvider.GetUtcNow(); var expiresAt = now.Add(ttl); var entry = new TokenData { Token = token, Payload = payload, IssuedAt = now, ExpiresAt = expiresAt }; var serialized = JsonSerializer.Serialize(entry, _jsonOptions); var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false); await db.StringSetAsync(redisKey, serialized, ttl).ConfigureAwait(false); return TokenIssueResult.Succeeded(token, expiresAt); } /// public async ValueTask> TryConsumeAsync( string key, string expectedToken, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(expectedToken); var redisKey = BuildKey(key); var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false); var now = _timeProvider.GetUtcNow(); try { var result = await db.ScriptEvaluateAsync( ConsumeScript, new RedisKey[] { redisKey }, new RedisValue[] { expectedToken }).ConfigureAwait(false); var results = (RedisResult[])result!; var status = (int)results[0]; switch (status) { case 0: // Not found return TokenConsumeResult.NotFound(); case 1: // Success var data = JsonSerializer.Deserialize>((string)results[1]!, _jsonOptions); if (data is null) { return TokenConsumeResult.NotFound(); } if (data.ExpiresAt < now) { return TokenConsumeResult.Expired(data.IssuedAt, data.ExpiresAt); } return TokenConsumeResult.Success(data.Payload!, data.IssuedAt, data.ExpiresAt); case 2: // Mismatch return TokenConsumeResult.Mismatch(); default: return TokenConsumeResult.NotFound(); } } catch (RedisServerException ex) when (ex.Message.Contains("NOSCRIPT")) { // Fallback: non-atomic approach (less safe but works without Lua) return await TryConsumeNonAtomicAsync(db, redisKey, expectedToken, now).ConfigureAwait(false); } } private async ValueTask> TryConsumeNonAtomicAsync( IDatabase db, string redisKey, string expectedToken, DateTimeOffset now) { var value = await db.StringGetAsync(redisKey).ConfigureAwait(false); if (value.IsNullOrEmpty) { return TokenConsumeResult.NotFound(); } var data = JsonSerializer.Deserialize>((string)value!, _jsonOptions); if (data is null) { return TokenConsumeResult.NotFound(); } if (data.ExpiresAt < now) { await db.KeyDeleteAsync(redisKey).ConfigureAwait(false); return TokenConsumeResult.Expired(data.IssuedAt, data.ExpiresAt); } if (!string.Equals(data.Token, expectedToken, StringComparison.Ordinal)) { return TokenConsumeResult.Mismatch(); } // Try to delete - if someone else deleted it first, we lost the race if (await db.KeyDeleteAsync(redisKey).ConfigureAwait(false)) { return TokenConsumeResult.Success(data.Payload!, data.IssuedAt, data.ExpiresAt); } return TokenConsumeResult.NotFound(); } /// public async ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(key); var redisKey = BuildKey(key); var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false); return await db.KeyExistsAsync(redisKey).ConfigureAwait(false); } /// public async ValueTask RevokeAsync(string key, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(key); var redisKey = BuildKey(key); var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false); return await db.KeyDeleteAsync(redisKey).ConfigureAwait(false); } private string BuildKey(string key) => $"token:{_name}:{key}"; private sealed class TokenData { public required string Token { get; init; } public T? Payload { get; init; } public DateTimeOffset IssuedAt { get; init; } public DateTimeOffset ExpiresAt { get; init; } } } /// /// Factory for creating Valkey atomic token store instances. /// public sealed class ValkeyAtomicTokenStoreFactory : IAtomicTokenStoreFactory { private readonly ValkeyConnectionFactory _connectionFactory; private readonly ILoggerFactory? _loggerFactory; private readonly JsonSerializerOptions? _jsonOptions; private readonly TimeProvider _timeProvider; public ValkeyAtomicTokenStoreFactory( ValkeyConnectionFactory connectionFactory, ILoggerFactory? loggerFactory = null, JsonSerializerOptions? jsonOptions = null, TimeProvider? timeProvider = null) { _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); _loggerFactory = loggerFactory; _jsonOptions = jsonOptions; _timeProvider = timeProvider ?? TimeProvider.System; } /// public string ProviderName => "valkey"; /// public IAtomicTokenStore Create(string name) { ArgumentNullException.ThrowIfNull(name); return new ValkeyAtomicTokenStore( _connectionFactory, name, _loggerFactory?.CreateLogger>(), _jsonOptions, _timeProvider); } }