diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
index 3b8ed40..7a96725 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
@@ -90,8 +90,7 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
OpcUaAuthType.Username => new UserIdentity(
_options.Username ?? string.Empty,
System.Text.Encoding.UTF8.GetBytes(_options.Password ?? string.Empty)),
- OpcUaAuthType.Certificate => throw new NotSupportedException(
- "Certificate authentication lands in a follow-up PR; for now use Anonymous or Username"),
+ OpcUaAuthType.Certificate => BuildCertificateIdentity(_options),
_ => new UserIdentity(new AnonymousIdentityToken()),
};
@@ -271,6 +270,39 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
return match;
}
+ ///
+ /// Build a carrying a client user-authentication
+ /// certificate loaded from .
+ /// Used when the remote server's endpoint advertises Certificate-type user tokens.
+ /// Fails fast if the path is missing, the file doesn't exist, or the certificate
+ /// lacks a private key (the private key is required to sign the user-token
+ /// challenge during session activation).
+ ///
+ internal static UserIdentity BuildCertificateIdentity(OpcUaClientDriverOptions options)
+ {
+ if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
+ throw new InvalidOperationException(
+ "OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
+ if (!System.IO.File.Exists(options.UserCertificatePath))
+ throw new System.IO.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. Passes the
+ // password through verbatim; PEM files with external keys fall back to
+ // LoadCertificateFromFile which picks up the adjacent .key if present.
+ 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);
+ }
+
/// Convert a driver to the OPC UA policy URI.
internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch
{
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs
index a620bae..d61287b 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs
@@ -39,6 +39,23 @@ public sealed class OpcUaClientDriverOptions
/// Password (required only for ).
public string? Password { get; init; }
+ ///
+ /// Filesystem path to the user-identity certificate (PFX/PEM). Required when
+ /// is . The driver
+ /// loads the cert + private key, which the remote server validates against its
+ /// TrustedUserCertificates store to authenticate the session's user token.
+ /// Leave unset to use the driver's application-instance certificate as the user
+ /// token (not typical โ most deployments have a separate user cert).
+ ///
+ public string? UserCertificatePath { get; init; }
+
+ ///
+ /// Optional password that unlocks when the PFX is
+ /// protected. PEM files generally have their password on the adjacent key file; this
+ /// knob only applies to password-locked PFX.
+ ///
+ public string? UserCertificatePassword { get; init; }
+
/// Server-negotiated session timeout. Default 120s per driver-specs.md ยง8.
public TimeSpan SessionTimeout { get; init; } = TimeSpan.FromSeconds(120);
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertAuthTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertAuthTests.cs
new file mode 100644
index 0000000..2a658bb
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientCertAuthTests.cs
@@ -0,0 +1,59 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class OpcUaClientCertAuthTests
+{
+ [Fact]
+ public void BuildCertificateIdentity_rejects_missing_path()
+ {
+ var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Certificate };
+ Should.Throw(() => OpcUaClientDriver.BuildCertificateIdentity(opts))
+ .Message.ShouldContain("UserCertificatePath");
+ }
+
+ [Fact]
+ public void BuildCertificateIdentity_rejects_nonexistent_file()
+ {
+ var opts = new OpcUaClientDriverOptions
+ {
+ AuthType = OpcUaAuthType.Certificate,
+ UserCertificatePath = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.pfx"),
+ };
+ Should.Throw(() => OpcUaClientDriver.BuildCertificateIdentity(opts));
+ }
+
+ [Fact]
+ public void BuildCertificateIdentity_loads_a_valid_PFX_with_private_key()
+ {
+ // Generate a self-signed cert on the fly so the test doesn't ship a static PFX.
+ // The driver doesn't care about the issuer โ just needs a cert with a private key.
+ using var rsa = RSA.Create(2048);
+ var req = new CertificateRequest("CN=OpcUaClientCertAuthTests", rsa,
+ HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
+
+ var tmpPath = Path.Combine(Path.GetTempPath(), $"opcua-cert-test-{Guid.NewGuid():N}.pfx");
+ File.WriteAllBytes(tmpPath, cert.Export(X509ContentType.Pfx, "testpw"));
+ try
+ {
+ var opts = new OpcUaClientDriverOptions
+ {
+ AuthType = OpcUaAuthType.Certificate,
+ UserCertificatePath = tmpPath,
+ UserCertificatePassword = "testpw",
+ };
+ var identity = OpcUaClientDriver.BuildCertificateIdentity(opts);
+ identity.ShouldNotBeNull();
+ identity.TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate);
+ }
+ finally
+ {
+ try { File.Delete(tmpPath); } catch { /* best-effort */ }
+ }
+ }
+}