using System.Text.Json; namespace MxGateway.Server.Security.Authentication; public sealed class ApiKeyAdminCliRunner( IAuthStoreMigrator migrator, IApiKeyAdminStore adminStore, IApiKeyAuditStore auditStore, IApiKeySecretHasher hasher) { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; public async Task RunAsync( ApiKeyAdminCommand command, TextWriter output, CancellationToken cancellationToken) { ApiKeyAdminOutput result = command.Kind switch { ApiKeyAdminCommandKind.InitDb => await InitDbAsync(cancellationToken).ConfigureAwait(false), ApiKeyAdminCommandKind.CreateKey => await CreateKeyAsync(command, cancellationToken).ConfigureAwait(false), ApiKeyAdminCommandKind.ListKeys => await ListKeysAsync(cancellationToken).ConfigureAwait(false), ApiKeyAdminCommandKind.RevokeKey => await RevokeKeyAsync(command, cancellationToken).ConfigureAwait(false), ApiKeyAdminCommandKind.RotateKey => await RotateKeyAsync(command, cancellationToken).ConfigureAwait(false), _ => throw new InvalidOperationException($"Unsupported API key command '{command.Kind}'.") }; await WriteOutputAsync(command, result, output).ConfigureAwait(false); return 0; } private async Task InitDbAsync(CancellationToken cancellationToken) { await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); await AppendAuditAsync(null, "init-db", null, cancellationToken).ConfigureAwait(false); return new ApiKeyAdminOutput("init-db", "initialized", null, []); } private async Task CreateKeyAsync( ApiKeyAdminCommand command, CancellationToken cancellationToken) { await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); string keyId = Required(command.KeyId); string secret = ApiKeySecretGenerator.Generate(); string apiKey = FormatApiKey(keyId, secret); await adminStore.CreateAsync( new ApiKeyCreateRequest( KeyId: keyId, KeyPrefix: $"mxgw_{keyId}", SecretHash: hasher.HashSecret(secret), DisplayName: Required(command.DisplayName), Scopes: command.Scopes, CreatedUtc: DateTimeOffset.UtcNow), cancellationToken) .ConfigureAwait(false); await AppendAuditAsync(keyId, "create-key", null, cancellationToken).ConfigureAwait(false); return new ApiKeyAdminOutput("create-key", "created", apiKey, []); } private async Task ListKeysAsync(CancellationToken cancellationToken) { await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); IReadOnlyList keys = await adminStore.ListAsync(cancellationToken).ConfigureAwait(false); await AppendAuditAsync(null, "list-keys", null, cancellationToken).ConfigureAwait(false); return new ApiKeyAdminOutput( "list-keys", "ok", null, keys.Select(ToListedKey).ToArray()); } private async Task RevokeKeyAsync( ApiKeyAdminCommand command, CancellationToken cancellationToken) { await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); string keyId = Required(command.KeyId); bool revoked = await adminStore.RevokeAsync(keyId, DateTimeOffset.UtcNow, cancellationToken) .ConfigureAwait(false); await AppendAuditAsync(keyId, "revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", cancellationToken) .ConfigureAwait(false); return new ApiKeyAdminOutput("revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", null, []); } private async Task RotateKeyAsync( ApiKeyAdminCommand command, CancellationToken cancellationToken) { await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); string keyId = Required(command.KeyId); string secret = ApiKeySecretGenerator.Generate(); string apiKey = FormatApiKey(keyId, secret); bool rotated = await adminStore.RotateAsync(keyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken) .ConfigureAwait(false); await AppendAuditAsync(keyId, "rotate-key", rotated ? "rotated" : "not-found", cancellationToken) .ConfigureAwait(false); return new ApiKeyAdminOutput("rotate-key", rotated ? "rotated" : "not-found", rotated ? apiKey : null, []); } private static async Task WriteOutputAsync( ApiKeyAdminCommand command, ApiKeyAdminOutput result, TextWriter output) { if (command.Json) { await output.WriteLineAsync(JsonSerializer.Serialize(result, JsonOptions)).ConfigureAwait(false); return; } await output.WriteLineAsync($"{result.Command}: {result.Status}").ConfigureAwait(false); if (result.ApiKey is not null) { await output.WriteLineAsync($"API key: {result.ApiKey}").ConfigureAwait(false); } foreach (ApiKeyAdminListedKey key in result.Keys) { string revoked = key.RevokedUtc is null ? "active" : "revoked"; await output.WriteLineAsync($"{key.KeyId}\t{key.DisplayName}\t{revoked}\t{string.Join(',', key.Scopes)}") .ConfigureAwait(false); } } private async Task AppendAuditAsync( string? keyId, string eventType, string? details, CancellationToken cancellationToken) { await auditStore.AppendAsync( new ApiKeyAuditEntry( KeyId: keyId, EventType: eventType, RemoteAddress: null, Details: details), cancellationToken) .ConfigureAwait(false); } private static ApiKeyAdminListedKey ToListedKey(ApiKeyRecord key) { return new ApiKeyAdminListedKey( KeyId: key.KeyId, KeyPrefix: key.KeyPrefix, DisplayName: key.DisplayName, Scopes: key.Scopes, CreatedUtc: key.CreatedUtc, LastUsedUtc: key.LastUsedUtc, RevokedUtc: key.RevokedUtc); } private static string FormatApiKey(string keyId, string secret) { return $"mxgw_{keyId}_{secret}"; } private static string Required(string? value) { return value ?? throw new InvalidOperationException("Required command value was not provided."); } }