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:
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>>()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1–PLG5 | 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 Wave 0B 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 | PLG1–PLG3 | 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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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:00–16:00 UTC; ✅ 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:00–16:05 UTC 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:12 UTC) 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.
|
||||
|
||||
Reference in New Issue
Block a user