Initial commit (history squashed)
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user