feat(auth): cut MxGateway API keys over to ZB.MOM.WW.Auth.ApiKeys 0.1.2; keep constraint enforcement+gRPC+CLI on top (Task 1.3)

This commit is contained in:
Joseph Doherty
2026-06-02 02:08:38 -04:00
parent f4dc11bae4
commit 05009d7370
49 changed files with 515 additions and 1642 deletions
@@ -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<IApiKeyVerifier>();
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<ApiKeyAuditRecord> auditRecords = await services
IReadOnlyList<ApiKeyAuditEntry> auditRecords = await services
.GetRequiredService<IApiKeyAuditStore>()
.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<IApiKeyVerifier>()
.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
Assert.False(verification.Succeeded);
Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, verification.Failure);
Assert.Equal(ApiKeyFailure.KeyRevoked, verification.Failure);
IReadOnlyList<ApiKeyAuditRecord> auditRecords = await services
IReadOnlyList<ApiKeyAuditEntry> auditRecords = await services
.GetRequiredService<IApiKeyAuditStore>()
.ListRecentAsync(10, CancellationToken.None);
@@ -141,11 +145,11 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
Assert.Equal(1, CountOccurrences(rotateJson, newApiKey));
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
ApiKeyVerificationResult oldVerification = await verifier.VerifyAsync($"Bearer {oldApiKey}", CancellationToken.None);
ApiKeyVerificationResult newVerification = await verifier.VerifyAsync($"Bearer {newApiKey}", CancellationToken.None);
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<IApiKeyVerifier>()
.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<IConfiguration>(configuration);
services.AddGatewayConfiguration(configuration);
services.AddSqliteAuthStore();
services.AddSqliteAuthStore(configuration);
return services.BuildServiceProvider(validateScopes: true);
}
@@ -1,41 +0,0 @@
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
public sealed class ApiKeyParserTests
{
/// <summary>Verifies that TryParseAuthorizationHeader parses a valid Bearer token and returns the key ID and secret.</summary>
[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);
}
/// <summary>Verifies that TryParseAuthorizationHeader returns false for malformed tokens.</summary>
/// <param name="authorizationHeader">Malformed authorization header value.</param>
[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);
}
}
@@ -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
{
/// <summary>
/// Verifies identical pepper and secret produce identical hashes.
/// </summary>
[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);
}
/// <summary>
/// Verifies different pepper values produce different hashes.
/// </summary>
[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);
}
/// <summary>
/// Verifies missing pepper throws an exception.
/// </summary>
[Fact]
public void HashSecret_MissingPepper_Throws()
{
ApiKeySecretHasher hasher = CreateHasher(pepper: null);
Assert.Throws<ApiKeyPepperUnavailableException>(() => hasher.HashSecret("raw-secret"));
}
private static ApiKeySecretHasher CreateHasher(string? pepper)
{
Dictionary<string, string?> 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));
}
}
@@ -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;
/// <summary>
/// Parity tests for the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> verifier as the gateway relies on it:
/// the <c>mxgw</c> 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.
/// </summary>
public sealed class ApiKeyVerifierTests
{
private static readonly ApiKeyOptions Options = new() { TokenPrefix = "mxgw" };
/// <summary>Verifies that VerifyAsync returns identity and scopes for a valid key.</summary>
[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);
}
/// <summary>Verifies that VerifyAsync fails with unauthenticated status for a malformed key.</summary>
/// <summary>Verifies that VerifyAsync fails as missing/malformed for a malformed key.</summary>
/// <param name="authorizationHeader">Authorization header value to test.</param>
[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);
}
/// <summary>Verifies that VerifyAsync fails for an unknown key.</summary>
[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);
}
/// <summary>Verifies that VerifyAsync fails for a wrong secret.</summary>
/// <summary>Verifies that VerifyAsync fails for a wrong secret (constant-time compare rejects it).</summary>
[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);
}
/// <summary>Verifies that VerifyAsync fails when the pepper is missing.</summary>
/// <summary>Verifies that VerifyAsync fails closed when the pepper is missing.</summary>
[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)
/// <summary>Computes HMAC-SHA256(pepper, secret) — the documented peppered-hash format.</summary>
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<string>(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<string, string?> 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));
/// <summary>Returns the configured pepper (or null to simulate an unavailable pepper).</summary>
public string? GetPepper() => pepper;
}
/// <summary>Fake in-memory API key store for testing.</summary>
@@ -181,18 +169,14 @@ public sealed class ApiKeyVerifierTests
/// <summary>Gets whether the key was marked as used.</summary>
public bool MarkedUsed { get; private set; }
/// <summary>Finds an API key record by its ID.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
/// <inheritdoc />
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
{
return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null);
}
/// <summary>Finds an active (non-revoked) API key record by its ID.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
/// <inheritdoc />
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
{
return Task.FromResult(
storedKey?.KeyId == keyId && storedKey.RevokedUtc is null
@@ -200,11 +184,8 @@ public sealed class ApiKeyVerifierTests
: null);
}
/// <summary>Marks an API key as used at the specified time.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="usedUtc">Timestamp when the key was used.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
/// <inheritdoc />
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
{
MarkedUsed = storedKey?.KeyId == keyId;
@@ -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;
/// <summary>
/// Tests for <see cref="SqliteAuthStore"/>.
/// Parity tests for the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> SQLite store as wired by the gateway.
/// The gateway is the donor this store was extracted from; these tests pin that existing deployed
/// <c>gateway-auth.db</c> databases (schema version 2, same tables/columns/scopes encoding) remain
/// readable and that migration is idempotent and refuses a newer on-disk schema.
/// </summary>
public sealed class SqliteAuthStoreTests : IDisposable
{
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
/// <summary>
/// Verifies that MigrateAsync initializes the database schema.
/// Verifies that MigrateAsync initializes the database schema at the donor's version (2).
/// </summary>
[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<IAuthStoreMigrator>();
SqliteAuthStoreMigrator migrator = services.GetRequiredService<SqliteAuthStoreMigrator>();
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<IAuthStoreMigrator>();
SqliteAuthStoreMigrator migrator = services.GetRequiredService<SqliteAuthStoreMigrator>();
await migrator.MigrateAsync(CancellationToken.None);
await migrator.MigrateAsync(CancellationToken.None);
@@ -74,14 +80,15 @@ public sealed class SqliteAuthStoreTests : IDisposable
}
/// <summary>
/// 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).
/// </summary>
[Fact]
public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey()
{
string databasePath = CreateTempDatabasePath();
await using ServiceProvider services = BuildAuthServices(databasePath);
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
await services.GetRequiredService<SqliteAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
await InsertApiKeyAsync(databasePath, revokedUtc: null);
IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();
@@ -104,7 +111,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
{
string databasePath = CreateTempDatabasePath();
await using ServiceProvider services = BuildAuthServices(databasePath);
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
await services.GetRequiredService<SqliteAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
await InsertApiKeyAsync(databasePath, DateTimeOffset.UtcNow);
IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();
@@ -127,7 +134,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
{
string databasePath = CreateTempDatabasePath();
await using ServiceProvider services = BuildAuthServices(databasePath);
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
await services.GetRequiredService<SqliteAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
IApiKeyAuditStore auditStore = services.GetRequiredService<IApiKeyAuditStore>();
@@ -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<ApiKeyAuditRecord> records = await auditStore.ListRecentAsync(
IReadOnlyList<ApiKeyAuditEntry> 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<IConfiguration>(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<string>(StringComparer.Ordinal) { "session:open", "events:read" }));
ScopeSerializer.Serialize(new HashSet<string>(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);
@@ -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
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
public Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
return Task.FromResult<IReadOnlyList<ApiKeyAuditEntry>>([]);
}
}
}
@@ -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<RpcException>(
@@ -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<RpcException>(
@@ -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<string>(scopes, StringComparer.Ordinal)));
return new ApiKeyVerification(
Succeeded: true,
Identity: new LibApiKeyIdentity(
KeyId: "operator01",
DisplayName: "Operator Key",
Scopes: new HashSet<string>(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
{
/// <summary>Gets whether the verifier was called.</summary>
public bool WasCalled { get; private set; }
@@ -505,11 +517,11 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
/// <summary>Verifies the authorization header against stored result.</summary>
/// <param name="authorizationHeader">The authorization header to verify.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Configured verification result.</returns>
public Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken)
public Task<ApiKeyVerification> VerifyAsync(
string authorizationHeader,
CancellationToken ct)
{
WasCalled = true;
LastAuthorizationHeader = authorizationHeader;