Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,122 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Auth.Client;
/// <summary>
/// File-based token cache suitable for CLI/offline usage.
/// </summary>
public sealed class FileTokenCache : IStellaOpsTokenCache
{
private readonly string cacheDirectory;
private readonly TimeProvider timeProvider;
private readonly TimeSpan expirationSkew;
private readonly ILogger<FileTokenCache>? logger;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
public FileTokenCache(string cacheDirectory, TimeProvider? timeProvider = null, TimeSpan? expirationSkew = null, ILogger<FileTokenCache>? logger = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
this.cacheDirectory = cacheDirectory;
this.timeProvider = timeProvider ?? TimeProvider.System;
this.expirationSkew = expirationSkew ?? TimeSpan.FromSeconds(30);
this.logger = logger;
}
public async ValueTask<StellaOpsTokenCacheEntry?> GetAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
var path = GetPath(key);
if (!File.Exists(path))
{
return null;
}
try
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
var entry = await JsonSerializer.DeserializeAsync<StellaOpsTokenCacheEntry>(stream, serializerOptions, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
return null;
}
entry = entry.NormalizeScopes();
if (entry.IsExpired(timeProvider, expirationSkew))
{
await RemoveInternalAsync(path).ConfigureAwait(false);
return null;
}
return entry;
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
logger?.LogWarning(ex, "Failed to read token cache entry '{CacheKey}'.", key);
return null;
}
}
public async ValueTask SetAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(entry);
Directory.CreateDirectory(cacheDirectory);
var path = GetPath(key);
var payload = entry.NormalizeScopes();
try
{
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous);
await JsonSerializer.SerializeAsync(stream, payload, serializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger?.LogWarning(ex, "Failed to persist token cache entry '{CacheKey}'.", key);
}
}
public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
var path = GetPath(key);
return new ValueTask(RemoveInternalAsync(path));
}
private async Task RemoveInternalAsync(string path)
{
try
{
if (File.Exists(path))
{
await Task.Run(() => File.Delete(path)).ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger?.LogDebug(ex, "Failed to remove cache file '{Path}'.", path);
}
}
private string GetPath(string key)
{
using var sha = SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(key);
var hash = Convert.ToHexString(sha.ComputeHash(bytes));
return Path.Combine(cacheDirectory, $"{hash}.json");
}
}

View File

@@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Auth.Client;
/// <summary>
/// Abstraction for caching StellaOps tokens.
/// </summary>
public interface IStellaOpsTokenCache
{
/// <summary>
/// Retrieves a cached token entry, if present.
/// </summary>
ValueTask<StellaOpsTokenCacheEntry?> GetAsync(string key, CancellationToken cancellationToken = default);
/// <summary>
/// Stores or updates a token entry for the specified key.
/// </summary>
ValueTask SetAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default);
/// <summary>
/// Removes the cached entry for the specified key.
/// </summary>
ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,42 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Client;
/// <summary>
/// Abstraction for requesting tokens from StellaOps Authority.
/// </summary>
public interface IStellaOpsTokenClient
{
/// <summary>
/// Requests an access token using the resource owner password credentials flow.
/// </summary>
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
/// <summary>
/// Requests an access token using the client credentials flow.
/// </summary>
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the cached JWKS document.
/// </summary>
Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a cached token entry.
/// </summary>
ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default);
/// <summary>
/// Persists a token entry in the cache.
/// </summary>
ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default);
/// <summary>
/// Removes a cached entry.
/// </summary>
ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Auth.Client;
/// <summary>
/// In-memory token cache suitable for service scenarios.
/// </summary>
public sealed class InMemoryTokenCache : IStellaOpsTokenCache
{
private readonly ConcurrentDictionary<string, StellaOpsTokenCacheEntry> entries = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
private readonly Func<StellaOpsTokenCacheEntry, StellaOpsTokenCacheEntry> normalizer;
private readonly TimeSpan expirationSkew;
public InMemoryTokenCache(TimeProvider? timeProvider = null, TimeSpan? expirationSkew = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
this.expirationSkew = expirationSkew ?? TimeSpan.FromSeconds(30);
normalizer = static entry => entry.NormalizeScopes();
}
public ValueTask<StellaOpsTokenCacheEntry?> GetAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (!entries.TryGetValue(key, out var entry))
{
return ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
}
if (entry.IsExpired(timeProvider, expirationSkew))
{
entries.TryRemove(key, out _);
return ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
}
return ValueTask.FromResult<StellaOpsTokenCacheEntry?>(entry);
}
public ValueTask SetAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentNullException.ThrowIfNull(entry);
entries[key] = normalizer(entry);
return ValueTask.CompletedTask;
}
public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
entries.TryRemove(key, out _);
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,9 @@
# StellaOps.Auth.Client
Typed OpenID Connect client used by StellaOps services, agents, and tooling to talk to **StellaOps Authority**. It provides:
- Discovery + JWKS caching with deterministic refresh windows.
- Password and client-credential flows with token cache abstractions.
- Configurable HTTP retry/backoff policies (Polly) and offline fallback support for air-gapped deployments.
See `docs/dev/32_AUTH_CLIENT_GUIDE.md` in the repository for integration guidance, option descriptions, and rollout checklists.

