Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known metadata keys persisted with Authority client registrations.
|
||||
/// </summary>
|
||||
public static class AuthorityClientMetadataKeys
|
||||
{
|
||||
public const string AllowedGrantTypes = "allowedGrantTypes";
|
||||
public const string AllowedScopes = "allowedScopes";
|
||||
public const string Audiences = "audiences";
|
||||
public const string RedirectUris = "redirectUris";
|
||||
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
|
||||
public const string SenderConstraint = "senderConstraint";
|
||||
public const string Tenant = "tenant";
|
||||
public const string Project = "project";
|
||||
public const string ServiceIdentity = "serviceIdentity";
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known Authority plugin capability identifiers.
|
||||
/// </summary>
|
||||
public static class AuthorityPluginCapabilities
|
||||
{
|
||||
public const string Password = "password";
|
||||
public const string Bootstrap = "bootstrap";
|
||||
public const string Mfa = "mfa";
|
||||
public const string ClientProvisioning = "clientProvisioning";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable description of an Authority plugin loaded from configuration.
|
||||
/// </summary>
|
||||
/// <param name="Name">Logical name derived from configuration key.</param>
|
||||
/// <param name="Type">Plugin type identifier (used for capability routing).</param>
|
||||
/// <param name="Enabled">Whether the plugin is enabled.</param>
|
||||
/// <param name="AssemblyName">Assembly name without extension.</param>
|
||||
/// <param name="AssemblyPath">Explicit assembly path override.</param>
|
||||
/// <param name="Capabilities">Capability hints exposed by the plugin.</param>
|
||||
/// <param name="Metadata">Additional metadata forwarded to plugin implementations.</param>
|
||||
/// <param name="ConfigPath">Absolute path to the plugin configuration manifest.</param>
|
||||
public sealed record AuthorityPluginManifest(
|
||||
string Name,
|
||||
string Type,
|
||||
bool Enabled,
|
||||
string? AssemblyName,
|
||||
string? AssemblyPath,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
IReadOnlyDictionary<string, string?> Metadata,
|
||||
string ConfigPath)
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether the manifest declares the specified capability.
|
||||
/// </summary>
|
||||
/// <param name="capability">Capability identifier to check.</param>
|
||||
public bool HasCapability(string capability)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(capability))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var entry in Capabilities)
|
||||
{
|
||||
if (string.Equals(entry, capability, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime context combining plugin manifest metadata and its bound configuration.
|
||||
/// </summary>
|
||||
/// <param name="Manifest">Manifest describing the plugin.</param>
|
||||
/// <param name="Configuration">Root configuration built from the plugin YAML manifest.</param>
|
||||
public sealed record AuthorityPluginContext(
|
||||
AuthorityPluginManifest Manifest,
|
||||
IConfiguration Configuration);
|
||||
|
||||
/// <summary>
|
||||
/// Registry exposing the set of Authority plugins loaded at runtime.
|
||||
/// </summary>
|
||||
public interface IAuthorityPluginRegistry
|
||||
{
|
||||
IReadOnlyCollection<AuthorityPluginContext> Plugins { get; }
|
||||
|
||||
bool TryGet(string name, [NotNullWhen(true)] out AuthorityPluginContext? context);
|
||||
|
||||
AuthorityPluginContext GetRequired(string name)
|
||||
{
|
||||
if (TryGet(name, out var context))
|
||||
{
|
||||
return context;
|
||||
}
|
||||
|
||||
throw new KeyNotFoundException($"Authority plugin '{name}' is not registered.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry exposing loaded identity provider plugins and their capabilities.
|
||||
/// </summary>
|
||||
public interface IAuthorityIdentityProviderRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets metadata for all registered identity provider plugins.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for identity providers that advertise password support.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for identity providers that advertise multi-factor authentication support.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for identity providers that advertise client provisioning support.
|
||||
/// </summary>
|
||||
IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate capability flags across all registered providers.
|
||||
/// </summary>
|
||||
AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to resolve identity provider metadata by name.
|
||||
/// </summary>
|
||||
bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves identity provider metadata by name or throws when not found.
|
||||
/// </summary>
|
||||
AuthorityIdentityProviderMetadata GetRequired(string name)
|
||||
{
|
||||
if (TryGet(name, out var metadata))
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides shared services and metadata to Authority plugin registrars during DI setup.
|
||||
/// </summary>
|
||||
public sealed class AuthorityPluginRegistrationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialises a new registration context.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection used to register plugin services.</param>
|
||||
/// <param name="plugin">Plugin context describing the manifest and configuration.</param>
|
||||
/// <param name="hostConfiguration">Root host configuration available during registration.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when any argument is null.</exception>
|
||||
public AuthorityPluginRegistrationContext(
|
||||
IServiceCollection services,
|
||||
AuthorityPluginContext plugin,
|
||||
IConfiguration hostConfiguration)
|
||||
{
|
||||
Services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
Plugin = plugin ?? throw new ArgumentNullException(nameof(plugin));
|
||||
HostConfiguration = hostConfiguration ?? throw new ArgumentNullException(nameof(hostConfiguration));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the service collection used to register plugin dependencies.
|
||||
/// </summary>
|
||||
public IServiceCollection Services { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin context containing manifest metadata and configuration.
|
||||
/// </summary>
|
||||
public AuthorityPluginContext Plugin { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the root configuration associated with the Authority host.
|
||||
/// </summary>
|
||||
public IConfiguration HostConfiguration { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Authority plugin services for a specific plugin type.
|
||||
/// </summary>
|
||||
public interface IAuthorityPluginRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// Logical plugin type identifier supported by this registrar (e.g. <c>standard</c>, <c>ldap</c>).
|
||||
/// </summary>
|
||||
string PluginType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Registers services for the supplied plugin context.
|
||||
/// </summary>
|
||||
/// <param name="context">Registration context containing services and metadata.</param>
|
||||
void Register(AuthorityPluginRegistrationContext context);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic hashing utilities for secrets managed by Authority plugins.
|
||||
/// </summary>
|
||||
public static class AuthoritySecretHasher
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a stable SHA-256 hash for the provided secret.
|
||||
/// </summary>
|
||||
public static string ComputeHash(string secret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(secret));
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,897 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Describes feature support advertised by an identity provider plugin.
|
||||
/// </summary>
|
||||
public sealed record AuthorityIdentityProviderCapabilities(
|
||||
bool SupportsPassword,
|
||||
bool SupportsMfa,
|
||||
bool SupportsClientProvisioning)
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds capabilities metadata from a list of capability identifiers.
|
||||
/// </summary>
|
||||
public static AuthorityIdentityProviderCapabilities FromCapabilities(IEnumerable<string> capabilities)
|
||||
{
|
||||
if (capabilities is null)
|
||||
{
|
||||
return new AuthorityIdentityProviderCapabilities(false, false, false);
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in capabilities)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.Add(entry.Trim());
|
||||
}
|
||||
|
||||
return new AuthorityIdentityProviderCapabilities(
|
||||
SupportsPassword: seen.Contains(AuthorityPluginCapabilities.Password),
|
||||
SupportsMfa: seen.Contains(AuthorityPluginCapabilities.Mfa),
|
||||
SupportsClientProvisioning: seen.Contains(AuthorityPluginCapabilities.ClientProvisioning));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a loaded Authority identity provider plugin instance.
|
||||
/// </summary>
|
||||
public interface IIdentityProviderPlugin
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the logical name of the plugin instance (matches the manifest key).
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin type identifier (e.g. <c>standard</c>, <c>ldap</c>).
|
||||
/// </summary>
|
||||
string Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin context comprising the manifest and bound configuration.
|
||||
/// </summary>
|
||||
AuthorityPluginContext Context { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the credential store responsible for authenticator validation and user provisioning.
|
||||
/// </summary>
|
||||
IUserCredentialStore Credentials { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the claims enricher applied to issued principals.
|
||||
/// </summary>
|
||||
IClaimsEnricher ClaimsEnricher { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional client provisioning store exposed by the plugin.
|
||||
/// </summary>
|
||||
IClientProvisioningStore? ClientProvisioning { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the capability metadata advertised by the plugin.
|
||||
/// </summary>
|
||||
AuthorityIdentityProviderCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the health of the plugin and backing data stores.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token used to cancel the operation.</param>
|
||||
/// <returns>Health result describing the plugin status.</returns>
|
||||
ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supplies operations for validating credentials and managing user records.
|
||||
/// </summary>
|
||||
public interface IUserCredentialStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the supplied username/password combination.
|
||||
/// </summary>
|
||||
ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(
|
||||
string username,
|
||||
string password,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a user record based on the supplied registration data.
|
||||
/// </summary>
|
||||
ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
|
||||
AuthorityUserRegistration registration,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to resolve a user descriptor by its canonical subject identifier.
|
||||
/// </summary>
|
||||
ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(
|
||||
string subjectId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches issued principals with additional claims based on plugin-specific rules.
|
||||
/// </summary>
|
||||
public interface IClaimsEnricher
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds or adjusts claims on the provided identity.
|
||||
/// </summary>
|
||||
ValueTask EnrichAsync(
|
||||
ClaimsIdentity identity,
|
||||
AuthorityClaimsEnrichmentContext context,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages client (machine-to-machine) provisioning for Authority.
|
||||
/// </summary>
|
||||
public interface IClientProvisioningStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates or updates a client registration.
|
||||
/// </summary>
|
||||
ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
|
||||
AuthorityClientRegistration registration,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to resolve a client descriptor by its identifier.
|
||||
/// </summary>
|
||||
ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(
|
||||
string clientId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a client registration.
|
||||
/// </summary>
|
||||
ValueTask<AuthorityPluginOperationResult> DeleteAsync(
|
||||
string clientId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the health state of a plugin or backing store.
|
||||
/// </summary>
|
||||
public enum AuthorityPluginHealthStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Plugin is healthy and operational.
|
||||
/// </summary>
|
||||
Healthy,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is degraded but still usable (e.g. transient connectivity issues).
|
||||
/// </summary>
|
||||
Degraded,
|
||||
|
||||
/// <summary>
|
||||
/// Plugin is unavailable and cannot service requests.
|
||||
/// </summary>
|
||||
Unavailable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a plugin health probe.
|
||||
/// </summary>
|
||||
public sealed record AuthorityPluginHealthResult
|
||||
{
|
||||
private AuthorityPluginHealthResult(
|
||||
AuthorityPluginHealthStatus status,
|
||||
string? message,
|
||||
IReadOnlyDictionary<string, string?> details)
|
||||
{
|
||||
Status = status;
|
||||
Message = message;
|
||||
Details = details;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the overall status of the plugin.
|
||||
/// </summary>
|
||||
public AuthorityPluginHealthStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an optional human-readable status description.
|
||||
/// </summary>
|
||||
public string? Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional structured details for diagnostics.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string?> Details { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a healthy result.
|
||||
/// </summary>
|
||||
public static AuthorityPluginHealthResult Healthy(
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, string?>? details = null)
|
||||
=> new(AuthorityPluginHealthStatus.Healthy, message, details ?? EmptyDetails);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a degraded result.
|
||||
/// </summary>
|
||||
public static AuthorityPluginHealthResult Degraded(
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, string?>? details = null)
|
||||
=> new(AuthorityPluginHealthStatus.Degraded, message, details ?? EmptyDetails);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unavailable result.
|
||||
/// </summary>
|
||||
public static AuthorityPluginHealthResult Unavailable(
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, string?>? details = null)
|
||||
=> new(AuthorityPluginHealthStatus.Unavailable, message, details ?? EmptyDetails);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string?> EmptyDetails =
|
||||
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a canonical Authority user surfaced by a plugin.
|
||||
/// </summary>
|
||||
public sealed record AuthorityUserDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialises a new user descriptor.
|
||||
/// </summary>
|
||||
public AuthorityUserDescriptor(
|
||||
string subjectId,
|
||||
string username,
|
||||
string? displayName,
|
||||
bool requiresPasswordReset,
|
||||
IReadOnlyCollection<string>? roles = null,
|
||||
IReadOnlyDictionary<string, string?>? attributes = null)
|
||||
{
|
||||
SubjectId = ValidateRequired(subjectId, nameof(subjectId));
|
||||
Username = ValidateRequired(username, nameof(username));
|
||||
DisplayName = displayName;
|
||||
RequiresPasswordReset = requiresPasswordReset;
|
||||
Roles = roles is null ? Array.Empty<string>() : roles.ToArray();
|
||||
Attributes = attributes is null
|
||||
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string?>(attributes, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stable subject identifier for token issuance.
|
||||
/// </summary>
|
||||
public string SubjectId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical username (case-normalised).
|
||||
/// </summary>
|
||||
public string Username { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional human-friendly display name.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the user must reset their password.
|
||||
/// </summary>
|
||||
public bool RequiresPasswordReset { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Collection of role identifiers associated with the user.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Roles { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Arbitrary plugin-defined attributes (used by claims enricher).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string?> Attributes { get; }
|
||||
|
||||
private static string ValidateRequired(string value, string paramName)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
|
||||
: value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a credential verification attempt.
|
||||
/// </summary>
|
||||
public sealed record AuthorityCredentialVerificationResult
|
||||
{
|
||||
private AuthorityCredentialVerificationResult(
|
||||
bool succeeded,
|
||||
AuthorityUserDescriptor? user,
|
||||
AuthorityCredentialFailureCode? failureCode,
|
||||
string? message,
|
||||
TimeSpan? retryAfter,
|
||||
IReadOnlyList<AuthEventProperty> auditProperties)
|
||||
{
|
||||
Succeeded = succeeded;
|
||||
User = user;
|
||||
FailureCode = failureCode;
|
||||
Message = message;
|
||||
RetryAfter = retryAfter;
|
||||
AuditProperties = auditProperties ?? Array.Empty<AuthEventProperty>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the verification succeeded.
|
||||
/// </summary>
|
||||
public bool Succeeded { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolved user descriptor when successful.
|
||||
/// </summary>
|
||||
public AuthorityUserDescriptor? User { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Failure classification when unsuccessful.
|
||||
/// </summary>
|
||||
public AuthorityCredentialFailureCode? FailureCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional message describing the outcome.
|
||||
/// </summary>
|
||||
public string? Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional suggested retry interval (e.g. for lockouts).
|
||||
/// </summary>
|
||||
public TimeSpan? RetryAfter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional audit properties emitted by the credential store.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AuthEventProperty> AuditProperties { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds a successful verification result.
|
||||
/// </summary>
|
||||
public static AuthorityCredentialVerificationResult Success(
|
||||
AuthorityUserDescriptor user,
|
||||
string? message = null,
|
||||
IReadOnlyList<AuthEventProperty>? auditProperties = null)
|
||||
=> new(true, user ?? throw new ArgumentNullException(nameof(user)), null, message, null, auditProperties ?? Array.Empty<AuthEventProperty>());
|
||||
|
||||
/// <summary>
|
||||
/// Builds a failed verification result.
|
||||
/// </summary>
|
||||
public static AuthorityCredentialVerificationResult Failure(
|
||||
AuthorityCredentialFailureCode failureCode,
|
||||
string? message = null,
|
||||
TimeSpan? retryAfter = null,
|
||||
IReadOnlyList<AuthEventProperty>? auditProperties = null)
|
||||
=> new(false, null, failureCode, message, retryAfter, auditProperties ?? Array.Empty<AuthEventProperty>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies credential verification failures.
|
||||
/// </summary>
|
||||
public enum AuthorityCredentialFailureCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Username/password combination is invalid.
|
||||
/// </summary>
|
||||
InvalidCredentials,
|
||||
|
||||
/// <summary>
|
||||
/// Account is locked out (retry after a specified duration).
|
||||
/// </summary>
|
||||
LockedOut,
|
||||
|
||||
/// <summary>
|
||||
/// Password has expired and must be reset.
|
||||
/// </summary>
|
||||
PasswordExpired,
|
||||
|
||||
/// <summary>
|
||||
/// User must reset password before proceeding.
|
||||
/// </summary>
|
||||
RequiresPasswordReset,
|
||||
|
||||
/// <summary>
|
||||
/// Additional multi-factor authentication is required.
|
||||
/// </summary>
|
||||
RequiresMfa,
|
||||
|
||||
/// <summary>
|
||||
/// Unexpected failure occurred (see message for details).
|
||||
/// </summary>
|
||||
UnknownError
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a user provisioning request.
|
||||
/// </summary>
|
||||
public sealed record AuthorityUserRegistration
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialises a new registration.
|
||||
/// </summary>
|
||||
public AuthorityUserRegistration(
|
||||
string username,
|
||||
string? password,
|
||||
string? displayName,
|
||||
string? email,
|
||||
bool requirePasswordReset,
|
||||
IReadOnlyCollection<string>? roles = null,
|
||||
IReadOnlyDictionary<string, string?>? attributes = null)
|
||||
{
|
||||
Username = ValidateRequired(username, nameof(username));
|
||||
Password = password;
|
||||
DisplayName = displayName;
|
||||
Email = email;
|
||||
RequirePasswordReset = requirePasswordReset;
|
||||
Roles = roles is null ? Array.Empty<string>() : roles.ToArray();
|
||||
Attributes = attributes is null
|
||||
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string?>(attributes, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical username (unique).
|
||||
/// </summary>
|
||||
public string Username { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional raw password (hashed by plugin).
|
||||
/// </summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional human-friendly display name.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional contact email.
|
||||
/// </summary>
|
||||
public string? Email { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the user must reset their password at next login.
|
||||
/// </summary>
|
||||
public bool RequirePasswordReset { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated roles.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Roles { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin-defined attributes.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string?> Attributes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy with the provided password while preserving other fields.
|
||||
/// </summary>
|
||||
public AuthorityUserRegistration WithPassword(string? password)
|
||||
=> new(Username, password, DisplayName, Email, RequirePasswordReset, Roles, Attributes);
|
||||
|
||||
private static string ValidateRequired(string value, string paramName)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
|
||||
: value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic operation result utilised by plugins.
|
||||
/// </summary>
|
||||
public sealed record AuthorityPluginOperationResult
|
||||
{
|
||||
private AuthorityPluginOperationResult(bool succeeded, string? errorCode, string? message)
|
||||
{
|
||||
Succeeded = succeeded;
|
||||
ErrorCode = errorCode;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the operation succeeded.
|
||||
/// </summary>
|
||||
public bool Succeeded { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Machine-readable error code (populated on failure).
|
||||
/// </summary>
|
||||
public string? ErrorCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional human-readable message.
|
||||
/// </summary>
|
||||
public string? Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns a successful result.
|
||||
/// </summary>
|
||||
public static AuthorityPluginOperationResult Success(string? message = null)
|
||||
=> new(true, null, message);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a failed result with the supplied error code.
|
||||
/// </summary>
|
||||
public static AuthorityPluginOperationResult Failure(string errorCode, string? message = null)
|
||||
=> new(false, ValidateErrorCode(errorCode), message);
|
||||
|
||||
internal static string ValidateErrorCode(string errorCode)
|
||||
=> string.IsNullOrWhiteSpace(errorCode)
|
||||
? throw new ArgumentException("Error code is required for failures.", nameof(errorCode))
|
||||
: errorCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic operation result that returns a value.
|
||||
/// </summary>
|
||||
public sealed record AuthorityPluginOperationResult<TValue>
|
||||
{
|
||||
private AuthorityPluginOperationResult(
|
||||
bool succeeded,
|
||||
TValue? value,
|
||||
string? errorCode,
|
||||
string? message)
|
||||
{
|
||||
Succeeded = succeeded;
|
||||
Value = value;
|
||||
ErrorCode = errorCode;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the operation succeeded.
|
||||
/// </summary>
|
||||
public bool Succeeded { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returned value when successful.
|
||||
/// </summary>
|
||||
public TValue? Value { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Machine-readable error code (on failure).
|
||||
/// </summary>
|
||||
public string? ErrorCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional human-readable message.
|
||||
/// </summary>
|
||||
public string? Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns a successful result with the provided value.
|
||||
/// </summary>
|
||||
public static AuthorityPluginOperationResult<TValue> Success(TValue value, string? message = null)
|
||||
=> new(true, value, null, message);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a successful result without a value (defaults to <c>default</c>).
|
||||
/// </summary>
|
||||
public static AuthorityPluginOperationResult<TValue> Success(string? message = null)
|
||||
=> new(true, default, null, message);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a failed result with the supplied error code.
|
||||
/// </summary>
|
||||
public static AuthorityPluginOperationResult<TValue> Failure(string errorCode, string? message = null)
|
||||
=> new(false, default, AuthorityPluginOperationResult.ValidateErrorCode(errorCode), message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context supplied to claims enrichment routines.
|
||||
/// </summary>
|
||||
public sealed class AuthorityClaimsEnrichmentContext
|
||||
{
|
||||
private readonly Dictionary<string, object?> items;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a new context instance.
|
||||
/// </summary>
|
||||
public AuthorityClaimsEnrichmentContext(
|
||||
AuthorityPluginContext plugin,
|
||||
AuthorityUserDescriptor? user,
|
||||
AuthorityClientDescriptor? client)
|
||||
{
|
||||
Plugin = plugin ?? throw new ArgumentNullException(nameof(plugin));
|
||||
User = user;
|
||||
Client = client;
|
||||
items = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the plugin context associated with the principal.
|
||||
/// </summary>
|
||||
public AuthorityPluginContext Plugin { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user descriptor when available.
|
||||
/// </summary>
|
||||
public AuthorityUserDescriptor? User { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the client descriptor when available.
|
||||
/// </summary>
|
||||
public AuthorityClientDescriptor? Client { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Extensible bag for plugin-specific data passed between enrichment stages.
|
||||
/// </summary>
|
||||
public IDictionary<string, object?> Items => items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a registered OAuth/OpenID client.
|
||||
/// </summary>
|
||||
public sealed record AuthorityClientDescriptor
|
||||
{
|
||||
public AuthorityClientDescriptor(
|
||||
string clientId,
|
||||
string? displayName,
|
||||
bool confidential,
|
||||
IReadOnlyCollection<string>? allowedGrantTypes = null,
|
||||
IReadOnlyCollection<string>? allowedScopes = null,
|
||||
IReadOnlyCollection<string>? allowedAudiences = null,
|
||||
IReadOnlyCollection<Uri>? redirectUris = null,
|
||||
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
|
||||
IReadOnlyDictionary<string, string?>? properties = null)
|
||||
{
|
||||
ClientId = ValidateRequired(clientId, nameof(clientId));
|
||||
DisplayName = displayName;
|
||||
Confidential = confidential;
|
||||
AllowedGrantTypes = Normalize(allowedGrantTypes);
|
||||
AllowedScopes = NormalizeScopes(allowedScopes);
|
||||
AllowedAudiences = Normalize(allowedAudiences);
|
||||
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
|
||||
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
|
||||
var propertyBag = properties is null
|
||||
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
|
||||
Tenant = propertyBag.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue)
|
||||
? AuthorityClientRegistration.NormalizeTenantValue(tenantValue)
|
||||
: null;
|
||||
var normalizedProject = propertyBag.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectValue)
|
||||
? AuthorityClientRegistration.NormalizeProjectValue(projectValue)
|
||||
: null;
|
||||
Project = normalizedProject ?? StellaOpsTenancyDefaults.AnyProject;
|
||||
propertyBag[AuthorityClientMetadataKeys.Project] = Project;
|
||||
Properties = propertyBag;
|
||||
}
|
||||
|
||||
public string ClientId { get; }
|
||||
public string? DisplayName { get; }
|
||||
public bool Confidential { get; }
|
||||
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
|
||||
public IReadOnlyCollection<string> AllowedScopes { get; }
|
||||
public IReadOnlyCollection<string> AllowedAudiences { get; }
|
||||
public IReadOnlyCollection<Uri> RedirectUris { get; }
|
||||
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
|
||||
public string? Tenant { get; }
|
||||
public string? Project { get; }
|
||||
public IReadOnlyDictionary<string, string?> Properties { get; }
|
||||
|
||||
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
|
||||
=> values is null || values.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
private static IReadOnlyCollection<string> NormalizeScopes(IReadOnlyCollection<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
var normalized = StellaOpsScopes.Normalize(value);
|
||||
if (normalized is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
unique.Add(normalized);
|
||||
}
|
||||
|
||||
if (unique.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return unique.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static string ValidateRequired(string value, string paramName)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
|
||||
: value;
|
||||
}
|
||||
|
||||
public sealed record AuthorityClientCertificateBindingRegistration
|
||||
{
|
||||
public AuthorityClientCertificateBindingRegistration(
|
||||
string thumbprint,
|
||||
string? serialNumber = null,
|
||||
string? subject = null,
|
||||
string? issuer = null,
|
||||
IReadOnlyCollection<string>? subjectAlternativeNames = null,
|
||||
DateTimeOffset? notBefore = null,
|
||||
DateTimeOffset? notAfter = null,
|
||||
string? label = null)
|
||||
{
|
||||
Thumbprint = NormalizeThumbprint(thumbprint);
|
||||
SerialNumber = Normalize(serialNumber);
|
||||
Subject = Normalize(subject);
|
||||
Issuer = Normalize(issuer);
|
||||
SubjectAlternativeNames = subjectAlternativeNames is null || subjectAlternativeNames.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: subjectAlternativeNames
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
NotBefore = notBefore;
|
||||
NotAfter = notAfter;
|
||||
Label = Normalize(label);
|
||||
}
|
||||
|
||||
public string Thumbprint { get; }
|
||||
public string? SerialNumber { get; }
|
||||
public string? Subject { get; }
|
||||
public string? Issuer { get; }
|
||||
public IReadOnlyCollection<string> SubjectAlternativeNames { get; }
|
||||
public DateTimeOffset? NotBefore { get; }
|
||||
public DateTimeOffset? NotAfter { get; }
|
||||
public string? Label { get; }
|
||||
|
||||
private static string NormalizeThumbprint(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Thumbprint is required.", nameof(value));
|
||||
}
|
||||
|
||||
return value
|
||||
.Replace(":", string.Empty, StringComparison.Ordinal)
|
||||
.Replace(" ", string.Empty, StringComparison.Ordinal)
|
||||
.ToUpperInvariant();
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
public sealed record AuthorityClientRegistration
|
||||
{
|
||||
public AuthorityClientRegistration(
|
||||
string clientId,
|
||||
bool confidential,
|
||||
string? displayName,
|
||||
string? clientSecret,
|
||||
IReadOnlyCollection<string>? allowedGrantTypes = null,
|
||||
IReadOnlyCollection<string>? allowedScopes = null,
|
||||
IReadOnlyCollection<string>? allowedAudiences = null,
|
||||
IReadOnlyCollection<Uri>? redirectUris = null,
|
||||
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
|
||||
string? tenant = null,
|
||||
string? project = null,
|
||||
IReadOnlyDictionary<string, string?>? properties = null,
|
||||
IReadOnlyCollection<AuthorityClientCertificateBindingRegistration>? certificateBindings = null)
|
||||
{
|
||||
ClientId = ValidateRequired(clientId, nameof(clientId));
|
||||
Confidential = confidential;
|
||||
DisplayName = displayName;
|
||||
ClientSecret = confidential
|
||||
? ValidateRequired(clientSecret ?? string.Empty, nameof(clientSecret))
|
||||
: clientSecret;
|
||||
AllowedGrantTypes = Normalize(allowedGrantTypes);
|
||||
AllowedScopes = NormalizeScopes(allowedScopes);
|
||||
AllowedAudiences = Normalize(allowedAudiences);
|
||||
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
|
||||
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
|
||||
Tenant = NormalizeTenantValue(tenant);
|
||||
var propertyBag = properties is null
|
||||
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
|
||||
var normalizedProject = NormalizeProjectValue(project ?? (propertyBag.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectValue) ? projectValue : null));
|
||||
Project = normalizedProject ?? StellaOpsTenancyDefaults.AnyProject;
|
||||
propertyBag[AuthorityClientMetadataKeys.Project] = Project;
|
||||
Properties = propertyBag;
|
||||
CertificateBindings = certificateBindings is null
|
||||
? Array.Empty<AuthorityClientCertificateBindingRegistration>()
|
||||
: certificateBindings.ToArray();
|
||||
}
|
||||
|
||||
public string ClientId { get; }
|
||||
public bool Confidential { get; }
|
||||
public string? DisplayName { get; }
|
||||
public string? ClientSecret { get; init; }
|
||||
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
|
||||
public IReadOnlyCollection<string> AllowedScopes { get; }
|
||||
public IReadOnlyCollection<string> AllowedAudiences { get; }
|
||||
public IReadOnlyCollection<Uri> RedirectUris { get; }
|
||||
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
|
||||
public string? Tenant { get; }
|
||||
public string? Project { get; }
|
||||
public IReadOnlyDictionary<string, string?> Properties { get; }
|
||||
public IReadOnlyCollection<AuthorityClientCertificateBindingRegistration> CertificateBindings { get; }
|
||||
|
||||
public AuthorityClientRegistration WithClientSecret(string? clientSecret)
|
||||
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, AllowedAudiences, RedirectUris, PostLogoutRedirectUris, Tenant, Project, Properties, CertificateBindings);
|
||||
|
||||
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
|
||||
=> values is null || values.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
private static IReadOnlyCollection<string> NormalizeScopes(IReadOnlyCollection<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var unique = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
var normalized = StellaOpsScopes.Normalize(value);
|
||||
if (normalized is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
unique.Add(normalized);
|
||||
}
|
||||
|
||||
if (unique.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return unique.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
internal static string? NormalizeTenantValue(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
internal static string? NormalizeProjectValue(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ValidateRequired(string value, string paramName)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
|
||||
: value;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateMSBuildEditorConfigFile>false</GenerateMSBuildEditorConfigFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<EditorConfigFiles Remove="$(IntermediateOutputPath)$(MSBuildProjectName).GeneratedMSBuildEditorConfig.editorconfig" />
|
||||
</ItemGroup>
|
||||
<Target Name="EnsureGeneratedEditorConfig" BeforeTargets="ResolveEditorConfigFiles">
|
||||
<WriteLinesToFile File="$(IntermediateOutputPath)$(MSBuildProjectName).GeneratedMSBuildEditorConfig.editorconfig" Lines="" Overwrite="false" />
|
||||
</Target>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user