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
@@ -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<string> Scopes, object? Constraints);
@@ -29,7 +29,10 @@ public interface IApiKeyVerifier
public sealed record ApiKeyRecord(
string KeyId, string KeyPrefix, byte[] SecretHash, string DisplayName,
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
{
@@ -46,7 +49,9 @@ public sealed record ApiKeyAuditEntry(string? KeyId, string EventType, string? R
/// </summary>
public sealed record ApiKeyListItem(
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
{
@@ -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.
/// </summary>
/// <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(
string keyId,
string displayName,
IReadOnlySet<string> 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.
/// </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>
public async Task<CreateKeyResult> RotateKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
{
@@ -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))
{
@@ -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;
@@ -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";
/// <inheritdoc />
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),
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));
}
@@ -6,14 +6,16 @@ namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
public static class SqliteAuthSchema
{
/// <summary>
/// The schema version this build creates and supports. This is <c>2</c>, not <c>1</c>,
/// 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
/// <c>version = 2</c> on disk. The final schema has been byte-identical since v1, so a
/// single-shot create stamped as 2 interoperates with existing <c>gateway-auth.db</c>
/// files (the migrator only refuses an on-disk version <em>newer</em> than this).
/// The schema version this build creates and supports. Versions <c>1</c> and <c>2</c> shared a
/// byte-identical schema (the store was extracted from the donor MxAccessGateway, whose deployed
/// <c>gateway-auth.db</c> stamps <c>version = 2</c>); <c>3</c> adds the nullable
/// <c>expires_utc</c> 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 <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>
public const int CurrentVersion = 2;
public const int CurrentVersion = 3;
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
public const string SchemaVersionTable = "schema_version";
@@ -34,6 +36,11 @@ public static class SqliteAuthSchema
""";
/// <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 = """
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
);
""";
/// <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>
public const string CreateApiKeyAuditTable = """
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
// 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(