save progress

This commit is contained in:
StellaOps Bot
2026-01-02 21:06:27 +02:00
parent f46bde5575
commit 3f197814c5
441 changed files with 21545 additions and 4306 deletions

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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))

View File

@@ -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. |