Skip to content

Latest commit

 

History

History
959 lines (813 loc) · 40 KB

File metadata and controls

959 lines (813 loc) · 40 KB

Identity Providers (OPC UA Part 6 §6.5)

The OPC UA .NET Standard stack exposes a pluggable identity-provider model that covers every user identity mechanism defined in OPC UA Part 6 §6.5 and the identity-provider handshakes described in OPC UA Part 4 §6.2.

The design is intentionally symmetric:

                 ┌──────────────────────────────────────────────┐
                 │            Opc.Ua.Core / Opc.Ua.Identity     │
                 │  (shared interfaces, no behaviour change)    │
                 └──────────────────────────────────────────────┘
                              ▲                       ▲
              ┌───────────────┘                       └───────────────┐
              │                                                       │
   IClientIdentityProvider                                IUserTokenAuthenticator
   IAccessTokenProvider                                   IServerIdentityRegistry
   AuthorizationServerMetadata                            ITokenIssuer
              │                                                       │
   Used by:                                              Used by:
     ManagedSession,                                       SessionManager
     ConsoleReferenceClient                                ReferenceServer
     custom Entra/OIDC providers                           JwtAuthenticator, X509Authenticator
     custom Windows/AspNetCore providers                   AuthorizationServiceType

The model does not replace the existing IUserIdentity / IUserIdentityTokenHandler contracts that ship on the wire — those are still the canonical on-the-wire types. The provider model layers on top of those types so legacy callbacks keep working during migration, while new code can register IUserTokenAuthenticator instances directly.

Quick start — dependency injection

For applications hosted on Microsoft.Extensions.Hosting (the default in this stack — see Dependency Injection), the identity surface is configured through fluent extensions on IOpcUaServerBuilder and IOpcUaClientBuilder, plus matching appsettings.json sections.

Server example

services.AddOpcUa()
    .AddServer(opt =>
    {
        opt.ApplicationName = "MyServer";
        opt.ApplicationUri  = "urn:my-server";
    })
    .AddDefaultIdentityAuthenticators(opt =>
    {
        opt.EnableAnonymous        = true;
        opt.EnableUserNamePassword = true;
        opt.EnableX509             = true;
        opt.EnableJwt              = true;
        opt.ExpectedAudience       = "urn:my-server";
    })
    .AddJwtIssuer(opt =>
    {
        opt.IssuerUri = "https://login.microsoftonline.com/{tenant}/v2.0";
        opt.JwksUri   = "https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys";
    });

Equivalent appsettings.json picked up automatically by builder.AddServer(IConfiguration) — that overload walks the section and (in addition to binding OpcUaServerOptions.Identity) also registers each Issuers[] entry through AddJwtIssuer(...). The Identity:Defaults block is honoured because AddServer(IConfiguration) enables the configured-default-authenticators bridge:

{
  "OpcUa": {
    "Server": {
      "ApplicationName": "MyServer",
      "ApplicationUri": "urn:my-server",
      "Identity": {
        "Defaults": {
          "EnableAnonymous": true,
          "EnableUserNamePassword": true,
          "EnableX509": true,
          "EnableJwt": true,
          "ExpectedAudience": "urn:my-server"
        },
        "Issuers": [
          {
            "IssuerUri": "https://login.microsoftonline.com/{tenant}/v2.0",
            "JwksUri":   "https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys"
          }
        ]
      }
    }
  }
}

The AddServer(Action<OpcUaServerOptions>) overload (code-only) does NOT wire the default authenticators or JWT issuers from OpcUaServerOptions.Identity — that bridge is enabled only on the AddServer(IConfiguration) path. With the Action<> overload, call AddDefaultIdentityAuthenticators(opt => …) and AddJwtIssuer(opt => …) explicitly.

Client example

The composite builder API takes the supporting service (an ISecretRegistry for passwords, an IAccessTokenProvider for issued tokens) directly:

ISecretRegistry secrets = /* register and populate elsewhere */;
IAccessTokenProvider tokens = new JwtBearerAccessTokenProvider(
    authorityUri: "https://issuer.example",
    tokenBytes:   System.Text.Encoding.UTF8.GetBytes(jwt),
    expiresAt:    DateTime.UtcNow.AddMinutes(55));

services.AddOpcUa()
    .AddClient(opt =>
    {
        opt.SessionName = "MyClient";
    })
    .AddAccessTokenProvider(tokens)
    .AddIdentityProvider(builder =>
    {
        builder.AddAnonymous();
        builder.AddUserName(
            configure: opt =>
            {
                opt.UserName        = "alice";
                opt.SecretName      = "alice-password";
                opt.SecretStoreType = "InMemory";
            },
            registry: secrets);
        builder.AddIssuedToken(
            configure: opt => opt.ProfileUri = Profiles.JwtUserToken,
            provider:  tokens);
    });

