diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs
new file mode 100644
index 0000000..fa85d68
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs
@@ -0,0 +1,36 @@
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+using ZB.MOM.WW.OtOpcUa.Security.Ldap;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
+
+///
+/// Production adapter that bridges OPC UA UserName
+/// tokens to the same the Admin UI cookie/JWT flows use, so a
+/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA)
+/// session identities. Roles flow through unchanged — the data-plane ACL evaluator reads
+/// them off OperationContext.UserIdentity downstream.
+///
+public sealed class LdapOpcUaUserAuthenticator(
+ ILdapAuthService ldap,
+ ILogger logger)
+ : IOpcUaUserAuthenticator
+{
+ public async Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
+ {
+ try
+ {
+ var result = await ldap.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
+ if (!result.Success)
+ {
+ return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials");
+ }
+ return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, result.Roles);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning(ex, "LDAP authentication threw for OPC UA user {User}", username);
+ return OpcUaUserAuthResult.Deny("Authentication backend error");
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
index 5e58088..28acb60 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
@@ -21,6 +22,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
{
private readonly IConfiguration _configuration;
private readonly DeferredAddressSpaceSink _deferredSink;
+ private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
@@ -30,10 +32,12 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
public OtOpcUaServerHostedService(
IConfiguration configuration,
DeferredAddressSpaceSink deferredSink,
+ IOpcUaUserAuthenticator userAuthenticator,
ILoggerFactory loggerFactory)
{
_configuration = configuration;
_deferredSink = deferredSink;
+ _userAuthenticator = userAuthenticator;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger();
}
@@ -44,7 +48,10 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_configuration.GetSection("OpcUa").Bind(options);
_server = new OtOpcUaSdkServer();
- _appHost = new OpcUaApplicationHost(options, _loggerFactory.CreateLogger());
+ _appHost = new OpcUaApplicationHost(
+ options,
+ _loggerFactory.CreateLogger(),
+ _userAuthenticator);
try
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index fcaab4b..e20384f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -12,9 +12,11 @@ using ZB.MOM.WW.OtOpcUa.Host;
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
using ZB.MOM.WW.OtOpcUa.Host.Health;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Security;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
+using ZB.MOM.WW.OtOpcUa.Security.Ldap;
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
@@ -56,6 +58,15 @@ if (hasDriver)
builder.Services.AddSingleton();
builder.Services.AddSingleton(sp =>
sp.GetRequiredService());
+
+ // F13c — bind UserName tokens to the same LDAP backend the Admin cookie/JWT flows use.
+ // ILdapAuthService is registered by AddOtOpcUaAuth on admin nodes; on driver-only nodes
+ // it isn't, so we register the LDAP options + service unconditionally for driver hosts
+ // to keep parity. The LdapAdapter falls back to Deny on any backend error.
+ builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Ldap"));
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+
builder.Services.AddHostedService();
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
index 9ac2847..20978b9 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Configuration;
using Opc.Ua.Server;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -77,16 +78,20 @@ public sealed class OpcUaApplicationHostOptions
public sealed class OpcUaApplicationHost : IAsyncDisposable
{
private readonly OpcUaApplicationHostOptions _options;
+ private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly ILogger _logger;
private ApplicationInstance? _application;
private StandardServer? _server;
+ private ImpersonateEventHandler? _impersonateHandler;
public OpcUaApplicationHost(
OpcUaApplicationHostOptions options,
- ILogger logger)
+ ILogger logger,
+ IOpcUaUserAuthenticator? userAuthenticator = null)
{
_options = options;
_logger = logger;
+ _userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance;
}
public ApplicationInstance? ApplicationInstance => _application;
@@ -106,10 +111,99 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
await _application.Start(server).ConfigureAwait(false);
+ AttachUserAuthenticator();
+
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
_options.PublicHostname, _options.OpcUaPort);
}
+ ///
+ /// Subscribes to after the SDK has its
+ /// SessionManager ready (only after _application.Start). Anonymous tokens
+ /// pass through; UserName tokens hit and, on
+ /// success, attach a with the mapped role-set to the session
+ /// so downstream ACL checks can read it via OperationContext.UserIdentity.
+ ///
+ /// The SDK calls ImpersonateUser synchronously off the session-activation
+ /// thread, so the authenticator's async work is run via GetAwaiter().GetResult().
+ /// LDAP binds typically complete in <100 ms; if a backing store ever gets that slow
+ /// it should not block the OPC UA stack — callers must enforce their own timeouts inside
+ /// .
+ ///
+ private void AttachUserAuthenticator()
+ {
+ var sessionManager = _server?.CurrentInstance?.SessionManager;
+ if (sessionManager is null)
+ {
+ _logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled");
+ return;
+ }
+
+ _impersonateHandler = OnImpersonateUser;
+ sessionManager.ImpersonateUser += _impersonateHandler;
+ }
+
+ private void OnImpersonateUser(Session session, ImpersonateEventArgs args) =>
+ HandleImpersonation(_userAuthenticator, args, _logger);
+
+ ///
+ /// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
+ /// the full SDK. Side-effects are confined to mutating
+ /// and logging.
+ ///
+ internal static void HandleImpersonation(
+ IOpcUaUserAuthenticator authenticator,
+ ImpersonateEventArgs args,
+ ILogger logger)
+ {
+ if (args.NewIdentity is not UserNameIdentityToken token)
+ {
+ // Anonymous + X509 tokens — let the SDK's default validation stand.
+ return;
+ }
+
+ string password;
+ try
+ {
+ password = token.DecryptedPassword ?? string.Empty;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token");
+ args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
+ "UserName token decryption failed");
+ return;
+ }
+
+ OpcUaUserAuthResult result;
+ try
+ {
+ result = authenticator
+ .AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, CancellationToken.None)
+ .GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
+ args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
+ "Authentication failed");
+ return;
+ }
+
+ if (!result.Success)
+ {
+ logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
+ token.UserName, result.Error);
+ args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
+ result.Error ?? "Invalid credentials");
+ return;
+ }
+
+ args.Identity = new UserIdentity(token);
+ logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} ({Roles})",
+ token.UserName, string.Join(",", result.Roles));
+ }
+
///
/// Guarantees the application instance certificate exists in {PkiStoreRoot}/own.
/// The SDK auto-creates a self-signed certificate the first time this is called on a fresh
@@ -253,6 +347,13 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
public ValueTask DisposeAsync()
{
+ if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
+ {
+ try { sessionManager.ImpersonateUser -= _impersonateHandler; }
+ catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); }
+ }
+ _impersonateHandler = null;
+
try { _application?.Stop(); }
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
return ValueTask.CompletedTask;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs
new file mode 100644
index 0000000..67b63b1
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs
@@ -0,0 +1,49 @@
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+
+///
+/// Validates OPC UA UserName tokens. The SDK already decrypts the token (using the server
+/// application cert) and hands the cleartext username + password to this seam. Implementations
+/// decide whether the credentials are valid and what roles to attach for downstream ACL checks.
+///
+/// Production implementation lives in the Host project (wraps ILdapAuthService); the
+/// default rejects every attempt so misconfigured
+/// dev nodes don't silently accept credentials.
+///
+public interface IOpcUaUserAuthenticator
+{
+ ///
+ /// Resolves cleartext UserName credentials against the configured backing store. Must not
+ /// throw — callers turn results into ImpersonateEventArgs.IdentityValidationError
+ /// reject codes, and a thrown exception escapes into the OPC UA SDK's session-activation
+ /// path where it surfaces as a generic BadInternalError.
+ ///
+ Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
+}
+
+/// Outcome of a UserName authentication attempt. populates the session identity's role set.
+public sealed record OpcUaUserAuthResult(
+ bool Success,
+ string? DisplayName,
+ IReadOnlyList Roles,
+ string? Error)
+{
+ public static OpcUaUserAuthResult Allow(string displayName, IReadOnlyList roles) =>
+ new(true, displayName, roles, null);
+
+ public static OpcUaUserAuthResult Deny(string error) =>
+ new(false, null, Array.Empty(), error);
+}
+
+///
+/// Default deny-all authenticator. Wired by OpcUaApplicationHost when no production
+/// authenticator is registered in DI — keeps the server safe-by-default rather than accepting
+/// arbitrary UserName credentials. Production Host DI overrides this with the LDAP adapter.
+///
+public sealed class NullOpcUaUserAuthenticator : IOpcUaUserAuthenticator
+{
+ public static readonly NullOpcUaUserAuthenticator Instance = new();
+ private NullOpcUaUserAuthenticator() { }
+
+ public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
+ Task.FromResult(OpcUaUserAuthResult.Deny("No UserName authenticator is configured on this server."));
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs
new file mode 100644
index 0000000..1750ddd
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs
@@ -0,0 +1,75 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Security.Ldap;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
+
+///
+/// F13c — verifies faithfully translates
+/// outcomes into OpcUaUserAuthResult and turns LDAP
+/// backend exceptions into a denial rather than letting them escape into the SDK.
+///
+public sealed class LdapOpcUaUserAuthenticatorTests
+{
+ [Fact]
+ public async Task Authenticate_LDAP_success_returns_Allow_with_roles()
+ {
+ var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, new[] { "ConfigEditor" }, null));
+ var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger.Instance);
+
+ var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
+
+ result.Success.ShouldBeTrue();
+ result.DisplayName.ShouldBe("Alice");
+ result.Roles.ShouldBe(new[] { "ConfigEditor" });
+ }
+
+ [Fact]
+ public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
+ {
+ var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty(), Array.Empty(), "Invalid username or password"));
+ var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger.Instance);
+
+ var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
+
+ result.Success.ShouldBeFalse();
+ result.Error.ShouldBe("Invalid username or password");
+ }
+
+ [Fact]
+ public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
+ {
+ var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
+ var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger.Instance);
+
+ var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
+
+ result.Success.ShouldBeFalse();
+ result.Error.ShouldNotBeNull();
+ result.Error.ShouldContain("backend");
+ }
+
+ [Fact]
+ public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
+ {
+ var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", Array.Empty(), new[] { "ReadOnly" }, null));
+ var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger.Instance);
+
+ var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
+
+ result.Success.ShouldBeTrue();
+ result.DisplayName.ShouldBe("alice");
+ }
+
+ private sealed class FakeLdap : ILdapAuthService
+ {
+ private readonly Func _handler;
+ public FakeLdap(LdapAuthResult fixed_) => _handler = _ => fixed_;
+ public FakeLdap(Func handler) => _handler = handler;
+
+ public Task AuthenticateAsync(string username, string password, CancellationToken ct = default)
+ => Task.FromResult(_handler(username));
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs
new file mode 100644
index 0000000..aadb589
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs
@@ -0,0 +1,118 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Opc.Ua.Server;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+///
+/// F13c — verifies the impersonation handler routes UserName tokens through
+/// and translates its result into the SDK's
+/// shape (granted identity vs. rejection status).
+///
+public sealed class OpcUaApplicationHostImpersonationTests
+{
+ private static readonly UserTokenPolicy UserNamePolicy = new(UserTokenType.UserName) { PolicyId = "username_basic256sha256" };
+ private static readonly UserTokenPolicy AnonPolicy = new(UserTokenType.Anonymous) { PolicyId = "anonymous" };
+
+ [Fact]
+ public void HandleImpersonation_username_success_sets_identity_and_no_validation_error()
+ {
+ var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = "secret" };
+ var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
+ var authenticator = new RecordingAuthenticator(
+ OpcUaUserAuthResult.Allow("Alice", new[] { "ReadOnly", "WriteOperate" }));
+
+ OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger