feat: Enhance Authority Identity Provider Registry with Bootstrap Capability

- Added support for bootstrap providers in AuthorityIdentityProviderRegistry.
- Introduced a new property for bootstrap providers and updated AggregateCapabilities.
- Updated relevant methods to handle bootstrap capabilities during provider registration.

feat: Introduce Sealed Mode Status in OpenIddict Handlers

- Added SealedModeStatusProperty to AuthorityOpenIddictConstants.
- Enhanced ValidateClientCredentialsHandler, ValidatePasswordGrantHandler, and ValidateRefreshTokenGrantHandler to validate sealed mode evidence.
- Implemented logic to handle airgap seal confirmation requirements.

feat: Update Program Configuration for Sealed Mode

- Registered IAuthoritySealedModeEvidenceValidator in Program.cs.
- Added logging for bootstrap capabilities in identity provider plugins.
- Implemented checks for bootstrap support in API endpoints.

chore: Update Tasks and Documentation

- Marked AUTH-MTLS-11-002 as DONE in TASKS.md.
- Updated documentation to reflect changes in sealed mode and bootstrap capabilities.

fix: Improve CLI Command Handlers Output

- Enhanced output formatting for command responses and prompts in CommandHandlers.cs.

feat: Extend Advisory AI Models

- Added Response property to AdvisoryPipelineOutputModel for better output handling.

fix: Adjust Concelier Web Service Authentication

- Improved JWT token handling in Concelier Web Service to ensure proper token extraction and logging.

test: Enhance Web Service Endpoints Tests

- Added detailed logging for authentication failures in WebServiceEndpointsTests.
- Enabled PII logging for better debugging of authentication issues.

feat: Introduce Air-Gap Configuration Options

- Added AuthorityAirGapOptions and AuthoritySealedModeOptions to StellaOpsAuthorityOptions.
- Implemented validation logic for air-gap configurations to ensure proper setup.
This commit is contained in:
master
2025-11-09 12:18:14 +02:00
parent d71c81e45d
commit ba4c935182
68 changed files with 2142 additions and 291 deletions

View File

@@ -55,14 +55,24 @@ public class StandardPluginRegistrarTests
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityLoginAttemptStore>(sp =>
{
var mongo = sp.GetRequiredService<IMongoDatabase>();
var collection = mongo.GetCollection<AuthorityLoginAttemptDocument>("authority_login_attempts");
return new AuthorityLoginAttemptStore(collection, NullLogger<AuthorityLoginAttemptStore>.Instance);
});
services.AddSingleton<IAuthorityLoginAttemptStore>(sp =>
{
var mongo = sp.GetRequiredService<IMongoDatabase>();
var collection = mongo.GetCollection<AuthorityLoginAttemptDocument>("authority_login_attempts");
return new AuthorityLoginAttemptStore(collection, NullLogger<AuthorityLoginAttemptStore>.Instance);
});
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
@@ -91,7 +101,9 @@ public class StandardPluginRegistrarTests
using var scope = provider.CreateScope();
var plugin = scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>();
Assert.Equal("standard", plugin.Type);
Assert.True(plugin.Capabilities.SupportsPassword);
Assert.True(plugin.Capabilities.SupportsPassword);
Assert.True(plugin.Capabilities.SupportsBootstrap);
Assert.True(plugin.Capabilities.SupportsClientProvisioning);
Assert.False(plugin.Capabilities.SupportsMfa);
Assert.True(plugin.Capabilities.SupportsClientProvisioning);
@@ -181,7 +193,9 @@ public class StandardPluginRegistrarTests
using var scope = provider.CreateScope();
var plugin = scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>();
Assert.True(plugin.Capabilities.SupportsPassword);
Assert.True(plugin.Capabilities.SupportsPassword);
Assert.True(plugin.Capabilities.SupportsBootstrap);
Assert.True(plugin.Capabilities.SupportsClientProvisioning);
}
[Fact]

View File

@@ -19,6 +19,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
private readonly IMongoDatabase database;
private readonly StandardPluginOptions options;
private readonly StandardUserCredentialStore store;
private readonly TestAuditLogger auditLogger;
public StandardUserCredentialStoreTests()
{
@@ -50,17 +51,20 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
}
};
var cryptoProvider = new DefaultCryptoProvider();
auditLogger = new TestAuditLogger();
store = new StandardUserCredentialStore(
"standard",
database,
options,
new CryptoPasswordHasher(options, cryptoProvider),
auditLogger,
NullLogger<StandardUserCredentialStore>.Instance);
}
[Fact]
public async Task VerifyPasswordAsync_ReturnsSuccess_ForValidCredentials()
{
auditLogger.Reset();
var registration = new AuthorityUserRegistration(
"alice",
"Password1!",
@@ -77,11 +81,17 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.True(result.Succeeded);
Assert.Equal("alice", result.User?.Username);
Assert.Empty(result.AuditProperties);
var auditEntry = Assert.Single(auditLogger.Events);
Assert.Equal("alice", auditEntry.Username);
Assert.True(auditEntry.Success);
Assert.Null(auditEntry.FailureCode);
}
[Fact]
public async Task VerifyPasswordAsync_EnforcesLockout_AfterRepeatedFailures()
{
auditLogger.Reset();
await store.UpsertUserAsync(
new AuthorityUserRegistration(
"bob",
@@ -103,11 +113,18 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.NotNull(second.RetryAfter);
Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero);
Assert.Contains(second.AuditProperties, property => property.Name == "plugin.lockout_until");
Assert.Equal(2, auditLogger.Events.Count);
Assert.False(auditLogger.Events[0].Success);
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, auditLogger.Events[0].FailureCode);
Assert.False(auditLogger.Events[1].Success);
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, auditLogger.Events[1].FailureCode);
}
[Fact]
public async Task VerifyPasswordAsync_RehashesLegacyHashesToArgon2()
{
auditLogger.Reset();
var legacyHash = new Pbkdf2PasswordHasher().Hash(
"Legacy1!",
new PasswordHashOptions
@@ -136,6 +153,10 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.Equal("legacy", result.User?.Username);
Assert.Contains(result.AuditProperties, property => property.Name == "plugin.rehashed");
var auditEntry = Assert.Single(auditLogger.Events);
Assert.True(auditEntry.Success);
Assert.Equal("legacy", auditEntry.Username);
var updated = await database.GetCollection<StandardUserDocument>("authority_users_standard")
.Find(u => u.NormalizedUsername == "legacy")
.FirstOrDefaultAsync();
@@ -144,6 +165,23 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);
}
[Fact]
public async Task VerifyPasswordAsync_RecordsAudit_ForUnknownUser()
{
auditLogger.Reset();
var result = await store.VerifyPasswordAsync("unknown", "bad", CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, result.FailureCode);
var auditEntry = Assert.Single(auditLogger.Events);
Assert.Equal("unknown", auditEntry.Username);
Assert.False(auditEntry.Success);
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, auditEntry.FailureCode);
Assert.Equal("Invalid credentials.", auditEntry.Reason);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
@@ -152,3 +190,28 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
return Task.CompletedTask;
}
}
internal sealed class TestAuditLogger : IStandardCredentialAuditLogger
{
private readonly List<AuditEntry> events = new();
public IReadOnlyList<AuditEntry> Events => events;
public void Reset() => events.Clear();
public ValueTask RecordAsync(
string pluginName,
string normalizedUsername,
string? subjectId,
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> properties,
CancellationToken cancellationToken)
{
events.Add(new AuditEntry(normalizedUsername, success, failureCode, reason));
return ValueTask.CompletedTask;
}
internal sealed record AuditEntry(string Username, bool Success, AuthorityCredentialFailureCode? FailureCode, string? Reason);
}

View File

