# Description Remove stock phrasing and correct misleading or inconsistent technical documentation. The initial two passes covered 367 Markdown files, followed by an interactive review of 169 editorial flags. The U5 interpretation originally deferred in that review has since received the upstream Mantis answer recorded by #4843; remaining server/spec follow-ups stay explicit in the CTT log. - Correct package responsibilities, migration steps, API names, ownership, protocol descriptions and links. Keep bundled migration guides synchronized. Preserve historical conclusions as historical records with separate re-check notes. - Provide at least two rendered TOC levels throughout `docs/`. Convert appropriate architecture figures to Mermaid without changing code examples, transcripts or historical quotations. - Address the October 19 feedback with concrete setup/migration instructions, corrected certificate paths and PubSub names, shorter repeated caveats, consistent XML comments, and a full review of the Concepts guide. Retain #4839's API corrections. - Integrate #4841's runtime/performance guidance and review incoming/recent prose. Preserve dated measurements rather than claiming new benchmark runs; clarify allocation ratios, startup-only processor settings, JIT warm-up and the benchmark commit's scope. Missing raw measurement artifacts and repeat ranges remain tracked by #4842. - Merge master through `e8c5d800a` in `32c6394b7`. Resolve the CTT log conflict using the updated Mantis states and OPC answers, retaining historical framing and re-checks for C28/C32/C43. - Address the latest naming/index feedback in `adb5bf693`: rename the guides to `docs/WotCon.md` and `docs/WotNodeSetConversion.md`; move the provider and DataType-declaration contracts into the conversion guide and remove `docs/WoT.md`. Rename `samples/DI` to `samples/Di` and `samples/Isa95` to `samples/ISA95`. Add content-index READMEs to Di, FluentApi, ISA95, Lds and PubSub; update solution entries, project references, Docker/CI paths, test fixture paths and documentation links with exact casing. **WoT guide scope.** This branch retains the detailed conversion guide that #4758 had replaced with an anchor stub. Conversion limits, verified document sets, preservation rules and provider ownership now live together in `WotNodeSetConversion.md`; binding/runtime guidance remains in `WotBindings.md` and `WotCon.md`. The design-rationale/test-ledger prose was shortened, the oversized argument-table cell moved into its section, and existing anchors preserved. **SDK policy is a separate, explicit user-directed change.** `global.json` sets a stable minimum of `10.0.400`, `rollForward: latestMajor`, and `allowPrerelease: false`. Newer major SDKs are permitted; CI's explicit SDK pins are unchanged. README, GettingStarted, DeveloperGuide and Recycling instructions reflect the possible analyzer differences. The image-contract regression fix accepts compatible stable SDK images instead of requiring exact equality with the floor. The latest four rename/index threads are resolved after publication. Three older threads remain open: universal obsolete-removal timing, unconditional nonce-strength wording, and a blanket Vision NativeAOT claim. Existing implementation/evidence constraints are explained in their inline replies. The two-level TOCs remain as requested. ## Validation - `git diff --check` and staged diff checks: passed. The untouched upstream AutoID NodeSet retains its provenance bytes, including two existing license-header trailing spaces. - `.\.azurepipelines\validate-migration-plugin.ps1`: passed; 15 bundled migration documents and manifests synchronized. - Documentation/path validation: **101** documents retain two rendered TOC levels (**2,228** entries), with idempotent formatting. **52** new relative links and their anchors passed exact-case checks. No tracked references to the old guide/sample paths remain. Both relocated provider-contract sections are preserved in the destination. - `dotnet sln UA.slnx list`: passed; shows the renamed Di/ISA95 projects and the existing FluentApi/Lds/PubSub projects. Updated solution and pump ProjectReference paths also passed exact-case existence checks. - Latest focused commands, run from the repository root: ```powershell # Passed: 61 channel send-order and reconnect tests. dotnet test tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~UaSCBinaryChannelSendOrderTests|FullyQualifiedName~TcpReconnectKeyAgreementTests' --verbosity minimal # Passed: 5 pump datasheet tests; builds the renamed pump sample. dotnet test tests\Opc.Ua.Di.Tests\Opc.Ua.Di.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~PumpDatasheetConformanceTests' --verbosity minimal # Passed: 4 repository NodeSet path/round-trip tests, including the moved pump models. dotnet test tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~RepositorySourceSurvivesSerializedLinkedPartitionsAsync' --verbosity minimal # Passed: build, zero warnings/errors. dotnet build samples\ISA95\MinimalIsa95Server\MinimalIsa95Server.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal # Passed: dependent sample-test project build, zero warnings/errors. dotnet build tests\Opc.Ua.WotCon.Samples.Tests\Opc.Ua.WotCon.Samples.Tests.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal ``` Earlier focused integration checks passed 2,620 tests across AutoID, event queues/scheduling, source generation, MCP/package profiles and WoT. Both reviewed Mermaid diagrams rendered locally without network access. These checks are not a full solution run. The full solution has **not** been rerun for this review. Earlier `dotnet test UA.slnx -p:CustomTestTarget=net10.0 --verbosity quiet` did not pass: three AOT projects rejected VSTest and 20 Vision tests failed during graphics initialization with `OutOfMemoryException`. Remaining long-running tests were stopped at the user's request. A focused Vision rerun passed 558 tests and reproduced those 20 failures. The earlier `dotnet test UA.slnx -p:CustomTestTarget=net48 --verbosity quiet` was blocked by the former SDK pin and was not retried after the policy change. The user approved proceeding with those limitations documented; current remote CI is not claimed green. ## Related Issues - Follow-up #4842 tracks the requested 2.0.2+ benchmark/tuning and diagnostic-tool documentation refresh. #4841 supplies documentation and earlier results, not all newly requested measurements. - #4758 explains the conversion guide's earlier consolidation; the current provider contracts are now consolidated into `WotNodeSetConversion.md`. - #4839 supplies the reviewed API corrections; #4843 supplies the updated CTT issue record. - The original prose cleanup and SDK policy were user-requested without a source issue. No new runtime architecture is introduced by these documentation edits. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [ ] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. --------- Co-authored-by: Roman Ettlinger <romanett98@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
45 KiB
Identity Providers (OPC UA Part 6 §6.5)
The OPC UA .NET Standard stack provides a pluggable identity-provider model. It covers the user-identity mechanisms in OPC UA Part 6 §6.5 and the identity-provider handshakes in OPC UA Part 4 §6.2.
The design is intentionally symmetric:
flowchart TB
shared["Opc.Ua.Core / Opc.Ua.Identity<br/>Shared interfaces, no behaviour change"]
client["IClientIdentityProvider<br/>IAccessTokenProvider<br/>AuthorizationServerMetadata"]
server["IUserTokenAuthenticator<br/>IServerIdentityRegistry<br/>ITokenIssuer"]
clientUsers["ManagedSession<br/>ConsoleReferenceClient<br/>Custom Entra/OIDC providers<br/>Custom Windows/AspNetCore providers"]
serverUsers["SessionManager<br/>ReferenceServer<br/>JwtAuthenticator, X509Authenticator<br/>AuthorizationServiceType"]
client --> shared
server --> shared
clientUsers -->|uses| client
serverUsers -->|uses| server
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.
Contents
- Quick start — dependency injection
- Separate identity responsibilities
- Client side
- Server side
- How-to: server-side authentication
- How-to: client-side provider selection
- How-to: migrate from
SessionManager.ImpersonateUser - Implementing your own provider
- See also
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 fromOpcUaServerOptions.Identity— that bridge is enabled only on theAddServer(IConfiguration)path. With theAction<>overload, callAddDefaultIdentityAuthenticators(opt => …)andAddJwtIssuer(opt => …)explicitly.
Custom authenticator registrations
AddIdentityAuthenticator<TAuth>() resolves an authenticator from
dependency injection. Two other extensions on IOpcUaServerBuilder,
in Microsoft.Extensions.DependencyInjection, support already-created
instances and configuration-aware construction:
| Overload | Use |
|---|---|
AddIdentityAuthenticator(IUserTokenAuthenticator) |
Register an existing authenticator instance. |
AddIdentityAuthenticator(Func<IServiceProvider, ICertificateValidatorEx?, IUserTokenAuthenticator>) |
Construct an authenticator during startup using injected services and the effective certificate validator. |
IUserTokenAuthenticator is in Opc.Ua.Identity;
ICertificateValidatorEx is in Opc.Ua.
The factory runs after configuration and certificate setup, including
when configuration is loaded from XML or a stream. Its validator can be
null: an authenticator that requires it should reject that case
explicitly instead of bypassing validation.
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Server.Hosting;
services.AddOpcUa()
.AddServer(options =>
{
options.ApplicationName = "CertificateServer";
options.ApplicationUri = "urn:example:CertificateServer";
options.UserTokenPolicies.Add(new OpcUaUserTokenPolicy
{
TokenType = UserTokenType.Anonymous
});
options.UserTokenPolicies.Add(new OpcUaUserTokenPolicy
{
TokenType = UserTokenType.Certificate
});
})
.AddIdentityAuthenticator(new Opc.Ua.Server.AnonymousAuthenticator())
.AddIdentityAuthenticator((_, validator) =>
new Opc.Ua.Server.X509Authenticator(
validator ?? throw new InvalidOperationException(
"A certificate validator is required for X.509 user tokens.")));
Provision user-certificate trust through the
certificate manager and stores; no certificate
or secret belongs in this registration code. Register matching
UserTokenPolicies as shown: adding an authenticator does not
automatically advertise its token policy. Use the service-provider
argument when the factory also needs application services.
The instance and factory overloads are on IOpcUaServerBuilder; the
existing generic registration remains available on the GDS builder.
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"));
AddIdentityProvider(section) explicitly registers a configuration-backed
IClientIdentityProvider in DI. AddClient(IConfiguration) also binds identity
options; when no session-specific or DI-registered provider is selected, the
session factory builds a composite from non-default bound options at resolution
time. The matching appsettings.json is:
{
"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 the shared identity configuration extensions 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) |
A session-specific provider takes precedence, followed by DI-registered IClientIdentityProvider instances. If neither is selected and at least one non-default field is set (UserName, X509, IssuedToken, Order, or EnableAnonymous = false), the session factory builds a composite from the bound options. Pass the section to .AddIdentityProvider(IConfiguration) for explicit DI registration; the registered factory constructs the provider when resolved. |
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.
Separate identity responsibilities
Authentication, augmentation, claim extraction and role mapping have separate contracts. Keep each responsibility in its designated interface:
| Responsibility | 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. |
Do not grant roles inside an authenticator. Put role assignment in an
IRoleManager identity-mapping rule instead. The same rule then applies
whether the user authenticated with a username, X.509 certificate, JWT, or
another 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 rawSessionwith a fresh identity. The method serialises with the existing reactivation lock and binds the new token to the current server nonce. - Use
ManagedSessionOptions.IdentityProviderfor managed clients.ManagedSessionrefreshes discovery metadata before acquiring the initial identity when endpoint refresh is enabled. Selection uses the configured security policies, instance-certificate algorithm and policy registry, just likeUpdateIdentityAsync. The first activation uses that identity; it does not require an anonymous session. A supplied single-use reverse connection without a reverse-connect manager must already have endpoint metadata, as on the direct session-factory path. The session then schedules proactive refresh atprovider.ExpiresAt - 60susing the configuredTimeProvider. 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:resourceUri→ResourceUri). - Accepts OIDC-style aliases (
issuer,token_endpoint,authorization_endpoint,jwks_uri,scopes_supported) so you can feed an OIDC.well-known/openid-configurationpayload directly. - Accepts a bare string where the spec allows an array (some servers
emit a single value for
requestTypes). - Preserves unknown fields in
AdditionalFieldsfor vendor-specific use (e.g. Entratenant_id, PKCE configuration). - Throws
BadDecodingErroron 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 ?? throw new ArgumentNullException(nameof(checkPassword));
}
public UserTokenType TokenType => UserTokenType.UserName;
public string? IssuedTokenProfileUri => null;
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 theActivateSessionresponse error. Lockout / failure counters are recorded through theSessionManagerfailed-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).
Register replaces the existing registration with the same token type and
profile, so application authenticators replace hosted defaults rather than
being shadowed by them. Issuer-backed JwtAuthenticator instances implement
IIssuerTokenAuthenticator: distinct issuers coexist, while re-registering the
same issuer replaces its old verifier. Issuer matching is ordinal and never
substitutes for signature, audience, or lifetime validation. A custom,
unqualified authenticator replaces every issuer for its type/profile; a later
issuer-qualified registration replaces any unqualified registration.
Rejections stop dispatch and preserve their status and diagnostic message.
If neither the registry nor the legacy callback handles a non-anonymous token,
session activation fails closed.
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.GroupIdmatches entries inIIdentityClaims.Groups.IdentityCriteriaType.Rolematches entries inIIdentityClaims.Roles. The criterion may be eitherroleNameor issuer-qualified asissuerUri/roleName; the issuer-qualified form also checksIIdentityClaims.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 AddAuthorizationService(...)
or a custom issuer with AddAuthorizationService<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. JwksIssuerKeyResolver
retains only its current snapshot; retired keys and their native handles are
collected when their last reader releases them. Refresh and resolver disposal
do not invalidate already returned JWKS keys. Other resolver owners dispose
their concrete IssuerVerificationKey instances according to their lifetime.
The helper
deliberately uses
byte[] overloads (no System.IdentityModel.Tokens.Jwt) so it works
on 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.AddSingleton<Func<string, byte[], bool>>(checkPassword);
services.AddOpcUa()
.AddServer(o =>
{
o.ApplicationName = "MyServer";
o.ApplicationUri = "urn:example:my-server";
o.EndpointUrls.Add("opc.tcp://localhost:4840");
})
.AddIdentityAuthenticator<StaticUserNameAuthenticator>()
.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";
});
This uses StaticUserNameAuthenticator from the server-side example.
Supply checkPassword from your application's credential-verification service before
registering it. It must verify the supplied password against the user's stored credentials;
a recognized username or a nonempty password is not sufficient. Do not log or retain the
decrypted password.
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 StaticUserNameAuthenticator(checkPassword));
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 : IAccessTokenProviderby wrapping MSALIPublicClientApplicationfor native/user-delegated clients orIConfidentialClientApplicationfor daemon services. Public clients should callAcquireTokenSilentfirst, then fall back to interactive or device-code flows; services should useAcquireTokenForClient. - Let MSAL own token caching and refresh. Return the acquired JWT as an
AccessToken, mapping the audience fromAuthorizationServerMetadata.ResourceUri,api://<server-app-id>, or the target serverApplicationUri; map user-delegated scopes fromAuthorizationServerMetadata.Scopes, and use<server-app-id>/.defaultfor client credentials. - Implement
EntraIdClientIdentityProvider : IClientIdentityProviderby composing the access-token provider withIssuedTokenIdentityProviderandProfiles.JwtUserToken. - On the server, validate Entra JWTs with
JwksIssuerKeyResolver(or a provider-specificEntraIdJwtIssuerKeyResolver) againsthttps://login.microsoftonline.com/{tenant}/discovery/v2.0/keys. Register it throughAddJwtIssuer(...)orJwtAuthenticator, cache keys bykid, and refresh once on unknown keys or signature failure to handle tenant key rotation.
OIDC provider
- Implement
OidcAccessTokenProvider : IAccessTokenProviderfrom an issuer or authority URI. Read.well-known/openid-configurationto discover the authorization, token, and JWKS endpoints, and map the discovered values intoAuthorizationServerMetadataso 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, sendBASE64URL(SHA256(code_verifier))as the challenge, exchange the returned code at the token endpoint, and return the access token as an OPC UAAccessToken. - Persist refresh tokens and related state through
ISecretStoreorISecretRegistryso 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 : IClientIdentityProvideras anIssuedTokenIdentityProviderwrapper. For server validation, useJwksIssuerKeyResolveror anOidcJwksKeyResolver : IIssuerKeyResolveragainst the discoveredjwks_uri, cache bykidand algorithm, honor HTTP cache headers, and refresh once before rejecting an unknownkid.
Windows Integrated provider
- Implement
WindowsIntegratedClientIdentityProvider : IClientIdentityProviderto 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.NegotiateAuthenticationonnet8.0+; useSystem.Net.NegotiateStreamon down-level TFMs where the newer API is unavailable. - Derive the service principal name from the target OPC UA endpoint and
ApplicationUri, for exampleHOST/<server-fqdn>for host-bound deployments orOPCUA/<server-fqdn>where an OPC UA-specific SPN is registered. - Implement
KerberosUserTokenAuthenticator : IUserTokenAuthenticatorto validate the AP-REQ or wrapped token, verify the ticket target matches the expected server identity, and return anIUserIdentity. - Decode PAC group SIDs into
IIdentityClaims.Groupsso Part 18IdentityCriteriaType.GroupIdand role criteria can map Windows groups without changingIRoleManager.
ASP.NET Core provider
- Implement
AspNetCoreAccessTokenProvider : IAccessTokenProviderby adaptingMicrosoft.Identity.WebITokenAcquisition.GetAccessTokenForUserAsync(...). Resolve the currentClaimsPrincipalfromIHttpContextAccessoror from an explicit accessor delegate supplied by the host. - Map
AuthorizationServerMetadata.Scopesto the requested scope list and useAuthorizationServerMetadata.ResourceUrias 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
ISecretStoreorISecretRegistryabstractions. - Implement
AspNetCoreClientIdentityProvider : IClientIdentityProviderby composingAspNetCoreAccessTokenProviderwithIssuedTokenIdentityProvider, then register both as scoped services so request handlers can resolve the identity provider and callSession.UpdateIdentityAsync(...). - Server-side JWT validation is the same as other OIDC providers: configure
AddJwtIssuer(...)orJwtAuthenticatorwith the issuer, audience, and JWKS endpoint used by the ASP.NET Core authority.
See also
- 2.0 migration guide — User Identity Providers for source migrations from obsolete identity APIs.
- 2.0 migration guide — User Identity Token Handlers for the 1.6 token-handler async / non-disposable refactor.
- Role-Based Security (OPC UA Part 18) for
the role-mapping layer that consumes
IIdentityClaims. - AuthorizationService for the OPC 10000-12 §9 service that issues access tokens.
- KeyCredentialService for the OPC 10000-12 §8 service, Push model, and experimental bridge.
- Dependency Injection for the
services.AddOpcUa()hosting surface. - Implementing your own provider for Entra ID, generic OIDC, Windows Integrated, and ASP.NET Core provider guidance.
- OPC UA specification references: