Files
scadaproj/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs
T

76 lines
3.1 KiB
C#

using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
namespace ZB.MOM.WW.Auth.ApiKeys;
/// <summary>
/// Verifies presented API-key credentials against the key store, returning a structured,
/// discriminated result. The pipeline is fail-closed: any inability to positively verify a
/// credential yields a failure rather than a success.
/// </summary>
/// <remarks>
/// The failure reason is discriminated for the caller/audit pipeline, but the verifier returns a
/// structured result rather than throwing (the caller decides the opaque client-facing message).
/// The only exception path is cancellation. A successful identity carries the key's scopes and the
/// opaque <c>ConstraintsJson</c> blob (which the verifier does not interpret); it never carries the
/// presented secret, the pepper, or the stored secret hash.
/// </remarks>
public sealed class ApiKeyVerifier(
ApiKeyOptions options,
IApiKeyStore store,
IApiKeyPepperProvider pepperProvider,
TimeProvider? timeProvider = null) : IApiKeyVerifier
{
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
/// <inheritdoc />
public async Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
// 1. Parse the header/token. Malformed or wrong-prefix credentials are indistinguishable
// from a missing credential and are reported uniformly.
ParsedApiKey? parsed = ApiKeyParser.TryParse(authorizationHeader, options.TokenPrefix);
if (parsed is null)
{
return Fail(ApiKeyFailure.MissingOrMalformed);
}
// 2. Resolve the pepper before touching the store. Without it, no verification is possible,
// so we fail closed (and avoid an unnecessary store lookup).
string? pepper = pepperProvider.GetPepper();
if (string.IsNullOrWhiteSpace(pepper))
{
return Fail(ApiKeyFailure.PepperUnavailable);
}
// 3. Look up the record (including revoked ones) so we can discriminate not-found vs revoked.
ApiKeyRecord? record = await store.FindByKeyIdAsync(parsed.KeyId, ct).ConfigureAwait(false);
if (record is null)
{
return Fail(ApiKeyFailure.KeyNotFound);
}
// 4. Reject revoked keys.
if (record.RevokedUtc is not null)
{
return Fail(ApiKeyFailure.KeyRevoked);
}
// 5. Constant-time secret comparison.
if (!ApiKeySecretHasher.Verify(parsed.Secret, pepper, record.SecretHash))
{
return Fail(ApiKeyFailure.SecretMismatch);
}
// 6. Record successful use, then return the identity (no secret/hash/pepper included).
await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct).ConfigureAwait(false);
return new ApiKeyVerification(
Succeeded: true,
Identity: new ApiKeyIdentity(record.KeyId, record.DisplayName, record.Scopes, record.ConstraintsJson),
Failure: null);
}
private static ApiKeyVerification Fail(ApiKeyFailure failure) => new(false, null, failure);
}