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
@@ -79,11 +79,27 @@ public sealed class ApiKeyAdminCommands
/// and returns the assembled token <c>&lt;prefix&gt;_&lt;keyId&gt;_&lt;secret&gt;</c> EXACTLY ONCE.
/// </summary>
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
public Task<CreateKeyResult> CreateKeyAsync(
string keyId,
string displayName,
IReadOnlySet<string> scopes,
string? constraintsJson,
string? remoteAddress,
CancellationToken ct) =>
CreateKeyAsync(keyId, displayName, scopes, constraintsJson, expiresUtc: null, remoteAddress, ct);
/// <summary>
/// create-key with an optional absolute expiry. A non-null <paramref name="expiresUtc"/> is
/// persisted on the record and enforced by <see cref="ApiKeyVerifier"/>; <c>null</c> means the
/// key never expires (the same behaviour as the expiry-less overload).
/// </summary>
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
public async Task<CreateKeyResult> CreateKeyAsync(
string keyId,
string displayName,
IReadOnlySet<string> scopes,
string? constraintsJson,
DateTimeOffset? expiresUtc,
string? remoteAddress,
CancellationToken ct)
{
@@ -111,7 +127,8 @@ public sealed class ApiKeyAdminCommands
ConstraintsJson: constraintsJson,
CreatedUtc: now,
LastUsedUtc: null,
RevokedUtc: null);
RevokedUtc: null,
ExpiresUtc: expiresUtc);
await _adminStore.CreateAsync(record, ct).ConfigureAwait(false);
await AppendAuditAsync(keyId, "create-key", remoteAddress, details: null, ct).ConfigureAwait(false);
@@ -151,6 +168,11 @@ public sealed class ApiKeyAdminCommands
/// All attempts are audited, including failures (key not found) — this is intentional to
/// maintain a complete security trail.
/// </summary>
/// <remarks>
/// Rotation replaces only the secret (and clears revoked/last-used); it deliberately leaves any
/// existing <see cref="ApiKeyRecord.ExpiresUtc"/> untouched, so a key's expiry survives a secret
/// rotation. Changing a key's expiry is a create-time concern (use <see cref="CreateKeyAsync(string,string,IReadOnlySet{string},string?,DateTimeOffset?,string?,CancellationToken)"/>).
/// </remarks>
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
public async Task<CreateKeyResult> RotateKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
{