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.
This commit is contained in:
Joseph Doherty
2026-07-09 06:04:54 -04:00
parent 497c0aac52
commit 378880f0ed
14 changed files with 381 additions and 32 deletions
@@ -56,6 +56,14 @@ public sealed class ApiKeyVerifier(
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))
{