using ZB.MOM.WW.Auth.Abstractions.ApiKeys; namespace ZB.MOM.WW.Auth.ApiKeys; /// /// 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. /// /// /// 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 ConstraintsJson blob (which the verifier does not interpret); it never carries the /// presented secret, the pepper, or the stored secret hash. /// public sealed class ApiKeyVerifier( ApiKeyOptions options, IApiKeyStore store, IApiKeyPepperProvider pepperProvider, TimeProvider? timeProvider = null) : IApiKeyVerifier { private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; /// public async Task 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); } // 4b. Reject expired keys. Expiry is absolute and inclusive: a key whose ExpiresUtc is at or // before "now" is expired. A NULL ExpiresUtc never expires (the pre-expiry default), so // upgrading a store leaves existing keys valid. Uses the injected clock for testability. if (record.ExpiresUtc is { } expiresUtc && expiresUtc <= _timeProvider.GetUtcNow()) { return Fail(ApiKeyFailure.KeyExpired); } // 5. Constant-time secret comparison. if (!ApiKeySecretHasher.Verify(parsed.Secret, pepper, record.SecretHash)) { return Fail(ApiKeyFailure.SecretMismatch); } // 6. The authentication decision is already made (line 60). Recording last-used is // best-effort bookkeeping: a transient storage hiccup (SQLITE_BUSY past the busy-timeout, // disk full, DB locked by a migration) must NOT turn an otherwise-valid credential into a // failed auth. Swallow any non-cancellation failure so the only exception path remains // cancellation, as the class contract promises. Cancellation is honoured (re-thrown). try { await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch { // Best-effort: the last-used write failed, but the credential is valid. Fail open on the // bookkeeping (not the auth decision) rather than denying a legitimate caller. } 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); }