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

@@ -1,6 +1,8 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -81,8 +83,23 @@ public sealed class FileTokenCache : IStellaOpsTokenCache
try
{
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous);
var streamOptions = new FileStreamOptions
{
Mode = FileMode.Create,
Access = FileAccess.Write,
Share = FileShare.None,
Options = FileOptions.Asynchronous
};
if (!OperatingSystem.IsWindows())
{
streamOptions.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
}
await using var stream = new FileStream(path, streamOptions);
await JsonSerializer.SerializeAsync(stream, payload, serializerOptions, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
TryHardenPermissions(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
@@ -119,4 +136,31 @@ public sealed class FileTokenCache : IStellaOpsTokenCache
var hash = Convert.ToHexString(sha.ComputeHash(bytes));
return Path.Combine(cacheDirectory, $"{hash}.json");
}
private void TryHardenPermissions(string path)
{
try
{
if (OperatingSystem.IsWindows())
{
var identity = WindowsIdentity.GetCurrent();
if (identity.User is null)
{
return;
}
var security = new FileSecurity();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
security.AddAccessRule(new FileSystemAccessRule(identity.User, FileSystemRights.FullControl, AccessControlType.Allow));
new FileInfo(path).SetAccessControl(security);
return;
}
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
logger?.LogWarning(ex, "Failed to harden permissions for cache file '{Path}'.", path);
}
}
}

View File

