diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs
index 2567e97..a58f3d4 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs
@@ -1,5 +1,7 @@
using System.Security.Claims;
using Microsoft.Data.Sqlite;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
@@ -7,12 +9,13 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardApiKeyManagementService(
DashboardApiKeyAuthorization authorization,
+ ApiKeyAdminCommands adminCommands,
IApiKeyAdminStore adminStore,
IApiKeyAuditStore auditStore,
- IApiKeySecretHasher hasher,
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
{
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
+ private const string PepperUnavailableMarker = "pepper unavailable";
/// Determines whether the user can manage API keys.
/// The authenticated user principal.
@@ -42,28 +45,29 @@ public sealed class DashboardApiKeyManagementService(
}
string keyId = request.KeyId.Trim();
- string secret = ApiKeySecretGenerator.Generate();
- string apiKey = FormatApiKey(keyId, secret);
try
{
- await adminStore.CreateAsync(
- new ApiKeyCreateRequest(
- KeyId: keyId,
- KeyPrefix: $"mxgw_{keyId}",
- SecretHash: hasher.HashSecret(secret),
- DisplayName: request.DisplayName.Trim(),
- Scopes: request.Scopes,
- Constraints: request.Constraints,
- CreatedUtc: DateTimeOffset.UtcNow),
+ // The shared command set generates the secret, hashes it with the pepper, persists the
+ // record and assembles the mxgw__ token (shown once). It also appends its own
+ // "create-key" audit entry; the dashboard layers a "dashboard-create-key" entry with the
+ // caller's remote address on top to preserve the dashboard audit vocabulary.
+ CreateKeyResult created = await adminCommands.CreateKeyAsync(
+ keyId,
+ request.DisplayName.Trim(),
+ request.Scopes,
+ ApiKeyConstraintSerializer.Serialize(request.Constraints),
+ RemoteAddress(),
cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
- return DashboardApiKeyManagementResult.Success("API key created. Copy the key now; it will not be shown again.", apiKey);
+ return DashboardApiKeyManagementResult.Success(
+ "API key created. Copy the key now; it will not be shown again.",
+ created.Token);
}
- catch (ApiKeyPepperUnavailableException)
+ catch (InvalidOperationException exception) when (IsPepperUnavailable(exception))
{
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
}
@@ -94,18 +98,18 @@ public sealed class DashboardApiKeyManagementService(
}
string normalizedKeyId = keyId.Trim();
- bool revoked = await adminStore
- .RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken)
+ KeyActionResult result = await adminCommands
+ .RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(
normalizedKeyId,
"dashboard-revoke-key",
- revoked ? "revoked" : "not-found-or-already-revoked",
+ result.Succeeded ? "revoked" : "not-found-or-already-revoked",
cancellationToken)
.ConfigureAwait(false);
- return revoked
+ return result.Succeeded
? DashboardApiKeyManagementResult.Success("API key revoked.")
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
}
@@ -131,27 +135,29 @@ public sealed class DashboardApiKeyManagementService(
}
string normalizedKeyId = keyId.Trim();
- string secret = ApiKeySecretGenerator.Generate();
- string apiKey = FormatApiKey(normalizedKeyId, secret);
try
{
- bool rotated = await adminStore
- .RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
+ CreateKeyResult rotated = await adminCommands
+ .RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false);
+ bool succeeded = rotated.Token is not null;
+
await AppendAuditAsync(
normalizedKeyId,
"dashboard-rotate-key",
- rotated ? "rotated" : "not-found",
+ succeeded ? "rotated" : "not-found",
cancellationToken)
.ConfigureAwait(false);
- return rotated
- ? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey)
+ return succeeded
+ ? DashboardApiKeyManagementResult.Success(
+ "API key rotated. Copy the key now; it will not be shown again.",
+ rotated.Token)
: DashboardApiKeyManagementResult.Fail("API key was not found.");
}
- catch (ApiKeyPepperUnavailableException)
+ catch (InvalidOperationException exception) when (IsPepperUnavailable(exception))
{
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
}
@@ -194,6 +200,9 @@ public sealed class DashboardApiKeyManagementService(
: DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting.");
}
+ private string? RemoteAddress() =>
+ httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
+
private async Task AppendAuditAsync(
string? keyId,
string eventType,
@@ -204,12 +213,16 @@ public sealed class DashboardApiKeyManagementService(
new ApiKeyAuditEntry(
KeyId: keyId,
EventType: eventType,
- RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(),
+ RemoteAddress: RemoteAddress(),
+ CreatedUtc: DateTimeOffset.UtcNow,
Details: details),
cancellationToken)
.ConfigureAwait(false);
}
+ private static bool IsPepperUnavailable(InvalidOperationException exception) =>
+ exception.Message.Contains(PepperUnavailableMarker, StringComparison.OrdinalIgnoreCase);
+
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
{
string? keyIdValidation = ValidateKeyId(request.KeyId);
@@ -248,9 +261,4 @@ public sealed class DashboardApiKeyManagementService(
? null
: "API key id may contain only letters, numbers, periods, and hyphens.";
}
-
- private static string FormatApiKey(string keyId, string secret)
- {
- return $"mxgw_{keyId}_{secret}";
- }
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs
index 35b10bc..290879c 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs
@@ -2,6 +2,7 @@ using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Metrics;
@@ -242,7 +243,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
KeyId: key.KeyId,
DisplayName: key.DisplayName,
Scopes: key.Scopes,
- Constraints: key.Constraints,
+ Constraints: ApiKeyConstraintSerializer.Deserialize(key.ConstraintsJson),
CreatedUtc: key.CreatedUtc,
LastUsedUtc: key.LastUsedUtc,
RevokedUtc: key.RevokedUtc))
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs
index 59f1544..30766a5 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs
@@ -1,6 +1,6 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Diagnostics.HealthChecks;
-using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
+using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs
index 62eb5cc..82a68db 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs
@@ -66,7 +66,7 @@ public static class GatewayApplication
builder.AddZbSerilog(o => o.ServiceName = "mxgateway");
builder.Services.AddGatewayConfiguration(builder.Configuration);
- builder.Services.AddSqliteAuthStore();
+ builder.Services.AddSqliteAuthStore(builder.Configuration);
builder.Services.AddGatewayGrpcAuthorization();
builder.Services.AddHealthChecks()
.AddTypeActivatedCheck(
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs
index f2c99af..24d01e6 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs
@@ -1,15 +1,19 @@
using System.Text.Json;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys.Admin;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
///
/// Executes API key administration commands from the CLI.
///
-public sealed class ApiKeyAdminCliRunner(
- IAuthStoreMigrator migrator,
- IApiKeyAdminStore adminStore,
- IApiKeyAuditStore auditStore,
- IApiKeySecretHasher hasher)
+///
+/// The create/revoke/rotate/list/init-db verbs (secret generation, peppered hashing, token
+/// assembly and per-action audit) are delegated to the shared
+/// . This runner adapts the gateway's strongly-typed command and
+/// output DTOs (which carry ) onto the library's JSON-based contract.
+///
+public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -44,8 +48,7 @@ public sealed class ApiKeyAdminCliRunner(
private async Task InitDbAsync(CancellationToken cancellationToken)
{
- await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
- await AppendAuditAsync(null, "init-db", null, cancellationToken).ConfigureAwait(false);
+ await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
return new ApiKeyAdminOutput("init-db", "initialized", null, []);
}
@@ -54,33 +57,26 @@ public sealed class ApiKeyAdminCliRunner(
ApiKeyAdminCommand command,
CancellationToken cancellationToken)
{
- await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
+ // The shared command set requires the schema to exist; init-db is idempotent.
+ await commands.InitDbAsync(remoteAddress: null, 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,
- Constraints: command.Constraints,
- CreatedUtc: DateTimeOffset.UtcNow),
+ CreateKeyResult created = await commands.CreateKeyAsync(
+ keyId,
+ Required(command.DisplayName),
+ command.Scopes,
+ ApiKeyConstraintSerializer.Serialize(command.Constraints),
+ remoteAddress: null,
cancellationToken)
.ConfigureAwait(false);
- await AppendAuditAsync(keyId, "create-key", null, cancellationToken).ConfigureAwait(false);
- return new ApiKeyAdminOutput("create-key", "created", apiKey, []);
+ return new ApiKeyAdminOutput("create-key", "created", created.Token, []);
}
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);
+ await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
+ IReadOnlyList keys = await commands.ListKeysAsync(cancellationToken).ConfigureAwait(false);
return new ApiKeyAdminOutput(
"list-keys",
@@ -93,35 +89,28 @@ public sealed class ApiKeyAdminCliRunner(
ApiKeyAdminCommand command,
CancellationToken cancellationToken)
{
- await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
+ await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
string keyId = Required(command.KeyId);
- bool revoked = await adminStore.RevokeAsync(keyId, DateTimeOffset.UtcNow, cancellationToken)
+ KeyActionResult result = await commands.RevokeKeyAsync(keyId, remoteAddress: null, 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, []);
+ return new ApiKeyAdminOutput("revoke-key", result.Succeeded ? "revoked" : "not-found-or-already-revoked", null, []);
}
private async Task RotateKeyAsync(
ApiKeyAdminCommand command,
CancellationToken cancellationToken)
{
- await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
+ await commands.InitDbAsync(remoteAddress: null, 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)
+ CreateKeyResult rotated = await commands.RotateKeyAsync(keyId, remoteAddress: null, cancellationToken)
.ConfigureAwait(false);
- await AppendAuditAsync(keyId, "rotate-key", rotated ? "rotated" : "not-found", cancellationToken)
- .ConfigureAwait(false);
+ bool succeeded = rotated.Token is not null;
- return new ApiKeyAdminOutput("rotate-key", rotated ? "rotated" : "not-found", rotated ? apiKey : null, []);
+ return new ApiKeyAdminOutput("rotate-key", succeeded ? "rotated" : "not-found", rotated.Token, []);
}
private static async Task WriteOutputAsync(
@@ -150,40 +139,19 @@ public sealed class ApiKeyAdminCliRunner(
}
}
- 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)
+ private static ApiKeyAdminListedKey ToListedKey(ApiKeyListItem key)
{
return new ApiKeyAdminListedKey(
KeyId: key.KeyId,
KeyPrefix: key.KeyPrefix,
DisplayName: key.DisplayName,
Scopes: key.Scopes,
- Constraints: key.Constraints,
+ Constraints: ApiKeyConstraintSerializer.Deserialize(key.ConstraintsJson),
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.");
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditEntry.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditEntry.cs
deleted file mode 100644
index 543fe30..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditEntry.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed record ApiKeyAuditEntry(
- string? KeyId,
- string EventType,
- string? RemoteAddress,
- string? Details);
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditRecord.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditRecord.cs
deleted file mode 100644
index 44d3240..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditRecord.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed record ApiKeyAuditRecord(
- long AuditId,
- string? KeyId,
- string EventType,
- string? RemoteAddress,
- DateTimeOffset CreatedUtc,
- string? Details);
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs
deleted file mode 100644
index 2092fb5..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed record ApiKeyCreateRequest(
- string KeyId,
- string KeyPrefix,
- byte[] SecretHash,
- string DisplayName,
- IReadOnlySet Scopes,
- ApiKeyConstraints Constraints,
- DateTimeOffset CreatedUtc);
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyParser.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyParser.cs
deleted file mode 100644
index 9a60da4..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyParser.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed class ApiKeyParser : IApiKeyParser
-{
- private const string BearerPrefix = "Bearer ";
- private const string TokenPrefix = "mxgw_";
-
- /// Attempts to parse a Bearer token from an Authorization header and extract the API key ID and secret.
- /// Authorization header value to parse.
- /// Parsed API key with ID and secret, or null if parsing failed.
- /// True if the header was successfully parsed; otherwise, false.
- public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
- {
- apiKey = null;
-
- if (string.IsNullOrWhiteSpace(authorizationHeader)
- || !authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- string token = authorizationHeader[BearerPrefix.Length..].Trim();
-
- if (!token.StartsWith(TokenPrefix, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
-
- string keyPayload = token[TokenPrefix.Length..];
- int separatorIndex = keyPayload.IndexOf('_', StringComparison.Ordinal);
-
- if (separatorIndex <= 0 || separatorIndex == keyPayload.Length - 1)
- {
- return false;
- }
-
- string keyId = keyPayload[..separatorIndex];
- string secret = keyPayload[(separatorIndex + 1)..];
-
- if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secret))
- {
- return false;
- }
-
- apiKey = new ParsedApiKey(keyId, secret);
-
- return true;
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs
deleted file mode 100644
index 3bf9888..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs
+++ /dev/null
@@ -1,4 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed class ApiKeyPepperUnavailableException(string pepperSecretName)
- : InvalidOperationException($"API key pepper secret '{pepperSecretName}' is not configured.");
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecord.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecord.cs
deleted file mode 100644
index abe5bff..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecord.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed record ApiKeyRecord(
- string KeyId,
- string KeyPrefix,
- byte[] SecretHash,
- string DisplayName,
- IReadOnlySet Scopes,
- ApiKeyConstraints Constraints,
- DateTimeOffset CreatedUtc,
- DateTimeOffset? LastUsedUtc,
- DateTimeOffset? RevokedUtc);
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs
deleted file mode 100644
index 297aeec..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using Microsoft.Data.Sqlite;
-
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-/// Reads API key records from SQLite query results.
-public static class ApiKeyRecordReader
-{
- /// Deserializes a row from the API key table into an ApiKeyRecord.
- /// The data reader positioned at the API key row.
- /// The deserialized API key record.
- public static ApiKeyRecord Read(SqliteDataReader reader)
- {
- return new ApiKeyRecord(
- KeyId: reader.GetString(0),
- KeyPrefix: reader.GetString(1),
- SecretHash: (byte[])reader["secret_hash"],
- DisplayName: reader.GetString(3),
- Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)),
- Constraints: ApiKeyConstraintSerializer.Deserialize(reader.IsDBNull(5) ? null : reader.GetString(5)),
- CreatedUtc: DateTimeOffset.Parse(reader.GetString(6), System.Globalization.CultureInfo.InvariantCulture),
- LastUsedUtc: ReadNullableDateTimeOffset(reader, 7),
- RevokedUtc: ReadNullableDateTimeOffset(reader, 8));
- }
-
- private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal)
- {
- return reader.IsDBNull(ordinal)
- ? null
- : DateTimeOffset.Parse(reader.GetString(ordinal), System.Globalization.CultureInfo.InvariantCulture);
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs
deleted file mode 100644
index d098833..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using System.Text.Json;
-
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public static class ApiKeyScopeSerializer
-{
- /// Serializes scopes to JSON string.
- /// The scopes to serialize.
- /// JSON string representation.
- public static string Serialize(IReadOnlySet scopes)
- {
- return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
- }
-
- /// Deserializes scopes from JSON string.
- /// The JSON string to deserialize.
- /// Deserialized scopes set.
- public static IReadOnlySet Deserialize(string value)
- {
- if (string.IsNullOrWhiteSpace(value))
- {
- return new HashSet(StringComparer.Ordinal);
- }
-
- string[]? scopes = JsonSerializer.Deserialize(value);
-
- return new HashSet(scopes ?? [], StringComparer.Ordinal);
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs
deleted file mode 100644
index 70ac7d6..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.Security.Cryptography;
-
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-/// Generates cryptographically secure API key secrets.
-public static class ApiKeySecretGenerator
-{
- /// Generates a new random API key secret string.
- public static string Generate()
- {
- Span bytes = stackalloc byte[32];
- RandomNumberGenerator.Fill(bytes);
-
- return Convert.ToBase64String(bytes)
- .TrimEnd('=')
- .Replace('+', '-')
- .Replace('/', '_');
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs
deleted file mode 100644
index a04cf62..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using System.Security.Cryptography;
-using System.Text;
-using Microsoft.Extensions.Options;
-using ZB.MOM.WW.MxGateway.Server.Configuration;
-
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed class ApiKeySecretHasher(
- IConfiguration configuration,
- IOptions options) : IApiKeySecretHasher
-{
- /// Hashes an API key secret with pepper using HMAC-SHA256.
- /// The secret to hash.
- /// The hashed secret.
- public byte[] HashSecret(string secret)
- {
- string pepper = GetPepper();
- byte[] pepperBytes = Encoding.UTF8.GetBytes(pepper);
- byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
-
- using HMACSHA256 hmac = new(pepperBytes);
-
- return hmac.ComputeHash(secretBytes);
- }
-
- private string GetPepper()
- {
- string pepperSecretName = options.Value.Authentication.PepperSecretName;
- string? pepper = configuration[pepperSecretName];
-
- if (string.IsNullOrWhiteSpace(pepper))
- {
- throw new ApiKeyPepperUnavailableException(pepperSecretName);
- }
-
- return pepper;
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs
deleted file mode 100644
index 27b12d4..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public enum ApiKeyVerificationFailure
-{
- None,
- MissingOrMalformedCredentials,
- PepperUnavailable,
- KeyNotFound,
- KeyRevoked,
- SecretMismatch
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs
deleted file mode 100644
index 4c1d469..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed record ApiKeyVerificationResult(
- bool Succeeded,
- ApiKeyIdentity? Identity,
- ApiKeyVerificationFailure Failure)
-{
- ///
- /// Creates a successful verification result.
- ///
- /// API key identity.
- /// Success result.
- public static ApiKeyVerificationResult Success(ApiKeyIdentity identity)
- {
- return new ApiKeyVerificationResult(
- Succeeded: true,
- Identity: identity,
- Failure: ApiKeyVerificationFailure.None);
- }
-
- ///
- /// Creates a failed verification result.
- ///
- /// Verification failure reason.
- /// Failure result.
- public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure)
- {
- return new ApiKeyVerificationResult(
- Succeeded: false,
- Identity: null,
- Failure: failure);
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs
deleted file mode 100644
index 3344e3d..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using System.Security.Cryptography;
-
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed class ApiKeyVerifier(
- IApiKeyParser parser,
- IApiKeySecretHasher hasher,
- IApiKeyStore keyStore) : IApiKeyVerifier
-{
- ///
- /// Verifies an API key from an authorization header asynchronously.
- ///
- /// Authorization header value.
- /// Cancellation token.
- /// Verification result.
- public async Task VerifyAsync(
- string? authorizationHeader,
- CancellationToken cancellationToken)
- {
- if (!parser.TryParseAuthorizationHeader(authorizationHeader, out ParsedApiKey? parsedKey)
- || parsedKey is null)
- {
- return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.MissingOrMalformedCredentials);
- }
-
- ApiKeyRecord? storedKey = await keyStore.FindByKeyIdAsync(parsedKey.KeyId, cancellationToken)
- .ConfigureAwait(false);
-
- if (storedKey is null)
- {
- return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.KeyNotFound);
- }
-
- if (storedKey.RevokedUtc is not null)
- {
- return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.KeyRevoked);
- }
-
- byte[] presentedHash;
- try
- {
- presentedHash = hasher.HashSecret(parsedKey.Secret);
- }
- catch (ApiKeyPepperUnavailableException)
- {
- return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.PepperUnavailable);
- }
-
- if (!CryptographicOperations.FixedTimeEquals(presentedHash, storedKey.SecretHash))
- {
- return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch);
- }
-
- await keyStore.MarkKeyUsedAsync(storedKey.KeyId, DateTimeOffset.UtcNow, cancellationToken)
- .ConfigureAwait(false);
-
- return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
- KeyId: storedKey.KeyId,
- KeyPrefix: storedKey.KeyPrefix,
- DisplayName: storedKey.DisplayName,
- Scopes: storedKey.Scopes,
- Constraints: storedKey.Constraints));
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs
deleted file mode 100644
index 1ba5867..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs
+++ /dev/null
@@ -1,80 +0,0 @@
-using Microsoft.Data.Sqlite;
-using Microsoft.Extensions.Options;
-using ZB.MOM.WW.MxGateway.Server.Configuration;
-
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-///
-/// Factory for creating SQLite connections to the authentication store.
-///
-public sealed class AuthSqliteConnectionFactory(IOptions options)
-{
- ///
- /// Busy timeout applied to every auth-store connection. SQLite retries a busy
- /// database for this long before surfacing SQLITE_BUSY, so the concurrent
- /// MarkKeyUsedAsync / audit-append writers degrade gracefully under load
- /// instead of failing the request path.
- ///
- private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5);
-
- ///
- /// Creates an unopened SQLite connection to the auth database. Prefer
- /// , which also applies WAL journaling and the
- /// busy timeout.
- ///
- public SqliteConnection CreateConnection()
- {
- string sqlitePath = options.Value.Authentication.SqlitePath;
- string? directory = Path.GetDirectoryName(sqlitePath);
-
- if (!string.IsNullOrWhiteSpace(directory))
- {
- Directory.CreateDirectory(directory);
- }
-
- SqliteConnectionStringBuilder builder = new()
- {
- DataSource = sqlitePath,
- Mode = SqliteOpenMode.ReadWriteCreate,
- Pooling = true,
- DefaultTimeout = (int)BusyTimeout.TotalSeconds,
- };
-
- return new SqliteConnection(builder.ToString());
- }
-
- ///
- /// Creates a SQLite connection, opens it, and configures WAL journaling and a
- /// non-zero busy timeout so concurrent readers and writers degrade gracefully
- /// rather than surfacing SQLITE_BUSY as a hard failure.
- ///
- /// Cancellation token for the operation.
- /// An opened and configured SQLite connection.
- public async Task OpenConnectionAsync(CancellationToken cancellationToken)
- {
- SqliteConnection connection = CreateConnection();
- try
- {
- await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
- await ConfigureConnectionAsync(connection, cancellationToken).ConfigureAwait(false);
- return connection;
- }
- catch
- {
- await connection.DisposeAsync().ConfigureAwait(false);
- throw;
- }
- }
-
- private static async Task ConfigureConnectionAsync(
- SqliteConnection connection,
- CancellationToken cancellationToken)
- {
- // WAL is a persistent, database-level setting; re-applying it per connection
- // is cheap and a no-op once set. busy_timeout is per-connection state.
- await using SqliteCommand command = connection.CreateCommand();
- command.CommandText =
- $"PRAGMA journal_mode=WAL; PRAGMA busy_timeout={(int)BusyTimeout.TotalMilliseconds};";
- await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationException.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationException.cs
deleted file mode 100644
index 303cdc2..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationException.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed class AuthStoreMigrationException(string message) : InvalidOperationException(message);
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs
deleted file mode 100644
index abc0dd4..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using Microsoft.Extensions.Options;
-using ZB.MOM.WW.MxGateway.Server.Configuration;
-
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-///
-/// Hosted service that runs authentication store migrations on startup.
-///
-public sealed class AuthStoreMigrationHostedService(
- IOptions options,
- IAuthStoreMigrator migrator) : IHostedService
-{
- ///
- public async Task StartAsync(CancellationToken cancellationToken)
- {
- AuthenticationOptions authentication = options.Value.Authentication;
-
- if (authentication.Mode == AuthenticationMode.ApiKey && authentication.RunMigrationsOnStartup)
- {
- await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
- }
- }
-
- ///
- public Task StopAsync(CancellationToken cancellationToken)
- {
- return Task.CompletedTask;
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs
index 959caa5..4c99ff2 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs
@@ -1,27 +1,80 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys.Admin;
+using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
+using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
+
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
///
/// Extension methods for configuring the SQLite authentication store.
///
+///
+/// The peppered-HMAC API-key pipeline (token format, hashing, constant-time compare, SQLite
+/// schema, stores, verifier and migration) is provided by the shared
+/// ZB.MOM.WW.Auth.ApiKeys library, of which this gateway is the donor. This wiring binds
+/// the library's from the gateway's MxGateway:Authentication
+/// section and layers the gateway-specific constraint enforcement, gRPC interceptor, CLI and
+/// dashboard on top.
+///
public static class AuthStoreServiceCollectionExtensions
{
+ /// The configuration section the gateway binds API-key options from.
+ public const string AuthenticationSectionPath = "MxGateway:Authentication";
+
+ /// The gateway API-key token prefix (token format mxgw_<id>_<secret>).
+ public const string TokenPrefix = "mxgw";
+
+ /// The configuration key the API-key pepper is resolved from.
+ public const string PepperSecretName = "MxGateway:ApiKeyPepper";
+
///
/// Adds the SQLite authentication store and related services to the dependency container.
///
/// Service collection to configure.
+ /// Application configuration carrying the API-key options.
/// The service collection for chaining.
- public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
+ public static IServiceCollection AddSqliteAuthStore(
+ this IServiceCollection services,
+ IConfiguration configuration)
{
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ // Pin the gateway's API-key contract (token prefix "mxgw"; pepper resolved from
+ // MxGateway:ApiKeyPepper) by layering fallback defaults UNDER the supplied configuration:
+ // an in-memory source provides TokenPrefix/PepperSecretName only when the bound
+ // MxGateway:Authentication section omits them (the section has no TokenPrefix, and the pepper
+ // is intentionally not in appsettings — it is supplied at runtime). Explicit config wins
+ // because it is added last. ApiKeyOptions is an init-only record, so the values must be
+ // present at bind time rather than mutated post-configure.
+ IConfiguration effectiveConfig = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [$"{AuthenticationSectionPath}:TokenPrefix"] = TokenPrefix,
+ [$"{AuthenticationSectionPath}:PepperSecretName"] = PepperSecretName,
+ })
+ .AddConfiguration(configuration)
+ .Build();
+
+ // Register the shared API-key provider: binds ApiKeyOptions from MxGateway:Authentication,
+ // wires up the SQLite stores, the configuration-backed pepper provider, the verifier, the
+ // migrator and the migration hosted service.
+ services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
+
+ // The shared admin command set (create/revoke/rotate/list/init-db with audit) is not
+ // auto-registered by AddZbApiKeyAuth; the gateway CLI and dashboard drive it, so register
+ // it here over the already-wired stores, pepper provider and migrator.
+ services.AddSingleton(sp => new ApiKeyAdminCommands(
+ sp.GetRequiredService>().Value,
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService()));
+
services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddHostedService();
return services;
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/GatewayApiKeyIdentityMapper.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/GatewayApiKeyIdentityMapper.cs
new file mode 100644
index 0000000..ed55248
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/GatewayApiKeyIdentityMapper.cs
@@ -0,0 +1,43 @@
+using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
+
+namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
+
+///
+/// Maps the shared (which
+/// carries the key's scopes plus the opaque constraints JSON blob) onto the gateway's
+/// (which exposes the deserialized
+/// the downstream authorization code enforces).
+///
+///
+/// The shared verifier does not interpret the constraints column; it returns the stored
+/// JSON verbatim in .
+/// This mapper re-hydrates it via so the gateway's
+/// constraint enforcement (ConstraintEnforcer) and request-identity accessor continue
+/// to operate on the strongly-typed model unchanged.
+///
+public static class GatewayApiKeyIdentityMapper
+{
+ ///
+ /// Converts a shared API-key identity into the gateway identity, deserializing the opaque
+ /// constraints JSON into .
+ ///
+ /// The shared identity returned by the library verifier.
+ /// The gateway identity carrying the effective constraints.
+ public static ApiKeyIdentity ToGatewayIdentity(LibApiKeyIdentity identity)
+ {
+ ArgumentNullException.ThrowIfNull(identity);
+
+ // The library stores the opaque constraints blob in Constraints as the ConstraintsJson
+ // string (or null when the key has no constraints).
+ string? constraintsJson = identity.Constraints as string;
+
+ return new ApiKeyIdentity(
+ KeyId: identity.KeyId,
+ // The gateway token prefix is fixed ("mxgw"); the key id is its own field. KeyPrefix
+ // is retained only for surface compatibility with the gateway identity record.
+ KeyPrefix: "mxgw",
+ DisplayName: identity.DisplayName,
+ Scopes: identity.Scopes,
+ Constraints: ApiKeyConstraintSerializer.Deserialize(constraintsJson));
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs
deleted file mode 100644
index a5d7c10..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public interface IApiKeyAdminStore
-{
- ///
- /// Creates a new API key asynchronously.
- ///
- /// API key creation request.
- /// Cancellation token.
- /// Completed task.
- Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken);
-
- ///
- /// Lists all API keys asynchronously.
- ///
- /// Cancellation token.
- /// List of API key records.
- Task> ListAsync(CancellationToken cancellationToken);
-
- ///
- /// Revokes an API key asynchronously.
- ///
- /// Key identifier.
- /// Revocation timestamp.
- /// Cancellation token.
- /// True if revoked; otherwise false.
- Task RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken);
-
- ///
- /// Rotates an API key secret asynchronously.
- ///
- /// Key identifier.
- /// New secret hash.
- /// Rotation timestamp.
- /// Cancellation token.
- /// True if rotated; otherwise false.
- Task RotateAsync(
- string keyId,
- byte[] secretHash,
- DateTimeOffset rotatedUtc,
- CancellationToken cancellationToken);
-
- ///
- /// Permanently deletes an API key, but only if it is already revoked. Active keys are
- /// untouched (returns false) so an admin cannot delete a working credential without
- /// first revoking it — that preserves the audit trail and forces the revoke event to
- /// land in the audit log before the row disappears.
- ///
- /// Key identifier.
- /// Cancellation token.
- /// True if a revoked key was deleted; false if the key is missing or active.
- Task DeleteAsync(string keyId, CancellationToken cancellationToken);
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs
deleted file mode 100644
index c672a39..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-///
-/// Stores and retrieves audit events for API key operations.
-///
-public interface IApiKeyAuditStore
-{
- ///
- /// Appends an audit entry to the audit log.
- ///
- /// Audit entry to record.
- /// Token to cancel the asynchronous operation.
- /// Asynchronous task representing the append operation.
- Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken);
-
- ///
- /// Lists the most recent audit entries, up to the specified count.
- ///
- /// Maximum number of entries to return.
- /// Token to cancel the asynchronous operation.
- /// Asynchronous task returning the list of audit records.
- Task> ListRecentAsync(int count, CancellationToken cancellationToken);
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyParser.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyParser.cs
deleted file mode 100644
index ed78417..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyParser.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public interface IApiKeyParser
-{
- /// Attempts to parse an authorization header and extract the API key.
- /// Authorization header value to parse.
- /// Parsed API key if successful.
- bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey);
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs
deleted file mode 100644
index 3359caa..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public interface IApiKeySecretHasher
-{
- /// Hashes an API key secret and returns the hash bytes.
- /// API key secret to hash.
- byte[] HashSecret(string secret);
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyStore.cs
deleted file mode 100644
index 5ffdd56..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyStore.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-/// Persists API keys and audit records for authentication and accounting.
-public interface IApiKeyStore
-{
- /// Retrieves an API key by ID regardless of revocation status.
- /// Identifier of the API key.
- /// Token to cancel the asynchronous operation.
- Task FindByKeyIdAsync(string keyId, CancellationToken cancellationToken);
-
- /// Retrieves an active (non-revoked) API key by ID.
- /// Identifier of the API key.
- /// Token to cancel the asynchronous operation.
- Task FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken);
-
- /// Records that an API key was used for auditing and tracking.
- /// Identifier of the API key.
- /// Timestamp when the key was used in UTC.
- /// Token to cancel the asynchronous operation.
- Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken);
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs
deleted file mode 100644
index 977ca9e..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-/// Verifies API key authorization headers and returns the authenticated identity.
-public interface IApiKeyVerifier
-{
- /// Parses and verifies an authorization header, returning success with identity or a failure reason.
- /// The authorization header value to verify.
- /// Token to cancel the asynchronous operation.
- Task VerifyAsync(
- string? authorizationHeader,
- CancellationToken cancellationToken);
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs
deleted file mode 100644
index b147175..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-/// Migrates authentication storage between versions.
-public interface IAuthStoreMigrator
-{
- /// Performs authentication store migration asynchronously.
- /// Token to cancel the asynchronous operation.
- /// Asynchronous task representing the migration operation.
- Task MigrateAsync(CancellationToken cancellationToken);
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ParsedApiKey.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ParsedApiKey.cs
deleted file mode 100644
index a32f043..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ParsedApiKey.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed record ParsedApiKey(string KeyId, string Secret);
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs
deleted file mode 100644
index 38cf68a..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs
+++ /dev/null
@@ -1,141 +0,0 @@
-using Microsoft.Data.Sqlite;
-
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-///
-/// SQLite-backed storage for API key administration (create, list, revoke, rotate).
-///
-public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
-{
- ///
- public async Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
- {
- await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
-
- await using SqliteCommand command = connection.CreateCommand();
- command.CommandText = """
- INSERT INTO api_keys (
- key_id,
- key_prefix,
- secret_hash,
- display_name,
- scopes,
- constraints,
- created_utc,
- last_used_utc,
- revoked_utc)
- VALUES (
- $key_id,
- $key_prefix,
- $secret_hash,
- $display_name,
- $scopes,
- $constraints,
- $created_utc,
- NULL,
- NULL);
- """;
- AddCreateParameters(command, request);
-
- await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
-
- ///
- public async Task> ListAsync(CancellationToken cancellationToken)
- {
- await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
-
- await using SqliteCommand command = connection.CreateCommand();
- command.CommandText = """
- SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
- FROM api_keys
- ORDER BY key_id;
- """;
-
- List records = [];
-
- await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
- .ConfigureAwait(false);
-
- while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
- {
- records.Add(ApiKeyRecordReader.Read(reader));
- }
-
- return records;
- }
-
- ///
- public async Task RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken)
- {
- await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
-
- await using SqliteCommand command = connection.CreateCommand();
- command.CommandText = """
- UPDATE api_keys
- SET revoked_utc = $revoked_utc
- WHERE key_id = $key_id AND revoked_utc IS NULL;
- """;
- command.Parameters.AddWithValue("$key_id", keyId);
- command.Parameters.AddWithValue("$revoked_utc", revokedUtc.ToString("O"));
-
- int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
-
- return rows > 0;
- }
-
- ///
- public async Task RotateAsync(
- string keyId,
- byte[] secretHash,
- DateTimeOffset rotatedUtc,
- CancellationToken cancellationToken)
- {
- await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
-
- await using SqliteCommand command = connection.CreateCommand();
- command.CommandText = """
- UPDATE api_keys
- SET secret_hash = $secret_hash,
- last_used_utc = NULL,
- revoked_utc = NULL
- WHERE key_id = $key_id;
- """;
- command.Parameters.AddWithValue("$key_id", keyId);
- command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = secretHash;
-
- int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
-
- return rows > 0;
- }
-
- ///
- public async Task DeleteAsync(string keyId, CancellationToken cancellationToken)
- {
- await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
-
- await using SqliteCommand command = connection.CreateCommand();
- command.CommandText = """
- DELETE FROM api_keys
- WHERE key_id = $key_id AND revoked_utc IS NOT NULL;
- """;
- command.Parameters.AddWithValue("$key_id", keyId);
-
- int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
-
- return rows > 0;
- }
-
- private static void AddCreateParameters(SqliteCommand command, ApiKeyCreateRequest request)
- {
- command.Parameters.AddWithValue("$key_id", request.KeyId);
- command.Parameters.AddWithValue("$key_prefix", request.KeyPrefix);
- command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = request.SecretHash;
- command.Parameters.AddWithValue("$display_name", request.DisplayName);
- command.Parameters.AddWithValue("$scopes", ApiKeyScopeSerializer.Serialize(request.Scopes));
- command.Parameters.AddWithValue(
- "$constraints",
- (object?)ApiKeyConstraintSerializer.Serialize(request.Constraints) ?? DBNull.Value);
- command.Parameters.AddWithValue("$created_utc", request.CreatedUtc.ToString("O"));
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs
deleted file mode 100644
index ee2fef4..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-using Microsoft.Data.Sqlite;
-
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore
-{
- ///
- public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
- {
- await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
-
- await using SqliteCommand command = connection.CreateCommand();
- command.CommandText = """
- INSERT INTO api_key_audit (key_id, event_type, remote_address, created_utc, details)
- VALUES ($key_id, $event_type, $remote_address, $created_utc, $details);
- """;
- command.Parameters.AddWithValue("$key_id", (object?)entry.KeyId ?? DBNull.Value);
- command.Parameters.AddWithValue("$event_type", entry.EventType);
- command.Parameters.AddWithValue("$remote_address", (object?)entry.RemoteAddress ?? DBNull.Value);
- command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UtcNow.ToString("O"));
- command.Parameters.AddWithValue("$details", (object?)entry.Details ?? DBNull.Value);
-
- await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
-
- ///
- public async Task> ListRecentAsync(int count, CancellationToken cancellationToken)
- {
- if (count <= 0)
- {
- return [];
- }
-
- await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
-
- await using SqliteCommand command = connection.CreateCommand();
- command.CommandText = """
- SELECT audit_id, key_id, event_type, remote_address, created_utc, details
- FROM api_key_audit
- ORDER BY audit_id DESC
- LIMIT $count;
- """;
- command.Parameters.AddWithValue("$count", count);
-
- List records = [];
-
- await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
- .ConfigureAwait(false);
-
- while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
- {
- records.Add(new ApiKeyAuditRecord(
- AuditId: reader.GetInt64(0),
- KeyId: reader.IsDBNull(1) ? null : reader.GetString(1),
- EventType: reader.GetString(2),
- RemoteAddress: reader.IsDBNull(3) ? null : reader.GetString(3),
- CreatedUtc: DateTimeOffset.Parse(
- reader.GetString(4),
- System.Globalization.CultureInfo.InvariantCulture),
- Details: reader.IsDBNull(5) ? null : reader.GetString(5)));
- }
-
- return records;
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs
deleted file mode 100644
index a8ecf50..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-using Microsoft.Data.Sqlite;
-
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-/// SQLite-based store for API key records.
-public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore
-{
- ///
- public Task FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
- {
- return FindByKeyIdAsync(keyId, requireActive: false, cancellationToken);
- }
-
- ///
- public Task FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
- {
- return FindByKeyIdAsync(keyId, requireActive: true, cancellationToken);
- }
-
- ///
- public async Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
- {
- await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
-
- await using SqliteCommand command = connection.CreateCommand();
- command.CommandText = """
- UPDATE api_keys
- SET last_used_utc = $last_used_utc
- WHERE key_id = $key_id AND revoked_utc IS NULL;
- """;
- command.Parameters.AddWithValue("$key_id", keyId);
- command.Parameters.AddWithValue("$last_used_utc", usedUtc.ToString("O"));
-
- await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
-
- private async Task FindByKeyIdAsync(
- string keyId,
- bool requireActive,
- CancellationToken cancellationToken)
- {
- await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
-
- await using SqliteCommand command = connection.CreateCommand();
- command.CommandText = requireActive
- ? """
- SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
- FROM api_keys
- WHERE key_id = $key_id AND revoked_utc IS NULL;
- """
- : """
- SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
- FROM api_keys
- WHERE key_id = $key_id;
- """;
- command.Parameters.AddWithValue("$key_id", keyId);
-
- await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
- .ConfigureAwait(false);
-
- if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
- {
- return null;
- }
-
- return ApiKeyRecordReader.Read(reader);
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs
deleted file mode 100644
index 299441a..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public static class SqliteAuthSchema
-{
- public const int CurrentVersion = 2;
-
- public const string SchemaVersionTable = "schema_version";
-
- public const string ApiKeysTable = "api_keys";
-
- public const string ApiKeyAuditTable = "api_key_audit";
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs
deleted file mode 100644
index f5a0e77..0000000
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs
+++ /dev/null
@@ -1,192 +0,0 @@
-using Microsoft.Data.Sqlite;
-
-namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IAuthStoreMigrator
-{
- /// Applies database migrations to the authentication store.
- /// Cancellation token.
- public async Task MigrateAsync(CancellationToken cancellationToken)
- {
- await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
-
- await using SqliteTransaction transaction =
- (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
-
- int existingVersion = await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken)
- .ConfigureAwait(false);
-
- if (existingVersion > SqliteAuthSchema.CurrentVersion)
- {
- throw new AuthStoreMigrationException(
- $"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
- }
-
- await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
- await ApplyVersionTwoAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
- await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
-
- await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
- }
-
- private static async Task ReadExistingSchemaVersionAsync(
- SqliteConnection connection,
- SqliteTransaction transaction,
- CancellationToken cancellationToken)
- {
- await using SqliteCommand tableExistsCommand = connection.CreateCommand();
- tableExistsCommand.Transaction = transaction;
- tableExistsCommand.CommandText = """
- SELECT COUNT(*)
- FROM sqlite_master
- WHERE type = 'table' AND name = $table_name;
- """;
- tableExistsCommand.Parameters.AddWithValue("$table_name", SqliteAuthSchema.SchemaVersionTable);
-
- long tableCount = (long)(await tableExistsCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? 0L);
-
- if (tableCount == 0)
- {
- return 0;
- }
-
- await using SqliteCommand versionCommand = connection.CreateCommand();
- versionCommand.Transaction = transaction;
- versionCommand.CommandText = """
- SELECT version
- FROM schema_version
- WHERE id = 1;
- """;
-
- object? version = await versionCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
-
- return version is null || version == DBNull.Value
- ? 0
- : Convert.ToInt32(version, System.Globalization.CultureInfo.InvariantCulture);
- }
-
- private static async Task ApplyVersionOneAsync(
- SqliteConnection connection,
- SqliteTransaction transaction,
- CancellationToken cancellationToken)
- {
- await ExecuteNonQueryAsync(
- connection,
- transaction,
- """
- CREATE TABLE IF NOT EXISTS schema_version (
- id INTEGER PRIMARY KEY CHECK (id = 1),
- version INTEGER NOT NULL,
- applied_utc TEXT NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS api_keys (
- key_id TEXT PRIMARY KEY,
- key_prefix TEXT NOT NULL,
- secret_hash BLOB NOT NULL,
- display_name TEXT NOT NULL,
- scopes TEXT NOT NULL,
- constraints TEXT NULL,
- created_utc TEXT NOT NULL,
- last_used_utc TEXT NULL,
- revoked_utc TEXT NULL
- );
-
- CREATE TABLE IF NOT EXISTS api_key_audit (
- audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
- key_id TEXT NULL,
- event_type TEXT NOT NULL,
- remote_address TEXT NULL,
- created_utc TEXT NOT NULL,
- details TEXT NULL
- );
-
- CREATE INDEX IF NOT EXISTS ix_api_keys_revoked_utc
- ON api_keys (revoked_utc);
-
- CREATE INDEX IF NOT EXISTS ix_api_key_audit_key_id_created_utc
- ON api_key_audit (key_id, created_utc);
- """,
- cancellationToken).ConfigureAwait(false);
-
- }
-
- private static async Task ApplyVersionTwoAsync(
- SqliteConnection connection,
- SqliteTransaction transaction,
- CancellationToken cancellationToken)
- {
- if (await ColumnExistsAsync(connection, transaction, SqliteAuthSchema.ApiKeysTable, "constraints", cancellationToken)
- .ConfigureAwait(false))
- {
- return;
- }
-
- await ExecuteNonQueryAsync(
- connection,
- transaction,
- """
- ALTER TABLE api_keys
- ADD COLUMN constraints TEXT NULL;
- """,
- cancellationToken).ConfigureAwait(false);
- }
-
- private static async Task WriteSchemaVersionAsync(
- SqliteConnection connection,
- SqliteTransaction transaction,
- CancellationToken cancellationToken)
- {
- await using SqliteCommand versionCommand = connection.CreateCommand();
- versionCommand.Transaction = transaction;
- versionCommand.CommandText = """
- INSERT INTO schema_version (id, version, applied_utc)
- VALUES (1, $version, $applied_utc)
- ON CONFLICT(id) DO UPDATE SET
- version = excluded.version,
- applied_utc = excluded.applied_utc;
- """;
- versionCommand.Parameters.AddWithValue("$version", SqliteAuthSchema.CurrentVersion);
- versionCommand.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
-
- await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
-
- private static async Task ColumnExistsAsync(
- SqliteConnection connection,
- SqliteTransaction transaction,
- string tableName,
- string columnName,
- CancellationToken cancellationToken)
- {
- await using SqliteCommand command = connection.CreateCommand();
- command.Transaction = transaction;
- command.CommandText = $"PRAGMA table_info({tableName});";
-
- await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
- .ConfigureAwait(false);
-
- while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
- {
- if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
- }
-
- return false;
- }
-
- private static async Task ExecuteNonQueryAsync(
- SqliteConnection connection,
- SqliteTransaction transaction,
- string commandText,
- CancellationToken cancellationToken)
- {
- await using SqliteCommand command = connection.CreateCommand();
- command.Transaction = transaction;
- command.CommandText = commandText;
-
- await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs
index 17c9e10..ef85a5a 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs
@@ -1,8 +1,13 @@
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Sessions;
+// The gateway carries its own constraint-bearing identity downstream; the shared library also
+// defines an ApiKeyIdentity (scopes + opaque constraints JSON), so disambiguate to the gateway type.
+using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
+
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
public sealed class ConstraintEnforcer(
@@ -126,6 +131,7 @@ public sealed class ConstraintEnforcer(
KeyId: identity?.KeyId,
EventType: "constraint-denied",
RemoteAddress: null,
+ CreatedUtc: DateTimeOffset.UtcNow,
Details: $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"),
cancellationToken)
.ConfigureAwait(false);
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs
index ad98a30..97533d5 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs
@@ -1,9 +1,14 @@
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
+// The handler pushes the gateway's constraint-bearing identity; alias away the shared library's
+// ApiKeyIdentity so the unqualified name resolves to the gateway type.
+using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
+
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
public sealed class GatewayGrpcAuthorizationInterceptor(
@@ -57,25 +62,31 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
}
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
- ApiKeyVerificationResult verificationResult = await apiKeyVerifier
- .VerifyAsync(authorizationHeader, context.CancellationToken)
+
+ // The shared verifier owns parse + pepper + lookup + revocation + constant-time compare,
+ // returning a discriminated failure rather than throwing. Every authentication failure maps
+ // to Unauthenticated with an opaque message; the client never learns which stage failed.
+ ApiKeyVerification verification = await apiKeyVerifier
+ .VerifyAsync(authorizationHeader ?? string.Empty, context.CancellationToken)
.ConfigureAwait(false);
- if (!verificationResult.Succeeded || verificationResult.Identity is null)
+ if (!verification.Succeeded || verification.Identity is null)
{
throw new RpcException(new Status(
StatusCode.Unauthenticated,
"Missing or invalid API key."));
}
+ ApiKeyIdentity identity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
+
string requiredScope = scopeResolver.ResolveRequiredScope(request);
- if (!verificationResult.Identity.Scopes.Contains(requiredScope))
+ if (!identity.Scopes.Contains(requiredScope))
{
throw new RpcException(new Status(
StatusCode.PermissionDenied,
$"API key is missing required scope '{requiredScope}'."));
}
- return verificationResult.Identity;
+ return identity;
}
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
index 7fc4ca6..961f650 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
+++ b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
@@ -6,9 +6,10 @@
-
-
-
+
+
+
+
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs
index 3a9aa1f..df87ed3 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs
@@ -1,8 +1,6 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
-using Microsoft.Extensions.Options;
-using ZB.MOM.WW.MxGateway.Server.Configuration;
+using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
-using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics;
@@ -10,13 +8,8 @@ public sealed class AuthStoreHealthCheckTests
{
private static AuthSqliteConnectionFactory FactoryFor(string sqlitePath)
{
- // GatewayOptions.Authentication and AuthenticationOptions.SqlitePath are both
- // init-only, so populate them through object initializers.
- var options = new GatewayOptions
- {
- Authentication = new AuthenticationOptions { SqlitePath = sqlitePath },
- };
- return new AuthSqliteConnectionFactory(Options.Create(options));
+ // The shared connection factory targets a database path directly.
+ return new AuthSqliteConnectionFactory(sqlitePath);
}
[Fact]
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs
index 917d877..c8e4faf 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs
@@ -1,21 +1,37 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Options;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
+using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
+
+// The mapped identity is the gateway's constraint-bearing type; disambiguate from the library's.
+using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
-public sealed class DashboardApiKeyManagementServiceTests
+///
+/// Tests the gateway dashboard API-key management surface over the shared
+/// ZB.MOM.WW.Auth.ApiKeys admin commands and stores (the gateway is the donor). The service
+/// is exercised against a real temporary SQLite store so the create/revoke/rotate/delete flow,
+/// dashboard audit vocabulary, mxgw token format, duplicate-id rejection and revoke-before-delete
+/// rule are all proven end-to-end.
+///
+public sealed class DashboardApiKeyManagementServiceTests : IDisposable
{
+ private readonly List _tempDirectories = [];
+
/// Verifies that unauthorized users cannot create API keys.
[Fact]
- public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore()
+ public async Task CreateAsync_UnauthorizedUser_DoesNotCreate()
{
- FakeApiKeyAdminStore adminStore = new();
- DashboardApiKeyManagementService service = CreateService(adminStore);
+ await using ServiceProvider services = BuildServices();
+ DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.CreateAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
@@ -23,17 +39,15 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.False(result.Succeeded);
- Assert.Equal(0, adminStore.CreateCount);
+ Assert.Empty(await ListAsync(services));
}
- /// Verifies that authorized users can create keys with secret hashing and audit trail.
+ /// Verifies that authorized users create a verifiable, constrained key and audit it.
[Fact]
- public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits()
+ public async Task CreateAsync_AuthorizedUser_CreatesVerifiableKeyAndAudits()
{
- FakeApiKeyAdminStore adminStore = new();
- FakeApiKeyAuditStore auditStore = new();
- FakeApiKeySecretHasher hasher = new();
- DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
+ await using ServiceProvider services = BuildServices();
+ DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.CreateAsync(
CreateAuthorizedUser(),
@@ -43,42 +57,46 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.True(result.Succeeded);
Assert.NotNull(result.ApiKey);
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
- string secret = result.ApiKey["mxgw_operator01_".Length..];
- Assert.Equal(secret, hasher.LastSecret);
- Assert.DoesNotContain("mxgw_operator01_", hasher.LastSecret, StringComparison.Ordinal);
- ApiKeyCreateRequest stored = Assert.Single(adminStore.CreatedRequests);
- Assert.Equal("operator01", stored.KeyId);
- Assert.Equal("Operator", stored.DisplayName);
- Assert.Contains(GatewayScopes.SessionOpen, stored.Scopes);
- Assert.Equal(["Area1/*"], stored.Constraints.BrowseSubtrees);
- Assert.Contains(auditStore.Entries, entry =>
- entry.EventType == "dashboard-create-key"
- && entry.KeyId == "operator01");
+
+ // The freshly minted token authenticates against the same store and surfaces its scopes.
+ ApiKeyVerification verification = await services
+ .GetRequiredService()
+ .VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None);
+ Assert.True(verification.Succeeded);
+ Assert.Contains(GatewayScopes.SessionOpen, verification.Identity!.Scopes);
+
+ // Constraints round-trip through the opaque JSON blob.
+ ApiKeyIdentity gatewayIdentity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
+ Assert.Equal(["Area1/*"], gatewayIdentity.EffectiveConstraints.BrowseSubtrees);
+
+ IReadOnlyList audit = await ListAuditAsync(services);
+ Assert.Contains(audit, entry => entry.EventType == "dashboard-create-key" && entry.KeyId == "operator01");
}
- /// Verifies that unauthorized users cannot revoke API keys.
+ /// Verifies that creating a key whose id already exists is rejected.
[Fact]
- public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore()
+ public async Task CreateAsync_DuplicateKeyId_ReportsConflict()
{
- FakeApiKeyAdminStore adminStore = new();
- DashboardApiKeyManagementService service = CreateService(adminStore);
+ await using ServiceProvider services = BuildServices();
+ DashboardApiKeyManagementService service = CreateService(services);
- DashboardApiKeyManagementResult result = await service.RevokeAsync(
- new ClaimsPrincipal(new ClaimsIdentity()),
- "operator01",
+ await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
+ DashboardApiKeyManagementResult duplicate = await service.CreateAsync(
+ CreateAuthorizedUser(),
+ CreateRequest(),
CancellationToken.None);
- Assert.False(result.Succeeded);
- Assert.Equal(0, adminStore.RevokeCount);
+ Assert.False(duplicate.Succeeded);
+ Assert.Contains("already exists", duplicate.Message, StringComparison.OrdinalIgnoreCase);
}
/// Verifies that authorized users can revoke keys with audit trail.
[Fact]
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
{
- FakeApiKeyAdminStore adminStore = new() { RevokeResult = true };
- FakeApiKeyAuditStore auditStore = new();
- DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
+ await using ServiceProvider services = BuildServices();
+ DashboardApiKeyManagementService service = CreateService(services);
+ await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult result = await service.RevokeAsync(
CreateAuthorizedUser(),
@@ -86,21 +104,23 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.True(result.Succeeded);
- Assert.Equal("operator01", adminStore.LastRevokedKeyId);
- Assert.Contains(auditStore.Entries, entry =>
+ ApiKeyListItem key = Assert.Single(await ListAsync(services));
+ Assert.NotNull(key.RevokedUtc);
+ IReadOnlyList audit = await ListAuditAsync(services);
+ Assert.Contains(audit, entry =>
entry.EventType == "dashboard-revoke-key"
&& entry.KeyId == "operator01"
&& entry.Details == "revoked");
}
- /// Verifies that authorized users can rotate secret hashes with audit trail.
+ /// Verifies that authorized users can rotate a key's secret with audit trail.
[Fact]
- public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits()
+ public async Task RotateAsync_AuthorizedUser_RotatesAndAudits()
{
- FakeApiKeyAdminStore adminStore = new() { RotateResult = true };
- FakeApiKeyAuditStore auditStore = new();
- FakeApiKeySecretHasher hasher = new();
- DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
+ await using ServiceProvider services = BuildServices();
+ DashboardApiKeyManagementService service = CreateService(services);
+ DashboardApiKeyManagementResult created = await service.CreateAsync(
+ CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult result = await service.RotateAsync(
CreateAuthorizedUser(),
@@ -110,36 +130,28 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.True(result.Succeeded);
Assert.NotNull(result.ApiKey);
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
- Assert.Equal(hasher.HashSecret(hasher.LastSecret!), adminStore.LastRotatedSecretHash);
- Assert.Contains(auditStore.Entries, entry =>
+ Assert.NotEqual(created.ApiKey, result.ApiKey);
+
+ // Old token no longer authenticates; new one does.
+ IApiKeyVerifier verifier = services.GetRequiredService();
+ Assert.False((await verifier.VerifyAsync($"Bearer {created.ApiKey}", CancellationToken.None)).Succeeded);
+ Assert.True((await verifier.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None)).Succeeded);
+
+ IReadOnlyList audit = await ListAuditAsync(services);
+ Assert.Contains(audit, entry =>
entry.EventType == "dashboard-rotate-key"
&& entry.KeyId == "operator01"
&& entry.Details == "rotated");
}
- /// Verifies that unauthorized users cannot delete API keys.
- [Fact]
- public async Task DeleteAsync_UnauthorizedUser_DoesNotCallStore()
- {
- FakeApiKeyAdminStore adminStore = new();
- DashboardApiKeyManagementService service = CreateService(adminStore);
-
- DashboardApiKeyManagementResult result = await service.DeleteAsync(
- new ClaimsPrincipal(new ClaimsIdentity()),
- "operator01",
- CancellationToken.None);
-
- Assert.False(result.Succeeded);
- Assert.Equal(0, adminStore.DeleteCount);
- }
-
/// Verifies that authorized users can delete revoked keys with audit trail.
[Fact]
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
{
- FakeApiKeyAdminStore adminStore = new() { DeleteResult = true };
- FakeApiKeyAuditStore auditStore = new();
- DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
+ await using ServiceProvider services = BuildServices();
+ DashboardApiKeyManagementService service = CreateService(services);
+ await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
+ await service.RevokeAsync(CreateAuthorizedUser(), "operator01", CancellationToken.None);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
@@ -147,27 +159,25 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.True(result.Succeeded);
- Assert.Equal("operator01", adminStore.LastDeletedKeyId);
- Assert.Contains(auditStore.Entries, entry =>
+ Assert.Empty(await ListAsync(services));
+ IReadOnlyList audit = await ListAuditAsync(services);
+ Assert.Contains(audit, entry =>
entry.EventType == "dashboard-delete-key"
&& entry.KeyId == "operator01"
&& entry.Details == "deleted");
}
///
- /// Tests-030: when the admin store refuses the delete (returns false), the service
- /// still emits a dashboard-delete-key audit entry with Details = "not-found-or-active"
- /// because AppendAuditAsync runs unconditionally after the store call. A regression that
- /// moved the audit-append call inside the if (deleted) branch would silently drop the
- /// audit trail for refused deletes — a real audit-completeness gap. This test pins both the
- /// friendly-error response AND the unconditional audit entry.
+ /// When the key is still active (not revoked), the delete is refused but a
+ /// dashboard-delete-key audit entry with Details = "not-found-or-active" is still
+ /// written — audit completeness for refused deletes.
///
[Fact]
- public async Task DeleteAsync_WhenStoreRefuses_ReportsFriendlyErrorAndAudits()
+ public async Task DeleteAsync_ActiveKey_ReportsFriendlyErrorAndAudits()
{
- FakeApiKeyAdminStore adminStore = new() { DeleteResult = false };
- FakeApiKeyAuditStore auditStore = new();
- DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
+ await using ServiceProvider services = BuildServices();
+ DashboardApiKeyManagementService service = CreateService(services);
+ await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
@@ -177,17 +187,14 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.False(result.Succeeded);
Assert.Contains("Revoke", result.Message, StringComparison.Ordinal);
- ApiKeyAuditEntry auditEntry = Assert.Single(auditStore.Entries);
- Assert.Equal("dashboard-delete-key", auditEntry.EventType);
- Assert.Equal("operator01", auditEntry.KeyId);
- Assert.Equal("not-found-or-active", auditEntry.Details);
+ IReadOnlyList audit = await ListAuditAsync(services);
+ ApiKeyAuditEntry deleteEntry = Assert.Single(
+ audit, entry => entry.EventType == "dashboard-delete-key");
+ Assert.Equal("operator01", deleteEntry.KeyId);
+ Assert.Equal("not-found-or-active", deleteEntry.Details);
}
- ///
- /// Tests-030: calls
- /// ValidateKeyId after the authorisation check. A blank key id must fail with the
- /// shared "API key id is required." message before any store or audit call runs.
- ///
+ /// A blank key id fails validation before any store or audit call runs.
/// A blank or whitespace key identifier.
[Theory]
[InlineData("")]
@@ -195,9 +202,8 @@ public sealed class DashboardApiKeyManagementServiceTests
[InlineData("\t")]
public async Task DeleteAsync_BlankKeyId_ReturnsFailure(string blankKeyId)
{
- FakeApiKeyAdminStore adminStore = new();
- FakeApiKeyAuditStore auditStore = new();
- DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
+ await using ServiceProvider services = BuildServices();
+ DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
@@ -205,20 +211,19 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.False(result.Succeeded);
- Assert.Equal(0, adminStore.DeleteCount);
- Assert.Empty(auditStore.Entries);
+ Assert.Empty(await ListAuditAsync(services));
}
///
- /// Server-004 regression: the dashboard create path must reject a request
- /// carrying a non-canonical scope string rather than persisting a key whose
- /// scope the authorization resolver never matches.
+ /// Server-004 regression: the dashboard create path must reject a request carrying a
+ /// non-canonical scope string rather than persisting a key whose scope the authorization
+ /// resolver never matches.
///
[Fact]
- public async Task CreateAsync_UnknownScope_DoesNotCallStore()
+ public async Task CreateAsync_UnknownScope_DoesNotCreate()
{
- FakeApiKeyAdminStore adminStore = new();
- DashboardApiKeyManagementService service = CreateService(adminStore);
+ await using ServiceProvider services = BuildServices();
+ DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementRequest request = CreateRequest() with
{
@@ -233,25 +238,61 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.False(result.Succeeded);
- Assert.Equal(0, adminStore.CreateCount);
+ Assert.Empty(await ListAsync(services));
}
- private static DashboardApiKeyManagementService CreateService(
- FakeApiKeyAdminStore? adminStore = null,
- FakeApiKeyAuditStore? auditStore = null,
- FakeApiKeySecretHasher? hasher = null)
+ private DashboardApiKeyManagementService CreateService(ServiceProvider services)
{
DefaultHttpContext httpContext = new();
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
return new DashboardApiKeyManagementService(
new DashboardApiKeyAuthorization(),
- adminStore ?? new FakeApiKeyAdminStore(),
- auditStore ?? new FakeApiKeyAuditStore(),
- hasher ?? new FakeApiKeySecretHasher(),
+ services.GetRequiredService(),
+ services.GetRequiredService(),
+ services.GetRequiredService(),
new HttpContextAccessor { HttpContext = httpContext });
}
+ private ServiceProvider BuildServices()
+ {
+ TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-dashboard-apikey-tests");
+ _tempDirectories.Add(directory);
+
+ IConfigurationRoot configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(
+ new Dictionary
+ {
+ ["MxGateway:Authentication:SqlitePath"] = directory.DatabasePath(),
+ ["MxGateway:ApiKeyPepper"] = "test-pepper"
+ })
+ .Build();
+
+ ServiceCollection services = new();
+ services.AddSingleton(configuration);
+ services.AddGatewayConfiguration(configuration);
+ services.AddSqliteAuthStore(configuration);
+
+ ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
+
+ // Production migrates the schema via the migration hosted service at startup; in these
+ // DI-only tests no host runs, so apply the (idempotent) migration up front.
+ provider.GetRequiredService()
+ .MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
+
+ return provider;
+ }
+
+ private static Task> ListAsync(ServiceProvider services)
+ {
+ return services.GetRequiredService().ListAsync(CancellationToken.None);
+ }
+
+ private static Task> ListAuditAsync(ServiceProvider services)
+ {
+ return services.GetRequiredService().ListRecentAsync(50, CancellationToken.None);
+ }
+
private static DashboardApiKeyManagementRequest CreateRequest()
{
return new DashboardApiKeyManagementRequest(
@@ -275,114 +316,14 @@ public sealed class DashboardApiKeyManagementServiceTests
return new ClaimsPrincipal(identity);
}
- private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
+ /// Clears SQLite pools and deletes every temporary directory created by this test.
+ public void Dispose()
{
- /// Gets the count of create operations performed.
- public int CreateCount { get; private set; }
-
- /// Gets the count of revoke operations performed.
- public int RevokeCount { get; private set; }
-
- /// Gets the count of delete operations performed.
- public int DeleteCount { get; private set; }
-
- /// Gets or sets the result value returned by revoke operations.
- public bool RevokeResult { get; init; }
-
- /// Gets or sets the result value returned by rotate operations.
- public bool RotateResult { get; init; }
-
- /// Gets or sets the result value returned by delete operations.
- public bool DeleteResult { get; init; }
-
- /// Gets the last key ID revoked.
- public string? LastRevokedKeyId { get; private set; }
-
- /// Gets the last key ID deleted.
- public string? LastDeletedKeyId { get; private set; }
-
- /// Gets the last secret hash rotated.
- public byte[]? LastRotatedSecretHash { get; private set; }
-
- /// Gets the list of create requests received.
- public List CreatedRequests { get; } = [];
-
- ///
- public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
+ foreach (TempDatabaseDirectory directory in _tempDirectories)
{
- CreateCount++;
- CreatedRequests.Add(request);
- return Task.CompletedTask;
+ directory.Dispose();
}
- ///
- public Task> ListAsync(CancellationToken cancellationToken)
- {
- return Task.FromResult>([]);
- }
-
- ///
- public Task RevokeAsync(
- string keyId,
- DateTimeOffset revokedUtc,
- CancellationToken cancellationToken)
- {
- RevokeCount++;
- LastRevokedKeyId = keyId;
- return Task.FromResult(RevokeResult);
- }
-
- ///
- public Task RotateAsync(
- string keyId,
- byte[] secretHash,
- DateTimeOffset rotatedUtc,
- CancellationToken cancellationToken)
- {
- LastRotatedSecretHash = secretHash;
- return Task.FromResult(RotateResult);
- }
-
- ///
- public Task DeleteAsync(string keyId, CancellationToken cancellationToken)
- {
- DeleteCount++;
- LastDeletedKeyId = keyId;
- return Task.FromResult(DeleteResult);
- }
- }
-
- private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
- {
- /// Gets the list of audit entries appended.
- public List Entries { get; } = [];
-
- ///
- public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
- {
- Entries.Add(entry);
- return Task.CompletedTask;
- }
-
- ///
- public Task> ListRecentAsync(
- int count,
- CancellationToken cancellationToken)
- {
- return Task.FromResult>([]);
- }
- }
-
- private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher
- {
- /// Gets the last secret hashed.
- public string? LastSecret { get; private set; }
-
- ///
- public byte[] HashSecret(string secret)
- {
- LastSecret = secret;
- return System.Text.Encoding.UTF8.GetBytes($"hash:{secret}");
- }
+ _tempDirectories.Clear();
}
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs
index c189d99..a50b374 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs
@@ -1,5 +1,6 @@
using System.Globalization;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -273,16 +274,15 @@ public sealed class DashboardSnapshotServiceTests
{
using GatewayMetrics metrics = new();
CountingApiKeyAdminStore apiKeyAdminStore = new(
- new ApiKeyRecord(
+ new ApiKeyListItem(
KeyId: "operator01",
- KeyPrefix: "mxgw_operator01",
- SecretHash: [1, 2, 3],
+ KeyPrefix: "mxgw",
DisplayName: "Operator",
Scopes: new HashSet([GatewayScopes.MetadataRead], StringComparer.Ordinal),
- Constraints: ApiKeyConstraints.Empty with
+ ConstraintsJson: ApiKeyConstraintSerializer.Serialize(ApiKeyConstraints.Empty with
{
BrowseSubtrees = ["Area1/*"],
- },
+ }),
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
LastUsedUtc: null,
RevokedUtc: null));
@@ -310,13 +310,12 @@ public sealed class DashboardSnapshotServiceTests
{
using GatewayMetrics metrics = new();
SequencedApiKeyAdminStore apiKeyAdminStore = new(
- new ApiKeyRecord(
+ new ApiKeyListItem(
KeyId: "operator01",
- KeyPrefix: "mxgw_operator01",
- SecretHash: [1, 2, 3],
+ KeyPrefix: "mxgw",
DisplayName: "Operator",
Scopes: new HashSet([GatewayScopes.MetadataRead], StringComparer.Ordinal),
- Constraints: ApiKeyConstraints.Empty,
+ ConstraintsJson: null,
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
LastUsedUtc: null,
RevokedUtc: null));
@@ -425,63 +424,56 @@ public sealed class DashboardSnapshotServiceTests
private class FakeApiKeyAdminStore : IApiKeyAdminStore
{
///
- public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
+ public Task CreateAsync(ApiKeyRecord record, CancellationToken ct)
{
return Task.CompletedTask;
}
///
- public virtual Task> ListAsync(CancellationToken cancellationToken)
+ public virtual Task> ListAsync(CancellationToken ct)
{
- return Task.FromResult>([]);
+ return Task.FromResult>([]);
}
///
- public Task RevokeAsync(
- string keyId,
- DateTimeOffset revokedUtc,
- CancellationToken cancellationToken)
+ public Task RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
{
return Task.FromResult(false);
}
///
- public Task RotateAsync(
- string keyId,
- byte[] secretHash,
- DateTimeOffset rotatedUtc,
- CancellationToken cancellationToken)
+ public Task RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct)
{
return Task.FromResult(false);
}
///
- public Task DeleteAsync(string keyId, CancellationToken cancellationToken)
+ public Task DeleteAsync(string keyId, CancellationToken ct)
{
return Task.FromResult(false);
}
}
- private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
+ private class CountingApiKeyAdminStore(params ApiKeyListItem[] records) : FakeApiKeyAdminStore
{
/// Gets the count of list operations performed.
public int ListCount { get; protected set; }
///
- public override Task> ListAsync(CancellationToken cancellationToken)
+ public override Task> ListAsync(CancellationToken ct)
{
ListCount++;
- return Task.FromResult>(records);
+ return Task.FromResult>(records);
}
}
- private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
+ private sealed class SequencedApiKeyAdminStore(ApiKeyListItem record) : CountingApiKeyAdminStore(record)
{
/// Gets or sets a value indicating whether the next list operation should fail.
public bool FailNext { get; set; }
///
- public override Task> ListAsync(CancellationToken cancellationToken)
+ public override Task> ListAsync(CancellationToken ct)
{
if (FailNext)
{
@@ -490,7 +482,7 @@ public sealed class DashboardSnapshotServiceTests
throw new InvalidOperationException("Simulated SQLite failure.");
}
- return base.ListAsync(cancellationToken);
+ return base.ListAsync(ct);
}
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs
index 173d4b4..c6cce0f 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs
@@ -1,9 +1,13 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
+// The mapped identity is the gateway's constraint-bearing type; disambiguate from the library's.
+using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
+
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
public sealed class ApiKeyAdminCliRunnerTests : IDisposable
@@ -33,14 +37,14 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
string apiKey = ReadApiKey(output.ToString());
IApiKeyVerifier verifier = services.GetRequiredService();
- ApiKeyVerificationResult verification = await verifier.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
+ ApiKeyVerification 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 auditRecords = await services
+ IReadOnlyList auditRecords = await services
.GetRequiredService()
.ListRecentAsync(10, CancellationToken.None);
@@ -98,14 +102,14 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
TextWriter.Null,
CancellationToken.None);
- ApiKeyVerificationResult verification = await services
+ ApiKeyVerification verification = await services
.GetRequiredService()
.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
Assert.False(verification.Succeeded);
- Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, verification.Failure);
+ Assert.Equal(ApiKeyFailure.KeyRevoked, verification.Failure);
- IReadOnlyList auditRecords = await services
+ IReadOnlyList auditRecords = await services
.GetRequiredService()
.ListRecentAsync(10, CancellationToken.None);
@@ -141,11 +145,11 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
Assert.Equal(1, CountOccurrences(rotateJson, newApiKey));
IApiKeyVerifier verifier = services.GetRequiredService();
- ApiKeyVerificationResult oldVerification = await verifier.VerifyAsync($"Bearer {oldApiKey}", CancellationToken.None);
- ApiKeyVerificationResult newVerification = await verifier.VerifyAsync($"Bearer {newApiKey}", CancellationToken.None);
+ ApiKeyVerification oldVerification = await verifier.VerifyAsync($"Bearer {oldApiKey}", CancellationToken.None);
+ ApiKeyVerification newVerification = await verifier.VerifyAsync($"Bearer {newApiKey}", CancellationToken.None);
Assert.False(oldVerification.Succeeded);
- Assert.Equal(ApiKeyVerificationFailure.SecretMismatch, oldVerification.Failure);
+ Assert.Equal(ApiKeyFailure.SecretMismatch, oldVerification.Failure);
Assert.True(newVerification.Succeeded);
}
@@ -203,13 +207,16 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
CancellationToken.None);
string apiKey = ReadApiKey(output.ToString());
- ApiKeyVerificationResult verification = await services
+ ApiKeyVerification verification = await services
.GetRequiredService()
.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
Assert.True(verification.Succeeded);
- Assert.Equal(["Area1/*"], verification.Identity!.EffectiveConstraints.BrowseSubtrees);
- Assert.True(verification.Identity.EffectiveConstraints.ReadAlarmOnly);
+ // The shared verifier returns the opaque constraints JSON; map it to the gateway identity so
+ // the strongly-typed effective constraints round-trip can be asserted.
+ ApiKeyIdentity gatewayIdentity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity!);
+ Assert.Equal(["Area1/*"], gatewayIdentity.EffectiveConstraints.BrowseSubtrees);
+ Assert.True(gatewayIdentity.EffectiveConstraints.ReadAlarmOnly);
}
@@ -246,7 +253,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
ServiceCollection services = new();
services.AddSingleton(configuration);
services.AddGatewayConfiguration(configuration);
- services.AddSqliteAuthStore();
+ services.AddSqliteAuthStore(configuration);
return services.BuildServiceProvider(validateScopes: true);
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs
deleted file mode 100644
index 385de26..0000000
--- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
-
-public sealed class ApiKeyParserTests
-{
- /// Verifies that TryParseAuthorizationHeader parses a valid Bearer token and returns the key ID and secret.
- [Fact]
- public void TryParseAuthorizationHeader_ValidBearerToken_ReturnsKeyIdAndSecret()
- {
- ApiKeyParser parser = new();
-
- bool parsed = parser.TryParseAuthorizationHeader(
- "Bearer mxgw_operator01_secret_value",
- out ParsedApiKey? apiKey);
-
- Assert.True(parsed);
- Assert.NotNull(apiKey);
- Assert.Equal("operator01", apiKey.KeyId);
- Assert.Equal("secret_value", apiKey.Secret);
- }
-
- /// Verifies that TryParseAuthorizationHeader returns false for malformed tokens.
- /// Malformed authorization header value.
- [Theory]
- [InlineData(null)]
- [InlineData("")]
- [InlineData("mxgw_operator01_secret")]
- [InlineData("Bearer not-a-gateway-key")]
- [InlineData("Bearer mxgw__secret")]
- [InlineData("Bearer mxgw_operator01_")]
- public void TryParseAuthorizationHeader_MalformedToken_ReturnsFalse(string? authorizationHeader)
- {
- ApiKeyParser parser = new();
-
- bool parsed = parser.TryParseAuthorizationHeader(authorizationHeader, out ParsedApiKey? apiKey);
-
- Assert.False(parsed);
- Assert.Null(apiKey);
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs
deleted file mode 100644
index 0a4136a..0000000
--- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs
+++ /dev/null
@@ -1,71 +0,0 @@
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Options;
-using ZB.MOM.WW.MxGateway.Server.Configuration;
-using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
-
-namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
-
-public sealed class ApiKeySecretHasherTests
-{
- ///
- /// Verifies identical pepper and secret produce identical hashes.
- ///
- [Fact]
- public void HashSecret_SamePepperAndSecret_ReturnsSameHash()
- {
- ApiKeySecretHasher hasher = CreateHasher("pepper-one");
-
- byte[] firstHash = hasher.HashSecret("raw-secret");
- byte[] secondHash = hasher.HashSecret("raw-secret");
-
- Assert.Equal(firstHash, secondHash);
- Assert.NotEqual("raw-secret"u8.ToArray(), firstHash);
- }
-
- ///
- /// Verifies different pepper values produce different hashes.
- ///
- [Fact]
- public void HashSecret_DifferentPepper_ReturnsDifferentHash()
- {
- byte[] firstHash = CreateHasher("pepper-one").HashSecret("raw-secret");
- byte[] secondHash = CreateHasher("pepper-two").HashSecret("raw-secret");
-
- Assert.NotEqual(firstHash, secondHash);
- }
-
- ///
- /// Verifies missing pepper throws an exception.
- ///
- [Fact]
- public void HashSecret_MissingPepper_Throws()
- {
- ApiKeySecretHasher hasher = CreateHasher(pepper: null);
-
- Assert.Throws(() => hasher.HashSecret("raw-secret"));
- }
-
- private static ApiKeySecretHasher CreateHasher(string? pepper)
- {
- Dictionary values = [];
-
- if (pepper is not null)
- {
- values["TestPepper"] = pepper;
- }
-
- IConfigurationRoot configuration = new ConfigurationBuilder()
- .AddInMemoryCollection(values)
- .Build();
-
- GatewayOptions options = new()
- {
- Authentication = new AuthenticationOptions
- {
- PepperSecretName = "TestPepper"
- }
- };
-
- return new ApiKeySecretHasher(configuration, Options.Create(options));
- }
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs
index 6def040..5fa6f0e 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs
@@ -1,22 +1,30 @@
+using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Options;
-using ZB.MOM.WW.MxGateway.Server.Configuration;
-using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
+///
+/// Parity tests for the shared ZB.MOM.WW.Auth.ApiKeys verifier as the gateway relies on it:
+/// the mxgw token format, peppered HMAC-SHA256 secret hashing, constant-time comparison,
+/// fail-closed discrimination (missing/unknown/revoked/wrong-secret/missing-pepper), and that the
+/// raw secret never leaks into the result. The expected hash is computed here independently to keep
+/// the test honest against the library's internal hasher.
+///
public sealed class ApiKeyVerifierTests
{
+ private static readonly ApiKeyOptions Options = new() { TokenPrefix = "mxgw" };
+
/// Verifies that VerifyAsync returns identity and scopes for a valid key.
[Fact]
public async Task VerifyAsync_ValidKey_ReturnsIdentityAndScopes()
{
- ApiKeySecretHasher hasher = CreateHasher("pepper");
- FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null));
- ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
+ FakeApiKeyStore store = new(CreateRecord("pepper", revokedUtc: null));
+ ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider("pepper"));
- ApiKeyVerificationResult result = await verifier.VerifyAsync(
+ ApiKeyVerification result = await verifier.VerifyAsync(
"Bearer mxgw_operator01_correct-secret",
CancellationToken.None);
@@ -33,11 +41,10 @@ public sealed class ApiKeyVerifierTests
[Fact]
public async Task VerifyAsync_ValidKey_DoesNotExposeRawSecretInResult()
{
- ApiKeySecretHasher hasher = CreateHasher("pepper");
- FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null));
- ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
+ FakeApiKeyStore store = new(CreateRecord("pepper", revokedUtc: null));
+ ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider("pepper"));
- ApiKeyVerificationResult result = await verifier.VerifyAsync(
+ ApiKeyVerification result = await verifier.VerifyAsync(
"Bearer mxgw_operator01_correct-secret",
CancellationToken.None);
@@ -46,58 +53,51 @@ public sealed class ApiKeyVerifierTests
Assert.DoesNotContain("correct-secret", serialized, StringComparison.Ordinal);
}
- /// Verifies that VerifyAsync fails with unauthenticated status for a malformed key.
+ /// Verifies that VerifyAsync fails as missing/malformed for a malformed key.
/// Authorization header value to test.
[Theory]
- [InlineData(null)]
+ [InlineData("")]
[InlineData("Bearer mxgw_operator01")]
[InlineData("Bearer wrong")]
- public async Task VerifyAsync_MalformedKey_FailsUnauthenticated(string? authorizationHeader)
+ public async Task VerifyAsync_MalformedKey_FailsMissingOrMalformed(string authorizationHeader)
{
- ApiKeyVerifier verifier = new(
- new ApiKeyParser(),
- CreateHasher("pepper"),
- new FakeApiKeyStore(storedKey: null));
+ ApiKeyVerifier verifier = new(Options, new FakeApiKeyStore(storedKey: null), new FakePepperProvider("pepper"));
- ApiKeyVerificationResult result = await verifier.VerifyAsync(
+ ApiKeyVerification result = await verifier.VerifyAsync(
authorizationHeader,
CancellationToken.None);
Assert.False(result.Succeeded);
- Assert.Equal(ApiKeyVerificationFailure.MissingOrMalformedCredentials, result.Failure);
+ Assert.Equal(ApiKeyFailure.MissingOrMalformed, result.Failure);
}
/// Verifies that VerifyAsync fails for an unknown key.
[Fact]
public async Task VerifyAsync_UnknownKey_Fails()
{
- ApiKeyVerifier verifier = new(
- new ApiKeyParser(),
- CreateHasher("pepper"),
- new FakeApiKeyStore(storedKey: null));
+ ApiKeyVerifier verifier = new(Options, new FakeApiKeyStore(storedKey: null), new FakePepperProvider("pepper"));
- ApiKeyVerificationResult result = await verifier.VerifyAsync(
+ ApiKeyVerification result = await verifier.VerifyAsync(
"Bearer mxgw_missing_secret",
CancellationToken.None);
Assert.False(result.Succeeded);
- Assert.Equal(ApiKeyVerificationFailure.KeyNotFound, result.Failure);
+ Assert.Equal(ApiKeyFailure.KeyNotFound, result.Failure);
}
- /// Verifies that VerifyAsync fails for a wrong secret.
+ /// Verifies that VerifyAsync fails for a wrong secret (constant-time compare rejects it).
[Fact]
public async Task VerifyAsync_WrongSecret_Fails()
{
- ApiKeySecretHasher hasher = CreateHasher("pepper");
- FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null));
- ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
+ FakeApiKeyStore store = new(CreateRecord("pepper", revokedUtc: null));
+ ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider("pepper"));
- ApiKeyVerificationResult result = await verifier.VerifyAsync(
+ ApiKeyVerification result = await verifier.VerifyAsync(
"Bearer mxgw_operator01_wrong-secret",
CancellationToken.None);
Assert.False(result.Succeeded);
- Assert.Equal(ApiKeyVerificationFailure.SecretMismatch, result.Failure);
+ Assert.Equal(ApiKeyFailure.SecretMismatch, result.Failure);
Assert.False(store.MarkedUsed);
}
@@ -105,74 +105,62 @@ public sealed class ApiKeyVerifierTests
[Fact]
public async Task VerifyAsync_RevokedKey_Fails()
{
- ApiKeySecretHasher hasher = CreateHasher("pepper");
- FakeApiKeyStore store = new(CreateRecord(hasher, DateTimeOffset.UtcNow));
- ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
+ FakeApiKeyStore store = new(CreateRecord("pepper", DateTimeOffset.UtcNow));
+ ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider("pepper"));
- ApiKeyVerificationResult result = await verifier.VerifyAsync(
+ ApiKeyVerification result = await verifier.VerifyAsync(
"Bearer mxgw_operator01_correct-secret",
CancellationToken.None);
Assert.False(result.Succeeded);
- Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, result.Failure);
+ Assert.Equal(ApiKeyFailure.KeyRevoked, result.Failure);
Assert.False(store.MarkedUsed);
}
- /// Verifies that VerifyAsync fails when the pepper is missing.
+ /// Verifies that VerifyAsync fails closed when the pepper is missing.
[Fact]
public async Task VerifyAsync_MissingPepper_Fails()
{
- FakeApiKeyStore store = new(CreateRecord(CreateHasher("pepper"), revokedUtc: null));
- ApiKeyVerifier verifier = new(new ApiKeyParser(), CreateHasher(pepper: null), store);
+ FakeApiKeyStore store = new(CreateRecord("pepper", revokedUtc: null));
+ ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider(pepper: null));
- ApiKeyVerificationResult result = await verifier.VerifyAsync(
+ ApiKeyVerification result = await verifier.VerifyAsync(
"Bearer mxgw_operator01_correct-secret",
CancellationToken.None);
Assert.False(result.Succeeded);
- Assert.Equal(ApiKeyVerificationFailure.PepperUnavailable, result.Failure);
+ Assert.Equal(ApiKeyFailure.PepperUnavailable, result.Failure);
}
- private static ApiKeyRecord CreateRecord(ApiKeySecretHasher hasher, DateTimeOffset? revokedUtc)
+ /// Computes HMAC-SHA256(pepper, secret) — the documented peppered-hash format.
+ private static byte[] PepperedHash(string secret, string pepper)
+ {
+ using HMACSHA256 hmac = new(Encoding.UTF8.GetBytes(pepper));
+ return hmac.ComputeHash(Encoding.UTF8.GetBytes(secret));
+ }
+
+ private static ApiKeyRecord CreateRecord(string pepper, DateTimeOffset? revokedUtc)
{
return new ApiKeyRecord(
KeyId: "operator01",
- KeyPrefix: "mxgw_operator01",
- SecretHash: hasher.HashSecret("correct-secret"),
+ KeyPrefix: "mxgw",
+ SecretHash: PepperedHash("correct-secret", pepper),
DisplayName: "Operator Key",
Scopes: new HashSet(StringComparer.Ordinal)
{
"session:open",
"events:read"
},
- Constraints: ApiKeyConstraints.Empty,
+ ConstraintsJson: null,
CreatedUtc: DateTimeOffset.UtcNow,
LastUsedUtc: null,
RevokedUtc: revokedUtc);
}
- private static ApiKeySecretHasher CreateHasher(string? pepper)
+ private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider
{
- Dictionary values = [];
-
- if (pepper is not null)
- {
- values["TestPepper"] = pepper;
- }
-
- IConfigurationRoot configuration = new ConfigurationBuilder()
- .AddInMemoryCollection(values)
- .Build();
-
- GatewayOptions options = new()
- {
- Authentication = new AuthenticationOptions
- {
- PepperSecretName = "TestPepper"
- }
- };
-
- return new ApiKeySecretHasher(configuration, Options.Create(options));
+ /// Returns the configured pepper (or null to simulate an unavailable pepper).
+ public string? GetPepper() => pepper;
}
/// Fake in-memory API key store for testing.
@@ -181,18 +169,14 @@ public sealed class ApiKeyVerifierTests
/// Gets whether the key was marked as used.
public bool MarkedUsed { get; private set; }
- /// Finds an API key record by its ID.
- /// Identifier of the API key.
- /// Token to cancel the asynchronous operation.
- public Task FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
+ ///
+ public Task FindByKeyIdAsync(string keyId, CancellationToken ct)
{
return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null);
}
- /// Finds an active (non-revoked) API key record by its ID.
- /// Identifier of the API key.
- /// Token to cancel the asynchronous operation.
- public Task FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
+ ///
+ public Task FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
{
return Task.FromResult(
storedKey?.KeyId == keyId && storedKey.RevokedUtc is null
@@ -200,11 +184,8 @@ public sealed class ApiKeyVerifierTests
: null);
}
- /// Marks an API key as used at the specified time.
- /// Identifier of the API key.
- /// Timestamp when the key was used.
- /// Token to cancel the asynchronous operation.
- public Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
+ ///
+ public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
{
MarkedUsed = storedKey?.KeyId == keyId;
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs
index 985ef00..8b6b633 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs
@@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
+using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
using ZB.MOM.WW.MxGateway.Server;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
@@ -9,13 +11,17 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
///
-/// Tests for .
+/// Parity tests for the shared ZB.MOM.WW.Auth.ApiKeys SQLite store as wired by the gateway.
+/// The gateway is the donor this store was extracted from; these tests pin that existing deployed
+/// gateway-auth.db databases (schema version 2, same tables/columns/scopes encoding) remain
+/// readable and that migration is idempotent and refuses a newer on-disk schema.
///
public sealed class SqliteAuthStoreTests : IDisposable
{
private readonly List _tempDirectories = [];
+
///
- /// Verifies that MigrateAsync initializes the database schema.
+ /// Verifies that MigrateAsync initializes the database schema at the donor's version (2).
///
[Fact]
public async Task MigrateAsync_EmptyDatabase_InitializesCurrentSchema()
@@ -23,7 +29,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
string databasePath = CreateTempDatabasePath();
await using ServiceProvider services = BuildAuthServices(databasePath);
- IAuthStoreMigrator migrator = services.GetRequiredService();
+ SqliteAuthStoreMigrator migrator = services.GetRequiredService();
await migrator.MigrateAsync(CancellationToken.None);
@@ -42,7 +48,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
await CreateVersionZeroDatabaseAsync(databasePath);
await using ServiceProvider services = BuildAuthServices(databasePath);
- IAuthStoreMigrator migrator = services.GetRequiredService();
+ SqliteAuthStoreMigrator migrator = services.GetRequiredService();
await migrator.MigrateAsync(CancellationToken.None);
await migrator.MigrateAsync(CancellationToken.None);
@@ -74,14 +80,15 @@ public sealed class SqliteAuthStoreTests : IDisposable
}
///
- /// Verifies that FindActiveByKeyIdAsync returns an active key.
+ /// Verifies that FindActiveByKeyIdAsync returns an active key, reading a row whose columns match
+ /// the donor schema (peppered secret_hash BLOB, ordinal-sorted scopes JSON).
///
[Fact]
public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey()
{
string databasePath = CreateTempDatabasePath();
await using ServiceProvider services = BuildAuthServices(databasePath);
- await services.GetRequiredService().MigrateAsync(CancellationToken.None);
+ await services.GetRequiredService().MigrateAsync(CancellationToken.None);
await InsertApiKeyAsync(databasePath, revokedUtc: null);
IApiKeyStore store = services.GetRequiredService();
@@ -104,7 +111,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
{
string databasePath = CreateTempDatabasePath();
await using ServiceProvider services = BuildAuthServices(databasePath);
- await services.GetRequiredService().MigrateAsync(CancellationToken.None);
+ await services.GetRequiredService().MigrateAsync(CancellationToken.None);
await InsertApiKeyAsync(databasePath, DateTimeOffset.UtcNow);
IApiKeyStore store = services.GetRequiredService();
@@ -127,7 +134,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
{
string databasePath = CreateTempDatabasePath();
await using ServiceProvider services = BuildAuthServices(databasePath);
- await services.GetRequiredService().MigrateAsync(CancellationToken.None);
+ await services.GetRequiredService().MigrateAsync(CancellationToken.None);
IApiKeyAuditStore auditStore = services.GetRequiredService();
@@ -136,14 +143,15 @@ public sealed class SqliteAuthStoreTests : IDisposable
KeyId: "test-key",
EventType: "lookup",
RemoteAddress: "127.0.0.1",
+ CreatedUtc: DateTimeOffset.UtcNow,
Details: "matched active key"),
CancellationToken.None);
- IReadOnlyList records = await auditStore.ListRecentAsync(
+ IReadOnlyList records = await auditStore.ListRecentAsync(
10,
CancellationToken.None);
- ApiKeyAuditRecord record = Assert.Single(records);
+ ApiKeyAuditEntry record = Assert.Single(records);
Assert.Equal("test-key", record.KeyId);
Assert.Equal("lookup", record.EventType);
Assert.Equal("127.0.0.1", record.RemoteAddress);
@@ -189,7 +197,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
ServiceCollection services = new();
services.AddSingleton(configuration);
services.AddGatewayConfiguration(configuration);
- services.AddSqliteAuthStore();
+ services.AddSqliteAuthStore(configuration);
return services.BuildServiceProvider(validateScopes: true);
}
@@ -288,7 +296,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
command.Parameters.AddWithValue("$display_name", "Test Key");
command.Parameters.AddWithValue(
"$scopes",
- ApiKeyScopeSerializer.Serialize(new HashSet(StringComparer.Ordinal) { "session:open", "events:read" }));
+ ScopeSerializer.Serialize(new HashSet(StringComparer.Ordinal) { "session:open", "events:read" }));
command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UtcNow.ToString("O"));
command.Parameters.AddWithValue("$revoked_utc", revokedUtc?.ToString("O") ?? (object)DBNull.Value);
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs
index 3b776b7..f2ae363 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs
@@ -1,3 +1,4 @@
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -6,6 +7,10 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions;
+// ConstraintEnforcer enforces against the gateway's constraint-bearing identity; the shared library
+// also defines an ApiKeyIdentity, so disambiguate to the gateway type.
+using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
+
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
public sealed class ConstraintEnforcerTests
@@ -250,9 +255,9 @@ public sealed class ConstraintEnforcerTests
}
///
- public Task> ListRecentAsync(int count, CancellationToken cancellationToken)
+ public Task> ListRecentAsync(int limit, CancellationToken ct)
{
- return Task.FromResult>([]);
+ return Task.FromResult>([]);
}
}
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs
index c171b3a..0636d9a 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs
@@ -2,6 +2,7 @@ using System.Runtime.CompilerServices;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -12,6 +13,11 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
+// The handler exposes the gateway's constraint-bearing identity; alias the shared library identity
+// (returned by the verifier) so the two can be referenced unambiguously.
+using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
+using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
+
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
public sealed class GatewayGrpcAuthorizationInterceptorTests
@@ -21,8 +27,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated()
{
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
- new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail(
- ApiKeyVerificationFailure.MissingOrMalformedCredentials)),
+ new FakeApiKeyVerifier(Failure(ApiKeyFailure.MissingOrMalformed)),
new GatewayRequestIdentityAccessor());
RpcException exception = await Assert.ThrowsAsync(
@@ -40,7 +45,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus()
{
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
- new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch)),
+ new FakeApiKeyVerifier(Failure(ApiKeyFailure.SecretMismatch)),
new GatewayRequestIdentityAccessor());
RpcException exception = await Assert.ThrowsAsync(
@@ -146,8 +151,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification()
{
GatewayRequestIdentityAccessor identityAccessor = new();
- FakeApiKeyVerifier verifier = new(ApiKeyVerificationResult.Fail(
- ApiKeyVerificationFailure.MissingOrMalformedCredentials));
+ FakeApiKeyVerifier verifier = new(Failure(ApiKeyFailure.MissingOrMalformed));
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
verifier,
identityAccessor,
@@ -374,13 +378,21 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
}));
}
- private static ApiKeyVerificationResult SuccessWithScopes(params string[] scopes)
+ private static ApiKeyVerification SuccessWithScopes(params string[] scopes)
{
- return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
- KeyId: "operator01",
- KeyPrefix: "mxgw_operator01",
- DisplayName: "Operator Key",
- Scopes: new HashSet(scopes, StringComparer.Ordinal)));
+ return new ApiKeyVerification(
+ Succeeded: true,
+ Identity: new LibApiKeyIdentity(
+ KeyId: "operator01",
+ DisplayName: "Operator Key",
+ Scopes: new HashSet(scopes, StringComparer.Ordinal),
+ Constraints: null),
+ Failure: null);
+ }
+
+ private static ApiKeyVerification Failure(ApiKeyFailure failure)
+ {
+ return new ApiKeyVerification(Succeeded: false, Identity: null, Failure: failure);
}
private static TestServerCallContext ContextWithAuthorization(string authorizationHeader)
@@ -495,7 +507,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
}
}
- private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
+ private sealed class FakeApiKeyVerifier(ApiKeyVerification result) : IApiKeyVerifier
{
/// Gets whether the verifier was called.
public bool WasCalled { get; private set; }
@@ -505,11 +517,11 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
/// Verifies the authorization header against stored result.
/// The authorization header to verify.
- /// Cancellation token.
+ /// Cancellation token.
/// Configured verification result.
- public Task VerifyAsync(
- string? authorizationHeader,
- CancellationToken cancellationToken)
+ public Task VerifyAsync(
+ string authorizationHeader,
+ CancellationToken ct)
{
WasCalled = true;
LastAuthorizationHeader = authorizationHeader;