using System; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; namespace StellaOps.Auth.Client; /// /// Caches JWKS documents for Authority. /// public sealed class StellaOpsJwksCache { private readonly HttpClient httpClient; private readonly StellaOpsDiscoveryCache discoveryCache; private readonly IOptionsMonitor optionsMonitor; private readonly TimeProvider timeProvider; private readonly ILogger? logger; private JsonWebKeySet? cachedSet; private DateTimeOffset cacheExpiresAt; private DateTimeOffset offlineExpiresAt; public StellaOpsJwksCache( HttpClient httpClient, StellaOpsDiscoveryCache discoveryCache, IOptionsMonitor optionsMonitor, TimeProvider? timeProvider = null, ILogger? logger = null) { this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); this.discoveryCache = discoveryCache ?? throw new ArgumentNullException(nameof(discoveryCache)); this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); this.timeProvider = timeProvider ?? TimeProvider.System; this.logger = logger; } public async Task GetAsync(CancellationToken cancellationToken) { var now = timeProvider.GetUtcNow(); if (cachedSet is not null && now < cacheExpiresAt) { return cachedSet; } var options = optionsMonitor.CurrentValue; var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false); logger?.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksEndpoint); try { using var response = await httpClient.GetAsync(configuration.JwksEndpoint, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); cachedSet = new JsonWebKeySet(json); cacheExpiresAt = now + options.JwksCacheLifetime; offlineExpiresAt = cacheExpiresAt + options.OfflineCacheTolerance; return cachedSet; } catch (Exception exception) when (IsOfflineCandidate(exception, cancellationToken) && TryUseOfflineFallback(options, now, exception)) { return cachedSet!; } } private static bool IsOfflineCandidate(Exception exception, CancellationToken cancellationToken) { if (exception is HttpRequestException) { return true; } if (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested) { return true; } if (exception is TimeoutException) { return true; } return false; } private bool TryUseOfflineFallback(StellaOpsAuthClientOptions options, DateTimeOffset now, Exception exception) { if (!options.AllowOfflineCacheFallback || cachedSet is null) { return false; } if (options.OfflineCacheTolerance <= TimeSpan.Zero) { return false; } if (offlineExpiresAt == DateTimeOffset.MinValue) { return false; } if (now >= offlineExpiresAt) { return false; } logger?.LogWarning(exception, "JWKS fetch failed; reusing cached keys until {FallbackExpiresAt}.", offlineExpiresAt); return true; } }