Files
scadaproj/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs
T
Joseph Doherty 378880f0ed feat(apikeys): optional ExpiresUtc — create + verifier enforcement + sqlite v3 migration (archreview G-2)
ApiKeyRecord/ApiKeyListItem gain a nullable ExpiresUtc (NULL = never
expires); ApiKeyFailure gains KeyExpired. ApiKeyVerifier rejects a key
whose ExpiresUtc is at-or-before now (inclusive, injected clock), before
the secret comparison. CreateKeyAsync gets an expiresUtc overload; rotate
preserves existing expiry. SQLite schema bumps to v3: a nullable
expires_utc column, added to fresh DBs via CREATE and to existing v1/v2
DBs via an idempotent guarded ALTER — donor v2 gateway-auth.db upgrades
in place, no key invalidated. Version 0.1.3 -> 0.1.4 (not yet published;
nuget push is human-gated). Consumers (HistorianGateway, mxaccessgw) bump
to 0.1.4 to set/enforce expiry. 146 Auth.ApiKeys tests pass.
2026-07-09 06:04:54 -04:00

100 lines
4.3 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);
}
// 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);
}