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
+2 -1
View File
@@ -136,7 +136,7 @@ each project's **code-verified current state**, and the **gaps** between. See
| Component | Status | Goal | Design | Implementation | | 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/) | | 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/) | | 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/) | | 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`. `docs/plans/2026-06-02-auth-audit-normalization*.md`.
Build/test from `ZB.MOM.WW.Auth/`: `dotnet test`. Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore; Build/test from `ZB.MOM.WW.Auth/`: `dotnet test`. Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore;
MxAccessGateway & ScadaBridge → all four (ApiKeys not used by OtOpcUa). 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), 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 a [`design-tokens`](components/ui-theme/spec/DESIGN-TOKENS.md) reference, a
+1 -1
View File
@@ -5,7 +5,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Version>0.1.3</Version> <Version>0.1.4</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
+9 -1
View File
@@ -29,7 +29,15 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
## Versioning ## 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).
--- ---
@@ -8,7 +8,7 @@ public sealed record ApiKeyOptions
public bool RunMigrationsOnStartup { get; init; } = true; 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<string> Scopes, object? Constraints); public sealed record ApiKeyIdentity(string KeyId, string DisplayName, IReadOnlySet<string> Scopes, object? Constraints);
@@ -29,7 +29,10 @@ public interface IApiKeyVerifier
public sealed record ApiKeyRecord( public sealed record ApiKeyRecord(
string KeyId, string KeyPrefix, byte[] SecretHash, string DisplayName, string KeyId, string KeyPrefix, byte[] SecretHash, string DisplayName,
IReadOnlySet<string> Scopes, string? ConstraintsJson, IReadOnlySet<string> 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 public interface IApiKeyStore
{ {
@@ -46,7 +49,9 @@ public sealed record ApiKeyAuditEntry(string? KeyId, string EventType, string? R
/// </summary> /// </summary>
public sealed record ApiKeyListItem( public sealed record ApiKeyListItem(
string KeyId, string KeyPrefix, string DisplayName, IReadOnlySet<string> Scopes, string KeyId, string KeyPrefix, string DisplayName, IReadOnlySet<string> 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 public interface IApiKeyAdminStore
{ {
@@ -79,11 +79,27 @@ public sealed class ApiKeyAdminCommands
/// and returns the assembled token <c>&lt;prefix&gt;_&lt;keyId&gt;_&lt;secret&gt;</c> EXACTLY ONCE. /// and returns the assembled token <c>&lt;prefix&gt;_&lt;keyId&gt;_&lt;secret&gt;</c> EXACTLY ONCE.
/// </summary> /// </summary>
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception> /// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
public Task<CreateKeyResult> CreateKeyAsync(
string keyId,
string displayName,
IReadOnlySet<string> scopes,
string? constraintsJson,
string? remoteAddress,
CancellationToken ct) =>
CreateKeyAsync(keyId, displayName, scopes, constraintsJson, expiresUtc: null, remoteAddress, ct);
/// <summary>
/// create-key with an optional absolute expiry. A non-null <paramref name="expiresUtc"/> is
/// persisted on the record and enforced by <see cref="ApiKeyVerifier"/>; <c>null</c> means the
/// key never expires (the same behaviour as the expiry-less overload).
/// </summary>
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
public async Task<CreateKeyResult> CreateKeyAsync( public async Task<CreateKeyResult> CreateKeyAsync(
string keyId, string keyId,
string displayName, string displayName,
IReadOnlySet<string> scopes, IReadOnlySet<string> scopes,
string? constraintsJson, string? constraintsJson,
DateTimeOffset? expiresUtc,
string? remoteAddress, string? remoteAddress,
CancellationToken ct) CancellationToken ct)
{ {
@@ -111,7 +127,8 @@ public sealed class ApiKeyAdminCommands
ConstraintsJson: constraintsJson, ConstraintsJson: constraintsJson,
CreatedUtc: now, CreatedUtc: now,
LastUsedUtc: null, LastUsedUtc: null,
RevokedUtc: null); RevokedUtc: null,
ExpiresUtc: expiresUtc);
await _adminStore.CreateAsync(record, ct).ConfigureAwait(false); await _adminStore.CreateAsync(record, ct).ConfigureAwait(false);
await AppendAuditAsync(keyId, "create-key", remoteAddress, details: null, 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 /// All attempts are audited, including failures (key not found) — this is intentional to
/// maintain a complete security trail. /// maintain a complete security trail.
/// </summary> /// </summary>
/// <remarks>
/// Rotation replaces only the secret (and clears revoked/last-used); it deliberately leaves any
/// existing <see cref="ApiKeyRecord.ExpiresUtc"/> untouched, so a key's expiry survives a secret
/// rotation. Changing a key's expiry is a create-time concern (use <see cref="CreateKeyAsync(string,string,IReadOnlySet{string},string?,DateTimeOffset?,string?,CancellationToken)"/>).
/// </remarks>
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception> /// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
public async Task<CreateKeyResult> RotateKeyAsync(string keyId, string? remoteAddress, CancellationToken ct) public async Task<CreateKeyResult> RotateKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
{ {
@@ -56,6 +56,14 @@ public sealed class ApiKeyVerifier(
return Fail(ApiKeyFailure.KeyRevoked); 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. // 5. Constant-time secret comparison.
if (!ApiKeySecretHasher.Verify(parsed.Secret, pepper, record.SecretHash)) if (!ApiKeySecretHasher.Verify(parsed.Secret, pepper, record.SecretHash))
{ {
@@ -21,10 +21,10 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
command.CommandText = """ command.CommandText = """
INSERT INTO api_keys ( INSERT INTO api_keys (
key_id, key_prefix, secret_hash, display_name, scopes, 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 ( VALUES (
$key_id, $key_prefix, $secret_hash, $display_name, $scopes, $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_id", record.KeyId);
command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix); 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("$created_utc", record.CreatedUtc.ToString("O"));
command.Parameters.AddWithValue("$last_used_utc", (object?)record.LastUsedUtc?.ToString("O") ?? DBNull.Value); 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("$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); 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. // Deliberately omits secret_hash so listing can never leak secret material.
command.CommandText = """ command.CommandText = """
SELECT key_id, key_prefix, display_name, scopes, constraints, 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 FROM api_keys
ORDER BY created_utc DESC, key_id DESC; 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), ConstraintsJson: reader.IsDBNull(4) ? null : reader.GetString(4),
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(5)), CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(5)),
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 6), LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 6),
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7))); RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
ExpiresUtc: SqliteValueParsing.ReadNullableUtc(reader, 8)));
} }
return items; return items;
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore
{ {
private const string SelectColumns = 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";
/// <inheritdoc /> /// <inheritdoc />
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct) public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
@@ -72,5 +72,6 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact
ConstraintsJson: reader.IsDBNull(5) ? null : reader.GetString(5), ConstraintsJson: reader.IsDBNull(5) ? null : reader.GetString(5),
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(6)), CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(6)),
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7), LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8)); RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8),
ExpiresUtc: SqliteValueParsing.ReadNullableUtc(reader, 9));
} }
@@ -6,14 +6,16 @@ namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
public static class SqliteAuthSchema public static class SqliteAuthSchema
{ {
/// <summary> /// <summary>
/// The schema version this build creates and supports. This is <c>2</c>, not <c>1</c>, /// The schema version this build creates and supports. Versions <c>1</c> and <c>2</c> shared a
/// to match the deployed databases of the donor (MxAccessGateway) this store was /// byte-identical schema (the store was extracted from the donor MxAccessGateway, whose deployed
/// extracted from: that store reached its final shape via a v1→v2 history and stamps /// <c>gateway-auth.db</c> stamps <c>version = 2</c>); <c>3</c> adds the nullable
/// <c>version = 2</c> on disk. The final schema has been byte-identical since v1, so a /// <c>expires_utc</c> column (API-key expiry, archreview G-2). The migration is additive and
/// single-shot create stamped as 2 interoperates with existing <c>gateway-auth.db</c> /// backward-compatible: an existing v1/v2 database is upgraded in place (the column is added via
/// files (the migrator only refuses an on-disk version <em>newer</em> than this). /// an idempotent <c>ALTER TABLE</c>, existing rows get <c>NULL</c> = never-expires) and re-stamped
/// to 3, so no key is invalidated on upgrade. The migrator still only refuses an on-disk version
/// <em>newer</em> than this, so a donor v2 database is accepted (2 &lt;= 3) and upgraded.
/// </summary> /// </summary>
public const int CurrentVersion = 2; public const int CurrentVersion = 3;
/// <summary>Name of the single-row table tracking the applied schema version.</summary> /// <summary>Name of the single-row table tracking the applied schema version.</summary>
public const string SchemaVersionTable = "schema_version"; public const string SchemaVersionTable = "schema_version";
@@ -34,6 +36,11 @@ public static class SqliteAuthSchema
"""; """;
/// <summary>DDL creating the API-key record table.</summary> /// <summary>DDL creating the API-key record table.</summary>
/// <remarks>
/// <c>expires_utc</c> (nullable) was added at schema v3. Fresh databases get it from this DDL;
/// existing v1/v2 databases get it via the idempotent <c>ALTER TABLE</c> in
/// <see cref="SqliteAuthStoreMigrator"/> (<see cref="AddExpiresUtcColumn"/>).
/// </remarks>
public const string CreateApiKeysTable = """ public const string CreateApiKeysTable = """
CREATE TABLE IF NOT EXISTS api_keys ( CREATE TABLE IF NOT EXISTS api_keys (
key_id TEXT PRIMARY KEY, key_id TEXT PRIMARY KEY,
@@ -44,10 +51,22 @@ public static class SqliteAuthSchema
constraints TEXT NULL, constraints TEXT NULL,
created_utc TEXT NOT NULL, created_utc TEXT NOT NULL,
last_used_utc TEXT NULL, last_used_utc TEXT NULL,
revoked_utc TEXT NULL revoked_utc TEXT NULL,
expires_utc TEXT NULL
); );
"""; """;
/// <summary>Name of the nullable expiry column added at schema v3.</summary>
public const string ExpiresUtcColumn = "expires_utc";
/// <summary>
/// Idempotent forward migration adding <see cref="ExpiresUtcColumn"/> to a pre-v3
/// <c>api_keys</c> 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.
/// </summary>
public const string AddExpiresUtcColumn =
"ALTER TABLE api_keys ADD COLUMN expires_utc TEXT NULL;";
/// <summary>DDL creating the append-only audit table.</summary> /// <summary>DDL creating the append-only audit table.</summary>
public const string CreateApiKeyAuditTable = """ public const string CreateApiKeyAuditTable = """
CREATE TABLE IF NOT EXISTS api_key_audit ( CREATE TABLE IF NOT EXISTS api_key_audit (
@@ -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 // 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. // separately by WriteSchemaVersionAsync.
private static async Task ApplySchemaAsync( private static async Task ApplySchemaAsync(
SqliteConnection connection, SqliteConnection connection,
@@ -96,6 +97,54 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
SqliteAuthSchema.CreateApiKeyAuditTable, SqliteAuthSchema.CreateApiKeyAuditTable,
SqliteAuthSchema.CreateIndexes), SqliteAuthSchema.CreateIndexes),
cancellationToken).ConfigureAwait(false); 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( private static async Task WriteSchemaVersionAsync(
@@ -87,6 +87,46 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
Assert.Single(recent, e => e.EventType == "create-key"); 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] [Fact]
public async Task CreateKey_PersistsBareTokenPrefix_NotPrefixUnderscoreKeyId() public async Task CreateKey_PersistsBareTokenPrefix_NotPrefixUnderscoreKeyId()
{ {
@@ -20,7 +20,8 @@ public class ApiKeyVerifierTests
private static ApiKeyRecord BuildRecord( private static ApiKeyRecord BuildRecord(
byte[] secretHash, byte[] secretHash,
DateTimeOffset? revokedUtc = null) => new( DateTimeOffset? revokedUtc = null,
DateTimeOffset? expiresUtc = null) => new(
KeyId: KeyId, KeyId: KeyId,
KeyPrefix: TokenPrefix, KeyPrefix: TokenPrefix,
SecretHash: secretHash, SecretHash: secretHash,
@@ -29,7 +30,8 @@ public class ApiKeyVerifierTests
ConstraintsJson: ConstraintsJson, ConstraintsJson: ConstraintsJson,
CreatedUtc: DateTimeOffset.UnixEpoch, CreatedUtc: DateTimeOffset.UnixEpoch,
LastUsedUtc: null, LastUsedUtc: null,
RevokedUtc: revokedUtc); RevokedUtc: revokedUtc,
ExpiresUtc: expiresUtc);
private static ApiKeyVerifier BuildVerifier( private static ApiKeyVerifier BuildVerifier(
FakeApiKeyStore store, FakeApiKeyStore store,
@@ -126,6 +128,101 @@ public class ApiKeyVerifierTests
Assert.False(store.MarkUsedCalled); 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 --- // --- SecretMismatch ---
[Fact] [Fact]
@@ -41,6 +41,34 @@ public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
Assert.Null(found.RevokedUtc); 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<ApiKeyListItem> 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<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
Assert.Null(Assert.Single(listed, k => k.KeyId == "key-1").ExpiresUtc);
}
// --- Revoke --- // --- Revoke ---
[Fact] [Fact]
@@ -35,24 +35,93 @@ public sealed class SqliteMigratorTests : IDisposable
} }
[Fact] [Fact]
public void CurrentVersion_Is2_ToMatchDonorGatewayDeployedSchema() => public void CurrentVersion_Is3_ForExpiresUtcColumn() =>
// The store was extracted from MxAccessGateway, whose deployed gateway-auth.db is // v3 adds the nullable expires_utc column (archreview G-2). The value must stay >= 2 so the
// stamped version 2. The library must stamp 2 (not reset to 1) so it does not refuse // migrator does not refuse a donor MxAccessGateway gateway-auth.db (stamped 2) on first boot:
// those existing databases on first boot. Locking this invariant. // 2 <= 3, so such a DB is accepted and upgraded in place. Locking this invariant.
Assert.Equal(2, SqliteAuthSchema.CurrentVersion); Assert.Equal(3, SqliteAuthSchema.CurrentVersion);
[Fact] [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); var migrator = new SqliteAuthStoreMigrator(Factory);
await migrator.MigrateAsync(CancellationToken.None); await migrator.MigrateAsync(CancellationToken.None);
await SetVersionAsync(2); await SetVersionAsync(2);
await migrator.MigrateAsync(CancellationToken.None); // must not throw 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 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<bool> 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] [Fact]