feat(authority): truthful dpop runtime extensions
Sprint SPRINT_20260416_012_Authority_truthful_dpop_runtime. AuthorityDpopRuntimeExtensions wiring, standard plugin bootstrapper + options tests, DPoP runtime security tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,4 +16,4 @@ PostgreSQL database `stellaops_authority` (dedicated DB); Valkey for session/cac
|
||||
|
||||
## Background Workers
|
||||
- `AuthoritySecretHasherInitializer` — crypto secret initialization on startup
|
||||
- Plugin hosting via `IPluginHost` (standard identity plugin with bootstrap user/client seeding)
|
||||
- Plugin hosting via `IPluginHost` (standard identity plugin with first-party client seeding and setup-driven human admin creation)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -32,7 +33,7 @@ public class StandardPluginBootstrapperTests
|
||||
services.AddOptions<StandardPluginOptions>("standard")
|
||||
.Configure(options =>
|
||||
{
|
||||
options.TenantId = "demo-prod";
|
||||
options.TenantId = "default";
|
||||
options.BootstrapClients = new[]
|
||||
{
|
||||
new BootstrapClientOptions
|
||||
@@ -100,7 +101,7 @@ public class StandardPluginBootstrapperTests
|
||||
Assert.Contains(StellaOpsScopes.ReleasePublish, client.AllowedScopes);
|
||||
Assert.Contains("authorization_code", client.AllowedGrantTypes);
|
||||
Assert.True(client.RequirePkce);
|
||||
Assert.Equal("demo-prod", client.Properties[AuthorityClientMetadataKeys.Tenant]);
|
||||
Assert.Equal("default", client.Properties[AuthorityClientMetadataKeys.Tenant]);
|
||||
|
||||
var humanCliClient = await clientStore.FindByClientIdAsync("stellaops-cli", TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(humanCliClient);
|
||||
@@ -119,6 +120,60 @@ public class StandardPluginBootstrapperTests
|
||||
Assert.Contains(StellaOpsScopes.OpsHealth, automationCliClient.AllowedScopes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StartAsync_RetriesBootstrapClientsUntilClientStoreRecovers()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions<StandardPluginOptions>("standard")
|
||||
.Configure(options =>
|
||||
{
|
||||
options.TenantId = "default";
|
||||
options.BootstrapClients = new[]
|
||||
{
|
||||
new BootstrapClientOptions
|
||||
{
|
||||
ClientId = "stella-ops-ui",
|
||||
DisplayName = "Stella Ops Console",
|
||||
AllowedGrantTypes = "authorization_code refresh_token",
|
||||
AllowedScopes = $"openid profile {StellaOpsScopes.UiRead}",
|
||||
RedirectUris = "https://stella-ops.local/auth/callback",
|
||||
PostLogoutRedirectUris = "https://stella-ops.local/",
|
||||
RequirePkce = true
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var clientStore = new TransientClientStore(new InMemoryClientStore(), failureCount: 2);
|
||||
services.AddSingleton<IAuthorityClientStore>(clientStore);
|
||||
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
||||
services.AddSingleton<TimeProvider>(new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T13:00:00Z")));
|
||||
services.AddSingleton(sp =>
|
||||
new StandardClientProvisioningStore(
|
||||
"standard",
|
||||
sp.GetRequiredService<IAuthorityClientStore>(),
|
||||
sp.GetRequiredService<IAuthorityRevocationStore>(),
|
||||
sp.GetRequiredService<TimeProvider>()));
|
||||
|
||||
services.AddSingleton(sp =>
|
||||
new StandardPluginBootstrapper(
|
||||
"standard",
|
||||
sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<StandardPluginBootstrapper>.Instance,
|
||||
retryDelay: TimeSpan.Zero));
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var bootstrapper = provider.GetRequiredService<StandardPluginBootstrapper>();
|
||||
|
||||
await bootstrapper.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(clientStore.FailureObserved);
|
||||
|
||||
var client = await clientStore.FindByClientIdAsync("stella-ops-ui", TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(client);
|
||||
Assert.Contains("authorization_code", client!.AllowedGrantTypes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenBootstrapFails()
|
||||
@@ -432,6 +487,41 @@ public class StandardPluginBootstrapperTests
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TransientClientStore : IAuthorityClientStore
|
||||
{
|
||||
private readonly IAuthorityClientStore inner;
|
||||
private int remainingFailures;
|
||||
|
||||
public TransientClientStore(IAuthorityClientStore inner, int failureCount)
|
||||
{
|
||||
this.inner = inner;
|
||||
remainingFailures = failureCount;
|
||||
}
|
||||
|
||||
public bool FailureObserved { get; private set; }
|
||||
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
|
||||
{
|
||||
if (remainingFailures > 0)
|
||||
{
|
||||
remainingFailures--;
|
||||
FailureObserved = true;
|
||||
throw new IOException("Transient bootstrap storage failure.");
|
||||
}
|
||||
|
||||
return inner.FindByClientIdAsync(clientId, cancellationToken, session);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityClientDocument>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default, MongoDB.Driver.IClientSessionHandle? session = null)
|
||||
=> inner.ListAsync(limit, offset, cancellationToken, session);
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
|
||||
=> inner.UpsertAsync(document, cancellationToken, session);
|
||||
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
|
||||
=> inner.DeleteByClientIdAsync(clientId, cancellationToken, session);
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset fixedNow;
|
||||
|
||||
@@ -74,6 +74,9 @@ public class StandardPluginOptionsTests
|
||||
options.Normalize(configPath);
|
||||
options.Validate("standard");
|
||||
|
||||
Assert.Equal("default", options.TenantId);
|
||||
Assert.Null(options.BootstrapUser);
|
||||
|
||||
var clients = options.BootstrapClients.ToDictionary(client => client.ClientId!, StringComparer.Ordinal);
|
||||
|
||||
Assert.Contains("stella-ops-ui", clients.Keys);
|
||||
|
||||
@@ -7,8 +7,12 @@ using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using Npgsql;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -22,28 +26,30 @@ internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
private readonly string pluginName;
|
||||
private readonly IServiceScopeFactory scopeFactory;
|
||||
private readonly ILogger<StandardPluginBootstrapper> logger;
|
||||
private readonly int maxAttempts;
|
||||
private readonly int? maxAttempts;
|
||||
private readonly TimeSpan retryDelay;
|
||||
|
||||
public StandardPluginBootstrapper(
|
||||
string pluginName,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<StandardPluginBootstrapper> logger,
|
||||
int maxAttempts = 15,
|
||||
int maxAttempts = 0,
|
||||
TimeSpan? retryDelay = null)
|
||||
{
|
||||
this.pluginName = pluginName;
|
||||
this.scopeFactory = scopeFactory;
|
||||
this.logger = logger;
|
||||
this.maxAttempts = Math.Max(1, maxAttempts);
|
||||
this.maxAttempts = maxAttempts > 0 ? maxAttempts : null;
|
||||
this.retryDelay = retryDelay ?? DefaultRetryDelay;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
var attempt = 0;
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
attempt++;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -55,16 +61,27 @@ internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
"Standard Authority plugin '{PluginName}' bootstrap completed on retry attempt {Attempt}/{MaxAttempts}.",
|
||||
pluginName,
|
||||
attempt,
|
||||
maxAttempts);
|
||||
maxAttempts?.ToString() ?? "unbounded");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var finalAttempt = attempt == maxAttempts;
|
||||
var finalAttempt = maxAttempts.HasValue && attempt >= maxAttempts.Value;
|
||||
var retryable = IsRetryable(ex);
|
||||
var level = finalAttempt ? LogLevel.Error : LogLevel.Warning;
|
||||
|
||||
if (!retryable && !finalAttempt)
|
||||
{
|
||||
logger.LogError(
|
||||
ex,
|
||||
"Standard Authority plugin '{PluginName}' bootstrap failed on attempt {Attempt} with a non-retryable error. Automatic bootstrap retries are stopping.",
|
||||
pluginName,
|
||||
attempt);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.Log(
|
||||
level,
|
||||
ex,
|
||||
@@ -73,7 +90,7 @@ internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
: "Standard Authority plugin '{PluginName}' bootstrap attempt {Attempt}/{MaxAttempts} failed. Retrying in {RetryDelay}.",
|
||||
pluginName,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
maxAttempts?.ToString() ?? "unbounded",
|
||||
retryDelay);
|
||||
|
||||
if (finalAttempt)
|
||||
@@ -86,6 +103,25 @@ internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsRetryable(Exception exception)
|
||||
{
|
||||
if (exception is OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return exception switch
|
||||
{
|
||||
IOException => true,
|
||||
HttpRequestException => true,
|
||||
NpgsqlException => true,
|
||||
SocketException => true,
|
||||
TimeoutException => true,
|
||||
_ when exception.InnerException is not null => IsRetryable(exception.InnerException),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task RunBootstrapPassAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Authority.Security;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Tests.Security;
|
||||
|
||||
[Collection(nameof(AuthorityValkeyFixtureCollection))]
|
||||
public sealed class AuthorityDpopRuntimeTests
|
||||
{
|
||||
private readonly AuthorityValkeyFixture _valkey;
|
||||
|
||||
public AuthorityDpopRuntimeTests(AuthorityValkeyFixture valkey)
|
||||
{
|
||||
_valkey = valkey;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestingRuntime_UsesInMemoryDpopStores()
|
||||
{
|
||||
using var provider = CreateServiceProvider(
|
||||
"Testing",
|
||||
_ => { });
|
||||
|
||||
Assert.IsType<InMemoryDpopReplayCache>(provider.GetRequiredService<IDpopReplayCache>());
|
||||
Assert.IsType<InMemoryDpopNonceStore>(provider.GetRequiredService<IDpopNonceStore>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProductionRuntime_DpopDisabled_StartsWithoutDurableStore()
|
||||
{
|
||||
using var provider = CreateServiceProvider(
|
||||
"Production",
|
||||
options => options.Dpop.Enabled = false);
|
||||
|
||||
var replayCache = provider.GetRequiredService<IDpopReplayCache>();
|
||||
var nonceStore = provider.GetRequiredService<IDpopNonceStore>();
|
||||
|
||||
Assert.True(await replayCache.TryStoreAsync(Guid.NewGuid().ToString("N"), DateTimeOffset.UtcNow.AddMinutes(5)));
|
||||
|
||||
var issuance = await nonceStore.IssueAsync(
|
||||
"signer",
|
||||
"client",
|
||||
"thumb",
|
||||
TimeSpan.FromMinutes(5),
|
||||
maxIssuancePerMinute: 5);
|
||||
|
||||
Assert.Equal(DpopNonceIssueStatus.Failure, issuance.Status);
|
||||
Assert.Null(issuance.Nonce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionRuntime_DpopEnabled_WithMemoryNonceStore_FailsFast()
|
||||
{
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => CreateServiceProvider(
|
||||
"Production",
|
||||
options =>
|
||||
{
|
||||
options.Dpop.Enabled = true;
|
||||
options.Dpop.Nonce.Store = "memory";
|
||||
}));
|
||||
|
||||
Assert.Contains("Nonce:Store=redis", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProductionRuntime_UsesDurableReplayAndNonceStoresAcrossRestart()
|
||||
{
|
||||
var jwtId = Guid.NewGuid().ToString("N");
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5);
|
||||
string issuedNonce;
|
||||
|
||||
await using (var firstProvider = CreateServiceProvider(
|
||||
"Production",
|
||||
options =>
|
||||
{
|
||||
options.Dpop.Enabled = true;
|
||||
options.Dpop.Nonce.Enabled = true;
|
||||
options.Dpop.Nonce.Store = "redis";
|
||||
options.Dpop.Nonce.RedisConnectionString = _valkey.ConnectionString;
|
||||
}))
|
||||
{
|
||||
var replayCache = firstProvider.GetRequiredService<IDpopReplayCache>();
|
||||
var nonceStore = firstProvider.GetRequiredService<IDpopNonceStore>();
|
||||
|
||||
Assert.IsType<MessagingDpopReplayCache>(replayCache);
|
||||
Assert.IsType<MessagingDpopNonceStore>(nonceStore);
|
||||
Assert.True(await replayCache.TryStoreAsync(jwtId, expiresAt));
|
||||
|
||||
var issuance = await nonceStore.IssueAsync(
|
||||
"signer",
|
||||
"authority-client",
|
||||
"thumbprint",
|
||||
TimeSpan.FromMinutes(5),
|
||||
maxIssuancePerMinute: 5);
|
||||
|
||||
Assert.Equal(DpopNonceIssueStatus.Success, issuance.Status);
|
||||
issuedNonce = issuance.Nonce!;
|
||||
}
|
||||
|
||||
await using (var restartedProvider = CreateServiceProvider(
|
||||
"Production",
|
||||
options =>
|
||||
{
|
||||
options.Dpop.Enabled = true;
|
||||
options.Dpop.Nonce.Enabled = true;
|
||||
options.Dpop.Nonce.Store = "redis";
|
||||
options.Dpop.Nonce.RedisConnectionString = _valkey.ConnectionString;
|
||||
}))
|
||||
{
|
||||
var replayCache = restartedProvider.GetRequiredService<IDpopReplayCache>();
|
||||
var nonceStore = restartedProvider.GetRequiredService<IDpopNonceStore>();
|
||||
|
||||
Assert.IsType<MessagingDpopReplayCache>(replayCache);
|
||||
Assert.IsType<MessagingDpopNonceStore>(nonceStore);
|
||||
Assert.False(await replayCache.TryStoreAsync(jwtId, expiresAt));
|
||||
|
||||
var consume = await nonceStore.TryConsumeAsync(
|
||||
issuedNonce,
|
||||
"signer",
|
||||
"authority-client",
|
||||
"thumbprint");
|
||||
|
||||
Assert.Equal(DpopNonceConsumeStatus.Success, consume.Status);
|
||||
}
|
||||
}
|
||||
|
||||
private static ServiceProvider CreateServiceProvider(
|
||||
string environmentName,
|
||||
Action<AuthoritySenderConstraintOptions> configure)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<TimeProvider>(TimeProvider.System);
|
||||
|
||||
var senderConstraints = new AuthoritySenderConstraintOptions();
|
||||
configure(senderConstraints);
|
||||
|
||||
services.AddAuthorityDpopRuntime(new TestHostEnvironment(environmentName), senderConstraints);
|
||||
|
||||
return services.BuildServiceProvider(validateScopes: true);
|
||||
}
|
||||
|
||||
private sealed class TestHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public TestHostEnvironment(string environmentName)
|
||||
{
|
||||
EnvironmentName = environmentName;
|
||||
}
|
||||
|
||||
public string EnvironmentName { get; set; }
|
||||
public string ApplicationName { get; set; } = "AuthorityDpopRuntimeTests";
|
||||
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AuthorityValkeyFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly IContainer _container;
|
||||
|
||||
public AuthorityValkeyFixture()
|
||||
{
|
||||
_container = new ContainerBuilder()
|
||||
.WithImage("valkey/valkey:8-alpine")
|
||||
.WithPortBinding(6379, true)
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("valkey-cli", "ping"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
public string ConnectionString { get; private set; } = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync();
|
||||
ConnectionString = $"{_container.Hostname}:{_container.GetMappedPublicPort(6379)}";
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _container.StopAsync();
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(nameof(AuthorityValkeyFixtureCollection))]
|
||||
public sealed class AuthorityValkeyFixtureCollection : ICollectionFixture<AuthorityValkeyFixture>
|
||||
{
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Testcontainers" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Authority.csproj" />
|
||||
@@ -31,4 +32,4 @@
|
||||
<Compile Include="../../../__Tests/shared/OpenSslLegacyShim.cs" Link="Infrastructure/OpenSslLegacyShim.cs" />
|
||||
<None Include="../../../__Tests/native/openssl-1.1/linux-x64/*" Link="native/linux-x64/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0100-M | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0100-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0100-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
| AUTH-DPOP-REAL-003 | DONE | 2026-04-16: focused Authority DPoP runtime wiring and restart-survival proof passed (`4/4`). |
|
||||
|
||||
@@ -68,7 +68,6 @@ using StellaOps.Authority.Vulnerability.Attachments;
|
||||
using StellaOps.Audit.Emission;
|
||||
#if STELLAOPS_AUTH_SECURITY
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StackExchange.Redis;
|
||||
#endif
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -172,34 +171,8 @@ builder.Services.AddOptions<DpopValidationOptions>()
|
||||
})
|
||||
.PostConfigure(static options => options.Validate());
|
||||
|
||||
builder.Services.TryAddSingleton<IDpopReplayCache>(provider => new InMemoryDpopReplayCache(provider.GetService<TimeProvider>()));
|
||||
builder.Services.AddAuthorityDpopRuntime(builder.Environment, senderConstraints);
|
||||
builder.Services.TryAddSingleton<IDpopProofValidator, DpopProofValidator>();
|
||||
if (string.Equals(senderConstraints.Dpop.Nonce.Store, "redis", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.Services.TryAddSingleton<IConnectionMultiplexer>(_ =>
|
||||
{
|
||||
var redisOptions = ConfigurationOptions.Parse(senderConstraints.Dpop.Nonce.RedisConnectionString!);
|
||||
redisOptions.ClientName ??= "stellaops-authority-dpop-nonce";
|
||||
return ConnectionMultiplexer.Connect(redisOptions);
|
||||
});
|
||||
|
||||
builder.Services.TryAddSingleton<IDpopNonceStore>(provider =>
|
||||
{
|
||||
var multiplexer = provider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var timeProvider = provider.GetService<TimeProvider>();
|
||||
return new RedisDpopNonceStore(multiplexer, timeProvider);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.TryAddSingleton<IDpopNonceStore>(provider =>
|
||||
{
|
||||
var timeProvider = provider.GetService<TimeProvider>();
|
||||
var nonceLogger = provider.GetService<ILogger<InMemoryDpopNonceStore>>();
|
||||
return new InMemoryDpopNonceStore(timeProvider, nonceLogger);
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddScoped<ValidateDpopProofHandler>();
|
||||
#endif
|
||||
|
||||
@@ -3418,4 +3391,3 @@ sealed record RouterClaimRequirementEntry
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string? Value { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Messaging.Transport.Valkey;
|
||||
|
||||
namespace StellaOps.Authority.Security;
|
||||
|
||||
public static class AuthorityDpopRuntimeExtensions
|
||||
{
|
||||
public static IServiceCollection AddAuthorityDpopRuntime(
|
||||
this IServiceCollection services,
|
||||
IHostEnvironment environment,
|
||||
AuthoritySenderConstraintOptions senderConstraints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
ArgumentNullException.ThrowIfNull(senderConstraints);
|
||||
|
||||
if (environment.IsEnvironment("Testing"))
|
||||
{
|
||||
services.TryAddSingleton<IDpopReplayCache>(sp => new InMemoryDpopReplayCache(sp.GetService<TimeProvider>()));
|
||||
services.TryAddSingleton<IDpopNonceStore>(sp =>
|
||||
{
|
||||
var timeProvider = sp.GetService<TimeProvider>();
|
||||
var logger = sp.GetService<ILogger<InMemoryDpopNonceStore>>();
|
||||
return new InMemoryDpopNonceStore(timeProvider, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
if (!senderConstraints.Dpop.Enabled)
|
||||
{
|
||||
services.TryAddSingleton<IDpopReplayCache, DisabledDpopReplayCache>();
|
||||
services.TryAddSingleton<IDpopNonceStore, DisabledDpopNonceStore>();
|
||||
return services;
|
||||
}
|
||||
|
||||
if (!string.Equals(senderConstraints.Dpop.Nonce.Store, "redis", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Authority requires Authority:Security:SenderConstraints:Dpop:Nonce:Store=redis outside Testing when DPoP is enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(senderConstraints.Dpop.Nonce.RedisConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Authority requires Authority:Security:SenderConstraints:Dpop:Nonce:RedisConnectionString outside Testing when DPoP is enabled.");
|
||||
}
|
||||
|
||||
services.AddOptions<ValkeyTransportOptions>()
|
||||
.Configure(options =>
|
||||
{
|
||||
options.ConnectionString = senderConstraints.Dpop.Nonce.RedisConnectionString!;
|
||||
options.QueueWaitTimeoutSeconds = 0;
|
||||
});
|
||||
|
||||
services.TryAddSingleton<ValkeyConnectionFactory>();
|
||||
services.TryAddSingleton<IRateLimiterFactory, ValkeyRateLimiterFactory>();
|
||||
services.TryAddSingleton<IAtomicTokenStoreFactory, ValkeyAtomicTokenStoreFactory>();
|
||||
services.TryAddSingleton<IIdempotencyStoreFactory, ValkeyIdempotencyStoreFactory>();
|
||||
|
||||
services.TryAddSingleton<IDpopReplayCache>(sp =>
|
||||
new MessagingDpopReplayCache(
|
||||
sp.GetRequiredService<IIdempotencyStoreFactory>(),
|
||||
sp.GetRequiredService<TimeProvider>()));
|
||||
|
||||
services.TryAddSingleton<IDpopNonceStore>(sp =>
|
||||
new MessagingDpopNonceStore(
|
||||
sp.GetRequiredService<IRateLimiterFactory>().Create("authority:dpop:nonce"),
|
||||
sp.GetRequiredService<IAtomicTokenStoreFactory>().Create<DpopNonceMetadata>("authority:dpop:nonce"),
|
||||
sp.GetRequiredService<TimeProvider>(),
|
||||
sp.GetService<ILogger<MessagingDpopNonceStore>>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private sealed class DisabledDpopReplayCache : IDpopReplayCache
|
||||
{
|
||||
public ValueTask<bool> TryStoreAsync(
|
||||
string jwtId,
|
||||
DateTimeOffset expiresAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DisabledDpopNonceStore : IDpopNonceStore
|
||||
{
|
||||
public ValueTask<DpopNonceIssueResult> IssueAsync(
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
TimeSpan ttl,
|
||||
int maxIssuancePerMinute,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(DpopNonceIssueResult.Failure("dpop_disabled"));
|
||||
}
|
||||
|
||||
public ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
|
||||
string nonce,
|
||||
string audience,
|
||||
string clientId,
|
||||
string keyThumbprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(DpopNonceConsumeResult.NotFound());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration.AuthorityPlugin/StellaOps.Configuration.AuthorityPlugin.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||
<ProjectReference Include="../../../Router/__Libraries/StellaOps.Messaging.Transport.Valkey/StellaOps.Messaging.Transport.Valkey.csproj" />
|
||||
<ProjectReference Include="../../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Audit.Emission/StellaOps.Audit.Emission.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -10,3 +10,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0085-T | DONE | Revalidated 2026-01-06 (coverage reviewed). |
|
||||
| AUDIT-0085-A | TODO | Reopened 2026-01-06: remove Guid.NewGuid/DateTimeOffset.UtcNow, fix branding error messages, and modularize Program.cs. |
|
||||
| TASK-033-008 | DONE | Added BCrypt.Net-Next and updated dependency notices (SPRINT_20260120_033). |
|
||||
| AUTH-DPOP-REAL-001 | DONE | 2026-04-16: non-testing Authority now resolves DPoP replay state through durable Valkey-backed messaging primitives; `Testing` remains explicitly in-memory. |
|
||||
| AUTH-DPOP-REAL-002 | DONE | 2026-04-16: removed non-testing in-memory DPoP nonce fallback; misconfigured live DPoP now fails fast. |
|
||||
| AUTH-DPOP-REAL-003 | DONE | 2026-04-16: added restart-survival proof in `AuthorityDpopRuntimeTests` and synced Authority docs/task boards. |
|
||||
|
||||
Reference in New Issue
Block a user