diff --git a/CLAUDE.md b/CLAUDE.md
index 0500069..54179fd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -136,7 +136,7 @@ each project's **code-verified current state**, and the **gaps** between. See
| Component | Status | Goal | Design | Implementation |
|---|---|---|---|---|
-| Auth (login / identity / authz) | Adopted (lib `0.1.3`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
+| Auth (login / identity / authz) | Adopted (lib `0.1.4`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| UI Theme (layout / tokens / components) | Adopted (lib `0.2.0`; all 3 apps, merged to **local default** + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
| Health (readiness / liveness / active-node) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
| Observability (metrics / traces / logs) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
@@ -164,6 +164,7 @@ MxGateway `0.1.2`, ScadaBridge `0.1.3`. Per-repo detail in [`components/auth/GAP
`docs/plans/2026-06-02-auth-audit-normalization*.md`.
Build/test from `ZB.MOM.WW.Auth/`: `dotnet test`. Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore;
MxAccessGateway & ScadaBridge → all four (ApiKeys not used by OtOpcUa).
+**`0.1.4` (2026-07-09, archreview G-2):** optional `ApiKeyRecord.ExpiresUtc` (nullable = never-expires), `ApiKeyFailure.KeyExpired`, `ApiKeyVerifier` expiry enforcement (inclusive, injected clock), a `CreateKeyAsync(expiresUtc)` overload, and a SQLite **schema v3** additive `expires_utc` column (idempotent `ALTER`; donor v2 DBs upgrade in place, no key invalidated). Committed in scadaproj; **NOT yet published to the Gitea feed (human-gated `dotnet nuget push`)**. On publish: HistorianGateway consumes it (unblocks `apikey create --expires` + Task-10 dashboard Expires column) and mxaccessgw gains the same verifier expiry enforcement — both must bump to `0.1.4`.
The UI-theme component is fully populated: a normalized [`spec`](components/ui-theme/spec/SPEC.md),
a [`design-tokens`](components/ui-theme/spec/DESIGN-TOKENS.md) reference, a
diff --git a/ZB.MOM.WW.Auth/Directory.Build.props b/ZB.MOM.WW.Auth/Directory.Build.props
index 566ed20..c3bb10e 100644
--- a/ZB.MOM.WW.Auth/Directory.Build.props
+++ b/ZB.MOM.WW.Auth/Directory.Build.props
@@ -5,7 +5,7 @@
enable
enable
latest
- 0.1.3
+ 0.1.4
true
diff --git a/ZB.MOM.WW.Auth/README.md b/ZB.MOM.WW.Auth/README.md
index bab3786..36a3c3d 100644
--- a/ZB.MOM.WW.Auth/README.md
+++ b/ZB.MOM.WW.Auth/README.md
@@ -29,7 +29,15 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
## Versioning
-All four packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.1.0**. A single version bump in `Directory.Build.props` bumps all four packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
+All four packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.1.4**. A single version bump in `Directory.Build.props` bumps all four packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
+
+### 0.1.4 — API-key expiry (archreview G-2)
+
+- **`ApiKeyRecord`/`ApiKeyListItem` gain an optional `DateTimeOffset? ExpiresUtc`** (nullable; `NULL` = never expires). `ApiKeyFailure` gains `KeyExpired`.
+- **`ApiKeyVerifier` enforces expiry** — a key whose `ExpiresUtc` is at or before "now" (inclusive, injected clock) fails with `KeyExpired`, decided before the secret comparison. Existing (never-expiring) keys are unaffected.
+- **`ApiKeyAdminCommands.CreateKeyAsync`** has a new overload taking `DateTimeOffset? expiresUtc`; the old signature delegates with `null`. `RotateKeyAsync` deliberately preserves an existing expiry (rotation changes only the secret).
+- **SQLite schema → v3**: a nullable `expires_utc` column, added to fresh DBs by the CREATE DDL and to existing v1/v2 DBs by an idempotent `ALTER TABLE` in the migrator (existing rows → `NULL`). A donor v2 `gateway-auth.db` is accepted (2 ≤ 3) and upgraded in place — no key is invalidated. Old-build readers tolerate the extra column.
+- **Consumers must bump to 0.1.4** to set/enforce expiry (mxaccessgw + HistorianGateway benefit from the same verifier).
---
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs
index 05ad04e..765064e 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs
@@ -8,7 +8,7 @@ public sealed record ApiKeyOptions
public bool RunMigrationsOnStartup { get; init; } = true;
}
-public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch }
+public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch, KeyExpired }
public sealed record ApiKeyIdentity(string KeyId, string DisplayName, IReadOnlySet Scopes, object? Constraints);
@@ -29,7 +29,10 @@ public interface IApiKeyVerifier
public sealed record ApiKeyRecord(
string KeyId, string KeyPrefix, byte[] SecretHash, string DisplayName,
IReadOnlySet Scopes, string? ConstraintsJson,
- DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc);
+ DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc,
+ // Optional absolute expiry. NULL = never expires (the default for every key minted before this
+ // field existed, so upgrading a store leaves existing keys valid). Enforced in ApiKeyVerifier.
+ DateTimeOffset? ExpiresUtc = null);
public interface IApiKeyStore
{
@@ -46,7 +49,9 @@ public sealed record ApiKeyAuditEntry(string? KeyId, string EventType, string? R
///
public sealed record ApiKeyListItem(
string KeyId, string KeyPrefix, string DisplayName, IReadOnlySet Scopes,
- string? ConstraintsJson, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc);
+ string? ConstraintsJson, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc,
+ // Optional absolute expiry (NULL = never expires); surfaced so admins can see/alert on expiry.
+ DateTimeOffset? ExpiresUtc = null);
public interface IApiKeyAdminStore
{
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs
index 3d9d071..ac0dff3 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs
@@ -79,11 +79,27 @@ public sealed class ApiKeyAdminCommands
/// and returns the assembled token <prefix>_<keyId>_<secret> EXACTLY ONCE.
///
/// The pepper is unavailable; nothing is persisted or audited.
+ public Task CreateKeyAsync(
+ string keyId,
+ string displayName,
+ IReadOnlySet scopes,
+ string? constraintsJson,
+ string? remoteAddress,
+ CancellationToken ct) =>
+ CreateKeyAsync(keyId, displayName, scopes, constraintsJson, expiresUtc: null, remoteAddress, ct);
+
+ ///
+ /// create-key with an optional absolute expiry. A non-null is
+ /// persisted on the record and enforced by ; null means the
+ /// key never expires (the same behaviour as the expiry-less overload).
+ ///
+ /// The pepper is unavailable; nothing is persisted or audited.
public async Task CreateKeyAsync(
string keyId,
string displayName,
IReadOnlySet 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.
///
+ ///
+ /// Rotation replaces only the secret (and clears revoked/last-used); it deliberately leaves any
+ /// existing untouched, so a key's expiry survives a secret
+ /// rotation. Changing a key's expiry is a create-time concern (use ).
+ ///
/// The pepper is unavailable; nothing is persisted or audited.
public async Task RotateKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
{
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs
index 7ae5da6..95ea7ff 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs
@@ -56,6 +56,14 @@ public sealed class ApiKeyVerifier(
return Fail(ApiKeyFailure.KeyRevoked);
}
+ // 4b. Reject expired keys. Expiry is absolute and inclusive: a key whose ExpiresUtc is at or
+ // before "now" is expired. A NULL ExpiresUtc never expires (the pre-expiry default), so
+ // upgrading a store leaves existing keys valid. Uses the injected clock for testability.
+ if (record.ExpiresUtc is { } expiresUtc && expiresUtc <= _timeProvider.GetUtcNow())
+ {
+ return Fail(ApiKeyFailure.KeyExpired);
+ }
+
// 5. Constant-time secret comparison.
if (!ApiKeySecretHasher.Verify(parsed.Secret, pepper, record.SecretHash))
{
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs
index a7261a6..f71cd16 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs
@@ -21,10 +21,10 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
command.CommandText = """
INSERT INTO api_keys (
key_id, key_prefix, secret_hash, display_name, scopes,
- constraints, created_utc, last_used_utc, revoked_utc)
+ constraints, created_utc, last_used_utc, revoked_utc, expires_utc)
VALUES (
$key_id, $key_prefix, $secret_hash, $display_name, $scopes,
- $constraints, $created_utc, $last_used_utc, $revoked_utc);
+ $constraints, $created_utc, $last_used_utc, $revoked_utc, $expires_utc);
""";
command.Parameters.AddWithValue("$key_id", record.KeyId);
command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix);
@@ -35,6 +35,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
command.Parameters.AddWithValue("$created_utc", record.CreatedUtc.ToString("O"));
command.Parameters.AddWithValue("$last_used_utc", (object?)record.LastUsedUtc?.ToString("O") ?? DBNull.Value);
command.Parameters.AddWithValue("$revoked_utc", (object?)record.RevokedUtc?.ToString("O") ?? DBNull.Value);
+ command.Parameters.AddWithValue("$expires_utc", (object?)record.ExpiresUtc?.ToString("O") ?? DBNull.Value);
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
@@ -178,7 +179,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
// Deliberately omits secret_hash so listing can never leak secret material.
command.CommandText = """
SELECT key_id, key_prefix, display_name, scopes, constraints,
- created_utc, last_used_utc, revoked_utc
+ created_utc, last_used_utc, revoked_utc, expires_utc
FROM api_keys
ORDER BY created_utc DESC, key_id DESC;
""";
@@ -197,7 +198,8 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
ConstraintsJson: reader.IsDBNull(4) ? null : reader.GetString(4),
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(5)),
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 6),
- RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7)));
+ RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
+ ExpiresUtc: SqliteValueParsing.ReadNullableUtc(reader, 8)));
}
return items;
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyStore.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyStore.cs
index 8e2bff5..412e59f 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyStore.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyStore.cs
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore
{
private const string SelectColumns =
- "key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc";
+ "key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc, expires_utc";
///
public Task FindByKeyIdAsync(string keyId, CancellationToken ct)
@@ -72,5 +72,6 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact
ConstraintsJson: reader.IsDBNull(5) ? null : reader.GetString(5),
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(6)),
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
- RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8));
+ RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8),
+ ExpiresUtc: SqliteValueParsing.ReadNullableUtc(reader, 9));
}
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthSchema.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthSchema.cs
index 86b87a5..e256c1c 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthSchema.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthSchema.cs
@@ -6,14 +6,16 @@ namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
public static class SqliteAuthSchema
{
///
- /// The schema version this build creates and supports. This is 2, not 1,
- /// to match the deployed databases of the donor (MxAccessGateway) this store was
- /// extracted from: that store reached its final shape via a v1→v2 history and stamps
- /// version = 2 on disk. The final schema has been byte-identical since v1, so a
- /// single-shot create stamped as 2 interoperates with existing gateway-auth.db
- /// files (the migrator only refuses an on-disk version newer than this).
+ /// The schema version this build creates and supports. Versions 1 and 2 shared a
+ /// byte-identical schema (the store was extracted from the donor MxAccessGateway, whose deployed
+ /// gateway-auth.db stamps version = 2); 3 adds the nullable
+ /// expires_utc column (API-key expiry, archreview G-2). The migration is additive and
+ /// backward-compatible: an existing v1/v2 database is upgraded in place (the column is added via
+ /// an idempotent ALTER TABLE, existing rows get NULL = never-expires) and re-stamped
+ /// to 3, so no key is invalidated on upgrade. The migrator still only refuses an on-disk version
+ /// newer than this, so a donor v2 database is accepted (2 <= 3) and upgraded.
///
- public const int CurrentVersion = 2;
+ public const int CurrentVersion = 3;
/// Name of the single-row table tracking the applied schema version.
public const string SchemaVersionTable = "schema_version";
@@ -34,6 +36,11 @@ public static class SqliteAuthSchema
""";
/// DDL creating the API-key record table.
+ ///
+ /// expires_utc (nullable) was added at schema v3. Fresh databases get it from this DDL;
+ /// existing v1/v2 databases get it via the idempotent ALTER TABLE in
+ /// ().
+ ///
public const string CreateApiKeysTable = """
CREATE TABLE IF NOT EXISTS api_keys (
key_id TEXT PRIMARY KEY,
@@ -44,10 +51,22 @@ public static class SqliteAuthSchema
constraints TEXT NULL,
created_utc TEXT NOT NULL,
last_used_utc TEXT NULL,
- revoked_utc TEXT NULL
+ revoked_utc TEXT NULL,
+ expires_utc TEXT NULL
);
""";
+ /// Name of the nullable expiry column added at schema v3.
+ public const string ExpiresUtcColumn = "expires_utc";
+
+ ///
+ /// Idempotent forward migration adding to a pre-v3
+ /// api_keys table. Guarded by a column-existence check at the call site, so it is safe to
+ /// run against a fresh (already-has-the-column) database too.
+ ///
+ public const string AddExpiresUtcColumn =
+ "ALTER TABLE api_keys ADD COLUMN expires_utc TEXT NULL;";
+
/// DDL creating the append-only audit table.
public const string CreateApiKeyAuditTable = """
CREATE TABLE IF NOT EXISTS api_key_audit (
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs
index 8234692..6439087 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs
@@ -79,7 +79,8 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
}
// Single-shot create of the final schema (all DDL is CREATE ... IF NOT EXISTS, so it is
- // idempotent against an already-provisioned database). The applied version is stamped
+ // idempotent against an already-provisioned database), followed by any additive column
+ // migrations that a pre-existing table would be missing. The applied version is stamped
// separately by WriteSchemaVersionAsync.
private static async Task ApplySchemaAsync(
SqliteConnection connection,
@@ -96,6 +97,54 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
SqliteAuthSchema.CreateApiKeyAuditTable,
SqliteAuthSchema.CreateIndexes),
cancellationToken).ConfigureAwait(false);
+
+ // v3: add the nullable expires_utc column to a pre-v3 api_keys table. On a fresh database the
+ // CREATE above already includes the column, so the existence check makes this a no-op.
+ await EnsureApiKeysColumnAsync(
+ connection,
+ transaction,
+ SqliteAuthSchema.ExpiresUtcColumn,
+ SqliteAuthSchema.AddExpiresUtcColumn,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ // Adds a column to api_keys only if it is not already present. SQLite has no
+ // "ADD COLUMN IF NOT EXISTS", so we probe PRAGMA table_info first. The reader is fully consumed
+ // and disposed before the ALTER runs on the same connection/transaction.
+ private static async Task EnsureApiKeysColumnAsync(
+ SqliteConnection connection,
+ SqliteTransaction transaction,
+ string columnName,
+ string addColumnDdl,
+ CancellationToken cancellationToken)
+ {
+ bool exists = false;
+
+ await using (SqliteCommand pragma = connection.CreateCommand())
+ {
+ pragma.Transaction = transaction;
+ // table_info takes an identifier, not a bindable parameter; the table name is a fixed
+ // internal constant, never caller input.
+ pragma.CommandText = $"PRAGMA table_info({SqliteAuthSchema.ApiKeysTable});";
+
+ await using SqliteDataReader reader =
+ await pragma.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+
+ while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ // PRAGMA table_info columns: cid(0), name(1), type(2), ...
+ if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
+ {
+ exists = true;
+ break;
+ }
+ }
+ }
+
+ if (!exists)
+ {
+ await ExecuteNonQueryAsync(connection, transaction, addColumnDdl, cancellationToken).ConfigureAwait(false);
+ }
}
private static async Task WriteSchemaVersionAsync(
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs
index 58fb904..f993e9f 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs
@@ -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(["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(["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()
{
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs
index 6195fa1..f2b6a82 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs
@@ -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]
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs
index 8f1bf3f..c6a4f39 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs
@@ -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 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 listed = await _admin.ListAsync(CancellationToken.None);
+ Assert.Null(Assert.Single(listed, k => k.KeyId == "key-1").ExpiresUtc);
+ }
+
// --- Revoke ---
[Fact]
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteMigratorTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteMigratorTests.cs
index f7870d0..c69cc2d 100644
--- a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteMigratorTests.cs
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteMigratorTests.cs
@@ -35,24 +35,93 @@ public sealed class SqliteMigratorTests : IDisposable
}
[Fact]
- public void CurrentVersion_Is2_ToMatchDonorGatewayDeployedSchema() =>
- // The store was extracted from MxAccessGateway, whose deployed gateway-auth.db is
- // stamped version 2. The library must stamp 2 (not reset to 1) so it does not refuse
- // those existing databases on first boot. Locking this invariant.
- Assert.Equal(2, SqliteAuthSchema.CurrentVersion);
+ public void CurrentVersion_Is3_ForExpiresUtcColumn() =>
+ // v3 adds the nullable expires_utc column (archreview G-2). The value must stay >= 2 so the
+ // migrator does not refuse a donor MxAccessGateway gateway-auth.db (stamped 2) on first boot:
+ // 2 <= 3, so such a DB is accepted and upgraded in place. Locking this invariant.
+ Assert.Equal(3, SqliteAuthSchema.CurrentVersion);
[Fact]
- public async Task MigrateAsync_AgainstExistingVersion2Db_DoesNotThrow_AndStaysAt2()
+ public async Task MigrateAsync_AgainstExistingVersion2Db_DoesNotThrow_AndUpgradesTo3()
{
- // The deployed-gateway scenario: a database already provisioned at version 2.
+ // The deployed-gateway scenario: a database already provisioned at the donor's version 2.
+ // Migrating must accept it (2 <= 3), add the expires_utc column, and re-stamp to 3 — no key
+ // is invalidated, the table survives.
var migrator = new SqliteAuthStoreMigrator(Factory);
await migrator.MigrateAsync(CancellationToken.None);
await SetVersionAsync(2);
await migrator.MigrateAsync(CancellationToken.None); // must not throw
- Assert.Equal(2, await ReadVersionAsync());
+ Assert.Equal(3, await ReadVersionAsync());
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable));
+ Assert.True(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn));
+ }
+
+ [Fact]
+ public async Task MigrateAsync_PreV3ApiKeysTableWithoutExpiresUtc_AddsColumn_AndPreservesRows()
+ {
+ // Simulate a genuine pre-expiry (v2) database: an api_keys table WITHOUT expires_utc, holding
+ // an existing key. The forward migration must add the column (existing row → NULL) without
+ // dropping the row.
+ await CreateLegacyV2SchemaWithKeyAsync("legacy-key");
+
+ Assert.False(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn));
+
+ await new SqliteAuthStoreMigrator(Factory).MigrateAsync(CancellationToken.None);
+
+ Assert.True(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn));
+ Assert.Equal(3, await ReadVersionAsync());
+
+ // The pre-existing key survives and its new expires_utc is NULL (never-expires).
+ var read = new SqliteApiKeyStore(Factory);
+ var found = await read.FindByKeyIdAsync("legacy-key", CancellationToken.None);
+ Assert.NotNull(found);
+ Assert.Null(found!.ExpiresUtc);
+ }
+
+ private async Task CreateLegacyV2SchemaWithKeyAsync(string keyId)
+ {
+ await using SqliteConnection connection =
+ await Factory.OpenConnectionAsync(CancellationToken.None);
+ await using SqliteCommand command = connection.CreateCommand();
+ command.CommandText = $"""
+ CREATE TABLE api_keys (
+ key_id TEXT PRIMARY KEY,
+ key_prefix TEXT NOT NULL,
+ secret_hash BLOB NOT NULL,
+ display_name TEXT NOT NULL,
+ scopes TEXT NOT NULL,
+ constraints TEXT NULL,
+ created_utc TEXT NOT NULL,
+ last_used_utc TEXT NULL,
+ revoked_utc TEXT NULL
+ );
+ {SqliteAuthSchema.CreateSchemaVersionTable}
+ INSERT INTO schema_version (id, version, applied_utc) VALUES (1, 2, '2026-01-01T00:00:00.0000000+00:00');
+ INSERT INTO api_keys (key_id, key_prefix, secret_hash, display_name, scopes, created_utc)
+ VALUES ($key_id, 'mxgw', X'01020304', 'Legacy', 'read', '2026-01-01T00:00:00.0000000+00:00');
+ """;
+ command.Parameters.AddWithValue("$key_id", keyId);
+ await command.ExecuteNonQueryAsync(CancellationToken.None);
+ }
+
+ private async Task ColumnExistsAsync(string table, string column)
+ {
+ await using SqliteConnection connection =
+ await Factory.OpenConnectionAsync(CancellationToken.None);
+ await using SqliteCommand command = connection.CreateCommand();
+ command.CommandText = $"PRAGMA table_info({table});";
+ await using SqliteDataReader reader = await command.ExecuteReaderAsync(CancellationToken.None);
+ while (await reader.ReadAsync(CancellationToken.None))
+ {
+ if (string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
}
[Fact]