Merge pull request 'Phase 3 PR 19 — LDAP user identity + Basic256Sha256 security profile' (#18) from phase-3-pr19-ldap-security into v2

This commit was merged in pull request #18.
This commit is contained in:
2026-04-18 11:36:18 -04:00
10 changed files with 477 additions and 21 deletions

View File

@@ -3,6 +3,7 @@ using Opc.Ua;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
@@ -18,6 +19,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
{
private readonly OpcUaServerOptions _options;
private readonly DriverHost _driverHost;
private readonly IUserAuthenticator _authenticator;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application;
@@ -25,10 +27,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
private bool _disposed;
public OpcUaApplicationHost(OpcUaServerOptions options, DriverHost driverHost,
ILoggerFactory loggerFactory, ILogger<OpcUaApplicationHost> logger)
IUserAuthenticator authenticator, ILoggerFactory loggerFactory, ILogger<OpcUaApplicationHost> logger)
{
_options = options;
_driverHost = driverHost;
_authenticator = authenticator;
_loggerFactory = loggerFactory;
_logger = logger;
}
@@ -55,7 +58,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
throw new InvalidOperationException(
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
_server = new OtOpcUaServer(_driverHost, _loggerFactory);
_server = new OtOpcUaServer(_driverHost, _authenticator, _loggerFactory);
await _application.Start(_server).ConfigureAwait(false);
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
@@ -126,22 +129,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
ServerConfiguration = new ServerConfiguration
{
BaseAddresses = new StringCollection { _options.EndpointUrl },
SecurityPolicies = new ServerSecurityPolicyCollection
{
new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
},
},
UserTokenPolicies = new UserTokenPolicyCollection
{
new UserTokenPolicy(UserTokenType.Anonymous)
{
PolicyId = "Anonymous",
SecurityPolicyUri = SecurityPolicies.None,
},
},
SecurityPolicies = BuildSecurityPolicies(),
UserTokenPolicies = BuildUserTokenPolicies(),
MinRequestThreadCount = 5,
MaxRequestThreadCount = 100,
MaxQueuedRequestCount = 200,
@@ -164,6 +153,58 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
return cfg;
}
private ServerSecurityPolicyCollection BuildSecurityPolicies()
{
var policies = new ServerSecurityPolicyCollection
{
// Keep the None policy present so legacy clients can discover + browse. Locked-down
// deployments remove this by setting Ldap.Enabled=true + dropping None here; left in
// for PR 19 so the PR 17 test harness continues to pass unchanged.
new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
},
};
if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)
{
policies.Add(new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
});
}
return policies;
}
private UserTokenPolicyCollection BuildUserTokenPolicies()
{
var tokens = new UserTokenPolicyCollection
{
new UserTokenPolicy(UserTokenType.Anonymous)
{
PolicyId = "Anonymous",
SecurityPolicyUri = SecurityPolicies.None,
},
};
if (_options.SecurityProfile == OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt
&& _options.Ldap.Enabled)
{
tokens.Add(new UserTokenPolicy(UserTokenType.UserName)
{
PolicyId = "UserName",
// Passwords must ride an encrypted channel — scope this token to Basic256Sha256
// so the stack rejects any attempt to send UserName over the None endpoint.
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
});
}
return tokens;
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;

View File

@@ -1,5 +1,23 @@
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
/// <summary>
/// OPC UA transport security profile selector. Controls which <c>ServerSecurityPolicy</c>
/// entries the endpoint advertises + which token types the <c>UserTokenPolicies</c> permits.
/// </summary>
public enum OpcUaSecurityProfile
{
/// <summary>Anonymous only on <c>SecurityPolicies.None</c> — dev-only, no signing or encryption.</summary>
None,
/// <summary>
/// <c>Basic256Sha256 SignAndEncrypt</c> with <c>UserName</c> and <c>Anonymous</c> token
/// policies. Clients must present a valid application certificate + user credentials.
/// </summary>
Basic256Sha256SignAndEncrypt,
}
/// <summary>
/// OPC UA server endpoint + application-identity configuration. Bound from the
/// <c>OpcUaServer</c> section of <c>appsettings.json</c>. PR 17 minimum-viable scope: no LDAP,
@@ -39,4 +57,18 @@ public sealed class OpcUaServerOptions
/// Admin UI.
/// </summary>
public bool AutoAcceptUntrustedClientCertificates { get; init; } = true;
/// <summary>
/// Security profile advertised on the endpoint. Default <see cref="OpcUaSecurityProfile.None"/>
/// preserves the PR 17 endpoint shape; set to <see cref="OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt"/>
/// for production deployments with LDAP-backed UserName auth.
/// </summary>
public OpcUaSecurityProfile SecurityProfile { get; init; } = OpcUaSecurityProfile.None;
/// <summary>
/// LDAP binding for UserName token validation. Only consulted when the active
/// <see cref="SecurityProfile"/> advertises a UserName token policy. When
/// <c>LdapOptions.Enabled = false</c>, UserName token attempts are rejected.
/// </summary>
public LdapOptions Ldap { get; init; } = new();
}