Or, drive the composite from configuration — the IConfiguration overload resolves the ISecretRegistry and IAccessTokenProvider from the dependency-injection container:

services.AddOpcUa()
    .AddClient(configuration.GetSection("OpcUa:Client"))
    .AddAccessTokenProvider(tokens)
    .AddIdentityProvider(configuration.GetSection("OpcUa:Client:Identity"));

The matching appsettings.json (only the AddIdentityProvider(section) overload picks this up — AddClient(IConfiguration) alone binds the section into OpcUaClientOptions.Identity but does not register providers):

{
  "OpcUa": {
    "Client": {
      "SessionName": "MyClient",
      "Identity": {
        "EnableAnonymous": true,
        "UserName": {
          "UserName":        "alice",
          "SecretName":      "alice-password",
          "SecretStoreType": "InMemory"
        },
        "IssuedToken": {
          "ProfileUri":   "http://opcfoundation.org/UA/UserToken#JWT",
          "AuthorityUri": "https://issuer.example"
        }
      }
    }
  }
}

IssuedToken.AuthorityUri selects which registered IAccessTokenProvider to use (matched against IAccessTokenProvider.AuthorityUri). Omit it if exactly one provider is registered.

GDS example

IGdsServerBuilder forwards every identity-related extension to the underlying server builder (ConfigureRoles, AddIdentityAuthenticator<T>, AddIdentityAugmenter<T>, AddDefaultIdentityAuthenticators, AddJwtIssuer — each with both Action<> and IConfiguration overloads where applicable). A GDS host configures identity the same way as a regular server, just on the GDS builder:

services.AddOpcUa()
    .AddGdsServer(opt => opt.ApplicationName = "MyGds")
    .AddDefaultIdentityAuthenticators(opt => opt.EnableJwt = false);

GdsServerHostedService consumes the forwarded authenticator and augmenter registrations during start-up and registers them with the same identity registry used by the regular hosted server. The GDS default authenticator helper also registers GdsApplicationSelfAdminProvider so SelfAdmin elevation works without custom host code.

Configuration reference

