Add tests and implement StubBearer authentication for Signer endpoints

- Created SignerEndpointsTests to validate the SignDsse and VerifyReferrers endpoints.
- Implemented StubBearerAuthenticationDefaults and StubBearerAuthenticationHandler for token-based authentication.
- Developed ConcelierExporterClient for managing Trivy DB settings and export operations.
- Added TrivyDbSettingsPageComponent for UI interactions with Trivy DB settings, including form handling and export triggering.
- Implemented styles and HTML structure for Trivy DB settings page.
- Created NotifySmokeCheck tool for validating Redis event streams and Notify deliveries.
This commit is contained in:
master
2025-10-21 09:37:07 +03:00
parent d6cb41dd51
commit 48f3071e2a
298 changed files with 20490 additions and 5751 deletions

View File

@@ -78,7 +78,7 @@ public class StandardPluginRegistrarTests
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
var provider = services.BuildServiceProvider();
using var provider = services.BuildServiceProvider();
var hostedServices = provider.GetServices<IHostedService>();
foreach (var hosted in hostedServices)
{
@@ -88,7 +88,8 @@ public class StandardPluginRegistrarTests
}
}
var plugin = provider.GetRequiredService<IIdentityProviderPlugin>();
using var scope = provider.CreateScope();
var plugin = scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>();
Assert.Equal("standard", plugin.Type);
Assert.True(plugin.Capabilities.SupportsPassword);
Assert.False(plugin.Capabilities.SupportsMfa);
@@ -138,7 +139,8 @@ public class StandardPluginRegistrarTests
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
_ = provider.GetRequiredService<StandardUserCredentialStore>();
using var scope = provider.CreateScope();
_ = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
Assert.Contains(loggerProvider.Entries, entry =>
entry.Level == LogLevel.Warning &&
@@ -176,7 +178,8 @@ public class StandardPluginRegistrarTests
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
var plugin = provider.GetRequiredService<IIdentityProviderPlugin>();
using var scope = provider.CreateScope();
var plugin = scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>();
Assert.True(plugin.Capabilities.SupportsPassword);
}
@@ -215,7 +218,8 @@ public class StandardPluginRegistrarTests
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IIdentityProviderPlugin>());
using var scope = provider.CreateScope();
Assert.Throws<InvalidOperationException>(() => scope.ServiceProvider.GetRequiredService<IIdentityProviderPlugin>());
}
[Fact]

View File

@@ -1,5 +1,6 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -10,24 +11,25 @@ namespace StellaOps.Authority.Plugin.Standard.Bootstrap;
internal sealed class StandardPluginBootstrapper : IHostedService
{
private readonly string pluginName;
private readonly IOptionsMonitor<StandardPluginOptions> optionsMonitor;
private readonly StandardUserCredentialStore credentialStore;
private readonly IServiceScopeFactory scopeFactory;
private readonly ILogger<StandardPluginBootstrapper> logger;
public StandardPluginBootstrapper(
string pluginName,
IOptionsMonitor<StandardPluginOptions> optionsMonitor,
StandardUserCredentialStore credentialStore,
IServiceScopeFactory scopeFactory,
ILogger<StandardPluginBootstrapper> logger)
{
this.pluginName = pluginName;
this.optionsMonitor = optionsMonitor;
this.credentialStore = credentialStore;
this.scopeFactory = scopeFactory;
this.logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = scopeFactory.CreateScope();
var optionsMonitor = scope.ServiceProvider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var credentialStore = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
var options = optionsMonitor.Get(pluginName);
if (options.BootstrapUser is null || !options.BootstrapUser.IsConfigured)
{

View File

@@ -43,7 +43,7 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
})
.ValidateOnStart();
context.Services.AddSingleton(sp =>
context.Services.AddScoped(sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
@@ -79,7 +79,7 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
context.Services.AddSingleton(sp =>
context.Services.AddScoped(sp =>
{
var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>();
@@ -87,7 +87,7 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider);
});
context.Services.AddSingleton<IIdentityProviderPlugin>(sp =>
context.Services.AddScoped<IIdentityProviderPlugin>(sp =>
{
var store = sp.GetRequiredService<StandardUserCredentialStore>();
var clientProvisioningStore = sp.GetRequiredService<StandardClientProvisioningStore>();
@@ -100,14 +100,13 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
loggerFactory.CreateLogger<StandardIdentityProviderPlugin>());
});
context.Services.AddSingleton<IClientProvisioningStore>(sp =>
context.Services.AddScoped<IClientProvisioningStore>(sp =>
sp.GetRequiredService<StandardClientProvisioningStore>());
context.Services.AddSingleton<IHostedService>(sp =>
new StandardPluginBootstrapper(
pluginName,
sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>(),
sp.GetRequiredService<StandardUserCredentialStore>(),
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetRequiredService<ILogger<StandardPluginBootstrapper>>()));
}
}

View File

@@ -5,10 +5,10 @@
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1PLG5 | Final polish + diagrams for plugin developer guide (AUTHPLUG-DOCS-01-001). | Docs team delivers copy-edit + exported diagrams; PR merged. |
| SEC1.PLG | DONE (2025-10-11) | Security Guild, BE-Auth Plugin | SEC1.A (StellaOps.Cryptography) | Swap Standard plugin hashing to Argon2id via `StellaOps.Cryptography` abstractions; keep PBKDF2 verification for legacy. | ✅ `StandardUserCredentialStore` uses `ICryptoProvider` to hash/check; ✅ Transparent rehash on success; ✅ Unit tests cover tamper + legacy rehash. |
| SEC1.OPT | DONE (2025-10-11) | Security Guild | SEC1.PLG | Expose password hashing knobs in `StandardPluginOptions` (`memoryKiB`, `iterations`, `parallelism`, `algorithm`) with validation. | ✅ Options bound from YAML; ✅ Invalid configs throw; ✅ Docs include tuning guidance. |
| SEC2.PLG | DOING (2025-10-14) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. <br>⏳ Awaiting AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 completion to unlock Wave0B verification paths. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
| SEC3.PLG | DOING (2025-10-14) | 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 so limiter telemetry contract matches final authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
| 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 / PLUGIN-DI-08-001 to stabilise Authority auth surfaces before final verification + publish. | ✅ 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 so limiter telemetry contract matches final authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
| SEC4.PLG | DONE (2025-10-12) | Security Guild | SEC4.A (revocation schema) | Provide plugin hooks so revoked users/clients write reasons for revocation bundle export. | ✅ Revocation exporter consumes plugin data; ✅ Tests cover revoked user/client output. |
| SEC5.PLG | DOING (2025-10-14) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. <br> Final documentation depends on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 outcomes. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
| 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 depends on AUTH-DPOP-11-001 / AUTH-MTLS-11-002 / PLUGIN-DI-08-001 outcomes. | ✅ 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. |
| PLG7.RFC | REVIEW | 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. |
| PLG6.DIAGRAM | TODO | Docs Guild | PLG6.DOC | Export final sequence/component diagrams for the developer guide and add offline-friendly assets under `docs/assets/authority`. | ✅ Mermaid sources committed; ✅ Rendered SVG/PNG linked from Section 2 + Section 9; ✅ Docs build preview shared with Plugin + Docs guilds. |

View File

@@ -1,7 +1,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Authority.Plugins.Abstractions;
@@ -95,24 +98,24 @@ public interface IAuthorityPluginRegistry
public interface IAuthorityIdentityProviderRegistry
{
/// <summary>
/// Gets all registered identity provider plugins keyed by logical name.
/// Gets metadata for all registered identity provider plugins.
/// </summary>
IReadOnlyCollection<IIdentityProviderPlugin> Providers { get; }
IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers { get; }
/// <summary>
/// Gets identity providers that advertise password support.
/// Gets metadata for identity providers that advertise password support.
/// </summary>
IReadOnlyCollection<IIdentityProviderPlugin> PasswordProviders { get; }
IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders { get; }
/// <summary>
/// Gets identity providers that advertise multi-factor authentication support.
/// Gets metadata for identity providers that advertise multi-factor authentication support.
/// </summary>
IReadOnlyCollection<IIdentityProviderPlugin> MfaProviders { get; }
IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders { get; }
/// <summary>
/// Gets identity providers that advertise client provisioning support.
/// Gets metadata for identity providers that advertise client provisioning support.
/// </summary>
IReadOnlyCollection<IIdentityProviderPlugin> ClientProvisioningProviders { get; }
IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders { get; }
/// <summary>
/// Aggregate capability flags across all registered providers.
@@ -120,20 +123,89 @@ public interface IAuthorityIdentityProviderRegistry
AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
/// <summary>
/// Attempts to resolve an identity provider by name.
/// Attempts to resolve identity provider metadata by name.
/// </summary>
bool TryGet(string name, [NotNullWhen(true)] out IIdentityProviderPlugin? provider);
bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata);
/// <summary>
/// Resolves an identity provider by name or throws when not found.
/// Resolves identity provider metadata by name or throws when not found.
/// </summary>
IIdentityProviderPlugin GetRequired(string name)
AuthorityIdentityProviderMetadata GetRequired(string name)
{
if (TryGet(name, out var provider))
if (TryGet(name, out var metadata))
{
return provider;
return metadata;
}
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
}
/// <summary>
/// Acquires a scoped handle to the specified identity provider.
/// </summary>
/// <param name="name">Logical provider name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Handle managing the provider instance lifetime.</returns>
ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken);
}
/// <summary>
/// Immutable metadata describing a registered identity provider.
/// </summary>
/// <param name="Name">Logical provider name from the manifest.</param>
/// <param name="Type">Provider type identifier.</param>
/// <param name="Capabilities">Capability flags advertised by the provider.</param>
public sealed record AuthorityIdentityProviderMetadata(
string Name,
string Type,
AuthorityIdentityProviderCapabilities Capabilities);
/// <summary>
/// Represents a scoped identity provider instance and manages its disposal.
/// </summary>
public sealed class AuthorityIdentityProviderHandle : IAsyncDisposable, IDisposable
{
private readonly AsyncServiceScope scope;
private bool disposed;
public AuthorityIdentityProviderHandle(AsyncServiceScope scope, AuthorityIdentityProviderMetadata metadata, IIdentityProviderPlugin provider)
{
this.scope = scope;
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
Provider = provider ?? throw new ArgumentNullException(nameof(provider));
}
/// <summary>
/// Gets the metadata associated with the provider instance.
/// </summary>
public AuthorityIdentityProviderMetadata Metadata { get; }
/// <summary>
/// Gets the active provider instance.
/// </summary>
public IIdentityProviderPlugin Provider { get; }
/// <inheritdoc />
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
scope.Dispose();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
await scope.DisposeAsync().ConfigureAwait(false);
}
}

