Skip to content

Server: session-less service invocation (OPC 10000-4 §6.3) with AddSessionlessInvocation - #4659

Merged
romanett merged 6 commits into
OPCFoundation:masterfrom
biancode:feat/sessionless-invocation
Oct 6, 2026
Merged

romanett merged 6 commits into
OPCFoundation:masterfrom
biancode:feat/sessionless-invocation

Conversation

@biancode

@biancode biancode commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Marc answered the five design questions (2026-10-05). This update implements the answers; see "Design decisions" at the end.

Description

OPC 10000-4 §6.3 lets a client invoke a subset of the services without CreateSession/ActivateSession. The request then carries either an Access Token in RequestHeader.authenticationToken or no token at all. The server today only offers the low-level ISessionManager.ValidateSessionLessRequest event. Without a handler, every request whose token names no session is answered with Bad_SessionIdInvalid, including a request without a token. That is the code a client of an expired session expects; for a session-less request, §6.3.1 has a server that does not support the invocation answer Bad_ServiceUnsupported.

This PR adds built-in session-less invocation behind an opt-in and keeps the existing event as the way to take over completely.

Changes

AddSessionlessInvocation() (src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs)

services.AddOpcUa()
    .AddServer(options => { /* … */ })
    .AddSessionlessInvocation(o => o.AllowAnonymous = false)  // the default
    .AddJwtIssuer(/* … */);   // validates the Access Tokens
  • Registers SessionlessInvocationOptions. DependencyInjectionStandardServer applies them to the SessionManager in use, including one created by a registered session manager factory, unless that manager already has options.
  • Without dependency injection: SessionManager.SessionlessInvocation = new SessionlessInvocationOptions().
  • Without either, nothing changes apart from the Bad_ServiceUnsupported answer below.

SessionlessInvocationOptions (new, src/Opc.Ua.Server/Session/)

  • AcceptAccessTokens (default true): an Access Token in RequestHeader.authenticationToken, carried as a String NodeId, is handed to the server's identity registry (IServerIdentityRegistry, the one ActivateSession uses) as an issued JWT token (OPC 10000-6 §7.6.5.2.3). The resulting identity runs the request. §6.3.1 requires an encrypted channel for the token, so it is only accepted over SignAndEncrypt or HTTPS.
  • AllowAnonymous (default false): a request without a token runs as an anonymous user. Without it, such a request is rejected with Bad_IdentityTokenInvalid.
  • MaxConcurrentRequests (default 64) and MaxConcurrentRequestsPerChannel (default 16): the budget for session-less requests, see below. Zero does not limit; a negative value is rejected.

Budget for session-less requests (SessionlessRequestBudget, new, internal)

#4541 bounds channels and incomplete messages. A session-less request has no Session to be accounted for, but it occupies a worker and keeps its channel active. It gets a budget of its own:

  • Two limits, checked and taken together under one System.Threading.Lock: concurrent requests on the server, and on one channel. A request refused by the channel limit takes no server-wide place. The channel is the SecureChannel on opc.tcp. The HTTPS bindings use one SecureChannel id per listener, so their requests are grouped by peer address (IPv4-mapped IPv6 addresses count as IPv4).
  • Answer: Bad_ServerTooBusy ("The Server does not have the resources to process the request at this time."), the code Add configurable server resource isolation #4541's request queue uses when it has no room. Bad_TooManyOperations means too many operations in one request, so it does not fit.
  • Lifetime: the place is taken before the identity is checked, which also bounds the validations that run at the same time. The lease is attached to the OperationContext and returned when the context is disposed, which every service does with using, however the request ends (completed, failed, cancelled). A rejected token, a handler error or exception, and a cancelled validation return it at once. A lease releases once, however often it is disposed. Channel entries are removed with their last request.
  • Scope: it applies to every session-less request on a manager that has SessionlessInvocation set, including requests a ValidateSessionLessRequest handler decides. A handler without AddSessionlessInvocation runs without a limit, as before. Requests with a Session token and services outside the §6.3.1 set are not counted.
  • The limits are read for each request, so a change of the options applies to the next request.