View File

@@ -0,0 +1,115 @@
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Extensions.Http;
namespace StellaOps.Auth.Client;
/// <summary>
/// DI helpers for the StellaOps auth client.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the StellaOps auth client with the provided configuration.
/// </summary>
public static IServiceCollection AddStellaOpsAuthClient(this IServiceCollection services, Action<StellaOpsAuthClientOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
services.AddOptions<StellaOpsAuthClientOptions>()
.Configure(configure)
.PostConfigure(static options => options.Validate());
services.TryAddSingleton<IStellaOpsTokenCache, InMemoryTokenCache>();
services.AddHttpClient<StellaOpsDiscoveryCache>((provider, client) =>
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = options.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = options.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
client.Timeout = options.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
return services;
}
/// <summary>
/// Registers a file-backed token cache implementation.
/// </summary>
public static IServiceCollection AddStellaOpsFileTokenCache(this IServiceCollection services, string cacheDirectory)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory);
services.Replace(ServiceDescriptor.Singleton<IStellaOpsTokenCache>(provider =>
{
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);
}));
return services;
}
private static IAsyncPolicy<HttpResponseMessage> CreateRetryPolicy(IServiceProvider provider)
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
var delays = options.NormalizedRetryDelays;
if (delays.Count == 0)
{
return Policy.NoOpAsync<HttpResponseMessage>();
}
var logger = provider.GetService<ILoggerFactory>()?.CreateLogger("StellaOps.Auth.Client.HttpRetry");
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(static response => response.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
delays.Count,
attempt => delays[attempt - 1],
(outcome, delay, attempt, _) =>
{
if (logger is null)
{
return;
}
if (outcome.Exception is not null)
{
logger.LogWarning(
outcome.Exception,
"Retrying Authority HTTP call ({Attempt}/{TotalAttempts}) after exception; waiting {Delay}.",
attempt,
delays.Count,
delay);
}
else
{
logger.LogWarning(
"Retrying Authority HTTP call ({Attempt}/{TotalAttempts}) due to status {StatusCode}; waiting {Delay}.",
attempt,
delays.Count,
outcome.Result!.StatusCode,
delay);
}
});
}
}

View File