Key Type Default
OpcUa:Server:Identity:Defaults:EnableAnonymous bool true
OpcUa:Server:Identity:Defaults:EnableUserNamePassword bool true
OpcUa:Server:Identity:Defaults:EnableX509 bool true
OpcUa:Server:Identity:Defaults:EnableJwt bool true
OpcUa:Server:Identity:Defaults:ExpectedAudience string? null (required when EnableJwt)
OpcUa:Server:Identity:Defaults:ClockSkewTolerance TimeSpan 00:01:00
OpcUa:Server:Identity:Defaults:UserCertificateTrustList TrustListIdentifier Users
OpcUa:Server:Identity:Issuers[].IssuerUri string — (required)
OpcUa:Server:Identity:Issuers[].JwksUri string? null
OpcUa:Server:Identity:Issuers[].StaticKeys[].Kid string? null
OpcUa:Server:Identity:Issuers[].StaticKeys[].Algorithm string RS256
OpcUa:Server:Identity:Issuers[].StaticKeys[].RsaPublicKeyPem string? null (SPKI or PKCS#1 PEM)
OpcUa:Server:Identity:Issuers[].StaticKeys[].RsaModulus / .RsaExponent string? base64url-encoded JWK n / e
OpcUa:Server:Identity:Issuers[].StaticKeys[].EcCurve string? P-256 / P-384 / P-521
OpcUa:Server:Identity:Issuers[].StaticKeys[].EcX / .EcY string? base64url-encoded JWK x / y
OpcUa:Server:Identity:Issuers[].Algorithms string[] ["RS256"]
OpcUa:Server:Identity:Issuers[].Audience string? null (falls back to Defaults.ExpectedAudience)
OpcUa:Client:Identity:EnableAnonymous bool true
OpcUa:Client:Identity:UserName:UserName string
OpcUa:Client:Identity:UserName:SecretName string
OpcUa:Client:Identity:UserName:SecretStoreType string
OpcUa:Client:Identity:UserName:SecretStorePath string? null
OpcUa:Client:Identity:X509:StoreType string
OpcUa:Client:Identity:X509:StorePath string
OpcUa:Client:Identity:X509:SubjectName string? null (exclusive with Thumbprint)
OpcUa:Client:Identity:X509:Thumbprint string? null (exclusive with SubjectName)
OpcUa:Client:Identity:IssuedToken:ProfileUri string http://opcfoundation.org/UA/UserToken#JWT
OpcUa:Client:Identity:IssuedToken:AuthorityUri string? null (selects the matching IAccessTokenProvider by AuthorityUri)
OpcUa:Client:Identity:Order string[] [] (registration order)

What gets auto-wired

Section Path that auto-wires it Notes
OpcUa:Server:Identity:Defaults AddServer(IConfiguration) The hosted service materialises the four default authenticators from the bound flags.
OpcUa:Server:Identity:Issuers[] AddServer(IConfiguration) Each entry is registered through AddJwtIssuer(...) at builder time.
OpcUa:Client:Identity AddClient(IConfiguration) (fallback at session-factory resolution) The session factory builds a composite from the bound options when no IClientIdentityProvider is registered AND at least one non-default field is set (any of UserName, X509, IssuedToken, Order, or EnableAnonymous = false). Pass the section to .AddIdentityProvider(IConfiguration) for an explicit eager registration.
OpcUa:Server:Identity:* from the AddServer(Action<>) path Not auto-wired Use the explicit fluent calls (AddDefaultIdentityAuthenticators, AddJwtIssuer).

See Dependency Injection for the full services.AddOpcUa() surface and how options flow through IConfiguration.

Three layers, kept separate

A common pitfall when designing identity systems is to collapse three distinct responsibilities into one interface. This API keeps them separate so each layer can be replaced independently:

Layer Interface What it does What it must NOT do
Authentication IUserTokenAuthenticator Validates a single token type / profile (password lookup, X.509 trust, JWT signature). Produces an IUserIdentity. Pre-grant Part 18 role NodeIds.
Augmentation IIdentityAugmenter Runs after an accepted authenticator and may wrap the identity with deployment-specific bindings such as GDS ApplicationSelfAdmin. Validate a token or run after rejection.
Claim extraction IIdentityClaims (probe interface on the returned identity) Surfaces OIDC / JWT / X.509 claims so role mapping has data to work with. Decide which roles get granted.
Role mapping IRoleManager.ResolveGrantedRoles (already exists, see Role-Based Security) Applies OPC UA Part 18 §4.4 identity-mapping rules to the claims and emits the granted role NodeIds. Authenticate the token.

If you find yourself granting roles inside an authenticator, you have overstepped — push that logic into an IRoleManager identity-mapping rule instead, then the same rule applies whether the user came in via UserName, X509, JWT, or a future token type.

Client side

IClientIdentityProvider — what to send in ActivateSession

IClientIdentityProvider returns an IUserIdentity lazily, per activation. Long-lived OPC UA sessions outlive short OAuth2 access tokens; the provider model is built around that fact.

using Opc.Ua.Identity;

public sealed class MyEntraIdentityProvider : IClientIdentityProvider
{
    private readonly IAccessTokenProvider m_tokens;
    private DateTime m_lastExpiry = DateTime.MaxValue;

    public MyEntraIdentityProvider(IAccessTokenProvider tokens)
    {
        m_tokens = tokens;
    }

    public IReadOnlyList<UserTokenType> SupportedTokenTypes
        => new[] { UserTokenType.IssuedToken };

    public IReadOnlyList<string> SupportedIssuedTokenProfileUris
        => new[] { Profiles.JwtUserToken };

    public bool CanSatisfy(UserTokenPolicy policy, IdentitySelectionContext _)
        => policy.TokenType == UserTokenType.IssuedToken
           && string.Equals(policy.IssuedTokenType, Profiles.JwtUserToken,
               StringComparison.Ordinal);

    public DateTime ExpiresAt => m_lastExpiry;

    public async ValueTask<IUserIdentity> GetIdentityAsync(
        UserTokenPolicy policy,
        IdentitySelectionContext context,
        CancellationToken ct = default)
    {
        // 1. Parse the server's IssuerEndpointUrl JSON per OPC 10000-6 §6.5.2.2.
        if (!AuthorizationServerMetadata.TryFromPolicy(policy, out var meta))
        {
            throw ServiceResultException.Create(
                StatusCodes.BadIdentityTokenRejected,
                "Policy is not a JWT IssuedToken policy.");
        }

        // 2. Acquire the access token via OAuth2 / MSAL / GDS / etc.
        using AccessToken accessToken = await m_tokens
            .AcquireAsync(meta, ct)
            .ConfigureAwait(false);
        m_lastExpiry = accessToken.ExpiresAt;

        // 3. Wrap in an IssuedIdentityToken handler.
        return new UserIdentity(
            accessToken.TokenData.ToArray(),
            accessToken.ProfileUri)
        {
            PolicyId = policy.PolicyId
        };
    }
}

Key design notes:

  • Token refresh is the session's responsibility, not the provider's. Call await session.UpdateIdentityAsync(provider, ct) to reactivate a raw Session with a fresh identity. The method serialises with the existing reactivation lock and binds the new token to the current server nonce.
  • Use ManagedSessionOptions.IdentityProvider for managed clients. ManagedSession calls UpdateIdentityAsync after connect, then schedules proactive refresh at provider.ExpiresAt - 60s using the configured TimeProvider. Refresh failures are logged and retried with backoff; they do not close the session.
var options = new ManagedSessionOptions
{
    Endpoint = endpoint,
    IdentityProvider = new IssuedTokenIdentityProvider(accessTokens)
};

ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
    .UseEndpoint(endpoint)
    .WithIdentityProvider(options.IdentityProvider)
    .ConnectAsync(ct);

IAccessTokenProvider — orthogonal to the OPC UA stack

IAccessTokenProvider is decoupled from the OPC UA session entirely. A single provider serves any number of OPC UA endpoints whose UserTokenPolicy.IssuerEndpointUrl points at the same Authorization Service.

public sealed class StaticJwtBearerProvider : IAccessTokenProvider
{
    private readonly byte[] m_bytes;
    public StaticJwtBearerProvider(string jwt, string authorityUri)
    {
        m_bytes = System.Text.Encoding.UTF8.GetBytes(jwt);
        AuthorityUri = authorityUri;
    }

    public string AuthorityUri { get; }

    public ValueTask<AccessToken> AcquireAsync(
        AuthorizationServerMetadata metadata,
        CancellationToken ct = default)
    {
        return new ValueTask<AccessToken>(new AccessToken(
            Profiles.JwtUserToken,
            (byte[])m_bytes.Clone(),
            DateTime.MaxValue,
            "static-jwt"));
    }
}

AccessToken is IDisposable and zeroes its buffer on dispose — that is the only reliable "secure clear" path until the JWT is encrypted into the wire token. Always use using or pass ownership to a UserIdentity/handler that takes ownership.

AuthorizationServerMetadata — the JSON nobody told you about

The OPC 10000-6 §6.5.2.2 IssuerEndpointUrl field of a JWT UserTokenPolicy is, despite its name, not a URL — it is a JSON object that describes the Authorization Service (authority URI, resource URI, request types, scopes, optionally token / JWKS endpoints).

using Opc.Ua.Identity;

if (AuthorizationServerMetadata.TryFromPolicy(policy, out var meta))
{
    Console.WriteLine($"Authority   : {meta.AuthorityUri}");
    Console.WriteLine($"Resource    : {meta.ResourceUri}");
    Console.WriteLine($"Flows       : {string.Join(",", meta.RequestTypes)}");
    Console.WriteLine($"Scopes      : {string.Join(",", meta.Scopes)}");
    Console.WriteLine($"Token EP    : {meta.TokenEndpoint ?? "(OIDC discovery)"}");
}

The parser:

  • Tolerates the ua: namespace prefix used by the spec (ua:resourceUriResourceUri).
  • Accepts OIDC-style aliases (issuer, token_endpoint, authorization_endpoint, jwks_uri, scopes_supported) so you can feed an OIDC .well-known/openid-configuration payload directly.
  • Accepts a bare string where the spec allows an array (some servers emit a single value for requestTypes).
  • Preserves unknown fields in AdditionalFields for vendor-specific use (e.g. Entra tenant_id, PKCE configuration).
  • Throws BadDecodingError on malformed JSON or non-object root.
  • Returns an empty instance (no exception) for null, empty, or whitespace-only payloads.

Server side

IUserTokenAuthenticator — what to do with the incoming token

using Opc.Ua.Identity;

public sealed class StaticUserNameAuthenticator : IUserTokenAuthenticator
{
    private readonly Func<string, byte[], bool> m_checkPassword;

    public StaticUserNameAuthenticator(Func<string, byte[], bool> checkPassword)
    {
        m_checkPassword = checkPassword;
    }

    public UserTokenType TokenType => UserTokenType.UserName;
    public string IssuedTokenProfileUri => null;  // n/a for UserName

    public ValueTask<AuthenticationResult> AuthenticateAsync(
        AuthenticationContext context,
        CancellationToken ct = default)
    {
        if (context.TokenHandler is not UserNameIdentityTokenHandler u)
        {
            return new ValueTask<AuthenticationResult>(
                AuthenticationResult.NotHandled);
        }
        if (string.IsNullOrEmpty(u.UserName) || u.DecryptedPassword == null)
        {
            return new ValueTask<AuthenticationResult>(
                AuthenticationResult.Reject(
                    new ServiceResult(StatusCodes.BadIdentityTokenInvalid)));
        }
        if (!m_checkPassword(u.UserName, u.DecryptedPassword))
        {
            return new ValueTask<AuthenticationResult>(
                AuthenticationResult.Reject(
                    new ServiceResult(StatusCodes.BadUserAccessDenied)));
        }

        var identity = new UserIdentity(u);  // wraps the wire handler
        return new ValueTask<AuthenticationResult>(
            AuthenticationResult.Accept(identity));
    }
}

The three outcomes:

  • AuthenticationResult.Accept(identity) — token validated, identity returned.
  • AuthenticationResult.Reject(serviceResult) — token decoded but refused. The status code flows back to the client as the ActivateSession response error. Lockout / failure counters are recorded through the SessionManager failed-authentication path.
  • AuthenticationResult.NotHandled — the authenticator does not own this token type / profile. The registry moves on to the next authenticator and ultimately falls back to the legacy callback when no authenticator claimed the token.

IServerIdentityRegistry — composing authenticators

var registry = new ServerIdentityRegistry(
    new AnonymousAuthenticator(),
    new StaticUserNameAuthenticator(MyUserDb.CheckPassword),
    new X509Authenticator(myCertValidator),
    new JwtAuthenticator(myEntraKeyResolver, expectedAudience: applicationUri));

Authenticators are tried in registration order. For UserTokenType.IssuedToken the registry dispatches by the IssuedTokenProfileUri carried on the inbound IssuedIdentityTokenHandler — register a JWT authenticator with IssuedTokenProfileUri = Profiles.JwtUserToken and it only sees JWT tokens; a SAML or Kerberos authenticator on the same channel is left to handle the rest. Register with IssuedTokenProfileUri = null for a catch-all (useful when bridging to a legacy ITokenValidator).

Identity augmenters

IIdentityAugmenter is a post-authentication hook. It runs only after an authenticator returns AuthenticationOutcome.Accepted; NotHandled and Rejected skip the chain. Augmenters may return the same identity or wrap it with extra deployment-specific state.

public sealed class MyTenantAugmenter : IIdentityAugmenter
{
    public ValueTask<IUserIdentity> AugmentAsync(
        IUserIdentity identity,
        AuthenticationContext context,
        CancellationToken ct = default)
    {
        // Inspect context.ChannelCertificate, context.ChannelApplicationUri,
        // claims, or deployment state, then return identity or a wrapper.
        return new ValueTask<IUserIdentity>(identity);
    }
}

services.AddOpcUa()
    .AddServer(o => o.ApplicationUri = "urn:my-server")
    .AddIdentityAugmenter<MyTenantAugmenter>();

// Manual host alternative:
server.CurrentInstance.IdentityRegistry.RegisterAugmenter(
    new MyTenantAugmenter());

The GDS server ships GdsApplicationSelfAdminProvider, an augmenter that implements OPC 10000-12 §7.2 ApplicationSelfAdmin by matching the secure-channel ApplicationInstance certificate to the registered application certificate.

Claims surface — wiring IdentityCriteriaType.GroupId and Role

IRoleManager.ResolveGrantedRoles probes returned identities for IIdentityClaims and uses that surface to satisfy OPC 10000-18 §4.4.4 claim criteria:

  • IdentityCriteriaType.GroupId matches entries in IIdentityClaims.Groups.
  • IdentityCriteriaType.Role matches entries in IIdentityClaims.Roles. The criterion may be either roleName or issuer-qualified as issuerUri/roleName; the issuer-qualified form also checks IIdentityClaims.Issuer.

Authenticators should populate IIdentityClaims when the token contains claims. Role assignment remains a role-manager decision; authenticators validate credentials and return an identity.

internal sealed class JwtUserIdentity : IUserIdentity, IIdentityClaims
{
    public JwtUserIdentity(
        IUserIdentityTokenHandler handler,
        IReadOnlyDictionary<string, object> claims,
        IReadOnlyList<string> groups,
        IReadOnlyList<string> roles,
        string issuer,
        string subject)
    {
        TokenHandler = handler;
        Claims = claims;
        Groups = groups;
        Roles = roles;
        Issuer = issuer;
        Subject = subject;
    }

    public string DisplayName => Subject;
    public string PolicyId => TokenHandler.Token.PolicyId;
    public UserTokenType TokenType => UserTokenType.IssuedToken;
    public XmlQualifiedName IssuedTokenType => IssuedTokenType.JWT;
    public bool SupportsSignatures => false;
    public ArrayOf<NodeId> GrantedRoleIds => default;
    public IUserIdentityTokenHandler TokenHandler { get; }

    public IReadOnlyDictionary<string, object> Claims { get; }
    public IReadOnlyList<string> Groups { get; }
    public IReadOnlyList<string> Roles { get; }
    public string Issuer { get; }
    public string Subject { get; }
}

A role rule such as CriteriaType = IdentityCriteriaType.GroupId and Criteria = "engineering-leads" matches when Groups contains engineering-leads. See Role-Based Security for a worked Entra JWT example.

ITokenIssuer — server-side JWT issuance

ITokenIssuer is the server-side counterpart of IAccessTokenProvider. It backs the modern Part 12 v1.05 AuthorizationServiceType StartRequestToken / FinishRequestToken flow and the legacy RequestAccessToken compatibility path.

public sealed class CertificateJwtIssuer : ITokenIssuer
{
    public string IssuerUri => "https://my-gds.example.com";
    public string ProfileUri => Profiles.JwtUserToken;

    public async ValueTask<AccessToken> IssueAsync(
        TokenIssuanceRequest request,
        CancellationToken ct = default)
    {
        byte[] jws = await SignJwsAsync(request, ct).ConfigureAwait(false);
        return new AccessToken(
            Profiles.JwtUserToken,
            jws,
            DateTime.UtcNow + request.RequestedLifetime,
            request.Subject);
    }
}

GDS hosts register the default issuer with WithAuthorizationService(...) or a custom issuer with WithAuthorizationService<TIssuer>(...); see AuthorizationService.

IIssuerKeyResolver + IssuerVerificationKey — JWT validation

Server-side JWT validation in the GDS JwtAuthenticator resolves verification keys through IIssuerKeyResolver. Consumers receive each key as a non-disposable IIssuerVerificationKey view (the resolver owns and disposes the concrete IssuerVerificationKey). The helper deliberately uses byte[] overloads (no System.IdentityModel.Tokens.Jwt) so it works on netstandard2.1 / net472 / net48 / net8+/net9+/net10+ and is AOT-friendly.

Supported JWS algorithms (RFC 7518 §3.1):

Algorithm Key type Notes
RS256 / RS384 / RS512 RSA PKCS#1 v1.5 padding
PS256 / PS384 / PS512 RSA RSA-PSS padding
ES256 / ES384 / ES512 ECDSA IEEE P1363 fixed-size r‖s format

HS256 (HMAC) is intentionally unsupported because the OPC UA multi-party authorization model would require sharing a symmetric secret with every verifier.

using RSA rsa = LoadRsaPublicKey();
using var key = new IssuerVerificationKey("kid-1", rsa, "RS256");
bool isValid = key.VerifySignature(signingInputBytes, signatureBytes);

How-to: server-side authentication

Use dependency injection when the .NET Generic Host owns the server. This example wires the shipped defaults, a custom authenticator, and a trusted JWT issuer.

using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Identity;
using Opc.Ua.Server;
using Opc.Ua.Server.Hosting;

services.AddOpcUa()
    .AddServer(o =>
    {
        o.ApplicationName = "MyServer";
        o.ApplicationUri = "urn:example:my-server";
        o.EndpointUrls.Add("opc.tcp://localhost:4840");
    })
    .AddIdentityAuthenticator<MyAuthenticator>()
    .AddDefaultIdentityAuthenticators(o =>
    {
        o.EnableAnonymous = true;
        o.EnableUserNamePassword = true;
        o.EnableX509 = true;
        o.EnableJwt = true;
        o.ExpectedAudience = "urn:example:my-server";
    })
    .AddJwtIssuer(o =>
    {
        o.IssuerUri = "https://issuer.example";
        o.JwksUri = "https://issuer.example/.well-known/jwks.json";
        o.Audience = "urn:example:my-server";
    });

public sealed class MyAuthenticator : IUserTokenAuthenticator
{
    public UserTokenType TokenType => UserTokenType.UserName;
    public string? IssuedTokenProfileUri => null;

    public ValueTask<AuthenticationResult> AuthenticateAsync(
        AuthenticationContext context, CancellationToken ct = default)
    {
        if (context.TokenHandler is not UserNameIdentityTokenHandler userName)
        {
            return new ValueTask<AuthenticationResult>(AuthenticationResult.NotHandled);
        }

        bool ok = userName.UserName == "alice" &&
            !Utils.Utf8IsNullOrEmpty(userName.DecryptedPassword);
        return new ValueTask<AuthenticationResult>(ok
            ? AuthenticationResult.Accept(new UserIdentity(userName))
            : AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadUserAccessDenied)));
    }
}