SessionManager

  • Service sets: a request without a session is limited to the service sets §6.3.1 allows: View (without RegisterNodes/UnregisterNodes), Attribute, Method, NodeManagement and Query. Every other service keeps answering Bad_SessionIdInvalid. IsSessionlessService(RequestType) is public.
  • Request without a token: with session-less invocation not enabled and no ValidateSessionLessRequest handler, it is answered with Bad_ServiceUnsupported (§6.3.1). A token that names no session stays Bad_SessionIdInvalid.
  • Precedence: a registered ValidateSessionLessRequest handler still decides first, unchanged apart from the service-set limit. ValidateSessionlessRequestAsync is protected virtual for servers that need their own rules.
  • Roles: the identity of a session-less request goes through the role resolution ActivateSession applies to the effective identity of a Session (the ResolveRoles part of AddMandatoryRoles: the MustChangePassword restriction, IRoleManager.ResolveGrantedRoles, so mapped Access Token claims and the built-in AuthenticatedUser role). This includes an identity a ValidateSessionLessRequest handler sets. There is no client application certificate without a Session, so no TrustedApplication role and no application-based mapping.
  • Cancellation: cancelling an Access Token validation propagates as OperationCanceledException so the endpoint maps it to the status of the request lifetime (previously it became Bad_UnexpectedError). The budget place is still returned.
  • Log: a rejected Access Token is logged with a dedicated [LoggerMessage] (ServerEventIds.SessionManager + 18, the next free id after Fix server session defects found by a spec-checked review of CreateSession, ActivateSession, session lifecycle and continuation points #4632).

Documentation

  • docs/SessionlessInvocation.md (new, linked from docs/README.md): which services, enabling it, caller identity, the answers without the feature, the request limits, the handler, calling the server over opc.tcp and the REST binding.
  • docs/ResourceIsolation.md: a short section "Requests without a Session" pointing to the budget.
  • docs/DependencyInjection.md: AddSessionlessInvocation in the identity extension tables.

Tests

  • Opc.Ua.Server.Tests/SessionlessInvocationTests (mocked server internals):
    • SessionlessRequestIsUnsupportedUnlessEnabled, AnUnknownSessionTokenStaysBadSessionIdInvalid, ServicesOutsideTheSessionlessSetStillNeedASession (7 services), TheServiceSetLimitAppliesBeforeAValidateSessionLessRequestHandler, AValidateSessionLessRequestHandlerTakesPrecedenceAsync;
    • SessionlessRequestWithoutIdentityIsRejectedUnlessAnonymousIsAllowedAsync, SessionlessAccessTokenIsValidatedByTheIdentityRegistryAsync, AnAccessTokenNoAuthenticatorHandlesIsRejected, SessionlessAccessTokenRequiresAnEncryptedChannel (None, Sign), SessionlessAccessTokenIsAcceptedOverSignAndEncryptAsync, AccessTokensCanBeDisabled, IsSessionlessServiceFollowsTheServiceSetsOfPartFour (21 cases), ValidateSessionlessRequestRejectsMissingArguments;
    • wiring: AddSessionlessInvocationRegistersTheConfiguredOptions, AddSessionlessInvocationRejectsANullBuilder, TheHostedServerAppliesTheOptionsToItsSessionManager, TheHostedServerAppliesTheOptionsToAFactoryCreatedSessionManager, TheHostedServerKeepsOptionsTheSessionManagerAlreadyHas.
  • End to end against the reference server, Opc.Ua.Sessions.Tests/SessionlessServiceInvocationTests (opc.tcp, SessionClient on a channel without CreateSession):
    • ReadWithAnAccessTokenNeedsNoSessionAsync (SignAndEncrypt), AnAccessTokenOnAnUnencryptedChannelIsRejectedAsync, ARequestWithoutAnIdentityIsRejectedAsync, SubscriptionsStillNeedASessionAsync;
    • one sample per service set: ViewServicesRunWithoutASessionAsync (Browse, TranslateBrowsePathsToNodeIds), AttributeServicesRunWithoutASessionAsync (Write, HistoryRead), MethodServicesRunWithoutASessionAsync (Call), NodeManagementServicesRunWithoutASessionAsync (AddNodes). They check that the service runs and reports its operation result, not a missing session or an internal error, since Fix server session defects found by a spec-checked review of CreateSession, ActivateSession, session lifecycle and continuation points #4632 hardened only Browse/BrowseNext against a missing session. Query is not sampled: StandardServer does not implement QueryFirst/QueryNext.
  • REST binding, HttpsWebApiIntegrationTests.SessionlessReadOverRestRunsWithAnAccessTokenAsync (Compact and Verbose): POST /read with the Access Token as String NodeId ("AuthenticationToken": "s=<token>"), and the same request without a token.
  • Budget, Opc.Ua.Server.Tests/SessionlessRequestBudgetTests (22 tests):
    • the budget itself: server limit and release, channel limit that takes nothing from the server, server limit checked first, zero does not limit, a lease releases once, HTTPS grouped by peer address (IPv4-mapped form too), 200 concurrent callers against the limits (exactly the limit granted, none above the channel limit, parallel release leaves zero and no tracked channel), 16 callers acquiring and releasing in a loop (high-water mark never above the limit);
    • through SessionManager.ValidateRequestAsync: Bad_ServerTooBusy over the server and the channel limit, disposing a context twice releases once, places returned after a rejected Access Token, an unencrypted channel, a missing identity, a cancelled validation (while it waits, the place is taken), a handler error and a handler exception; the budget also limits requests a handler decides, and a handler-only server has none; requests outside the service set and requests a disabled server refuses take no place; changed limits apply to the next request and new options start a new budget;
    • options: finite defaults, negative values rejected.
  • Budget end to end, Opc.Ua.Sessions.Tests/SessionlessServiceInvocationTests.SessionlessRequestsRunInABudgetOfTheirOwnAsync (opc.tcp, requests held in the Access Token validation): the third request over a limit of two gets Bad_ServerTooBusy; after release, five sequential reads and five reads of an unknown node (operation result bad) all pass; a channel limit of one refuses the second request on that channel while another channel's request is admitted. ARequestWithoutATokenIsUnsupportedWhileTheFeatureIsOffAsync: Bad_ServiceUnsupported for a request without token on a server without the feature, Bad_SessionIdInvalid for a token that names no Session.
  • Fail without the change: the places-returned tests fail when the budget does not release on failure (4 of 22 fail with the release in SessionManager removed; the other budget tests use API this change adds). SessionlessRequestIsUnsupportedUnlessEnabled and TheServiceSetLimitAppliesBeforeAValidateSessionLessRequestHandler are unchanged from the first version.
  • Review follow-up (e9a35980b): SessionlessAccessTokenReceivesTheRolesMappedFromItsClaimsAsync, SessionlessAccessTokenWithoutTheMappedClaimIsNotGrantedTheRoleAsync, AnAnonymousSessionlessRequestIsGrantedTheAnonymousRoleOnlyAsync, TheIdentityOfAValidateSessionLessRequestHandlerReceivesTheMappedRolesAsync, AValidateSessionLessRequestHandlerThatSetsNoIdentityKeepsNoIdentityAsync; end to end ARoleMappedNodeIsReadWithTheRolesMappedFromTheAccessTokenAsync (the ServerDiagnosticsSummary, readable for SecurityAdmin only, is denied for a token whose claim is not mapped, readable once the rule maps it, denied for another token) and ACancelledAccessTokenValidationAnswersTheTimeoutAndReturnsItsPlaceAsync. The anonymous and no-identity tests guard unchanged behavior. With the SessionManager.cs change removed the three claim-mapping tests, the end-to-end role test (Bad_UserAccessDenied), the end-to-end cancellation test (Bad_UnexpectedError instead of Bad_Timeout) and ACancelledValidationReturnsItsPlaceAsync fail. Lines of this commit were not re-measured for patch coverage; the figure below is for 496f84fce.
  • ClientTest: the three assertions that accepted Bad_ServiceUnsupported too are back to master (Bad_SessionIdInvalid only), so the PR no longer touches that file. Since Client: never select an OpenAPI endpoint in SelectEndpoint, and keep a closed Session from sending requests without authenticationToken #4646 a closed Session refuses a request without authenticationToken itself; the server code is not reached. The three tests pass with the single code (12 cases on net10.0).
  • Results on macOS: Opc.Ua.Server.Tests full run on net10.0 8076 passed / 9 skipped / 1 failed (ACancelledValidationReturnsItsPlaceAsync, which asserted the old Bad_UnexpectedError mapping; updated to the propagated cancellation and passing); the session-less, SessionManager and role filters 368 passed / 0 failed on net10.0 and on net8.0, and the session-less invocation and budget tests 73 passed on net10.0 after the last test was added. Opc.Ua.Sessions.Tests with the HTTPS/WebApi/WSS/OpenApi/Sessionless/ClientTest filter: 400 passed / 241 skipped / 0 failed on net10.0 and on net8.0 (the earlier host-name failures were an environment issue of the test machine and are gone).
  • Builds: Opc.Ua.Server, Opc.Ua.Server.Tests and Opc.Ua.Sessions.Tests for net48, net8.0, net9.0 and net10.0 and Opc.Ua.Server in Release net10.0: 0 errors, no new warnings (the CA2213 in SamplingGroup.cs and the CA1861/CA2025 in other test files are on master too).
  • dotnet format (whitespace, style, analyzers --severity info, one --include per changed file): no finding on a changed line. What remains is on master lines (SessionManager.cs 552, 1374, 2205 and DependencyInjectionStandardServer.cs 228) and the line-ending finding of a macOS checkout.
  • Coverage (.azurepipelines/check-coverage.ps1 against master, only the session-less tests of Opc.Ua.Server.Tests on net10.0): patch 105/116 changed lines (90.5 %). The 11 uncovered lines are Session-bound lines of ValidateRequestAsync and the lockout logger that moved with the change; the rest of the suite covers them. The project floor is not meaningful for this subset.
  • Fork CI and CodeQL: previous head 0fba9f21d (master incl. WebApi: enforce AddWebApi*Auth on the HTTPS listener's own host (REST routes were served unauthenticated) #4674 merged): CI run 37307824769 green (one net48 timing test, KeepAliveHandlerV2Async, failed in the first attempt and passed on the job rerun; the PR does not touch subscriptions), CodeQL run 37307829138 green. Current head e9a35980b: CI run 37364178979, CodeQL run 37364182697 (results pending).

Design decisions

Marc's answers (2026-10-05) and what the PR does:

  1. Built in when AddSessionlessInvocation was used. Done.
  2. Service-set limit for all paths: it applies before a ValidateSessionLessRequest handler too. Done, unchanged.
  3. Bad_ServiceUnsupported for a request without a token on a server without the feature. Done, unchanged. A token that names no Session stays Bad_SessionIdInvalid.
  4. Identity model: an Access Token in the RequestHeader, validated as an issued JWT token through the identity registry, anonymous only by opt-in. Unchanged. The REST authentication is WebApi: enforce AddWebApi*Auth on the HTTPS listener's own host (REST routes were served unauthenticated) #4674, now in master.
  5. Separate budget for session-less requests: added, see "Budget for session-less requests". Open: no limit per identity yet (identities are claims-defined, so the keys would be unbounded); say if you want one.

Related Issues

Checklist

  • I have signed the CLA and read the CONTRIBUTING doc.
  • I have added tests that prove my fix is effective or that my feature works and increased code coverage.
  • 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. (Affected suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered by the fork CI's Windows net48 legs.)
  • I fixed all failing and flaky tests in the CI pipelines and all CodeQL warnings.
  • I have addressed all PR feedback received.

OPC 10000-4 6.3 lets a client invoke the View (without RegisterNodes and
UnregisterNodes), Attribute, Method, NodeManagement and Query services
without CreateSession/ActivateSession, with an Access Token in
RequestHeader.authenticationToken or with no token at all. The server
only offered the ValidateSessionLessRequest event for this, and without
a handler it answered every such request with Bad_SessionIdInvalid,
including one without a token.

AddSessionlessInvocation() registers SessionlessInvocationOptions, which
DependencyInjectionStandardServer applies to the session manager in use
(SessionManager.SessionlessInvocation without dependency injection):

- An Access Token, carried as a String NodeId, is validated as an issued
  JWT token by the server's identity registry, the one ActivateSession
  uses, and is only accepted over SignAndEncrypt or HTTPS (6.3.1).
- A request without a token runs as an anonymous user only with
  AllowAnonymous; otherwise it is rejected with Bad_IdentityTokenInvalid.

The session manager limits requests without a session to the service
sets 6.3.1 allows, also before a ValidateSessionLessRequest handler, which
still decides first. Without the feature and without a handler, a
request that carries no token is answered with Bad_ServiceUnsupported
(6.3.1); a token that names no session stays Bad_SessionIdInvalid. A
closed client Session sends its requests without a token, so the
ClientTest assertions for reads after Close accept both codes.

End-to-end tests run a Read with an Access Token over opc.tcp without
CreateSession, one service per service set, and a Read over the REST
binding. docs/SessionlessInvocation.md describes the feature.
@marcschier

marcschier commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

@biancode

Do you want this built in? Or should session-less invocation stay with applications through ValidateSessionLessRequest?

Built in for sure if AddSessionlessInvocation was used

Service-set limit before the handler: the §6.3.1 limit now also applies when a ValidateSessionLessRequest handler is registered. Until now a handler could admit any service. Is that change acceptable, or should the limit apply only to the built-in path?

No, all.

Bad_ServiceUnsupported for a request without a token: this changes the answer a client gets when it sends a request without a token to a server that has not enabled the feature (today Bad_SessionIdInvalid), for example from a closed Session (see the updated ClientTest assertions). OK, or only when AddSessionlessInvocation was called?

Ok by me

Identity model: an Access Token in the RequestHeader, validated as an issued JWT token through the identity registry, and anonymous only by opt-in. Is that the model you want, or do you have a different hook in mind?

No that is the model that makes sense. Of course with the rest auth fixed.

Resource limits: #4541 bounds channels without an activated session. Should session-less requests count toward a separate budget?

I think that is the only way to do it correctly. There is no session but the channel is active, nothing clearly accounted for at this point I believe

marcschier and others added 3 commits October 5, 2026 08:30
…closed

A closed Session refuses requests without authenticationToken itself since
the client change that keeps it from sending session-less requests, so the
server code is not reached. Return the three assertions to the single code
of master.
A request without a Session has no Session to be accounted for, although it
occupies a worker and keeps its channel active. Give session-less requests a
budget of their own (OPC 10000-4 6.3):

- SessionlessInvocationOptions.MaxConcurrentRequests (default 64) limits the
  requests that run at the same time on the server,
  MaxConcurrentRequestsPerChannel (default 16) the requests on one channel.
  HTTPS requests, which share one SecureChannel id per listener, are grouped
  by peer address. Zero does not limit.
- A request over a limit is answered with Bad_ServerTooBusy, the code the
  request queue uses when it has no resources for a request.
- The limits are checked and taken together under one lock. The place is
  taken before the identity is checked and returned with the OperationContext,
  or at once when the check fails or is cancelled.
- The limit applies to requests a ValidateSessionLessRequest handler decides
  when the options are set.

Document it in SessionlessInvocation.md and ResourceIsolation.md.
@biancode

biancode commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, all five answers are in.

  1. Built in. AddSessionlessInvocation() enables it. The options are applied to the session manager in use, including one from a registered factory. Without the call nothing is built in.
  2. Service-set limit for all paths. The §6.3.1 limit (View without RegisterNodes/UnregisterNodes, Attribute, Method, NodeManagement, Query) applies before a ValidateSessionLessRequest handler and before the built-in handling. Every other service keeps answering Bad_SessionIdInvalid.
  3. Bad_ServiceUnsupported. A request without a token on a server that has not enabled the feature (and has no handler) is answered with Bad_ServiceUnsupported. A token that names no session stays Bad_SessionIdInvalid. A new end-to-end test covers both over opc.tcp.
  4. Identity model. Unchanged: an Access Token in the RequestHeader, validated as an issued JWT token through the identity registry, over SignAndEncrypt or HTTPS. Anonymous only with AllowAnonymous. The REST authentication is WebApi: enforce AddWebApi*Auth on the HTTPS listener's own host (REST routes were served unauthenticated) #4674, now in master. I check the REST session-less test against current master before the PR leaves draft.
  5. Separate budget. Session-less requests now have their own budget, apart from the Sessions:
    • SessionlessInvocationOptions.MaxConcurrentRequests (default 64): requests that run at the same time on the server.
    • SessionlessInvocationOptions.MaxConcurrentRequestsPerChannel (default 16): the same on one channel. On opc.tcp the channel is the SecureChannel. The HTTPS bindings have one SecureChannel id per listener, so their requests are grouped by peer address.
    • 0 does not limit. A negative value is rejected.
    • A request counts from the identity check until its service has completed, failed or was cancelled. The lease is held by the OperationContext, so disposing the context releases it once. A request rejected during the identity check releases it too.
    • Over a limit the answer is Bad_ServerTooBusy: "The Server does not have the resources to process the request at this time." This is the code Add configurable server resource isolation #4541 uses for a full request queue. Bad_TooManyOperations means too many operations in one request, so it does not fit.
    • The limits are checked and taken together under one System.Threading.Lock. A refused request takes no place.
    • The budget also applies to requests a handler decides, when the options are set. A handler without AddSessionlessInvocation has no limit.
    • It does not replace the Add configurable server resource isolation #4541 stages. Those still apply to these requests. docs/SessionlessInvocation.md and docs/ResourceIsolation.md describe it.

The three ClientTest assertions are back to Bad_SessionIdInvalid alone, as on master. Since #4646 a closed Session refuses the request itself, so the server code is not reached there.

Open: a limit per identity is not included. Say if you want it.

@romanett
romanett marked this pull request as ready for review October 5, 2026 12:47
Copilot AI balanced review requested due to automatic review settings October 5, 2026 12:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Session-less identity propagation, role mapping, cancellation handling, and WSS confidentiality contain unresolved functional issues.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity · 1 Low severity

Open (3)
What changed in this PR

Adds opt-in OPC UA session-less service invocation with token authentication, concurrency limits, DI wiring, tests, and documentation.

Changes:

  • Implements session-less request validation and supported service sets.
  • Adds server/per-channel request budgets.
  • Adds comprehensive unit, integration, and REST coverage.
File Description
tests/​Opc.Ua.Sessions.Tests/​SessionlessServiceInvocationTests.cs Adds end-to-end opc.tcp tests.
tests/​Opc.Ua.Sessions.Tests/​HttpsWebApiIntegrationTests.cs Tests REST token invocation.
tests/​Opc.Ua.Server.Tests/​SessionlessRequestBudgetTests.cs Tests budget limits and cleanup.
tests/​Opc.Ua.Server.Tests/​SessionlessInvocationTests.cs Tests validation and DI behavior.
src/​Opc.Ua.Server/​Session/​SessionManager.cs Implements session-less validation.
src/​Opc.Ua.Server/​Session/​SessionlessRequestBudget.cs Adds concurrent-request accounting.
src/​Opc.Ua.Server/​Session/​SessionlessInvocationOptions.cs Defines feature options.
src/​Opc.Ua.Server/​Server/​OperationContext.cs Releases budget leases.
src/​Opc.Ua.Server/​Hosting/​OpcUaServerBuilderExtensions.cs Adds the builder extension.
src/​Opc.Ua.Server/​Hosting/​DependencyInjectionStandardServer.cs Applies options to hosted servers.
docs/​SessionlessInvocation.md Documents the feature.
docs/​ResourceIsolation.md Documents budget integration.
docs/​README.md Links the new guide.
docs/​DependencyInjection.md Documents DI registration.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Opc.Ua.Server/Session/SessionManager.cs
Comment thread src/Opc.Ua.Server/Session/SessionManager.cs
Comment thread docs/SessionlessInvocation.md Outdated
… propagate

A session-less request ran as the identity the registry returned, which
skips the role mapping ActivateSession applies. A JWT identity therefore had
no granted roles: neither the roles mapped from its claims nor the built-in
AuthenticatedUser role. The identity of a session-less request, including
one a ValidateSessionLessRequest handler sets, now goes through the same
role resolution as the effective identity of a Session. Without a Session
there is no client application certificate, so no TrustedApplication role
and no application-based mapping.

ValidateRequestAsync turned the cancellation of an Access Token validation
into Bad_UnexpectedError, which bypassed the endpoint's mapping to the
status of the request lifetime. The cancellation now propagates; the
session-less budget lease is still returned.

Reword the documentation of the session-less request budget: it is a ceiling
on top of the shared queue and execution stages and reserves nothing.
@romanett
romanett enabled auto-merge (squash) October 6, 2026 06:07
@romanett
romanett merged commit b0d8ca0 into OPCFoundation:master Oct 6, 2026
250 of 335 checks passed
marcschier added a commit that referenced this pull request Oct 7, 2026
…of CreateSession (#4689)

# Description

`AddSessionlessInvocation(o => o.AllowAnonymous = true)` (#4659) lets a
request without an Access Token run as an anonymous user. OPC 10000-4
§6.3.1 allows that only "if application authentication through the
SecureChannel is sufficient". On HTTPS the channel gave no such
authentication: on an endpoint with mutual TLS and a SignAndEncrypt
policy, a session-less request without a TLS client certificate and
without an Access Token was answered `Good`, over the REST binding and
over the binary POST with `OPCUA-SecurityPolicy: Basic256Sha256`.
`CreateSession` on the same endpoint answers `Bad_SecurityChecksFailed`
without the certificate, and ActivateSession on a new channel does too
(OPC 10000-6 §7.4.1: applications that use HTTPS and require application
authentication check the application certificates during the
CreateSession/ActivateSession handshake).

This PR makes a session-less request without an Access Token meet what
the endpoint demands of a Session. #4659 is merged, in no release tag
and opt-in, so this is a change to a new feature; the default
`AllowAnonymous = false` is not affected.

## Changes

### `SessionManager.ValidateSessionlessRequestAsync`
(`src/Opc.Ua.Server/Session/SessionManager.cs`)

With `AllowAnonymous`, a request without an Access Token now needs:

| Check | Where it applies | Status otherwise | Same as |
| --- | --- | --- | --- |
| The client certificate of the channel
(`SecureChannelContext.ClientChannelCertificate`) | endpoints with a
security mode or policy other than None, and HTTPS endpoints when
`HttpsMutualTls` is set | `Bad_SecurityChecksFailed` | CreateSession on
the HTTPS listener; ActivateSession on a new channel |
| An Anonymous user token policy on the endpoint (an endpoint without
policies does not restrict) | every endpoint |
`Bad_IdentityTokenRejected` | ActivateSession with an anonymous identity
token |
| An endpoint on the channel | every request |
`Bad_SecurityModeInsufficient` | the same request with an Access Token |

- The certificate is the one the transport has validated: the
SecureChannel certificate of opc.tcp and opc.wss, the TLS client
certificate of the HTTPS listener (REST, binary, JSON). So opc.tcp
channels with security keep working as they did, and an HTTPS client
that wants an anonymous session-less call presents its application
certificate in the TLS handshake.
- An HTTPS listener without `HttpsMutualTls` advertises SecurityMode
None without an Anonymous user token policy (`HttpsServiceHost`); an
anonymous session-less request there is now rejected as an anonymous
Session is.
- A request **with an Access Token** is unchanged: the token
authenticates the caller and HTTPS keeps it confidential (HTTPS clients
are not required to present a certificate, OPC 10000-6 §7.4.1). A
`ValidateSessionLessRequest` handler still takes over completely.
- Not changed: the transport credential authentication of the REST
routes (#4674).

`SessionlessInvocationOptions.AllowAnonymous` documents the rule.
`docs/SessionlessInvocation.md` has a new section "Anonymous requests
and the channel" with the table.

### Tests

- `tests/Opc.Ua.Sessions.Tests/SessionlessChannelSecurityTests.cs` (new,
net8.0+): a real reference server with an HTTPS listener, twice (mutual
TLS on with a SignAndEncrypt-only endpoint; mutual TLS off), over REST
and the binary POST, with and without a TLS client certificate:
anonymous without a certificate, with a certificate, `AllowAnonymous =
false`, a valid and a bogus Access Token, and CreateSession on the
binary POST without the certificate for comparison. 44 cases.
- `tests/Opc.Ua.Server.Tests/SessionlessInvocationTests.cs`: opc.tcp
with and without a channel certificate (Sign, SignAndEncrypt, None),
HTTPS with and without mutual TLS and with a secured endpoint, user
token policies with and without Anonymous, no endpoint, an Access Token
and a handler without a certificate. The existing tests that use
`AllowAnonymous` set `HttpsMutualTls = false` or give the channel an
endpoint.
- Fail without the change: 7 of the 9 new `SessionlessInvocationTests`
cases fail with the `SessionManager.cs` change removed (the opc.tcp with
security, HTTPS with and without mutual TLS, user token policy and
unknown endpoint tests); the 6 failing `SessionlessChannelSecurityTests`
cases are below.

## Testing

- **Reproduction on master (`c03c12838`)**:
`SessionlessChannelSecurityTests` without the change: 6 of 44 cases
fail, 38 pass. With mutual TLS on and no TLS client certificate the
anonymous request is answered `Good` instead of
`Bad_SecurityChecksFailed` (REST and binary). With mutual TLS off it is
answered `Good` instead of `Bad_IdentityTokenRejected` (REST and binary,
with and without a certificate). All 44 pass with the change; the Access
Token cases pass both ways.
- Results on macOS: `Opc.Ua.Server.Tests` full run on net10.0 8197
passed / 9 skipped / 0 failed; the session-less and SessionManager
filters 139 passed / 0 failed on net10.0 and on net8.0.
`Opc.Ua.Sessions.Tests` with the HTTPS/WebApi/Sessionless filters 125
passed / 0 failed on net10.0 and on net8.0, and with the
Wss/OpenApi/ClientTest filters 320 passed / 241 skipped / 0 failed on
net10.0.
- Builds: `Opc.Ua.Server`, `Opc.Ua.Server.Tests` and
`Opc.Ua.Sessions.Tests` for net48, net8.0, net9.0 and net10.0: 0
warnings, 0 errors.
- `dotnet format` (whitespace, style, analyzers `--severity info`, one
`--include` per changed file): no finding on a changed line (CA1849 at
`SessionManager.cs` 235 is on master).
- Coverage (`.azurepipelines/check-coverage.ps1` against master,
session-less and SessionManager tests of `Opc.Ua.Server.Tests` on
net10.0): patch 36/36 changed lines (100 %). The project floor is not
meaningful for this subset.
- CI and CodeQL on the PR branch: [CI run
37529750287](https://github.com/biancode/UA-.NETStandard/actions/runs/37529750287)
green (158/158 jobs, incl. macOS and Windows net48), [CodeQL run
37529754883](https://github.com/biancode/UA-.NETStandard/actions/runs/37529754883)
green, on `03a8e3d91`.

## Related Issues

- Fixes #4688
- Relates to #4659 (session-less invocation), #4657 (the REST binding's
session-less service set) and #4674 (REST authentication)

## Checklist

- [x] 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.
- [x] 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.
- [x] 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. (Affected
suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the branch CI's Windows net48 legs.)
- [ ] 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: Marc Schier <marcschier@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@biancode
biancode deleted the feat/sessionless-invocation branch October 7, 2026 09:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server: built-in session-less service invocation (OPC 10000-4 §6.3)

4 participants