Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using StellaOps.Authority.Plugin.Standard.Security;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Standard.Tests.Security;
|
||||
|
||||
public class CryptoPasswordHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Hash_EmitsArgon2idByDefault()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var hasher = new CryptoPasswordHasher(options, new DefaultCryptoProvider());
|
||||
|
||||
var encoded = hasher.Hash("Secr3t!");
|
||||
|
||||
Assert.StartsWith("$argon2id$", encoded, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsSuccess_ForCurrentAlgorithm()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var provider = new DefaultCryptoProvider();
|
||||
var hasher = new CryptoPasswordHasher(options, provider);
|
||||
var encoded = hasher.Hash("Passw0rd!");
|
||||
|
||||
var result = hasher.Verify("Passw0rd!", encoded);
|
||||
|
||||
Assert.Equal(PasswordVerificationResult.Success, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_FlagsLegacyPbkdf2_ForRehash()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var provider = new DefaultCryptoProvider();
|
||||
var hasher = new CryptoPasswordHasher(options, provider);
|
||||
|
||||
var legacy = new Pbkdf2PasswordHasher().Hash(
|
||||
"Passw0rd!",
|
||||
new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 150_000
|
||||
});
|
||||
|
||||
var result = hasher.Verify("Passw0rd!", legacy);
|
||||
|
||||
Assert.Equal(PasswordVerificationResult.SuccessRehashNeeded, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_RejectsTamperedPayload()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var provider = new DefaultCryptoProvider();
|
||||
var hasher = new CryptoPasswordHasher(options, provider);
|
||||
|
||||
var legacy = new Pbkdf2PasswordHasher().Hash(
|
||||
"Passw0rd!",
|
||||
new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 160_000
|
||||
});
|
||||
|
||||
var tampered = legacy + "corrupted";
|
||||
|
||||
var result = hasher.Verify("Passw0rd!", tampered);
|
||||
|
||||
Assert.Equal(PasswordVerificationResult.Failed, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_AllowsLegacyAlgorithmWhenConfigured()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.PasswordHashing = options.PasswordHashing with
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 200_000
|
||||
};
|
||||
|
||||
var provider = new DefaultCryptoProvider();
|
||||
var hasher = new CryptoPasswordHasher(options, provider);
|
||||
|
||||
var legacy = new Pbkdf2PasswordHasher().Hash(
|
||||
"Passw0rd!",
|
||||
new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 200_000
|
||||
});
|
||||
|
||||
var result = hasher.Verify("Passw0rd!", legacy);
|
||||
|
||||
Assert.Equal(PasswordVerificationResult.Success, result);
|
||||
}
|
||||
|
||||
private static StandardPluginOptions CreateOptions() => new()
|
||||
{
|
||||
PasswordPolicy = new PasswordPolicyOptions
|
||||
{
|
||||
MinimumLength = 8,
|
||||
RequireDigit = true,
|
||||
RequireLowercase = true,
|
||||
RequireUppercase = true,
|
||||
RequireSymbol = false
|
||||
},
|
||||
Lockout = new LockoutOptions
|
||||
{
|
||||
Enabled = true,
|
||||
MaxAttempts = 5,
|
||||
WindowMinutes = 15
|
||||
},
|
||||
PasswordHashing = new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Argon2id,
|
||||
MemorySizeInKib = 8 * 1024,
|
||||
Iterations = 2,
|
||||
Parallelism = 1
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Driver;
|
||||
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 revocations = new TrackingRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_NormalisesTenant()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
clientId: "tenant-client",
|
||||
confidential: false,
|
||||
displayName: "Tenant Client",
|
||||
clientSecret: null,
|
||||
allowedGrantTypes: new[] { "client_credentials" },
|
||||
allowedScopes: new[] { "scopeA" },
|
||||
tenant: " Tenant-Alpha " );
|
||||
|
||||
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
|
||||
|
||||
Assert.True(store.Documents.TryGetValue("tenant-client", out var document));
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal("tenant-alpha", document!.Properties[AuthorityClientMetadataKeys.Tenant]);
|
||||
|
||||
var descriptor = await provisioning.FindByClientIdAsync("tenant-client", CancellationToken.None);
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal("tenant-alpha", descriptor!.Tenant);
|
||||
}
|
||||
|
||||
|
||||
public async Task CreateOrUpdateAsync_StoresAudiences()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
clientId: "signer",
|
||||
confidential: false,
|
||||
displayName: "Signer",
|
||||
clientSecret: null,
|
||||
allowedGrantTypes: new[] { "client_credentials" },
|
||||
allowedScopes: new[] { "signer.sign" },
|
||||
allowedAudiences: new[] { "attestor", "signer" });
|
||||
|
||||
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.True(store.Documents.TryGetValue("signer", out var document));
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal("attestor signer", document!.Properties[AuthorityClientMetadataKeys.Audiences]);
|
||||
|
||||
var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None);
|
||||
Assert.NotNull(descriptor);
|
||||
Assert.Equal(new[] { "attestor", "signer" }, descriptor!.AllowedAudiences.OrderBy(value => value, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_MapsCertificateBindings()
|
||||
{
|
||||
var store = new TrackingClientStore();
|
||||
var revocations = new TrackingRevocationStore();
|
||||
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
|
||||
|
||||
var bindingRegistration = new AuthorityClientCertificateBindingRegistration(
|
||||
thumbprint: "aa:bb:cc:dd",
|
||||
serialNumber: "01ff",
|
||||
subject: "CN=mtls-client",
|
||||
issuer: "CN=test-ca",
|
||||
subjectAlternativeNames: new[] { "client.mtls.test", "spiffe://client" },
|
||||
notBefore: DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
notAfter: DateTimeOffset.UtcNow.AddHours(1),
|
||||
label: "primary");
|
||||
|
||||
var registration = new AuthorityClientRegistration(
|
||||
clientId: "mtls-client",
|
||||
confidential: true,
|
||||
displayName: "MTLS Client",
|
||||
clientSecret: "secret",
|
||||
allowedGrantTypes: new[] { "client_credentials" },
|
||||
allowedScopes: new[] { "signer.sign" },
|
||||
allowedAudiences: new[] { "signer" },
|
||||
certificateBindings: new[] { bindingRegistration });
|
||||
|
||||
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
|
||||
|
||||
Assert.True(store.Documents.TryGetValue("mtls-client", out var document));
|
||||
Assert.NotNull(document);
|
||||
var binding = Assert.Single(document!.CertificateBindings);
|
||||
Assert.Equal("AABBCCDD", binding.Thumbprint);
|
||||
Assert.Equal("01ff", binding.SerialNumber);
|
||||
Assert.Equal("CN=mtls-client", binding.Subject);
|
||||
Assert.Equal("CN=test-ca", binding.Issuer);
|
||||
Assert.Equal(new[] { "client.mtls.test", "spiffe://client" }, binding.SubjectAlternativeNames);
|
||||
Assert.Equal(bindingRegistration.NotBefore, binding.NotBefore);
|
||||
Assert.Equal(bindingRegistration.NotAfter, binding.NotAfter);
|
||||
Assert.Equal("primary", binding.Label);
|
||||
}
|
||||
|
||||
private sealed class TrackingClientStore : IAuthorityClientStore
|
||||
{
|
||||
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
Documents.TryGetValue(clientId, out var document);
|
||||
return ValueTask.FromResult(document);
|
||||
}
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
Documents[document.ClientId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var removed = Documents.Remove(clientId);
|
||||
return ValueTask.FromResult(removed);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackingRevocationStore : IAuthorityRevocationStore
|
||||
{
|
||||
public List<AuthorityRevocationDocument> Upserts { get; } = new();
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
Upserts.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(true);
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using StellaOps.Authority.Plugin.Standard;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenPasswordHashingMemoryInvalid()
|
||||
{
|
||||
var options = new StandardPluginOptions
|
||||
{
|
||||
PasswordHashing = new PasswordHashOptions
|
||||
{
|
||||
MemorySizeInKib = 0
|
||||
}
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
|
||||
Assert.Contains("memory", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenPasswordHashingIterationsInvalid()
|
||||
{
|
||||
var options = new StandardPluginOptions
|
||||
{
|
||||
PasswordHashing = new PasswordHashOptions
|
||||
{
|
||||
Iterations = 0
|
||||
}
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
|
||||
Assert.Contains("iteration", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Throws_WhenPasswordHashingParallelismInvalid()
|
||||
{
|
||||
var options = new StandardPluginOptions
|
||||
{
|
||||
PasswordHashing = new PasswordHashOptions
|
||||
{
|
||||
Parallelism = 0
|
||||
}
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
|
||||
Assert.Contains("parallelism", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
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));
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var hostedServices = provider.GetServices<IHostedService>();
|
||||
foreach (var hosted in hostedServices)
|
||||
{
|
||||
if (hosted is StandardPluginBootstrapper bootstrapper)
|
||||
{
|
||||
await bootstrapper.StartAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
using var scope = provider.CreateScope();
|
||||
var plugin = scope.ServiceProvider.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();
|
||||
using var scope = provider.CreateScope();
|
||||
_ = scope.ServiceProvider.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();
|
||||
using var scope = provider.CreateScope();
|
||||
var plugin = scope.ServiceProvider.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();
|
||||
using var scope = provider.CreateScope();
|
||||
Assert.Throws<InvalidOperationException>(() => scope.ServiceProvider.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, IClientSessionHandle? session = null)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(false);
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> 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, IClientSessionHandle? session = null)
|
||||
{
|
||||
clients.TryGetValue(clientId, out var document);
|
||||
return ValueTask.FromResult(document);
|
||||
}
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
clients[document.ClientId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> ValueTask.FromResult(clients.Remove(clientId));
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
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;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
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
|
||||
},
|
||||
PasswordHashing = new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Argon2id,
|
||||
MemorySizeInKib = 8 * 1024,
|
||||
Iterations = 2,
|
||||
Parallelism = 1
|
||||
}
|
||||
};
|
||||
var cryptoProvider = new DefaultCryptoProvider();
|
||||
store = new StandardUserCredentialStore(
|
||||
"standard",
|
||||
database,
|
||||
options,
|
||||
new CryptoPasswordHasher(options, cryptoProvider),
|
||||
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);
|
||||
Assert.Empty(result.AuditProperties);
|
||||
}
|
||||
|
||||
[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);
|
||||
Assert.Contains(second.AuditProperties, property => property.Name == "plugin.lockout_until");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyPasswordAsync_RehashesLegacyHashesToArgon2()
|
||||
{
|
||||
var legacyHash = new Pbkdf2PasswordHasher().Hash(
|
||||
"Legacy1!",
|
||||
new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 160_000
|
||||
});
|
||||
|
||||
var document = new StandardUserDocument
|
||||
{
|
||||
Username = "legacy",
|
||||
NormalizedUsername = "legacy",
|
||||
PasswordHash = legacyHash,
|
||||
Roles = new List<string>(),
|
||||
Attributes = new Dictionary<string, string?>(),
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
};
|
||||
|
||||
await database.GetCollection<StandardUserDocument>("authority_users_standard")
|
||||
.InsertOneAsync(document);
|
||||
|
||||
var result = await store.VerifyPasswordAsync("legacy", "Legacy1!", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("legacy", result.User?.Username);
|
||||
Assert.Contains(result.AuditProperties, property => property.Name == "plugin.rehashed");
|
||||
|
||||
var updated = await database.GetCollection<StandardUserDocument>("authority_users_standard")
|
||||
.Find(u => u.NormalizedUsername == "legacy")
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
Assert.NotNull(updated);
|
||||
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
runner.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<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>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user