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:
dohertj2
2026-06-01 03:59:23 -04:00
commit 37e23cf9f2
73 changed files with 6836 additions and 0 deletions
@@ -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>&lt;prefix&gt;_&lt;keyId&gt;_&lt;secret&gt;</c> EXACTLY ONCE.
/// </summary>
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
public 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>&lt;tokenPrefix&gt;_&lt;keyId&gt;_&lt;secret&gt;</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);
}
@@ -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;
}
@@ -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;
}
}
@@ -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>