Restructure solution layout by module
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user