@@ -0,0 +1,47 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<PackageId>StellaOps.Auth.Client</PackageId>
<Description>Typed OAuth/OpenID client for StellaOps Authority with caching, retries, and token helpers.</Description>
<Authors>StellaOps</Authors>
<Company>StellaOps</Company>
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
<PackageProjectUrl>https://stella-ops.org</PackageProjectUrl>
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PackageTags>stellaops;authentication;authority;oauth2;client</PackageTags>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<PackageReadmeFile>README.NuGet.md</PackageReadmeFile>
<VersionPrefix>1.0.0-preview.1</VersionPrefix>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
</ItemGroup>
<ItemGroup>
<None Include="README.NuGet.md" Pack="true" PackagePath="" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>StellaOps.Auth.Client.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.Client;
/// <summary>
/// Options controlling the StellaOps authentication client.
/// </summary>
public sealed class StellaOpsAuthClientOptions
{
private static readonly TimeSpan[] DefaultRetryDelays =
{
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5)
};
private static readonly TimeSpan DefaultOfflineTolerance = TimeSpan.FromMinutes(10);
private readonly List<string> scopes = new();
private readonly List<TimeSpan> retryDelays = new(DefaultRetryDelays);
/// <summary>
/// Authority (issuer) base URL.
/// </summary>
public string Authority { get; set; } = string.Empty;
/// <summary>
/// OAuth client identifier (optional for password flow).
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// OAuth client secret (optional for public clients).
/// </summary>
public string? ClientSecret { get; set; }
/// <summary>
/// Default scopes requested for flows that do not explicitly override them.
/// </summary>
public IList<string> DefaultScopes => scopes;
/// <summary>
/// Retry delays applied by HTTP retry policy (empty uses defaults).
/// </summary>
public IList<TimeSpan> RetryDelays => retryDelays;
/// <summary>
/// Gets or sets a value indicating whether HTTP retry policies are enabled.
/// </summary>
public bool EnableRetries { get; set; } = true;
/// <summary>
/// Timeout applied to discovery and token HTTP requests.
/// </summary>
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Lifetime of cached discovery metadata.
/// </summary>
public TimeSpan DiscoveryCacheLifetime { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// Lifetime of cached JWKS metadata.
/// </summary>
public TimeSpan JwksCacheLifetime { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Buffer applied when determining cache expiration (default: 30 seconds).
/// </summary>
public TimeSpan ExpirationSkew { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets a value indicating whether cached discovery/JWKS responses may be served when the Authority is unreachable.
/// </summary>
public bool AllowOfflineCacheFallback { get; set; } = true;
/// <summary>
/// Additional tolerance window during which stale cache entries remain valid if offline fallback is allowed.
/// </summary>
public TimeSpan OfflineCacheTolerance { get; set; } = DefaultOfflineTolerance;
/// <summary>
/// Parsed Authority URI (populated after validation).
/// </summary>
public Uri AuthorityUri { get; private set; } = null!;
/// <summary>
/// Normalised scope list (populated after validation).
/// </summary>
public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>();
/// <summary>
/// Normalised retry delays (populated after validation).
/// </summary>
public IReadOnlyList<TimeSpan> NormalizedRetryDelays { get; private set; } = Array.Empty<TimeSpan>();
/// <summary>
/// Validates required values and normalises scope entries.
/// </summary>
public void Validate()
{
if (string.IsNullOrWhiteSpace(Authority))
{
throw new InvalidOperationException("Auth client requires an Authority URL.");
}
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
{
throw new InvalidOperationException("Auth client Authority must be an absolute URI.");
}
if (HttpTimeout <= TimeSpan.Zero)
{
throw new InvalidOperationException("Auth client HTTP timeout must be greater than zero.");
}
if (DiscoveryCacheLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("Discovery cache lifetime must be greater than zero.");
}
if (JwksCacheLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("JWKS cache lifetime must be greater than zero.");
}
if (ExpirationSkew < TimeSpan.Zero || ExpirationSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("Expiration skew must be between 0 seconds and 5 minutes.");
}
if (OfflineCacheTolerance < TimeSpan.Zero)
{
throw new InvalidOperationException("Offline cache tolerance must be greater than or equal to zero.");
}
AuthorityUri = authorityUri;
NormalizedScopes = NormalizeScopes(scopes);
NormalizedRetryDelays = EnableRetries ? NormalizeRetryDelays(retryDelays) : Array.Empty<TimeSpan>();
}
private static IReadOnlyList<string> NormalizeScopes(IList<string> values)
{
if (values.Count == 0)
{
return Array.Empty<string>();
}
var unique = new HashSet<string>(StringComparer.Ordinal);
for (var index = values.Count - 1; index >= 0; index--)
{
var entry = values[index];
if (string.IsNullOrWhiteSpace(entry))
{
values.RemoveAt(index);
continue;
}
var normalized = StellaOpsScopes.Normalize(entry);
if (normalized is null)
{
values.RemoveAt(index);
continue;
}
if (!unique.Add(normalized))
{
values.RemoveAt(index);
continue;
}
values[index] = normalized;
}
return values.Count == 0
? Array.Empty<string>()
: values.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
}
private static IReadOnlyList<TimeSpan> NormalizeRetryDelays(IList<TimeSpan> values)
{
for (var index = values.Count - 1; index >= 0; index--)
{
var delay = values[index];
if (delay <= TimeSpan.Zero)
{
values.RemoveAt(index);
}
}
if (values.Count == 0)
{
foreach (var delay in DefaultRetryDelays)
{
values.Add(delay);
}
}
return values.ToArray();
}
}

View File

@@ -0,0 +1,142 @@
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Auth.Client;
/// <summary>
/// Caches Authority discovery metadata.
/// </summary>
public sealed class StellaOpsDiscoveryCache
{
private readonly HttpClient httpClient;
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsDiscoveryCache>? logger;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
private OpenIdConfiguration? cachedConfiguration;
private DateTimeOffset cacheExpiresAt;
private DateTimeOffset offlineExpiresAt;
public StellaOpsDiscoveryCache(HttpClient httpClient, IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor, TimeProvider? timeProvider = null, ILogger<StellaOpsDiscoveryCache>? logger = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public async Task<OpenIdConfiguration> GetAsync(CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
if (cachedConfiguration is not null && now < cacheExpiresAt)
{
return cachedConfiguration;
}
var options = optionsMonitor.CurrentValue;
var discoveryUri = new Uri(options.AuthorityUri, ".well-known/openid-configuration");
logger?.LogDebug("Fetching StellaOps discovery document from {DiscoveryUri}.", discoveryUri);
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, discoveryUri);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var document = await JsonSerializer.DeserializeAsync<DiscoveryDocument>(stream, serializerOptions, cancellationToken).ConfigureAwait(false);
if (document is null)
{
throw new InvalidOperationException("Authority discovery document is empty.");
}
if (string.IsNullOrWhiteSpace(document.TokenEndpoint))
{
throw new InvalidOperationException("Authority discovery document does not expose token_endpoint.");
}
if (string.IsNullOrWhiteSpace(document.JwksUri))
{
throw new InvalidOperationException("Authority discovery document does not expose jwks_uri.");
}
var configuration = new OpenIdConfiguration(
new Uri(document.TokenEndpoint, UriKind.Absolute),
new Uri(document.JwksUri, UriKind.Absolute));
cachedConfiguration = configuration;
cacheExpiresAt = now + options.DiscoveryCacheLifetime;
offlineExpiresAt = cacheExpiresAt + options.OfflineCacheTolerance;
return configuration;
}
catch (Exception exception) when (IsOfflineCandidate(exception, cancellationToken) && TryUseOfflineFallback(options, now, exception))
{
return cachedConfiguration!;
}
}
private sealed record DiscoveryDocument(
[property: System.Text.Json.Serialization.JsonPropertyName("token_endpoint")] string? TokenEndpoint,
[property: System.Text.Json.Serialization.JsonPropertyName("jwks_uri")] string? JwksUri);
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 || 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, "Discovery document fetch failed; reusing cached configuration until {FallbackExpiresAt}.", offlineExpiresAt);
return true;
}
}
/// <summary>
/// Minimal OpenID Connect configuration representation.
/// </summary>
public sealed record OpenIdConfiguration(Uri TokenEndpoint, Uri JwksEndpoint);

