Initial commit (history squashed)
This commit is contained in:
		@@ -0,0 +1,20 @@
 | 
			
		||||
# Plugin Team Charter
 | 
			
		||||
 | 
			
		||||
## Mission
 | 
			
		||||
Own the Mongo-backed Standard identity provider plug-in and shared Authority plug-in contracts. Deliver secure credential flows, configuration validation, and documentation that help other identity providers integrate cleanly.
 | 
			
		||||
 | 
			
		||||
## Responsibilities
 | 
			
		||||
- Maintain `StellaOps.Authority.Plugin.Standard` and related test projects.
 | 
			
		||||
- Coordinate schema/option changes with Authority Core and Docs guilds.
 | 
			
		||||
- Ensure plugin options remain deterministic and offline-friendly.
 | 
			
		||||
- Surface open work on `TASKS.md`; update statuses (TODO/DOING/DONE/BLOCKED/REVIEW).
 | 
			
		||||
 | 
			
		||||
## Key Paths
 | 
			
		||||
- `StandardPluginOptions` & registrar wiring
 | 
			
		||||
- `StandardUserCredentialStore` (Mongo persistence + lockouts)
 | 
			
		||||
- `docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md`
 | 
			
		||||
 | 
			
		||||
## Coordination
 | 
			
		||||
- Team 2 (Authority Core) for handler integration.
 | 
			
		||||
- Security Guild for password hashing, audit, revocation.
 | 
			
		||||
- Docs Guild for developer guide polish and diagrams.
 | 
			
		||||
@@ -0,0 +1,42 @@
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
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 IOptionsMonitor<StandardPluginOptions> optionsMonitor;
 | 
			
		||||
    private readonly StandardUserCredentialStore credentialStore;
 | 
			
		||||
    private readonly ILogger<StandardPluginBootstrapper> logger;
 | 
			
		||||
 | 
			
		||||
    public StandardPluginBootstrapper(
 | 
			
		||||
        string pluginName,
 | 
			
		||||
        IOptionsMonitor<StandardPluginOptions> optionsMonitor,
 | 
			
		||||
        StandardUserCredentialStore credentialStore,
 | 
			
		||||
        ILogger<StandardPluginBootstrapper> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.pluginName = pluginName;
 | 
			
		||||
        this.optionsMonitor = optionsMonitor;
 | 
			
		||||
        this.credentialStore = credentialStore;
 | 
			
		||||
        this.logger = logger;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task StartAsync(CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        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;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,3 @@
 | 
			
		||||
using System.Runtime.CompilerServices;
 | 
			
		||||
 | 
			
		||||
[assembly: InternalsVisibleTo("StellaOps.Authority.Plugin.Standard.Tests")]
 | 
			
		||||
@@ -0,0 +1,113 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Security.Cryptography;
 | 
			
		||||
using System.Text;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard.Security;
 | 
			
		||||
 | 
			
		||||
internal interface IPasswordHasher
 | 
			
		||||
{
 | 
			
		||||
    string Hash(string password);
 | 
			
		||||
 | 
			
		||||
    PasswordVerificationResult Verify(string password, string hashedPassword);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal enum PasswordVerificationResult
 | 
			
		||||
{
 | 
			
		||||
    Failed,
 | 
			
		||||
    Success,
 | 
			
		||||
    SuccessRehashNeeded
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class Pbkdf2PasswordHasher : IPasswordHasher
 | 
			
		||||
{
 | 
			
		||||
    private const int SaltSize = 16;
 | 
			
		||||
    private const int HashSize = 32;
 | 
			
		||||
    private const int Iterations = 210_000;
 | 
			
		||||
    private const string Header = "PBKDF2";
 | 
			
		||||
 | 
			
		||||
    public string Hash(string password)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrEmpty(password))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Password is required.", nameof(password));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Span<byte> salt = stackalloc byte[SaltSize];
 | 
			
		||||
        RandomNumberGenerator.Fill(salt);
 | 
			
		||||
 | 
			
		||||
        Span<byte> hash = stackalloc byte[HashSize];
 | 
			
		||||
        var derived = Rfc2898DeriveBytes.Pbkdf2(password, salt.ToArray(), Iterations, HashAlgorithmName.SHA256, HashSize);
 | 
			
		||||
        derived.CopyTo(hash);
 | 
			
		||||
 | 
			
		||||
        var payload = new byte[1 + SaltSize + HashSize];
 | 
			
		||||
        payload[0] = 0x01; // version
 | 
			
		||||
        salt.CopyTo(payload.AsSpan(1));
 | 
			
		||||
        hash.CopyTo(payload.AsSpan(1 + SaltSize));
 | 
			
		||||
 | 
			
		||||
        var builder = new StringBuilder();
 | 
			
		||||
        builder.Append(Header);
 | 
			
		||||
        builder.Append('.');
 | 
			
		||||
        builder.Append(Iterations);
 | 
			
		||||
        builder.Append('.');
 | 
			
		||||
        builder.Append(Convert.ToBase64String(payload));
 | 
			
		||||
        return builder.ToString();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public PasswordVerificationResult Verify(string password, string hashedPassword)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(hashedPassword))
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var parts = hashedPassword.Split('.', StringSplitOptions.RemoveEmptyEntries);
 | 
			
		||||
        if (parts.Length != 3 || !string.Equals(parts[0], Header, StringComparison.Ordinal))
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!int.TryParse(parts[1], out var iterations))
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        byte[] payload;
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            payload = Convert.FromBase64String(parts[2]);
 | 
			
		||||
        }
 | 
			
		||||
        catch (FormatException)
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (payload.Length != 1 + SaltSize + HashSize)
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var version = payload[0];
 | 
			
		||||
        if (version != 0x01)
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var salt = new byte[SaltSize];
 | 
			
		||||
        Array.Copy(payload, 1, salt, 0, SaltSize);
 | 
			
		||||
 | 
			
		||||
        var expectedHash = new byte[HashSize];
 | 
			
		||||
        Array.Copy(payload, 1 + SaltSize, expectedHash, 0, HashSize);
 | 
			
		||||
 | 
			
		||||
        var actualHash = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, HashSize);
 | 
			
		||||
 | 
			
		||||
        var success = CryptographicOperations.FixedTimeEquals(expectedHash, actualHash);
 | 
			
		||||
        if (!success)
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return iterations < Iterations
 | 
			
		||||
            ? PasswordVerificationResult.SuccessRehashNeeded
 | 
			
		||||
            : PasswordVerificationResult.Success;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,43 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Security.Claims;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using StellaOps.Authority.Plugins.Abstractions;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard;
 | 
			
		||||
 | 
			
		||||
