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,66 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using Xunit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardClientProvisioningStoreTests
{
[Fact]
public async Task CreateOrUpdateAsync_HashesSecretAndPersistsDocument()
{
var store = new TrackingClientStore();
var provisioning = new StandardClientProvisioningStore("standard", store);
var registration = new AuthorityClientRegistration(
clientId: "bootstrap-client",
confidential: true,
displayName: "Bootstrap",
clientSecret: "SuperSecret1!",
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "scopeA" });
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded);
Assert.True(store.Documents.TryGetValue("bootstrap-client", out var document));
Assert.NotNull(document);
Assert.Equal(AuthoritySecretHasher.ComputeHash("SuperSecret1!"), document!.SecretHash);
Assert.Equal("standard", document.Plugin);
var descriptor = await provisioning.FindByClientIdAsync("bootstrap-client", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal("bootstrap-client", descriptor!.ClientId);
Assert.True(descriptor.Confidential);
Assert.Contains("client_credentials", descriptor.AllowedGrantTypes);
Assert.Contains("scopeA", descriptor.AllowedScopes);
}
private sealed class TrackingClientStore : IAuthorityClientStore
{
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
Documents.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
{
Documents[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
var removed = Documents.Remove(clientId);
return ValueTask.FromResult(removed);
}
}
}

View File

@@ -0,0 +1,99 @@
using System;
using System.IO;
using StellaOps.Authority.Plugin.Standard;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardPluginOptionsTests
{
[Fact]
public void Validate_AllowsBootstrapWhenCredentialsProvided()
{
var options = new StandardPluginOptions
{
BootstrapUser = new BootstrapUserOptions
{
Username = "admin",
Password = "Bootstrap1!",
RequirePasswordReset = true
}
};
options.Validate("standard");
}
[Fact]
public void Validate_Throws_WhenBootstrapUserIncomplete()
{
var options = new StandardPluginOptions
{
BootstrapUser = new BootstrapUserOptions
{
Username = "admin",
Password = null
}
};
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
Assert.Contains("bootstrapUser", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_WhenLockoutWindowMinutesInvalid()
{
var options = new StandardPluginOptions
{
Lockout = new LockoutOptions
{
Enabled = true,
MaxAttempts = 5,
WindowMinutes = 0
}
};
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
Assert.Contains("lockout.windowMinutes", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Normalize_ResolvesRelativeTokenSigningDirectory()
{
var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(configDir);
try
{
var configPath = Path.Combine(configDir, "standard.yaml");
var options = new StandardPluginOptions
{
TokenSigning = { KeyDirectory = "../keys" }
};
options.Normalize(configPath);
var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
Assert.Equal(expected, options.TokenSigning.KeyDirectory);
}
finally
{
if (Directory.Exists(configDir))
{
Directory.Delete(configDir, recursive: true);
}
}
}
[Fact]
public void Normalize_PreservesAbsoluteTokenSigningDirectory()
{
var absolute = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"), "keys");
var options = new StandardPluginOptions
{
TokenSigning = { KeyDirectory = absolute }
};
options.Normalize(Path.Combine(Path.GetTempPath(), "config", "standard.yaml"));
Assert.Equal(Path.GetFullPath(absolute), options.TokenSigning.KeyDirectory);
}
}

View File

@@ -0,0 +1,227 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardPluginRegistrarTests
{
[Fact]
public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-tests");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["passwordPolicy:minimumLength"] = "8",
["passwordPolicy:requireDigit"] = "false",
["passwordPolicy:requireSymbol"] = "false",
["lockout:enabled"] = "false",
["bootstrapUser:username"] = "bootstrap",
["bootstrapUser:password"] = "Bootstrap1!",
["bootstrapUser:requirePasswordReset"] = "true"
})
.Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Bootstrap, AuthorityPluginCapabilities.ClientProvisioning },
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
var provider = services.BuildServiceProvider();
var hostedServices = provider.GetServices<IHostedService>();
foreach (var hosted in hostedServices)
{
if (hosted is StandardPluginBootstrapper bootstrapper)
{
await bootstrapper.StartAsync(CancellationToken.None);
}
}
var plugin = provider.GetRequiredService<IIdentityProviderPlugin>();
Assert.Equal("standard", plugin.Type);
Assert.True(plugin.Capabilities.SupportsPassword);
Assert.False(plugin.Capabilities.SupportsMfa);
Assert.True(plugin.Capabilities.SupportsClientProvisioning);
var verification = await plugin.Credentials.VerifyPasswordAsync("bootstrap", "Bootstrap1!", CancellationToken.None);
Assert.True(verification.Succeeded);
Assert.True(verification.User?.RequiresPasswordReset);
}
[Fact]
public void Register_ForcesPasswordCapability_WhenManifestMissing()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-capabilities");
var configuration = new ConfigurationBuilder().Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
Array.Empty<string>(),
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
var plugin = provider.GetRequiredService<IIdentityProviderPlugin>();
Assert.True(plugin.Capabilities.SupportsPassword);
}
[Fact]
public void Register_Throws_WhenBootstrapConfigurationIncomplete()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-bootstrap-validation");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["bootstrapUser:username"] = "bootstrap"
})
.Build();
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IIdentityProviderPlugin>());
}
[Fact]
public void Register_NormalizesTokenSigningKeyDirectory()
{
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
var database = client.GetDatabase("registrar-token-signing");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["tokenSigning:keyDirectory"] = "../keys"
})
.Build();
var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(configDir);
try
{
var configPath = Path.Combine(configDir, "standard.yaml");
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
typeof(StandardPluginRegistrar).Assembly.GetName().Name,
typeof(StandardPluginRegistrar).Assembly.Location,
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
configPath);
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var options = optionsMonitor.Get("standard");
var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
Assert.Equal(expected, options.TokenSigning.KeyDirectory);
}
finally
{
if (Directory.Exists(configDir))
{
Directory.Delete(configDir, recursive: true);
}
}
}
}
internal sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
{
clients[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken)
=> ValueTask.FromResult(clients.Remove(clientId));
}

