76 lines
3.1 KiB
C#
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);
|
|
}
|