View File

@@ -5,6 +5,7 @@ using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
@@ -17,12 +18,14 @@ namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
public sealed class OtOpcUaServer : StandardServer
{
private readonly DriverHost _driverHost;
private readonly IUserAuthenticator _authenticator;
private readonly ILoggerFactory _loggerFactory;
private readonly List<DriverNodeManager> _driverNodeManagers = new();
public OtOpcUaServer(DriverHost driverHost, ILoggerFactory loggerFactory)
public OtOpcUaServer(DriverHost driverHost, IUserAuthenticator authenticator, ILoggerFactory loggerFactory)
{
_driverHost = driverHost;
_authenticator = authenticator;
_loggerFactory = loggerFactory;
}
@@ -50,6 +53,63 @@ public sealed class OtOpcUaServer : StandardServer
return new MasterNodeManager(server, configuration, null, _driverNodeManagers.ToArray());
}
protected override void OnServerStarted(IServerInternal server)
{
base.OnServerStarted(server);
// Hook UserName / Anonymous token validation here. Anonymous passes through; UserName
// is validated against the IUserAuthenticator (LDAP in production). Rejected identities
// throw ServiceResultException which the stack translates to Bad_IdentityTokenInvalid.
server.SessionManager.ImpersonateUser += OnImpersonateUser;
}
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)
{
switch (args.NewIdentity)
{
case AnonymousIdentityToken:
args.Identity = new UserIdentity(); // anonymous
return;
case UserNameIdentityToken user:
{
var result = _authenticator.AuthenticateAsync(
user.UserName, user.DecryptedPassword, CancellationToken.None)
.GetAwaiter().GetResult();
if (!result.Success)
{
throw ServiceResultException.Create(
StatusCodes.BadUserAccessDenied,
"Invalid username or password ({0})", result.Error ?? "no detail");
}
args.Identity = new RoleBasedIdentity(user.UserName, result.DisplayName, result.Roles);
return;
}
default:
throw ServiceResultException.Create(
StatusCodes.BadIdentityTokenInvalid,
"Unsupported user identity token type: {0}", args.NewIdentity?.GetType().Name ?? "null");
}
}
/// <summary>
/// Tiny UserIdentity carrier that preserves the resolved roles so downstream node
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
/// uses the stack's default.
/// </summary>
private sealed class RoleBasedIdentity : UserIdentity
{
public IReadOnlyList<string> Roles { get; }
public string? Display { get; }
public RoleBasedIdentity(string userName, string? displayName, IReadOnlyList<string> roles)
: base(userName, "")
{
Display = displayName;
Roles = roles;
}
}
protected override ServerProperties LoadServerProperties() => new()
{
ManufacturerName = "OtOpcUa",

View File

@@ -1,11 +1,13 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
var builder = Host.CreateApplicationBuilder(args);
@@ -31,6 +33,20 @@ var options = new NodeOptions
};
var opcUaSection = builder.Configuration.GetSection(OpcUaServerOptions.SectionName);
var ldapSection = opcUaSection.GetSection("Ldap");
var ldapOptions = new LdapOptions
{
Enabled = ldapSection.GetValue<bool?>("Enabled") ?? false,
Server = ldapSection.GetValue<string>("Server") ?? "localhost",
Port = ldapSection.GetValue<int?>("Port") ?? 3893,
UseTls = ldapSection.GetValue<bool?>("UseTls") ?? false,
AllowInsecureLdap = ldapSection.GetValue<bool?>("AllowInsecureLdap") ?? true,
SearchBase = ldapSection.GetValue<string>("SearchBase") ?? "dc=lmxopcua,dc=local",
ServiceAccountDn = ldapSection.GetValue<string>("ServiceAccountDn") ?? string.Empty,
ServiceAccountPassword = ldapSection.GetValue<string>("ServiceAccountPassword") ?? string.Empty,
GroupToRole = ldapSection.GetSection("GroupToRole").Get<Dictionary<string, string>>() ?? new(StringComparer.OrdinalIgnoreCase),
};
var opcUaOptions = new OpcUaServerOptions
{
EndpointUrl = opcUaSection.GetValue<string>("EndpointUrl") ?? "opc.tcp://0.0.0.0:4840/OtOpcUa",
@@ -39,10 +55,17 @@ var opcUaOptions = new OpcUaServerOptions
PkiStoreRoot = opcUaSection.GetValue<string>("PkiStoreRoot")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OtOpcUa", "pki"),
AutoAcceptUntrustedClientCertificates = opcUaSection.GetValue<bool?>("AutoAcceptUntrustedClientCertificates") ?? true,
SecurityProfile = Enum.TryParse<OpcUaSecurityProfile>(opcUaSection.GetValue<string>("SecurityProfile"), true, out var p)
? p : OpcUaSecurityProfile.None,
Ldap = ldapOptions,
};
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(opcUaOptions);
builder.Services.AddSingleton(ldapOptions);
builder.Services.AddSingleton<IUserAuthenticator>(sp => ldapOptions.Enabled
? new LdapUserAuthenticator(ldapOptions, sp.GetRequiredService<ILogger<LdapUserAuthenticator>>())
: new DenyAllUserAuthenticator());
builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(options.LocalCachePath));
builder.Services.AddSingleton<DriverHost>();
builder.Services.AddSingleton<NodeBootstrap>();

