Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
@@ -5,5 +5,7 @@ Typed OpenID Connect client used by StellaOps services, agents, and tooling to t
|
||||
- Discovery + JWKS caching with deterministic refresh windows.
|
||||
- Password and client-credential flows with token cache abstractions.
|
||||
- Configurable HTTP retry/backoff policies (Polly) and offline fallback support for air-gapped deployments.
|
||||
- `HttpClient` authentication helpers that attach OAuth2 (password/client-credentials) or personal access tokens,
|
||||
including automatic `X-StellaOps-Tenant` header injection for multi-tenant APIs.
|
||||
|
||||
See `docs/dev/32_AUTH_CLIENT_GUIDE.md` in the repository for integration guidance, option descriptions, and rollout checklists.
|
||||
|
||||
@@ -68,6 +68,29 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds authentication and tenancy header handling for an <see cref="HttpClient"/> registered via <see cref="IHttpClientBuilder"/>.
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddStellaOpsApiAuthentication(this IHttpClientBuilder builder, Action<StellaOpsApiAuthenticationOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
builder.Services.AddOptions<StellaOpsApiAuthenticationOptions>(builder.Name)
|
||||
.Configure(configure)
|
||||
.PostConfigure(static options => options.Validate());
|
||||
|
||||
builder.AddHttpMessageHandler(provider => new StellaOpsBearerTokenHandler(
|
||||
builder.Name,
|
||||
provider.GetRequiredService<IOptionsMonitor<StellaOpsApiAuthenticationOptions>>(),
|
||||
provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>(),
|
||||
provider.GetRequiredService<IStellaOpsTokenClient>(),
|
||||
provider.GetService<TimeProvider>(),
|
||||
provider.GetService<ILogger<StellaOpsBearerTokenHandler>>()));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static IAsyncPolicy<HttpResponseMessage> CreateRetryPolicy(IServiceProvider provider)
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Auth.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication strategies supported by the StellaOps API client helpers.
|
||||
/// </summary>
|
||||
public enum StellaOpsApiAuthMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Use the OAuth 2.0 client credentials grant to request access tokens.
|
||||
/// </summary>
|
||||
ClientCredentials,
|
||||
|
||||
/// <summary>
|
||||
/// Use the resource owner password credentials grant to request access tokens.
|
||||
/// </summary>
|
||||
Password,
|
||||
|
||||
/// <summary>
|
||||
/// Use a pre-issued personal access token (PAT) as the bearer credential.
|
||||
/// </summary>
|
||||
PersonalAccessToken
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Auth.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling how <see cref="HttpClient"/> instances obtain authentication and tenancy headers.
|
||||
/// </summary>
|
||||
public sealed class StellaOpsApiAuthenticationOptions
|
||||
{
|
||||
private string tenantHeader = StellaOpsHttpHeaderNames.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the authentication mode used to authorise outbound requests.
|
||||
/// </summary>
|
||||
public StellaOpsApiAuthMode Mode { get; set; } = StellaOpsApiAuthMode.ClientCredentials;
|
||||
|
||||
/// <summary>
|
||||
/// Optional scope override supplied when requesting OAuth access tokens.
|
||||
/// </summary>
|
||||
public string? Scope { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Username used when <see cref="Mode"/> is <see cref="StellaOpsApiAuthMode.Password"/>.
|
||||
/// </summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Password used when <see cref="Mode"/> is <see cref="StellaOpsApiAuthMode.Password"/>.
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Pre-issued personal access token used when <see cref="Mode"/> is <see cref="StellaOpsApiAuthMode.PersonalAccessToken"/>.
|
||||
/// </summary>
|
||||
public string? PersonalAccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant identifier injected via <see cref="TenantHeader"/>. If <c>null</c>, the header is omitted.
|
||||
/// </summary>
|
||||
public string? Tenant { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Header name used to convey the tenant override (defaults to <c>X-StellaOps-Tenant</c>).
|
||||
/// </summary>
|
||||
public string TenantHeader
|
||||
{
|
||||
get => tenantHeader;
|
||||
set => tenantHeader = string.IsNullOrWhiteSpace(value) ? StellaOpsHttpHeaderNames.Tenant : value.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Buffer window applied before token expiration that triggers proactive refresh (defaults to 30 seconds).
|
||||
/// </summary>
|
||||
public TimeSpan RefreshBuffer { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (RefreshBuffer < TimeSpan.Zero || RefreshBuffer > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
throw new InvalidOperationException("RefreshBuffer must be between 0 seconds and 5 minutes.");
|
||||
}
|
||||
|
||||
Tenant = string.IsNullOrWhiteSpace(Tenant) ? null : Tenant.Trim();
|
||||
|
||||
Scope = string.IsNullOrWhiteSpace(Scope) ? null : Scope.Trim();
|
||||
|
||||
switch (Mode)
|
||||
{
|
||||
case StellaOpsApiAuthMode.ClientCredentials:
|
||||
break;
|
||||
case StellaOpsApiAuthMode.Password:
|
||||
if (string.IsNullOrWhiteSpace(Username))
|
||||
{
|
||||
throw new InvalidOperationException("Username is required for password authentication.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(Password))
|
||||
{
|
||||
throw new InvalidOperationException("Password is required for password authentication.");
|
||||
}
|
||||
|
||||
Username = Username.Trim();
|
||||
break;
|
||||
case StellaOpsApiAuthMode.PersonalAccessToken:
|
||||
if (string.IsNullOrWhiteSpace(PersonalAccessToken))
|
||||
{
|
||||
throw new InvalidOperationException("PersonalAccessToken is required when using personal access token mode.");
|
||||
}
|
||||
|
||||
PersonalAccessToken = PersonalAccessToken.Trim();
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported authentication mode '{Mode}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
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;
|
||||
|
||||
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 token = cachedToken;
|
||||
|
||||
if (token is not null && token.ExpiresAt - buffer > now)
|
||||
{
|
||||
return token.AccessToken;
|
||||
}
|
||||
|
||||
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
token = cachedToken;
|
||||
now = timeProvider.GetUtcNow();
|
||||
if (token is not null && token.ExpiresAt - buffer > now)
|
||||
{
|
||||
return token.AccessToken;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,11 @@ public sealed record StellaOpsTokenResult(
|
||||
string? IdToken = null,
|
||||
string? RawResponse = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Temporary shim for callers expecting the legacy <c>ExpiresAt</c> member.
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt => ExpiresAtUtc;
|
||||
|
||||
/// <summary>
|
||||
/// Converts the result to a cache entry.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user