using System; using System.Collections.Generic; using System.Linq; using StellaOps.Auth.Abstractions; namespace StellaOps.Auth.Client; /// /// Options controlling the StellaOps authentication client. /// 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 scopes = new(); private readonly List retryDelays = new(DefaultRetryDelays); /// /// Authority (issuer) base URL. /// public string Authority { get; set; } = string.Empty; /// /// OAuth client identifier (optional for password flow). /// public string ClientId { get; set; } = string.Empty; /// /// OAuth client secret (optional for public clients). /// public string? ClientSecret { get; set; } /// /// Default scopes requested for flows that do not explicitly override them. /// public IList DefaultScopes => scopes; /// /// Retry delays applied by HTTP retry policy (empty uses defaults). /// public IList RetryDelays => retryDelays; /// /// Gets or sets a value indicating whether HTTP retry policies are enabled. /// public bool EnableRetries { get; set; } = true; /// /// Timeout applied to discovery and token HTTP requests. /// public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(30); /// /// Lifetime of cached discovery metadata. /// public TimeSpan DiscoveryCacheLifetime { get; set; } = TimeSpan.FromMinutes(10); /// /// Lifetime of cached JWKS metadata. /// public TimeSpan JwksCacheLifetime { get; set; } = TimeSpan.FromMinutes(30); /// /// Buffer applied when determining cache expiration (default: 30 seconds). /// public TimeSpan ExpirationSkew { get; set; } = TimeSpan.FromSeconds(30); /// /// Gets or sets a value indicating whether cached discovery/JWKS responses may be served when the Authority is unreachable. /// public bool AllowOfflineCacheFallback { get; set; } = true; /// /// Additional tolerance window during which stale cache entries remain valid if offline fallback is allowed. /// public TimeSpan OfflineCacheTolerance { get; set; } = DefaultOfflineTolerance; /// /// Parsed Authority URI (populated after validation). /// public Uri AuthorityUri { get; private set; } = null!; /// /// Normalised scope list (populated after validation). /// public IReadOnlyList NormalizedScopes { get; private set; } = Array.Empty(); /// /// Normalised retry delays (populated after validation). /// public IReadOnlyList NormalizedRetryDelays { get; private set; } = Array.Empty(); /// /// Validates required values and normalises scope entries. /// 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(); } private static IReadOnlyList NormalizeScopes(IList values) { if (values.Count == 0) { return Array.Empty(); } var unique = new HashSet(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() : values.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray(); } private static IReadOnlyList NormalizeRetryDelays(IList 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(); } }