Initial commit: scadaproj umbrella — sister-project index, auth component normalization (design + GAPS), and the built ZB.MOM.WW.Auth shared library (0.1.0, flattened in).
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a verb that yields a freshly assembled token (create-key / rotate-key).
|
||||
/// The <see cref="Token"/> is the ONLY moment the secret is ever available; it is never
|
||||
/// retrievable afterwards. A <c>null</c> <see cref="Token"/> indicates the verb failed
|
||||
/// (for example, rotating a key that does not exist).
|
||||
/// </summary>
|
||||
public sealed record CreateKeyResult(string KeyId, string? Token);
|
||||
|
||||
/// <summary>Result of a mutating verb that succeeds or fails without yielding a token.</summary>
|
||||
public sealed record KeyActionResult(bool Succeeded, string? Message);
|
||||
|
||||
/// <summary>
|
||||
/// Reusable, front-end-agnostic API-key administration command set. Each verb returns a
|
||||
/// structured result and performs no console I/O, so consumers can wire their own CLI or HTTP
|
||||
/// front-end on top. Audit is wired here (the command layer): every mutating verb appends an
|
||||
/// <see cref="ApiKeyAuditEntry"/> via <see cref="IApiKeyAuditStore"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>create-key</c> and <c>rotate-key</c> return the assembled token EXACTLY ONCE — the only
|
||||
/// time the secret is ever available. No other result carries the secret or its hash;
|
||||
/// <see cref="ApiKeyListItem"/> is a hash-free projection by construction.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ApiKeyAdminCommands
|
||||
{
|
||||
private readonly ApiKeyOptions _options;
|
||||
private readonly IApiKeyAdminStore _adminStore;
|
||||
private readonly IApiKeyAuditStore _auditStore;
|
||||
private readonly IApiKeyPepperProvider _pepperProvider;
|
||||
private readonly SqliteAuthStoreMigrator _migrator;
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
/// <summary>Creates the command set over the supplied stores and options.</summary>
|
||||
/// <param name="options">API-key options (token prefix, store path, ...).</param>
|
||||
/// <param name="adminStore">Mutating store (create / revoke / rotate / delete / list).</param>
|
||||
/// <param name="auditStore">Append-only audit store wired into every mutating verb.</param>
|
||||
/// <param name="pepperProvider">Resolves the pepper used to hash secrets.</param>
|
||||
/// <param name="migrator">Schema migrator used by <see cref="InitDbAsync"/>.</param>
|
||||
/// <param name="clock">Optional clock; defaults to <see cref="TimeProvider.System"/>.</param>
|
||||
public ApiKeyAdminCommands(
|
||||
ApiKeyOptions options,
|
||||
IApiKeyAdminStore adminStore,
|
||||
IApiKeyAuditStore auditStore,
|
||||
IApiKeyPepperProvider pepperProvider,
|
||||
SqliteAuthStoreMigrator migrator,
|
||||
TimeProvider? clock = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(adminStore);
|
||||
ArgumentNullException.ThrowIfNull(auditStore);
|
||||
ArgumentNullException.ThrowIfNull(pepperProvider);
|
||||
ArgumentNullException.ThrowIfNull(migrator);
|
||||
|
||||
_options = options;
|
||||
_adminStore = adminStore;
|
||||
_auditStore = auditStore;
|
||||
_pepperProvider = pepperProvider;
|
||||
_migrator = migrator;
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// init-db: applies the schema migration, then appends an <c>init-db</c> audit entry.
|
||||
/// </summary>
|
||||
public async Task InitDbAsync(string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
await _migrator.MigrateAsync(ct).ConfigureAwait(false);
|
||||
await AppendAuditAsync(keyId: null, "init-db", remoteAddress, details: null, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// create-key: generates a secret, persists its hash, appends a <c>create-key</c> audit entry,
|
||||
/// and returns the assembled token <c><prefix>_<keyId>_<secret></c> EXACTLY ONCE.
|
||||
/// </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,
|
||||
string? remoteAddress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
if (keyId.Contains('_'))
|
||||
throw new ArgumentException("keyId must not contain '_'.", nameof(keyId));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
|
||||
ArgumentNullException.ThrowIfNull(scopes);
|
||||
|
||||
string pepper = RequirePepper();
|
||||
|
||||
string secret = ApiKeySecretGenerator.NewSecret();
|
||||
byte[] secretHash = ApiKeySecretHasher.Hash(secret, pepper);
|
||||
DateTimeOffset now = _clock.GetUtcNow();
|
||||
|
||||
var record = new ApiKeyRecord(
|
||||
KeyId: keyId,
|
||||
KeyPrefix: $"{_options.TokenPrefix}_{keyId}",
|
||||
SecretHash: secretHash,
|
||||
DisplayName: displayName,
|
||||
Scopes: scopes,
|
||||
ConstraintsJson: constraintsJson,
|
||||
CreatedUtc: now,
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null);
|
||||
|
||||
await _adminStore.CreateAsync(record, ct).ConfigureAwait(false);
|
||||
await AppendAuditAsync(keyId, "create-key", remoteAddress, details: null, ct).ConfigureAwait(false);
|
||||
|
||||
return new CreateKeyResult(keyId, AssembleToken(keyId, secret));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// list-keys: returns the hash-free <see cref="ApiKeyListItem"/> projection of every key,
|
||||
/// newest first. This is a read, so it appends no audit entry and never carries secret material.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<ApiKeyListItem>> ListKeysAsync(CancellationToken ct) =>
|
||||
_adminStore.ListAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// revoke-key: marks the key revoked and appends a <c>revoke-key</c> audit entry.
|
||||
/// All attempts are audited, including failures (key not found or already revoked) — this is
|
||||
/// intentional to maintain a complete security trail.
|
||||
/// </summary>
|
||||
public async Task<KeyActionResult> RevokeKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
DateTimeOffset now = _clock.GetUtcNow();
|
||||
bool revoked = await _adminStore.RevokeAsync(keyId, now, ct).ConfigureAwait(false);
|
||||
|
||||
string status = revoked ? "revoked" : "not-found-or-already-revoked";
|
||||
await AppendAuditAsync(keyId, "revoke-key", remoteAddress, status, ct).ConfigureAwait(false);
|
||||
|
||||
return new KeyActionResult(revoked, status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// rotate-key: replaces the stored secret with a freshly generated one and appends a
|
||||
/// <c>rotate-key</c> audit entry. Returns a <see cref="CreateKeyResult"/> whose token is the new
|
||||
/// secret (shown once); a <c>null</c> token indicates the key did not exist.
|
||||
/// All attempts are audited, including failures (key not found) — this is intentional to
|
||||
/// maintain a complete security trail.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
|
||||
public async Task<CreateKeyResult> RotateKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
string pepper = RequirePepper();
|
||||
|
||||
string secret = ApiKeySecretGenerator.NewSecret();
|
||||
byte[] newHash = ApiKeySecretHasher.Hash(secret, pepper);
|
||||
|
||||
bool rotated = await _adminStore.RotateAsync(keyId, newHash, ct).ConfigureAwait(false);
|
||||
|
||||
string status = rotated ? "rotated" : "not-found";
|
||||
await AppendAuditAsync(keyId, "rotate-key", remoteAddress, status, ct).ConfigureAwait(false);
|
||||
|
||||
return new CreateKeyResult(keyId, rotated ? AssembleToken(keyId, secret) : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// delete-key: removes the key (only succeeds once it has been revoked) and appends a
|
||||
/// <c>delete-key</c> audit entry.
|
||||
/// All attempts are audited, including failures (key not found or not yet revoked) — this is
|
||||
/// intentional to maintain a complete security trail.
|
||||
/// </summary>
|
||||
public async Task<KeyActionResult> DeleteKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
bool deleted = await _adminStore.DeleteAsync(keyId, ct).ConfigureAwait(false);
|
||||
|
||||
string status = deleted ? "deleted" : "not-found-or-not-revoked";
|
||||
await AppendAuditAsync(keyId, "delete-key", remoteAddress, status, ct).ConfigureAwait(false);
|
||||
|
||||
return new KeyActionResult(deleted, status);
|
||||
}
|
||||
|
||||
private string RequirePepper()
|
||||
{
|
||||
string? pepper = _pepperProvider.GetPepper();
|
||||
if (string.IsNullOrWhiteSpace(pepper))
|
||||
{
|
||||
throw new InvalidOperationException("pepper unavailable");
|
||||
}
|
||||
|
||||
return pepper;
|
||||
}
|
||||
|
||||
private string AssembleToken(string keyId, string secret) =>
|
||||
$"{_options.TokenPrefix}_{keyId}_{secret}";
|
||||
|
||||
private Task AppendAuditAsync(
|
||||
string? keyId, string eventType, string? remoteAddress, string? details, CancellationToken ct) =>
|
||||
_auditStore.AppendAsync(
|
||||
new ApiKeyAuditEntry(keyId, eventType, remoteAddress, _clock.GetUtcNow(), details),
|
||||
ct);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
internal sealed record ParsedApiKey(string KeyId, string Secret);
|
||||
|
||||
internal static class ApiKeyParser
|
||||
{
|
||||
private const string BearerPrefix = "Bearer ";
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse an Authorization header value or a raw token into a <see cref="ParsedApiKey"/>.
|
||||
/// Accepts an optional case-insensitive "Bearer " scheme prefix before the token.
|
||||
/// Token format: <c><tokenPrefix>_<keyId>_<secret></c>.
|
||||
/// The secret may itself contain underscores; only the first underscore after the key-id is used as
|
||||
/// the key-id/secret separator.
|
||||
/// </summary>
|
||||
/// <param name="authorizationHeaderOrToken">Authorization header value or raw token string.</param>
|
||||
/// <param name="tokenPrefix">Expected token prefix (e.g. "mxgw"), without trailing underscore.</param>
|
||||
/// <returns>A <see cref="ParsedApiKey"/> on success, or <c>null</c> if the input is malformed.</returns>
|
||||
public static ParsedApiKey? TryParse(string? authorizationHeaderOrToken, string tokenPrefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeaderOrToken))
|
||||
return null;
|
||||
|
||||
string token = authorizationHeaderOrToken;
|
||||
|
||||
// Strip optional "Bearer " prefix (case-insensitive).
|
||||
if (token.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
token = token[BearerPrefix.Length..].Trim();
|
||||
|
||||
// Token must start with "<prefix>_"
|
||||
string requiredPrefix = tokenPrefix + "_";
|
||||
if (!token.StartsWith(requiredPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
// Everything after "<prefix>_" is "<keyId>_<secret>"
|
||||
string keyPayload = token[requiredPrefix.Length..];
|
||||
|
||||
int separatorIndex = keyPayload.IndexOf('_', StringComparison.Ordinal);
|
||||
|
||||
// separatorIndex <= 0 means no underscore or empty keyId
|
||||
// separatorIndex == keyPayload.Length - 1 means empty secret
|
||||
if (separatorIndex <= 0 || separatorIndex == keyPayload.Length - 1)
|
||||
return null;
|
||||
|
||||
string keyId = keyPayload[..separatorIndex];
|
||||
string secret = keyPayload[(separatorIndex + 1)..];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secret))
|
||||
return null;
|
||||
|
||||
return new ParsedApiKey(keyId, secret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
internal static class ApiKeySecretGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a new cryptographically secure API key secret.
|
||||
/// Returns 32 random bytes encoded as URL-safe base64 (no padding, no '+', no '/').
|
||||
/// </summary>
|
||||
public static string NewSecret()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
internal static class ApiKeySecretHasher
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes HMAC-SHA256(key: UTF-8(pepper), data: UTF-8(secret)).
|
||||
/// </summary>
|
||||
public static byte[] Hash(string secret, string pepper)
|
||||
{
|
||||
byte[] pepperBytes = Encoding.UTF8.GetBytes(pepper);
|
||||
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
|
||||
using HMACSHA256 hmac = new(pepperBytes);
|
||||
return hmac.ComputeHash(secretBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true iff HMAC-SHA256(key: UTF-8(pepper), data: UTF-8(secret)) equals
|
||||
/// <paramref name="expectedHash"/>, using a constant-time comparison.
|
||||
/// Returns false (without throwing) if the lengths differ.
|
||||
/// </summary>
|
||||
public static bool Verify(string secret, string pepper, byte[] expectedHash)
|
||||
{
|
||||
byte[] actualHash = Hash(secret, pepper);
|
||||
return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies presented API-key credentials against the key store, returning a structured,
|
||||
/// discriminated result. The pipeline is fail-closed: any inability to positively verify a
|
||||
/// credential yields a failure rather than a success.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The failure reason is discriminated for the caller/audit pipeline, but the verifier returns a
|
||||
/// structured result rather than throwing (the caller decides the opaque client-facing message).
|
||||
/// The only exception path is cancellation. A successful identity carries the key's scopes and the
|
||||
/// opaque <c>ConstraintsJson</c> blob (which the verifier does not interpret); it never carries the
|
||||
/// presented secret, the pepper, or the stored secret hash.
|
||||
/// </remarks>
|
||||
public sealed class ApiKeyVerifier(
|
||||
ApiKeyOptions options,
|
||||
IApiKeyStore store,
|
||||
IApiKeyPepperProvider pepperProvider,
|
||||
TimeProvider? timeProvider = null) : IApiKeyVerifier
|
||||
{
|
||||
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// 1. Parse the header/token. Malformed or wrong-prefix credentials are indistinguishable
|
||||
// from a missing credential and are reported uniformly.
|
||||
ParsedApiKey? parsed = ApiKeyParser.TryParse(authorizationHeader, options.TokenPrefix);
|
||||
if (parsed is null)
|
||||
{
|
||||
return Fail(ApiKeyFailure.MissingOrMalformed);
|
||||
}
|
||||
|
||||
// 2. Resolve the pepper before touching the store. Without it, no verification is possible,
|
||||
// so we fail closed (and avoid an unnecessary store lookup).
|
||||
string? pepper = pepperProvider.GetPepper();
|
||||
if (string.IsNullOrWhiteSpace(pepper))
|
||||
{
|
||||
return Fail(ApiKeyFailure.PepperUnavailable);
|
||||
}
|
||||
|
||||
// 3. Look up the record (including revoked ones) so we can discriminate not-found vs revoked.
|
||||
ApiKeyRecord? record = await store.FindByKeyIdAsync(parsed.KeyId, ct).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Fail(ApiKeyFailure.KeyNotFound);
|
||||
}
|
||||
|
||||
// 4. Reject revoked keys.
|
||||
if (record.RevokedUtc is not null)
|
||||
{
|
||||
return Fail(ApiKeyFailure.KeyRevoked);
|
||||
}
|
||||
|
||||
// 5. Constant-time secret comparison.
|
||||
if (!ApiKeySecretHasher.Verify(parsed.Secret, pepper, record.SecretHash))
|
||||
{
|
||||
return Fail(ApiKeyFailure.SecretMismatch);
|
||||
}
|
||||
|
||||
// 6. Record successful use, then return the identity (no secret/hash/pepper included).
|
||||
await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct).ConfigureAwait(false);
|
||||
|
||||
return new ApiKeyVerification(
|
||||
Succeeded: true,
|
||||
Identity: new ApiKeyIdentity(record.KeyId, record.DisplayName, record.Scopes, record.ConstraintsJson),
|
||||
Failure: null);
|
||||
}
|
||||
|
||||
private static ApiKeyVerification Fail(ApiKeyFailure failure) => new(false, null, failure);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the API-key SQLite schema migration at application startup when
|
||||
/// <see cref="ApiKeyOptions.RunMigrationsOnStartup"/> is <see langword="true"/>.
|
||||
/// The migration is idempotent, so repeated restarts are safe.
|
||||
/// </summary>
|
||||
internal sealed class ApiKeyMigrationHostedService(
|
||||
SqliteAuthStoreMigrator migrator,
|
||||
IOptions<ApiKeyOptions> options) : IHostedService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (options.Value.RunMigrationsOnStartup)
|
||||
{
|
||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency-injection helpers that wire up the ZB.MOM.WW API-key authentication provider
|
||||
/// from configuration. These compose the SQLite-backed stores and the configuration-backed
|
||||
/// pepper provider so a consuming app registers the verifier with a single call.
|
||||
/// </summary>
|
||||
public static class ApiKeyServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers API-key authentication: binds <see cref="ApiKeyOptions"/> from the
|
||||
/// configuration section at <paramref name="sectionPath"/>, wires up the SQLite-backed
|
||||
/// stores and the configuration-backed pepper provider, and registers
|
||||
/// <see cref="IApiKeyVerifier"/>.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add to.</param>
|
||||
/// <param name="config">The application configuration.</param>
|
||||
/// <param name="sectionPath">Path of the configuration section holding the API-key options.</param>
|
||||
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
||||
public static IServiceCollection AddZbApiKeyAuth(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config,
|
||||
string sectionPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
services.Configure<ApiKeyOptions>(config.GetSection(sectionPath));
|
||||
|
||||
// The pepper provider reads the live IConfiguration on each call. In an ASP.NET Core
|
||||
// host IConfiguration is already registered; register the caller-supplied instance here
|
||||
// (TryAdd, so the host's own registration wins when present) so the provider resolves
|
||||
// even in a bare ServiceCollection.
|
||||
services.TryAddSingleton(config);
|
||||
services.TryAddSingleton<IApiKeyPepperProvider, ConfigurationApiKeyPepperProvider>();
|
||||
|
||||
// One connection factory targets the configured SQLite path. Singleton: it is
|
||||
// stateless aside from the path and opens a fresh connection per operation.
|
||||
services.TryAddSingleton(sp =>
|
||||
new AuthSqliteConnectionFactory(
|
||||
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value.SqlitePath));
|
||||
|
||||
services.TryAddSingleton<IApiKeyStore>(sp =>
|
||||
new SqliteApiKeyStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
|
||||
services.TryAddSingleton<IApiKeyAdminStore>(sp =>
|
||||
new SqliteApiKeyAdminStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
|
||||
services.TryAddSingleton<IApiKeyAuditStore>(sp =>
|
||||
new SqliteApiKeyAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
|
||||
|
||||
services.TryAddSingleton<IApiKeyVerifier>(sp =>
|
||||
new ApiKeyVerifier(
|
||||
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value,
|
||||
sp.GetRequiredService<IApiKeyStore>(),
|
||||
sp.GetRequiredService<IApiKeyPepperProvider>()));
|
||||
|
||||
// Migrator: singleton, constructed from the already-registered connection factory.
|
||||
// Needed before any store operations so the schema exists.
|
||||
services.TryAddSingleton(sp =>
|
||||
new SqliteAuthStoreMigrator(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
|
||||
|
||||
// Hosted service that runs migrations on startup when ApiKeyOptions.RunMigrationsOnStartup.
|
||||
services.AddHostedService<ApiKeyMigrationHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration-backed <see cref="IApiKeyPepperProvider"/> that resolves the API-key pepper
|
||||
/// from <see cref="IConfiguration"/> using the key name in
|
||||
/// <see cref="ApiKeyOptions.PepperSecretName"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The pepper is read live from configuration on each call so that a secret rotated in the
|
||||
/// underlying provider (e.g. an environment variable or a refreshed secret store) takes effect
|
||||
/// without restarting the process. When the secret name is unconfigured or the value is absent,
|
||||
/// <see cref="GetPepper"/> returns <see langword="null"/>/empty, which the verifier treats as a
|
||||
/// fail-closed "pepper unavailable" condition.
|
||||
/// </remarks>
|
||||
public sealed class ConfigurationApiKeyPepperProvider(
|
||||
IConfiguration config,
|
||||
IOptions<ApiKeyOptions> options) : IApiKeyPepperProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string? GetPepper()
|
||||
{
|
||||
string secretName = options.Value.PepperSecretName;
|
||||
|
||||
return string.IsNullOrWhiteSpace(secretName)
|
||||
? null
|
||||
: config[secretName];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the secret pepper used to verify API-key secret hashes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations resolve the pepper from a configured secret source. A concrete,
|
||||
/// configuration-backed provider is wired up separately; this abstraction lets the
|
||||
/// verifier fail closed when the pepper cannot be resolved.
|
||||
/// </remarks>
|
||||
public interface IApiKeyPepperProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the resolved pepper, or <c>null</c>/empty if it is currently unavailable.
|
||||
/// </summary>
|
||||
/// <returns>The pepper value, or <c>null</c>/whitespace when unavailable.</returns>
|
||||
string? GetPepper();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating and opening SQLite connections to the API-key store.
|
||||
/// </summary>
|
||||
public sealed class AuthSqliteConnectionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Busy timeout applied to every connection. SQLite retries a busy database for
|
||||
/// this long before surfacing <c>SQLITE_BUSY</c>, so the concurrent
|
||||
/// mark-used / audit-append writers degrade gracefully under load instead of
|
||||
/// failing the request path.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _sqlitePath;
|
||||
|
||||
/// <summary>Creates a factory targeting the database at <paramref name="sqlitePath"/>.</summary>
|
||||
/// <param name="sqlitePath">Filesystem path of the SQLite database file.</param>
|
||||
public AuthSqliteConnectionFactory(string sqlitePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sqlitePath);
|
||||
_sqlitePath = sqlitePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unopened SQLite connection (Mode=ReadWriteCreate). Prefer
|
||||
/// <see cref="OpenConnectionAsync"/>, which also applies WAL journaling and the
|
||||
/// busy timeout.
|
||||
/// </summary>
|
||||
public SqliteConnection CreateConnection()
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(_sqlitePath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
SqliteConnectionStringBuilder builder = new()
|
||||
{
|
||||
DataSource = _sqlitePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Pooling = true,
|
||||
DefaultTimeout = (int)BusyTimeout.TotalSeconds,
|
||||
};
|
||||
|
||||
return new SqliteConnection(builder.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SQLite connection, opens it, and configures WAL journaling and a
|
||||
/// non-zero busy timeout so concurrent readers and writers degrade gracefully
|
||||
/// rather than surfacing <c>SQLITE_BUSY</c> as a hard failure.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>An opened and configured SQLite connection.</returns>
|
||||
public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
SqliteConnection connection = CreateConnection();
|
||||
try
|
||||
{
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ConfigureConnectionAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ConfigureConnectionAsync(
|
||||
SqliteConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// WAL is a persistent, database-level setting; re-applying it per connection
|
||||
// is cheap and a no-op once set. busy_timeout is per-connection state.
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
$"PRAGMA journal_mode=WAL; PRAGMA busy_timeout={(int)BusyTimeout.TotalMilliseconds};";
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes API-key scope sets to a canonical JSON array. Scopes are sorted with
|
||||
/// <see cref="StringComparer.Ordinal"/> so that equal sets always produce identical
|
||||
/// column text, regardless of insertion order.
|
||||
/// </summary>
|
||||
public static class ScopeSerializer
|
||||
{
|
||||
/// <summary>Serializes scopes to an ordinal-sorted JSON array.</summary>
|
||||
/// <param name="scopes">The scopes to serialize.</param>
|
||||
/// <returns>A JSON array string with elements sorted ordinally.</returns>
|
||||
public static string Serialize(IReadOnlySet<string> scopes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopes);
|
||||
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Deserializes scopes from a JSON array string.</summary>
|
||||
/// <param name="value">The JSON string to deserialize; may be null or empty.</param>
|
||||
/// <returns>An ordinal-compared set of scopes; empty when the input is null/blank.</returns>
|
||||
public static IReadOnlySet<string> Deserialize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
|
||||
|
||||
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-backed administration store for API keys (create, revoke, rotate, delete).
|
||||
/// </summary>
|
||||
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task CreateAsync(ApiKeyRecord record, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO api_keys (
|
||||
key_id, key_prefix, secret_hash, display_name, scopes,
|
||||
constraints, created_utc, last_used_utc, revoked_utc)
|
||||
VALUES (
|
||||
$key_id, $key_prefix, $secret_hash, $display_name, $scopes,
|
||||
$constraints, $created_utc, $last_used_utc, $revoked_utc);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", record.KeyId);
|
||||
command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix);
|
||||
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = record.SecretHash;
|
||||
command.Parameters.AddWithValue("$display_name", record.DisplayName);
|
||||
command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(record.Scopes));
|
||||
command.Parameters.AddWithValue("$constraints", (object?)record.ConstraintsJson ?? DBNull.Value);
|
||||
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);
|
||||
|
||||
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET revoked_utc = $revoked_utc
|
||||
WHERE key_id = $key_id AND revoked_utc IS NULL;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.AddWithValue("$revoked_utc", whenUtc.ToString("O"));
|
||||
|
||||
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
ArgumentNullException.ThrowIfNull(newSecretHash);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET secret_hash = $secret_hash,
|
||||
last_used_utc = NULL,
|
||||
revoked_utc = NULL
|
||||
WHERE key_id = $key_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = newSecretHash;
|
||||
|
||||
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
DELETE FROM api_keys
|
||||
WHERE key_id = $key_id AND revoked_utc IS NOT NULL;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
|
||||
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
|
||||
// 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
|
||||
FROM api_keys
|
||||
ORDER BY created_utc DESC, key_id DESC;
|
||||
""";
|
||||
|
||||
List<ApiKeyListItem> items = [];
|
||||
|
||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(new ApiKeyListItem(
|
||||
KeyId: reader.GetString(0),
|
||||
KeyPrefix: reader.GetString(1),
|
||||
DisplayName: reader.GetString(2),
|
||||
Scopes: ScopeSerializer.Deserialize(reader.GetString(3)),
|
||||
ConstraintsJson: reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(5)),
|
||||
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 6),
|
||||
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7)));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>SQLite-backed, append-only audit store for API-key events.</summary>
|
||||
public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO api_key_audit (key_id, event_type, remote_address, created_utc, details)
|
||||
VALUES ($key_id, $event_type, $remote_address, $created_utc, $details);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", (object?)entry.KeyId ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$event_type", entry.EventType);
|
||||
command.Parameters.AddWithValue("$remote_address", (object?)entry.RemoteAddress ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$created_utc", entry.CreatedUtc.ToString("O"));
|
||||
command.Parameters.AddWithValue("$details", (object?)entry.Details ?? DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
if (limit <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT key_id, event_type, remote_address, created_utc, details
|
||||
FROM api_key_audit
|
||||
ORDER BY audit_id DESC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
List<ApiKeyAuditEntry> entries = [];
|
||||
|
||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
entries.Add(new ApiKeyAuditEntry(
|
||||
KeyId: reader.IsDBNull(0) ? null : reader.GetString(0),
|
||||
EventType: reader.GetString(1),
|
||||
RemoteAddress: reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(3)),
|
||||
Details: reader.IsDBNull(4) ? null : reader.GetString(4)));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>SQLite-backed read store for API-key records.</summary>
|
||||
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";
|
||||
|
||||
/// <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 />
|
||||
public async Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET last_used_utc = $last_used_utc
|
||||
WHERE key_id = $key_id AND revoked_utc IS NULL;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.AddWithValue("$last_used_utc", whenUtc.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<ApiKeyRecord?> FindAsync(string keyId, bool requireActive, CancellationToken ct)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = requireActive
|
||||
? $"SELECT {SelectColumns} FROM api_keys WHERE key_id = $key_id AND revoked_utc IS NULL;"
|
||||
: $"SELECT {SelectColumns} FROM api_keys WHERE key_id = $key_id;";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
|
||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReadRecord(reader);
|
||||
}
|
||||
|
||||
internal static ApiKeyRecord ReadRecord(SqliteDataReader 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: SqliteValueParsing.ParseUtc(reader.GetString(6)),
|
||||
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
|
||||
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// Schema constants and table DDL for the API-key SQLite store.
|
||||
/// </summary>
|
||||
public static class SqliteAuthSchema
|
||||
{
|
||||
/// <summary>The schema version this build creates and supports.</summary>
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
|
||||
public const string SchemaVersionTable = "schema_version";
|
||||
|
||||
/// <summary>Name of the table storing API-key records.</summary>
|
||||
public const string ApiKeysTable = "api_keys";
|
||||
|
||||
/// <summary>Name of the append-only audit table.</summary>
|
||||
public const string ApiKeyAuditTable = "api_key_audit";
|
||||
|
||||
/// <summary>DDL creating the single-row schema-version table.</summary>
|
||||
public const string CreateSchemaVersionTable = """
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
version INTEGER NOT NULL,
|
||||
applied_utc TEXT NOT NULL
|
||||
);
|
||||
""";
|
||||
|
||||
/// <summary>DDL creating the API-key record table.</summary>
|
||||
public const string CreateApiKeysTable = """
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
""";
|
||||
|
||||
/// <summary>DDL creating the append-only audit table.</summary>
|
||||
public const string CreateApiKeyAuditTable = """
|
||||
CREATE TABLE IF NOT EXISTS api_key_audit (
|
||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key_id TEXT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
remote_address TEXT NULL,
|
||||
created_utc TEXT NOT NULL,
|
||||
details TEXT NULL
|
||||
);
|
||||
""";
|
||||
|
||||
/// <summary>DDL creating supporting indexes (idempotent).</summary>
|
||||
public const string CreateIndexes = """
|
||||
CREATE INDEX IF NOT EXISTS ix_api_keys_revoked_utc
|
||||
ON api_keys (revoked_utc);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_api_key_audit_key_id_created_utc
|
||||
ON api_key_audit (key_id, created_utc);
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>Thrown when the auth store cannot be migrated to the supported schema.</summary>
|
||||
public sealed class AuthStoreMigrationException(string message) : InvalidOperationException(message);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the API-key store schema and records the applied version. Idempotent: it
|
||||
/// is safe to run repeatedly. Refuses to run against a database whose on-disk version
|
||||
/// is newer than this build supports.
|
||||
/// </summary>
|
||||
public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory)
|
||||
{
|
||||
/// <summary>Applies the schema migration to the auth store.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <exception cref="AuthStoreMigrationException">
|
||||
/// The on-disk schema version is newer than <see cref="SqliteAuthSchema.CurrentVersion"/>.
|
||||
/// </exception>
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteTransaction transaction =
|
||||
(SqliteTransaction)await connection.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
int existingVersion =
|
||||
await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (existingVersion > SqliteAuthSchema.CurrentVersion)
|
||||
{
|
||||
throw new AuthStoreMigrationException(
|
||||
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
|
||||
}
|
||||
|
||||
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<int> ReadExistingSchemaVersionAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteCommand tableExistsCommand = connection.CreateCommand();
|
||||
tableExistsCommand.Transaction = transaction;
|
||||
tableExistsCommand.CommandText = """
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = $table_name;
|
||||
""";
|
||||
tableExistsCommand.Parameters.AddWithValue("$table_name", SqliteAuthSchema.SchemaVersionTable);
|
||||
|
||||
long tableCount =
|
||||
(long)(await tableExistsCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? 0L);
|
||||
|
||||
if (tableCount == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using SqliteCommand versionCommand = connection.CreateCommand();
|
||||
versionCommand.Transaction = transaction;
|
||||
versionCommand.CommandText = """
|
||||
SELECT version
|
||||
FROM schema_version
|
||||
WHERE id = 1;
|
||||
""";
|
||||
|
||||
object? version = await versionCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return version is null || version == DBNull.Value
|
||||
? 0
|
||||
: Convert.ToInt32(version, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task ApplyVersionOneAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
transaction,
|
||||
string.Join(
|
||||
"\n",
|
||||
SqliteAuthSchema.CreateSchemaVersionTable,
|
||||
SqliteAuthSchema.CreateApiKeysTable,
|
||||
SqliteAuthSchema.CreateApiKeyAuditTable,
|
||||
SqliteAuthSchema.CreateIndexes),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task WriteSchemaVersionAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteCommand versionCommand = connection.CreateCommand();
|
||||
versionCommand.Transaction = transaction;
|
||||
versionCommand.CommandText = """
|
||||
INSERT INTO schema_version (id, version, applied_utc)
|
||||
VALUES (1, $version, $applied_utc)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
applied_utc = excluded.applied_utc;
|
||||
""";
|
||||
versionCommand.Parameters.AddWithValue("$version", SqliteAuthSchema.CurrentVersion);
|
||||
versionCommand.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task ExecuteNonQueryAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
string commandText,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = commandText;
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for reading round-trippable timestamps out of the SQLite stores.
|
||||
/// All timestamps are persisted with the round-trip ("O") format, so parsing is centralized
|
||||
/// here to keep the three stores DRY and consistent.
|
||||
/// </summary>
|
||||
internal static class SqliteValueParsing
|
||||
{
|
||||
/// <summary>Parses a round-trip ("O") formatted timestamp written by the stores.</summary>
|
||||
internal static DateTimeOffset ParseUtc(string value) =>
|
||||
DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
|
||||
/// <summary>Reads a nullable round-trip timestamp at <paramref name="ordinal"/>.</summary>
|
||||
internal static DateTimeOffset? ReadNullableUtc(SqliteDataReader reader, int ordinal) =>
|
||||
reader.IsDBNull(ordinal) ? null : ParseUtc(reader.GetString(ordinal));
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.Auth.Abstractions\ZB.MOM.WW.Auth.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<!--
|
||||
Lightweight Microsoft.Extensions.* abstractions back the DI helpers (AddZbApiKeyAuth):
|
||||
IServiceCollection / TryAdd* / Configure (DependencyInjection.Abstractions), IOptions
|
||||
(Options), IConfiguration (Configuration.Abstractions), and IHostedService / AddHostedService
|
||||
(Hosting.Abstractions). These are plain libraries — no FrameworkReference — so an LDAP-only
|
||||
consumer still pays nothing for ApiKeys, while an API-key consumer wires up with one call.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<!-- Supplies the Configure<TOptions>(IConfiguration) binding overload used by AddZbApiKeyAuth. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Auth.ApiKeys</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>SQLite-backed API-key store with pepper-based hashing for the ZB.MOM.WW SCADA family.</Description>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.Auth.ApiKeys.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user