@@ -66,7 +66,8 @@ public static class ServiceCollectionExtensions
{
var logger = provider.GetService<Microsoft.Extensions.Logging.ILogger<FileTokenCache>>();
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
return new FileTokenCache(cacheDirectory, TimeProvider.System, options.ExpirationSkew, logger);
var timeProvider = provider.GetService<TimeProvider>();
return new FileTokenCache(cacheDirectory, timeProvider, options.ExpirationSkew, logger);
}));
return services;
@@ -95,13 +96,27 @@ public static class ServiceCollectionExtensions
return builder;
}
private static void ConfigureResilience(ResiliencePipelineBuilder<HttpResponseMessage> builder)
private static void ConfigureResilience(ResiliencePipelineBuilder<HttpResponseMessage> builder, ResilienceHandlerContext context)
{
context.EnableReloads<StellaOpsAuthClientOptions>();
var options = context.GetOptions<StellaOpsAuthClientOptions>();
if (!options.EnableRetries || options.NormalizedRetryDelays.Count == 0)
{
return;
}
var delays = options.NormalizedRetryDelays;
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
MaxRetryAttempts = delays.Count,
DelayGenerator = args =>
{
var index = args.AttemptNumber < delays.Count ? args.AttemptNumber : delays.Count - 1;
return ValueTask.FromResult<TimeSpan?>(delays[index]);
},
BackoffType = DelayBackoffType.Constant,
ShouldHandle = static args => ValueTask.FromResult(
args.Outcome.Exception is not null ||
args.Outcome.Result?.StatusCode is HttpStatusCode.RequestTimeout

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.Client</PackageId>

View File

@@ -1,6 +1,7 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -22,6 +23,7 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
private readonly SemaphoreSlim refreshLock = new(1, 1);
private StellaOpsTokenResult? cachedToken;
private string? cachedTokenKey;
public StellaOpsBearerTokenHandler(
string clientName,
@@ -67,9 +69,11 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
var buffer = GetRefreshBuffer(options);
var now = timeProvider.GetUtcNow();
var clientOptions = authClientOptions.CurrentValue;
var cacheKey = BuildCacheKey(options, clientOptions);
var token = cachedToken;
if (token is not null && token.ExpiresAt - buffer > now)
if (token is not null && cachedTokenKey == cacheKey && token.ExpiresAt - buffer > now)
{
return token.AccessToken;
}
@@ -79,11 +83,30 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
{
token = cachedToken;
now = timeProvider.GetUtcNow();
if (token is not null && token.ExpiresAt - buffer > now)
if (token is not null && cachedTokenKey == cacheKey && token.ExpiresAt - buffer > now)
{
return token.AccessToken;
}
var cachedEntry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cachedEntry is not null && !cachedEntry.IsExpired(timeProvider, buffer))
{
cachedToken = new StellaOpsTokenResult(
cachedEntry.AccessToken,
cachedEntry.TokenType,
cachedEntry.ExpiresAtUtc,
cachedEntry.Scopes,
cachedEntry.RefreshToken,
cachedEntry.IdToken,
null);
cachedTokenKey = cacheKey;
return cachedEntry.AccessToken;
}
else if (cachedEntry is not null)
{
await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
}
StellaOpsTokenResult result = options.Mode switch
{
StellaOpsApiAuthMode.ClientCredentials => await tokenClient.RequestClientCredentialsTokenAsync(
@@ -100,6 +123,8 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
};
cachedToken = result;
cachedTokenKey = cacheKey;
await tokenClient.CacheTokenAsync(cacheKey, result.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
logger?.LogDebug("Issued access token for client {ClientName}; expires at {ExpiresAt}.", clientName, result.ExpiresAt);
return result.AccessToken;
}
@@ -120,4 +145,33 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
return buffer > authOptions.ExpirationSkew ? buffer : authOptions.ExpirationSkew;
}
private string BuildCacheKey(StellaOpsApiAuthenticationOptions apiOptions, StellaOpsAuthClientOptions clientOptions)
{
var resolvedScope = ResolveScope(apiOptions.Scope, clientOptions);
var authority = clientOptions.AuthorityUri?.ToString() ?? clientOptions.Authority;
var builder = new StringBuilder();
builder.Append("stellaops|");
builder.Append(clientName).Append('|');
builder.Append(authority).Append('|');
builder.Append(clientOptions.ClientId ?? string.Empty).Append('|');
builder.Append(apiOptions.Mode).Append('|');
builder.Append(resolvedScope ?? string.Empty).Append('|');
builder.Append(apiOptions.Username ?? string.Empty).Append('|');
builder.Append(apiOptions.Tenant ?? string.Empty);
return builder.ToString();
}
private static string? ResolveScope(string? scope, StellaOpsAuthClientOptions clientOptions)
{
var resolved = scope;
if (string.IsNullOrWhiteSpace(resolved) && clientOptions.NormalizedScopes.Count > 0)
{
resolved = string.Join(' ', clientOptions.NormalizedScopes);
}
return string.IsNullOrWhiteSpace(resolved) ? null : resolved.Trim();
}
}

View File

@@ -23,6 +23,9 @@ public sealed class StellaOpsDiscoveryCache
private DateTimeOffset cacheExpiresAt;
private DateTimeOffset offlineExpiresAt;
internal DateTimeOffset CacheExpiresAt => cacheExpiresAt;
internal DateTimeOffset OfflineExpiresAt => offlineExpiresAt;
public StellaOpsDiscoveryCache(HttpClient httpClient, IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor, TimeProvider? timeProvider = null, ILogger<StellaOpsDiscoveryCache>? logger = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));

View File

@@ -23,6 +23,9 @@ public sealed class StellaOpsJwksCache
private DateTimeOffset cacheExpiresAt;
private DateTimeOffset offlineExpiresAt;
internal DateTimeOffset CacheExpiresAt => cacheExpiresAt;
internal DateTimeOffset OfflineExpiresAt => offlineExpiresAt;
public StellaOpsJwksCache(
HttpClient httpClient,
StellaOpsDiscoveryCache discoveryCache,

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0080-M | DONE | Maintainability audit for StellaOps.Auth.Client. |
| AUDIT-0080-T | DONE | Test coverage audit for StellaOps.Auth.Client. |
| AUDIT-0080-A | TODO | Pending approval for changes. |
| AUDIT-0080-A | DONE | Retry options, shared cache, and coverage gaps addressed. |