Issue #7: implement local api key admin cli
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace MxGateway.Tests.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyAdminCommandLineParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_NonApiKeyCommand_ReturnsNotApiKeyCommand()
|
||||
{
|
||||
ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(["--urls=http://localhost:5000"]);
|
||||
|
||||
Assert.False(result.IsApiKeyCommand);
|
||||
Assert.Null(result.Command);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CreateKeyCommand_ReturnsOptions()
|
||||
{
|
||||
ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(
|
||||
[
|
||||
"apikey",
|
||||
"create-key",
|
||||
"--key-id",
|
||||
"operator01",
|
||||
"--display-name",
|
||||
"Operator",
|
||||
"--scopes",
|
||||
"session:open,events:read",
|
||||
"--sqlite-path",
|
||||
"auth.db",
|
||||
"--pepper",
|
||||
"pepper",
|
||||
"--json"
|
||||
]);
|
||||
|
||||
Assert.True(result.IsApiKeyCommand);
|
||||
Assert.Null(result.Error);
|
||||
Assert.NotNull(result.Command);
|
||||
Assert.Equal(ApiKeyAdminCommandKind.CreateKey, result.Command.Kind);
|
||||
Assert.True(result.Command.Json);
|
||||
Assert.Equal("operator01", result.Command.KeyId);
|
||||
Assert.Equal("Operator", result.Command.DisplayName);
|
||||
Assert.Equal("auth.db", result.Command.SqlitePath);
|
||||
Assert.Equal("pepper", result.Command.Pepper);
|
||||
Assert.Contains("session:open", result.Command.Scopes);
|
||||
Assert.Contains("events:read", result.Command.Scopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CreateKeyWithoutDisplayName_ReturnsError()
|
||||
{
|
||||
ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(
|
||||
["apikey", "create-key", "--key-id", "operator01"]);
|
||||
|
||||
Assert.True(result.IsApiKeyCommand);
|
||||
Assert.Null(result.Command);
|
||||
Assert.Contains("--display-name", result.Error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_KeyIdWithUnderscore_ReturnsError()
|
||||
{
|
||||
ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(
|
||||
["apikey", "revoke-key", "--key-id", "operator_01"]);
|
||||
|
||||
Assert.True(result.IsApiKeyCommand);
|
||||
Assert.Null(result.Command);
|
||||
Assert.Contains("letters, numbers, periods, and hyphens", result.Error, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user