Issue #6: implement api key hashing and verification
This commit is contained in:
@@ -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