diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md
index 85f6168d..66651886 100644
--- a/docs/drivers/OpcUaClient.md
+++ b/docs/drivers/OpcUaClient.md
@@ -25,7 +25,8 @@ out-of-process isolation.
| Project | Target | Role |
|---------|--------|------|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/` | net10.0 | In-process driver — session lifetime, read / write / subscribe / alarm / history passthrough |
-| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | net10.0 | `IDriverBrowser` — live address-picker browse used by the AdminUI |
+| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | net10.0 | `IDriverBrowser` — live address-picker browse used by the AdminUI. Its side-panel `AttributesAsync` reads a variable's DataType / ValueRank / UserAccessLevel and maps them through the same `UpstreamAttributeMapping` discovery uses; it authenticates Anonymous, Username **or Certificate** through the driver's own `OpcUaUserIdentityFactory`, resolving `secret:` credentials through the host's secret store (fail-closed) — review 2026-10-21 O-5 (it was a stub, and Certificate was refused) |
+| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/` | net10.0 | The SDK-typed layer the driver AND the browser share: `NamespaceMap`, `UpstreamAttributeMapping`, `OpcUaUserIdentityFactory` |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/` | net10.0 | Config records + enums bound from `DriverConfig` JSON |
## Minimum deployment
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs
index 42f10018..fa534016 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs
@@ -54,10 +54,64 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
}
///
- public Task> AttributesAsync(string nodeId, CancellationToken cancellationToken)
+ // R06:O-5 — reads the node's NodeClass / DisplayName / DataType / ValueRank / UserAccessLevel in one request and
+ // maps them through the same UpstreamAttributeMapping the runtime driver's discovery registers with, so the
+ // side-panel shows what a browse-commit would author. An Object (folder) has no value attributes → empty.
+ // Throws ArgumentException when nodeId cannot be resolved against the live session's namespace table.
+ public async Task> AttributesAsync(string nodeId, CancellationToken cancellationToken)
{
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (!NamespaceMap.TryResolve(_session, nodeId, out var resolved))
+ throw new ArgumentException(
+ $"Cannot resolve NodeId '{nodeId}' against the live session.", nameof(nodeId));
+
+ uint[] attributeIds =
+ [
+ Attributes.NodeClass, Attributes.DisplayName, Attributes.DataType, Attributes.ValueRank,
+ Attributes.UserAccessLevel,
+ ];
+ var toRead = new ReadValueIdCollection(attributeIds.Length);
+ foreach (var id in attributeIds) toRead.Add(new ReadValueId { NodeId = resolved, AttributeId = id });
+
+ DataValueCollection values;
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var resp = await _session.ReadAsync(
+ requestHeader: null,
+ maxAge: 0,
+ timestampsToReturn: TimestampsToReturn.Neither,
+ nodesToRead: toRead,
+ ct: cancellationToken).ConfigureAwait(false);
+ values = resp?.Results ?? [];
+ }
+ finally
+ {
+ _gate.Release();
+ }
LastUsedUtc = DateTime.UtcNow;
- return Task.FromResult>(Array.Empty());
+
+ DataValue? At(int i) => i < values.Count && StatusCode.IsGood(values[i].StatusCode)
+ ? values[i] : null;
+
+ var nodeClass = At(0)?.Value is int nc ? (NodeClass)nc : NodeClass.Unspecified;
+ if (nodeClass != NodeClass.Variable) return Array.Empty();
+
+ var name = (At(1)?.Value as LocalizedText)?.Text ?? resolved.ToString() ?? nodeId;
+ var dataType = At(2)?.Value is NodeId dt
+ ? UpstreamAttributeMapping.MapDataType(dt)
+ : ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String;
+ var valueRank = At(3)?.Value is int vr ? vr : ValueRanks.Scalar;
+ var access = At(4)?.Value is byte ab ? ab : (byte)0;
+
+ return
+ [
+ new AttributeInfo(
+ Name: name,
+ DriverDataType: dataType.ToString(),
+ IsArray: UpstreamAttributeMapping.IsArray(valueRank),
+ SecurityClass: UpstreamAttributeMapping.MapAccessLevel(access).ToString()),
+ ];
}
/// Issue a single-level Browse (plus continuation-point follow-ups) under the
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs
index 6573e87d..845d2caa 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs
@@ -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 _logger;
+ private readonly ISecretResolver? _secretResolver;
/// Creates a new browser. Logger defaults to NullLogger when not supplied.
/// Optional logger; defaults to .
- public OpcUaClientDriverBrowser(ILogger? logger = null)
+ ///
+ /// The host's shared secret resolver (DI-injected where one is registered), used to resolve
+ /// secret:-prefixed Password / UserCertificatePassword exactly as the runtime driver does.
+ /// Null ⇒ a secret: ref fails closed (it is never sent as the literal).
+ ///
+ public OpcUaClientDriverBrowser(ILogger? logger = null, ISecretResolver? secretResolver = null)
{
_logger = logger ?? NullLogger.Instance;
+ _secretResolver = secretResolver;
}
///
@@ -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;
}
- /// Build the OPC UA user identity from the form's auth fields.
+ ///
+ /// 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 secret:-prefixed credential (fail-closed).
+ ///
/// Driver options carrying the form's auth fields.
- private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
+ /// The secret store, or null when none is available.
+ /// Cancellation token for the secret resolution.
+ /// The identity the browse session activates with.
+ internal static async Task 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);
+ }
/// Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.
/// Application configuration used by the discovery client.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs
new file mode 100644
index 00000000..16be7eed
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs
@@ -0,0 +1,115 @@
+using Opc.Ua;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
+
+///
+/// The ONE place the OpcUaClient user identity is built from — shared by
+/// the runtime driver's session open and the AdminUI live browse, so a certificate-authenticated upstream is
+/// browsable with exactly the credentials the deployed driver will present. Includes the secret:
+/// resolution of the two credential fields (fail-closed).
+///
+public static class OpcUaUserIdentityFactory
+{
+ private const string SecretPrefix = "secret:";
+
+ ///
+ /// Build the user-identity token from options whose credential fields are already resolved plaintext
+ /// (see ).
+ ///
+ /// Driver options containing authentication configuration.
+ /// The constructed for session activation.
+ public static UserIdentity Build(OpcUaClientDriverOptions options) =>
+ options.AuthType switch
+ {
+ OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
+ OpcUaAuthType.Username => new UserIdentity(
+ options.Username ?? string.Empty,
+ System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)),
+ OpcUaAuthType.Certificate => FromCertificate(options),
+ _ => new UserIdentity(new AnonymousIdentityToken()),
+ };
+
+ ///
+ /// Build a carrying a client user-authentication certificate loaded from
+ /// . Fails fast if the path is missing, the file
+ /// doesn't exist, or the certificate lacks a private key (required to sign the user-token challenge).
+ ///
+ /// Driver options containing certificate configuration.
+ /// The certificate-backed .
+ public static UserIdentity FromCertificate(OpcUaClientDriverOptions options)
+ {
+ if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
+ throw new InvalidOperationException(
+ "OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
+ if (!File.Exists(options.UserCertificatePath))
+ throw new FileNotFoundException(
+ $"User certificate not found at '{options.UserCertificatePath}'.",
+ options.UserCertificatePath);
+
+ // X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX
+ // since the legacy X509Certificate2 ctors are marked obsolete on net10.
+ var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader
+ .LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword);
+
+ if (!cert.HasPrivateKey)
+ throw new InvalidOperationException(
+ $"User certificate at '{options.UserCertificatePath}' has no private key — " +
+ "the private key is required to sign the OPC UA user-token challenge at session activation.");
+
+ return new UserIdentity(cert);
+ }
+
+ ///
+ /// Return a copy of with any secret:-prefixed
+ /// /
+ /// resolved through . Non-secret / null / empty fields pass through unchanged (the
+ /// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a
+ /// secret: ref with no resolver available, throws rather than sending the literal as a credential.
+ ///
+ /// The raw options (credential fields may carry secret: refs).
+ /// The shared secret resolver, or null when none is available.
+ /// Cancellation token.
+ /// An options copy whose credential fields carry resolved plaintext.
+ public static async Task ResolveSecretRefsAsync(
+ OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
+ .ConfigureAwait(false);
+ var certPassword = await ResolveFieldAsync(
+ options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
+ .ConfigureAwait(false);
+
+ if (ReferenceEquals(password, options.Password)
+ && ReferenceEquals(certPassword, options.UserCertificatePassword))
+ {
+ return options;
+ }
+
+ return options with { Password = password, UserCertificatePassword = certPassword };
+ }
+
+ private static async Task ResolveFieldAsync(
+ string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(value)
+ || !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ return value;
+ }
+
+ var name = value[SecretPrefix.Length..];
+ if (resolver is null)
+ throw new InvalidOperationException(
+ $"OpcUaClientDriverOptions.{fieldName}='{value}' references secret '{name}', but no secret store is " +
+ "available here (fail-closed).");
+ var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
+ return !string.IsNullOrEmpty(resolved)
+ ? resolved
+ : throw new InvalidOperationException(
+ $"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
+ "absent from the store (fail-closed).");
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamAttributeMapping.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamAttributeMapping.cs
new file mode 100644
index 00000000..046fa2e1
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamAttributeMapping.cs
@@ -0,0 +1,65 @@
+using Opc.Ua;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
+
+///
+/// The ONE mapping from an upstream variable's OPC UA attributes (DataType, ValueRank, AccessLevel) to the
+/// driver-agnostic shape — shared by the runtime driver's discovery enrichment and the AdminUI live browse
+/// side-panel, so the picker shows exactly what a browse-commit / discovery would register.
+///
+public static class UpstreamAttributeMapping
+{
+ ///
+ /// Map an upstream OPC UA built-in DataType NodeId (via DataTypeIds.*) to a
+ /// . Unknown / custom types fall through to
+ /// which is the safest passthrough for
+ /// Variant-wrapped structs + enums + extension objects.
+ ///
+ /// The OPC UA data type NodeId to map.
+ /// The mapped .
+ public static DriverDataType MapDataType(NodeId dataType)
+ {
+ if (dataType == DataTypeIds.Boolean) return DriverDataType.Boolean;
+ // SByte (signed 8-bit) shares Int16 — DriverDataType has no narrower signed type.
+ // Byte (unsigned 8-bit) belongs in the unsigned family → UInt16, not Int16
+ // (mapping an unsigned 0-255 type onto Int16 misrepresents type metadata and
+ // confuses range/validation logic keyed off DriverDataType).
+ if (dataType == DataTypeIds.SByte || dataType == DataTypeIds.Int16) return DriverDataType.Int16;
+ if (dataType == DataTypeIds.Byte || dataType == DataTypeIds.UInt16) return DriverDataType.UInt16;
+ if (dataType == DataTypeIds.Int32) return DriverDataType.Int32;
+ if (dataType == DataTypeIds.UInt32) return DriverDataType.UInt32;
+ if (dataType == DataTypeIds.Int64) return DriverDataType.Int64;
+ if (dataType == DataTypeIds.UInt64) return DriverDataType.UInt64;
+ if (dataType == DataTypeIds.Float) return DriverDataType.Float32;
+ if (dataType == DataTypeIds.Double) return DriverDataType.Float64;
+ if (dataType == DataTypeIds.String) return DriverDataType.String;
+ if (dataType == DataTypeIds.DateTime || dataType == DataTypeIds.UtcTime)
+ return DriverDataType.DateTime;
+ return DriverDataType.String;
+ }
+
+ ///
+ /// Map an OPC UA AccessLevel/UserAccessLevel attribute value (AccessLevels bitmask) to a
+ /// : CurrentWrite-capable variables surface as
+ /// ; read-only as .
+ ///
+ /// The OPC UA access level bitmask.
+ /// The mapped security classification.
+ public static SecurityClassification MapAccessLevel(byte accessLevel)
+ {
+ const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02
+ return (accessLevel & CurrentWrite) != 0
+ ? SecurityClassification.Operate
+ : SecurityClassification.ViewOnly;
+ }
+
+ ///
+ /// OPC UA Part 3 ValueRank: -3 ScalarOrOneDimension, -2 Any, -1 Scalar, 0 OneOrMoreDimensions, 1 OneDimension,
+ /// >1 N dimensions. Anything >= 0 is an array; -3 / -2 are conservatively scalar until the upstream variable
+ /// carries a concrete dimensioned rank.
+ ///
+ /// The ValueRank attribute.
+ /// Whether the variable is exposed as an array.
+ public static bool IsArray(int valueRank) => valueRank >= 0;
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj
index 3ecc9663..41166a40 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj
@@ -11,8 +11,9 @@
+
+