# OPC UA REST Binding (Part 6 §G.3 "OpenAPI Mapping") The OPC UA REST binding exposes the OPC UA service set as ASP.NET Core **Minimal-API endpoints**, implementing the **OpenAPI Mapping** defined in [OPC UA Part 6 §G.3](https://reference.opcfoundation.org/specs/OPC-10000-6/g-3) (v1.05.07). The binding ships as part of `OPCFoundation.NetStandard.Opc.Ua.Bindings.Https` (net8+ only; mounted on the same Kestrel host as the binary and `application/opcua+uajson` sub-profiles). - **Server side**: ASP.NET Core Minimal-API endpoints (one `MapPost` per spec service) — **NativeAOT-compatible**; no MVC reflection, no `[UnconditionalSuppressMessage]` attributes. - **Client side**: symmetric `IWebApiClient` in `OPCFoundation.NetStandard.Opc.Ua.Client` under `src/Opc.Ua.Client/WebApi/`. ## Quick start ```csharp using Microsoft.Extensions.DependencyInjection; using Opc.Ua; using Opc.Ua.Bindings; services .AddOpcUa() .AddHttpsTransport() // existing binary + opcua+uajson .AddWebApiTransport() // adds REST routes alongside them .AddWebApiAnonymousAuth(); // or AddWebApiBearerAuth(...) etc. ``` The REST surface mounts inside the existing `HttpsTransportListener` Kestrel pipeline, so a single port serves binary, `application/opcua+uajson`, **and** REST traffic without configuring a second listener. A spec-shaped client request: ```bash curl -X POST https://server:4843/read \ -H 'Content-Type: application/json' \ --data '{ "RequestHeader": { "TimeoutHint": 10000 }, "TimestampsToReturn": 2, "NodesToRead": [ { "NodeId": "i=2258", "AttributeId": 13 } ] }' ``` returns the matching `ReadResponse` body. Add `?encoding=verbose` to the `Content-Type` (or `Accept`) to opt into the Verbose JSON flavour. ## Routes (full coverage) The binding implements every service in the spec's [`opc.ua.openapi.allservices.json`](https://github.com/OPCFoundation/UA-Nodeset/blob/latest/Schema/opc.ua.openapi.allservices.json) document — 28 services across the Discovery, Session, View, Attribute, Method, MonitoredItem, and Subscription service sets. NodeManagement (Part 4 §5.8) and Query (§5.10) are **not in scope** — the spec document deliberately omits them. | Service set (Part 4) | Routes | | --- | --- | | Discovery (§5.5) | `/findservers`, `/getendpoints` | | Session (§5.7) | `/createsession`, `/activatesession`, `/closesession`, `/cancel` | | View (§5.9) | `/browse`, `/browsenext`, `/translate`, `/registernodes`, `/unregisternodes` | | Attribute (§5.11) | `/read`, `/write`, `/historyread`, `/historyupdate` | | Method (§5.12) | `/call` | | MonitoredItem (§5.13) | `/createmonitoreditems`, `/modifymonitoreditems`, `/setmonitoringmode`, `/settriggering`, `/deletemonitoreditems` | | Subscription (§5.14) | `/createsubscription`, `/modifysubscription`, `/setpublishingmode`, `/publish`, `/republish`, `/transfersubscriptions`, `/deletesubscriptions` | All routes are `POST` with a JSON body holding the matching `Request`; the response body is the matching `Response`. The route table is the source of truth — see `WebApiServiceRoutes` in `src/Opc.Ua.Core/Stack/WebApi/`. ## Encoding negotiation OPC UA Part 6 §5.4 defines two JSON flavours: **Compact** (default, mandatory) and **Verbose**. The REST binding selects between them via the `encoding` media-type parameter: | Request `Content-Type` | Server response `Content-Type` | | --- | --- | | `application/json` | `application/json; encoding=compact` | | `application/json; encoding=compact` | `application/json; encoding=compact` | | `application/json; encoding=verbose` | `application/json; encoding=verbose` | | `application/json; encoding=verbose` + `Accept: application/json; encoding=compact` | `application/json; encoding=compact` | The `Accept` header takes precedence for the response encoding; the `Content-Type` header governs how the inbound body is decoded. Unknown `encoding` parameter values fall back to **Compact**. ## Discovery When the binding is registered, the `HttpsServiceHost` emits a discovery-only twin per `SecurityMode.None` HTTPS-binary endpoint with: - `TransportProfileUri = http://opcfoundation.org/UA-Profile/Transport/https-uajson-openapi` (OPC Foundation [profile/2338](https://profiles.opcfoundation.org/profile/2338); surfaced via `Profiles.HttpsOpenApiTransport`) - `SecurityMode = None` (TLS provides transport security) - `SecurityPolicyUri = None` - `ServerCertificate` and `UserIdentityTokens` copied from the companion HTTPS-binary description so clients can pick a compatible identity at activate time. Clients discover the binding through `GetEndpoints` and resolve it by the `TransportProfileUri` — the synthetic registry-key scheme `opc.https+webapi` (`Utils.UriSchemeOpcHttpsWebApi`) maps the profile to the `WebApiTransportChannelFactory` while the wire-level URL stays `https://...`. The fluent shortcut `ManagedSessionBuilder.UseWebApiEndpoint(url)` constructs the endpoint description with all of these fields pre-populated. > The companion WebSocket sub-profile > `Profiles.WssOpenApiTransport` (OPC Foundation [profile/2339](https://profiles.opcfoundation.org/profile/2339)) > is implemented as a peer to the HTTPS surface: > `WebApiWssTransportChannel` on the client side, > `HttpsTransportListener.AcceptWebSocketOpenApiAsync` on the server > side. Fluent shortcut: > `ManagedSessionBuilder.UseWssOpenApiEndpoint(url, encoding)`. Bearer > tokens ride in the sub-protocol name > (`opcua+openapi+`) because browser WebSocket APIs forbid > custom HTTP request headers. > > **Security considerations for the bearer-prefix sub-protocol:** > the WebSocket spec requires the server to echo the selected > sub-protocol back to the client in the 101 handshake. The bearer > token therefore appears in plain text in: > 1. The 101 response on the wire (rejected by the client when the > URL scheme is not `wss://`; rejected by the server when the > request is not `IsHttps`). > 2. The server's Kestrel access log (`Sec-WebSocket-Protocol` header). > 3. Any HTTP proxy / WAF / load-balancer log on the path. > > Operators **must** redact the `Sec-WebSocket-Protocol` header from > logs and **should** use short-lived tokens (≤ 60 s TTL) so a > captured token expires before it can be replayed. The server also > requires `AddWebApiBearerAuth(...)` to be registered: without a > bearer validator the listener fail-closed rejects every > bearer-prefix upgrade rather than echo the token back. ## Wire format The body is the bare `Request` / `Response` object — **no `{UaTypeId, UaBody}` envelope** at the HTTPS layer. Body serialization uses the stack's existing `JsonEncoder` / `JsonDecoder` driven by `WebApiBodyCodec`. The encoder shape matches the spec's component schemas property-for-property; see `WebApiEncoderConformanceTests` in `tests/Opc.Ua.Core.Tests/Stack/WebApi/`. ## Authentication Four auth modes are pluggable via DI: ```csharp services.AddOpcUa() .AddHttpsTransport() .AddWebApiTransport() .AddWebApiAnonymousAuth() // baseline .AddWebApiBearerAuth(opt => { opt.Authority = "..."; }) // JWT .AddWebApiBasicAuth(async (user, password) => { /* ... */ }) // RFC 7617 .AddWebApiMutualTlsAuth(opt => { opt.AllowedCertificateTypes = ... }); ``` - **Anonymous** — no `Authorization` header; identity = `AnonymousIdentityToken`. - **Bearer JWT** — standard `Microsoft.AspNetCore.Authentication.JwtBearer` middleware; the JWT flows into the OPC UA dispatcher through `ISessionlessIdentityProvider`. - **HTTP Basic** — in-package `BasicAuthenticationHandler`. Rejects requests over plain HTTP by default (`Options.RequireHttps = true`). - **Mutual TLS** — `Microsoft.AspNetCore.Authentication.Certificate` against the client cert presented to Kestrel. Kestrel itself must be configured to request client certificates (the existing `HttpsSettings.HttpsMutualTls` flag is the supported path). For sessionless services (Read/Write/Browse/…) the resolved `IUserIdentity` is attached to the `WebApiInvocationContext.Identity` and flows into the dispatcher's role-based-access pipeline through `ISessionlessIdentityProvider`. For session-based services the client is responsible for calling `CreateSession` + `ActivateSession` with credentials in the request body — the binding does not double-authenticate. The `WebApiHttpsStartupContributor` automatically inserts `app.UseAuthentication()` between `UseRouting()` and `UseEndpoints()` whenever at least one auth scheme is registered (detected via `IOptions.Schemes`), so the `HttpContext.User` is populated before the dispatcher invokes the sessionless identity provider. Bindings registered without any `AddWebApi*Auth()` call skip the authentication middleware entirely to preserve the historical anonymous request flow. The default `ISessionlessIdentityProvider` is intentionally conservative; register a custom implementation to map richer claim sets (e.g. JWT role claims → OPC UA roles). ### JWT claim projection (built-in) For services configured with `Microsoft.AspNetCore.Authentication.JwtBearer`, the binding ships a built-in identity provider that projects the authenticated JWT principal onto an OPC UA `IUserIdentity`: ```csharp services.AddOpcUa() .AddHttpsTransport() .AddWebApiTransport() .AddWebApiBearerAuth(opt => { opt.Authority = "..."; }) .UseJwtClaimIdentityProvider(opt => { opt.SubjectClaim = "sub"; // default opt.ScopeClaim = "scope"; // default — space- or comma-separated opt.RolesClaim = "roles"; // default — also picks up ClaimTypes.Role opt.ReturnAnonymousForUnauthenticated = false; // default opt.TransformIdentity = (identity, ctx) => identity; // optional hook }); ``` The provider builds a `UserIdentity` carrying the raw bearer token as an `IssuedIdentityToken` (`Profiles.JwtUserToken`) so server-side authenticators can re-validate the JWT downstream, and exposes a projection of the subject / scopes / roles via the static helpers `JwtClaimSessionlessIdentityProvider.GetSubject(IUserIdentity)`, `GetScopes(IUserIdentity)`, and `GetRoles(IUserIdentity)` for role-based-access lookups. Scopes accept either the single space-separated OAuth 2.0 form or repeated `scope` claims; roles accept comma/semicolon/space-separated values plus `ClaimTypes.Role` mappings. ## Hosting modes ```csharp services.AddWebApiTransport(opt => { opt.HostingMode = WebApiHostingMode.SharedWithHttpsListener; // default opt.DefaultEncoding = WebApiEncoding.Compact; // spec default }); ``` - **`SharedWithHttpsListener`** (default) — Minimal-API endpoints mount into the existing `HttpsTransportListener` Kestrel pipeline via the internal `IHttpsListenerStartupContributor` hook. Single port for binary / `opcua+uajson` / REST. ## Long-poll `/publish` `PublishRequest`/`PublishResponse` flow through the same dispatcher the binary path uses. The server side awaits the next notification (or the request's `RequestHeader.TimeoutHint`) without blocking a thread. Kestrel timeouts must exceed the largest expected `TimeoutHint`: ```csharp webHostBuilder.UseKestrel(o => { o.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(5); o.Limits.RequestBodyTimeout = TimeSpan.FromMinutes(5); }); ``` On the client, set `WebApiClientOptions.RequestTimeout = Timeout.InfiniteTimeSpan` for the publish endpoint so the server-side `TimeoutHint` governs cancellation. ## Client integration The OPC Foundation publishes reference WebApi clients for several ecosystems: - TypeScript: - Python: - .NET: On the .NET stack this binding's REST surface is wired into `ManagedSession` via the fluent `ManagedSessionBuilder.UseWebApiEndpoint(url, encoding)` shortcut, so applications open and operate a WebApi session through the same `ISession` / subscription / monitored-item API that the binary transport uses: ```csharp using Opc.Ua.Client; await using ManagedSession session = await new ManagedSessionBuilder(telemetry) .UseWebApiEndpoint("https://server:4843/") .WithSessionName("Aot-Sample-Client") .WithUserIdentity(new UserIdentity()) .StartAsync(ct) .ConfigureAwait(false); ReadResponse response = await session.ReadAsync(new ReadRequest { RequestHeader = new RequestHeader { TimeoutHint = 10000 }, NodesToRead = new ArrayOf(/* … */) }, ct).ConfigureAwait(false); ``` The companion `UseWssOpenApiEndpoint(url)` shortcut binds the same session model to the WebSocket `opcua+openapi` sub-protocol. ## Related plans and follow-ups - **Source-generated OpenAPI document** (deferred) — the spec's `opc.ua.openapi.allservices.json` document and a runtime `/openapi/v1.json` endpoint will land in a future PR; the current binding produces spec-shaped JSON bodies without needing the document itself.