View File

@@ -0,0 +1,102 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Mongo2Go;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardUserCredentialStoreTests : IAsyncLifetime
{
private readonly MongoDbRunner runner;
private readonly IMongoDatabase database;
private readonly StandardPluginOptions options;
private readonly StandardUserCredentialStore store;
public StandardUserCredentialStoreTests()
{
runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
database = client.GetDatabase("authority-tests");
options = new StandardPluginOptions
{
PasswordPolicy = new PasswordPolicyOptions
{
MinimumLength = 8,
RequireDigit = true,
RequireLowercase = true,
RequireUppercase = true,
RequireSymbol = false
},
Lockout = new LockoutOptions
{
Enabled = true,
MaxAttempts = 2,
WindowMinutes = 1
}
};
store = new StandardUserCredentialStore(
"standard",
database,
options,
new Pbkdf2PasswordHasher(),
NullLogger<StandardUserCredentialStore>.Instance);
}
[Fact]
public async Task VerifyPasswordAsync_ReturnsSuccess_ForValidCredentials()
{
var registration = new AuthorityUserRegistration(
"alice",
"Password1!",
"Alice",
null,
false,
new[] { "admin" },
new Dictionary<string, string?>());
var upsert = await store.UpsertUserAsync(registration, CancellationToken.None);
Assert.True(upsert.Succeeded);
var result = await store.VerifyPasswordAsync("alice", "Password1!", CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Equal("alice", result.User?.Username);
}
[Fact]
public async Task VerifyPasswordAsync_EnforcesLockout_AfterRepeatedFailures()
{
await store.UpsertUserAsync(
new AuthorityUserRegistration(
"bob",
"Password1!",
"Bob",
null,
false,
new[] { "operator" },
new Dictionary<string, string?>()),
CancellationToken.None);
var first = await store.VerifyPasswordAsync("bob", "wrong", CancellationToken.None);
Assert.False(first.Succeeded);
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, first.FailureCode);
var second = await store.VerifyPasswordAsync("bob", "stillwrong", CancellationToken.None);
Assert.False(second.Succeeded);
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, second.FailureCode);
Assert.NotNull(second.RetryAfter);
Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
runner.Dispose();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
</ItemGroup>
</Project>