diff --git a/ZB.MOM.WW.Auth/Directory.Packages.props b/ZB.MOM.WW.Auth/Directory.Packages.props
index 1e7fff5..3b06c64 100644
--- a/ZB.MOM.WW.Auth/Directory.Packages.props
+++ b/ZB.MOM.WW.Auth/Directory.Packages.props
@@ -15,6 +15,14 @@
+
+
+
+
diff --git a/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx b/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx
index e5f051b..5cb6064 100644
--- a/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx
+++ b/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx
@@ -2,11 +2,13 @@
+
+
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/AuthSqlServerConnectionFactory.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/AuthSqlServerConnectionFactory.cs
new file mode 100644
index 0000000..c0cdca5
--- /dev/null
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/AuthSqlServerConnectionFactory.cs
@@ -0,0 +1,64 @@
+using Microsoft.Data.SqlClient;
+
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
+
+///
+/// Opens connections to the shared SQL Server API-key database and carries the settings every
+/// command needs (schema name, command timeout).
+///
+/// The SQL Server API-key store options; validated in the constructor.
+/// is not usable.
+public sealed class AuthSqlServerConnectionFactory(SqlServerApiKeyOptions options)
+{
+ private readonly SqlServerApiKeyOptions _options = Validate(options);
+
+ private static SqlServerApiKeyOptions Validate(SqlServerApiKeyOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ options.Validate();
+ return options;
+ }
+
+ /// The schema that owns the three API-key tables.
+ public string Schema => _options.Schema;
+
+ /// Command timeout, in whole seconds, for every command issued against this store.
+ public int CommandTimeoutSeconds => Math.Max(1, (int)_options.CommandTimeout.TotalSeconds);
+
+ /// Opens a connection to the shared API-key database.
+ /// A token to cancel the connect.
+ /// An opened connection. The caller owns disposal.
+ public async Task OpenConnectionAsync(CancellationToken cancellationToken)
+ {
+ var connection = new SqlConnection(_options.ConnectionString);
+ try
+ {
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+ return connection;
+ }
+ catch
+ {
+ await connection.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+ }
+
+ ///
+ /// Creates a command on with the configured timeout applied.
+ ///
+ /// The open connection.
+ /// The command text.
+ /// The ambient transaction, if any.
+ /// The configured command. The caller owns disposal.
+ public SqlCommand CreateCommand(
+ SqlConnection connection, string commandText, SqlTransaction? transaction = null)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+
+ SqlCommand command = connection.CreateCommand();
+ command.CommandText = commandText;
+ command.CommandTimeout = CommandTimeoutSeconds;
+ command.Transaction = transaction;
+ return command;
+ }
+}
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs
new file mode 100644
index 0000000..a3b4006
--- /dev/null
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs
@@ -0,0 +1,36 @@
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
+
+///
+/// Options for the shared SQL Server API-key store (section supplied by the host, e.g.
+/// ScadaBridge:InboundApi:ApiKeyStore:SqlServer).
+///
+public sealed class SqlServerApiKeyOptions
+{
+ ///
+ /// Full SQL Server connection string. Required. Never a ${secret:} reference on hosts
+ /// whose secret store is itself SQL-backed.
+ ///
+ public string ConnectionString { get; set; } = string.Empty;
+
+ /// Schema that owns the three tables. Letters, digits and underscore only (it is interpolated into DDL).
+ public string Schema { get; set; } = "zbauth";
+
+ ///
+ /// Command timeout applied to every store operation. Defaults to 30 seconds — long enough for
+ /// a contended shared database, short enough that a hung server surfaces as an error rather
+ /// than stalling a resolve indefinitely.
+ ///
+ public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
+
+ /// Fails fast on a blank connection string, an unsafe schema identifier, or a non-positive command timeout.
+ /// The options are not usable.
+ public void Validate()
+ {
+ if (string.IsNullOrWhiteSpace(ConnectionString))
+ throw new InvalidOperationException("SQL Server API-key store requires a ConnectionString.");
+ if (string.IsNullOrEmpty(Schema) || !Schema.All(c => char.IsAsciiLetterOrDigit(c) || c == '_') || Schema.Length > 64)
+ throw new InvalidOperationException($"SQL Server API-key store schema name '{Schema}' is not a safe identifier.");
+ if (CommandTimeout <= TimeSpan.Zero)
+ throw new InvalidOperationException($"SQL Server API-key store CommandTimeout must be positive (was {CommandTimeout}).");
+ }
+}
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/ZB.MOM.WW.Auth.ApiKeys.SqlServer.csproj b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/ZB.MOM.WW.Auth.ApiKeys.SqlServer.csproj
new file mode 100644
index 0000000..f073efb
--- /dev/null
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/ZB.MOM.WW.Auth.ApiKeys.SqlServer.csproj
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+ true
+ ZB.MOM.WW.Auth.ApiKeys.SqlServer
+ ZB.MOM.WW
+ SQL Server API-key store for the ZB.MOM.WW SCADA family — one shared row set for clustered hosts (central pairs).
+ https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth
+ https://gitea.dohertylan.com/dohertj2/zb-mom-ww-auth
+
+
+
+
+
+
+
diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj
index 72aeab0..be93eb2 100644
--- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj
+++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj
@@ -38,6 +38,9 @@
+
+
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyOptionsTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyOptionsTests.cs
new file mode 100644
index 0000000..568e627
--- /dev/null
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyOptionsTests.cs
@@ -0,0 +1,16 @@
+namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests;
+
+public sealed class SqlServerApiKeyOptionsTests
+{
+ [Fact]
+ public void Validate_RejectsMissingConnectionString() =>
+ Assert.Throws(() => new SqlServerApiKeyOptions { ConnectionString = " " }.Validate());
+
+ [Fact]
+ public void Validate_RejectsUnsafeSchemaName() =>
+ Assert.Throws(() => new SqlServerApiKeyOptions { ConnectionString = "Server=x", Schema = "zb;auth" }.Validate());
+
+ [Fact]
+ public void Schema_DefaultsToZbauth() =>
+ Assert.Equal("zbauth", new SqlServerApiKeyOptions().Schema);
+}
diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.csproj b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.csproj
new file mode 100644
index 0000000..9e9e6ae
--- /dev/null
+++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+