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:
master
2025-11-02 13:40:38 +02:00
parent 66cb6c4b8a
commit f98cea3bcf
516 changed files with 68157 additions and 24754 deletions

View File

@@ -8,6 +8,9 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Client;
using Xunit;
@@ -92,4 +95,206 @@ public class ServiceCollectionExtensionsTests
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> responder(request, cancellationToken);
}
[Fact]
public async Task AddStellaOpsApiAuthentication_AttachesPatAndTenantHeader()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddStellaOpsAuthClient(options =>
{
options.Authority = "https://authority.test";
options.DiscoveryCacheLifetime = TimeSpan.FromMinutes(1);
options.JwksCacheLifetime = TimeSpan.FromMinutes(1);
options.AllowOfflineCacheFallback = false;
});
var tokenClient = new ThrowingTokenClient();
services.AddSingleton<IStellaOpsTokenClient>(tokenClient);
var handler = new RecordingHttpMessageHandler();
services.AddHttpClient("notify")
.ConfigurePrimaryHttpMessageHandler(() => handler)
.AddStellaOpsApiAuthentication(options =>
{
options.Mode = StellaOpsApiAuthMode.PersonalAccessToken;
options.PersonalAccessToken = "pat-token";
options.Tenant = "tenant-123";
options.TenantHeader = "X-Custom-Tenant";
});
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("notify");
var response = await client.GetAsync("https://notify.example/api");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Single(handler.AuthorizationHistory);
var authorization = handler.AuthorizationHistory[0];
Assert.NotNull(authorization);
Assert.Equal("Bearer", authorization!.Scheme);
Assert.Equal("pat-token", authorization.Parameter);
Assert.Single(handler.TenantHeaders);
Assert.Equal("tenant-123", handler.TenantHeaders[0]);
Assert.Equal(0, tokenClient.RequestCount);
}
[Fact]
public async Task AddStellaOpsApiAuthentication_UsesClientCredentialsWithCaching()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddStellaOpsAuthClient(options =>
{
options.Authority = "https://authority.test";
options.DiscoveryCacheLifetime = TimeSpan.FromMinutes(1);
options.JwksCacheLifetime = TimeSpan.FromMinutes(1);
options.AllowOfflineCacheFallback = false;
options.ExpirationSkew = TimeSpan.FromSeconds(10);
});
var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T00:00:00Z"));
services.AddSingleton<TimeProvider>(fakeTime);
var recordingTokenClient = new RecordingTokenClient(fakeTime);
services.AddSingleton<IStellaOpsTokenClient>(recordingTokenClient);
var handler = new RecordingHttpMessageHandler();
services.AddHttpClient("notify")
.ConfigurePrimaryHttpMessageHandler(() => handler)
.AddStellaOpsApiAuthentication(options =>
{
options.Mode = StellaOpsApiAuthMode.ClientCredentials;
options.Scope = "notify.read";
options.Tenant = "tenant-oauth";
});
using var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("notify");
await client.GetAsync("https://notify.example/api");
await client.GetAsync("https://notify.example/api");
Assert.Equal(2, handler.AuthorizationHistory.Count);
Assert.Equal(1, recordingTokenClient.ClientCredentialsCallCount);
Assert.All(handler.AuthorizationHistory, header =>
{
Assert.NotNull(header);
Assert.Equal("Bearer", header!.Scheme);
Assert.Equal("token-1", header.Parameter);
});
Assert.All(handler.TenantHeaders, value => Assert.Equal("tenant-oauth", value));
// Advance beyond expiry buffer to force refresh.
fakeTime.Advance(TimeSpan.FromMinutes(2));
await client.GetAsync("https://notify.example/api");
Assert.Equal(3, handler.AuthorizationHistory.Count);
Assert.Equal("token-2", handler.AuthorizationHistory[^1]!.Parameter);
Assert.Equal(2, recordingTokenClient.ClientCredentialsCallCount);
}
private sealed class RecordingHttpMessageHandler : HttpMessageHandler
{
public List<AuthenticationHeaderValue?> AuthorizationHistory { get; } = new();
public List<string?> TenantHeaders { get; } = new();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AuthorizationHistory.Add(request.Headers.Authorization);
if (request.Headers.TryGetValues("X-Custom-Tenant", out var customTenant))
{
TenantHeaders.Add(customTenant.Single());
}
else if (request.Headers.TryGetValues("X-StellaOps-Tenant", out var defaultTenant))
{
TenantHeaders.Add(defaultTenant.Single());
}
else
{
TenantHeaders.Add(null);
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
private sealed class ThrowingTokenClient : IStellaOpsTokenClient
{
public int RequestCount { get; private set; }
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new JsonWebKeySet());
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
RequestCount++;
throw new InvalidOperationException("Client credentials flow should not be invoked for PAT mode.");
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
RequestCount++;
throw new InvalidOperationException("Password flow should not be invoked for PAT mode.");
}
}
private sealed class RecordingTokenClient : IStellaOpsTokenClient
{
private readonly FakeTimeProvider timeProvider;
private int tokenCounter;
public RecordingTokenClient(FakeTimeProvider timeProvider)
{
this.timeProvider = timeProvider;
}
public int ClientCredentialsCallCount { get; private set; }
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
ClientCredentialsCallCount++;
var tokenId = Interlocked.Increment(ref tokenCounter);
var result = new StellaOpsTokenResult(
$"token-{tokenId}",
"Bearer",
timeProvider.GetUtcNow().AddMinutes(1),
scope is null ? Array.Empty<string>() : new[] { scope },
null,
null,
"{}");
return Task.FromResult(result);
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new JsonWebKeySet());
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<StellaOpsTokenCacheEntry?>(null);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
}