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
@@ -20,7 +20,8 @@ public class ApiKeyVerifierTests
private static ApiKeyRecord BuildRecord(
byte[] secretHash,
DateTimeOffset? revokedUtc = null) => new(
DateTimeOffset? revokedUtc = null,
DateTimeOffset? expiresUtc = null) => new(
KeyId: KeyId,
KeyPrefix: TokenPrefix,
SecretHash: secretHash,
@@ -29,7 +30,8 @@ public class ApiKeyVerifierTests
ConstraintsJson: ConstraintsJson,
CreatedUtc: DateTimeOffset.UnixEpoch,
LastUsedUtc: null,
RevokedUtc: revokedUtc);
RevokedUtc: revokedUtc,
ExpiresUtc: expiresUtc);
private static ApiKeyVerifier BuildVerifier(
FakeApiKeyStore store,
@@ -126,6 +128,101 @@ public class ApiKeyVerifierTests
Assert.False(store.MarkUsedCalled);
}
// --- KeyExpired (archreview G-2) ---
private static readonly DateTimeOffset Now = new(2026, 6, 1, 12, 0, 0, TimeSpan.Zero);
private static ApiKeyVerifier BuildVerifierAt(
FakeApiKeyStore store, DateTimeOffset now) =>
new(new ApiKeyOptions { TokenPrefix = TokenPrefix },
store,
new FakePepperProvider(Pepper),
new FakeTimeProvider(now));
[Fact]
public async Task VerifyAsync_ExpiredKey_ReturnsKeyExpired()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
var store = new FakeApiKeyStore
{
Record = BuildRecord(hash, expiresUtc: Now - TimeSpan.FromSeconds(1)),
};
var verifier = BuildVerifierAt(store, Now);
ApiKeyVerification result =
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyFailure.KeyExpired, result.Failure);
Assert.Null(result.Identity);
// Expiry is decided before the secret comparison and usage write.
Assert.False(store.MarkUsedCalled);
}
[Fact]
public async Task VerifyAsync_ExpiryExactlyNow_IsExpired_Inclusive()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
var store = new FakeApiKeyStore { Record = BuildRecord(hash, expiresUtc: Now) };
var verifier = BuildVerifierAt(store, Now);
ApiKeyVerification result =
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyFailure.KeyExpired, result.Failure);
}
[Fact]
public async Task VerifyAsync_NotYetExpiredKey_Succeeds()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
var store = new FakeApiKeyStore
{
Record = BuildRecord(hash, expiresUtc: Now + TimeSpan.FromSeconds(1)),
};
var verifier = BuildVerifierAt(store, Now);
ApiKeyVerification result =
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Null(result.Failure);
Assert.True(store.MarkUsedCalled);
}
[Fact]
public async Task VerifyAsync_NullExpiry_NeverExpires()
{
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
var store = new FakeApiKeyStore { Record = BuildRecord(hash, expiresUtc: null) };
// A clock far in the future must still accept a never-expiring key.
var verifier = BuildVerifierAt(store, Now + TimeSpan.FromDays(3650));
ApiKeyVerification result =
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.True(result.Succeeded);
}
[Fact]
public async Task VerifyAsync_RevokedTakesPrecedenceOverExpiry()
{
// A key that is both revoked and expired reports KeyRevoked (the earlier check).
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
var store = new FakeApiKeyStore
{
Record = BuildRecord(hash, revokedUtc: Now - TimeSpan.FromDays(1), expiresUtc: Now - TimeSpan.FromDays(1)),
};
var verifier = BuildVerifierAt(store, Now);
ApiKeyVerification result =
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyFailure.KeyRevoked, result.Failure);
}
// --- SecretMismatch ---
[Fact]