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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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><prefix>_<keyId>_<secret></c> EXACTLY ONCE.
|
/// and returns the assembled token <c><prefix>_<keyId>_<secret></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 <= 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]
|
||||||
|
|||||||
Reference in New Issue
Block a user