@@ -0,0 +1,132 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Security;
internal interface IStandardCredentialAuditLogger
{
ValueTask RecordAsync(
string pluginName,
string normalizedUsername,
string? subjectId,
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> properties,
CancellationToken cancellationToken);
}
internal sealed class StandardCredentialAuditLogger : IStandardCredentialAuditLogger
{
private const string EventType = "authority.plugin.standard.password_verification";
private readonly IAuthorityLoginAttemptStore loginAttemptStore;
private readonly TimeProvider timeProvider;
private readonly ILogger<StandardCredentialAuditLogger> logger;
public StandardCredentialAuditLogger(
IAuthorityLoginAttemptStore loginAttemptStore,
TimeProvider timeProvider,
ILogger<StandardCredentialAuditLogger> logger)
{
this.loginAttemptStore = loginAttemptStore ?? throw new ArgumentNullException(nameof(loginAttemptStore));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask RecordAsync(
string pluginName,
string normalizedUsername,
string? subjectId,
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> properties,
CancellationToken cancellationToken)
{
try
{
var document = new AuthorityLoginAttemptDocument
{
EventType = EventType,
Outcome = NormalizeOutcome(success, failureCode),
SubjectId = Normalize(subjectId),
Username = Normalize(normalizedUsername),
Plugin = pluginName,
Successful = success,
Reason = Normalize(reason),
OccurredAt = timeProvider.GetUtcNow()
};
if (properties.Count > 0)
{
document.Properties = ConvertProperties(properties);
}
await loginAttemptStore.InsertAsync(document, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to record credential audit event for plugin {PluginName}.", pluginName);
}
}
private static string NormalizeOutcome(bool success, AuthorityCredentialFailureCode? failureCode)
{
if (success)
{
return "success";
}
return failureCode switch
{
AuthorityCredentialFailureCode.LockedOut => "locked_out",
AuthorityCredentialFailureCode.RequiresMfa => "requires_mfa",
AuthorityCredentialFailureCode.RequiresPasswordReset => "requires_password_reset",
AuthorityCredentialFailureCode.PasswordExpired => "password_expired",
_ => "failure"
};
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static List<AuthorityLoginAttemptPropertyDocument> ConvertProperties(
IReadOnlyList<AuthEventProperty> properties)
{
if (properties.Count == 0)
{
return new List<AuthorityLoginAttemptPropertyDocument>();
}
var documents = new List<AuthorityLoginAttemptPropertyDocument>(properties.Count);
foreach (var property in properties)
{
if (property is null || string.IsNullOrWhiteSpace(property.Name) || !property.Value.HasValue)
{
continue;
}
documents.Add(new AuthorityLoginAttemptPropertyDocument
{
Name = property.Name,
Value = property.Value.Value,
Classification = NormalizeClassification(property.Value.Classification)
});
}
return documents;
}
private static string NormalizeClassification(AuthEventDataClassification classification)
=> classification switch
{
AuthEventDataClassification.Personal => "personal",
AuthEventDataClassification.Sensitive => "sensitive",
_ => "none"
};
}

View File

@@ -32,7 +32,26 @@ internal sealed class StandardIdentityProviderPlugin : IIdentityProviderPlugin
Context.Manifest.Name);
}
Capabilities = manifestCapabilities with { SupportsPassword = true };
if (!manifestCapabilities.SupportsBootstrap)
{
this.logger.LogWarning(
"Standard Authority plugin '{PluginName}' manifest does not declare the 'bootstrap' capability. Forcing bootstrap support.",
Context.Manifest.Name);
}
if (!manifestCapabilities.SupportsClientProvisioning)
{
this.logger.LogWarning(
"Standard Authority plugin '{PluginName}' manifest does not declare the 'clientProvisioning' capability. Forcing client provisioning support.",
Context.Manifest.Name);
}
Capabilities = manifestCapabilities with
{
SupportsPassword = true,
SupportsBootstrap = true,
SupportsClientProvisioning = true
};
}
public string Name => Context.Manifest.Name;

View File