View File

@@ -1,15 +1,20 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Authority.Plugins.Abstractions;
using Xunit;
using System.Linq;
namespace StellaOps.Authority.Tests.Identity;
public class AuthorityIdentityProviderRegistryTests
{
[Fact]
public void RegistryIndexesProvidersAndAggregatesCapabilities()
public async Task RegistryIndexesProvidersAndAggregatesCapabilities()
{
var providers = new[]
{
@@ -17,21 +22,25 @@ public class AuthorityIdentityProviderRegistryTests
CreateProvider("sso", type: "saml", supportsPassword: false, supportsMfa: true, supportsClientProvisioning: true)
};
var registry = new AuthorityIdentityProviderRegistry(providers, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
using var serviceProvider = BuildServiceProvider(providers);
var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
Assert.Equal(2, registry.Providers.Count);
Assert.True(registry.TryGet("standard", out var standard));
Assert.Same(providers[0], 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);
await using var handle = await registry.AcquireAsync("standard", default);
Assert.Same(providers[0], handle.Provider);
}
[Fact]
public void RegistryIgnoresDuplicateNames()
public async Task RegistryIgnoresDuplicateNames()
{
var duplicate = CreateProvider("standard", "ldap", supportsPassword: true, supportsMfa: false, supportsClientProvisioning: false);
var providers = new[]
@@ -40,12 +49,56 @@ public class AuthorityIdentityProviderRegistryTests
duplicate
};
var registry = new AuthorityIdentityProviderRegistry(providers, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
using var serviceProvider = BuildServiceProvider(providers);
var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
Assert.Single(registry.Providers);
Assert.Same(providers[0], registry.Providers.First());
Assert.Equal("standard", registry.Providers.First().Name);
Assert.True(registry.TryGet("standard", out var provider));
Assert.Same(providers[0], provider);
await using var handle = await registry.AcquireAsync("standard", default);
Assert.Same(providers[0], handle.Provider);
Assert.Equal("standard", provider!.Name);
}
[Fact]
public async Task AcquireAsync_ReturnsScopedProviderInstances()
{
var configuration = new ConfigurationBuilder().Build();
var manifest = new AuthorityPluginManifest(
"scoped",
"scoped",
true,
AssemblyName: null,
AssemblyPath: null,
Capabilities: new[] { AuthorityPluginCapabilities.Password },
Metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
ConfigPath: string.Empty);
var context = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddScoped<IIdentityProviderPlugin>(_ => new ScopedIdentityProviderPlugin(context));
using var serviceProvider = services.BuildServiceProvider();
var registry = new AuthorityIdentityProviderRegistry(serviceProvider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
await using var first = await registry.AcquireAsync("scoped", default);
await using var second = await registry.AcquireAsync("scoped", default);
var firstPlugin = Assert.IsType<ScopedIdentityProviderPlugin>(first.Provider);
var secondPlugin = Assert.IsType<ScopedIdentityProviderPlugin>(second.Provider);
Assert.NotEqual(firstPlugin.InstanceId, secondPlugin.InstanceId);
}
private static ServiceProvider BuildServiceProvider(IEnumerable<IIdentityProviderPlugin> providers)
{
var services = new ServiceCollection();
foreach (var provider in providers)
{
services.AddSingleton<IIdentityProviderPlugin>(provider);
}
return services.BuildServiceProvider();
}
private static IIdentityProviderPlugin CreateProvider(
@@ -122,4 +175,36 @@ public class AuthorityIdentityProviderRegistryTests
public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
}
private sealed class ScopedIdentityProviderPlugin : IIdentityProviderPlugin
{
public ScopedIdentityProviderPlugin(AuthorityPluginContext context)
{
Context = context;
InstanceId = Guid.NewGuid();
Capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: true,
SupportsMfa: false,
SupportsClientProvisioning: false);
}
public Guid InstanceId { get; }
public string Name => Context.Manifest.Name;
public string Type => Context.Manifest.Type;
public AuthorityPluginContext Context { get; }
public IUserCredentialStore Credentials => throw new NotImplementedException();
public IClaimsEnricher ClaimsEnricher => throw new NotImplementedException();
public IClientProvisioningStore? ClientProvisioning => null;
public AuthorityIdentityProviderCapabilities Capabilities { get; }
public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using OpenIddict.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Plugins.Abstractions;
@@ -67,8 +68,14 @@ public class AuthorityIdentityProviderSelectorTests
private static AuthorityIdentityProviderRegistry CreateRegistry(IEnumerable<IIdentityProviderPlugin> passwordProviders)
{
var providers = passwordProviders.ToList<IIdentityProviderPlugin>();
return new AuthorityIdentityProviderRegistry(providers, Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthorityIdentityProviderRegistry>.Instance);
var services = new ServiceCollection();
foreach (var provider in passwordProviders)
{
services.AddSingleton<IIdentityProviderPlugin>(provider);
}
var serviceProvider = services.BuildServiceProvider();
return new AuthorityIdentityProviderRegistry(serviceProvider, Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthorityIdentityProviderRegistry>.Instance);
}
private static IIdentityProviderPlugin CreateProvider(string name, bool supportsPassword)

View File

@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens;
@@ -350,6 +351,7 @@ public class ClientCredentialsHandlersTests
};
options.Security.SenderConstraints.Mtls.Enabled = true;
options.Security.SenderConstraints.Mtls.RequireChainValidation = false;
options.Security.SenderConstraints.Mtls.AllowedSanTypes.Clear();
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost/test";
@@ -394,7 +396,7 @@ public class ClientCredentialsHandlersTests
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
Assert.False(context.IsRejected, context.ErrorDescription ?? context.Error);
Assert.Equal(AuthoritySenderConstraintKinds.Mtls, context.Transaction.Properties[AuthorityOpenIddictConstants.SenderConstraintProperty]);
var expectedBase64 = Base64UrlEncoder.Encode(certificate.GetCertHash(HashAlgorithmName.SHA256));
@@ -581,7 +583,7 @@ public class TokenValidationHandlersTests
descriptor: CreateDescriptor(clientDocument),
user: userDescriptor);
var registry = new AuthorityIdentityProviderRegistry(new[] { plugin }, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
var registry = CreateRegistryFromPlugins(plugin);
var metadataAccessorSuccess = new TestRateLimiterMetadataAccessor();
var auditSinkSuccess = new TestAuthEventSink();
@@ -1073,7 +1075,7 @@ internal static class TestHelpers
descriptor: clientDescriptor,
user: null);
return new AuthorityIdentityProviderRegistry(new[] { plugin }, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
return CreateRegistryFromPlugins(plugin);
}
public static TestIdentityProviderPlugin CreatePlugin(
@@ -1109,6 +1111,19 @@ internal static class TestHelpers
SupportsClientProvisioning: supportsClientProvisioning));
}
public static AuthorityIdentityProviderRegistry CreateRegistryFromPlugins(params IIdentityProviderPlugin[] plugins)
{
var services = new ServiceCollection();
services.AddLogging();
foreach (var plugin in plugins)
{
services.AddSingleton<IIdentityProviderPlugin>(plugin);
}
var provider = services.BuildServiceProvider();
return new AuthorityIdentityProviderRegistry(provider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
}
public static OpenIddictServerTransaction CreateTokenTransaction(string clientId, string? secret, string? scope)
{
var request = new OpenIddictRequest

View File

@@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
@@ -97,7 +98,13 @@ public class PasswordGrantHandlersTests
private static AuthorityIdentityProviderRegistry CreateRegistry(IUserCredentialStore store)
{
var plugin = new StubIdentityProviderPlugin("stub", store);
return new AuthorityIdentityProviderRegistry(new[] { plugin }, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IIdentityProviderPlugin>(plugin);
var provider = services.BuildServiceProvider();
return new AuthorityIdentityProviderRegistry(provider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
}
private static OpenIddictServerTransaction CreatePasswordTransaction(string username, string password)

View File

@@ -131,9 +131,7 @@ public sealed class TokenPersistenceIntegrationTests
descriptor,
userDescriptor);
var registry = new AuthorityIdentityProviderRegistry(
new[] { plugin },
NullLogger<AuthorityIdentityProviderRegistry>.Instance);
var registry = TestHelpers.CreateRegistryFromPlugins(plugin);
const string revokedTokenId = "refresh-token-1";
var refreshToken = new AuthorityTokenDocument

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Authority.Plugins;
using StellaOps.Authority.Plugins.Abstractions;
@@ -67,6 +68,7 @@ public class AuthorityPluginLoaderTests
public void RegisterPlugins_RegistersEnabledPlugin_WhenRegistrarAvailable()
{
var services = new ServiceCollection();
services.AddLogging();
var hostConfiguration = new ConfigurationBuilder().Build();
var manifest = new AuthorityPluginManifest(
@@ -99,6 +101,46 @@ public class AuthorityPluginLoaderTests
Assert.NotNull(provider.GetRequiredService<TestMarkerService>());
}
[Fact]
public void RegisterPlugins_ActivatesRegistrarUsingDependencyInjection()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(TimeProvider.System);
var hostConfiguration = new ConfigurationBuilder().Build();
var manifest = new AuthorityPluginManifest(
"di-test",
DiAuthorityPluginRegistrar.PluginTypeIdentifier,
true,
typeof(DiAuthorityPluginRegistrar).Assembly.GetName().Name,
typeof(DiAuthorityPluginRegistrar).Assembly.Location,
Array.Empty<string>(),
new Dictionary<string, string?>(),
"di-test.yaml");
var pluginContext = new AuthorityPluginContext(manifest, hostConfiguration);
var descriptor = new AuthorityPluginLoader.LoadedPluginDescriptor(
typeof(DiAuthorityPluginRegistrar).Assembly,
typeof(DiAuthorityPluginRegistrar).Assembly.Location);
var summary = AuthorityPluginLoader.RegisterPluginsCore(
services,
hostConfiguration,
new[] { pluginContext },
new[] { descriptor },
Array.Empty<string>(),
NullLogger.Instance);
Assert.Contains("di-test", summary.RegisteredPlugins);
var provider = services.BuildServiceProvider();
var dependent = provider.GetRequiredService<DependentService>();
Assert.True(dependent.LoggerWasResolved);
Assert.True(dependent.TimeProviderResolved);
}
private sealed class TestAuthorityPluginRegistrar : IAuthorityPluginRegistrar
{
public const string PluginTypeIdentifier = "test-plugin";
@@ -114,4 +156,38 @@ public class AuthorityPluginLoaderTests
private sealed class TestMarkerService
{
}
private sealed class DiAuthorityPluginRegistrar : IAuthorityPluginRegistrar
{
public const string PluginTypeIdentifier = "test-plugin-di";
private readonly ILogger<DiAuthorityPluginRegistrar> logger;
private readonly TimeProvider timeProvider;
public DiAuthorityPluginRegistrar(ILogger<DiAuthorityPluginRegistrar> logger, TimeProvider timeProvider)
{
this.logger = logger;
this.timeProvider = timeProvider;
}
public string PluginType => PluginTypeIdentifier;
public void Register(AuthorityPluginRegistrationContext context)
{
context.Services.AddSingleton(new DependentService(logger != null, timeProvider != null));
}
}
private sealed class DependentService
{
public DependentService(bool loggerResolved, bool timeProviderResolved)
{
LoggerWasResolved = loggerResolved;
TimeProviderResolved = timeProviderResolved;
}
public bool LoggerWasResolved { get; }
public bool TimeProviderResolved { get; }
}
}

View File

@@ -1,5 +1,9 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Plugins.Abstractions;
@@ -7,29 +11,34 @@ namespace StellaOps.Authority;
internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProviderRegistry
{
private readonly IReadOnlyDictionary<string, IIdentityProviderPlugin> providersByName;
private readonly ReadOnlyCollection<IIdentityProviderPlugin> providers;
private readonly ReadOnlyCollection<IIdentityProviderPlugin> passwordProviders;
private readonly ReadOnlyCollection<IIdentityProviderPlugin> mfaProviders;
private readonly ReadOnlyCollection<IIdentityProviderPlugin> clientProvisioningProviders;
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;
public AuthorityIdentityProviderRegistry(
IEnumerable<IIdentityProviderPlugin> providerInstances,
IServiceProvider serviceProvider,
ILogger<AuthorityIdentityProviderRegistry> logger)
{
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
logger = logger ?? throw new ArgumentNullException(nameof(logger));
using var scope = serviceProvider.CreateScope();
var providerInstances = scope.ServiceProvider.GetServices<IIdentityProviderPlugin>();
var orderedProviders = providerInstances?
.Where(static p => p is not null)
.OrderBy(static p => p.Name, StringComparer.OrdinalIgnoreCase)
.ToList() ?? new List<IIdentityProviderPlugin>();
var uniqueProviders = new List<IIdentityProviderPlugin>(orderedProviders.Count);
var password = new List<IIdentityProviderPlugin>();
var mfa = new List<IIdentityProviderPlugin>();
var clientProvisioning = new List<IIdentityProviderPlugin>();
var uniqueProviders = new List<AuthorityIdentityProviderMetadata>(orderedProviders.Count);
var password = new List<AuthorityIdentityProviderMetadata>();
var mfa = new List<AuthorityIdentityProviderMetadata>();
var clientProvisioning = new List<AuthorityIdentityProviderMetadata>();
var dictionary = new Dictionary<string, IIdentityProviderPlugin>(StringComparer.OrdinalIgnoreCase);
var dictionary = new Dictionary<string, AuthorityIdentityProviderMetadata>(StringComparer.OrdinalIgnoreCase);
foreach (var provider in orderedProviders)
{
@@ -41,7 +50,9 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
continue;
}
if (!dictionary.TryAdd(provider.Name, provider))
var metadata = new AuthorityIdentityProviderMetadata(provider.Name, provider.Type, provider.Capabilities);
if (!dictionary.TryAdd(provider.Name, metadata))
{
logger.LogWarning(
"Duplicate identity provider name '{PluginName}' detected; ignoring additional registration for type '{PluginType}'.",
@@ -50,29 +61,29 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
continue;
}
uniqueProviders.Add(provider);
uniqueProviders.Add(metadata);
if (provider.Capabilities.SupportsPassword)
if (metadata.Capabilities.SupportsPassword)
{
password.Add(provider);
password.Add(metadata);
}
if (provider.Capabilities.SupportsMfa)
if (metadata.Capabilities.SupportsMfa)
{
mfa.Add(provider);
mfa.Add(metadata);
}
if (provider.Capabilities.SupportsClientProvisioning)
if (metadata.Capabilities.SupportsClientProvisioning)
{
clientProvisioning.Add(provider);
clientProvisioning.Add(metadata);
}
}
providersByName = dictionary;
providers = new ReadOnlyCollection<IIdentityProviderPlugin>(uniqueProviders);
passwordProviders = new ReadOnlyCollection<IIdentityProviderPlugin>(password);
mfaProviders = new ReadOnlyCollection<IIdentityProviderPlugin>(mfa);
clientProvisioningProviders = new ReadOnlyCollection<IIdentityProviderPlugin>(clientProvisioning);
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,
@@ -80,24 +91,56 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
SupportsClientProvisioning: clientProvisioningProviders.Count > 0);
}
public IReadOnlyCollection<IIdentityProviderPlugin> Providers => providers;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers => providers;
public IReadOnlyCollection<IIdentityProviderPlugin> PasswordProviders => passwordProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders => passwordProviders;
public IReadOnlyCollection<IIdentityProviderPlugin> MfaProviders => mfaProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders => mfaProviders;
public IReadOnlyCollection<IIdentityProviderPlugin> ClientProvisioningProviders => clientProvisioningProviders;
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders => clientProvisioningProviders;
public AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
public bool TryGet(string name, [NotNullWhen(true)] out IIdentityProviderPlugin? provider)
public bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata)
{
if (string.IsNullOrWhiteSpace(name))
{
provider = null;
metadata = null;
return false;
}
return providersByName.TryGetValue(name, out provider);
return providersByName.TryGetValue(name, out metadata);
}
public async ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken)
{
if (!providersByName.TryGetValue(name, out var metadata))
{
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
}
cancellationToken.ThrowIfCancellationRequested();
var scope = serviceProvider.CreateAsyncScope();
try
{
var provider = scope.ServiceProvider
.GetServices<IIdentityProviderPlugin>()
.FirstOrDefault(p => string.Equals(p.Name, metadata.Name, StringComparison.OrdinalIgnoreCase));
if (provider is null)
{
await scope.DisposeAsync().ConfigureAwait(false);
throw new InvalidOperationException($"Identity provider plugin '{metadata.Name}' could not be resolved.");
}
cancellationToken.ThrowIfCancellationRequested();
return new AuthorityIdentityProviderHandle(scope, metadata, provider);
}
catch
{
await scope.DisposeAsync().ConfigureAwait(false);
throw;
}
}
}

