From 696be171392cb5c3c8d9a2ba4614ab056a4beae8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 16:40:36 -0400 Subject: [PATCH] Issue #6: implement api key hashing and verification --- docs/gateway-process-design.md | 14 ++ .../Security/Authentication/ApiKeyIdentity.cs | 7 + .../Security/Authentication/ApiKeyParser.cs | 45 ++++ .../ApiKeyPepperUnavailableException.cs | 4 + .../Authentication/ApiKeySecretHasher.cs | 35 ++++ .../ApiKeyVerificationFailure.cs | 11 + .../ApiKeyVerificationResult.cs | 23 +++ .../Security/Authentication/ApiKeyVerifier.cs | 57 ++++++ .../AuthStoreServiceCollectionExtensions.cs | 3 + .../Security/Authentication/IApiKeyParser.cs | 6 + .../Authentication/IApiKeySecretHasher.cs | 6 + .../Authentication/IApiKeyVerifier.cs | 8 + .../Security/Authentication/ParsedApiKey.cs | 3 + .../Diagnostics/GatewayLogRedactorTests.cs | 9 + .../Authentication/ApiKeyParserTests.cs | 38 ++++ .../Authentication/ApiKeySecretHasherTests.cs | 62 ++++++ .../Authentication/ApiKeyVerifierTests.cs | 193 ++++++++++++++++++ 17 files changed, 524 insertions(+) create mode 100644 src/MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs create mode 100644 src/MxGateway.Server/Security/Authentication/ApiKeyParser.cs create mode 100644 src/MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs create mode 100644 src/MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs create mode 100644 src/MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs create mode 100644 src/MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs create mode 100644 src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs create mode 100644 src/MxGateway.Server/Security/Authentication/IApiKeyParser.cs create mode 100644 src/MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs create mode 100644 src/MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs create mode 100644 src/MxGateway.Server/Security/Authentication/ParsedApiKey.cs create mode 100644 src/MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs create mode 100644 src/MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs create mode 100644 src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs diff --git a/docs/gateway-process-design.md b/docs/gateway-process-design.md index 356e998..88ade9e 100644 --- a/docs/gateway-process-design.md +++ b/docs/gateway-process-design.md @@ -589,6 +589,20 @@ The gateway should split the key into a stable key id and secret component, load the key record by id, hash the presented secret, and compare using a constant-time comparison. +`ApiKeyParser` accepts only `authorization: Bearer mxgw__`. +Malformed headers fail before any database lookup. The parsed raw secret is +kept only long enough for `ApiKeySecretHasher` to compute an HMAC-SHA256 hash +using the configured `Authentication:PepperSecretName` lookup in application +configuration. The raw secret is not stored in the auth database, identity +model, logs, or verification result. + +`ApiKeyVerifier` loads the stored key record by key id, rejects revoked keys, +hashes the presented secret, and compares the stored and presented hashes with +`CryptographicOperations.FixedTimeEquals`. A successful verification returns an +`ApiKeyIdentity` with key id, key prefix, display name, and scopes. Failure +results distinguish malformed credentials, missing keys, revoked keys, missing +pepper configuration, and hash mismatch for internal authorization handling. + Recommended scopes: - `session:open` diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs new file mode 100644 index 0000000..caab0d4 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs @@ -0,0 +1,7 @@ +namespace MxGateway.Server.Security.Authentication; + +public sealed record ApiKeyIdentity( + string KeyId, + string KeyPrefix, + string DisplayName, + IReadOnlySet Scopes); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyParser.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyParser.cs new file mode 100644 index 0000000..e02a56a --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyParser.cs @@ -0,0 +1,45 @@ +namespace MxGateway.Server.Security.Authentication; + +public sealed class ApiKeyParser : IApiKeyParser +{ + private const string BearerPrefix = "Bearer "; + private const string TokenPrefix = "mxgw_"; + + 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/MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs new file mode 100644 index 0000000..4dfcf43 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs @@ -0,0 +1,4 @@ +namespace MxGateway.Server.Security.Authentication; + +public sealed class ApiKeyPepperUnavailableException(string pepperSecretName) + : InvalidOperationException($"API key pepper secret '{pepperSecretName}' is not configured."); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs b/src/MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs new file mode 100644 index 0000000..c901035 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs @@ -0,0 +1,35 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; + +namespace MxGateway.Server.Security.Authentication; + +public sealed class ApiKeySecretHasher( + IConfiguration configuration, + IOptions options) : IApiKeySecretHasher +{ + 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/MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs new file mode 100644 index 0000000..c5b07ea --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs @@ -0,0 +1,11 @@ +namespace MxGateway.Server.Security.Authentication; + +public enum ApiKeyVerificationFailure +{ + None, + MissingOrMalformedCredentials, + PepperUnavailable, + KeyNotFound, + KeyRevoked, + SecretMismatch +} diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs new file mode 100644 index 0000000..385d0a3 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs @@ -0,0 +1,23 @@ +namespace MxGateway.Server.Security.Authentication; + +public sealed record ApiKeyVerificationResult( + bool Succeeded, + ApiKeyIdentity? Identity, + ApiKeyVerificationFailure Failure) +{ + public static ApiKeyVerificationResult Success(ApiKeyIdentity identity) + { + return new ApiKeyVerificationResult( + Succeeded: true, + Identity: identity, + Failure: ApiKeyVerificationFailure.None); + } + + public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure) + { + return new ApiKeyVerificationResult( + Succeeded: false, + Identity: null, + Failure: failure); + } +} diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs new file mode 100644 index 0000000..a6354ec --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs @@ -0,0 +1,57 @@ +using System.Security.Cryptography; + +namespace MxGateway.Server.Security.Authentication; + +public sealed class ApiKeyVerifier( + IApiKeyParser parser, + IApiKeySecretHasher hasher, + IApiKeyStore keyStore) : IApiKeyVerifier +{ + 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)); + } +} diff --git a/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs b/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs index 813e4d1..7057b57 100644 --- a/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs @@ -4,6 +4,9 @@ public static class AuthStoreServiceCollectionExtensions { public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services) { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyParser.cs b/src/MxGateway.Server/Security/Authentication/IApiKeyParser.cs new file mode 100644 index 0000000..186eb41 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/IApiKeyParser.cs @@ -0,0 +1,6 @@ +namespace MxGateway.Server.Security.Authentication; + +public interface IApiKeyParser +{ + bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey); +} diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs b/src/MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs new file mode 100644 index 0000000..04febe8 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs @@ -0,0 +1,6 @@ +namespace MxGateway.Server.Security.Authentication; + +public interface IApiKeySecretHasher +{ + byte[] HashSecret(string secret); +} diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs b/src/MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs new file mode 100644 index 0000000..2b04e08 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs @@ -0,0 +1,8 @@ +namespace MxGateway.Server.Security.Authentication; + +public interface IApiKeyVerifier +{ + Task VerifyAsync( + string? authorizationHeader, + CancellationToken cancellationToken); +} diff --git a/src/MxGateway.Server/Security/Authentication/ParsedApiKey.cs b/src/MxGateway.Server/Security/Authentication/ParsedApiKey.cs new file mode 100644 index 0000000..5a1cc61 --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ParsedApiKey.cs @@ -0,0 +1,3 @@ +namespace MxGateway.Server.Security.Authentication; + +public sealed record ParsedApiKey(string KeyId, string Secret); diff --git a/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs b/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs index 5f2c16f..0034713 100644 --- a/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs +++ b/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs @@ -13,6 +13,15 @@ public sealed class GatewayLogRedactorTests Assert.DoesNotContain("super-secret", redacted); } + [Fact] + public void RedactApiKey_RemovesSecretContainingUnderscores() + { + string? redacted = GatewayLogRedactor.RedactApiKey("Bearer mxgw_operator01_super_secret_value"); + + Assert.Equal("Bearer mxgw_operator01_[redacted]", redacted); + Assert.DoesNotContain("super_secret_value", redacted); + } + [Theory] [InlineData("AuthenticateUser")] [InlineData("WriteSecured")] diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs new file mode 100644 index 0000000..2ca10d0 --- /dev/null +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs @@ -0,0 +1,38 @@ +using MxGateway.Server.Security.Authentication; + +namespace MxGateway.Tests.Security.Authentication; + +public sealed class ApiKeyParserTests +{ + [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); + } + + [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/MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs new file mode 100644 index 0000000..c386ff4 --- /dev/null +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Security.Authentication; + +namespace MxGateway.Tests.Security.Authentication; + +public sealed class ApiKeySecretHasherTests +{ + [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); + } + + [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); + } + + [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/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs new file mode 100644 index 0000000..afe0dac --- /dev/null +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs @@ -0,0 +1,193 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Security.Authentication; + +namespace MxGateway.Tests.Security.Authentication; + +public sealed class ApiKeyVerifierTests +{ + [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); + + ApiKeyVerificationResult result = await verifier.VerifyAsync( + "Bearer mxgw_operator01_correct-secret", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.NotNull(result.Identity); + Assert.Equal("operator01", result.Identity.KeyId); + Assert.Equal("Operator Key", result.Identity.DisplayName); + Assert.Contains("session:open", result.Identity.Scopes); + Assert.Contains("events:read", result.Identity.Scopes); + Assert.True(store.MarkedUsed); + } + + [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); + + ApiKeyVerificationResult result = await verifier.VerifyAsync( + "Bearer mxgw_operator01_correct-secret", + CancellationToken.None); + + string serialized = JsonSerializer.Serialize(result); + + Assert.DoesNotContain("correct-secret", serialized, StringComparison.Ordinal); + } + + [Theory] + [InlineData(null)] + [InlineData("Bearer mxgw_operator01")] + [InlineData("Bearer wrong")] + public async Task VerifyAsync_MalformedKey_FailsUnauthenticated(string? authorizationHeader) + { + ApiKeyVerifier verifier = new( + new ApiKeyParser(), + CreateHasher("pepper"), + new FakeApiKeyStore(storedKey: null)); + + ApiKeyVerificationResult result = await verifier.VerifyAsync( + authorizationHeader, + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(ApiKeyVerificationFailure.MissingOrMalformedCredentials, result.Failure); + } + + [Fact] + public async Task VerifyAsync_UnknownKey_Fails() + { + ApiKeyVerifier verifier = new( + new ApiKeyParser(), + CreateHasher("pepper"), + new FakeApiKeyStore(storedKey: null)); + + ApiKeyVerificationResult result = await verifier.VerifyAsync( + "Bearer mxgw_missing_secret", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(ApiKeyVerificationFailure.KeyNotFound, result.Failure); + } + + [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); + + ApiKeyVerificationResult result = await verifier.VerifyAsync( + "Bearer mxgw_operator01_wrong-secret", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(ApiKeyVerificationFailure.SecretMismatch, result.Failure); + Assert.False(store.MarkedUsed); + } + + [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); + + ApiKeyVerificationResult result = await verifier.VerifyAsync( + "Bearer mxgw_operator01_correct-secret", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, result.Failure); + Assert.False(store.MarkedUsed); + } + + [Fact] + public async Task VerifyAsync_MissingPepper_Fails() + { + FakeApiKeyStore store = new(CreateRecord(CreateHasher("pepper"), revokedUtc: null)); + ApiKeyVerifier verifier = new(new ApiKeyParser(), CreateHasher(pepper: null), store); + + ApiKeyVerificationResult result = await verifier.VerifyAsync( + "Bearer mxgw_operator01_correct-secret", + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Equal(ApiKeyVerificationFailure.PepperUnavailable, result.Failure); + } + + private static ApiKeyRecord CreateRecord(ApiKeySecretHasher hasher, DateTimeOffset? revokedUtc) + { + return new ApiKeyRecord( + KeyId: "operator01", + KeyPrefix: "mxgw_operator01", + SecretHash: hasher.HashSecret("correct-secret"), + DisplayName: "Operator Key", + Scopes: new HashSet(StringComparer.Ordinal) + { + "session:open", + "events:read" + }, + CreatedUtc: DateTimeOffset.UtcNow, + LastUsedUtc: null, + RevokedUtc: revokedUtc); + } + + 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)); + } + + private sealed class FakeApiKeyStore(ApiKeyRecord? storedKey) : IApiKeyStore + { + public bool MarkedUsed { get; private set; } + + public Task FindByKeyIdAsync(string keyId, CancellationToken cancellationToken) + { + return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null); + } + + public Task FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken) + { + return Task.FromResult( + storedKey?.KeyId == keyId && storedKey.RevokedUtc is null + ? storedKey + : null); + } + + public Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken) + { + MarkedUsed = storedKey?.KeyId == keyId; + + return Task.CompletedTask; + } + } +}