351 lines
15 KiB
C#
351 lines
15 KiB
C#
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.Logging;
|
|
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",
|
|
["passwordHashing:memorySizeInKib"] = "8192",
|
|
["passwordHashing:iterations"] = "2",
|
|
["passwordHashing:parallelism"] = "1",
|
|
["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());
|
|
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
|
services.AddSingleton(TimeProvider.System);
|
|
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
|
services.AddSingleton(TimeProvider.System);
|
|
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
|
services.AddSingleton(TimeProvider.System);
|
|
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
|
services.AddSingleton(TimeProvider.System);
|
|
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
|
services.AddSingleton(TimeProvider.System);
|
|
services.AddSingleton(TimeProvider.System);
|
|
services.AddSingleton(TimeProvider.System);
|
|
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
|
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
|
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
|
|
|
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_LogsWarning_WhenPasswordPolicyWeaker()
|
|
{
|
|
using var runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
|
var client = new MongoClient(runner.ConnectionString);
|
|
var database = client.GetDatabase("registrar-password-policy");
|
|
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["passwordPolicy:minimumLength"] = "6",
|
|
["passwordPolicy:requireUppercase"] = "false",
|
|
["passwordPolicy:requireLowercase"] = "false",
|
|
["passwordPolicy:requireDigit"] = "false",
|
|
["passwordPolicy:requireSymbol"] = "false"
|
|
})
|
|
.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();
|
|
var loggerProvider = new CapturingLoggerProvider();
|
|
services.AddLogging(builder => builder.AddProvider(loggerProvider));
|
|
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();
|
|
_ = provider.GetRequiredService<StandardUserCredentialStore>();
|
|
|
|
Assert.Contains(loggerProvider.Entries, entry =>
|
|
entry.Level == LogLevel.Warning &&
|
|
entry.Category.Contains(typeof(StandardPluginRegistrar).FullName!, StringComparison.Ordinal) &&
|
|
entry.Message.Contains("weaker password policy", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[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());
|
|
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
|
|
services.AddSingleton(TimeProvider.System);
|
|
|
|
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 record CapturedLogEntry(string Category, LogLevel Level, string Message);
|
|
|
|
internal sealed class CapturingLoggerProvider : ILoggerProvider
|
|
{
|
|
public List<CapturedLogEntry> Entries { get; } = new();
|
|
|
|
public ILogger CreateLogger(string categoryName) => new CapturingLogger(categoryName, Entries);
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
private sealed class CapturingLogger : ILogger
|
|
{
|
|
private readonly string category;
|
|
private readonly List<CapturedLogEntry> entries;
|
|
|
|
public CapturingLogger(string category, List<CapturedLogEntry> entries)
|
|
{
|
|
this.category = category;
|
|
this.entries = entries;
|
|
}
|
|
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
|
{
|
|
entries.Add(new CapturedLogEntry(category, logLevel, formatter(state, exception)));
|
|
}
|
|
|
|
private sealed class NullScope : IDisposable
|
|
{
|
|
public static readonly NullScope Instance = new();
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
internal sealed class StubRevocationStore : IAuthorityRevocationStore
|
|
{
|
|
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken)
|
|
=> ValueTask.CompletedTask;
|
|
|
|
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken)
|
|
=> ValueTask.FromResult(false);
|
|
|
|
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken)
|
|
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
|
|
}
|
|
|
|
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));
|
|
}
|