Files
git.stella-ops.org/src/StellaOps.Authority/StellaOps.Auth.Client/StellaOpsJwksCache.cs
2025-10-11 23:28:35 +03:00

117 lines
3.7 KiB
C#

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;
/// <summary>
/// Caches JWKS documents for Authority.
/// </summary>
public sealed class StellaOpsJwksCache
{
private readonly HttpClient httpClient;
private readonly StellaOpsDiscoveryCache discoveryCache;
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsJwksCache>? logger;
private JsonWebKeySet? cachedSet;
private DateTimeOffset cacheExpiresAt;
private DateTimeOffset offlineExpiresAt;
public StellaOpsJwksCache(
HttpClient httpClient,
StellaOpsDiscoveryCache discoveryCache,
IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor,
TimeProvider? timeProvider = null,
ILogger<StellaOpsJwksCache>? 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<JsonWebKeySet> 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;
}
}