mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-23 18:39:26 +08:00
b0d8ca0d3a
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`)
```csharp
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 #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 #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 #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 #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. #4674
merged): [CI run
37307824769](https://github.com/biancode/UA-.NETStandard/actions/runs/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](https://github.com/biancode/UA-.NETStandard/actions/runs/37307829138)
green. Current head `e9a35980b`: [CI run
37364178979](https://github.com/biancode/UA-.NETStandard/actions/runs/37364178979),
[CodeQL run
37364182697](https://github.com/biancode/UA-.NETStandard/actions/runs/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 #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
- Fixes #4658
- Relates to #4657 (the REST binding's session-less service set), #4646
(a closed `Session` no longer sends requests without token), #4541
(resource isolation) 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 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.
---------
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
610 lines
26 KiB
C#
610 lines
26 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
#nullable enable
|
|
|
|
using System;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Identity;
|
|
using Opc.Ua.Server.Hosting;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
/// <summary>
|
|
/// Session-less Service invocation (OPC 10000-4 §6.3) in
|
|
/// <see cref="SessionManager.ValidateRequestAsync"/> and its wiring
|
|
/// through <see cref="SessionlessInvocationOptions"/>.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Session")]
|
|
[Parallelizable]
|
|
public class SessionlessInvocationTests
|
|
{
|
|
private const string kGoodAccessToken = "good.jwt.token";
|
|
private static readonly string[] s_engineerRoles = ["Engineer"];
|
|
|
|
private Mock<IServerInternal> m_serverMock = null!;
|
|
private ITelemetryContext m_telemetry = null!;
|
|
private ApplicationConfiguration m_config = null!;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
m_telemetry = NUnitTelemetryContext.Create();
|
|
m_serverMock = new Mock<IServerInternal>();
|
|
m_serverMock.Setup(s => s.Telemetry).Returns(m_telemetry);
|
|
m_serverMock.Setup(s => s.NamespaceUris).Returns(new NamespaceTable());
|
|
m_serverMock.Setup(s => s.IdentityRegistry).Returns(new ServerIdentityRegistry());
|
|
|
|
m_config = new ApplicationConfiguration
|
|
{
|
|
ServerConfiguration = new ServerConfiguration
|
|
{
|
|
MinSessionTimeout = 1000,
|
|
MaxSessionTimeout = 3_600_000,
|
|
MaxSessionCount = 100,
|
|
MaxRequestAge = 60_000,
|
|
MaxBrowseContinuationPoints = 10,
|
|
MaxHistoryContinuationPoints = 10
|
|
}
|
|
};
|
|
}
|
|
|
|
[Test]
|
|
public void SessionlessRequestIsUnsupportedUnlessEnabled()
|
|
{
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config);
|
|
|
|
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await ValidateAsync(manager, CreateHttpsChannel(), NodeId.Null).ConfigureAwait(false));
|
|
|
|
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadServiceUnsupported));
|
|
}
|
|
|
|
[Test]
|
|
public void AnUnknownSessionTokenStaysBadSessionIdInvalid(
|
|
[Values] bool sessionlessEnabled)
|
|
{
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config);
|
|
if (sessionlessEnabled)
|
|
{
|
|
manager.SessionlessInvocation = new SessionlessInvocationOptions { AllowAnonymous = true };
|
|
}
|
|
|
|
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await ValidateAsync(manager, CreateHttpsChannel(), new NodeId(4711u)).ConfigureAwait(false));
|
|
|
|
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
|
|
}
|
|
|
|
[TestCase(RequestType.CreateSubscription)]
|
|
[TestCase(RequestType.Publish)]
|
|
[TestCase(RequestType.CreateMonitoredItems)]
|
|
[TestCase(RequestType.RegisterNodes)]
|
|
[TestCase(RequestType.UnregisterNodes)]
|
|
[TestCase(RequestType.CloseSession)]
|
|
[TestCase(RequestType.Cancel)]
|
|
public void ServicesOutsideTheSessionlessSetStillNeedASession(RequestType requestType)
|
|
{
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config)
|
|
{
|
|
SessionlessInvocation = new SessionlessInvocationOptions { AllowAnonymous = true }
|
|
};
|
|
|
|
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await ValidateAsync(manager, CreateHttpsChannel(), NodeId.Null, requestType)
|
|
.ConfigureAwait(false));
|
|
|
|
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
|
|
}
|
|
|
|
[Test]
|
|
public void TheServiceSetLimitAppliesBeforeAValidateSessionLessRequestHandler()
|
|
{
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config);
|
|
int calls = 0;
|
|
manager.ValidateSessionLessRequest += (_, args) =>
|
|
{
|
|
calls++;
|
|
args.Identity = new UserIdentity();
|
|
};
|
|
|
|
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await ValidateAsync(
|
|
manager,
|
|
CreateHttpsChannel(),
|
|
NodeId.Null,
|
|
RequestType.CreateSubscription).ConfigureAwait(false));
|
|
|
|
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
|
|
Assert.That(calls, Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
public async Task AValidateSessionLessRequestHandlerTakesPrecedenceAsync()
|
|
{
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config)
|
|
{
|
|
SessionlessInvocation = new SessionlessInvocationOptions()
|
|
};
|
|
IUserIdentity handlerIdentity = CreateUserNameIdentity("handler-user");
|
|
manager.ValidateSessionLessRequest += (_, args) => args.Identity = handlerIdentity;
|
|
|
|
OperationContext context = await ValidateAsync(manager, CreateHttpsChannel(), NodeId.Null)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(context.UserIdentity, Is.SameAs(handlerIdentity));
|
|
}
|
|
|
|
[Test]
|
|
public async Task SessionlessRequestWithoutIdentityIsRejectedUnlessAnonymousIsAllowedAsync()
|
|
{
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config)
|
|
{
|
|
SessionlessInvocation = new SessionlessInvocationOptions()
|
|
};
|
|
|
|
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await ValidateAsync(manager, CreateHttpsChannel(), NodeId.Null).ConfigureAwait(false));
|
|
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadIdentityTokenInvalid));
|
|
|
|
manager.SessionlessInvocation.AllowAnonymous = true;
|
|
OperationContext context = await ValidateAsync(manager, CreateHttpsChannel(), NodeId.Null)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(context.UserIdentity.TokenType, Is.EqualTo(UserTokenType.Anonymous));
|
|
Assert.That(context.Session, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public async Task SessionlessAccessTokenIsValidatedByTheIdentityRegistryAsync()
|
|
{
|
|
IUserIdentity issued = CreateUserNameIdentity("token-user");
|
|
UseJwtAuthenticator(issued);
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config)
|
|
{
|
|
SessionlessInvocation = new SessionlessInvocationOptions()
|
|
};
|
|
|
|
OperationContext context = await ValidateAsync(
|
|
manager,
|
|
CreateHttpsChannel(),
|
|
new NodeId(kGoodAccessToken, 0)).ConfigureAwait(false);
|
|
Assert.That(context.UserIdentity, Is.SameAs(issued));
|
|
|
|
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await ValidateAsync(
|
|
manager,
|
|
CreateHttpsChannel(),
|
|
new NodeId("forged.jwt.token", 0)).ConfigureAwait(false));
|
|
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadIdentityTokenRejected));
|
|
}
|
|
|
|
[Test]
|
|
public async Task SessionlessAccessTokenReceivesTheRolesMappedFromItsClaimsAsync()
|
|
{
|
|
using RoleManager roleManager = CreateEngineerRoleManager();
|
|
m_serverMock.Setup(s => s.RoleManager).Returns(roleManager);
|
|
UseJwtAuthenticator(new ClaimsTestIdentity(roles: s_engineerRoles));
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config)
|
|
{
|
|
SessionlessInvocation = new SessionlessInvocationOptions()
|
|
};
|
|
|
|
OperationContext context = await ValidateAsync(
|
|
manager,
|
|
CreateHttpsChannel(),
|
|
new NodeId(kGoodAccessToken, 0)).ConfigureAwait(false);
|
|
|
|
Assert.That(context.Session, Is.Null);
|
|
Assert.That(context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Engineer), Is.True);
|
|
Assert.That(context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public async Task SessionlessAccessTokenWithoutTheMappedClaimIsNotGrantedTheRoleAsync()
|
|
{
|
|
using RoleManager roleManager = CreateEngineerRoleManager();
|
|
m_serverMock.Setup(s => s.RoleManager).Returns(roleManager);
|
|
UseJwtAuthenticator(new ClaimsTestIdentity(roles: ["Operator"]));
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config)
|
|
{
|
|
SessionlessInvocation = new SessionlessInvocationOptions()
|
|
};
|
|
|
|
OperationContext context = await ValidateAsync(
|
|
manager,
|
|
CreateHttpsChannel(),
|
|
new NodeId(kGoodAccessToken, 0)).ConfigureAwait(false);
|
|
|
|
Assert.That(context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Engineer), Is.False);
|
|
Assert.That(context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public async Task AnAnonymousSessionlessRequestIsGrantedTheAnonymousRoleOnlyAsync()
|
|
{
|
|
using var roleManager = new RoleManager();
|
|
m_serverMock.Setup(s => s.RoleManager).Returns(roleManager);
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config)
|
|
{
|
|
SessionlessInvocation = new SessionlessInvocationOptions { AllowAnonymous = true }
|
|
};
|
|
|
|
OperationContext context = await ValidateAsync(manager, CreateHttpsChannel(), NodeId.Null)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Anonymous), Is.True);
|
|
Assert.That(context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser), Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public async Task TheIdentityOfAValidateSessionLessRequestHandlerReceivesTheMappedRolesAsync()
|
|
{
|
|
using RoleManager roleManager = CreateEngineerRoleManager();
|
|
m_serverMock.Setup(s => s.RoleManager).Returns(roleManager);
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config);
|
|
manager.ValidateSessionLessRequest += (_, args) =>
|
|
args.Identity = new ClaimsTestIdentity(roles: s_engineerRoles);
|
|
|
|
OperationContext context = await ValidateAsync(manager, CreateHttpsChannel(), NodeId.Null)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Engineer), Is.True);
|
|
Assert.That(context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public async Task AValidateSessionLessRequestHandlerThatSetsNoIdentityKeepsNoIdentityAsync()
|
|
{
|
|
using RoleManager roleManager = CreateEngineerRoleManager();
|
|
m_serverMock.Setup(s => s.RoleManager).Returns(roleManager);
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config);
|
|
manager.ValidateSessionLessRequest += (_, _) => { };
|
|
|
|
OperationContext context = await ValidateAsync(manager, CreateHttpsChannel(), NodeId.Null)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(context.UserIdentity, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public void AnAccessTokenNoAuthenticatorHandlesIsRejected()
|
|
{
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config)
|
|
{
|
|
SessionlessInvocation = new SessionlessInvocationOptions()
|
|
};
|
|
|
|
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await ValidateAsync(
|
|
manager,
|
|
CreateHttpsChannel(),
|
|
new NodeId(kGoodAccessToken, 0)).ConfigureAwait(false));
|
|
|
|
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadIdentityTokenRejected));
|
|
}
|
|
|
|
[TestCase(MessageSecurityMode.None)]
|
|
[TestCase(MessageSecurityMode.Sign)]
|
|
public void SessionlessAccessTokenRequiresAnEncryptedChannel(MessageSecurityMode securityMode)
|
|
{
|
|
UseJwtAuthenticator(CreateUserNameIdentity("token-user"));
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config)
|
|
{
|
|
SessionlessInvocation = new SessionlessInvocationOptions()
|
|
};
|
|
|
|
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await ValidateAsync(
|
|
manager,
|
|
CreateTcpChannel(securityMode),
|
|
new NodeId(kGoodAccessToken, 0)).ConfigureAwait(false));
|
|
|
|
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadSecurityModeInsufficient));
|
|
}
|
|
|
|
[Test]
|
|
public async Task SessionlessAccessTokenIsAcceptedOverSignAndEncryptAsync()
|
|
{
|
|
IUserIdentity issued = CreateUserNameIdentity("token-user");
|
|
UseJwtAuthenticator(issued);
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config)
|
|
{
|
|
SessionlessInvocation = new SessionlessInvocationOptions()
|
|
};
|
|
|
|
OperationContext context = await ValidateAsync(
|
|
manager,
|
|
CreateTcpChannel(MessageSecurityMode.SignAndEncrypt),
|
|
new NodeId(kGoodAccessToken, 0)).ConfigureAwait(false);
|
|
|
|
Assert.That(context.UserIdentity, Is.SameAs(issued));
|
|
}
|
|
|
|
[Test]
|
|
public void AccessTokensCanBeDisabled()
|
|
{
|
|
UseJwtAuthenticator(CreateUserNameIdentity("token-user"));
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config)
|
|
{
|
|
SessionlessInvocation = new SessionlessInvocationOptions { AcceptAccessTokens = false }
|
|
};
|
|
|
|
ServiceResultException? error = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await ValidateAsync(
|
|
manager,
|
|
CreateHttpsChannel(),
|
|
new NodeId(kGoodAccessToken, 0)).ConfigureAwait(false));
|
|
|
|
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
|
|
}
|
|
|
|
[TestCase(RequestType.Browse, true)]
|
|
[TestCase(RequestType.BrowseNext, true)]
|
|
[TestCase(RequestType.TranslateBrowsePathsToNodeIds, true)]
|
|
[TestCase(RequestType.Read, true)]
|
|
[TestCase(RequestType.HistoryRead, true)]
|
|
[TestCase(RequestType.Write, true)]
|
|
[TestCase(RequestType.HistoryUpdate, true)]
|
|
[TestCase(RequestType.Call, true)]
|
|
[TestCase(RequestType.AddNodes, true)]
|
|
[TestCase(RequestType.AddReferences, true)]
|
|
[TestCase(RequestType.DeleteNodes, true)]
|
|
[TestCase(RequestType.DeleteReferences, true)]
|
|
[TestCase(RequestType.QueryFirst, true)]
|
|
[TestCase(RequestType.QueryNext, true)]
|
|
[TestCase(RequestType.RegisterNodes, false)]
|
|
[TestCase(RequestType.UnregisterNodes, false)]
|
|
[TestCase(RequestType.CreateSubscription, false)]
|
|
[TestCase(RequestType.CreateMonitoredItems, false)]
|
|
[TestCase(RequestType.Publish, false)]
|
|
[TestCase(RequestType.ActivateSession, false)]
|
|
[TestCase(RequestType.FindServers, false)]
|
|
public void IsSessionlessServiceFollowsTheServiceSetsOfPartFour(RequestType requestType, bool expected)
|
|
{
|
|
Assert.That(SessionManager.IsSessionlessService(requestType), Is.EqualTo(expected));
|
|
}
|
|
|
|
[Test]
|
|
public void ValidateSessionlessRequestRejectsMissingArguments()
|
|
{
|
|
using var manager = new ProbeSessionManager(m_serverMock.Object, m_config);
|
|
|
|
Assert.That(
|
|
async () => await manager.ProbeAsync(null!, new SessionlessInvocationOptions()).ConfigureAwait(false),
|
|
Throws.ArgumentNullException);
|
|
Assert.That(
|
|
async () => await manager.ProbeAsync(CreateHttpsChannel(), null!).ConfigureAwait(false),
|
|
Throws.ArgumentNullException);
|
|
}
|
|
|
|
[Test]
|
|
public void AddSessionlessInvocationRegistersTheConfiguredOptions()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddOpcUa()
|
|
.AddServer(o =>
|
|
{
|
|
o.ApplicationName = "SessionlessServer";
|
|
o.ApplicationUri = "urn:localhost:SessionlessServer";
|
|
o.ProductUri = "urn:localhost:SessionlessServer:Product";
|
|
})
|
|
.AddSessionlessInvocation(o => o.AllowAnonymous = true);
|
|
|
|
using ServiceProvider provider = services.BuildServiceProvider();
|
|
SessionlessInvocationOptions? options = provider.GetService<SessionlessInvocationOptions>();
|
|
|
|
Assert.That(options, Is.Not.Null);
|
|
Assert.That(options!.AcceptAccessTokens, Is.True);
|
|
Assert.That(options.AllowAnonymous, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void AddSessionlessInvocationRejectsANullBuilder()
|
|
{
|
|
Assert.That(
|
|
() => OpcUaServerBuilderExtensions.AddSessionlessInvocation(null!),
|
|
Throws.ArgumentNullException);
|
|
}
|
|
|
|
[Test]
|
|
public void TheHostedServerAppliesTheOptionsToItsSessionManager()
|
|
{
|
|
var options = new SessionlessInvocationOptions();
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton(options);
|
|
using ServiceProvider provider = services.BuildServiceProvider();
|
|
using var server = new SessionManagerProbeServer(provider, m_telemetry);
|
|
|
|
ISessionManager manager = server.CreateManager(m_serverMock.Object, m_config);
|
|
try
|
|
{
|
|
Assert.That(manager, Is.InstanceOf<SessionManager>());
|
|
Assert.That(((SessionManager)manager).SessionlessInvocation, Is.SameAs(options));
|
|
}
|
|
finally
|
|
{
|
|
manager.Dispose();
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void TheHostedServerAppliesTheOptionsToAFactoryCreatedSessionManager()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddOpcUa()
|
|
.AddServer(o =>
|
|
{
|
|
o.ApplicationName = "SessionlessServer";
|
|
o.ApplicationUri = "urn:localhost:SessionlessServer";
|
|
o.ProductUri = "urn:localhost:SessionlessServer:Product";
|
|
})
|
|
.AddSessionManager((_, server, configuration) => new SessionManager(server, configuration))
|
|
.AddSessionlessInvocation();
|
|
using ServiceProvider provider = services.BuildServiceProvider();
|
|
using var server = new SessionManagerProbeServer(provider, m_telemetry);
|
|
|
|
using var manager = (SessionManager)server.CreateManager(m_serverMock.Object, m_config);
|
|
|
|
Assert.That(manager.SessionlessInvocation, Is.SameAs(provider.GetService<SessionlessInvocationOptions>()));
|
|
}
|
|
|
|
[Test]
|
|
public void TheHostedServerKeepsOptionsTheSessionManagerAlreadyHas()
|
|
{
|
|
var configured = new SessionlessInvocationOptions { AllowAnonymous = true };
|
|
using var manager = new SessionManager(m_serverMock.Object, m_config)
|
|
{
|
|
SessionlessInvocation = configured
|
|
};
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton(new SessionlessInvocationOptions());
|
|
services.AddSingleton<ISessionManager>(manager);
|
|
using ServiceProvider provider = services.BuildServiceProvider();
|
|
using var server = new SessionManagerProbeServer(provider, m_telemetry);
|
|
|
|
Assert.That(server.CreateManager(m_serverMock.Object, m_config), Is.SameAs(manager));
|
|
Assert.That(manager.SessionlessInvocation, Is.SameAs(configured));
|
|
}
|
|
|
|
private static ValueTask<OperationContext> ValidateAsync(
|
|
SessionManager manager,
|
|
SecureChannelContext channel,
|
|
NodeId authenticationToken,
|
|
RequestType requestType = RequestType.Read)
|
|
{
|
|
return manager.ValidateRequestAsync(
|
|
new RequestHeader { AuthenticationToken = authenticationToken },
|
|
channel,
|
|
requestType,
|
|
RequestLifetime.None);
|
|
}
|
|
|
|
private static RoleManager CreateEngineerRoleManager()
|
|
{
|
|
var roleManager = new RoleManager();
|
|
ServiceResult result = roleManager.AddIdentity(
|
|
ObjectIds.WellKnownRole_Engineer,
|
|
new IdentityMappingRuleType
|
|
{
|
|
CriteriaType = IdentityCriteriaType.Role,
|
|
Criteria = "Engineer"
|
|
});
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
return roleManager;
|
|
}
|
|
|
|
private static SecureChannelContext CreateHttpsChannel()
|
|
{
|
|
var endpoint = new EndpointDescription
|
|
{
|
|
EndpointUrl = "https://localhost:4843/",
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None
|
|
};
|
|
return new SecureChannelContext("rest", endpoint, RequestEncoding.Json);
|
|
}
|
|
|
|
private static SecureChannelContext CreateTcpChannel(MessageSecurityMode securityMode)
|
|
{
|
|
var endpoint = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://localhost:4840",
|
|
SecurityMode = securityMode,
|
|
SecurityPolicyUri = securityMode == MessageSecurityMode.None
|
|
? SecurityPolicies.None
|
|
: SecurityPolicies.Basic256Sha256
|
|
};
|
|
return new SecureChannelContext("tcp", endpoint, RequestEncoding.Binary);
|
|
}
|
|
|
|
private static IUserIdentity CreateUserNameIdentity(string userName)
|
|
{
|
|
var identity = new Mock<IUserIdentity>();
|
|
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
|
|
identity.Setup(i => i.DisplayName).Returns(userName);
|
|
identity.Setup(i => i.GrantedRoleIds).Returns([]);
|
|
return identity.Object;
|
|
}
|
|
|
|
private void UseJwtAuthenticator(IUserIdentity issued)
|
|
{
|
|
var authenticator = new Mock<IUserTokenAuthenticator>();
|
|
authenticator.Setup(a => a.TokenType).Returns(UserTokenType.IssuedToken);
|
|
authenticator.Setup(a => a.IssuedTokenProfileUri).Returns(Profiles.JwtUserToken);
|
|
authenticator
|
|
.Setup(a => a.AuthenticateAsync(It.IsAny<AuthenticationContext>(), It.IsAny<CancellationToken>()))
|
|
.Returns<AuthenticationContext, CancellationToken>((ctx, _) =>
|
|
new ValueTask<AuthenticationResult>(
|
|
ctx.TokenHandler is IssuedIdentityTokenHandler jwt &&
|
|
Encoding.UTF8.GetString(jwt.DecryptedTokenData!) == kGoodAccessToken
|
|
? AuthenticationResult.Accept(issued)
|
|
: AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadIdentityTokenRejected))));
|
|
m_serverMock.Setup(s => s.IdentityRegistry).Returns(new ServerIdentityRegistry(authenticator.Object));
|
|
}
|
|
|
|
private sealed class ProbeSessionManager : SessionManager
|
|
{
|
|
public ProbeSessionManager(IServerInternal server, ApplicationConfiguration configuration)
|
|
: base(server, configuration)
|
|
{
|
|
}
|
|
|
|
public ValueTask<IUserIdentity> ProbeAsync(
|
|
SecureChannelContext channel,
|
|
SessionlessInvocationOptions options)
|
|
{
|
|
return ValidateSessionlessRequestAsync(NodeId.Null, channel, options, CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
private sealed class SessionManagerProbeServer : DependencyInjectionStandardServer
|
|
{
|
|
public SessionManagerProbeServer(IServiceProvider services, ITelemetryContext telemetry)
|
|
: base(services, telemetry, TimeProvider.System)
|
|
{
|
|
}
|
|
|
|
public ISessionManager CreateManager(IServerInternal server, ApplicationConfiguration configuration)
|
|
{
|
|
return CreateSessionManager(server, configuration);
|
|
}
|
|
}
|
|
}
|
|
}
|