Manual hosts can register against the running server instance. Prefer dependency injection for hosted applications, but this is useful for existing StandardServer subclasses:

server.CurrentInstance.IdentityRegistry.Register(new MyAuthenticator());
server.CurrentInstance.IdentityRegistry.Register(
    new JwtAuthenticator(keyResolver, expectedAudience: "urn:example:my-server"));

GdsServerHostedService consumes the same dependency-injection registrations, so a GDS host uses .AddIdentityAuthenticator<T>(), .AddDefaultIdentityAuthenticators(), and .AddJwtIssuer(...) exactly like a regular server.

How-to: client-side provider selection

The shipped client providers are composable. CompositeClientIdentityProvider tries providers in order and picks the first one that satisfies the server's selected UserTokenPolicy.

using Opc.Ua;
using Opc.Ua.Identity;
using Opc.Ua.Security.Certificates;

ISecretRegistry secrets = BuildSecretRegistry();
IAccessTokenProvider accessTokens = BuildAccessTokenProvider();
ICertificateProvider certificates = configuration.CertificateManager.CertificateProvider;
ICertificatePasswordProvider passwords = new CertificatePasswordProvider();

IClientIdentityProvider provider = new CompositeClientIdentityProvider(
    new UserNamePasswordIdentityProvider(
        "alice",
        secrets,
        new SecretIdentifier("alice-password", "InMemory")),
    new X509ClientIdentityProvider(
        new CertificateIdentifier
        {
            StoreType = CertificateStoreType.Directory,
            StorePath = "pki/user",
            SubjectName = "CN=Alice"
        },
        passwords,
        certificates),
    new IssuedTokenIdentityProvider(accessTokens, Profiles.JwtUserToken));