internal sealed class StandardClaimsEnricher : IClaimsEnricher
 | 
			
		||||
{
 | 
			
		||||
    public ValueTask EnrichAsync(
 | 
			
		||||
        ClaimsIdentity identity,
 | 
			
		||||
        AuthorityClaimsEnrichmentContext context,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (identity is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentNullException(nameof(identity));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (context.User is { } user)
 | 
			
		||||
        {
 | 
			
		||||
            foreach (var role in user.Roles.Where(static r => !string.IsNullOrWhiteSpace(r)))
 | 
			
		||||
            {
 | 
			
		||||
                if (!identity.HasClaim(ClaimTypes.Role, role))
 | 
			
		||||
                {
 | 
			
		||||
                    identity.AddClaim(new Claim(ClaimTypes.Role, role));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            foreach (var pair in user.Attributes)
 | 
			
		||||
            {
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(pair.Key) && !identity.HasClaim(pair.Key, pair.Value ?? string.Empty))
 | 
			
		||||
                {
 | 
			
		||||
                    identity.AddClaim(new Claim(pair.Key, pair.Value ?? string.Empty));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return ValueTask.CompletedTask;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,65 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using StellaOps.Authority.Plugins.Abstractions;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard.Storage;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard;
 | 
			
		||||
 | 
			
		||||
internal sealed class StandardIdentityProviderPlugin : IIdentityProviderPlugin
 | 
			
		||||
{
 | 
			
		||||
    private readonly ILogger<StandardIdentityProviderPlugin> logger;
 | 
			
		||||
 | 
			
		||||
    public StandardIdentityProviderPlugin(
 | 
			
		||||
        AuthorityPluginContext context,
 | 
			
		||||
        StandardUserCredentialStore credentialStore,
 | 
			
		||||
        StandardClientProvisioningStore clientProvisioningStore,
 | 
			
		||||
        IClaimsEnricher claimsEnricher,
 | 
			
		||||
        ILogger<StandardIdentityProviderPlugin> logger)
 | 
			
		||||
    {
 | 
			
		||||
        Context = context ?? throw new ArgumentNullException(nameof(context));
 | 
			
		||||
        Credentials = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore));
 | 
			
		||||
        ClientProvisioning = clientProvisioningStore ?? throw new ArgumentNullException(nameof(clientProvisioningStore));
 | 
			
		||||
        ClaimsEnricher = claimsEnricher ?? throw new ArgumentNullException(nameof(claimsEnricher));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
 | 
			
		||||
        var manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(context.Manifest.Capabilities);
 | 
			
		||||
        if (!manifestCapabilities.SupportsPassword)
 | 
			
		||||
        {
 | 
			
		||||
            this.logger.LogWarning(
 | 
			
		||||
                "Standard Authority plugin '{PluginName}' manifest does not declare the 'password' capability. Forcing password support.",
 | 
			
		||||
                Context.Manifest.Name);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Capabilities = manifestCapabilities with { SupportsPassword = true };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public string Name => Context.Manifest.Name;
 | 
			
		||||
 | 
			
		||||
    public string Type => Context.Manifest.Type;
 | 
			
		||||
 | 
			
		||||
    public AuthorityPluginContext Context { get; }
 | 
			
		||||
 | 
			
		||||
    public IUserCredentialStore Credentials { get; }
 | 
			
		||||
 | 
			
		||||
    public IClaimsEnricher ClaimsEnricher { get; }
 | 
			
		||||
 | 
			
		||||
    public IClientProvisioningStore? ClientProvisioning { get; }
 | 
			
		||||
 | 
			
		||||
    public AuthorityIdentityProviderCapabilities Capabilities { get; }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var store = (StandardUserCredentialStore)Credentials;
 | 
			
		||||
            return await store.CheckHealthAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Standard Authority plugin '{PluginName}' health check failed.", Name);
 | 
			
		||||
            return AuthorityPluginHealthResult.Unavailable(ex.Message);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,130 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.IO;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard;
 | 
			
		||||
 | 
			
		||||
internal sealed class StandardPluginOptions
 | 
			
		||||
{
 | 
			
		||||
    public BootstrapUserOptions? BootstrapUser { get; set; }
 | 
			
		||||
 | 
			
		||||
    public PasswordPolicyOptions PasswordPolicy { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    public LockoutOptions Lockout { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    public TokenSigningOptions TokenSigning { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    public void Normalize(string configPath)
 | 
			
		||||
    {
 | 
			
		||||
        TokenSigning.Normalize(configPath);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void Validate(string pluginName)
 | 
			
		||||
    {
 | 
			
		||||
        BootstrapUser?.Validate(pluginName);
 | 
			
		||||
        PasswordPolicy.Validate(pluginName);
 | 
			
		||||
        Lockout.Validate(pluginName);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class BootstrapUserOptions
 | 
			
		||||
{
 | 
			
		||||
    public string? Username { get; set; }
 | 
			
		||||
 | 
			
		||||
    public string? Password { get; set; }
 | 
			
		||||
 | 
			
		||||
    public bool RequirePasswordReset { get; set; } = true;
 | 
			
		||||
 | 
			
		||||
    public bool IsConfigured => !string.IsNullOrWhiteSpace(Username) && !string.IsNullOrWhiteSpace(Password);
 | 
			
		||||
 | 
			
		||||
    public void Validate(string pluginName)
 | 
			
		||||
    {
 | 
			
		||||
        var hasUsername = !string.IsNullOrWhiteSpace(Username);
 | 
			
		||||
        var hasPassword = !string.IsNullOrWhiteSpace(Password);
 | 
			
		||||
 | 
			
		||||
        if (hasUsername ^ hasPassword)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Standard plugin '{pluginName}' requires both bootstrapUser.username and bootstrapUser.password when configuring a bootstrap user.");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class PasswordPolicyOptions
 | 
			
		||||
{
 | 
			
		||||
    public int MinimumLength { get; set; } = 12;
 | 
			
		||||
 | 
			
		||||
    public bool RequireUppercase { get; set; } = true;
 | 
			
		||||
 | 
			
		||||
    public bool RequireLowercase { get; set; } = true;
 | 
			
		||||
 | 
			
		||||
    public bool RequireDigit { get; set; } = true;
 | 
			
		||||
 | 
			
		||||
    public bool RequireSymbol { get; set; } = true;
 | 
			
		||||
 | 
			
		||||
    public void Validate(string pluginName)
 | 
			
		||||
    {
 | 
			
		||||
        if (MinimumLength <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Standard plugin '{pluginName}' requires passwordPolicy.minimumLength to be greater than zero.");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class LockoutOptions
 | 
			
		||||
{
 | 
			
		||||
    public bool Enabled { get; set; } = true;
 | 
			
		||||
 | 
			
		||||
    public int MaxAttempts { get; set; } = 5;
 | 
			
		||||
 | 
			
		||||
    public int WindowMinutes { get; set; } = 15;
 | 
			
		||||
 | 
			
		||||
    public TimeSpan Window => TimeSpan.FromMinutes(WindowMinutes <= 0 ? 15 : WindowMinutes);
 | 
			
		||||
 | 
			
		||||
    public void Validate(string pluginName)
 | 
			
		||||
    {
 | 
			
		||||
        if (Enabled && MaxAttempts <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Standard plugin '{pluginName}' requires lockout.maxAttempts to be greater than zero when lockout is enabled.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (Enabled && WindowMinutes <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException($"Standard plugin '{pluginName}' requires lockout.windowMinutes to be greater than zero when lockout is enabled.");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class TokenSigningOptions
 | 
			
		||||
{
 | 
			
		||||
    public string? KeyDirectory { get; set; }
 | 
			
		||||
 | 
			
		||||
    public void Normalize(string configPath)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(KeyDirectory))
 | 
			
		||||
        {
 | 
			
		||||
            KeyDirectory = null;
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var resolved = KeyDirectory.Trim();
 | 
			
		||||
        if (string.IsNullOrEmpty(resolved))
 | 
			
		||||
        {
 | 
			
		||||
            KeyDirectory = null;
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        resolved = Environment.ExpandEnvironmentVariables(resolved);
 | 
			
		||||
 | 
			
		||||
        if (!Path.IsPathRooted(resolved))
 | 
			
		||||
        {
 | 
			
		||||
            var baseDirectory = Path.GetDirectoryName(configPath);
 | 
			
		||||
            if (string.IsNullOrEmpty(baseDirectory))
 | 
			
		||||
            {
 | 
			
		||||
                baseDirectory = Directory.GetCurrentDirectory();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            resolved = Path.Combine(baseDirectory, resolved);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        KeyDirectory = Path.GetFullPath(resolved);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,88 @@
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection;
 | 
			
		||||
using Microsoft.Extensions.DependencyInjection.Extensions;
 | 
			
		||||
using Microsoft.Extensions.Hosting;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard;
 | 
			
		||||
 | 
			
		||||
internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
 | 
			
		||||
{
 | 
			
		||||
    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.TryAddSingleton<IPasswordHasher, Pbkdf2PasswordHasher>();
 | 
			
		||||
        context.Services.AddSingleton<StandardClaimsEnricher>();
 | 
			
		||||
        context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
 | 
			
		||||
 | 
			
		||||
        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.AddSingleton(sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
 | 
			
		||||
            var pluginOptions = optionsMonitor.Get(pluginName);
 | 
			
		||||
            var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
 | 
			
		||||
            var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
 | 
			
		||||
 | 
			
		||||
            return new StandardUserCredentialStore(
 | 
			
		||||
                pluginName,
 | 
			
		||||
                database,
 | 
			
		||||
                pluginOptions,
 | 
			
		||||
                passwordHasher,
 | 
			
		||||
                loggerFactory.CreateLogger<StandardUserCredentialStore>());
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        context.Services.AddSingleton(sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
 | 
			
		||||
            return new StandardClientProvisioningStore(pluginName, clientStore);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        context.Services.AddSingleton<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.AddSingleton<IClientProvisioningStore>(sp =>
 | 
			
		||||
            sp.GetRequiredService<StandardClientProvisioningStore>());
 | 
			
		||||
 | 
			
		||||
        context.Services.AddSingleton<IHostedService>(sp =>
 | 
			
		||||
            new StandardPluginBootstrapper(
 | 
			
		||||
                pluginName,
 | 
			
		||||
                sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>(),
 | 
			
		||||
                sp.GetRequiredService<StandardUserCredentialStore>(),
 | 
			
		||||
                sp.GetRequiredService<ILogger<StandardPluginBootstrapper>>()));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,22 @@
 | 
			
		||||
<Project Sdk="Microsoft.NET.Sdk">
 | 
			
		||||
  <PropertyGroup>
 | 
			
		||||
    <TargetFramework>net10.0</TargetFramework>
 | 
			
		||||
    <LangVersion>preview</LangVersion>
 | 
			
		||||
    <ImplicitUsings>enable</ImplicitUsings>
 | 
			
		||||
    <Nullable>enable</Nullable>
 | 
			
		||||
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
 | 
			
		||||
    <IsAuthorityPlugin>true</IsAuthorityPlugin>
 | 
			
		||||
  </PropertyGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
 | 
			
		||||
    <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
 | 
			
		||||
    <PackageReference Include="MongoDB.Driver" Version="2.22.0" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
  <ItemGroup>
 | 
			
		||||
    <ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
 | 
			
		||||
    <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
 | 
			
		||||
    <ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
 | 
			
		||||
    <ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
@@ -0,0 +1,109 @@
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
    public StandardClientProvisioningStore(string pluginName, IAuthorityClientStore clientStore)
 | 
			
		||||
    {
 | 
			
		||||
        this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
 | 
			
		||||
        this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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 = DateTimeOffset.UtcNow };
 | 
			
		||||
 | 
			
		||||
        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.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
 | 
			
		||||
        document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
 | 
			
		||||
 | 
			
		||||
        document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = string.Join(" ", registration.AllowedGrantTypes);
 | 
			
		||||
        document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = string.Join(" ", registration.AllowedScopes);
 | 
			
		||||
        document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
 | 
			
		||||
        document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
 | 
			
		||||
 | 
			
		||||
        foreach (var (key, value) in registration.Properties)
 | 
			
		||||
        {
 | 
			
		||||
            document.Properties[key] = value;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await clientStore.UpsertAsync(document, 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);
 | 
			
		||||
        return deleted
 | 
			
		||||
            ? AuthorityPluginOperationResult.Success()
 | 
			
		||||
            : AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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();
 | 
			
		||||
 | 
			
		||||
        return new AuthorityClientDescriptor(
 | 
			
		||||
            document.ClientId,
 | 
			
		||||
            document.DisplayName,
 | 
			
		||||
            string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
 | 
			
		||||
            allowedGrantTypes,
 | 
			
		||||
            allowedScopes,
 | 
			
		||||
            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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,329 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Plugins.Abstractions;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard.Security;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard.Storage;
 | 
			
		||||
 | 
			
		||||
internal sealed class StandardUserCredentialStore : IUserCredentialStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly IMongoCollection<StandardUserDocument> users;
 | 
			
		||||
    private readonly StandardPluginOptions options;
 | 
			
		||||
    private readonly IPasswordHasher passwordHasher;
 | 
			
		||||
    private readonly ILogger<StandardUserCredentialStore> logger;
 | 
			
		||||
    private readonly string pluginName;
 | 
			
		||||
 | 
			
		||||
    public StandardUserCredentialStore(
 | 
			
		||||
        string pluginName,
 | 
			
		||||
        IMongoDatabase database,
 | 
			
		||||
        StandardPluginOptions options,
 | 
			
		||||
        IPasswordHasher passwordHasher,
 | 
			
		||||
        ILogger<StandardUserCredentialStore> logger)
 | 
			
		||||
    {
 | 
			
		||||
        this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
 | 
			
		||||
        this.options = options ?? throw new ArgumentNullException(nameof(options));
 | 
			
		||||
        this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher));
 | 
			
		||||
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(database);
 | 
			
		||||
 | 
			
		||||
        var collectionName = $"authority_users_{pluginName.ToLowerInvariant()}";
 | 
			
		||||
        users = database.GetCollection<StandardUserDocument>(collectionName);
 | 
			
		||||
        EnsureIndexes();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(
 | 
			
		||||
        string username,
 | 
			
		||||
        string password,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password))
 | 
			
		||||
        {
 | 
			
		||||
            return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalized = NormalizeUsername(username);
 | 
			
		||||
        var user = await users.Find(u => u.NormalizedUsername == normalized)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (user is null)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogWarning("Plugin {PluginName} failed password verification for unknown user {Username}.", pluginName, normalized);
 | 
			
		||||
            return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
 | 
			
		||||
        {
 | 
			
		||||
            var retryAfter = lockoutEnd - DateTimeOffset.UtcNow;
 | 
			
		||||
            logger.LogWarning("Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter}).", pluginName, normalized, retryAfter);
 | 
			
		||||
            return AuthorityCredentialVerificationResult.Failure(
 | 
			
		||||
                AuthorityCredentialFailureCode.LockedOut,
 | 
			
		||||
                "Account is temporarily locked.",
 | 
			
		||||
                retryAfter);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var verification = passwordHasher.Verify(password, user.PasswordHash);
 | 
			
		||||
        if (verification is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded)
 | 
			
		||||
        {
 | 
			
		||||
            if (verification == PasswordVerificationResult.SuccessRehashNeeded)
 | 
			
		||||
            {
 | 
			
		||||
                user.PasswordHash = passwordHasher.Hash(password);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            ResetLockout(user);
 | 
			
		||||
            user.UpdatedAt = DateTimeOffset.UtcNow;
 | 
			
		||||
            await users.ReplaceOneAsync(
 | 
			
		||||
                Builders<StandardUserDocument>.Filter.Eq(u => u.Id, user.Id),
 | 
			
		||||
                user,
 | 
			
		||||
                cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            var descriptor = ToDescriptor(user);
 | 
			
		||||
            return AuthorityCredentialVerificationResult.Success(descriptor, descriptor.RequiresPasswordReset ? "Password reset required." : null);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await RegisterFailureAsync(user, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        var code = options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockout
 | 
			
		||||
            ? AuthorityCredentialFailureCode.LockedOut
 | 
			
		||||
            : AuthorityCredentialFailureCode.InvalidCredentials;
 | 
			
		||||
 | 
			
		||||
        TimeSpan? retry = user.Lockout.LockoutEnd is { } lockoutTime && lockoutTime > DateTimeOffset.UtcNow
 | 
			
		||||
            ? lockoutTime - DateTimeOffset.UtcNow
 | 
			
		||||
            : null;
 | 
			
		||||
 | 
			
		||||
        return AuthorityCredentialVerificationResult.Failure(
 | 
			
		||||
            code,
 | 
			
		||||
            code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.",
 | 
			
		||||
            retry);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
 | 
			
		||||
        AuthorityUserRegistration registration,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(registration);
 | 
			
		||||
 | 
			
		||||
        var normalized = NormalizeUsername(registration.Username);
 | 
			
		||||
        var now = DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrEmpty(registration.Password))
 | 
			
		||||
        {
 | 
			
		||||
            var passwordValidation = ValidatePassword(registration.Password);
 | 
			
		||||
            if (passwordValidation is not null)
 | 
			
		||||
            {
 | 
			
		||||
                return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("password_policy_violation", passwordValidation);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var existing = await users.Find(u => u.NormalizedUsername == normalized)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (existing is null)
 | 
			
		||||
        {
 | 
			
		||||
            if (string.IsNullOrEmpty(registration.Password))
 | 
			
		||||
            {
 | 
			
		||||
                return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("password_required", "New users require a password.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var document = new StandardUserDocument
 | 
			
		||||
            {
 | 
			
		||||
                Username = registration.Username,
 | 
			
		||||
                NormalizedUsername = normalized,
 | 
			
		||||
                DisplayName = registration.DisplayName,
 | 
			
		||||
                Email = registration.Email,
 | 
			
		||||
                PasswordHash = passwordHasher.Hash(registration.Password!),
 | 
			
		||||
                RequirePasswordReset = registration.RequirePasswordReset,
 | 
			
		||||
                Roles = registration.Roles.ToList(),
 | 
			
		||||
                Attributes = new Dictionary<string, string?>(registration.Attributes, StringComparer.OrdinalIgnoreCase),
 | 
			
		||||
                CreatedAt = now,
 | 
			
		||||
                UpdatedAt = now
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            await users.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(document));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        existing.Username = registration.Username;
 | 
			
		||||
        existing.DisplayName = registration.DisplayName ?? existing.DisplayName;
 | 
			
		||||
        existing.Email = registration.Email ?? existing.Email;
 | 
			
		||||
        existing.Roles = registration.Roles.Any()
 | 
			
		||||
            ? registration.Roles.ToList()
 | 
			
		||||
            : existing.Roles;
 | 
			
		||||
 | 
			
		||||
        if (registration.Attributes.Count > 0)
 | 
			
		||||
        {
 | 
			
		||||
            foreach (var pair in registration.Attributes)
 | 
			
		||||
            {
 | 
			
		||||
                existing.Attributes[pair.Key] = pair.Value;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrEmpty(registration.Password))
 | 
			
		||||
        {
 | 
			
		||||
            existing.PasswordHash = passwordHasher.Hash(registration.Password!);
 | 
			
		||||
            existing.RequirePasswordReset = registration.RequirePasswordReset;
 | 
			
		||||
        }
 | 
			
		||||
        else if (registration.RequirePasswordReset)
 | 
			
		||||
        {
 | 
			
		||||
            existing.RequirePasswordReset = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        existing.UpdatedAt = now;
 | 
			
		||||
 | 
			
		||||
        await users.ReplaceOneAsync(
 | 
			
		||||
            Builders<StandardUserDocument>.Filter.Eq(u => u.Id, existing.Id),
 | 
			
		||||
            existing,
 | 
			
		||||
            cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(existing));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(subjectId))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var user = await users.Find(u => u.SubjectId == subjectId)
 | 
			
		||||
            .FirstOrDefaultAsync(cancellationToken)
 | 
			
		||||
            .ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return user is null ? null : ToDescriptor(user);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task EnsureBootstrapUserAsync(BootstrapUserOptions bootstrap, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (bootstrap is null || !bootstrap.IsConfigured)
 | 
			
		||||
        {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var registration = new AuthorityUserRegistration(
 | 
			
		||||
            bootstrap.Username!,
 | 
			
		||||
            bootstrap.Password,
 | 
			
		||||
            displayName: bootstrap.Username,
 | 
			
		||||
            email: null,
 | 
			
		||||
            requirePasswordReset: bootstrap.RequirePasswordReset,
 | 
			
		||||
            roles: Array.Empty<string>(),
 | 
			
		||||
            attributes: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
 | 
			
		||||
 | 
			
		||||
        var result = await UpsertUserAsync(registration, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (!result.Succeeded)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogWarning(
 | 
			
		||||
                "Plugin {PluginName} failed to seed bootstrap user '{Username}': {Reason}",
 | 
			
		||||
                pluginName,
 | 
			
		||||
                bootstrap.Username,
 | 
			
		||||
                result.ErrorCode);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var command = new BsonDocument("ping", 1);
 | 
			
		||||
            await users.Database.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            return AuthorityPluginHealthResult.Healthy();
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Plugin {PluginName} failed MongoDB health check.", pluginName);
 | 
			
		||||
            return AuthorityPluginHealthResult.Unavailable(ex.Message);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private string? ValidatePassword(string password)
 | 
			
		||||
    {
 | 
			
		||||
        if (password.Length < options.PasswordPolicy.MinimumLength)
 | 
			
		||||
        {
 | 
			
		||||
            return $"Password must be at least {options.PasswordPolicy.MinimumLength} characters long.";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (options.PasswordPolicy.RequireUppercase && !password.Any(char.IsUpper))
 | 
			
		||||
        {
 | 
			
		||||
            return "Password must contain an uppercase letter.";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (options.PasswordPolicy.RequireLowercase && !password.Any(char.IsLower))
 | 
			
		||||
        {
 | 
			
		||||
            return "Password must contain a lowercase letter.";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (options.PasswordPolicy.RequireDigit && !password.Any(char.IsDigit))
 | 
			
		||||
        {
 | 
			
		||||
            return "Password must contain a digit.";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (options.PasswordPolicy.RequireSymbol && password.All(char.IsLetterOrDigit))
 | 
			
		||||
        {
 | 
			
		||||
            return "Password must contain a symbol.";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task RegisterFailureAsync(StandardUserDocument user, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        user.Lockout.LastFailure = DateTimeOffset.UtcNow;
 | 
			
		||||
        user.Lockout.FailedAttempts += 1;
 | 
			
		||||
 | 
			
		||||
        if (options.Lockout.Enabled && user.Lockout.FailedAttempts >= options.Lockout.MaxAttempts)
 | 
			
		||||
        {
 | 
			
		||||
            user.Lockout.LockoutEnd = DateTimeOffset.UtcNow + options.Lockout.Window;
 | 
			
		||||
            user.Lockout.FailedAttempts = 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await users.ReplaceOneAsync(
 | 
			
		||||
            Builders<StandardUserDocument>.Filter.Eq(u => u.Id, user.Id),
 | 
			
		||||
            user,
 | 
			
		||||
            cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static void ResetLockout(StandardUserDocument user)
 | 
			
		||||
    {
 | 
			
		||||
        user.Lockout.FailedAttempts = 0;
 | 
			
		||||
        user.Lockout.LockoutEnd = null;
 | 
			
		||||
        user.Lockout.LastFailure = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string NormalizeUsername(string username)
 | 
			
		||||
        => username.Trim().ToLowerInvariant();
 | 
			
		||||
 | 
			
		||||
    private AuthorityUserDescriptor ToDescriptor(StandardUserDocument document)
 | 
			
		||||
        => new(
 | 
			
		||||
            document.SubjectId,
 | 
			
		||||
            document.Username,
 | 
			
		||||
            document.DisplayName,
 | 
			
		||||
            document.RequirePasswordReset,
 | 
			
		||||
            document.Roles,
 | 
			
		||||
            document.Attributes);
 | 
			
		||||
 | 
			
		||||
    private void EnsureIndexes()
 | 
			
		||||
    {
 | 
			
		||||
        var indexKeys = Builders<StandardUserDocument>.IndexKeys
 | 
			
		||||
            .Ascending(u => u.NormalizedUsername);
 | 
			
		||||
 | 
			
		||||
        var indexModel = new CreateIndexModel<StandardUserDocument>(
 | 
			
		||||
            indexKeys,
 | 
			
		||||
            new CreateIndexOptions { Unique = true, Name = "idx_normalized_username" });
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            users.Indexes.CreateOne(indexModel);
 | 
			
		||||
        }
 | 
			
		||||
        catch (MongoCommandException ex) when (ex.CodeName.Equals("IndexOptionsConflict", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogDebug("Plugin {PluginName} skipped index creation due to existing index.", pluginName);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,64 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Bson.Serialization.Attributes;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard.Storage;
 | 
			
		||||
 | 
			
		||||
internal sealed class StandardUserDocument
 | 
			
		||||
{
 | 
			
		||||
    [BsonId]
 | 
			
		||||
    public ObjectId Id { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("subjectId")]
 | 
			
		||||
    public string SubjectId { get; set; } = Guid.NewGuid().ToString("N");
 | 
			
		||||
 | 
			
		||||
    [BsonElement("username")]
 | 
			
		||||
    public string Username { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("normalizedUsername")]
 | 
			
		||||
    public string NormalizedUsername { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("passwordHash")]
 | 
			
		||||
    public string PasswordHash { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("displayName")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? DisplayName { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("email")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public string? Email { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("requirePasswordReset")]
 | 
			
		||||
    public bool RequirePasswordReset { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("roles")]
 | 
			
		||||
    public List<string> Roles { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("attributes")]
 | 
			
		||||
    public Dictionary<string, string?> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
    [BsonElement("lockout")]
 | 
			
		||||
    public StandardLockoutState Lockout { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [BsonElement("createdAt")]
 | 
			
		||||
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
    [BsonElement("updatedAt")]
 | 
			
		||||
    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class StandardLockoutState
 | 
			
		||||
{
 | 
			
		||||
    [BsonElement("failedAttempts")]
 | 
			
		||||
    public int FailedAttempts { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("lockoutEnd")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public DateTimeOffset? LockoutEnd { get; set; }
 | 
			
		||||
 | 
			
		||||
    [BsonElement("lastFailure")]
 | 
			
		||||
    [BsonIgnoreIfNull]
 | 
			
		||||
    public DateTimeOffset? LastFailure { get; set; }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,16 @@
 | 
			
		||||
# Team 8 / Plugin Standard Backlog (UTC 2025-10-10)
 | 
			
		||||
 | 
			
		||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
 | 
			
		||||
|----|--------|----------|------------|-------------|---------------|
 | 
			
		||||
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1–PLG5 | Final polish + diagrams for plugin developer guide. | Docs team delivers copy-edit + exported diagrams; PR merged. |
 | 
			
		||||
| SEC1.PLG | TODO | Security Guild, BE-Auth Plugin | SEC1.A (StellaOps.Cryptography) | Swap Standard plugin hashing to Argon2id via `StellaOps.Cryptography` abstractions; keep PBKDF2 verification for legacy. | ✅ `StandardUserCredentialStore` uses `ICryptoProvider` to hash/check; ✅ Transparent rehash on success; ✅ Unit tests cover tamper + legacy rehash. |
 | 
			
		||||
| SEC1.OPT | TODO | Security Guild | SEC1.PLG | Expose password hashing knobs in `StandardPluginOptions` (`memoryKiB`, `iterations`, `parallelism`, `algorithm`) with validation. | ✅ Options bound from YAML; ✅ Invalid configs throw; ✅ Docs include tuning guidance. |
 | 
			
		||||
| SEC2.PLG | TODO | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
 | 
			
		||||
| SEC3.PLG | TODO | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
 | 
			
		||||
| SEC4.PLG | TODO | Security Guild | SEC4.A (revocation schema) | Provide plugin hooks so revoked users/clients write reasons for revocation bundle export. | ✅ Revocation exporter consumes plugin data; ✅ Tests cover revoked user/client output. |
 | 
			
		||||
| SEC5.PLG | TODO | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
 | 
			
		||||
| PLG4-6.CAPABILITIES | DOING (2025-10-10) | BE-Auth Plugin, Docs Guild | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. |
 | 
			
		||||
| PLG7.RFC | REVIEW | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
 | 
			
		||||
| PLG6.DIAGRAM | TODO | Docs Guild | PLG6.DOC | Export final sequence/component diagrams for the developer guide and add offline-friendly assets under `docs/assets/authority`. | ✅ Mermaid sources committed; ✅ Rendered SVG/PNG linked from Section 2 + Section 9; ✅ Docs build preview shared with Plugin + Docs guilds. |
 | 
			
		||||
 | 
			
		||||
> Update statuses to DOING/DONE/BLOCKED as you make progress. Always run `dotnet test` for touched projects before marking DONE.
 | 
			
		||||
		Reference in New Issue
	
	Block a user