View File

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

View File

@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Auth.Client;
/// <summary>
/// Represents a cached token entry.
/// </summary>
public sealed record StellaOpsTokenCacheEntry(
string AccessToken,
string TokenType,
DateTimeOffset ExpiresAtUtc,
IReadOnlyList<string> Scopes,
string? RefreshToken = null,
string? IdToken = null,
IReadOnlyDictionary<string, string>? Metadata = null)
{
/// <summary>
/// Determines whether the token is expired given the provided <see cref="TimeProvider"/>.
/// </summary>
public bool IsExpired(TimeProvider timeProvider, TimeSpan? skew = null)
{
ArgumentNullException.ThrowIfNull(timeProvider);
var now = timeProvider.GetUtcNow();
var buffer = skew ?? TimeSpan.Zero;
return now >= ExpiresAtUtc - buffer;
}
/// <summary>
/// Creates a copy with scopes normalised.
/// </summary>
public StellaOpsTokenCacheEntry NormalizeScopes()
{
if (Scopes.Count == 0)
{
return this;
}
var normalized = Scopes
.Where(scope => !string.IsNullOrWhiteSpace(scope))
.Select(scope => scope.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(scope => scope, StringComparer.Ordinal)
.ToArray();
return this with { Scopes = normalized };
}
}

View File

@@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Client;
/// <summary>
/// Default implementation of <see cref="IStellaOpsTokenClient"/>.
/// </summary>
public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
{
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
private readonly HttpClient httpClient;
private readonly StellaOpsDiscoveryCache discoveryCache;
private readonly StellaOpsJwksCache jwksCache;
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
private readonly IStellaOpsTokenCache tokenCache;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsTokenClient>? logger;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
public StellaOpsTokenClient(
HttpClient httpClient,
StellaOpsDiscoveryCache discoveryCache,
StellaOpsJwksCache jwksCache,
IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor,
IStellaOpsTokenCache tokenCache,
TimeProvider? timeProvider = null,
ILogger<StellaOpsTokenClient>? logger = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.discoveryCache = discoveryCache ?? throw new ArgumentNullException(nameof(discoveryCache));
this.jwksCache = jwksCache ?? throw new ArgumentNullException(nameof(jwksCache));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.tokenCache = tokenCache ?? throw new ArgumentNullException(nameof(tokenCache));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(
string username,
string password,
string? scope = null,
IReadOnlyDictionary<string, string>? additionalParameters = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(username);
ArgumentException.ThrowIfNullOrWhiteSpace(password);
var options = optionsMonitor.CurrentValue;
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
{
["grant_type"] = "password",
["username"] = username,
["password"] = password,
["client_id"] = options.ClientId
};
if (!string.IsNullOrEmpty(options.ClientSecret))
{
parameters["client_secret"] = options.ClientSecret;
}
AppendScope(parameters, scope, options);
if (additionalParameters is not null)
{
foreach (var (key, value) in additionalParameters)
{
if (string.IsNullOrWhiteSpace(key) || value is null)
{
continue;
}
parameters[key] = value;
}
}
return RequestTokenAsync(parameters, cancellationToken);
}
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
var options = optionsMonitor.CurrentValue;
if (string.IsNullOrWhiteSpace(options.ClientId))
{
throw new InvalidOperationException("Client credentials flow requires ClientId to be configured.");
}
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
{
["grant_type"] = "client_credentials",
["client_id"] = options.ClientId
};
if (!string.IsNullOrEmpty(options.ClientSecret))
{
parameters["client_secret"] = options.ClientSecret;
}
AppendScope(parameters, scope, options);
if (additionalParameters is not null)
{
foreach (var (key, value) in additionalParameters)
{
if (string.IsNullOrWhiteSpace(key) || value is null)
{
continue;
}
parameters[key] = value;
}
}
return RequestTokenAsync(parameters, cancellationToken);
}
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> jwksCache.GetAsync(cancellationToken);
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> tokenCache.GetAsync(key, cancellationToken);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> tokenCache.SetAsync(key, entry, cancellationToken);
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> tokenCache.RemoveAsync(key, cancellationToken);
private async Task<StellaOpsTokenResult> RequestTokenAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue;
var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false);
using var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint)
{
Content = new FormUrlEncodedContent(parameters)
};
request.Headers.Accept.TryParseAdd(JsonMediaType.ToString());
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
logger?.LogWarning("Token request failed with status {StatusCode}: {Payload}", response.StatusCode, payload);
throw new InvalidOperationException($"Token request failed with status {(int)response.StatusCode}.");
}
var document = JsonSerializer.Deserialize<TokenResponseDocument>(payload, serializerOptions);
if (document is null || string.IsNullOrWhiteSpace(document.AccessToken))
{
throw new InvalidOperationException("Token response did not contain an access_token.");
}
var expiresIn = document.ExpiresIn ?? 3600;
var expiresAt = timeProvider.GetUtcNow() + TimeSpan.FromSeconds(expiresIn);
var normalizedScopes = ParseScopes(document.Scope ?? parameters.GetValueOrDefault("scope"));
var result = new StellaOpsTokenResult(
document.AccessToken,
document.TokenType ?? "Bearer",
expiresAt,
normalizedScopes,
document.RefreshToken,
document.IdToken,
payload);
logger?.LogDebug("Token issued; expires at {ExpiresAt}.", expiresAt);
return result;
}
private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options)
{
var resolvedScope = scope;
if (string.IsNullOrWhiteSpace(resolvedScope) && options.NormalizedScopes.Count > 0)
{
resolvedScope = string.Join(' ', options.NormalizedScopes);
}
if (!string.IsNullOrWhiteSpace(resolvedScope))
{
parameters["scope"] = resolvedScope;
}
}
private static string[] ParseScopes(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return Array.Empty<string>();
}
var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0)
{
return Array.Empty<string>();
}
var unique = new HashSet<string>(parts.Length, StringComparer.Ordinal);
foreach (var part in parts)
{
unique.Add(part);
}
var result = new string[unique.Count];
unique.CopyTo(result);
Array.Sort(result, StringComparer.Ordinal);
return result;
}
private sealed record TokenResponseDocument(
[property: JsonPropertyName("access_token")] string? AccessToken,
[property: JsonPropertyName("refresh_token")] string? RefreshToken,
[property: JsonPropertyName("id_token")] string? IdToken,
[property: JsonPropertyName("token_type")] string? TokenType,
[property: JsonPropertyName("expires_in")] int? ExpiresIn,
[property: JsonPropertyName("scope")] string? Scope,
[property: JsonPropertyName("error")] string? Error,
[property: JsonPropertyName("error_description")] string? ErrorDescription);
}

View File

@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Auth.Client;
/// <summary>
/// Represents an issued token with metadata.
/// </summary>
public sealed record StellaOpsTokenResult(
string AccessToken,
string TokenType,
DateTimeOffset ExpiresAtUtc,
IReadOnlyList<string> Scopes,
string? RefreshToken = null,
string? IdToken = null,
string? RawResponse = null)
{
/// <summary>
/// Converts the result to a cache entry.
/// </summary>
public StellaOpsTokenCacheEntry ToCacheEntry()
=> new(AccessToken, TokenType, ExpiresAtUtc, Scopes, RefreshToken, IdToken);
}