Issue #6: implement api key hashing and verification
This commit is contained in:
@@ -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
|
load the key record by id, hash the presented secret, and compare using a
|
||||||
constant-time comparison.
|
constant-time comparison.
|
||||||
|
|
||||||
|
`ApiKeyParser` accepts only `authorization: Bearer mxgw_<key-id>_<secret>`.
|
||||||
|
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:
|
Recommended scopes:
|
||||||
|
|
||||||
- `session:open`
|
- `session:open`
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
public sealed record ApiKeyIdentity(
|
||||||
|
string KeyId,
|
||||||
|
string KeyPrefix,
|
||||||
|
string DisplayName,
|
||||||
|
IReadOnlySet<string> Scopes);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
public sealed class ApiKeyPepperUnavailableException(string pepperSecretName)
|
||||||
|
: InvalidOperationException($"API key pepper secret '{pepperSecretName}' is not configured.");
|
||||||
@@ -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<GatewayOptions> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
public enum ApiKeyVerificationFailure
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
MissingOrMalformedCredentials,
|
||||||
|
PepperUnavailable,
|
||||||
|
KeyNotFound,
|
||||||
|
KeyRevoked,
|
||||||
|
SecretMismatch
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ApiKeyVerificationResult> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ public static class AuthStoreServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
|
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
|
services.AddSingleton<IApiKeyParser, ApiKeyParser>();
|
||||||
|
services.AddSingleton<IApiKeySecretHasher, ApiKeySecretHasher>();
|
||||||
|
services.AddSingleton<IApiKeyVerifier, ApiKeyVerifier>();
|
||||||
services.AddSingleton<AuthSqliteConnectionFactory>();
|
services.AddSingleton<AuthSqliteConnectionFactory>();
|
||||||
services.AddSingleton<IAuthStoreMigrator, SqliteAuthStoreMigrator>();
|
services.AddSingleton<IAuthStoreMigrator, SqliteAuthStoreMigrator>();
|
||||||
services.AddSingleton<IApiKeyStore, SqliteApiKeyStore>();
|
services.AddSingleton<IApiKeyStore, SqliteApiKeyStore>();
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
public interface IApiKeyParser
|
||||||
|
{
|
||||||
|
bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
public interface IApiKeySecretHasher
|
||||||
|
{
|
||||||
|
byte[] HashSecret(string secret);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
public interface IApiKeyVerifier
|
||||||
|
{
|
||||||
|
Task<ApiKeyVerificationResult> VerifyAsync(
|
||||||
|
string? authorizationHeader,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
public sealed record ParsedApiKey(string KeyId, string Secret);
|
||||||
@@ -13,6 +13,15 @@ public sealed class GatewayLogRedactorTests
|
|||||||
Assert.DoesNotContain("super-secret", redacted);
|
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]
|
[Theory]
|
||||||
[InlineData("AuthenticateUser")]
|
[InlineData("AuthenticateUser")]
|
||||||
[InlineData("WriteSecured")]
|
[InlineData("WriteSecured")]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"session:open",
|
||||||
|
"events:read"
|
||||||
|
},
|
||||||
|
CreatedUtc: DateTimeOffset.UtcNow,
|
||||||
|
LastUsedUtc: null,
|
||||||
|
RevokedUtc: revokedUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeApiKeyStore(ApiKeyRecord? storedKey) : IApiKeyStore
|
||||||
|
{
|
||||||
|
public bool MarkedUsed { get; private set; }
|
||||||
|
|
||||||
|
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ApiKeyRecord?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user