b5da1c8c57
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
101 lines
4.4 KiB
C#
101 lines
4.4 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// SQL Server-backed read store for API-key records — the verification hot path.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Semantics are a deliberate one-for-one port of <c>SqliteApiKeyStore</c>, 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.
|
|
/// </remarks>
|
|
/// <param name="connectionFactory">Factory for connections to the shared API-key database.</param>
|
|
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";
|
|
|
|
/// <inheritdoc />
|
|
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
|
return FindAsync(keyId, requireActive: false, ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
|
return FindAsync(keyId, requireActive: true, ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Guarded on <c>revoked_utc IS NULL</c>: a revoked key's last-used stamp is frozen at its last
|
|
/// legitimate use, so it never records the rejected attempts that follow.
|
|
/// </remarks>
|
|
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<ApiKeyRecord?> 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<byte[]>(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));
|
|
}
|