await session.UpdateIdentityAsync(provider, ct).ConfigureAwait(false);

The access-token-backed shipped type is IssuedTokenIdentityProvider, which wraps an IAccessTokenProvider and materializes a UA IssuedIdentityToken for ActivateSession.

For managed clients, put the same provider on ManagedSessionOptions.IdentityProvider so ManagedSession can refresh proactively:

var options = new ManagedSessionOptions
{
    Endpoint = endpoint,
    IdentityProvider = provider
};

ISessionFactory sessionFactory = sp.GetRequiredService<ISessionFactory>();
ManagedSession managed = await ManagedSession.CreateAsync(
    configuration, endpoint, sessionFactory, identityProvider: provider, ct: ct);

How-to: migrate from SessionManager.ImpersonateUser

The event remains as a compatibility fallback, but it is marked [Obsolete]. New code should move validation into one IUserTokenAuthenticator per token type. See 2.0 migration guide — User Identity Providers for the full migration table.

1. Legacy event code

server.CurrentInstance.SessionManager.ImpersonateUser +=
    SessionManager_ImpersonateUser;

private void SessionManager_ImpersonateUser(
    Session session, ImpersonateEventArgs args)
{
    if (args.NewIdentity is UserNameIdentityToken userName &&
        ValidatePassword(userName.UserName, userName.DecryptedPassword))
    {
        args.Identity = new UserIdentity(userName);
        return;
    }

    args.Identity = null;
}

