Files
git.stella-ops.org/src/Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOpsBearerTokenHandler.cs
2026-02-01 21:37:40 +02:00

179 lines
7.1 KiB
C#

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;
/// <summary>
/// Delegating handler that attaches bearer credentials and tenant headers to outbound requests.
/// </summary>
internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
{
private readonly string clientName;
private readonly IOptionsMonitor<StellaOpsApiAuthenticationOptions> apiAuthOptions;
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> authClientOptions;
private readonly IStellaOpsTokenClient tokenClient;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsBearerTokenHandler>? logger;
private readonly SemaphoreSlim refreshLock = new(1, 1);
private StellaOpsTokenResult? cachedToken;
private string? cachedTokenKey;
public StellaOpsBearerTokenHandler(
string clientName,
IOptionsMonitor<StellaOpsApiAuthenticationOptions> apiAuthOptions,
IOptionsMonitor<StellaOpsAuthClientOptions> authClientOptions,
IStellaOpsTokenClient tokenClient,
TimeProvider? timeProvider,
ILogger<StellaOpsBearerTokenHandler>? 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<HttpResponseMessage> 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<string?> 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();
}
}