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
@@ -41,6 +41,34 @@ public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
Assert.Null(found.RevokedUtc);
}
[Fact]
public async Task Create_WithExpiresUtc_RoundTripsThroughFindAndList()
{
var expiry = new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero);
ApiKeyRecord record = SampleRecord("key-exp") with { ExpiresUtc = expiry };
await _admin.CreateAsync(record, CancellationToken.None);
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-exp", CancellationToken.None);
Assert.Equal(expiry, found!.ExpiresUtc);
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-exp");
Assert.Equal(expiry, item.ExpiresUtc);
}
[Fact]
public async Task Create_WithoutExpiresUtc_ReadsBackAsNull()
{
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
Assert.Null(found!.ExpiresUtc);
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
Assert.Null(Assert.Single(listed, k => k.KeyId == "key-1").ExpiresUtc);
}
// --- Revoke ---
[Fact]