View File

@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// Validates a (username, password) pair and returns the resolved OPC UA roles for the user.
/// The Server's <c>SessionManager_ImpersonateUser</c> hook delegates here so unit tests can
/// swap in a fake authenticator without a live LDAP.
/// </summary>
public interface IUserAuthenticator
{
Task<UserAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default);
}
public sealed record UserAuthResult(bool Success, string? DisplayName, IReadOnlyList<string> Roles, string? Error);
/// <summary>
/// Always-reject authenticator used when no security config is provided. Lets the server
/// start (with only an anonymous endpoint) without throwing on UserName token attempts.
/// </summary>
public sealed class DenyAllUserAuthenticator : IUserAuthenticator
{
public Task<UserAuthResult> AuthenticateAsync(string _, string __, CancellationToken ___)
=> Task.FromResult(new UserAuthResult(false, null, [], "UserName token not supported"));
}

View File

@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// LDAP settings for the OPC UA server's UserName token validator. Bound from
/// <c>appsettings.json</c> <c>OpcUaServer:Ldap</c>. Defaults match the GLAuth dev instance
/// (localhost:3893, dc=lmxopcua,dc=local). Production deployments set <see cref="UseTls"/>
/// true, populate <see cref="ServiceAccountDn"/> for search-then-bind, and maintain
/// <see cref="GroupToRole"/> with the real LDAP group names.
/// </summary>
public sealed class LdapOptions
{
public bool Enabled { get; init; } = false;
public string Server { get; init; } = "localhost";
public int Port { get; init; } = 3893;
public bool UseTls { get; init; } = false;
/// <summary>Dev-only escape hatch — must be false in production.</summary>
public bool AllowInsecureLdap { get; init; } = true;
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
public string ServiceAccountDn { get; init; } = string.Empty;
public string ServiceAccountPassword { get; init; } = string.Empty;
public string DisplayNameAttribute { get; init; } = "cn";
public string GroupAttribute { get; init; } = "memberOf";
/// <summary>
/// LDAP group → OPC UA role. Each authenticated user gets every role whose source group
/// is in their membership list. Recognized role names (CLAUDE.md): <c>ReadOnly</c> (browse
/// + read), <c>WriteOperate</c>, <c>WriteTune</c>, <c>WriteConfigure</c>, <c>AlarmAck</c>.
/// </summary>
public Dictionary<string, string> GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,151 @@
using Microsoft.Extensions.Logging;
using Novell.Directory.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// <see cref="IUserAuthenticator"/> that binds to the configured LDAP directory to validate
/// the (username, password) pair, then pulls group membership and maps to OPC UA roles.
/// Mirrors the bind-then-search pattern in <c>Admin.Security.LdapAuthService</c> but stays
/// in the Server project so the Server process doesn't take a cross-app dependency on Admin.
/// </summary>
public sealed class LdapUserAuthenticator(LdapOptions options, ILogger<LdapUserAuthenticator> logger)
: IUserAuthenticator
{
public async Task<UserAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
if (!options.Enabled)
return new UserAuthResult(false, null, [], "LDAP authentication disabled");
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return new UserAuthResult(false, null, [], "Credentials required");
if (!options.UseTls && !options.AllowInsecureLdap)
return new UserAuthResult(false, null, [],
"Insecure LDAP is disabled. Set UseTls or AllowInsecureLdap for dev/test.");
try
{
using var conn = new LdapConnection();
if (options.UseTls) conn.SecureSocketLayer = true;
await Task.Run(() => conn.Connect(options.Server, options.Port), ct);
var bindDn = await ResolveUserDnAsync(conn, username, ct);
await Task.Run(() => conn.Bind(bindDn, password), ct);
// Rebind as service account for attribute read, if configured — otherwise the just-
// bound user reads their own entry (works when ACL permits self-read).
if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn))
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
var displayName = username;
var groups = new List<string>();
try
{
var filter = $"(cn={EscapeLdapFilter(username)})";
var results = await Task.Run(() =>
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, attrs: null, typesOnly: false), ct);
while (results.HasMore())
{
try
{
var entry = results.Next();
var name = entry.GetAttribute(options.DisplayNameAttribute);
if (name is not null) displayName = name.StringValue;
var groupAttr = entry.GetAttribute(options.GroupAttribute);
if (groupAttr is not null)
{
foreach (var groupDn in groupAttr.StringValueArray)
groups.Add(ExtractFirstRdnValue(groupDn));
}
// GLAuth fallback: primary group is encoded as the ou= RDN above cn=.
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
{
var primary = ExtractOuSegment(entry.Dn);
if (primary is not null) groups.Add(primary);
}
}
catch (LdapException) { break; }
}
}
catch (LdapException ex)
{
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
}
conn.Disconnect();
var roles = groups
.Where(g => options.GroupToRole.ContainsKey(g))
.Select(g => options.GroupToRole[g])
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return new UserAuthResult(true, displayName, roles, null);
}
catch (LdapException ex)
{
logger.LogInformation("LDAP bind rejected user {User}: {Reason}", username, ex.ResultCode);
return new UserAuthResult(false, null, [], "Invalid username or password");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
return new UserAuthResult(false, null, [], "Authentication error");
}
}
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
{
if (username.Contains('=')) return username; // caller passed a DN directly
if (!string.IsNullOrWhiteSpace(options.ServiceAccountDn))
{
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
var filter = $"(uid={EscapeLdapFilter(username)})";
var results = await Task.Run(() =>
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
if (results.HasMore())
return results.Next().Dn;
throw new LdapException("User not found", LdapException.NoSuchObject,
$"No entry for uid={username}");
}
return string.IsNullOrWhiteSpace(options.SearchBase)
? $"cn={username}"
: $"cn={username},{options.SearchBase}";
}
internal static string EscapeLdapFilter(string input) =>
input.Replace("\\", "\\5c")
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00");
internal static string? ExtractOuSegment(string dn)
{
foreach (var segment in dn.Split(','))
{
var trimmed = segment.Trim();
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
return trimmed[3..];
}
return null;
}
internal static string ExtractFirstRdnValue(string dn)
{
var eq = dn.IndexOf('=');
if (eq < 0) return dn;
var valueStart = eq + 1;
var comma = dn.IndexOf(',', valueStart);
return comma > valueStart ? dn[valueStart..comma] : dn[valueStart..];
}
}

