save progress
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user