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
@@ -87,6 +87,46 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
Assert.Single(recent, e => e.EventType == "create-key");
}
[Fact]
public async Task CreateKey_WithExpiresUtc_PersistsExpiryOnRecord()
{
// archreview G-2: the expiry overload threads ExpiresUtc onto the stored record.
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
var expiry = new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero);
await commands.CreateKeyAsync(
"key-exp",
"Service Exp",
new HashSet<string>(["read"], StringComparer.Ordinal),
constraintsJson: null,
expiresUtc: expiry,
remoteAddress: null,
CancellationToken.None);
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-exp", CancellationToken.None);
Assert.NotNull(found);
Assert.Equal(expiry, found!.ExpiresUtc);
}
[Fact]
public async Task CreateKey_ExpiryLessOverload_PersistsNullExpiry()
{
ApiKeyAdminCommands commands = BuildCommands();
await commands.InitDbAsync(null, CancellationToken.None);
await commands.CreateKeyAsync(
"key-1",
"Service A",
new HashSet<string>(["read"], StringComparer.Ordinal),
constraintsJson: null,
remoteAddress: null,
CancellationToken.None);
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
Assert.Null(found!.ExpiresUtc);
}
[Fact]
public async Task CreateKey_PersistsBareTokenPrefix_NotPrefixUnderscoreKeyId()
{