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]