mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
61edaa1ed0
# Description Drops support for **netstandard2.1** and **.NET Framework 4.7.2**. Libraries now target `net48`, `net8.0`, `net9.0` and `net10.0`. `Opc.Ua.Types` and `Opc.Ua.SourceGeneration.Core` still also target `netstandard2.0`, but only because the Roslyn source generators load them. There is no general netstandard2.0 support. **Preprocessor directives** (112 files) - Every `#if`/`#elif` mentioning `NETSTANDARD2_1*`, `NET472*`, `NET_STANDARD_TESTS`, `SKIP_ECC_CERTIFICATE_REQUEST_SIGNING` or `NETSTANDARD*` was evaluated against the remaining TFM set (net48, net8/9/10, plus netstandard2.0 inside `Opc.Ua.Types`/`tools`) and simplified. Dead branches were removed. - Example: `NETSTANDARD2_1 || NET8_0_OR_GREATER` → `NET8_0_OR_GREATER`. - Example: `NET8_0_OR_GREATER || NET472_OR_GREATER` → removed (always true). - Netstandard-only code was removed: `UnixFileModeShim`, the CRDT reflection path in `ReplicatedGossipOptions.CreateReaderOptions`, and the netstandard2.1-only test variants. **Custom test logic** - `targets.props` drops the `net472`, `netstandard2.0` and `netstandard2.1` `CustomTestTarget` profiles, `NetStandardTests` and `SKIP_ECC_CERTIFICATE_REQUEST_SIGNING`. Supported values are now `net48`, `net8.0`, `net9.0`, `net10.0`. - The `NET_STANDARD_TESTS` define is removed from 29 test projects. - The "build netstandard shells as net10.0" overrides are removed from 18 projects. - `RestrictForLegacyTfm` now only applies to `net48`. - `tests/customtest.bat` is updated to the new values. **Build / packaging / CI** - MSBuild TFM conditions are simplified: Kestrel package choice, MQTTnet v4 (net48 only), BouncyCastle, Di/OpenUsd/Vision/Stress test excludes, and `System.IO.FileSystem.AccessControl` (dropped from Core). - The MigrationAnalyzer nuspec drops its net472/netstandard2.1 lib groups. The Azure signing lists are updated. - `get-ci-matrix.ps1` removes the net472/netstandard test profiles (including the PR-gating `linux-netstandard2.1`) and the matching build legs. **Breaking API change (2.0 preview):** removed `HttpsTransportListener.IsWssTransportSupported` and `ReplicatedGossipOptions.IsGossipStateDecodingSupported`. They only returned `false` on netstandard2.1 and are now always `true`. The test guards that used them were removed as well. **Docs** - NuGet READMEs, `README.md`, `DeveloperGuide.md`, `PubSub.md`, the certificate/ECC/identity docs and the 2.0 migration guide (plus its `.agents` skill mirror) are updated. - The migration guide notes that 4.7.2 consumers must move to 4.8 and netstandard2.1 consumers to net8.0+. ### Reviewer notes - The `System.IO.FileSystem.AccessControl` `PackageVersion` pin is left in `Directory.Packages.props`. It has no direct reference any more, but transitive pinning is on, so it was kept to avoid shifting resolved versions. - The `#if` rewrite was done mechanically by a truth-table evaluator. The diff in the `.cs` files is limited to directive lines, removed dead branches, and stale-comment updates. ### Verified locally - `dotnet build UA.slnx -p:CustomTestTarget=net48`: 0 errors. - `dotnet build UA.slnx -p:CustomTestTarget=net10.0`: 0 errors. - Default multi-target builds (no `CustomTestTarget`) of Opc.Ua.Types, Bindings.Https, PubSub.Mqtt and Redundancy.Server succeeded. - net10.0 tests: - Opc.Ua.Redundancy.Server.Tests: 840/840 passed. - Opc.Ua.Types.Tests: 11363 passed, 1 skipped. - ModelDependencyScanner tests: 20/20 passed. - The full test suite was **not** run locally. ## Related Issues - None ## Checklist - [ ] 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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1057 lines
46 KiB
Markdown
1057 lines
46 KiB
Markdown
# 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](https://reference.opcfoundation.org/Core/Part6/v105/docs/6.5)
|
|
and the identity-provider handshakes in
|
|
[OPC UA Part 4 §6.2](https://reference.opcfoundation.org/Core/Part4/v105/docs/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`](../src/Opc.Ua.Core/Stack/Client/IUserIdentity.cs) /
|
|
[`IUserIdentityTokenHandler`](../src/Opc.Ua.Core/Stack/Types/IUserIdentityTokenHandler.cs)
|
|
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](#quick-start--dependency-injection)
|
|
- [Server example](#server-example)
|
|
- [Custom authenticator registrations](#custom-authenticator-registrations)
|
|
- [Client example](#client-example)
|
|
- [GDS example](#gds-example)
|
|
- [Configuration reference](#configuration-reference)
|
|
- [Three layers, kept separate](#three-layers-kept-separate)
|
|
- [Client side](#client-side)
|
|
- [`IClientIdentityProvider` — what to send in `ActivateSession`](#iclientidentityprovider--what-to-send-in-activatesession)
|
|
- [`IAccessTokenProvider` — orthogonal to the OPC UA stack](#iaccesstokenprovider--orthogonal-to-the-opc-ua-stack)
|
|
- [`AuthorizationServerMetadata` — the JSON nobody told you about](#authorizationservermetadata--the-json-nobody-told-you-about)
|
|
- [Server side](#server-side)
|
|
- [`IUserTokenAuthenticator` — what to do with the incoming token](#iusertokenauthenticator--what-to-do-with-the-incoming-token)
|
|
- [`IServerIdentityRegistry` — composing authenticators](#iserveridentityregistry--composing-authenticators)
|
|
- [Identity augmenters](#identity-augmenters)
|
|
- [Claims surface — wiring `IdentityCriteriaType.GroupId` and `Role`](#claims-surface--wiring-identitycriteriatypegroupid-and-role)
|
|
- [`ITokenIssuer` — server-side JWT issuance](#itokenissuer--server-side-jwt-issuance)
|
|
- [`IIssuerKeyResolver` + `IssuerVerificationKey` — JWT validation](#iissuerkeyresolver--issuerverificationkey--jwt-validation)
|
|
- [How-to: server-side authentication](#how-to-server-side-authentication)
|
|
- [How-to: client-side provider selection](#how-to-client-side-provider-selection)
|
|
- [How-to: migrate from `SessionManager.ImpersonateUser`](#how-to-migrate-from-sessionmanagerimpersonateuser)
|
|
- [1. Legacy event code](#1-legacy-event-code)
|
|
- [2. Implement an authenticator for that token type](#2-implement-an-authenticator-for-that-token-type)
|
|
- [3. Register via dependency injection or the server registry](#3-register-via-dependency-injection-or-the-server-registry)
|
|
- [Implementing your own provider](#implementing-your-own-provider)
|
|
- [Entra ID provider](#entra-id-provider)
|
|
- [OIDC provider](#oidc-provider)
|
|
- [Windows Integrated provider](#windows-integrated-provider)
|
|
- [ASP.NET Core provider](#aspnet-core-provider)
|
|
- [See also](#see-also)
|
|
|
|
## Quick start — dependency injection
|
|
|
|
For applications hosted on `Microsoft.Extensions.Hosting` (the default
|
|
in this stack — see [Dependency Injection](DependencyInjection.md)),
|
|
the identity surface is configured through fluent extensions on
|
|
`IOpcUaServerBuilder` and `IOpcUaClientBuilder`, plus matching
|
|
`appsettings.json` sections.
|
|
|
|
### Server example
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```json
|
|
{
|
|
"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.
|
|
|
|
### 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.
|
|
|
|
```csharp
|
|
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](CertificateManager.md); 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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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):
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```csharp
|
|
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](DependencyInjection.md) 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](RoleBasedUserManagement.md)) | 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.
|
|
|
|
```csharp
|
|
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` refreshes 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
|
|
like `UpdateIdentityAsync`. 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 at `provider.ExpiresAt - 60s` using the
|
|
configured `TimeProvider`. Refresh failures are logged and retried
|
|
with backoff; they do not close the session.
|
|
|
|
```csharp
|
|
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.
|
|
|
|
```csharp
|
|
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).
|
|
|
|
```csharp
|
|
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-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
|
|
|
|
```csharp
|
|
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 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
|
|
|
|
```csharp
|
|
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.
|
|
|
|
```csharp
|
|
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.
|
|
|
|
```csharp
|
|
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](RoleBasedUserManagement.md)
|
|
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.
|
|
|
|
```csharp
|
|
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](AuthorizationService.md).
|
|
|
|
### `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.
|
|
|
|
```csharp
|
|
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.
|
|
|
|
```csharp
|
|
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](#server-side).
|
|
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:
|
|
|
|
```csharp
|
|
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`.
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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](migrate/2.0.x/identity.md#user-identity-providers)
|
|
for the full migration table.
|
|
|
|
### 1. Legacy event code
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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
|
|
|
|
* [2.0 migration guide — User Identity Providers](migrate/2.0.x/identity.md#user-identity-providers)
|
|
for source migrations from obsolete identity APIs.
|
|
* [2.0 migration guide — User Identity Token Handlers](migrate/2.0.x/identity.md#user-identity-token-handlers)
|
|
for the 1.6 token-handler async / non-disposable refactor.
|
|
* [Role-Based Security (OPC UA Part 18)](RoleBasedUserManagement.md) for
|
|
the role-mapping layer that consumes `IIdentityClaims`.
|
|
* [AuthorizationService](AuthorizationService.md) for the OPC 10000-12 §9
|
|
service that issues access tokens.
|
|
* [KeyCredentialService](KeyCredentialService.md) for the OPC 10000-12 §8
|
|
service, Push model, and experimental bridge.
|
|
* [Dependency Injection](DependencyInjection.md) for the `services.AddOpcUa()`
|
|
hosting surface.
|
|
* [Implementing your own provider](#implementing-your-own-provider) for Entra ID, generic OIDC,
|
|
Windows Integrated, and ASP.NET Core provider guidance.
|
|
* OPC UA specification references:
|
|
* [Part 4 §6.2 — Authorization Services](https://reference.opcfoundation.org/Core/Part4/v105/docs/6.2)
|
|
* [Part 4 §7.40 — UserIdentityToken parameters](https://reference.opcfoundation.org/Core/Part4/v105/docs/7.40)
|
|
* [Part 6 §6.5 — Issued User Identity Tokens](https://reference.opcfoundation.org/Core/Part6/v105/docs/6.5)
|
|
* [Part 6 §6.5.2.2 — JWT UserTokenPolicy IssuerEndpointUrl](https://reference.opcfoundation.org/Core/Part6/v105/docs/6.5.2.2)
|
|
* [Part 12 §8 — KeyCredentialManagement](https://reference.opcfoundation.org/GDS/v105/docs/8)
|
|
* [Part 12 §9 — AuthorizationServices](https://reference.opcfoundation.org/GDS/v105/docs/9)
|
|
* [Part 18 — Role-Based Security](https://reference.opcfoundation.org/Core/Part18/v105/docs/)
|