Initial commit (history squashed)

This commit is contained in:
master
2025-10-07 10:14:21 +03:00
commit 016c5a3fe7
1132 changed files with 117842 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
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 RedirectUris = "redirectUris";
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
}

View File

@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Configuration;
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 all registered identity provider plugins keyed by logical name.
/// </summary>
IReadOnlyCollection<IIdentityProviderPlugin> Providers { get; }
/// <summary>
/// Gets identity providers that advertise password support.
/// </summary>
IReadOnlyCollection<IIdentityProviderPlugin> PasswordProviders { get; }
/// <summary>
/// Gets identity providers that advertise multi-factor authentication support.
/// </summary>
IReadOnlyCollection<IIdentityProviderPlugin> MfaProviders { get; }
/// <summary>
/// Gets identity providers that advertise client provisioning support.
/// </summary>
IReadOnlyCollection<IIdentityProviderPlugin> ClientProvisioningProviders { get; }
/// <summary>
/// Aggregate capability flags across all registered providers.
/// </summary>
AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
/// <summary>
/// Attempts to resolve an identity provider by name.
/// </summary>
bool TryGet(string name, [NotNullWhen(true)] out IIdentityProviderPlugin? provider);
/// <summary>
/// Resolves an identity provider by name or throws when not found.
/// </summary>
IIdentityProviderPlugin GetRequired(string name)
{
if (TryGet(name, out var provider))
{
return provider;
}
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,785 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
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)
{
Succeeded = succeeded;
User = user;
FailureCode = failureCode;
Message = message;
RetryAfter = retryAfter;
}
/// <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>
/// Builds a successful verification result.
/// </summary>
public static AuthorityCredentialVerificationResult Success(
AuthorityUserDescriptor user,
string? message = null)
=> new(true, user ?? throw new ArgumentNullException(nameof(user)), null, message, null);
/// <summary>
/// Builds a failed verification result.
/// </summary>
public static AuthorityCredentialVerificationResult Failure(
AuthorityCredentialFailureCode failureCode,
string? message = null,
TimeSpan? retryAfter = null)
=> new(false, null, failureCode, message, retryAfter);
}
/// <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
{
/// <summary>
/// Initialises a new client descriptor.
/// </summary>
public AuthorityClientDescriptor(
string clientId,
string? displayName,
bool confidential,
IReadOnlyCollection<string>? allowedGrantTypes = null,
IReadOnlyCollection<string>? allowedScopes = null,
IReadOnlyCollection<Uri>? redirectUris = null,
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
IReadOnlyDictionary<string, string?>? properties = null)
{
ClientId = ValidateRequired(clientId, nameof(clientId));
DisplayName = displayName;
Confidential = confidential;
AllowedGrantTypes = allowedGrantTypes is null ? Array.Empty<string>() : allowedGrantTypes.ToArray();
AllowedScopes = allowedScopes is null ? Array.Empty<string>() : allowedScopes.ToArray();
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
Properties = properties is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Unique client identifier.
/// </summary>
public string ClientId { get; }
/// <summary>
/// Optional display name.
/// </summary>
public string? DisplayName { get; }
/// <summary>
/// Indicates whether the client is confidential (requires secret).
/// </summary>
public bool Confidential { get; }
/// <summary>
/// Permitted OAuth grant types.
/// </summary>
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
/// <summary>
/// Permitted scopes.
/// </summary>
public IReadOnlyCollection<string> AllowedScopes { get; }
/// <summary>
/// Registered redirect URIs.
/// </summary>
public IReadOnlyCollection<Uri> RedirectUris { get; }
/// <summary>
/// Registered post-logout redirect URIs.
/// </summary>
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
/// <summary>
/// Additional plugin-defined metadata.
/// </summary>
public IReadOnlyDictionary<string, string?> Properties { get; }
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
: value;
}
/// <summary>
/// Client registration payload used when provisioning clients through plugins.
/// </summary>
public sealed record AuthorityClientRegistration
{
/// <summary>
/// Initialises a new registration.
/// </summary>
public AuthorityClientRegistration(
string clientId,
bool confidential,
string? displayName,
string? clientSecret,
IReadOnlyCollection<string>? allowedGrantTypes = null,
IReadOnlyCollection<string>? allowedScopes = null,
IReadOnlyCollection<Uri>? redirectUris = null,
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
IReadOnlyDictionary<string, string?>? properties = null)
{
ClientId = ValidateRequired(clientId, nameof(clientId));
Confidential = confidential;
DisplayName = displayName;
ClientSecret = confidential
? ValidateRequired(clientSecret ?? string.Empty, nameof(clientSecret))
: clientSecret;
AllowedGrantTypes = allowedGrantTypes is null ? Array.Empty<string>() : allowedGrantTypes.ToArray();
AllowedScopes = allowedScopes is null ? Array.Empty<string>() : allowedScopes.ToArray();
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
Properties = properties is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Unique client identifier.
/// </summary>
public string ClientId { get; }
/// <summary>
/// Indicates whether the client is confidential (requires secret handling).
/// </summary>
public bool Confidential { get; }
/// <summary>
/// Optional display name.
/// </summary>
public string? DisplayName { get; }
/// <summary>
/// Optional raw client secret (hashed by the plugin for storage).
/// </summary>
public string? ClientSecret { get; init; }
/// <summary>
/// Grant types to enable.
/// </summary>
public IReadOnlyCollection<string> AllowedGrantTypes { get; }
/// <summary>
/// Scopes assigned to the client.
/// </summary>
public IReadOnlyCollection<string> AllowedScopes { get; }
/// <summary>
/// Redirect URIs permitted for the client.
/// </summary>
public IReadOnlyCollection<Uri> RedirectUris { get; }
/// <summary>
/// Post-logout redirect URIs.
/// </summary>
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
/// <summary>
/// Additional metadata for the plugin.
/// </summary>
public IReadOnlyDictionary<string, string?> Properties { get; }
/// <summary>
/// Creates a copy of the registration with the provided client secret.
/// </summary>
public AuthorityClientRegistration WithClientSecret(string? clientSecret)
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, RedirectUris, PostLogoutRedirectUris, Properties);
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)
: value;
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
</ItemGroup>
</Project>