286 lines
9.9 KiB
C#
286 lines
9.9 KiB
C#
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
|
using ZB.MOM.WW.Auth.ApiKeys;
|
|
|
|
namespace ZB.MOM.WW.Auth.ApiKeys.Tests;
|
|
|
|
public class ApiKeyVerifierTests
|
|
{
|
|
private const string TokenPrefix = "mxgw";
|
|
private const string Pepper = "test-pepper";
|
|
private const string KeyId = "abc123";
|
|
private const string Secret = "supersecretvalue";
|
|
private const string DisplayName = "Test Key";
|
|
private const string ConstraintsJson = """{"ipAllow":["10.0.0.0/8"]}""";
|
|
|
|
private static readonly IReadOnlySet<string> Scopes =
|
|
new HashSet<string> { "read", "write" };
|
|
|
|
private static string Header(string keyId, string secret) =>
|
|
$"{TokenPrefix}_{keyId}_{secret}";
|
|
|
|
private static ApiKeyRecord BuildRecord(
|
|
byte[] secretHash,
|
|
DateTimeOffset? revokedUtc = null) => new(
|
|
KeyId: KeyId,
|
|
KeyPrefix: TokenPrefix,
|
|
SecretHash: secretHash,
|
|
DisplayName: DisplayName,
|
|
Scopes: Scopes,
|
|
ConstraintsJson: ConstraintsJson,
|
|
CreatedUtc: DateTimeOffset.UnixEpoch,
|
|
LastUsedUtc: null,
|
|
RevokedUtc: revokedUtc);
|
|
|
|
private static ApiKeyVerifier BuildVerifier(
|
|
FakeApiKeyStore store,
|
|
FakePepperProvider pepperProvider) =>
|
|
new(new ApiKeyOptions { TokenPrefix = TokenPrefix }, store, pepperProvider);
|
|
|
|
// --- MissingOrMalformed ---
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData("garbage")]
|
|
[InlineData("wrongprefix_abc123_secret")]
|
|
public async Task VerifyAsync_MissingOrMalformedHeader_ReturnsMissingOrMalformed(string? header)
|
|
{
|
|
var store = new FakeApiKeyStore();
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result = await verifier.VerifyAsync(header!, CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(ApiKeyFailure.MissingOrMalformed, result.Failure);
|
|
Assert.Null(result.Identity);
|
|
Assert.False(store.MarkUsedCalled);
|
|
}
|
|
|
|
// --- PepperUnavailable ---
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task VerifyAsync_PepperUnavailable_ReturnsPepperUnavailable(string? pepper)
|
|
{
|
|
var store = new FakeApiKeyStore();
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(ApiKeyFailure.PepperUnavailable, result.Failure);
|
|
Assert.Null(result.Identity);
|
|
Assert.False(store.MarkUsedCalled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_PepperUnavailable_DoesNotQueryStore()
|
|
{
|
|
var store = new FakeApiKeyStore();
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(null));
|
|
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.False(store.FindByKeyIdCalled);
|
|
}
|
|
|
|
// --- KeyNotFound ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_KeyNotFound_ReturnsKeyNotFound()
|
|
{
|
|
var store = new FakeApiKeyStore { Record = null };
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(ApiKeyFailure.KeyNotFound, result.Failure);
|
|
Assert.Null(result.Identity);
|
|
Assert.False(store.MarkUsedCalled);
|
|
}
|
|
|
|
// --- KeyRevoked ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_RevokedKey_ReturnsKeyRevoked()
|
|
{
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore
|
|
{
|
|
Record = BuildRecord(hash, revokedUtc: DateTimeOffset.UtcNow),
|
|
};
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(ApiKeyFailure.KeyRevoked, result.Failure);
|
|
Assert.Null(result.Identity);
|
|
Assert.False(store.MarkUsedCalled);
|
|
}
|
|
|
|
// --- SecretMismatch ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_WrongSecret_ReturnsSecretMismatch()
|
|
{
|
|
// Record's hash is built from a DIFFERENT secret with the test pepper.
|
|
byte[] hash = ApiKeySecretHasher.Hash("a-different-secret", Pepper);
|
|
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(ApiKeyFailure.SecretMismatch, result.Failure);
|
|
Assert.Null(result.Identity);
|
|
Assert.False(store.MarkUsedCalled);
|
|
}
|
|
|
|
// --- Success ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_ValidKey_ReturnsSuccessWithIdentity()
|
|
{
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Null(result.Failure);
|
|
Assert.NotNull(result.Identity);
|
|
Assert.Equal(KeyId, result.Identity!.KeyId);
|
|
Assert.Equal(DisplayName, result.Identity.DisplayName);
|
|
Assert.Equal(Scopes, result.Identity.Scopes);
|
|
Assert.Equal(ConstraintsJson, result.Identity.Constraints);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_ValidKey_MarksKeyUsed()
|
|
{
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.True(store.MarkUsedCalled);
|
|
Assert.Equal(KeyId, store.MarkUsedKeyId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_ValidKey_UsesInjectedTimeProviderForMarkUsed()
|
|
{
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
|
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero));
|
|
var verifier = new ApiKeyVerifier(
|
|
new ApiKeyOptions { TokenPrefix = TokenPrefix },
|
|
store,
|
|
new FakePepperProvider(Pepper),
|
|
fakeTime);
|
|
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
Assert.Equal(fakeTime.Now, store.MarkUsedWhenUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_ValidKey_DoesNotLeakSecretInIdentity()
|
|
{
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
|
|
|
string identityText = result.Identity!.ToString();
|
|
Assert.DoesNotContain(Secret, identityText, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(Pepper, identityText, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(Convert.ToBase64String(hash), identityText, StringComparison.Ordinal);
|
|
}
|
|
|
|
// --- Cancellation ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_AlreadyCancelled_Throws()
|
|
{
|
|
var store = new FakeApiKeyStore();
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => verifier.VerifyAsync(Header(KeyId, Secret), cts.Token));
|
|
|
|
Assert.False(store.MarkUsedCalled);
|
|
}
|
|
|
|
// --- Bearer scheme acceptance (sanity) ---
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_BearerPrefixedValidKey_Succeeds()
|
|
{
|
|
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
|
var store = new FakeApiKeyStore { Record = BuildRecord(hash) };
|
|
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
|
|
|
ApiKeyVerification result =
|
|
await verifier.VerifyAsync($"Bearer {Header(KeyId, Secret)}", CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
}
|
|
|
|
// --- Fakes ---
|
|
|
|
private sealed class FakeApiKeyStore : IApiKeyStore
|
|
{
|
|
public ApiKeyRecord? Record { get; set; }
|
|
public bool FindByKeyIdCalled { get; private set; }
|
|
public bool MarkUsedCalled { get; private set; }
|
|
public string? MarkUsedKeyId { get; private set; }
|
|
public DateTimeOffset? MarkUsedWhenUtc { get; private set; }
|
|
|
|
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
|
{
|
|
FindByKeyIdCalled = true;
|
|
return Task.FromResult(Record);
|
|
}
|
|
|
|
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct) =>
|
|
throw new NotSupportedException("Verifier must use FindByKeyIdAsync to discriminate revoked keys.");
|
|
|
|
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
|
{
|
|
MarkUsedCalled = true;
|
|
MarkUsedKeyId = keyId;
|
|
MarkUsedWhenUtc = whenUtc;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider
|
|
{
|
|
public string? GetPepper() => pepper;
|
|
}
|
|
|
|
private sealed class FakeTimeProvider(DateTimeOffset now) : TimeProvider
|
|
{
|
|
public DateTimeOffset Now { get; } = now;
|
|
|
|
public override DateTimeOffset GetUtcNow() => Now;
|
|
}
|
|
}
|