using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation { public class SessionStampService : ISessionStampService { private readonly IUserDataService _userDataService; private readonly ISessionStampCache _cache; private readonly byte[] _key; public SessionStampService( IUserDataService userDataService, ISessionStampCache cache, IOptions jwtOptions) { _userDataService = userDataService; _cache = cache; _key = HKDF.DeriveKey( HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(jwtOptions.Value.Secret), 32, info: Encoding.UTF8.GetBytes("session-stamp-v1")); } public string ClaimValueFor(string securityStamp) { ArgumentException.ThrowIfNullOrEmpty(securityStamp); return Base64UrlEncoder.Encode(HMACSHA256.HashData(_key, Encoding.UTF8.GetBytes(securityStamp))); } public async Task IsCurrentAsync(string userId, string? claimValue, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(claimValue)) return false; if (!_cache.TryGet(userId, out var expected)) { var generation = _cache.Generation; var stamp = await _userDataService.GetActiveSecurityStampAsync(userId, cancellationToken); // A missing, deleted or stampless account has no current value: nothing matches it. expected = string.IsNullOrEmpty(stamp) ? null : ClaimValueFor(stamp); _cache.Set(userId, expected, generation); } return expected != null && CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(claimValue)); } public void Forget(string userId) => _cache.Remove(userId); } /// /// Holds each expected value for , so a change saved by another /// instance is enforced here within that time; a change saved by this instance is /// enforced at once through . /// public sealed class InMemorySessionStampCache : ISessionStampCache { public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(60); private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); private readonly TimeProvider _timeProvider; private long _generation; public InMemorySessionStampCache(TimeProvider timeProvider) { _timeProvider = timeProvider; } public long Generation => Interlocked.Read(ref _generation); public bool TryGet(string userId, out string? expected) { if (_entries.TryGetValue(userId, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow()) { expected = entry.Expected; return true; } expected = null; return false; } public void Set(string userId, string? expected, long generation) { var entry = new Entry(expected, _timeProvider.GetUtcNow().Add(Lifetime)); _entries[userId] = entry; // A removal since the read may have raced it: drop the value rather than keep it. if (Generation != generation) _entries.TryRemove(new KeyValuePair(userId, entry)); } public void Remove(string userId) { Interlocked.Increment(ref _generation); _entries.TryRemove(userId, out _); } private sealed record Entry(string? Expected, DateTimeOffset ExpiresAt); } }