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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user