From 21eac21409e9605a4dcb64c6cbce6bf01bc966c0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 10:21:37 -0400 Subject: [PATCH] feat(opcua,host): F13c LDAP-bound UserName validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds IOpcUaUserAuthenticator seam in OpcUaServer.Security with a deny-all NullOpcUaUserAuthenticator default. OpcUaApplicationHost subscribes to SessionManager.ImpersonateUser after _application.Start so UserName tokens flow through the authenticator and either attach a UserIdentity to the session (Allow) or set IdentityValidationError = BadIdentityTokenRejected (Deny / authenticator exception). Anonymous + X509 tokens fall through to SDK defaults. LdapOpcUaUserAuthenticator (Host project) bridges to the same ILdapAuthService that AddOtOpcUaAuth uses for Admin cookies / JWT, so a single LDAP source-of-truth governs both Admin control plane and OPC UA data plane. Program.cs registers LdapOptions + LdapAuthService + IOpcUaUserAuthenticator on driver-role hosts; admin-only nodes are unchanged. OtOpcUaServerHostedService threads the resolved authenticator into OpcUaApplicationHost so the seam respects Host DI. 10 new tests: 6 in OpcUaServer.Tests cover the pure HandleImpersonation static method (success / denial / anonymous fallthrough / authenticator- throw / null-username / Null authenticator); 4 in Host.IntegrationTests cover the LdapOpcUaUserAuthenticator adapter (LDAP allow → Allow with roles, LDAP deny → Deny, exception → backend-error denial, display-name fallback). OpcUaServer suite is 40 / 40 green. Closes #104. Unblocks Task 60 (dual-endpoint + ServiceLevel tests) once #81 residual lands. --- .../OpcUa/LdapOpcUaUserAuthenticator.cs | 36 ++++++ .../OpcUa/OtOpcUaServerHostedService.cs | 9 +- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 11 ++ .../OpcUaApplicationHost.cs | 103 ++++++++++++++- .../Security/IOpcUaUserAuthenticator.cs | 49 ++++++++ .../LdapOpcUaUserAuthenticatorTests.cs | 75 +++++++++++ .../OpcUaApplicationHostImpersonationTests.cs | 118 ++++++++++++++++++ 7 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs 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.Instance); + + args.Identity.ShouldNotBeNull(); + args.IdentityValidationError.ShouldBeNull(); + authenticator.LastUsername.ShouldBe("alice"); + authenticator.LastPassword.ShouldBe("secret"); + } + + [Fact] + public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity() + { + var token = new UserNameIdentityToken { UserName = "mallory", DecryptedPassword = "wrong" }; + var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); + var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("Invalid credentials")); + + OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); + + args.Identity.ShouldBeNull(); + args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected); + args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials"); + } + + [Fact] + public void HandleImpersonation_anonymous_token_falls_through_to_sdk_default() + { + var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription()); + var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty())); + + OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); + + // Handler leaves anonymous tokens untouched — no identity, no validation error. + args.Identity.ShouldBeNull(); + args.IdentityValidationError.ShouldBeNull(); + authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the authenticator"); + } + + [Fact] + public void HandleImpersonation_authenticator_throwing_results_in_rejection() + { + var token = new UserNameIdentityToken { UserName = "bob", DecryptedPassword = "x" }; + var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); + var authenticator = new ThrowingAuthenticator(new InvalidOperationException("LDAP unreachable")); + + OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); + + args.Identity.ShouldBeNull(); + args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected); + } + + [Fact] + public void HandleImpersonation_null_username_treated_as_empty_string() + { + var token = new UserNameIdentityToken { UserName = null, DecryptedPassword = "abc" }; + var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription()); + var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("no user")); + + OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); + + authenticator.LastUsername.ShouldBe(string.Empty); + } + + [Fact] + public async Task NullOpcUaUserAuthenticator_always_denies() + { + var result = await NullOpcUaUserAuthenticator.Instance + .AuthenticateUserNameAsync("anyone", "anything", CancellationToken.None); + + result.Success.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Roles.ShouldBeEmpty(); + } + + private sealed class RecordingAuthenticator(OpcUaUserAuthResult outcome) : IOpcUaUserAuthenticator + { + public string? LastUsername { get; private set; } + public string? LastPassword { get; private set; } + + public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) + { + LastUsername = username; + LastPassword = password; + return Task.FromResult(outcome); + } + } + + private sealed class ThrowingAuthenticator(Exception ex) : IOpcUaUserAuthenticator + { + public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) + => Task.FromException(ex); + } +}