View File

@@ -1,3 +1,4 @@
using System.Linq;
using OpenIddict.Abstractions;
using StellaOps.Authority.Plugins.Abstractions;
@@ -50,11 +51,11 @@ internal static class AuthorityIdentityProviderSelector
internal sealed record ProviderSelectionResult(
bool Succeeded,
IIdentityProviderPlugin? Provider,
AuthorityIdentityProviderMetadata? Provider,
string? Error,
string? Description)
{
public static ProviderSelectionResult Success(IIdentityProviderPlugin provider)
public static ProviderSelectionResult Success(AuthorityIdentityProviderMetadata provider)
=> new(true, provider, null, null);
public static ProviderSelectionResult Failure(string error, string description)

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
@@ -159,25 +159,28 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditConfidentialProperty] =
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
IIdentityProviderPlugin? provider = null;
if (!string.IsNullOrWhiteSpace(document.Plugin))
{
if (!registry.TryGet(document.Plugin, out provider))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Configured identity provider is unavailable.");
logger.LogWarning("Client credentials validation failed for {ClientId}: provider {Provider} unavailable.", context.ClientId, document.Plugin);
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditProviderProperty] = provider.Name;
if (!provider.Capabilities.SupportsClientProvisioning || provider.ClientProvisioning is null)
{
context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Associated identity provider does not support client provisioning.");
logger.LogWarning("Client credentials validation failed for {ClientId}: provider {Provider} lacks client provisioning capabilities.", context.ClientId, provider.Name);
return;
}
}
AuthorityIdentityProviderMetadata? providerMetadata = null;
if (!string.IsNullOrWhiteSpace(document.Plugin))
{
if (!registry.TryGet(document.Plugin, out providerMetadata))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Configured identity provider is unavailable.");
logger.LogWarning("Client credentials validation failed for {ClientId}: provider {Provider} unavailable.", context.ClientId, document.Plugin);
return;
}
await using var providerHandle = await registry.AcquireAsync(providerMetadata.Name, context.CancellationToken).ConfigureAwait(false);
var providerInstance = providerHandle.Provider;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditProviderProperty] = providerMetadata.Name;
if (!providerMetadata.Capabilities.SupportsClientProvisioning || providerInstance.ClientProvisioning is null)
{
context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Associated identity provider does not support client provisioning.");
logger.LogWarning("Client credentials validation failed for {ClientId}: provider {Provider} lacks client provisioning capabilities.", context.ClientId, providerMetadata.Name);
return;
}
}
var allowedGrantTypes = ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
if (allowedGrantTypes.Count > 0 &&
@@ -191,28 +194,28 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
var requiresSecret = string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
if (requiresSecret)
{
if (string.IsNullOrWhiteSpace(document.SecretHash))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client secret is not configured.");
logger.LogWarning("Client credentials validation failed for {ClientId}: secret not configured.", document.ClientId);
return;
}
if (string.IsNullOrWhiteSpace(context.ClientSecret) ||
!ClientCredentialHandlerHelpers.VerifySecret(context.ClientSecret, document.SecretHash))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client credentials.");
logger.LogWarning("Client credentials validation failed for {ClientId}: secret verification failed.", document.ClientId);
return;
}
}
else if (!string.IsNullOrWhiteSpace(context.ClientSecret) && !string.IsNullOrWhiteSpace(document.SecretHash) &&
!ClientCredentialHandlerHelpers.VerifySecret(context.ClientSecret, document.SecretHash))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client credentials.");
logger.LogWarning("Client credentials validation failed for {ClientId}: secret verification failed.", document.ClientId);
return;
}
if (string.IsNullOrWhiteSpace(document.SecretHash))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client secret is not configured.");
logger.LogWarning("Client credentials validation failed for {ClientId}: secret not configured.", document.ClientId);
return;
}
if (string.IsNullOrWhiteSpace(context.ClientSecret) ||
!ClientCredentialHandlerHelpers.VerifySecret(context.ClientSecret, document.SecretHash))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client credentials.");
logger.LogWarning("Client credentials validation failed for {ClientId}: secret verification failed.", document.ClientId);
return;
}
}
else if (!string.IsNullOrWhiteSpace(context.ClientSecret) && !string.IsNullOrWhiteSpace(document.SecretHash) &&
!ClientCredentialHandlerHelpers.VerifySecret(context.ClientSecret, document.SecretHash))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client credentials.");
logger.LogWarning("Client credentials validation failed for {ClientId}: secret verification failed.", document.ClientId);
return;
}
var allowedScopes = ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
var resolvedScopes = ClientCredentialHandlerHelpers.ResolveGrantedScopes(
@@ -230,11 +233,11 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditGrantedScopesProperty] = resolvedScopes.Scopes;
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = document;
if (provider is not null)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty] = provider.Name;
activity?.SetTag("authority.identity_provider", provider.Name);
}
if (providerMetadata is not null)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty] = providerMetadata.Name;
activity?.SetTag("authority.identity_provider", providerMetadata.Name);
}
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes;
logger.LogInformation("Client credentials validated for {ClientId}.", document.ClientId);
@@ -373,70 +376,88 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
_ => new[] { OpenIddictConstants.Destinations.AccessToken }
});
var (provider, descriptor) = await ResolveProviderAsync(context, document).ConfigureAwait(false);
if (context.IsRejected)
{
logger.LogWarning("Client credentials request rejected for {ClientId} during provider resolution.", document.ClientId);
return;
}
if (provider is null)
{
if (!string.IsNullOrWhiteSpace(document.Plugin))
{
identity.SetClaim(StellaOpsClaimTypes.IdentityProvider, document.Plugin);
activity?.SetTag("authority.identity_provider", document.Plugin);
}
}
else
{
identity.SetClaim(StellaOpsClaimTypes.IdentityProvider, provider.Name);
activity?.SetTag("authority.identity_provider", provider.Name);
}
ApplySenderConstraintClaims(context, identity, document);
var principal = new ClaimsPrincipal(identity);
var grantedScopes = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientGrantedScopesProperty, out var scopesValue) &&
scopesValue is IReadOnlyList<string> resolvedScopes
? resolvedScopes
: ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
if (grantedScopes.Count > 0)
{
principal.SetScopes(grantedScopes);
}
else
{
principal.SetScopes(Array.Empty<string>());
}
if (configuredAudiences.Count > 0)
{
principal.SetAudiences(configuredAudiences);
}
if (provider is not null && descriptor is not null)
{
var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user: null, descriptor);
await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
}
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
await PersistTokenAsync(context, document, tokenId, grantedScopes, session, activity).ConfigureAwait(false);
context.Principal = principal;
context.HandleRequest();
logger.LogInformation("Issued client credentials access token for {ClientId} with scopes {Scopes}.", document.ClientId, grantedScopes);
}
private async ValueTask<(IIdentityProviderPlugin? Provider, AuthorityClientDescriptor? Descriptor)> ResolveProviderAsync(
OpenIddictServerEvents.HandleTokenRequestContext context,
AuthorityClientDocument document)
{
string? providerName = null;
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientProviderTransactionProperty, out var providerValue) &&
var (providerHandle, descriptor) = await ResolveProviderAsync(context, document).ConfigureAwait(false);
if (context.IsRejected)
{
if (providerHandle is not null)
{
await providerHandle.DisposeAsync().ConfigureAwait(false);
}
logger.LogWarning("Client credentials request rejected for {ClientId} during provider resolution.", document.ClientId);
return;
}
AuthorityIdentityProviderHandle? handle = providerHandle;
try
{
var provider = handle?.Provider;
if (provider is null)
{
if (!string.IsNullOrWhiteSpace(document.Plugin))
{
identity.SetClaim(StellaOpsClaimTypes.IdentityProvider, document.Plugin);
activity?.SetTag("authority.identity_provider", document.Plugin);
}
}
else
{
identity.SetClaim(StellaOpsClaimTypes.IdentityProvider, provider.Name);
activity?.SetTag("authority.identity_provider", provider.Name);
}
ApplySenderConstraintClaims(context, identity, document);
var principal = new ClaimsPrincipal(identity);
var grantedScopes = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientGrantedScopesProperty, out var scopesValue) &&
scopesValue is IReadOnlyList<string> resolvedScopes
? resolvedScopes
: ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
if (grantedScopes.Count > 0)
{
principal.SetScopes(grantedScopes);
}
else
{
principal.SetScopes(Array.Empty<string>());
}
if (configuredAudiences.Count > 0)
{
principal.SetAudiences(configuredAudiences);
}
if (provider is not null && descriptor is not null)
{
var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user: null, descriptor);
await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false);
}
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
await PersistTokenAsync(context, document, tokenId, grantedScopes, session, activity).ConfigureAwait(false);
context.Principal = principal;
context.HandleRequest();
logger.LogInformation("Issued client credentials access token for {ClientId} with scopes {Scopes}.", document.ClientId, grantedScopes);
}
finally
{
if (handle is not null)
{
await handle.DisposeAsync().ConfigureAwait(false);
}
}
}
private async ValueTask<(AuthorityIdentityProviderHandle? Handle, AuthorityClientDescriptor? Descriptor)> ResolveProviderAsync(
OpenIddictServerEvents.HandleTokenRequestContext context,
AuthorityClientDocument document)
{
string? providerName = null;
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientProviderTransactionProperty, out var providerValue) &&
providerValue is string storedProvider)
{
providerName = storedProvider;
@@ -446,27 +467,46 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
providerName = document.Plugin;
}
if (string.IsNullOrWhiteSpace(providerName))
{
return (null, null);
}
if (!registry.TryGet(providerName, out var provider) || provider.ClientProvisioning is null)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Configured identity provider is unavailable.");
return (null, null);
}
var descriptor = await provider.ClientProvisioning.FindByClientIdAsync(document.ClientId, context.CancellationToken).ConfigureAwait(false);
if (descriptor is null)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client registration was not found.");
return (null, null);
}
return (provider, descriptor);
}
if (string.IsNullOrWhiteSpace(providerName))
{
return (null, null);
}
if (!registry.TryGet(providerName, out var metadata))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Configured identity provider is unavailable.");
return (null, null);
}
var handle = await registry.AcquireAsync(metadata.Name, context.CancellationToken).ConfigureAwait(false);
try
{
var provider = handle.Provider;
if (provider.ClientProvisioning is null)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Associated identity provider does not support client provisioning.");
await handle.DisposeAsync().ConfigureAwait(false);
return (null, null);
}
var descriptor = await provider.ClientProvisioning.FindByClientIdAsync(document.ClientId, context.CancellationToken).ConfigureAwait(false);
if (descriptor is null)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client registration was not found.");
await handle.DisposeAsync().ConfigureAwait(false);
return (null, null);
}
return (handle, descriptor);
}
catch
{
await handle.DisposeAsync().ConfigureAwait(false);
throw;
}
}
private async ValueTask PersistTokenAsync(
OpenIddictServerEvents.HandleTokenRequestContext context,

View File

@@ -367,66 +367,79 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
return new Uri(url, UriKind.Absolute);
}
private static string? ResolveNonceAudience(OpenIddictRequest request, AuthorityDpopNonceOptions nonceOptions, IReadOnlyList<string> configuredAudiences)
{
if (!nonceOptions.Enabled || request is null)
{
return null;
}
if (request.Resources is not null)
{
foreach (var resource in request.Resources)
{
if (string.IsNullOrWhiteSpace(resource))
{
continue;
}
var normalized = resource.Trim();
if (nonceOptions.RequiredAudiences.Contains(normalized))
{
return normalized;
}
}
}
if (request.Audiences is not null)
{
foreach (var audience in request.Audiences)
{
if (string.IsNullOrWhiteSpace(audience))
{
continue;
}
var normalized = audience.Trim();
if (nonceOptions.RequiredAudiences.Contains(normalized))
{
return normalized;
}
}
}
if (configuredAudiences is { Count: > 0 })
{
foreach (var audience in configuredAudiences)
{
if (string.IsNullOrWhiteSpace(audience))
{
continue;
}
var normalized = audience.Trim();
if (nonceOptions.RequiredAudiences.Contains(normalized))
{
return normalized;
}
}
}
return null;
}
private static string? ResolveNonceAudience(
OpenIddictRequest request,
AuthorityDpopNonceOptions nonceOptions,
IReadOnlyList<string> configuredAudiences)
{
if (!nonceOptions.Enabled || request is null)
{
return null;
}
var normalizedAudiences = nonceOptions.NormalizedAudiences;
IReadOnlySet<string> effectiveAudiences;
if (normalizedAudiences.Count > 0)
{
effectiveAudiences = normalizedAudiences;
}
else if (nonceOptions.RequiredAudiences.Count > 0)
{
effectiveAudiences = nonceOptions.RequiredAudiences.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
else
{
return null;
}
bool TryMatch(string? candidate, out string normalized)
{
normalized = string.Empty;
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
normalized = candidate.Trim();
return effectiveAudiences.Contains(normalized);
}
if (request.Resources is not null)
{
foreach (var resource in request.Resources)
{
if (TryMatch(resource, out var normalized))
{
return normalized;
}
}
}
if (request.Audiences is not null)
{
foreach (var audience in request.Audiences)
{
if (TryMatch(audience, out var normalized))
{
return normalized;
}
}
}
if (configuredAudiences is { Count: > 0 })
{
foreach (var audience in configuredAudiences)
{
if (TryMatch(audience, out var normalized))
{
return normalized;
}
}
}
return null;
}
private async ValueTask ChallengeNonceAsync(
OpenIddictServerEvents.ValidateTokenRequestContext context,

View File

@@ -110,6 +110,8 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
return;
}
var selectedProvider = selection.Provider!;
if (string.IsNullOrWhiteSpace(context.Request.Username) || string.IsNullOrEmpty(context.Request.Password))
{
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
@@ -119,7 +121,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
AuthEventOutcome.Failure,
"Both username and password must be provided.",
clientId,
providerName: selection.Provider?.Name,
providerName: selectedProvider.Name,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
@@ -134,9 +136,9 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.ProviderTransactionProperty] = selection.Provider!.Name;
activity?.SetTag("authority.identity_provider", selection.Provider.Name);
logger.LogInformation("Password grant validation succeeded for {Username} using provider {Provider}.", context.Request.Username, selection.Provider.Name);
context.Transaction.Properties[AuthorityOpenIddictConstants.ProviderTransactionProperty] = selectedProvider.Name;
activity?.SetTag("authority.identity_provider", selectedProvider.Name);
logger.LogInformation("Password grant validation succeeded for {Username} using provider {Provider}.", context.Request.Username, selectedProvider.Name);
}
}
@@ -195,10 +197,10 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
? value as string
: null;
IIdentityProviderPlugin? resolvedProvider;
AuthorityIdentityProviderMetadata? providerMetadata = null;
if (!string.IsNullOrWhiteSpace(providerName))
{
if (!registry.TryGet(providerName!, out var explicitProvider))
if (!registry.TryGet(providerName!, out providerMetadata))
{
var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
@@ -221,8 +223,6 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
logger.LogError("Password grant handling failed: provider {Provider} not found for user {Username}.", providerName, context.Request.Username);
return;
}
resolvedProvider = explicitProvider;
}
else
{
@@ -251,11 +251,17 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
return;
}
resolvedProvider = selection.Provider;
providerName = selection.Provider?.Name;
providerMetadata = selection.Provider;
providerName = providerMetadata?.Name;
}
var provider = resolvedProvider ?? throw new InvalidOperationException("No identity provider resolved for password grant.");
if (providerMetadata is null)
{
throw new InvalidOperationException("No identity provider metadata resolved for password grant.");
}
await using var providerHandle = await registry.AcquireAsync(providerMetadata.Name, context.CancellationToken).ConfigureAwait(false);
var provider = providerHandle.Provider;
var username = context.Request.Username;
var password = context.Request.Password;
@@ -268,7 +274,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
AuthEventOutcome.Failure,
"Both username and password must be provided.",
clientId,
provider.Name,
providerMetadata.Name,
user: null,
username: username,
scopes: requestedScopes,
@@ -301,7 +307,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
outcome,
verification.Message,
clientId,
provider.Name,
providerMetadata.Name,
verification.User,
username,
scopes: requestedScopes,
@@ -360,7 +366,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
AuthEventOutcome.Success,
verification.Message,
clientId,
provider.Name,
providerMetadata.Name,
verification.User,
username,
scopes: requestedScopes,

View File

@@ -141,13 +141,16 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
return;
}
if (!registry.TryGet(providerName, out var provider))
if (!registry.TryGet(providerName, out var providerMetadata))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The identity provider associated with the token is unavailable.");
logger.LogWarning("Access token validation failed: provider {Provider} unavailable for subject {Subject}.", providerName, context.Principal.GetClaim(OpenIddictConstants.Claims.Subject));
return;
}
await using var providerHandle = await registry.AcquireAsync(providerMetadata.Name, context.CancellationToken).ConfigureAwait(false);
var provider = providerHandle.Provider;
AuthorityUserDescriptor? user = null;
AuthorityClientDescriptor? client = null;

View File

@@ -6,6 +6,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Authority.Plugins;
@@ -51,7 +52,9 @@ internal static class AuthorityPluginLoader
IReadOnlyCollection<string> missingOrdered,
ILogger? logger)
{
var registrarLookup = DiscoverRegistrars(loadedAssemblies, logger);
var registrarCandidates = DiscoverRegistrars(loadedAssemblies);
var pluginTypeLookup = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
var registrarTypeLookup = new Dictionary<Type, string>();
var registered = new List<string>();
var failures = new List<AuthorityPluginRegistrationFailure>();
@@ -79,7 +82,16 @@ internal static class AuthorityPluginLoader
continue;
}
if (!registrarLookup.TryGetValue(manifest.Type, out var registrar))
var activation = TryResolveActivationForManifest(
services,
manifest.Type,
registrarCandidates,
pluginTypeLookup,
registrarTypeLookup,
logger,
out var registrarType);
if (activation is null || registrarType is null)
{
var reason = $"No registrar found for plugin type '{manifest.Type}'.";
logger?.LogError(
@@ -92,7 +104,9 @@ internal static class AuthorityPluginLoader
try
{
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
PluginServiceRegistration.RegisterAssemblyMetadata(services, registrarType.Assembly, logger);
activation.Registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
registered.Add(manifest.Name);
logger?.LogInformation(
@@ -109,6 +123,10 @@ internal static class AuthorityPluginLoader
manifest.Name);
failures.Add(new AuthorityPluginRegistrationFailure(manifest.Name, reason));
}
finally
{
activation.Dispose();
}
}
if (missingOrdered.Count > 0)
@@ -124,11 +142,9 @@ internal static class AuthorityPluginLoader
return new AuthorityPluginRegistrationSummary(registered, failures, missingOrdered);
}
private static Dictionary<string, IAuthorityPluginRegistrar> DiscoverRegistrars(
IReadOnlyCollection<LoadedPluginDescriptor> loadedAssemblies,
ILogger? logger)
private static IReadOnlyList<Type> DiscoverRegistrars(IReadOnlyCollection<LoadedPluginDescriptor> loadedAssemblies)
{
var lookup = new Dictionary<string, IAuthorityPluginRegistrar>(StringComparer.OrdinalIgnoreCase);
var registrars = new List<Type>();
foreach (var descriptor in loadedAssemblies)
{
@@ -139,43 +155,144 @@ internal static class AuthorityPluginLoader
continue;
}
try
{
if (Activator.CreateInstance(type) is not IAuthorityPluginRegistrar registrar)
{
continue;
}
if (string.IsNullOrWhiteSpace(registrar.PluginType))
{
logger?.LogWarning(
"Authority plugin registrar '{RegistrarType}' returned an empty plugin type and will be ignored.",
type.FullName);
continue;
}
if (lookup.TryGetValue(registrar.PluginType, out var existing))
{
logger?.LogWarning(
"Multiple registrars detected for plugin type '{PluginType}'. Replacing '{ExistingType}' with '{RegistrarType}'.",
registrar.PluginType,
existing.GetType().FullName,
type.FullName);
}
lookup[registrar.PluginType] = registrar;
}
catch (Exception ex)
{
logger?.LogError(
ex,
"Failed to instantiate Authority plugin registrar '{RegistrarType}'.",
type.FullName);
}
registrars.Add(type);
}
}
return lookup;
return registrars;
}
private static RegistrarActivation? TryResolveActivationForManifest(
IServiceCollection services,
string pluginType,
IReadOnlyList<Type> registrarCandidates,
IDictionary<string, Type> pluginTypeLookup,
IDictionary<Type, string> registrarTypeLookup,
ILogger? logger,
out Type? resolvedType)
{
resolvedType = null;
if (pluginTypeLookup.TryGetValue(pluginType, out var cachedType))
{
var cachedActivation = CreateRegistrarActivation(services, cachedType, logger);
if (cachedActivation is null)
{
pluginTypeLookup.Remove(pluginType);
registrarTypeLookup.Remove(cachedType);
return null;
}
resolvedType = cachedType;
return cachedActivation;
}
foreach (var candidate in registrarCandidates)
{
if (registrarTypeLookup.TryGetValue(candidate, out var knownType))
{
if (string.IsNullOrWhiteSpace(knownType))
{
continue;
}
if (string.Equals(knownType, pluginType, StringComparison.OrdinalIgnoreCase))
{
var activation = CreateRegistrarActivation(services, candidate, logger);
if (activation is null)
{
registrarTypeLookup.Remove(candidate);
pluginTypeLookup.Remove(knownType);
return null;
}
resolvedType = candidate;
return activation;
}
continue;
}
var attempt = CreateRegistrarActivation(services, candidate, logger);
if (attempt is null)
{
registrarTypeLookup[candidate] = string.Empty;
continue;
}
var candidateType = attempt.Registrar.PluginType;
if (string.IsNullOrWhiteSpace(candidateType))
{
logger?.LogWarning(
"Authority plugin registrar '{RegistrarType}' reported an empty plugin type and will be ignored.",
candidate.FullName);
registrarTypeLookup[candidate] = string.Empty;
attempt.Dispose();
continue;
}
registrarTypeLookup[candidate] = candidateType;
pluginTypeLookup[candidateType] = candidate;
if (string.Equals(candidateType, pluginType, StringComparison.OrdinalIgnoreCase))
{
resolvedType = candidate;
return attempt;
}
attempt.Dispose();
}
return null;
}
private static RegistrarActivation? CreateRegistrarActivation(IServiceCollection services, Type registrarType, ILogger? logger)
{
ServiceProvider? provider = null;
IServiceScope? scope = null;
try
{
provider = services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true
});
scope = provider.CreateScope();
var registrar = (IAuthorityPluginRegistrar)ActivatorUtilities.GetServiceOrCreateInstance(scope.ServiceProvider, registrarType);
return new RegistrarActivation(provider, scope, registrar);
}
catch (Exception ex)
{
logger?.LogError(
ex,
"Failed to activate Authority plugin registrar '{RegistrarType}'.",
registrarType.FullName);
scope?.Dispose();
provider?.Dispose();
return null;
}
}
private sealed class RegistrarActivation : IDisposable
{
private readonly ServiceProvider provider;
private readonly IServiceScope scope;
public RegistrarActivation(ServiceProvider provider, IServiceScope scope, IAuthorityPluginRegistrar registrar)
{
this.provider = provider;
this.scope = scope;
Registrar = registrar;
}
public IAuthorityPluginRegistrar Registrar { get; }
public void Dispose()
{
scope.Dispose();
provider.Dispose();
}
}
private static bool IsAssemblyLoaded(

View File

@@ -416,24 +416,24 @@ if (authorityOptions.Bootstrap.Enabled)
return Results.BadRequest(new { error = "invite_provider_mismatch", message = "Invite is limited to a different identity provider." });
}
if (string.IsNullOrWhiteSpace(providerName) || !registry.TryGet(providerName!, out var provider))
if (string.IsNullOrWhiteSpace(providerName) || !registry.TryGet(providerName!, out var providerMetadata))
{
await ReleaseInviteAsync("Specified identity provider was not found.");
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Specified identity provider was not found.", null, request.Username, providerName, request.Roles ?? Array.Empty<string>(), inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "invalid_provider", message = "Specified identity provider was not found." });
}
if (!provider.Capabilities.SupportsPassword)
if (!providerMetadata.Capabilities.SupportsPassword)
{
await ReleaseInviteAsync("Selected provider does not support password provisioning.");
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support password provisioning.", null, request.Username, provider.Name, request.Roles ?? Array.Empty<string>(), inviteToken).ConfigureAwait(false);
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support password 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 password provisioning." });
}
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrEmpty(request.Password))
{
await ReleaseInviteAsync("Username and password are required.");
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Username and password are required.", null, request.Username, provider.Name, request.Roles ?? Array.Empty<string>(), inviteToken).ConfigureAwait(false);
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Username and password are required.", null, request.Username, providerMetadata.Name, request.Roles ?? Array.Empty<string>(), inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "invalid_request", message = "Username and password are required." });
}
@@ -458,6 +458,9 @@ if (authorityOptions.Bootstrap.Enabled)
roles,
attributes);
await using var providerHandle = await registry.AcquireAsync(providerMetadata.Name, cancellationToken).ConfigureAwait(false);
var provider = providerHandle.Provider;
try
{
var result = await provider.Credentials.UpsertUserAsync(registration, cancellationToken).ConfigureAwait(false);
@@ -465,7 +468,7 @@ if (authorityOptions.Bootstrap.Enabled)
if (!result.Succeeded || result.Value is null)
{
await ReleaseInviteAsync(result.Message ?? "User provisioning failed.");
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, result.Message ?? "User provisioning failed.", null, request.Username, provider.Name, roles, inviteToken).ConfigureAwait(false);
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, result.Message ?? "User provisioning failed.", null, request.Username, providerMetadata.Name, roles, inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = result.ErrorCode ?? "bootstrap_failed", message = result.Message ?? "User provisioning failed." });
}
@@ -478,11 +481,11 @@ if (authorityOptions.Bootstrap.Enabled)
}
}
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Success, null, result.Value.SubjectId, result.Value.Username, provider.Name, roles, inviteToken).ConfigureAwait(false);
await WriteBootstrapUserAuditAsync(AuthEventOutcome.Success, null, result.Value.SubjectId, result.Value.Username, providerMetadata.Name, roles, inviteToken).ConfigureAwait(false);
return Results.Ok(new
{
provider = provider.Name,
provider = providerMetadata.Name,
subjectId = result.Value.SubjectId,
username = result.Value.Username
});
@@ -701,24 +704,34 @@ if (authorityOptions.Bootstrap.Enabled)
return Results.BadRequest(new { error = "invite_provider_mismatch", message = "Invite is limited to a different identity provider." });
}
if (string.IsNullOrWhiteSpace(providerName) || !registry.TryGet(providerName!, out var provider))
if (string.IsNullOrWhiteSpace(providerName) || !registry.TryGet(providerName!, out var providerMetadata))
{
await ReleaseInviteAsync("Specified identity provider was not found.");
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Specified identity provider was not found.", request.ClientId, null, providerName, request.AllowedScopes ?? Array.Empty<string>(), request?.Confidential, inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "invalid_provider", message = "Specified identity provider was not found." });
}
if (!provider.Capabilities.SupportsClientProvisioning || provider.ClientProvisioning is null)
if (!providerMetadata.Capabilities.SupportsClientProvisioning)
{
await ReleaseInviteAsync("Selected provider does not support client provisioning.");
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support client provisioning.", request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support client 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 client provisioning." });
}
await using var providerHandle = await registry.AcquireAsync(providerMetadata.Name, cancellationToken).ConfigureAwait(false);
var provider = providerHandle.Provider;
if (provider.ClientProvisioning is null)
{
await ReleaseInviteAsync("Selected provider does not support client provisioning.");
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support client 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 client provisioning." });
}
if (string.IsNullOrWhiteSpace(request.ClientId))
{
await ReleaseInviteAsync("ClientId is required.");
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "ClientId is required.", null, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "ClientId is required.", null, null, providerMetadata.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "invalid_request", message = "ClientId is required." });
}
@@ -732,7 +745,7 @@ if (authorityOptions.Bootstrap.Enabled)
if (request.Confidential && string.IsNullOrWhiteSpace(request.ClientSecret))
{
await ReleaseInviteAsync("Confidential clients require a client secret.");
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Confidential clients require a client secret.", request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Confidential clients require a client secret.", request.ClientId, null, providerMetadata.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "invalid_request", message = "Confidential clients require a client secret." });
}
@@ -740,7 +753,7 @@ if (authorityOptions.Bootstrap.Enabled)
{
var errorMessage = redirectError ?? "Redirect URI validation failed.";
await ReleaseInviteAsync(errorMessage);
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, errorMessage, request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, errorMessage, request.ClientId, null, providerMetadata.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "invalid_request", message = errorMessage });
}
@@ -748,7 +761,7 @@ if (authorityOptions.Bootstrap.Enabled)
{
var errorMessage = postLogoutError ?? "Post-logout redirect URI validation failed.";
await ReleaseInviteAsync(errorMessage);
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, errorMessage, request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, errorMessage, request.ClientId, null, providerMetadata.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "invalid_request", message = errorMessage });
}
@@ -765,7 +778,7 @@ if (authorityOptions.Bootstrap.Enabled)
if (binding is null || string.IsNullOrWhiteSpace(binding.Thumbprint))
{
await ReleaseInviteAsync("Certificate binding thumbprint is required.");
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Certificate binding thumbprint is required.", request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Certificate binding thumbprint is required.", request.ClientId, null, providerMetadata.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "invalid_request", message = "Certificate binding thumbprint is required." });
}
@@ -801,7 +814,7 @@ if (authorityOptions.Bootstrap.Enabled)
if (!result.Succeeded || result.Value is null)
{
await ReleaseInviteAsync(result.Message ?? "Client provisioning failed.");
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, result.Message ?? "Client provisioning failed.", request.ClientId, result.Value?.ClientId, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, result.Message ?? "Client provisioning failed.", request.ClientId, result.Value?.ClientId, providerMetadata.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
return Results.BadRequest(new { error = result.ErrorCode ?? "bootstrap_failed", message = result.Message ?? "Client provisioning failed." });
}
@@ -814,11 +827,11 @@ if (authorityOptions.Bootstrap.Enabled)
}
}
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Success, null, request.ClientId, result.Value.ClientId, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
await WriteBootstrapClientAuditAsync(AuthEventOutcome.Success, null, request.ClientId, result.Value.ClientId, providerMetadata.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential, inviteToken).ConfigureAwait(false);
return Results.Ok(new
{
provider = provider.Name,
provider = providerMetadata.Name,
clientId = result.Value.ClientId,
confidential = result.Value.Confidential
});
@@ -1169,12 +1182,13 @@ app.UseAuthorization();
app.MapGet("/health", async (IAuthorityIdentityProviderRegistry registry, CancellationToken cancellationToken) =>
{
var pluginHealth = new List<object>();
foreach (var provider in registry.Providers)
foreach (var providerMetadata in registry.Providers)
{
var health = await provider.CheckHealthAsync(cancellationToken).ConfigureAwait(false);
await using var handle = await registry.AcquireAsync(providerMetadata.Name, cancellationToken).ConfigureAwait(false);
var health = await handle.Provider.CheckHealthAsync(cancellationToken).ConfigureAwait(false);
pluginHealth.Add(new
{
provider = provider.Name,
provider = providerMetadata.Name,
status = health.Status.ToString().ToLowerInvariant(),
message = health.Message
});

View File

@@ -20,13 +20,13 @@
| AUTHCORE-STORAGE-DEVICE-TOKENS | DONE (2025-10-14) | Authority Core, Storage Guild | AUTHCORE-BUILD-OPENIDDICT | Reintroduce `AuthorityTokenDeviceDocument` + projections removed during refactor so storage layer compiles. | ✅ Document type restored with mappings/migrations; ✅ Storage tests cover device artifacts; ✅ Authority solution build green. |
| AUTHCORE-BOOTSTRAP-INVITES | DONE (2025-10-14) | Authority Core, DevOps | AUTHCORE-STORAGE-DEVICE-TOKENS | Wire bootstrap invite cleanup service against restored document schema and re-enable lifecycle tests. | ✅ `BootstrapInviteCleanupService` passes integration tests; ✅ Operator guide updated if behavior changes; ✅ Build/test matrices green. |
| AUTHSTORAGE-MONGO-08-001 | DONE (2025-10-19) | Authority Core & Storage Guild | — | Harden Mongo session usage with causal consistency for mutations and follow-up reads. | • Scoped middleware/service creates `IClientSessionHandle` with causal consistency + majority read/write concerns<br>• Stores accept optional session parameter and reuse it for write + immediate reads<br>• GraphQL/HTTP pipelines updated to flow session through post-mutation queries<br>• Replica-set integration test exercises primary election and verifies read-your-write guarantees |
| AUTH-PLUGIN-COORD-08-002 | DOING (2025-10-19) | Authority Core, Plugin Platform Guild | PLUGIN-DI-08-001 | Coordinate scoped-service adoption for Authority plug-in registrars and background jobs ahead of PLUGIN-DI-08-002 implementation. | ✅ Workshop locked for 2025-10-20 15:0016:00UTC; ✅ Pre-read checklist in `docs/dev/authority-plugin-di-coordination.md`; ✅ Follow-up tasks captured in module backlogs before code changes begin. |
| AUTH-DPOP-11-001 | DOING (2025-10-19) | Authority Core & Security Guild | — | Implement DPoP proof validation + nonce handling for high-value audiences per architecture. | • Proof handler validates method/uri/hash + replay; nonce issuing/consumption implemented for in-memory + Redis stores<br>• Client credential path stamps `cnf.jkt` and persists sender metadata<br>• Remaining: finalize Redis configuration surface (docs/sample config), unskip nonce-challenge regression once HTTP pipeline emits high-value audiences, refresh operator docs |
> Remark (2025-10-19): DPoP handler now seeds request resources/audiences from client metadata; nonce challenge integration test re-enabled (still requires full suite once Concelier build restored).
| AUTH-PLUGIN-COORD-08-002 | DONE (2025-10-20) | Authority Core, Plugin Platform Guild | PLUGIN-DI-08-001 | Coordinate scoped-service adoption for Authority plug-in registrars and background jobs ahead of PLUGIN-DI-08-002 implementation. | ✅ Workshop completed 2025-10-20 15:0016:05UTC with notes/action log in `docs/dev/authority-plugin-di-coordination.md`; ✅ Follow-up backlog updates assigned via documented action items ahead of PLUGIN-DI-08-002 delivery. |
| AUTH-DPOP-11-001 | DONE (2025-10-20) | Authority Core & Security Guild | — | Implement DPoP proof validation + nonce handling for high-value audiences per architecture. | ✅ Redis-configurable nonce store surfaced via `security.senderConstraints.dpop.nonce` with sample YAML and architecture docs refreshed<br>✅ High-value audience enforcement uses normalised required audiences to avoid whitespace/case drift<br>✅ Operator guide updated with Redis-backed nonce snippet and env-var override guidance; integration test already covers nonce challenge |
> Remark (2025-10-20): `etc/authority.yaml.sample` gains senderConstraint sections (rate limits, DPoP, mTLS), docs (`docs/ARCHITECTURE_AUTHORITY.md`, `docs/11_AUTHORITY.md`, plan) refreshed. `ResolveNonceAudience` now relies on `NormalizedAudiences` and options trim persisted values. `dotnet test StellaOps.Authority.sln` attempted (2025-10-20 15:12UTC) but failed on `NU1900` because the mirrored NuGet service index `https://mirrors.ablera.dev/nuget/nuget-mirror/v3/index.json` was unreachable; no project build executed.
| AUTH-MTLS-11-002 | DOING (2025-10-19) | Authority Core & Security Guild | — | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. | • Certificate validator scaffold plus cnf stamping present; tokens persist sender thumbprints<br>• Remaining: provisioning/storage for certificate bindings, SAN/CA validation, introspection propagation, integration tests/docs before marking DONE |
> Remark (2025-10-19): Client provisioning accepts certificate bindings; validator enforces SAN types/CA allow-list with rotation grace; mtls integration tests updated (full suite still blocked by upstream build).
> Remark (2025-10-19, AUTHSTORAGE-MONGO-08-001): Prerequisites re-checked (none outstanding). Session accessor wired through Authority pipeline; stores accept optional sessions; added replica-set election regression test for read-your-write.
> Remark (2025-10-19, AUTH-DPOP-11-001): Handler, nonce store, and persistence hooks merged; Redis-backed configuration + end-to-end nonce enforcement still open. Full solution test blocked by `StellaOps.Concelier.Storage.Mongo` compile errors.
> Remark (2025-10-19, AUTH-DPOP-11-001): Handler, nonce store, and persistence hooks merged; Redis-backed configuration + end-to-end nonce enforcement still open. (Superseded by 2025-10-20 update above.)
> Remark (2025-10-19, AUTH-MTLS-11-002): Certificate validator + cnf stamping delivered; binding storage, CA/SAN validation, integration suites outstanding before status can move to DONE.
> Update status columns (TODO / DOING / DONE / BLOCKED) together with code changes. Always run `dotnet test src/StellaOps.Authority.sln` when touching host logic.