diff --git a/src/Authority/README.md b/src/Authority/README.md index 112c62393..82b590dd1 100644 --- a/src/Authority/README.md +++ b/src/Authority/README.md @@ -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) diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginBootstrapperTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginBootstrapperTests.cs index e68acd316..6bf90b8c2 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginBootstrapperTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginBootstrapperTests.cs @@ -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("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("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(clientStore); + services.AddSingleton(new StubRevocationStore()); + services.AddSingleton(new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T13:00:00Z"))); + services.AddSingleton(sp => + new StandardClientProvisioningStore( + "standard", + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => + new StandardPluginBootstrapper( + "standard", + sp.GetRequiredService(), + NullLogger.Instance, + retryDelay: TimeSpan.Zero)); + + using var provider = services.BuildServiceProvider(); + var bootstrapper = provider.GetRequiredService(); + + 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 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> 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 DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null) + => inner.DeleteByClientIdAsync(clientId, cancellationToken, session); + } + private sealed class FakeTimeProvider : TimeProvider { private readonly DateTimeOffset fixedNow; diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginOptionsTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginOptionsTests.cs index 527e1f4be..34fbf08fb 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginOptionsTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginOptionsTests.cs @@ -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); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Bootstrap/StandardPluginBootstrapper.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Bootstrap/StandardPluginBootstrapper.cs index 336f5f033..d525b8f39 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Bootstrap/StandardPluginBootstrapper.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard/Bootstrap/StandardPluginBootstrapper.cs @@ -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 logger; - private readonly int maxAttempts; + private readonly int? maxAttempts; private readonly TimeSpan retryDelay; public StandardPluginBootstrapper( string pluginName, IServiceScopeFactory scopeFactory, ILogger 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(); diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Security/AuthorityDpopRuntimeTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Security/AuthorityDpopRuntimeTests.cs new file mode 100644 index 000000000..c404455d5 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/Security/AuthorityDpopRuntimeTests.cs @@ -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(provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public async Task ProductionRuntime_DpopDisabled_StartsWithoutDurableStore() + { + using var provider = CreateServiceProvider( + "Production", + options => options.Dpop.Enabled = false); + + var replayCache = provider.GetRequiredService(); + var nonceStore = provider.GetRequiredService(); + + 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(() => 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(); + var nonceStore = firstProvider.GetRequiredService(); + + Assert.IsType(replayCache); + Assert.IsType(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(); + var nonceStore = restartedProvider.GetRequiredService(); + + Assert.IsType(replayCache); + Assert.IsType(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 configure) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(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 +{ +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj index 1e695984a..8dc307306 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj @@ -18,6 +18,7 @@ + @@ -31,4 +32,4 @@ - \ No newline at end of file + diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/TASKS.md b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/TASKS.md index f76ec37ab..7b77ccbbd 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/TASKS.md +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/TASKS.md @@ -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`). | diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs index 642a60966..d400cdfac 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Program.cs @@ -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() }) .PostConfigure(static options => options.Validate()); -builder.Services.TryAddSingleton(provider => new InMemoryDpopReplayCache(provider.GetService())); +builder.Services.AddAuthorityDpopRuntime(builder.Environment, senderConstraints); builder.Services.TryAddSingleton(); -if (string.Equals(senderConstraints.Dpop.Nonce.Store, "redis", StringComparison.OrdinalIgnoreCase)) -{ - builder.Services.TryAddSingleton(_ => - { - var redisOptions = ConfigurationOptions.Parse(senderConstraints.Dpop.Nonce.RedisConnectionString!); - redisOptions.ClientName ??= "stellaops-authority-dpop-nonce"; - return ConnectionMultiplexer.Connect(redisOptions); - }); - - builder.Services.TryAddSingleton(provider => - { - var multiplexer = provider.GetRequiredService(); - var timeProvider = provider.GetService(); - return new RedisDpopNonceStore(multiplexer, timeProvider); - }); -} -else -{ - builder.Services.TryAddSingleton(provider => - { - var timeProvider = provider.GetService(); - var nonceLogger = provider.GetService>(); - return new InMemoryDpopNonceStore(timeProvider, nonceLogger); - }); -} - builder.Services.AddScoped(); #endif @@ -3418,4 +3391,3 @@ sealed record RouterClaimRequirementEntry public string Type { get; init; } = string.Empty; public string? Value { get; init; } } - diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Security/AuthorityDpopRuntimeExtensions.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Security/AuthorityDpopRuntimeExtensions.cs new file mode 100644 index 000000000..6ab5917a3 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Security/AuthorityDpopRuntimeExtensions.cs @@ -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(sp => new InMemoryDpopReplayCache(sp.GetService())); + services.TryAddSingleton(sp => + { + var timeProvider = sp.GetService(); + var logger = sp.GetService>(); + return new InMemoryDpopNonceStore(timeProvider, logger); + }); + + return services; + } + + if (!senderConstraints.Dpop.Enabled) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + 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() + .Configure(options => + { + options.ConnectionString = senderConstraints.Dpop.Nonce.RedisConnectionString!; + options.QueueWaitTimeoutSeconds = 0; + }); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddSingleton(sp => + new MessagingDpopReplayCache( + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.TryAddSingleton(sp => + new MessagingDpopNonceStore( + sp.GetRequiredService().Create("authority:dpop:nonce"), + sp.GetRequiredService().Create("authority:dpop:nonce"), + sp.GetRequiredService(), + sp.GetService>())); + + return services; + } + + private sealed class DisabledDpopReplayCache : IDpopReplayCache + { + public ValueTask TryStoreAsync( + string jwtId, + DateTimeOffset expiresAt, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jwtId); + return ValueTask.FromResult(true); + } + } + + private sealed class DisabledDpopNonceStore : IDpopNonceStore + { + public ValueTask IssueAsync( + string audience, + string clientId, + string keyThumbprint, + TimeSpan ttl, + int maxIssuancePerMinute, + CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(DpopNonceIssueResult.Failure("dpop_disabled")); + } + + public ValueTask TryConsumeAsync( + string nonce, + string audience, + string clientId, + string keyThumbprint, + CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(DpopNonceConsumeResult.NotFound()); + } + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj b/src/Authority/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj index 65db5fe2f..07f21d967 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj @@ -35,6 +35,7 @@ + diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/TASKS.md b/src/Authority/StellaOps.Authority/StellaOps.Authority/TASKS.md index d74ad620c..aa6ff996d 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/TASKS.md +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/TASKS.md @@ -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. |