179 lines
7.1 KiB
C#
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();
|
|
}
|
|
}
|