194 lines
6.6 KiB
C#
194 lines
6.6 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|