save progress
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<PackageId>StellaOps.Auth.ServerIntegration</PackageId>
|
||||
|
||||
@@ -25,6 +25,9 @@ internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationMan
|
||||
|
||||
private OpenIdConnectConfiguration? cachedConfiguration;
|
||||
private DateTimeOffset cacheExpiresAt;
|
||||
private DateTimeOffset offlineExpiresAt;
|
||||
private string? cachedMetadataAddress;
|
||||
private Uri? cachedAuthorityUri;
|
||||
|
||||
public StellaOpsAuthorityConfigurationManager(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
@@ -40,6 +43,15 @@ internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationMan
|
||||
|
||||
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var metadataAddress = ResolveMetadataAddress(options);
|
||||
if (OptionsChanged(options, metadataAddress))
|
||||
{
|
||||
cachedAuthorityUri = options.AuthorityUri;
|
||||
cachedMetadataAddress = metadataAddress;
|
||||
RequestRefresh();
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var current = Volatile.Read(ref cachedConfiguration);
|
||||
if (current is not null && now < cacheExpiresAt)
|
||||
@@ -55,8 +67,6 @@ internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationMan
|
||||
return cachedConfiguration;
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
var metadataAddress = ResolveMetadataAddress(options);
|
||||
var httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
httpClient.Timeout = options.BackchannelTimeout;
|
||||
|
||||
@@ -67,24 +77,32 @@ internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationMan
|
||||
|
||||
logger.LogDebug("Fetching OpenID Connect configuration from {MetadataAddress}.", metadataAddress);
|
||||
|
||||
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
|
||||
configuration.Issuer ??= options.AuthorityUri.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
|
||||
try
|
||||
{
|
||||
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
|
||||
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
|
||||
var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
|
||||
configuration.SigningKeys.Clear();
|
||||
foreach (JsonWebKey key in jsonWebKeySet.Keys)
|
||||
{
|
||||
configuration.SigningKeys.Add(key);
|
||||
}
|
||||
}
|
||||
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
|
||||
configuration.Issuer ??= options.AuthorityUri.ToString();
|
||||
|
||||
cachedConfiguration = configuration;
|
||||
cacheExpiresAt = now + options.MetadataCacheLifetime;
|
||||
return configuration;
|
||||
if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
|
||||
{
|
||||
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
|
||||
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
|
||||
var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
|
||||
configuration.SigningKeys.Clear();
|
||||
foreach (JsonWebKey key in jsonWebKeySet.Keys)
|
||||
{
|
||||
configuration.SigningKeys.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
cachedConfiguration = configuration;
|
||||
cacheExpiresAt = now + options.MetadataCacheLifetime;
|
||||
offlineExpiresAt = cacheExpiresAt + options.OfflineCacheTolerance;
|
||||
return configuration;
|
||||
}
|
||||
catch (Exception exception) when (IsOfflineCandidate(exception, cancellationToken) && TryUseOfflineFallback(options, now, exception))
|
||||
{
|
||||
return cachedConfiguration!;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -96,6 +114,7 @@ internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationMan
|
||||
{
|
||||
Volatile.Write(ref cachedConfiguration, null);
|
||||
cacheExpiresAt = DateTimeOffset.MinValue;
|
||||
offlineExpiresAt = DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
private static string ResolveMetadataAddress(StellaOpsResourceServerOptions options)
|
||||
@@ -113,4 +132,70 @@ internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationMan
|
||||
|
||||
return new Uri(authority, ".well-known/openid-configuration").AbsoluteUri;
|
||||
}
|
||||
|
||||
private bool OptionsChanged(StellaOpsResourceServerOptions options, string metadataAddress)
|
||||
{
|
||||
if (cachedAuthorityUri is null || cachedMetadataAddress is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(cachedMetadataAddress, metadataAddress, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Uri.Equals(cachedAuthorityUri, options.AuthorityUri))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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(StellaOpsResourceServerOptions options, DateTimeOffset now, Exception exception)
|
||||
{
|
||||
if (!options.AllowOfflineCacheFallback || cachedConfiguration 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, "Authority metadata refresh failed; reusing cached configuration until {FallbackExpiresAt}.", offlineExpiresAt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,16 @@ public sealed class StellaOpsResourceServerOptions
|
||||
/// </summary>
|
||||
public TimeSpan MetadataCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether stale metadata/JWKS may be reused if Authority is unreachable.
|
||||
/// </summary>
|
||||
public bool AllowOfflineCacheFallback { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Additional tolerance window during which stale metadata/JWKS may be reused when offline fallback is allowed.
|
||||
/// </summary>
|
||||
public TimeSpan OfflineCacheTolerance { get; set; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the canonical Authority URI (populated during validation).
|
||||
/// </summary>
|
||||
@@ -122,6 +132,11 @@ public sealed class StellaOpsResourceServerOptions
|
||||
throw new InvalidOperationException("Resource server metadata cache lifetime must be greater than zero and less than or equal to 24 hours.");
|
||||
}
|
||||
|
||||
if (OfflineCacheTolerance < TimeSpan.Zero || OfflineCacheTolerance > TimeSpan.FromHours(24))
|
||||
{
|
||||
throw new InvalidOperationException("Resource server offline cache tolerance must be between 0 and 24 hours.");
|
||||
}
|
||||
|
||||
AuthorityUri = authorityUri;
|
||||
|
||||
NormalizeList(audiences, toLower: false);
|
||||
|
||||
@@ -1011,7 +1011,11 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler<
|
||||
continue;
|
||||
}
|
||||
|
||||
scopes.Add(claim.Value);
|
||||
var normalized = StellaOpsScopes.Normalize(claim.Value);
|
||||
if (normalized is not null)
|
||||
{
|
||||
scopes.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0083-M | DONE | Maintainability audit for StellaOps.Auth.ServerIntegration. |
|
||||
| AUDIT-0083-T | DONE | Test coverage audit for StellaOps.Auth.ServerIntegration. |
|
||||
| AUDIT-0083-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0083-A | DONE | Metadata fallback, scope normalization, and coverage gaps addressed. |
|
||||
|
||||
Reference in New Issue
Block a user