@@ -27,12 +27,12 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
var pluginName = context.Plugin.Manifest.Name;
context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddOptions<StandardPluginOptions>(pluginName)
.Bind(context.Plugin.Configuration)
@@ -43,18 +43,21 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
})
.ValidateOnStart();
context.Services.AddScoped(sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
context.Services.AddScoped(sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
{
registrarLogger.LogWarning(
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
@@ -70,14 +73,15 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
baselinePolicy.RequireDigit,
baselinePolicy.RequireSymbol);
}
return new StandardUserCredentialStore(
pluginName,
database,
pluginOptions,
passwordHasher,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
return new StandardUserCredentialStore(
pluginName,
database,
pluginOptions,
passwordHasher,
auditLogger,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
context.Services.AddScoped(sp =>
{

View File

@@ -18,6 +18,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
private readonly IMongoCollection<StandardUserDocument> users;
private readonly StandardPluginOptions options;
private readonly IPasswordHasher passwordHasher;
private readonly IStandardCredentialAuditLogger auditLogger;
private readonly ILogger<StandardUserCredentialStore> logger;
private readonly string pluginName;
@@ -26,11 +27,13 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
IMongoDatabase database,
StandardPluginOptions options,
IPasswordHasher passwordHasher,
IStandardCredentialAuditLogger auditLogger,
ILogger<StandardUserCredentialStore> logger)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher));
this.auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(database);
@@ -60,6 +63,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
if (user is null)
{
logger.LogWarning("Plugin {PluginName} failed password verification for unknown user {Username}.", pluginName, normalized);
await RecordAuditAsync(
normalized,
subjectId: null,
success: false,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
reason: "Invalid credentials.",
auditProperties,
cancellationToken).ConfigureAwait(false);
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
}
@@ -73,6 +84,15 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
Value = ClassifiedString.Public(lockoutEnd.ToString("O", CultureInfo.InvariantCulture))
});
await RecordAuditAsync(
normalized,
user.SubjectId,
success: false,
failureCode: AuthorityCredentialFailureCode.LockedOut,
reason: "Account is temporarily locked.",
auditProperties,
cancellationToken).ConfigureAwait(false);
return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.LockedOut,
"Account is temporarily locked.",
@@ -111,6 +131,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
}
var descriptor = ToDescriptor(user);
await RecordAuditAsync(
normalized,
descriptor.SubjectId,
success: true,
failureCode: null,
reason: descriptor.RequiresPasswordReset ? "Password reset required." : null,
auditProperties,
cancellationToken).ConfigureAwait(false);
return AuthorityCredentialVerificationResult.Success(
descriptor,
descriptor.RequiresPasswordReset ? "Password reset required." : null,
@@ -142,6 +170,15 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
});
}
await RecordAuditAsync(
normalized,
user.SubjectId,
success: false,
failureCode: code,
reason: code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.",
auditProperties,
cancellationToken).ConfigureAwait(false);
return AuthorityCredentialVerificationResult.Failure(
code,
code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.",
@@ -371,4 +408,24 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
logger.LogDebug("Plugin {PluginName} skipped index creation due to existing index.", pluginName);
}
}
private async ValueTask RecordAuditAsync(
string normalizedUsername,
string? subjectId,
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> auditProperties,
CancellationToken cancellationToken)
{
await auditLogger.RecordAsync(
pluginName,
normalizedUsername,
subjectId,
success,
failureCode,
reason,
auditProperties,
cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -2,10 +2,10 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SEC2.PLG | BLOCKED (2025-10-21) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. <br>⛔ Waiting on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 to stabilise Authority auth surfaces (PLUGIN-DI-08-001 landed 2025-10-21). | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
| SEC2.PLG | DOING (2025-11-08) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
| SEC3.PLG | BLOCKED (2025-10-21) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). <br>⛔ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002; PLUGIN-DI-08-001 is done, limiter telemetry just awaits the updated Authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
| SEC5.PLG | BLOCKED (2025-10-21) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. <br>⛔ Final documentation now hinges on AUTH-DPOP-11-001 / AUTH-MTLS-11-002; scoped DI work is complete. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
| PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | BE-Auth Plugin, Docs Guild | PLG1PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. <br>⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. |
| PLG4-6.CAPABILITIES | DONE (2025-11-08) | BE-Auth Plugin, Docs Guild | PLG1PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. |
| PLG7.RFC | DONE (2025-11-03) | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
| PLG7.IMPL-001 | DONE (2025-11-03) | BE-Auth Plugin | PLG7.RFC | Scaffold `StellaOps.Authority.Plugin.Ldap` + tests, bind configuration (client certificate, trust-store, insecure toggle) with validation and docs samples. | ✅ Project + test harness build; ✅ Configuration bound & validated; ✅ Sample config updated. |
| PLG7.IMPL-002 | DONE (2025-11-04) | BE-Auth Plugin, Security Guild | PLG7.IMPL-001 | Implement LDAP credential store with TLS/mutual TLS enforcement, deterministic retry/backoff, and structured logging/metrics. | ✅ Credential store passes integration tests (OpenLDAP + mtls); ✅ Metrics/logs emitted; ✅ Error mapping documented.<br>2025-11-04: DirectoryServices factory now enforces TLS/mTLS options, credential store retries use deterministic backoff with metrics, audit logging includes failure codes, and unit suite (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests`) remains green. |
@@ -19,6 +19,8 @@
> 2025-11-03: PLG7.IMPL-001 completed created `StellaOps.Authority.Plugin.Ldap` + tests projects, implemented configuration normalization/validation (client certificate, trust store, insecure toggle) with coverage (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj`), and refreshed `etc/authority.plugins/ldap.yaml`.
> 2025-11-04: PLG7.IMPL-002 progress StartTLS initialization now uses `StartTransportLayerSecurity(null)` and LDAP result-code handling normalized for `System.DirectoryServices.Protocols` 8.0 (invalid credentials + transient cases). Plugin tests rerun via `dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj` (green).
> 2025-11-04: PLG7.IMPL-002 progress enforced TLS/client certificate validation, added structured audit properties and retry logging for credential lookups, warned on unsupported cipher lists, updated sample config, and reran `dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj --no-restore`.
> 2025-11-08: PLG4-6.CAPABILITIES completed added the `bootstrap` capability flag, extended registries/logs/docs, and gated bootstrap APIs on the new capability (`dotnet test` suites for plugins + Authority core all green).
> 2025-11-08: SEC2.PLG resumed Standard plugin now records password verification outcomes via `StandardCredentialAuditLogger`, persisting events to `IAuthorityLoginAttemptStore`; unit tests cover success/lockout/failure flows (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj --no-build`).
> Update statuses to DOING/DONE/BLOCKED as you make progress. Always run `dotnet test` for touched projects before marking DONE.

View File

@@ -12,12 +12,14 @@ public class AuthorityIdentityProviderCapabilitiesTests
{
"password",
"mfa",
"clientProvisioning"
"clientProvisioning",
"bootstrap"
});
Assert.True(capabilities.SupportsPassword);
Assert.True(capabilities.SupportsMfa);
Assert.True(capabilities.SupportsClientProvisioning);
Assert.True(capabilities.SupportsBootstrap);
}
[Fact]
@@ -28,6 +30,7 @@ public class AuthorityIdentityProviderCapabilitiesTests
Assert.False(capabilities.SupportsPassword);
Assert.False(capabilities.SupportsMfa);
Assert.False(capabilities.SupportsClientProvisioning);
Assert.False(capabilities.SupportsBootstrap);
}
[Fact]
@@ -38,5 +41,6 @@ public class AuthorityIdentityProviderCapabilitiesTests
Assert.False(capabilities.SupportsPassword);
Assert.False(capabilities.SupportsMfa);
Assert.False(capabilities.SupportsClientProvisioning);
Assert.False(capabilities.SupportsBootstrap);
}
}

View File

@@ -112,15 +112,20 @@ public interface IAuthorityIdentityProviderRegistry
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders { get; }
/// <summary>
/// Gets metadata for identity providers that advertise client provisioning support.
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders { get; }
/// <summary>
/// Aggregate capability flags across all registered providers.
/// </summary>
AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
/// <summary>
/// Gets metadata for identity providers that advertise client provisioning support.
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders { get; }
/// <summary>
/// Gets metadata for identity providers that advertise bootstrap flows (user/client).
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> BootstrapProviders { get; }
/// <summary>
/// Aggregate capability flags across all registered providers.
/// </summary>
AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
/// <summary>
/// Attempts to resolve identity provider metadata by name.

View File

@@ -12,11 +12,12 @@ namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Describes feature support advertised by an identity provider plugin.
/// </summary>
public sealed record AuthorityIdentityProviderCapabilities(
bool SupportsPassword,
bool SupportsMfa,
bool SupportsClientProvisioning)
{
public sealed record AuthorityIdentityProviderCapabilities(
bool SupportsPassword,
bool SupportsMfa,
bool SupportsClientProvisioning,
bool SupportsBootstrap)
{
/// <summary>
/// Builds capabilities metadata from a list of capability identifiers.
/// </summary>
@@ -24,7 +25,7 @@ public sealed record AuthorityIdentityProviderCapabilities(
{
if (capabilities is null)
{
return new AuthorityIdentityProviderCapabilities(false, false, false);
return new AuthorityIdentityProviderCapabilities(false, false, false, false);
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -38,11 +39,12 @@ public sealed record AuthorityIdentityProviderCapabilities(
seen.Add(entry.Trim());
}
return new AuthorityIdentityProviderCapabilities(
SupportsPassword: seen.Contains(AuthorityPluginCapabilities.Password),
SupportsMfa: seen.Contains(AuthorityPluginCapabilities.Mfa),
SupportsClientProvisioning: seen.Contains(AuthorityPluginCapabilities.ClientProvisioning));
}
return new AuthorityIdentityProviderCapabilities(
SupportsPassword: seen.Contains(AuthorityPluginCapabilities.Password),
SupportsMfa: seen.Contains(AuthorityPluginCapabilities.Mfa),
SupportsClientProvisioning: seen.Contains(AuthorityPluginCapabilities.ClientProvisioning),
SupportsBootstrap: seen.Contains(AuthorityPluginCapabilities.Bootstrap));
}
}
/// <summary>

View File

@@ -0,0 +1,146 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Configuration;
using Xunit;
namespace StellaOps.Authority.Tests.Airgap;
public class AuthoritySealedModeEvidenceValidatorTests
{
[Fact]
public async Task ValidateAsync_ReturnsSuccess_WhenEvidenceFreshAndPassing()
{
using var temp = new TempDirectory();
var evidencePath = Path.Combine(temp.Path, "authority-sealed-ci.json");
WriteEvidence(evidencePath, DateTimeOffset.UtcNow, "pass", "pass", "pass", "pass");
var validator = CreateValidator(temp.Path, evidencePath, TimeSpan.FromHours(4));
var result = await validator.ValidateAsync(CancellationToken.None);
Assert.True(result.IsSatisfied);
Assert.NotNull(result.EvidenceTimestamp);
Assert.Equal(evidencePath, result.EvidencePath);
}
[Fact]
public async Task ValidateAsync_ReturnsFailure_WhenEvidenceMissing()
{
using var temp = new TempDirectory();
var evidencePath = Path.Combine(temp.Path, "missing.json");
var validator = CreateValidator(temp.Path, evidencePath, TimeSpan.FromHours(1));
var result = await validator.ValidateAsync(CancellationToken.None);
Assert.False(result.IsSatisfied);
Assert.Equal("evidence_missing", result.FailureCode);
}
[Fact]
public async Task ValidateAsync_ReturnsFailure_WhenEvidenceStale()
{
using var temp = new TempDirectory();
var evidencePath = Path.Combine(temp.Path, "authority-sealed-ci.json");
var timestamp = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromHours(2));
WriteEvidence(evidencePath, timestamp, "pass", "pass", "pass", "pass");
var validator = CreateValidator(temp.Path, evidencePath, TimeSpan.FromMinutes(30));
var result = await validator.ValidateAsync(CancellationToken.None);
Assert.False(result.IsSatisfied);
Assert.Equal("evidence_stale", result.FailureCode);
}
private static AuthoritySealedModeEvidenceValidator CreateValidator(string contentRoot, string evidencePath, TimeSpan maxAge)
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.AirGap.SealedMode.EnforcementEnabled = true;
options.AirGap.SealedMode.EvidencePath = evidencePath;
options.AirGap.SealedMode.MaxEvidenceAge = maxAge;
options.AirGap.SealedMode.CacheLifetime = TimeSpan.FromMilliseconds(10);
var memoryCache = new MemoryCache(new MemoryCacheOptions());
var hostEnvironment = new TestHostEnvironment(contentRoot);
var timeProvider = TimeProvider.System;
return new AuthoritySealedModeEvidenceValidator(
options,
memoryCache,
hostEnvironment,
timeProvider,
NullLogger<AuthoritySealedModeEvidenceValidator>.Instance);
}
private static void WriteEvidence(
string path,
DateTimeOffset timestamp,
string authorityStatus,
string signerStatus,
string attestorStatus,
string egressStatus)
{
var json = new
{
timestamp = timestamp.ToUniversalTime().ToString("O"),
project = "sealedmode",
network = "sealed",
health = new
{
authority = new { status = authorityStatus, url = "http://127.0.0.1:5088/healthz", log = "authority" },
signer = new { status = signerStatus, url = "http://127.0.0.1:6088/healthz", log = "signer" },
attestor = new { status = attestorStatus, url = "http://127.0.0.1:7088/healthz", log = "attestor" }
},
egressProbe = new { status = egressStatus, report = "egress-probe.json" }
};
File.WriteAllText(path, JsonSerializer.Serialize(json));
}
private sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "authority-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
// Best effort cleanup.
}
}
}
private sealed class TestHostEnvironment : IHostEnvironment
{
public TestHostEnvironment(string contentRoot)
{
ContentRootPath = contentRoot;
}
public string ApplicationName { get; set; } = "tests";
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
public string ContentRootPath { get; set; }
public string EnvironmentName { get; set; } = Environments.Development;
}
}

View File

@@ -16,11 +16,11 @@ public class AuthorityIdentityProviderRegistryTests
[Fact]
public async Task RegistryIndexesProvidersAndAggregatesCapabilities()
{
var providers = new[]
{
CreateProvider("standard", type: "standard", supportsPassword: true, supportsMfa: false, supportsClientProvisioning: false),
CreateProvider("sso", type: "saml", supportsPassword: false, supportsMfa: true, supportsClientProvisioning: true)
};
var providers = new[]
{
CreateProvider("standard", type: "standard", supportsPassword: true, supportsMfa: false, supportsClientProvisioning: false, supportsBootstrap: true),
CreateProvider("sso", type: "saml", supportsPassword: false, supportsMfa: true, supportsClientProvisioning: true)
};
using var serviceProvider = BuildServiceProvider(providers);
var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
@@ -29,11 +29,13 @@ public class AuthorityIdentityProviderRegistryTests
Assert.True(registry.TryGet("standard", out var standard));
Assert.Equal("standard", standard!.Name);
Assert.Single(registry.PasswordProviders);
Assert.Single(registry.MfaProviders);
Assert.Single(registry.ClientProvisioningProviders);
Assert.True(registry.AggregateCapabilities.SupportsPassword);
Assert.True(registry.AggregateCapabilities.SupportsMfa);
Assert.True(registry.AggregateCapabilities.SupportsClientProvisioning);
Assert.Single(registry.MfaProviders);
Assert.Single(registry.ClientProvisioningProviders);
Assert.Single(registry.BootstrapProviders);
Assert.True(registry.AggregateCapabilities.SupportsPassword);
Assert.True(registry.AggregateCapabilities.SupportsMfa);
Assert.True(registry.AggregateCapabilities.SupportsClientProvisioning);
Assert.True(registry.AggregateCapabilities.SupportsBootstrap);
await using var handle = await registry.AcquireAsync("standard", default);
Assert.Same(providers[0], handle.Provider);
@@ -101,33 +103,34 @@ public class AuthorityIdentityProviderRegistryTests
return services.BuildServiceProvider();
}
private static IIdentityProviderPlugin CreateProvider(
string name,
string type,
bool supportsPassword,
bool supportsMfa,
bool supportsClientProvisioning)
{
var manifest = new AuthorityPluginManifest(
name,
type,
true,
AssemblyName: null,
AssemblyPath: null,
Capabilities: BuildCapabilities(supportsPassword, supportsMfa, supportsClientProvisioning),
Metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
ConfigPath: string.Empty);
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
return new TestIdentityProviderPlugin(context, supportsPassword, supportsMfa, supportsClientProvisioning);
}
private static IReadOnlyList<string> BuildCapabilities(bool password, bool mfa, bool clientProvisioning)
{
var capabilities = new List<string>();
if (password)
{
capabilities.Add(AuthorityPluginCapabilities.Password);
private static IIdentityProviderPlugin CreateProvider(
string name,
string type,
bool supportsPassword,
bool supportsMfa,
bool supportsClientProvisioning,
bool supportsBootstrap = false)
{
var manifest = new AuthorityPluginManifest(
name,
type,
true,
AssemblyName: null,
AssemblyPath: null,
Capabilities: BuildCapabilities(supportsPassword, supportsMfa, supportsClientProvisioning, supportsBootstrap),
Metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
ConfigPath: string.Empty);
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
return new TestIdentityProviderPlugin(context, supportsPassword, supportsMfa, supportsClientProvisioning, supportsBootstrap);
}
private static IReadOnlyList<string> BuildCapabilities(bool password, bool mfa, bool clientProvisioning, bool bootstrap)
{
var capabilities = new List<string>();
if (password)
{
capabilities.Add(AuthorityPluginCapabilities.Password);
}
if (mfa)
@@ -135,28 +138,35 @@ public class AuthorityIdentityProviderRegistryTests
capabilities.Add(AuthorityPluginCapabilities.Mfa);
}
if (clientProvisioning)
{
capabilities.Add(AuthorityPluginCapabilities.ClientProvisioning);
}
return capabilities;
}
private sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin
{
public TestIdentityProviderPlugin(
AuthorityPluginContext context,
bool supportsPassword,
bool supportsMfa,
bool supportsClientProvisioning)
{
Context = context;
Capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: supportsPassword,
SupportsMfa: supportsMfa,
SupportsClientProvisioning: supportsClientProvisioning);
}
if (clientProvisioning)
{
capabilities.Add(AuthorityPluginCapabilities.ClientProvisioning);
}
if (bootstrap)
{
capabilities.Add(AuthorityPluginCapabilities.Bootstrap);
}
return capabilities;
}
private sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin
{
public TestIdentityProviderPlugin(
AuthorityPluginContext context,
bool supportsPassword,
bool supportsMfa,
bool supportsClientProvisioning,
bool supportsBootstrap)
{
Context = context;
Capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: supportsPassword,
SupportsMfa: supportsMfa,
SupportsClientProvisioning: supportsClientProvisioning,
SupportsBootstrap: supportsBootstrap);
}
public string Name => Context.Manifest.Name;
@@ -178,15 +188,16 @@ public class AuthorityIdentityProviderRegistryTests
private sealed class ScopedIdentityProviderPlugin : IIdentityProviderPlugin
{
public ScopedIdentityProviderPlugin(AuthorityPluginContext context)
{
Context = context;
InstanceId = Guid.NewGuid();
Capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: true,
SupportsMfa: false,
SupportsClientProvisioning: false);
}
public ScopedIdentityProviderPlugin(AuthorityPluginContext context)
{
Context = context;
InstanceId = Guid.NewGuid();
Capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: true,
SupportsMfa: false,
SupportsClientProvisioning: false,
SupportsBootstrap: false);
}
public Guid InstanceId { get; }

View File

@@ -96,14 +96,15 @@ public class AuthorityIdentityProviderSelectorTests
private sealed class SelectorTestProvider : IIdentityProviderPlugin
{
public SelectorTestProvider(AuthorityPluginContext context, bool supportsPassword)
{
Context = context;
Capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: supportsPassword,
SupportsMfa: false,
SupportsClientProvisioning: false);
}
public SelectorTestProvider(AuthorityPluginContext context, bool supportsPassword)
{
Context = context;
Capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: supportsPassword,
SupportsMfa: false,
SupportsClientProvisioning: false,
SupportsBootstrap: false);
}
public string Name => Context.Manifest.Name;

View File

@@ -4623,7 +4623,11 @@ public class ObservabilityIncidentTokenHandlerTests
[Fact]
public async Task ValidateRefreshTokenHandler_RejectsObsIncidentScope()
{
var handler = new ValidateRefreshTokenGrantHandler(NullLogger<ValidateRefreshTokenGrantHandler>.Instance);
var clientStore = new TestClientStore(CreateClient());
var handler = new ValidateRefreshTokenGrantHandler(
clientStore,
new NoopCertificateValidator(),
NullLogger<ValidateRefreshTokenGrantHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
@@ -5139,7 +5143,8 @@ internal static class TestHelpers
new AuthorityIdentityProviderCapabilities(
SupportsPassword: true,
SupportsMfa: false,
SupportsClientProvisioning: supportsClientProvisioning));
SupportsClientProvisioning: supportsClientProvisioning,
SupportsBootstrap: false));
}
public static AuthorityIdentityProviderRegistry CreateRegistryFromPlugins(params IIdentityProviderPlugin[] plugins)

View File

@@ -733,7 +733,7 @@ public class PasswordGrantHandlersTests
Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
Credentials = store;
ClaimsEnricher = new NoopClaimsEnricher();
Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: supportsMfa, SupportsClientProvisioning: false);
Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: supportsMfa, SupportsClientProvisioning: false, SupportsBootstrap: false);
}
public string Name { get; }

View File

@@ -0,0 +1,269 @@
using System;
using System.Globalization;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Configuration;
namespace StellaOps.Authority.Airgap;
internal interface IAuthoritySealedModeEvidenceValidator
{
ValueTask<AuthoritySealedModeValidationResult> ValidateAsync(CancellationToken cancellationToken);
}
internal sealed record AuthoritySealedModeValidationResult(
bool IsSatisfied,
string? FailureCode,
string? FailureDescription,
DateTimeOffset? EvidenceTimestamp,
string? EvidencePath)
{
public static AuthoritySealedModeValidationResult Success(DateTimeOffset? timestamp, string? path)
=> new(true, null, null, timestamp, path);
public static AuthoritySealedModeValidationResult Failure(string failureCode, string failureDescription, string? path)
=> new(false, failureCode, failureDescription, null, path);
}
internal sealed class AuthoritySealedModeEvidenceValidator : IAuthoritySealedModeEvidenceValidator
{
private readonly StellaOpsAuthorityOptions options;
private readonly IMemoryCache memoryCache;
private readonly IHostEnvironment hostEnvironment;
private readonly TimeProvider timeProvider;
private readonly ILogger<AuthoritySealedModeEvidenceValidator> logger;
public AuthoritySealedModeEvidenceValidator(
StellaOpsAuthorityOptions options,
IMemoryCache memoryCache,
IHostEnvironment hostEnvironment,
TimeProvider timeProvider,
ILogger<AuthoritySealedModeEvidenceValidator> logger)
{
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
this.hostEnvironment = hostEnvironment ?? throw new ArgumentNullException(nameof(hostEnvironment));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask<AuthoritySealedModeValidationResult> ValidateAsync(CancellationToken cancellationToken)
{
var sealedOptions = options.AirGap.SealedMode;
if (!sealedOptions.EnforcementEnabled)
{
return AuthoritySealedModeValidationResult.Success(null, null);
}
var cacheKey = $"authority:sealed-mode:{sealedOptions.EvidencePath}";
if (memoryCache.TryGetValue(cacheKey, out AuthoritySealedModeValidationResult cached))
{
return cached;
}
var result = await LoadEvidenceAsync(sealedOptions, cancellationToken).ConfigureAwait(false);
var cacheLifetime = sealedOptions.CacheLifetime <= TimeSpan.Zero
? TimeSpan.FromSeconds(30)
: sealedOptions.CacheLifetime;
memoryCache.Set(cacheKey, result, cacheLifetime);
return result;
}
private async Task<AuthoritySealedModeValidationResult> LoadEvidenceAsync(
AuthoritySealedModeOptions sealedOptions,
CancellationToken cancellationToken)
{
var evidencePath = ResolveEvidencePath(sealedOptions.EvidencePath);
if (string.IsNullOrWhiteSpace(evidencePath) || !File.Exists(evidencePath))
{
var message = $"Sealed-mode evidence file '{evidencePath}' does not exist.";
logger.LogWarning("Sealed-mode evidence missing at {Path}.", evidencePath);
return AuthoritySealedModeValidationResult.Failure("evidence_missing", message, evidencePath);
}
try
{
await using var stream = new FileStream(
evidencePath,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
if (!root.TryGetProperty("timestamp", out var timestampElement) ||
timestampElement.ValueKind != JsonValueKind.String ||
!DateTimeOffset.TryParse(
timestampElement.GetString(),
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
out var evidenceTimestamp))
{
const string message = "Sealed-mode evidence is missing a valid timestamp.";
logger.LogWarning("Sealed-mode evidence at {Path} is missing a timestamp.", evidencePath);
return AuthoritySealedModeValidationResult.Failure("evidence_invalid_timestamp", message, evidencePath);
}
var now = timeProvider.GetUtcNow();
if (now - evidenceTimestamp > sealedOptions.MaxEvidenceAge)
{
var message = $"Sealed-mode evidence is older than the allowed window ({sealedOptions.MaxEvidenceAge}).";
logger.LogWarning("Sealed-mode evidence at {Path} expired at {Timestamp:O}.", evidencePath, evidenceTimestamp);
return AuthoritySealedModeValidationResult.Failure("evidence_stale", message, evidencePath);
}
if (!ValidateHealthSection(root, "authority", sealedOptions.RequireAuthorityHealthPass, evidencePath, out var validationFailure))
{
return validationFailure!;
}
if (!ValidateHealthSection(root, "signer", sealedOptions.RequireSignerHealthPass, evidencePath, out validationFailure))
{
return validationFailure!;
}
if (!ValidateHealthSection(root, "attestor", sealedOptions.RequireAttestorHealthPass, evidencePath, out validationFailure))
{
return validationFailure!;
}
if (sealedOptions.RequireEgressProbePass)
{
if (!IsComponentPassing(root, "egressProbe", out var egressStatus))
{
var message = "Sealed-mode evidence is missing egress probe results.";
logger.LogWarning("Sealed-mode evidence missing egress probe data at {Path}.", evidencePath);
return AuthoritySealedModeValidationResult.Failure("egress_probe_missing", message, evidencePath);
}
if (!IsPass(egressStatus))
{
var message = $"Sealed-mode egress probe failed with status '{egressStatus ?? "unknown"}'.";
logger.LogWarning("Sealed-mode egress probe failed with status {Status} at {Path}.", egressStatus ?? "unknown", evidencePath);
return AuthoritySealedModeValidationResult.Failure("egress_probe_failed", message, evidencePath);
}
}
logger.LogDebug(
"Sealed-mode evidence verified at {Path} (timestamp {Timestamp:O}).",
evidencePath,
evidenceTimestamp);
return AuthoritySealedModeValidationResult.Success(evidenceTimestamp, evidencePath);
}
catch (JsonException ex)
{
logger.LogWarning(ex, "Failed to parse sealed-mode evidence at {Path}.", evidencePath);
return AuthoritySealedModeValidationResult.Failure("evidence_invalid", "Sealed-mode evidence is not valid JSON.", evidencePath);
}
catch (IOException ex)
{
logger.LogWarning(ex, "Unable to read sealed-mode evidence at {Path}.", evidencePath);
return AuthoritySealedModeValidationResult.Failure("evidence_unreadable", "Unable to read sealed-mode evidence from disk.", evidencePath);
}
}
private string ResolveEvidencePath(string configuredPath)
{
if (string.IsNullOrWhiteSpace(configuredPath))
{
return string.Empty;
}
return Path.IsPathRooted(configuredPath)
? configuredPath
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath ?? string.Empty, configuredPath));
}
private bool ValidateHealthSection(
JsonElement root,
string componentName,
bool enforcementEnabled,
string evidencePath,
out AuthoritySealedModeValidationResult? failure)
{
failure = null;
if (!enforcementEnabled)
{
return true;
}
if (!TryGetHealthStatus(root, componentName, out var status))
{
var message = $"Sealed-mode evidence is missing health data for '{componentName}'.";
logger.LogWarning("Sealed-mode evidence missing {Component} health at {Path}.", componentName, evidencePath);
failure = AuthoritySealedModeValidationResult.Failure($"{componentName}_health_missing", message, evidencePath);
return false;
}
if (!IsPass(status))
{
var message = $"Sealed-mode health check '{componentName}' reported '{status ?? "unknown"}'.";
logger.LogWarning("Sealed-mode {Component} health reported {Status} at {Path}.", componentName, status ?? "unknown", evidencePath);
failure = AuthoritySealedModeValidationResult.Failure($"{componentName}_health_failed", message, evidencePath);
return false;
}
return true;
}
private static bool TryGetHealthStatus(JsonElement root, string componentName, out string? status)
{
status = null;
if (!root.TryGetProperty("health", out var healthElement) || healthElement.ValueKind != JsonValueKind.Object)
{
return false;
}
if (!healthElement.TryGetProperty(componentName, out var componentElement) || componentElement.ValueKind != JsonValueKind.Object)
{
return false;
}
if (!componentElement.TryGetProperty("status", out var statusElement) || statusElement.ValueKind != JsonValueKind.String)
{
return false;
}
status = statusElement.GetString();
return true;
}
private static bool IsComponentPassing(JsonElement root, string propertyName, out string? status)
{
status = null;
if (!root.TryGetProperty(propertyName, out var element))
{
return false;
}
if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty("status", out var statusElement) &&
statusElement.ValueKind == JsonValueKind.String)
{
status = statusElement.GetString();
return true;
}
return false;
}
private static bool IsPass(string? value)
=> !string.IsNullOrWhiteSpace(value) && value.Equals("pass", StringComparison.OrdinalIgnoreCase);
}
internal sealed class NoopAuthoritySealedModeEvidenceValidator : IAuthoritySealedModeEvidenceValidator
{
public static readonly NoopAuthoritySealedModeEvidenceValidator Instance = new();
public ValueTask<AuthoritySealedModeValidationResult> ValidateAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(AuthoritySealedModeValidationResult.Success(null, null));
}

View File

@@ -13,10 +13,11 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
{
private readonly IServiceProvider serviceProvider;
private readonly IReadOnlyDictionary<string, AuthorityIdentityProviderMetadata> providersByName;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> providers;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> passwordProviders;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> mfaProviders;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> clientProvisioningProviders;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> providers;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> passwordProviders;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> mfaProviders;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> clientProvisioningProviders;
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> bootstrapProviders;
public AuthorityIdentityProviderRegistry(
IServiceProvider serviceProvider,
@@ -36,7 +37,8 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
var uniqueProviders = new List<AuthorityIdentityProviderMetadata>(orderedProviders.Count);
var password = new List<AuthorityIdentityProviderMetadata>();
var mfa = new List<AuthorityIdentityProviderMetadata>();
var clientProvisioning = new List<AuthorityIdentityProviderMetadata>();
var clientProvisioning = new List<AuthorityIdentityProviderMetadata>();
var bootstrap = new List<AuthorityIdentityProviderMetadata>();
var dictionary = new Dictionary<string, AuthorityIdentityProviderMetadata>(StringComparer.OrdinalIgnoreCase);
@@ -73,22 +75,29 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
mfa.Add(metadata);
}
if (metadata.Capabilities.SupportsClientProvisioning)
{
clientProvisioning.Add(metadata);
}
}
if (metadata.Capabilities.SupportsClientProvisioning)
{
clientProvisioning.Add(metadata);
}
if (metadata.Capabilities.SupportsBootstrap)
{
bootstrap.Add(metadata);
}
}
providersByName = dictionary;
providers = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(uniqueProviders);
passwordProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(password);
mfaProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(mfa);
clientProvisioningProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(clientProvisioning);
AggregateCapabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: passwordProviders.Count > 0,
SupportsMfa: mfaProviders.Count > 0,
SupportsClientProvisioning: clientProvisioningProviders.Count > 0);
providersByName = dictionary;
providers = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(uniqueProviders);
passwordProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(password);
mfaProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(mfa);
clientProvisioningProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(clientProvisioning);
bootstrapProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(bootstrap);
AggregateCapabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: passwordProviders.Count > 0,
SupportsMfa: mfaProviders.Count > 0,
SupportsClientProvisioning: clientProvisioningProviders.Count > 0,
SupportsBootstrap: bootstrapProviders.Count > 0);
}
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers => providers;
@@ -97,7 +106,9 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders => mfaProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders => clientProvisioningProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders => clientProvisioningProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> BootstrapProviders => bootstrapProviders;
public AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }

View File

@@ -22,6 +22,7 @@ internal static class AuthorityOpenIddictConstants
internal const string DpopProofJwtIdProperty = "authority:dpop_jti";
internal const string DpopIssuedAtProperty = "authority:dpop_iat";
internal const string DpopConsumedNonceProperty = "authority:dpop_nonce";
internal const string SealedModeStatusProperty = "authority:sealed_mode";
internal const string ConfirmationClaimType = "cnf";
internal const string SenderConstraintClaimType = "authority_sender_constraint";
internal const string SenderNonceClaimType = "authority_sender_nonce";

View File

@@ -15,6 +15,7 @@ using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using MongoDB.Driver;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
@@ -126,6 +127,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
private readonly IHttpContextAccessor httpContextAccessor;
private readonly StellaOpsAuthorityOptions authorityOptions;
private readonly ILogger<ValidateClientCredentialsHandler> logger;
private readonly IAuthoritySealedModeEvidenceValidator sealedModeEvidenceValidator;
private static readonly Regex AttributeValueRegex = new("^[a-z0-9][a-z0-9:_-]{0,127}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
@@ -141,7 +143,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
IAuthorityClientCertificateValidator certificateValidator,
IHttpContextAccessor httpContextAccessor,
StellaOpsAuthorityOptions authorityOptions,
ILogger<ValidateClientCredentialsHandler> logger)
ILogger<ValidateClientCredentialsHandler> logger,
IAuthoritySealedModeEvidenceValidator? sealedModeEvidenceValidator = null)
{
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
@@ -155,6 +158,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.sealedModeEvidenceValidator = sealedModeEvidenceValidator ?? NoopAuthoritySealedModeEvidenceValidator.Instance;
}
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
@@ -220,6 +224,29 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return;
}
if (ClientCredentialHandlerHelpers.RequiresAirgapSealConfirmation(document.Properties))
{
var sealedResult = await sealedModeEvidenceValidator.ValidateAsync(context.CancellationToken).ConfigureAwait(false);
if (!sealedResult.IsSatisfied)
{
var failureCode = sealedResult.FailureCode ?? "sealed_mode_missing";
context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty] = $"failure:{failureCode}";
activity?.SetTag("authority.sealed_mode", failureCode);
context.Reject(OpenIddictConstants.Errors.InvalidClient, sealedResult.FailureDescription ?? "Sealed-mode evidence is missing or stale.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: sealed-mode evidence unsatisfied ({FailureCode}). {FailureDescription}",
document.ClientId,
failureCode,
sealedResult.FailureDescription);
return;
}
var confirmation = sealedResult.EvidenceTimestamp?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty] =
confirmation is null ? "confirmed" : $"confirmed:{confirmation}";
activity?.SetTag("authority.sealed_mode", "confirmed");
}
var existingSenderConstraint = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SenderConstraintProperty, out var senderConstraintValue) && senderConstraintValue is string existingConstraint
? existingConstraint
: null;
@@ -1309,6 +1336,17 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
});
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.SealedModeStatusProperty, out var sealedStatusObj) &&
sealedStatusObj is string sealedStatus &&
!string.IsNullOrWhiteSpace(sealedStatus))
{
extraProperties.Add(new AuthEventProperty
{
Name = "airgap.sealed",
Value = ClassifiedString.Public(sealedStatus)
});
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ServiceAccountProperty, out var serviceAccountAuditObj) &&
serviceAccountAuditObj is AuthorityServiceAccountDocument auditServiceAccount &&
!string.IsNullOrWhiteSpace(auditServiceAccount.AccountId))
@@ -2137,4 +2175,19 @@ internal static class ClientCredentialHandlerHelpers
return JsonSerializer.Serialize(current);
}
public static bool RequiresAirgapSealConfirmation(IReadOnlyDictionary<string, string?> properties)
{
if (!properties.TryGetValue(AuthorityClientMetadataKeys.RequiresAirGapSealConfirmation, out var value) ||
string.IsNullOrWhiteSpace(value))
{
return false;
}
var normalized = value.Trim();
return normalized.Equals("true", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("1", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("yes", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("y", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -11,6 +11,7 @@ using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.RateLimiting;
@@ -25,10 +26,11 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
private readonly IAuthorityIdentityProviderRegistry registry;
private readonly ActivitySource activitySource;
private readonly IAuthEventSink auditSink;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly IAuthorityClientStore clientStore;
private readonly TimeProvider timeProvider;
private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor;
private readonly IAuthorityClientStore clientStore;
private readonly TimeProvider timeProvider;
private readonly ILogger<ValidatePasswordGrantHandler> logger;
private readonly IAuthoritySealedModeEvidenceValidator sealedModeEvidenceValidator;
public ValidatePasswordGrantHandler(
IAuthorityIdentityProviderRegistry registry,
@@ -37,7 +39,8 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
IAuthorityRateLimiterMetadataAccessor metadataAccessor,
IAuthorityClientStore clientStore,
TimeProvider timeProvider,
ILogger<ValidatePasswordGrantHandler> logger)
ILogger<ValidatePasswordGrantHandler> logger,
IAuthoritySealedModeEvidenceValidator? sealedModeEvidenceValidator = null)
{
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
@@ -46,6 +49,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.sealedModeEvidenceValidator = sealedModeEvidenceValidator ?? NoopAuthoritySealedModeEvidenceValidator.Instance;
}
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
@@ -100,9 +104,9 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
return;
}
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
if (clientDocument is null || clientDocument.Disabled)
{
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
if (clientDocument is null || clientDocument.Disabled)
{
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
@@ -122,12 +126,61 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidClient, "The specified client is not permitted.");
logger.LogWarning("Password grant validation failed: client {ClientId} disabled or missing.", clientId);
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = clientDocument;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId;
logger.LogWarning("Password grant validation failed: client {ClientId} disabled or missing.", clientId);
return;
}
if (ClientCredentialHandlerHelpers.RequiresAirgapSealConfirmation(clientDocument.Properties))
{
var sealedResult = await sealedModeEvidenceValidator.ValidateAsync(context.CancellationToken).ConfigureAwait(false);
if (!sealedResult.IsSatisfied)
{
var failureCode = sealedResult.FailureCode ?? "sealed_mode_missing";
context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty] = $"failure:{failureCode}";
activity?.SetTag("authority.sealed_mode", failureCode);
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
sealedResult.FailureDescription ?? "Sealed-mode evidence is missing or stale.",
clientId,
providerName: null,
tenant: null,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: new[]
{
new AuthEventProperty
{
Name = "airgap.sealed",
Value = ClassifiedString.Public($"failure:{failureCode}")
}
});
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidClient, sealedResult.FailureDescription ?? "Sealed-mode evidence is missing or stale.");
logger.LogWarning(
"Password grant validation failed for client {ClientId}: sealed-mode evidence unsatisfied ({FailureCode}). {FailureDescription}",
clientId,
failureCode,
sealedResult.FailureDescription);
return;
}
var confirmation = sealedResult.EvidenceTimestamp?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty] =
confirmation is null ? "confirmed" : $"confirmed:{confirmation}";
activity?.SetTag("authority.sealed_mode", "confirmed");
}
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = clientDocument;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId;
var tenant = PasswordGrantAuditHelper.NormalizeTenant(clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue) ? tenantValue : null);
if (!string.IsNullOrWhiteSpace(tenant))

View File

@@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
@@ -8,6 +9,7 @@ using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.Security;
using StellaOps.Authority.Storage.Mongo.Stores;
@@ -18,15 +20,18 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityClientCertificateValidator certificateValidator;
private readonly ILogger<ValidateRefreshTokenGrantHandler> logger;
private readonly IAuthoritySealedModeEvidenceValidator sealedModeEvidenceValidator;
public ValidateRefreshTokenGrantHandler(
IAuthorityClientStore clientStore,
IAuthorityClientCertificateValidator certificateValidator,
ILogger<ValidateRefreshTokenGrantHandler> logger)
ILogger<ValidateRefreshTokenGrantHandler> logger,
IAuthoritySealedModeEvidenceValidator? sealedModeEvidenceValidator = null)
{
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.certificateValidator = certificateValidator ?? throw new ArgumentNullException(nameof(certificateValidator));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.sealedModeEvidenceValidator = sealedModeEvidenceValidator ?? NoopAuthoritySealedModeEvidenceValidator.Instance;
}
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
@@ -47,17 +52,48 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle
return;
}
var clientId = context.ClientId ?? context.Request.ClientId;
AuthorityClientDocument? clientDocument = null;
if (!string.IsNullOrWhiteSpace(clientId))
{
clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
if (clientDocument is not null &&
ClientCredentialHandlerHelpers.RequiresAirgapSealConfirmation(clientDocument.Properties))
{
var sealedResult = await sealedModeEvidenceValidator.ValidateAsync(context.CancellationToken).ConfigureAwait(false);
if (!sealedResult.IsSatisfied)
{
var failureCode = sealedResult.FailureCode ?? "sealed_mode_missing";
context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty] = $"failure:{failureCode}";
context.Reject(OpenIddictConstants.Errors.InvalidClient, sealedResult.FailureDescription ?? "Sealed-mode evidence is missing or stale.");
logger.LogWarning(
"Refresh token validation failed for client {ClientId}: sealed-mode evidence unsatisfied ({FailureCode}). {FailureDescription}",
clientId ?? "<unknown>",
failureCode,
sealedResult.FailureDescription);
return;
}
var confirmation = sealedResult.EvidenceTimestamp?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty] =
confirmation is null ? "confirmed" : $"confirmed:{confirmation}";
}
}
var senderConstraint = refreshPrincipal?.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType);
if (string.Equals(senderConstraint, AuthoritySenderConstraintKinds.Mtls, StringComparison.Ordinal))
{
if (!await EnsureMtlsBindingAsync(context, refreshPrincipal!).ConfigureAwait(false))
if (!await EnsureMtlsBindingAsync(context, refreshPrincipal!, clientDocument).ConfigureAwait(false))
{
return;
}
}
}
private async ValueTask<bool> EnsureMtlsBindingAsync(OpenIddictServerEvents.ValidateTokenRequestContext context, ClaimsPrincipal principal)
private async ValueTask<bool> EnsureMtlsBindingAsync(
OpenIddictServerEvents.ValidateTokenRequestContext context,
ClaimsPrincipal principal,
AuthorityClientDocument? clientDocument)
{
var clientId = context.ClientId ?? context.Request.ClientId;
if (string.IsNullOrWhiteSpace(clientId))
@@ -67,7 +103,7 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle
return false;
}
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
clientDocument ??= await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false);
if (clientDocument is null)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Unknown client.");
@@ -82,7 +118,7 @@ internal sealed class ValidateRefreshTokenGrantHandler : IOpenIddictServerHandle
return false;
}
var validation = await certificateValidator.ValidateAsync(httpContext, clientDocument, context.CancellationToken).ConfigureAwait(false);
var validation = await certificateValidator.ValidateAsync(httpContext!, clientDocument, context.CancellationToken).ConfigureAwait(false);
if (!validation.Succeeded ||
string.IsNullOrWhiteSpace(validation.HexThumbprint) ||
string.IsNullOrWhiteSpace(validation.ConfirmationThumbprint))

View File

@@ -140,6 +140,7 @@ builder.Services.TryAddSingleton<IAuthorityRateLimiterMetadataAccessor, Authorit
builder.Services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>();
builder.Services.AddSingleton<IAuthorityClientCertificateValidator, AuthorityClientCertificateValidator>();
builder.Services.TryAddSingleton<IAuthorityAirgapAuditService, AuthorityAirgapAuditService>();
builder.Services.TryAddSingleton<IAuthoritySealedModeEvidenceValidator, AuthoritySealedModeEvidenceValidator>();
builder.Services.AddSingleton<AuthorityOpenApiDocumentProvider>();
builder.Services.TryAddSingleton<IConsoleWorkspaceService, ConsoleWorkspaceSampleService>();
@@ -472,15 +473,21 @@ else
{
var caps = provider.Capabilities;
app.Logger.LogInformation(
"Identity provider plugin '{PluginName}' (type {PluginType}) capabilities: password={Password}, mfa={Mfa}, clientProvisioning={ClientProvisioning}.",
"Identity provider plugin '{PluginName}' (type {PluginType}) capabilities: password={Password}, mfa={Mfa}, clientProvisioning={ClientProvisioning}, bootstrap={Bootstrap}.",
provider.Name,
provider.Type,
caps.SupportsPassword,
caps.SupportsMfa,
caps.SupportsClientProvisioning);
caps.SupportsClientProvisioning,
caps.SupportsBootstrap);
}
}
if (authorityOptions.Bootstrap.Enabled && identityProviderRegistry.BootstrapProviders.Count == 0)
{
app.Logger.LogWarning("Bootstrap APIs are enabled but no identity providers advertise the 'bootstrap' capability.");
}
if (authorityOptions.Bootstrap.Enabled)
{
var bootstrapGroup = app.MapGroup("/internal");
@@ -561,6 +568,13 @@ if (authorityOptions.Bootstrap.Enabled)
return Results.BadRequest(new { error = "invalid_provider", message = "Specified identity provider was not found." });
}
if (!providerMetadata.Capabilities.SupportsBootstrap)
{
await ReleaseInviteAsync("Selected provider does not support bootstrap provisioning.");
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support bootstrap provisioning.", null, request.Username, providerMetadata.Name, request.Roles ?? Array.Empty<string>(), inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "unsupported_provider", message = "Selected provider does not support bootstrap provisioning." });
}
if (!providerMetadata.Capabilities.SupportsPassword)
{
await ReleaseInviteAsync("Selected provider does not support password provisioning.");
@@ -849,6 +863,13 @@ if (authorityOptions.Bootstrap.Enabled)
return Results.BadRequest(new { error = "invalid_provider", message = "Specified identity provider was not found." });
}
if (!providerMetadata.Capabilities.SupportsBootstrap)
{
await ReleaseInviteAsync("Selected provider does not support bootstrap provisioning.");
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support bootstrap provisioning.", request.ClientId, null, providerMetadata.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "unsupported_provider", message = "Selected provider does not support bootstrap provisioning." });
}
if (!providerMetadata.Capabilities.SupportsClientProvisioning)
{
await ReleaseInviteAsync("Selected provider does not support client provisioning.");

View File

@@ -35,7 +35,8 @@
| AUTH-DPOP-11-001 | DONE (2025-11-08) | Authority Core & Security Guild | AUTH-AOC-19-002 | Enforce DPoP sender constraints for all Authority token flows (nonce store selection, algorithm allowlist, `cnf.jkt` persistence, structured telemetry). | `/token` enforces configured DPoP policies (nonce, allowed algorithms); cnf claims verified in integration tests; docs/runbooks updated with configuration guidance. |
> 2025-11-08: DPoP validation now executes for every `/token` grant (client credentials, password, device, refresh); interactive handlers apply shared sender-constraint claims so tokens emit `cnf.jkt` + telemetry, and docs describe the expanded coverage.
> 2025-11-07: Joint Authority/DevOps stand-up committed to shipping nonce store + telemetry updates by 2025-11-10; config samples and integration tests being updated in tandem.
| AUTH-MTLS-11-002 | DOING (2025-11-07) | Authority Core & Security Guild | AUTH-DPOP-11-001 | Add mTLS-bound access token issuance/validation (client certificate thumbprints, JWKS rotation hooks) for high-assurance tenants and services. | mTLS certificate binding validated end-to-end; audit logs capture cert hashes; docs describe bootstrap/rotation steps. |
| AUTH-MTLS-11-002 | DONE (2025-11-08) | Authority Core & Security Guild | AUTH-DPOP-11-001 | Add mTLS-bound access token issuance/validation (client certificate thumbprints, JWKS rotation hooks) for high-assurance tenants and services. | mTLS certificate binding validated end-to-end; audit logs capture cert hashes; docs describe bootstrap/rotation steps. |
> 2025-11-08: Refresh tokens now require the bound certificate, certificate thumbprints propagate through token issuance via `AuthoritySenderConstraintHelper`, and JWKS/docs updated to cover the expanded sender constraint surface.
> 2025-11-08: Wiring cert thumbprint persistence + audit hooks now that DPoP nonce enforcement is in place; targeting shared delivery window with DEVOPS-AIRGAP-57-002.
> 2025-11-07: Same stand-up aligned on 2025-11-10 target for mTLS enforcement + JWKS rotation docs so plugin mitigations can unblock.
| AUTH-POLICY-23-001 | DONE (2025-10-27) | Authority Core & Docs Guild | AUTH-AOC-19-002 | Introduce fine-grained policy scopes (`policy:read`, `policy:author`, `policy:review`, `policy:simulate`, `findings:read`) for CLI/service identities; refresh discovery metadata, issuer templates, and offline defaults. | Scope catalogue and sample configs updated; `policy-cli` seed credentials rotated; docs recorded migration steps. |