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:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user