2. Implement an authenticator for that token type

public sealed class MyUserNameAuthenticator : IUserTokenAuthenticator
{
    public UserTokenType TokenType => UserTokenType.UserName;
    public string? IssuedTokenProfileUri => null;

    public ValueTask<AuthenticationResult> AuthenticateAsync(
        AuthenticationContext context, CancellationToken ct = default)
    {
        if (context.TokenHandler is not UserNameIdentityTokenHandler userName)
        {
            return new ValueTask<AuthenticationResult>(AuthenticationResult.NotHandled);
        }

        return new ValueTask<AuthenticationResult>(
            ValidatePassword(userName.UserName, userName.DecryptedPassword)
                ? AuthenticationResult.Accept(new UserIdentity(userName))
                : AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadUserAccessDenied)));
    }
}

3. Register via dependency injection or the server registry

services.AddOpcUa()
    .AddServer(o => o.ApplicationUri = "urn:example:my-server")
    .AddIdentityAuthenticator<MyUserNameAuthenticator>();

// Without dependency injection:
server.CurrentInstance.IdentityRegistry.Register(new MyUserNameAuthenticator());

Migrate one token type at a time. If no authenticator handles a token, the registry falls back to the obsolete event so existing deployments can stage the change safely.

Implementing your own provider

The shipped interfaces are intentionally small so deployments can build provider packages without changing stack internals. The sections below show how to implement common provider integrations on top of the core OPC UA identity abstractions plus the provider SDK you need.

