using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Threading; using System.Threading.Tasks; namespace StellaOps.Auth.Client; /// /// Delegating handler that attaches bearer credentials and tenant headers to outbound requests. /// internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler { private readonly string clientName; private readonly IOptionsMonitor apiAuthOptions; private readonly IOptionsMonitor authClientOptions; private readonly IStellaOpsTokenClient tokenClient; private readonly TimeProvider timeProvider; private readonly ILogger? logger; private readonly SemaphoreSlim refreshLock = new(1, 1); private StellaOpsTokenResult? cachedToken; private string? cachedTokenKey; public StellaOpsBearerTokenHandler( string clientName, IOptionsMonitor apiAuthOptions, IOptionsMonitor authClientOptions, IStellaOpsTokenClient tokenClient, TimeProvider? timeProvider, ILogger? logger) { this.clientName = clientName ?? throw new ArgumentNullException(nameof(clientName)); this.apiAuthOptions = apiAuthOptions ?? throw new ArgumentNullException(nameof(apiAuthOptions)); this.authClientOptions = authClientOptions ?? throw new ArgumentNullException(nameof(authClientOptions)); this.tokenClient = tokenClient ?? throw new ArgumentNullException(nameof(tokenClient)); this.timeProvider = timeProvider ?? TimeProvider.System; this.logger = logger; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var options = apiAuthOptions.Get(clientName); if (!string.IsNullOrWhiteSpace(options.Tenant)) { request.Headers.Remove(options.TenantHeader); request.Headers.TryAddWithoutValidation(options.TenantHeader, options.Tenant); } var token = await ResolveTokenAsync(options, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrEmpty(token)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); } private async Task ResolveTokenAsync(StellaOpsApiAuthenticationOptions options, CancellationToken cancellationToken) { if (options.Mode == StellaOpsApiAuthMode.PersonalAccessToken) { return options.PersonalAccessToken; } var buffer = GetRefreshBuffer(options); var now = timeProvider.GetUtcNow(); var clientOptions = authClientOptions.CurrentValue; var cacheKey = BuildCacheKey(options, clientOptions); var token = cachedToken; if (token is not null && cachedTokenKey == cacheKey && token.ExpiresAt - buffer > now) { return token.AccessToken; } await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { token = cachedToken; now = timeProvider.GetUtcNow(); if (token is not null && cachedTokenKey == cacheKey && token.ExpiresAt - buffer > now) { return token.AccessToken; } var cachedEntry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (cachedEntry is not null && !cachedEntry.IsExpired(timeProvider, buffer)) { cachedToken = new StellaOpsTokenResult( cachedEntry.AccessToken, cachedEntry.TokenType, cachedEntry.ExpiresAtUtc, cachedEntry.Scopes, cachedEntry.RefreshToken, cachedEntry.IdToken, null); cachedTokenKey = cacheKey; return cachedEntry.AccessToken; } else if (cachedEntry is not null) { await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); } StellaOpsTokenResult result = options.Mode switch { StellaOpsApiAuthMode.ClientCredentials => await tokenClient.RequestClientCredentialsTokenAsync( options.Scope, null, cancellationToken).ConfigureAwait(false), StellaOpsApiAuthMode.Password => await tokenClient.RequestPasswordTokenAsync( options.Username!, options.Password!, options.Scope, null, cancellationToken).ConfigureAwait(false), _ => throw new InvalidOperationException($"Unsupported authentication mode '{options.Mode}'.") }; cachedToken = result; cachedTokenKey = cacheKey; await tokenClient.CacheTokenAsync(cacheKey, result.ToCacheEntry(), cancellationToken).ConfigureAwait(false); logger?.LogDebug("Issued access token for client {ClientName}; expires at {ExpiresAt}.", clientName, result.ExpiresAt); return result.AccessToken; } finally { refreshLock.Release(); } } private TimeSpan GetRefreshBuffer(StellaOpsApiAuthenticationOptions options) { var authOptions = authClientOptions.CurrentValue; var buffer = options.RefreshBuffer; if (buffer <= TimeSpan.Zero) { return authOptions.ExpirationSkew; } return buffer > authOptions.ExpirationSkew ? buffer : authOptions.ExpirationSkew; } private string BuildCacheKey(StellaOpsApiAuthenticationOptions apiOptions, StellaOpsAuthClientOptions clientOptions) { var resolvedScope = ResolveScope(apiOptions.Scope, clientOptions); var authority = clientOptions.AuthorityUri?.ToString() ?? clientOptions.Authority; var builder = new StringBuilder(); builder.Append("stellaops|"); builder.Append(clientName).Append('|'); builder.Append(authority).Append('|'); builder.Append(clientOptions.ClientId ?? string.Empty).Append('|'); builder.Append(apiOptions.Mode).Append('|'); builder.Append(resolvedScope ?? string.Empty).Append('|'); builder.Append(apiOptions.Username ?? string.Empty).Append('|'); builder.Append(apiOptions.Tenant ?? string.Empty); return builder.ToString(); } private static string? ResolveScope(string? scope, StellaOpsAuthClientOptions clientOptions) { var resolved = scope; if (string.IsNullOrWhiteSpace(resolved) && clientOptions.NormalizedScopes.Count > 0) { resolved = string.Join(' ', clientOptions.NormalizedScopes); } return string.IsNullOrWhiteSpace(resolved) ? null : resolved.Trim(); } }