up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,44 +1,44 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
|
||||
internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
{
|
||||
private readonly string pluginName;
|
||||
private readonly IServiceScopeFactory scopeFactory;
|
||||
private readonly ILogger<StandardPluginBootstrapper> logger;
|
||||
|
||||
public StandardPluginBootstrapper(
|
||||
string pluginName,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<StandardPluginBootstrapper> logger)
|
||||
{
|
||||
this.pluginName = pluginName;
|
||||
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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName);
|
||||
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
|
||||
internal sealed class StandardPluginBootstrapper : IHostedService
|
||||
{
|
||||
private readonly string pluginName;
|
||||
private readonly IServiceScopeFactory scopeFactory;
|
||||
private readonly ILogger<StandardPluginBootstrapper> logger;
|
||||
|
||||
public StandardPluginBootstrapper(
|
||||
string pluginName,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<StandardPluginBootstrapper> logger)
|
||||
{
|
||||
this.pluginName = pluginName;
|
||||
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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName);
|
||||
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,122 +1,122 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard;
|
||||
|
||||
internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
{
|
||||
private const string DefaultTenantId = "default";
|
||||
|
||||
public string PluginType => "standard";
|
||||
|
||||
public void Register(AuthorityPluginRegistrationContext context)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
var pluginName = context.Plugin.Manifest.Name;
|
||||
|
||||
context.Services.AddSingleton<StandardClaimsEnricher>();
|
||||
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
|
||||
|
||||
context.Services.AddStellaOpsCrypto();
|
||||
|
||||
var configPath = context.Plugin.Manifest.ConfigPath;
|
||||
|
||||
context.Services.AddOptions<StandardPluginOptions>(pluginName)
|
||||
.Bind(context.Plugin.Configuration)
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
options.Normalize(configPath);
|
||||
options.Validate(pluginName);
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
|
||||
|
||||
context.Services.AddScoped(sp =>
|
||||
{
|
||||
var userRepository = sp.GetRequiredService<IUserRepository>();
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var pluginOptions = optionsMonitor.Get(pluginName);
|
||||
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
|
||||
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
|
||||
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
|
||||
|
||||
var baselinePolicy = new PasswordPolicyOptions();
|
||||
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
|
||||
{
|
||||
registrarLogger.LogWarning(
|
||||
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
|
||||
pluginName,
|
||||
pluginOptions.PasswordPolicy.MinimumLength,
|
||||
pluginOptions.PasswordPolicy.RequireUppercase,
|
||||
pluginOptions.PasswordPolicy.RequireLowercase,
|
||||
pluginOptions.PasswordPolicy.RequireDigit,
|
||||
pluginOptions.PasswordPolicy.RequireSymbol,
|
||||
baselinePolicy.MinimumLength,
|
||||
baselinePolicy.RequireUppercase,
|
||||
baselinePolicy.RequireLowercase,
|
||||
baselinePolicy.RequireDigit,
|
||||
baselinePolicy.RequireSymbol);
|
||||
}
|
||||
|
||||
// Use tenant from options or default
|
||||
var tenantId = pluginOptions.TenantId ?? DefaultTenantId;
|
||||
|
||||
return new StandardUserCredentialStore(
|
||||
pluginName,
|
||||
tenantId,
|
||||
userRepository,
|
||||
pluginOptions,
|
||||
passwordHasher,
|
||||
auditLogger,
|
||||
loggerFactory.CreateLogger<StandardUserCredentialStore>());
|
||||
});
|
||||
|
||||
context.Services.AddScoped(sp =>
|
||||
{
|
||||
var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
|
||||
var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider);
|
||||
});
|
||||
|
||||
context.Services.AddScoped<IIdentityProviderPlugin>(sp =>
|
||||
{
|
||||
var store = sp.GetRequiredService<StandardUserCredentialStore>();
|
||||
var clientProvisioningStore = sp.GetRequiredService<StandardClientProvisioningStore>();
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
return new StandardIdentityProviderPlugin(
|
||||
context.Plugin,
|
||||
store,
|
||||
clientProvisioningStore,
|
||||
sp.GetRequiredService<StandardClaimsEnricher>(),
|
||||
loggerFactory.CreateLogger<StandardIdentityProviderPlugin>());
|
||||
});
|
||||
|
||||
context.Services.AddScoped<IClientProvisioningStore>(sp =>
|
||||
sp.GetRequiredService<StandardClientProvisioningStore>());
|
||||
|
||||
context.Services.AddSingleton<IHostedService>(sp =>
|
||||
new StandardPluginBootstrapper(
|
||||
pluginName,
|
||||
sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
sp.GetRequiredService<ILogger<StandardPluginBootstrapper>>()));
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Standard.Bootstrap;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Authority.Plugin.Standard.Storage;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard;
|
||||
|
||||
internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
|
||||
{
|
||||
private const string DefaultTenantId = "default";
|
||||
|
||||
public string PluginType => "standard";
|
||||
|
||||
public void Register(AuthorityPluginRegistrationContext context)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
var pluginName = context.Plugin.Manifest.Name;
|
||||
|
||||
context.Services.AddSingleton<StandardClaimsEnricher>();
|
||||
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
|
||||
|
||||
context.Services.AddStellaOpsCrypto();
|
||||
|
||||
var configPath = context.Plugin.Manifest.ConfigPath;
|
||||
|
||||
context.Services.AddOptions<StandardPluginOptions>(pluginName)
|
||||
.Bind(context.Plugin.Configuration)
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
options.Normalize(configPath);
|
||||
options.Validate(pluginName);
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
|
||||
|
||||
context.Services.AddScoped(sp =>
|
||||
{
|
||||
var userRepository = sp.GetRequiredService<IUserRepository>();
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
|
||||
var pluginOptions = optionsMonitor.Get(pluginName);
|
||||
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
|
||||
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
|
||||
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
|
||||
|
||||
var baselinePolicy = new PasswordPolicyOptions();
|
||||
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
|
||||
{
|
||||
registrarLogger.LogWarning(
|
||||
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
|
||||
pluginName,
|
||||
pluginOptions.PasswordPolicy.MinimumLength,
|
||||
pluginOptions.PasswordPolicy.RequireUppercase,
|
||||
pluginOptions.PasswordPolicy.RequireLowercase,
|
||||
pluginOptions.PasswordPolicy.RequireDigit,
|
||||
pluginOptions.PasswordPolicy.RequireSymbol,
|
||||
baselinePolicy.MinimumLength,
|
||||
baselinePolicy.RequireUppercase,
|
||||
baselinePolicy.RequireLowercase,
|
||||
baselinePolicy.RequireDigit,
|
||||
baselinePolicy.RequireSymbol);
|
||||
}
|
||||
|
||||
// Use tenant from options or default
|
||||
var tenantId = pluginOptions.TenantId ?? DefaultTenantId;
|
||||
|
||||
return new StandardUserCredentialStore(
|
||||
pluginName,
|
||||
tenantId,
|
||||
userRepository,
|
||||
pluginOptions,
|
||||
passwordHasher,
|
||||
auditLogger,
|
||||
loggerFactory.CreateLogger<StandardUserCredentialStore>());
|
||||
});
|
||||
|
||||
context.Services.AddScoped(sp =>
|
||||
{
|
||||
var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
|
||||
var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider);
|
||||
});
|
||||
|
||||
context.Services.AddScoped<IIdentityProviderPlugin>(sp =>
|
||||
{
|
||||
var store = sp.GetRequiredService<StandardUserCredentialStore>();
|
||||
var clientProvisioningStore = sp.GetRequiredService<StandardClientProvisioningStore>();
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
return new StandardIdentityProviderPlugin(
|
||||
context.Plugin,
|
||||
store,
|
||||
clientProvisioningStore,
|
||||
sp.GetRequiredService<StandardClaimsEnricher>(),
|
||||
loggerFactory.CreateLogger<StandardIdentityProviderPlugin>());
|
||||
});
|
||||
|
||||
context.Services.AddScoped<IClientProvisioningStore>(sp =>
|
||||
sp.GetRequiredService<StandardClientProvisioningStore>());
|
||||
|
||||
context.Services.AddSingleton<IHostedService>(sp =>
|
||||
new StandardPluginBootstrapper(
|
||||
pluginName,
|
||||
sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
sp.GetRequiredService<ILogger<StandardPluginBootstrapper>>()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Storage.InMemory\StellaOps.Authority.Storage.InMemory.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.Mongo.Documents;
|
||||
using StellaOps.Authority.Storage.Mongo.Stores;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
{
|
||||
private readonly string pluginName;
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
private readonly IAuthorityRevocationStore revocationStore;
|
||||
private readonly TimeProvider clock;
|
||||
|
||||
public StandardClientProvisioningStore(
|
||||
string pluginName,
|
||||
IAuthorityClientStore clientStore,
|
||||
IAuthorityRevocationStore revocationStore,
|
||||
TimeProvider clock)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
|
||||
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
|
||||
AuthorityClientRegistration registration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
|
||||
if (registration.Confidential && string.IsNullOrWhiteSpace(registration.ClientSecret))
|
||||
{
|
||||
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("secret_required", "Confidential clients require a client secret.");
|
||||
}
|
||||
|
||||
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
|
||||
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
|
||||
|
||||
document.Plugin = pluginName;
|
||||
document.ClientType = registration.Confidential ? "confidential" : "public";
|
||||
document.DisplayName = registration.DisplayName;
|
||||
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
|
||||
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
|
||||
: null;
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
|
||||
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
|
||||
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes);
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
|
||||
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
|
||||
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
|
||||
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
|
||||
|
||||
if (registration.CertificateBindings is not null)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
document.CertificateBindings = registration.CertificateBindings
|
||||
.Select(binding => MapCertificateBinding(binding, now))
|
||||
.OrderBy(binding => binding.Thumbprint, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Storage.InMemory.Documents;
|
||||
using StellaOps.Authority.Storage.InMemory.Stores;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Storage;
|
||||
|
||||
internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
{
|
||||
private readonly string pluginName;
|
||||
private readonly IAuthorityClientStore clientStore;
|
||||
private readonly IAuthorityRevocationStore revocationStore;
|
||||
private readonly TimeProvider clock;
|
||||
|
||||
public StandardClientProvisioningStore(
|
||||
string pluginName,
|
||||
IAuthorityClientStore clientStore,
|
||||
IAuthorityRevocationStore revocationStore,
|
||||
TimeProvider clock)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
|
||||
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
|
||||
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
|
||||
AuthorityClientRegistration registration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
|
||||
if (registration.Confidential && string.IsNullOrWhiteSpace(registration.ClientSecret))
|
||||
{
|
||||
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("secret_required", "Confidential clients require a client secret.");
|
||||
}
|
||||
|
||||
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
|
||||
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
|
||||
|
||||
document.Plugin = pluginName;
|
||||
document.ClientType = registration.Confidential ? "confidential" : "public";
|
||||
document.DisplayName = registration.DisplayName;
|
||||
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
|
||||
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
|
||||
: null;
|
||||
document.UpdatedAt = clock.GetUtcNow();
|
||||
|
||||
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
|
||||
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
|
||||
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes);
|
||||
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
|
||||
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
|
||||
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
|
||||
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
|
||||
|
||||
if (registration.CertificateBindings is not null)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
document.CertificateBindings = registration.CertificateBindings
|
||||
.Select(binding => MapCertificateBinding(binding, now))
|
||||
.OrderBy(binding => binding.Thumbprint, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
foreach (var (key, value) in registration.Properties)
|
||||
{
|
||||
document.Properties[key] = value;
|
||||
@@ -79,113 +79,113 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
{
|
||||
document.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
|
||||
}
|
||||
|
||||
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
|
||||
{
|
||||
var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw);
|
||||
if (normalizedConstraint is not null)
|
||||
{
|
||||
document.SenderConstraint = normalizedConstraint;
|
||||
document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint;
|
||||
}
|
||||
else
|
||||
{
|
||||
document.SenderConstraint = null;
|
||||
document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint);
|
||||
}
|
||||
}
|
||||
|
||||
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await clientStore.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : ToDescriptor(document);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
|
||||
if (!deleted)
|
||||
{
|
||||
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["plugin"] = pluginName
|
||||
};
|
||||
|
||||
var revocation = new AuthorityRevocationDocument
|
||||
{
|
||||
Category = "client",
|
||||
RevocationId = clientId,
|
||||
ClientId = clientId,
|
||||
Reason = "operator_request",
|
||||
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
|
||||
RevokedAt = now,
|
||||
EffectiveAt = now,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Revocation export should proceed even if the metadata write fails.
|
||||
}
|
||||
|
||||
return AuthorityPluginOperationResult.Success();
|
||||
}
|
||||
|
||||
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
|
||||
{
|
||||
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
|
||||
var allowedScopes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
|
||||
|
||||
var redirectUris = document.RedirectUris
|
||||
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.Where(static uri => uri is not null)
|
||||
.Cast<Uri>()
|
||||
.ToArray();
|
||||
|
||||
var postLogoutUris = document.PostLogoutRedirectUris
|
||||
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.Where(static uri => uri is not null)
|
||||
.Cast<Uri>()
|
||||
.ToArray();
|
||||
|
||||
var audiences = Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
|
||||
|
||||
return new AuthorityClientDescriptor(
|
||||
document.ClientId,
|
||||
document.DisplayName,
|
||||
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
|
||||
allowedGrantTypes,
|
||||
allowedScopes,
|
||||
audiences,
|
||||
redirectUris,
|
||||
postLogoutUris,
|
||||
document.Properties);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
|
||||
{
|
||||
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
|
||||
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
|
||||
{
|
||||
var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw);
|
||||
if (normalizedConstraint is not null)
|
||||
{
|
||||
document.SenderConstraint = normalizedConstraint;
|
||||
document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint;
|
||||
}
|
||||
else
|
||||
{
|
||||
document.SenderConstraint = null;
|
||||
document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint);
|
||||
}
|
||||
}
|
||||
|
||||
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await clientStore.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : ToDescriptor(document);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
|
||||
if (!deleted)
|
||||
{
|
||||
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["plugin"] = pluginName
|
||||
};
|
||||
|
||||
var revocation = new AuthorityRevocationDocument
|
||||
{
|
||||
Category = "client",
|
||||
RevocationId = clientId,
|
||||
ClientId = clientId,
|
||||
Reason = "operator_request",
|
||||
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
|
||||
RevokedAt = now,
|
||||
EffectiveAt = now,
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Revocation export should proceed even if the metadata write fails.
|
||||
}
|
||||
|
||||
return AuthorityPluginOperationResult.Success();
|
||||
}
|
||||
|
||||
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
|
||||
{
|
||||
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
|
||||
var allowedScopes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
|
||||
|
||||
var redirectUris = document.RedirectUris
|
||||
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.Where(static uri => uri is not null)
|
||||
.Cast<Uri>()
|
||||
.ToArray();
|
||||
|
||||
var postLogoutUris = document.PostLogoutRedirectUris
|
||||
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
|
||||
.Where(static uri => uri is not null)
|
||||
.Cast<Uri>()
|
||||
.ToArray();
|
||||
|
||||
var audiences = Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
|
||||
|
||||
return new AuthorityClientDescriptor(
|
||||
document.ClientId,
|
||||
document.DisplayName,
|
||||
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
|
||||
allowedGrantTypes,
|
||||
allowedScopes,
|
||||
audiences,
|
||||
redirectUris,
|
||||
postLogoutUris,
|
||||
document.Properties);
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
|
||||
{
|
||||
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static string JoinValues(IReadOnlyCollection<string> values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
@@ -207,42 +207,42 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
|
||||
private static AuthorityClientCertificateBinding MapCertificateBinding(
|
||||
AuthorityClientCertificateBindingRegistration registration,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var subjectAlternativeNames = registration.SubjectAlternativeNames.Count == 0
|
||||
? new List<string>()
|
||||
: registration.SubjectAlternativeNames
|
||||
.Select(name => name.Trim())
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new AuthorityClientCertificateBinding
|
||||
{
|
||||
Thumbprint = registration.Thumbprint,
|
||||
SerialNumber = registration.SerialNumber,
|
||||
Subject = registration.Subject,
|
||||
Issuer = registration.Issuer,
|
||||
SubjectAlternativeNames = subjectAlternativeNames,
|
||||
NotBefore = registration.NotBefore,
|
||||
NotAfter = registration.NotAfter,
|
||||
Label = registration.Label,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeSenderConstraint(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim() switch
|
||||
{
|
||||
{ Length: 0 } => null,
|
||||
var constraint when string.Equals(constraint, "dpop", StringComparison.OrdinalIgnoreCase) => "dpop",
|
||||
var constraint when string.Equals(constraint, "mtls", StringComparison.OrdinalIgnoreCase) => "mtls",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
{
|
||||
var subjectAlternativeNames = registration.SubjectAlternativeNames.Count == 0
|
||||
? new List<string>()
|
||||
: registration.SubjectAlternativeNames
|
||||
.Select(name => name.Trim())
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new AuthorityClientCertificateBinding
|
||||
{
|
||||
Thumbprint = registration.Thumbprint,
|
||||
SerialNumber = registration.SerialNumber,
|
||||
Subject = registration.Subject,
|
||||
Issuer = registration.Issuer,
|
||||
SubjectAlternativeNames = subjectAlternativeNames,
|
||||
NotBefore = registration.NotBefore,
|
||||
NotAfter = registration.NotAfter,
|
||||
Label = registration.Label,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeSenderConstraint(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim() switch
|
||||
{
|
||||
{ Length: 0 } => null,
|
||||
var constraint when string.Equals(constraint, "dpop", StringComparison.OrdinalIgnoreCase) => "dpop",
|
||||
var constraint when string.Equals(constraint, "mtls", StringComparison.OrdinalIgnoreCase) => "mtls",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user