Files
git.stella-ops.org/src/StellaOps.Authority/StellaOps.Auth.Client/StellaOpsAuthClientOptions.cs
Vladimir Moushkov 3083c77a9e
Some checks failed
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled
Build Test Deploy / build-test (push) Has been cancelled
Build Test Deploy / authority-container (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
up
2025-10-10 18:33:10 +03:00

206 lines
6.4 KiB
C#

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();
}
}