// .NET 8+; server-side only. Register ONE instance per credential/audience // as a singleton, so the cache survives individual API requests. using System; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; public sealed class VinqueryTokenCache { private readonly IHttpClientFactory _clients; private readonly string _clientId, _clientSecret, _audience; private readonly TimeProvider _clock; private readonly SemaphoreSlim _refreshLock = new(1, 1); private CachedToken? _cached; public VinqueryTokenCache(IHttpClientFactory clients, string clientId, string clientSecret, string audience, TimeProvider? clock = null) { if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret) || string.IsNullOrWhiteSpace(audience)) throw new ArgumentException("Client ID, client secret and audience are required."); _clients = clients; _clientId = clientId; _clientSecret = clientSecret; _audience = audience; _clock = clock ?? TimeProvider.System; } public async Task GetTokenAsync(CancellationToken cancellationToken = default) { var cached = Volatile.Read(ref _cached); if (cached != null && _clock.GetUtcNow() < cached.RefreshAt) return cached.Token; await _refreshLock.WaitAsync(cancellationToken); try { // Another caller may have refreshed while this caller was waiting. cached = Volatile.Read(ref _cached); if (cached != null && _clock.GetUtcNow() < cached.RefreshAt) return cached.Token; using var client = _clients.CreateClient("VinqueryIdentity"); using var response = await client.PostAsJsonAsync( "https://identity.vinquery.com/connect/token", new { clientId = _clientId, clientSecret = _clientSecret, audience = _audience }, cancellationToken); response.EnsureSuccessStatusCode(); var token = await response.Content.ReadFromJsonAsync( cancellationToken: cancellationToken); var now = _clock.GetUtcNow(); if (token == null || string.IsNullOrWhiteSpace(token.JwtToken) || token.ExpiresUtc <= now) throw new InvalidOperationException("VINquery returned an empty token or invalid expiry."); var bufferSeconds = Math.Min(60, (token.ExpiresUtc - now).TotalSeconds * 0.1); Volatile.Write(ref _cached, new CachedToken(token.JwtToken, token.ExpiresUtc.AddSeconds(-bufferSeconds))); return token.JwtToken; } finally { _refreshLock.Release(); } } public void Invalidate(string rejectedToken) { var cached = Volatile.Read(ref _cached); if (cached?.Token == rejectedToken) Interlocked.CompareExchange(ref _cached, null, cached); } private sealed record CachedToken(string Token, DateTimeOffset RefreshAt); private sealed class TokenResponse { [JsonPropertyName("jwtToken")] public string JwtToken { get; set; } = ""; [JsonPropertyName("expiresUtc")] public DateTimeOffset ExpiresUtc { get; set; } } }