Entra ID provider

  • Implement EntraIdAccessTokenProvider : IAccessTokenProvider by wrapping MSAL IPublicClientApplication for native/user-delegated clients or IConfidentialClientApplication for daemon services. Public clients should call AcquireTokenSilent first, then fall back to interactive or device-code flows; services should use AcquireTokenForClient.
  • Let MSAL own token caching and refresh. Return the acquired JWT as an AccessToken, mapping the audience from AuthorizationServerMetadata.ResourceUri, api://<server-app-id>, or the target server ApplicationUri; map user-delegated scopes from AuthorizationServerMetadata.Scopes, and use <server-app-id>/.default for client credentials.
  • Implement EntraIdClientIdentityProvider : IClientIdentityProvider by composing the access-token provider with IssuedTokenIdentityProvider and Profiles.JwtUserToken.
  • On the server, validate Entra JWTs with JwksIssuerKeyResolver (or a provider-specific EntraIdJwtIssuerKeyResolver) against https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys. Register it through AddJwtIssuer(...) or JwtAuthenticator, cache keys by kid, and refresh once on unknown keys or signature failure to handle tenant key rotation.

OIDC provider

  • Implement OidcAccessTokenProvider : IAccessTokenProvider from an issuer or authority URI. Read .well-known/openid-configuration to discover the authorization, token, and JWKS endpoints, and map the discovered values into AuthorizationServerMetadata so existing OPC UA token-policy metadata still drives provider selection.
  • For desktop and native clients, use authorization code + PKCE: generate a high-entropy code_verifier, send BASE64URL(SHA256(code_verifier)) as the challenge, exchange the returned code at the token endpoint, and return the access token as an OPC UA AccessToken.
  • Persist refresh tokens and related state through ISecretStore or ISecretRegistry so deployments can use DPAPI, Keychain, Key Vault, Kubernetes secrets, or another durable store. Try the refresh-token grant before launching a new authorization-code flow.
  • Implement OidcClientIdentityProvider : IClientIdentityProvider as an IssuedTokenIdentityProvider wrapper. For server validation, use JwksIssuerKeyResolver or an OidcJwksKeyResolver : IIssuerKeyResolver against the discovered jwks_uri, cache by kid and algorithm, honor HTTP cache headers, and refresh once before rejecting an unknown kid.

