feat(opcuaclient-browser): real AttributesAsync + certificate-auth browse (R06:O-5)

- AttributesAsync was a stub returning nothing; it now reads NodeClass /
  DisplayName / DataType / ValueRank / UserAccessLevel in one request and maps them
  through UpstreamAttributeMapping, the same mapping discovery registers with (moved
  into the shared .Session leaf; the driver forwards to it). Objects have no value
  attributes.
- Certificate auth was refused "in v1". The browse now builds its identity through
  OpcUaUserIdentityFactory (shared .Session leaf, the driver forwards to it),
  including the secret: resolution of Password / UserCertificatePassword through the
  host's ISecretResolver (optional ctor param, DI-injected). Fail-closed: a secret:
  ref with no store throws instead of being sent as the literal (the browse used to
  send a secret: Password verbatim). The identity is built before any network I/O.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:33:08 -04:00
parent d98f4a5bf1
commit fcdc44f9f7
11 changed files with 492 additions and 134 deletions
@@ -7,7 +7,8 @@ using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap + OpcUaUserIdentityFactory
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
@@ -33,12 +34,19 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
};
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
private readonly ISecretResolver? _secretResolver;
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
/// <param name="secretResolver">
/// The host's shared secret resolver (DI-injected where one is registered), used to resolve
/// <c>secret:</c>-prefixed <c>Password</c> / <c>UserCertificatePassword</c> exactly as the runtime driver does.
/// Null ⇒ a <c>secret:</c> ref fails closed (it is never sent as the literal).
/// </param>
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null, ISecretResolver? secretResolver = null)
{
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
_secretResolver = secretResolver;
}
/// <inheritdoc />
@@ -58,9 +66,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
if (string.IsNullOrWhiteSpace(endpoint))
throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
if (opts.AuthType == OpcUaAuthType.Certificate)
throw new InvalidOperationException(
"Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username.");
// R06:O-5 — Certificate auth is supported: the identity is built (and any secret: credential resolved) by the
// same factory the runtime driver uses, BEFORE any network I/O, so a bad certificate path / password fails fast
// with its own message.
var identity = await BuildBrowseUserIdentityAsync(opts, _secretResolver, cancellationToken).ConfigureAwait(false);
if (opts.AutoAcceptCertificates)
_logger.LogWarning(
@@ -70,7 +79,6 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
endpoint);
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
var identity = BuildBrowseUserIdentity(opts);
var perEndpointBudget = TimeSpan.FromSeconds(
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
@@ -175,16 +183,20 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
return config;
}
/// <summary>Build the OPC UA user identity from the form's auth fields.</summary>
/// <summary>
/// Build the OPC UA user identity from the form's auth fields — Anonymous, Username or Certificate — through
/// the factory the runtime driver uses, after resolving any <c>secret:</c>-prefixed credential (fail-closed).
/// </summary>
/// <param name="opts">Driver options carrying the form's auth fields.</param>
private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
/// <param name="secretResolver">The secret store, or null when none is available.</param>
/// <param name="ct">Cancellation token for the secret resolution.</param>
/// <returns>The identity the browse session activates with.</returns>
internal static async Task<UserIdentity> BuildBrowseUserIdentityAsync(
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct)
{
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
OpcUaAuthType.Username => new UserIdentity(
opts.Username ?? string.Empty,
System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
_ => new UserIdentity(new AnonymousIdentityToken()),
};
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false);
return OpcUaUserIdentityFactory.Build(resolved);
}
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
/// <param name="appConfig">Application configuration used by the discovery client.</param>