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,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.

View File

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

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Authority.Plugin.Standard.Tests")]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -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 | PLG1PLG5 | 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 | PLG1PLG3 | 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.