Windows Integrated provider

  • Implement WindowsIntegratedClientIdentityProvider : IClientIdentityProvider to run a SPNEGO/Kerberos exchange and emit the resulting AP-REQ or wrapped Negotiate token as an issued user token profile owned by the package.
  • Use System.Net.Security.NegotiateAuthentication on net8.0+; use System.Net.NegotiateStream on down-level TFMs where the newer API is unavailable.
  • Derive the service principal name from the target OPC UA endpoint and ApplicationUri, for example HOST/<server-fqdn> for host-bound deployments or OPCUA/<server-fqdn> where an OPC UA-specific SPN is registered.
  • Implement KerberosUserTokenAuthenticator : IUserTokenAuthenticator to validate the AP-REQ or wrapped token, verify the ticket target matches the expected server identity, and return an IUserIdentity.
  • Decode PAC group SIDs into IIdentityClaims.Groups so Part 18 IdentityCriteriaType.GroupId and role criteria can map Windows groups without changing IRoleManager.

ASP.NET Core provider

  • Implement AspNetCoreAccessTokenProvider : IAccessTokenProvider by adapting Microsoft.Identity.Web ITokenAcquisition.GetAccessTokenForUserAsync(...). Resolve the current ClaimsPrincipal from IHttpContextAccessor or from an explicit accessor delegate supplied by the host.
  • Map AuthorizationServerMetadata.Scopes to the requested scope list and use AuthorizationServerMetadata.ResourceUri as the additional resource or audience hint when the identity provider requires one.
  • Use the ASP.NET Core / Microsoft.Identity.Web token cache for user-delegated tokens. If the host persists refresh-token references in cookies, session, or a server-side token cache, keep raw token material behind ISecretStore or ISecretRegistry abstractions.
  • Implement AspNetCoreClientIdentityProvider : IClientIdentityProvider by composing AspNetCoreAccessTokenProvider with IssuedTokenIdentityProvider, then register both as scoped services so request handlers can resolve the identity provider and call Session.UpdateIdentityAsync(...).
  • Server-side JWT validation is the same as other OIDC providers: configure AddJwtIssuer(...) or JwtAuthenticator with the issuer, audience, and JWKS endpoint used by the ASP.NET Core authority.

See also