View File

@@ -23,12 +23,17 @@
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.374.126"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.374.126"/>
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Server.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>

View File

@@ -7,6 +7,7 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
@@ -38,8 +39,8 @@ public sealed class OpcUaServerIntegrationTests : IAsyncLifetime
AutoAcceptUntrustedClientCertificates = true,
};
_server = new OpcUaApplicationHost(options, _driverHost, NullLoggerFactory.Instance,
NullLogger<OpcUaApplicationHost>.Instance);
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
await _server.StartAsync(CancellationToken.None);
}

View File

@@ -0,0 +1,88 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class SecurityConfigurationTests
{
[Fact]
public async Task DenyAllAuthenticator_rejects_every_credential()
{
var auth = new DenyAllUserAuthenticator();
var r = await auth.AuthenticateAsync("admin", "admin", CancellationToken.None);
r.Success.ShouldBeFalse();
r.Error.ShouldContain("not supported");
}
[Fact]
public async Task LdapAuthenticator_rejects_blank_credentials_without_hitting_server()
{
var options = new LdapOptions { Enabled = true, AllowInsecureLdap = true };
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
var empty = await auth.AuthenticateAsync("", "", CancellationToken.None);
empty.Success.ShouldBeFalse();
empty.Error.ShouldContain("Credentials");
}
[Fact]
public async Task LdapAuthenticator_rejects_when_disabled()
{
var options = new LdapOptions { Enabled = false };
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
var r = await auth.AuthenticateAsync("alice", "pw", CancellationToken.None);
r.Success.ShouldBeFalse();
r.Error.ShouldContain("disabled");
}
[Fact]
public async Task LdapAuthenticator_rejects_plaintext_when_both_TLS_and_insecure_are_disabled()
{
var options = new LdapOptions { Enabled = true, UseTls = false, AllowInsecureLdap = false };
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
var r = await auth.AuthenticateAsync("alice", "pw", CancellationToken.None);
r.Success.ShouldBeFalse();
r.Error.ShouldContain("Insecure");
}
[Theory]
[InlineData("hello", "hello")]
[InlineData("hi(there)", "hi\\28there\\29")]
[InlineData("name*", "name\\2a")]
[InlineData("a\\b", "a\\5cb")]
public void LdapFilter_escapes_reserved_characters(string input, string expected)
{
LdapUserAuthenticator.EscapeLdapFilter(input).ShouldBe(expected);
}
[Theory]
[InlineData("cn=alice,ou=Engineering,dc=example,dc=com", "Engineering")]
[InlineData("cn=bob,dc=example,dc=com", null)]
[InlineData("cn=carol,ou=Ops,dc=example,dc=com", "Ops")]
public void ExtractOuSegment_pulls_primary_group_from_DN(string dn, string? expected)
{
LdapUserAuthenticator.ExtractOuSegment(dn).ShouldBe(expected);
}
[Theory]
[InlineData("cn=Operators,ou=Groups,dc=example", "Operators")]
[InlineData("cn=LoneValue", "LoneValue")]
[InlineData("plain-no-equals", "plain-no-equals")]
public void ExtractFirstRdnValue_returns_first_rdn(string dn, string expected)
{
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe(expected);
}
[Fact]
public void OpcUaServerOptions_default_is_anonymous_only()
{
var opts = new OpcUaServerOptions();
opts.SecurityProfile.ShouldBe(OpcUaSecurityProfile.None);
opts.Ldap.Enabled.ShouldBeFalse();
}
}