diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs
new file mode 100644
index 00000000..0fa648d6
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs
@@ -0,0 +1,67 @@
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
+
+///
+/// Derives the interim server-wide role set for an authenticated principal from its
+/// grants (#520, stage 2).
+///
+///
+///
+/// Why this exists at all. The two live authorization gates —
+/// on Value writes and
+/// on the Part 9 alarm methods — check a server-wide
+/// role set on the session identity. Replacing LDAP with API keys therefore has to keep
+/// producing that role set, or the cutover would lock every client out of writes and alarm acks
+/// long before per-node ACL evaluation lands in stage 5.
+///
+///
+/// ⚠️ Only grants confer a role. This is deliberate,
+/// and it under-grants. The obvious implementation — union the permission flags across all of
+/// a principal's grants — would let a grant authored narrowly ("WriteOperate on Equipment X")
+/// confer the server-wide WriteOperate role, because nothing here can enforce scope yet. That
+/// fails in the dangerous direction: the operator's narrow intent silently becomes broad
+/// authority. Restricting to cluster-scoped grants means a narrow grant does nothing until
+/// stage 5 can honour its scope, which is no worse than today (where every ACL row does
+/// nothing) and never exceeds what was authored.
+///
+///
+/// Scope-narrower-than-cluster grants are not an error and are not logged as one — they are
+/// simply inert until enforcement lands. The AdminUI banner is where an operator is told that.
+///
+///
+public static class ApiKeyInterimRoleMapper
+{
+ ///
+ /// Maps a principal's cluster-scoped grants to the server-wide data-plane roles.
+ ///
+ /// Candidate ACL rows. Rows for other principals are ignored.
+ /// The authenticated principal.
+ /// The distinct role names to attach to the session identity; never null.
+ public static IReadOnlyList Map(IEnumerable grants, string principal)
+ {
+ ArgumentNullException.ThrowIfNull(grants);
+
+ if (string.IsNullOrWhiteSpace(principal)) return Array.Empty();
+
+ var effective = NodePermissions.None;
+ foreach (var grant in grants)
+ {
+ if (grant.ScopeKind != NodeAclScopeKind.Cluster) continue;
+ if (!string.Equals(grant.Principal, principal, StringComparison.OrdinalIgnoreCase)) continue;
+ effective |= grant.PermissionFlags;
+ }
+
+ var roles = new List(2);
+ if (effective.HasFlag(NodePermissions.WriteOperate)) roles.Add(OpcUaDataPlaneRoles.WriteOperate);
+
+ // AlarmAcknowledge is the flag an operator authors; AlarmAck is the role the Part 9 method gates
+ // check. The gate covers acknowledge/confirm/shelve as one role, so Confirm/Shelve do not map
+ // separately — mapping them would imply a granularity the gate does not have.
+ if (effective.HasFlag(NodePermissions.AlarmAcknowledge)) roles.Add(OpcUaDataPlaneRoles.AlarmAck);
+
+ return roles;
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs
new file mode 100644
index 00000000..0415cc16
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs
@@ -0,0 +1,131 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
+
+///
+/// Production for the machine-to-machine data plane (#520):
+/// the client presents the static username apikey with an
+/// otua_<keyId>_<secret> credential as the password. Replaces the LDAP adapter.
+///
+///
+///
+/// This runs inline on session activation. The SDK raises ImpersonateUser
+/// synchronously while holding a SessionManager-wide event lock, so a slow authenticator does not
+/// stall one request — it serializes every session activation server-wide. That is why
+/// verification is a single indexed lookup plus one HMAC, and why there is no directory call,
+/// no retry, and no network I/O on this path at all. It is the main practical advantage of keys
+/// over the LDAP bind this replaces.
+///
+///
+/// Fail closed, and say little. Every rejection returns the same opaque message. The
+/// reason is logged server-side but never returned, so a caller cannot distinguish "no such key"
+/// from "wrong secret" from "key disabled" and use the server as an oracle.
+///
+///
+public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
+{
+ ///
+ /// Returned for every failure mode, deliberately uninformative. Public so a test can assert that
+ /// the failure modes really are indistinguishable, rather than restating the literal and passing
+ /// even if the implementation started differentiating them.
+ ///
+ public const string DenyMessage = "Invalid API key.";
+
+ private readonly IApiKeyStore _store;
+ private readonly IOptions _options;
+ private readonly ILogger _log;
+ private readonly TimeProvider _time;
+
+ /// Initializes a new instance of the class.
+ /// The API key source.
+ /// The Security:ApiKeys options.
+ /// The logger.
+ /// Time provider, injected so expiry is testable.
+ public ApiKeyOpcUaUserAuthenticator(
+ IApiKeyStore store,
+ IOptions options,
+ ILogger log,
+ TimeProvider? time = null)
+ {
+ _store = store;
+ _options = options;
+ _log = log;
+ _time = time ?? TimeProvider.System;
+ }
+
+ ///
+ public async Task AuthenticateUserNameAsync(
+ string username, string password, CancellationToken ct)
+ {
+ var opts = _options.Value;
+
+ // The username carries no information, but check it rather than ignore it: a client sending the
+ // wrong username gets an explicit denial it can diagnose, instead of "any username works".
+ if (!string.Equals(username, opts.Username, StringComparison.Ordinal))
+ {
+ _log.LogInformation(
+ "ApiKeyAuth: denied — username {Presented} is not the configured {Expected}",
+ username, opts.Username);
+ return OpcUaUserAuthResult.Deny(DenyMessage);
+ }
+
+ if (!ApiKeySecret.TryParse(password, out var apiKeyId, out var secret))
+ {
+ _log.LogInformation("ApiKeyAuth: denied — credential is not a well-formed otua_ key");
+ return OpcUaUserAuthResult.Deny(DenyMessage);
+ }
+
+ var now = _time.GetUtcNow().UtcDateTime;
+
+ ApiKeyLookup? lookup;
+ try
+ {
+ lookup = await _store.FindAsync(apiKeyId, now, ct).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // The contract forbids throwing: an exception here escapes into the SDK's session-activation
+ // path and surfaces as BadInternalError. A store outage denies, it does not crash.
+ _log.LogError(ex, "ApiKeyAuth: key store threw for key id {ApiKeyId}", apiKeyId);
+ return OpcUaUserAuthResult.Deny(DenyMessage);
+ }
+
+ if (lookup is null)
+ {
+ _log.LogInformation("ApiKeyAuth: denied — no enabled, unexpired key with id {ApiKeyId}", apiKeyId);
+ return OpcUaUserAuthResult.Deny(DenyMessage);
+ }
+
+ bool verified;
+ try
+ {
+ verified = ApiKeySecret.Verify(secret, opts.Pepper, lookup.Key.SecretHash);
+ }
+ catch (ArgumentException ex)
+ {
+ // Thrown only when the pepper is unset. This is a deployment error, not a bad credential —
+ // log it loudly, because otherwise it presents as "every key is rejected" with no clue why.
+ _log.LogError(ex,
+ "ApiKeyAuth: {Section}:Pepper is not configured — every key will be rejected until it is",
+ ApiKeyOptions.SectionName);
+ return OpcUaUserAuthResult.Deny(DenyMessage);
+ }
+
+ if (!verified)
+ {
+ _log.LogInformation("ApiKeyAuth: denied — secret mismatch for key id {ApiKeyId}", apiKeyId);
+ return OpcUaUserAuthResult.Deny(DenyMessage);
+ }
+
+ var roles = ApiKeyInterimRoleMapper.Map(lookup.Grants, lookup.Key.Principal);
+
+ _log.LogInformation(
+ "ApiKeyAuth: granted key {Name} ({ApiKeyId}) as principal {Principal} with roles [{Roles}]",
+ lookup.Key.Name, apiKeyId, lookup.Key.Principal, string.Join(",", roles));
+
+ return OpcUaUserAuthResult.Allow(lookup.Key.Principal, roles);
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs
new file mode 100644
index 00000000..d6e384bf
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs
@@ -0,0 +1,30 @@
+namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
+
+///
+/// Security:ApiKeys — OPC UA data-plane API key settings (#520). The data plane is
+/// machine-to-machine, so a consuming system presents a key rather than an LDAP user.
+///
+public sealed class ApiKeyOptions
+{
+ /// The configuration section name.
+ public const string SectionName = "Security:ApiKeys";
+
+ ///
+ /// The static username every key-bearing client presents. The username carries no information —
+ /// the key identifies the principal — but OPC UA's UserName token requires one, so it is fixed
+ /// and checked rather than ignored, which keeps a typo an explicit denial instead of a silent
+ /// "any username works".
+ ///
+ public string Username { get; set; } = "apikey";
+
+ ///
+ /// Server-side pepper for the stored key hashes. Supply via Security__ApiKeys__Pepper or a
+ /// ${secret:} reference — never commit it. Rotating it invalidates every existing key.
+ ///
+ ///
+ /// Deliberately has no default. An empty pepper still hashes "successfully" and would produce a
+ /// working-looking deployment with none of the protection it believes it has, so
+ /// ApiKeySecret.ComputeHash throws on one and this stays empty to make that fire early.
+ ///
+ public string Pepper { get; set; } = string.Empty;
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs
new file mode 100644
index 00000000..f268f6f6
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs
@@ -0,0 +1,57 @@
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
+
+///
+/// reading the config database directly (#520, stage 2). Usable on a node
+/// that has a ConfigDb — i.e. one carrying the admin role, including a fused admin+driver node.
+///
+///
+/// ⚠️ A driver-only node has no ConfigDb since per-cluster mesh Phase 4, so this store cannot
+/// be registered there and such a node has no key source until the artifact-backed store lands in
+/// stage 4. Until then a driver-only node authenticates no UserName tokens at all — it falls to
+/// , which denies every attempt. That is
+/// the safe direction, and anonymous sessions (still permitted, and still unrestricted until
+/// enforcement lands in stages 5–6) are unaffected.
+///
+public sealed class ConfigDbApiKeyStore : IApiKeyStore
+{
+ private readonly IDbContextFactory _factory;
+
+ /// Initializes a new instance of the class.
+ /// The config DB context factory.
+ public ConfigDbApiKeyStore(IDbContextFactory factory) => _factory = factory;
+
+ ///
+ public async Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
+ {
+ await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
+
+ // Enabled + expiry are filtered in SQL rather than in memory so a disabled key never
+ // materialises into a process where something might later treat it as a match.
+ var key = await db.ApiKeys
+ .AsNoTracking()
+ .SingleOrDefaultAsync(
+ k => k.ApiKeyId == apiKeyId
+ && k.Enabled
+ && (k.ExpiresUtc == null || k.ExpiresUtc > utcNow),
+ ct)
+ .ConfigureAwait(false);
+
+ if (key is null) return null;
+
+ // Only cluster-scoped rows can confer an interim role (see ApiKeyInterimRoleMapper), so only
+ // those are fetched. Stage 5 widens this to the full grant set for per-node evaluation.
+ var grants = await db.NodeAcls
+ .AsNoTracking()
+ .Where(a => a.Principal == key.Principal
+ && a.ScopeKind == NodeAclScopeKind.Cluster)
+ .ToListAsync(ct)
+ .ConfigureAwait(false);
+
+ return new ApiKeyLookup(key, grants);
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs
new file mode 100644
index 00000000..67cfb2c1
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs
@@ -0,0 +1,30 @@
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
+
+///
+/// Source of API keys and their ACL grants for OPC UA session activation (#520).
+///
+///
+/// A seam rather than a direct ConfigDb read because the two sources differ by node role. A fused
+/// admin+driver node reads the config database directly; a driver-only node has no ConfigDb
+/// at all since per-cluster mesh Phase 4, so its keys must come out of the deployment artifact.
+/// Stage 2 ships the ConfigDb implementation; the artifact-backed one lands in stage 4.
+///
+public interface IApiKeyStore
+{
+ ///
+ /// Looks up an enabled, unexpired key by its public id, together with the ACL grants naming that
+ /// key's principal.
+ ///
+ /// The public key id parsed from the presented credential.
+ /// Current UTC time, for the expiry check.
+ /// The cancellation token.
+ /// The key and its principal's grants, or null when no usable key matches.
+ Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct);
+}
+
+/// An API key resolved for authentication, with the ACL grants naming its principal.
+/// The matched key row.
+/// ACL rows whose principal matches .
+public sealed record ApiKeyLookup(ApiKey Key, IReadOnlyList Grants);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 51380bb8..2efba25b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -339,7 +339,28 @@ if (hasDriver)
builder.Services.TryAddSingleton();
builder.Services.TryAddScoped();
builder.Services.TryAddScoped, OtOpcUaGroupRoleMapper>();
- builder.Services.AddSingleton();
+
+ // #520 stage 2 — the OPC UA DATA PLANE is machine-to-machine, so its identity is an API key
+ // (static username "apikey", the otua_ credential as the password), not an LDAP user. The LDAP
+ // registrations above are deliberately retained: they back ADMINUI LOGIN, which keeps LDAP.
+ // Only IOpcUaUserAuthenticator moves.
+ builder.Services.Configure(builder.Configuration.GetSection(ApiKeyOptions.SectionName));
+ if (hasAdmin)
+ {
+ // ConfigDbApiKeyStore needs a ConfigDb, and Phase 4 gated AddOtOpcUaConfigDb on hasAdmin.
+ // A driver-only node therefore has no key source until the artifact-backed store lands in
+ // stage 4; it falls through to NullOpcUaUserAuthenticator, which denies every UserName token.
+ // Safe direction, and anonymous sessions are unaffected.
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ }
+ else
+ {
+ // MUST be registered explicitly, not left absent: OtOpcUaServerHostedService takes
+ // IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so an unregistered seam is not a
+ // quiet fallback to deny-all — it fails DI and the node does not start at all.
+ builder.Services.AddSingleton(NullOpcUaUserAuthenticator.Instance);
+ }
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
// and let OtOpcUaServerHostedService consume the validated IOptions instance rather than
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs
new file mode 100644
index 00000000..767ebacc
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs
@@ -0,0 +1,221 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
+
+///
+/// Covers the #520 stage-2 identity cutover: the API-key authenticator and the interim role
+/// mapping it feeds the two live authorization gates from.
+///
+public sealed class ApiKeyAuthenticationTests
+{
+ private const string Pepper = "unit-test-pepper";
+ private const string Principal = "scadabridge-dcl";
+
+ // ---------- interim role mapping ----------
+
+ [Fact]
+ public void A_cluster_scoped_write_grant_confers_the_write_role()
+ {
+ var roles = ApiKeyInterimRoleMapper.Map(
+ [Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal);
+
+ roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
+ }
+
+ [Fact]
+ public void A_narrower_scoped_write_grant_confers_nothing_yet()
+ {
+ // THE load-bearing assertion of this stage. Nothing in the pipeline can honour a scope until
+ // stage 5, so honouring the FLAG alone would turn "WriteOperate on Equipment X" into
+ // server-wide write authority — the operator's narrow intent silently becoming broad. Under-
+ // granting is the only safe reading while scope is unenforceable.
+ foreach (var narrower in new[]
+ {
+ NodeAclScopeKind.Equipment, NodeAclScopeKind.UnsArea,
+ NodeAclScopeKind.UnsLine, NodeAclScopeKind.Namespace,
+ })
+ {
+ var roles = ApiKeyInterimRoleMapper.Map(
+ [Grant(narrower, NodePermissions.WriteOperate)], Principal);
+
+ roles.ShouldBeEmpty($"a {narrower}-scoped grant must not confer a server-wide role");
+ }
+ }
+
+ [Fact]
+ public void A_grant_for_a_different_principal_is_ignored()
+ {
+ var other = Grant(NodeAclScopeKind.Cluster, NodePermissions.Admin);
+ other.Principal = "someone-else";
+
+ ApiKeyInterimRoleMapper.Map([other], Principal).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Alarm_acknowledge_maps_to_the_alarm_role_and_read_only_maps_to_nothing()
+ {
+ ApiKeyInterimRoleMapper
+ .Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge)], Principal)
+ .ShouldContain(OpcUaDataPlaneRoles.AlarmAck);
+
+ // ReadOnly carries no gated permission — reads are ungated today, so it must confer no role
+ // rather than quietly granting one.
+ ApiKeyInterimRoleMapper
+ .Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.ReadOnly)], Principal)
+ .ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Flags_union_across_several_cluster_grants()
+ {
+ var roles = ApiKeyInterimRoleMapper.Map(
+ [
+ Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
+ Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge),
+ ], Principal);
+
+ roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
+ roles.ShouldContain(OpcUaDataPlaneRoles.AlarmAck);
+ }
+
+ [Fact]
+ public void Principal_matching_is_case_insensitive_but_not_prefix_based()
+ {
+ ApiKeyInterimRoleMapper
+ .Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal.ToUpperInvariant())
+ .ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
+
+ // A principal that merely starts with the granted name must not inherit it.
+ ApiKeyInterimRoleMapper
+ .Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal + "-readonly")
+ .ShouldBeEmpty();
+ }
+
+ // ---------- authenticator ----------
+
+ [Fact]
+ public async Task A_valid_key_authenticates_as_its_principal()
+ {
+ var (presented, store) = SetUp(NodePermissions.WriteOperate, NodeAclScopeKind.Cluster);
+
+ var result = await Auth(store).AuthenticateUserNameAsync("apikey", presented, default);
+
+ result.Success.ShouldBeTrue();
+ result.DisplayName.ShouldBe(Principal);
+ result.Roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate);
+ }
+
+ [Fact]
+ public async Task The_wrong_username_is_denied()
+ {
+ var (presented, store) = SetUp();
+ var result = await Auth(store).AuthenticateUserNameAsync("alice", presented, default);
+ result.Success.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task A_wrong_secret_a_missing_key_and_a_malformed_credential_are_all_denied_identically()
+ {
+ var (presented, store) = SetUp();
+ var auth = Auth(store);
+
+ var wrongSecret = await auth.AuthenticateUserNameAsync("apikey", $"otua_{store.KeyId}_wrong", default);
+ var noSuchKey = await auth.AuthenticateUserNameAsync("apikey", "otua_ffffffff_whatever", default);
+ var malformed = await auth.AuthenticateUserNameAsync("apikey", "not-a-key", default);
+ var empty = await auth.AuthenticateUserNameAsync("apikey", "", default);
+
+ foreach (var r in new[] { wrongSecret, noSuchKey, malformed, empty })
+ {
+ r.Success.ShouldBeFalse();
+ // Identical message for every failure mode: the server must not be usable as an oracle for
+ // which half of the credential was wrong, or whether a key id exists at all.
+ r.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
+ }
+
+ // ...and the valid one still works, so the above is not passing because everything is denied.
+ (await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task A_store_that_throws_denies_rather_than_propagating()
+ {
+ // The interface contract forbids throwing — an exception escapes into the SDK session-activation
+ // path and surfaces as BadInternalError instead of a clean rejection.
+ var store = new ThrowingApiKeyStore();
+ var result = await Auth(store).AuthenticateUserNameAsync("apikey", "otua_aabbccdd_secret", default);
+
+ result.Success.ShouldBeFalse();
+ result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage);
+ }
+
+ [Fact]
+ public async Task An_unset_pepper_denies_rather_than_authenticating_everything()
+ {
+ var (presented, store) = SetUp();
+ var auth = new ApiKeyOpcUaUserAuthenticator(
+ store,
+ Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = "" }),
+ NullLogger.Instance);
+
+ (await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeFalse();
+ }
+
+ // ---------- helpers ----------
+
+ private static NodeAcl Grant(NodeAclScopeKind scope, NodePermissions flags) => new()
+ {
+ NodeAclId = $"ACL-{scope}-{flags}",
+ ClusterId = "MAIN",
+ Principal = Principal,
+ ScopeKind = scope,
+ ScopeId = scope == NodeAclScopeKind.Cluster ? null : "SOME-SCOPE",
+ PermissionFlags = flags,
+ };
+
+ private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new(
+ store,
+ Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper }),
+ NullLogger.Instance);
+
+ private static (string Presented, FakeApiKeyStore Store) SetUp(
+ NodePermissions flags = NodePermissions.None,
+ NodeAclScopeKind scope = NodeAclScopeKind.Cluster)
+ {
+ var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
+ var key = new ApiKey
+ {
+ ApiKeyId = apiKeyId,
+ Name = "test key",
+ SecretHash = hash,
+ Principal = Principal,
+ Enabled = true,
+ CreatedUtc = DateTime.UtcNow,
+ };
+ var grants = flags == NodePermissions.None ? [] : new List { Grant(scope, flags) };
+ return (presented, new FakeApiKeyStore(key, grants));
+ }
+
+ private sealed class FakeApiKeyStore(ApiKey key, IReadOnlyList grants) : IApiKeyStore
+ {
+ public string KeyId => key.ApiKeyId;
+
+ public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
+ Task.FromResult(string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal)
+ ? new ApiKeyLookup(key, grants)
+ : null);
+ }
+
+ private sealed class ThrowingApiKeyStore : IApiKeyStore
+ {
+ public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) =>
+ throw new InvalidOperationException("store is down");
+ }
+}