using System.Data; using Microsoft.Data.SqlClient; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys.Sqlite; namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer; /// /// SQL Server-backed read store for API-key records — the verification hot path. /// /// /// Semantics are a deliberate one-for-one port of SqliteApiKeyStore, down to the WHERE /// clauses, so a host can switch providers without any behaviour changing. One connection per /// operation: the shared store is reached over the network and pooling, not a held connection, is /// what keeps a central pair's concurrent verifies cheap. /// /// Factory for connections to the shared API-key database. public sealed class SqlServerApiKeyStore(AuthSqlServerConnectionFactory connectionFactory) : IApiKeyStore { private const string SelectColumns = "key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc, expires_utc"; /// public Task FindByKeyIdAsync(string keyId, CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(keyId); return FindAsync(keyId, requireActive: false, ct); } /// public Task FindActiveByKeyIdAsync(string keyId, CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(keyId); return FindAsync(keyId, requireActive: true, ct); } /// /// /// Guarded on revoked_utc IS NULL: a revoked key's last-used stamp is frozen at its last /// legitimate use, so it never records the rejected attempts that follow. /// public async Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(keyId); string schema = connectionFactory.Schema; await using SqlConnection connection = await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false); await using SqlCommand command = connectionFactory.CreateCommand(connection, $""" UPDATE [{schema}].[{SqlServerAuthSchema.ApiKeysTable}] SET last_used_utc = @last_used_utc WHERE key_id = @key_id AND revoked_utc IS NULL; """); command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId; command.Parameters.Add("@last_used_utc", SqlDbType.DateTimeOffset).Value = whenUtc; await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } private async Task FindAsync(string keyId, bool requireActive, CancellationToken ct) { string schema = connectionFactory.Schema; string table = $"[{schema}].[{SqlServerAuthSchema.ApiKeysTable}]"; await using SqlConnection connection = await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false); // Active means NOT REVOKED only. Expiry is deliberately not filtered here — the verifier // owns it, so it can tell an expired key from an unknown one (ApiKeyFailure.KeyExpired). await using SqlCommand command = connectionFactory.CreateCommand( connection, requireActive ? $"SELECT {SelectColumns} FROM {table} WHERE key_id = @key_id AND revoked_utc IS NULL;" : $"SELECT {SelectColumns} FROM {table} WHERE key_id = @key_id;"); command.Parameters.Add("@key_id", SqlDbType.NVarChar, 64).Value = keyId; await using SqlDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false); if (!await reader.ReadAsync(ct).ConfigureAwait(false)) { return null; } return ReadRecord(reader); } internal static ApiKeyRecord ReadRecord(SqlDataReader reader) => new( KeyId: reader.GetString(0), KeyPrefix: reader.GetString(1), SecretHash: reader.GetFieldValue(2), DisplayName: reader.GetString(3), Scopes: ScopeSerializer.Deserialize(reader.GetString(4)), ConstraintsJson: reader.IsDBNull(5) ? null : reader.GetString(5), CreatedUtc: reader.GetDateTimeOffset(6), LastUsedUtc: reader.IsDBNull(7) ? null : reader.GetDateTimeOffset(7), RevokedUtc: reader.IsDBNull(8) ? null : reader.GetDateTimeOffset(8), ExpiresUtc: reader.IsDBNull(9) ? null : reader.GetDateTimeOffset(9)); }