243 lines
9.1 KiB
C#
243 lines
9.1 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using MxGateway.Server.Configuration;
|
|
using MxGateway.Server.Security.Authentication;
|
|
|
|
namespace MxGateway.Tests.Security.Authentication;
|
|
|
|
public sealed class ApiKeyAdminCliRunnerTests
|
|
{
|
|
[Fact]
|
|
public async Task CreateKeyAsync_CreatesAuthenticatingKeyAndAudits()
|
|
{
|
|
await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
|
|
ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
|
|
StringWriter output = new();
|
|
|
|
await runner.RunAsync(
|
|
new ApiKeyAdminCommand(
|
|
Kind: ApiKeyAdminCommandKind.CreateKey,
|
|
Json: true,
|
|
SqlitePath: null,
|
|
Pepper: null,
|
|
KeyId: "operator01",
|
|
DisplayName: "Operator",
|
|
Scopes: new HashSet<string>(StringComparer.Ordinal) { "session:open", "events:read" }),
|
|
output,
|
|
CancellationToken.None);
|
|
|
|
string apiKey = ReadApiKey(output.ToString());
|
|
|
|
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
|
|
ApiKeyVerificationResult verification = await verifier.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
|
|
|
|
Assert.True(verification.Succeeded);
|
|
Assert.NotNull(verification.Identity);
|
|
Assert.Equal("operator01", verification.Identity.KeyId);
|
|
Assert.Contains("session:open", verification.Identity.Scopes);
|
|
|
|
IReadOnlyList<ApiKeyAuditRecord> auditRecords = await services
|
|
.GetRequiredService<IApiKeyAuditStore>()
|
|
.ListRecentAsync(10, CancellationToken.None);
|
|
|
|
Assert.Contains(auditRecords, record => record.EventType == "create-key" && record.KeyId == "operator01");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListKeysAsync_DoesNotPrintRawSecret()
|
|
{
|
|
await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
|
|
ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
|
|
string apiKey = await CreateKeyAsync(runner, "operator01");
|
|
StringWriter listOutput = new();
|
|
|
|
await runner.RunAsync(
|
|
new ApiKeyAdminCommand(
|
|
Kind: ApiKeyAdminCommandKind.ListKeys,
|
|
Json: true,
|
|
SqlitePath: null,
|
|
Pepper: null,
|
|
KeyId: null,
|
|
DisplayName: null,
|
|
Scopes: new HashSet<string>(StringComparer.Ordinal)),
|
|
listOutput,
|
|
CancellationToken.None);
|
|
|
|
string listJson = listOutput.ToString();
|
|
|
|
Assert.Contains("operator01", listJson, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(apiKey, listJson, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(ApiKeySecret(apiKey), listJson, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("secret_hash", listJson, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevokeKeyAsync_RevokedKeyFailsVerificationAndAudits()
|
|
{
|
|
await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
|
|
ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
|
|
string apiKey = await CreateKeyAsync(runner, "operator01");
|
|
|
|
await runner.RunAsync(
|
|
new ApiKeyAdminCommand(
|
|
Kind: ApiKeyAdminCommandKind.RevokeKey,
|
|
Json: true,
|
|
SqlitePath: null,
|
|
Pepper: null,
|
|
KeyId: "operator01",
|
|
DisplayName: null,
|
|
Scopes: new HashSet<string>(StringComparer.Ordinal)),
|
|
TextWriter.Null,
|
|
CancellationToken.None);
|
|
|
|
ApiKeyVerificationResult verification = await services
|
|
.GetRequiredService<IApiKeyVerifier>()
|
|
.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
|
|
|
|
Assert.False(verification.Succeeded);
|
|
Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, verification.Failure);
|
|
|
|
IReadOnlyList<ApiKeyAuditRecord> auditRecords = await services
|
|
.GetRequiredService<IApiKeyAuditStore>()
|
|
.ListRecentAsync(10, CancellationToken.None);
|
|
|
|
Assert.Contains(auditRecords, record => record.EventType == "revoke-key" && record.KeyId == "operator01");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RotateKeyAsync_PrintsNewSecretOnceAndInvalidatesOldSecret()
|
|
{
|
|
await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
|
|
ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
|
|
string oldApiKey = await CreateKeyAsync(runner, "operator01");
|
|
StringWriter rotateOutput = new();
|
|
|
|
await runner.RunAsync(
|
|
new ApiKeyAdminCommand(
|
|
Kind: ApiKeyAdminCommandKind.RotateKey,
|
|
Json: true,
|
|
SqlitePath: null,
|
|
Pepper: null,
|
|
KeyId: "operator01",
|
|
DisplayName: null,
|
|
Scopes: new HashSet<string>(StringComparer.Ordinal)),
|
|
rotateOutput,
|
|
CancellationToken.None);
|
|
|
|
string rotateJson = rotateOutput.ToString();
|
|
string newApiKey = ReadApiKey(rotateJson);
|
|
|
|
Assert.NotEqual(oldApiKey, newApiKey);
|
|
Assert.Equal(1, CountOccurrences(rotateJson, newApiKey));
|
|
|
|
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
|
|
ApiKeyVerificationResult oldVerification = await verifier.VerifyAsync($"Bearer {oldApiKey}", CancellationToken.None);
|
|
ApiKeyVerificationResult newVerification = await verifier.VerifyAsync($"Bearer {newApiKey}", CancellationToken.None);
|
|
|
|
Assert.False(oldVerification.Succeeded);
|
|
Assert.Equal(ApiKeyVerificationFailure.SecretMismatch, oldVerification.Failure);
|
|
Assert.True(newVerification.Succeeded);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateKeyAsync_PrintsRawSecretExactlyOnce()
|
|
{
|
|
await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
|
|
ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
|
|
StringWriter output = new();
|
|
|
|
await runner.RunAsync(
|
|
new ApiKeyAdminCommand(
|
|
Kind: ApiKeyAdminCommandKind.CreateKey,
|
|
Json: true,
|
|
SqlitePath: null,
|
|
Pepper: null,
|
|
KeyId: "operator01",
|
|
DisplayName: "Operator",
|
|
Scopes: new HashSet<string>(StringComparer.Ordinal)),
|
|
output,
|
|
CancellationToken.None);
|
|
|
|
string json = output.ToString();
|
|
string apiKey = ReadApiKey(json);
|
|
|
|
Assert.Equal(1, CountOccurrences(json, apiKey));
|
|
Assert.Equal(1, CountOccurrences(json, ApiKeySecret(apiKey)));
|
|
}
|
|
|
|
private static async Task<string> CreateKeyAsync(ApiKeyAdminCliRunner runner, string keyId)
|
|
{
|
|
StringWriter output = new();
|
|
await runner.RunAsync(
|
|
new ApiKeyAdminCommand(
|
|
Kind: ApiKeyAdminCommandKind.CreateKey,
|
|
Json: true,
|
|
SqlitePath: null,
|
|
Pepper: null,
|
|
KeyId: keyId,
|
|
DisplayName: "Operator",
|
|
Scopes: new HashSet<string>(StringComparer.Ordinal) { "session:open" }),
|
|
output,
|
|
CancellationToken.None);
|
|
|
|
return ReadApiKey(output.ToString());
|
|
}
|
|
|
|
private static ServiceProvider BuildServices(string databasePath)
|
|
{
|
|
IConfigurationRoot configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(
|
|
new Dictionary<string, string?>
|
|
{
|
|
["MxGateway:Authentication:SqlitePath"] = databasePath,
|
|
["MxGateway:ApiKeyPepper"] = "test-pepper"
|
|
})
|
|
.Build();
|
|
|
|
ServiceCollection services = new();
|
|
services.AddSingleton<IConfiguration>(configuration);
|
|
services.AddGatewayConfiguration();
|
|
services.AddSqliteAuthStore();
|
|
|
|
return services.BuildServiceProvider(validateScopes: true);
|
|
}
|
|
|
|
private static string CreateTempDatabasePath()
|
|
{
|
|
string directory = Path.Combine(Path.GetTempPath(), "mxgateway-auth-cli-tests", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(directory);
|
|
|
|
return Path.Combine(directory, "gateway-auth.db");
|
|
}
|
|
|
|
private static string ReadApiKey(string json)
|
|
{
|
|
using JsonDocument document = JsonDocument.Parse(json);
|
|
|
|
return document.RootElement.GetProperty("ApiKey").GetString()
|
|
?? throw new InvalidOperationException("API key was not present in command output.");
|
|
}
|
|
|
|
private static string ApiKeySecret(string apiKey)
|
|
{
|
|
string[] parts = apiKey.Split('_', 3);
|
|
|
|
return parts[2];
|
|
}
|
|
|
|
private static int CountOccurrences(string value, string pattern)
|
|
{
|
|
int count = 0;
|
|
int index = 0;
|
|
|
|
while ((index = value.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0)
|
|
{
|
|
count++;
|
|
index += pattern.Length;
|
|
}
|
|
|
|
return count;
|
|
}
|
|
}
|