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
@@ -8,7 +8,7 @@ public sealed record ApiKeyOptions
public bool RunMigrationsOnStartup { get; init; } = true;
}
public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch }
public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch, KeyExpired }
public sealed record ApiKeyIdentity(string KeyId, string DisplayName, IReadOnlySet<string> Scopes, object? Constraints);
@@ -29,7 +29,10 @@ public interface IApiKeyVerifier
public sealed record ApiKeyRecord(
string KeyId, string KeyPrefix, byte[] SecretHash, string DisplayName,
IReadOnlySet<string> Scopes, string? ConstraintsJson,
DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc);
DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc,
// Optional absolute expiry. NULL = never expires (the default for every key minted before this
// field existed, so upgrading a store leaves existing keys valid). Enforced in ApiKeyVerifier.
DateTimeOffset? ExpiresUtc = null);
public interface IApiKeyStore
{
@@ -46,7 +49,9 @@ public sealed record ApiKeyAuditEntry(string? KeyId, string EventType, string? R
/// </summary>
public sealed record ApiKeyListItem(
string KeyId, string KeyPrefix, string DisplayName, IReadOnlySet<string> Scopes,
string? ConstraintsJson, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc);
string? ConstraintsJson, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc,
// Optional absolute expiry (NULL = never expires); surfaced so admins can see/alert on expiry.
DateTimeOffset? ExpiresUtc = null);
public interface IApiKeyAdminStore
{