diff --git a/src/Opc.Ua.Client/CoreClientUtils.cs b/src/Opc.Ua.Client/CoreClientUtils.cs index d81b24976e..eb531c5a9c 100644 --- a/src/Opc.Ua.Client/CoreClientUtils.cs +++ b/src/Opc.Ua.Client/CoreClientUtils.cs @@ -294,7 +294,8 @@ public static async ValueTask> DiscoverServersAsync( /// With set, an endpoint without /// message security is never returned, except an HTTPS endpoint with /// SecurityMode None when the discovery URL is HTTPS (TLS protects it) - /// and no endpoint with message security matches. + /// and no endpoint with message security matches. An endpoint of the + /// OpenAPI mapping (REST) is never returned. public static EndpointDescription? SelectEndpoint( ApplicationConfiguration configuration, Uri url, @@ -311,6 +312,15 @@ public static async ValueTask> DiscoverServersAsync( { EndpointDescription endpoint = endpoints[ii]; + // A Session cannot be created over the OpenAPI mapping (Part 6 + // §G.3), a REST binding that shares the URL and the message + // security mode of the binary HTTPS endpoint. Select by + // TransportProfileUri (Part 4 §5.5.4) instead of by list order. + if (IsOpenApiEndpoint(endpoint)) + { + continue; + } + // check for a match on the URL scheme. if (endpoint.EndpointUrl != null && endpoint.EndpointUrl.StartsWith(url.Scheme, StringComparison.Ordinal)) @@ -383,6 +393,7 @@ public static async ValueTask> DiscoverServersAsync( { bool tlsDiscovery = IsHttpsScheme(url.Scheme); selectedEndpoint = endpoints.Find(e => + !IsOpenApiEndpoint(e) && e.EndpointUrl?.StartsWith(url.Scheme, StringComparison.Ordinal) == true && (!useSecurity || IsSecureMode(e.SecurityMode) || @@ -401,6 +412,16 @@ private static bool IsSecureMode(MessageSecurityMode mode) return mode is MessageSecurityMode.Sign or MessageSecurityMode.SignAndEncrypt; } + /// + /// Whether the endpoint uses the OpenAPI mapping (HTTPS or WSS) + /// rather than a transport a Session can be created over. + /// + private static bool IsOpenApiEndpoint(EndpointDescription endpoint) + { + return Profiles.IsHttpsOpenApi(endpoint.TransportProfileUri) || + Profiles.IsWssOpenApi(endpoint.TransportProfileUri); + } + /// /// Whether the URI scheme is carried over TLS (https or opc.https). /// diff --git a/src/Opc.Ua.Client/Session/Session.cs b/src/Opc.Ua.Client/Session/Session.cs index a880c282c6..aed9e5d268 100644 --- a/src/Opc.Ua.Client/Session/Session.cs +++ b/src/Opc.Ua.Client/Session/Session.cs @@ -1577,7 +1577,7 @@ private async Task OpenCoreAsync( lock (m_lock) { // save session id and cookie in base - base.SessionCreated(sessionId, sessionCookie); + SessionCreated(sessionId, sessionCookie); } m_logger.RevisedSessionTimeoutValueSessionTimeout(m_sessionTimeout, SessionId); @@ -4775,6 +4775,39 @@ private async ValueTask StartKeepAliveTimerAsync() } } + /// + public override void SessionCreated(NodeId sessionId, NodeId sessionCookie) + { + base.SessionCreated(sessionId, sessionCookie); + if (!sessionCookie.IsNull) + { + m_hadSession = true; + } + } + + /// + /// + /// A request with a null authenticationToken is a session-less + /// invocation (OPC 10000-4 §6.3.1). Once this Session has been + /// created, a request without token is not sent: after the Session + /// was closed, it fails locally with Bad_SessionIdInvalid instead of + /// reaching the Server as a session-less invocation. CreateSession + /// and ActivateSession are not affected. + /// + protected override void UpdateRequestHeader(IServiceRequest request, bool useDefaults) + { + base.UpdateRequestHeader(request, useDefaults); + + if (m_hadSession && + request.RequestHeader.AuthenticationToken.IsNull && + request is not (CreateSessionRequest or ActivateSessionRequest)) + { + throw ServiceResultException.Create( + StatusCodes.BadSessionIdInvalid, + "The Session has been closed."); + } + } + /// protected override void RequestCompleted( IServiceRequest request, @@ -6755,6 +6788,7 @@ public BackgroundWorkScope(Session owner) /// protected int m_keepAliveGuardBand = 1000; + private volatile bool m_hadSession; private readonly Lock m_lock = new(); private readonly List m_subscriptions = []; private uint m_maxRequestMessageSize; diff --git a/tests/Opc.Ua.Client.Tests/CoreClientUtilsSelectEndpointTests.cs b/tests/Opc.Ua.Client.Tests/CoreClientUtilsSelectEndpointTests.cs new file mode 100644 index 0000000000..f82352b738 --- /dev/null +++ b/tests/Opc.Ua.Client.Tests/CoreClientUtilsSelectEndpointTests.cs @@ -0,0 +1,143 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 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/ + * ======================================================================*/ + +using System; +using NUnit.Framework; +using Opc.Ua.Tests; + +namespace Opc.Ua.Client.Tests +{ + /// + /// CoreClientUtils.SelectEndpoint selects by TransportProfileUri + /// (OPC 10000-4 §5.5.4) and never returns an endpoint of the OpenAPI + /// mapping (OPC 10000-6 §G.3), whatever the order of the endpoints + /// returned by GetEndpoints. + /// + [TestFixture] + [Category("Client")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + public sealed class CoreClientUtilsSelectEndpointTests + { + private static readonly Uri s_httpsUrl = new("https://localhost:4843/"); + private static readonly Uri s_wssUrl = new("opc.wss://localhost:4843/"); + + [Test] + public void SelectEndpointSkipsTheOpenApiEndpointListedFirst() + { + EndpointDescription openApi = CreateEndpoint(s_httpsUrl, Profiles.HttpsOpenApiTransport); + EndpointDescription binary = CreateEndpoint(s_httpsUrl, Profiles.HttpsBinaryTransport); + + EndpointDescription? selected = CoreClientUtils.SelectEndpoint( + null!, + s_httpsUrl, + [openApi, binary], + useSecurity: false, + NUnitTelemetryContext.Create()); + + Assert.That(selected, Is.SameAs(binary)); + } + + [Test] + public void SelectEndpointWithSecurityFallbackSkipsTheOpenApiEndpoint() + { + // No endpoint signs, so the HTTPS fallback for useSecurity applies. + EndpointDescription openApi = CreateEndpoint(s_httpsUrl, Profiles.HttpsOpenApiTransport); + EndpointDescription binary = CreateEndpoint(s_httpsUrl, Profiles.HttpsBinaryTransport); + + EndpointDescription? selected = CoreClientUtils.SelectEndpoint( + null!, + s_httpsUrl, + [openApi, binary], + useSecurity: true, + NUnitTelemetryContext.Create()); + + Assert.That(selected, Is.SameAs(binary)); + } + + [Test] + public void SelectEndpointSkipsTheWssOpenApiEndpoint() + { + EndpointDescription openApi = CreateEndpoint(s_wssUrl, Profiles.WssOpenApiTransport); + EndpointDescription binary = CreateEndpoint(s_wssUrl, Profiles.UaWssTransport); + + EndpointDescription? selected = CoreClientUtils.SelectEndpoint( + null!, + s_wssUrl, + [openApi, binary], + useSecurity: false, + NUnitTelemetryContext.Create()); + + Assert.That(selected, Is.SameAs(binary)); + } + + [TestCase(false)] + [TestCase(true)] + public void SelectEndpointReturnsNullWhenOnlyOpenApiEndpointsMatch(bool useSecurity) + { + EndpointDescription? selected = CoreClientUtils.SelectEndpoint( + null!, + s_httpsUrl, + [CreateEndpoint(s_httpsUrl, Profiles.HttpsOpenApiTransport)], + useSecurity, + NUnitTelemetryContext.Create()); + + Assert.That(selected, Is.Null); + } + + [Test] + public void SelectEndpointKeepsTheFirstBinaryEndpointWithoutTransportProfile() + { + // Endpoints without TransportProfileUri are still selected by URL + // scheme and mode, as before. + EndpointDescription first = CreateEndpoint(s_httpsUrl, null); + EndpointDescription second = CreateEndpoint(s_httpsUrl, Profiles.HttpsBinaryTransport); + + EndpointDescription? selected = CoreClientUtils.SelectEndpoint( + null!, + s_httpsUrl, + [first, second], + useSecurity: false, + NUnitTelemetryContext.Create()); + + Assert.That(selected, Is.SameAs(first)); + } + + private static EndpointDescription CreateEndpoint(Uri url, string? transportProfileUri) + { + return new EndpointDescription + { + EndpointUrl = url.ToString(), + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None, + TransportProfileUri = transportProfileUri + }; + } + } +} diff --git a/tests/Opc.Ua.Client.Tests/Session/SessionTests.cs b/tests/Opc.Ua.Client.Tests/Session/SessionTests.cs index ddd453cdfe..95f3bca886 100644 --- a/tests/Opc.Ua.Client.Tests/Session/SessionTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/SessionTests.cs @@ -1228,6 +1228,87 @@ public async Task CloseAsyncShouldHandleErrorsDuringCloseAsync() sut.Channel.Verify(); } + /// + /// A request with a null authenticationToken is a session-less + /// invocation (OPC 10000-4 §6.3.1). A closed Session must not send + /// one through the channel it keeps open. + /// + [Test] + public async Task RequestAfterCloseFailsLocallyWithBadSessionIdInvalidAsync() + { + using var sut = SessionMock.Create(); + sut.SetConnectedAndResponsive(); + CancellationToken ct = CancellationToken.None; + SetupCloseSession(sut); + List sent = SetupRead(sut); + + StatusCode closed = await sut.CloseAsync(closeChannel: false, ct).ConfigureAwait(false); + + Assert.That(closed, Is.EqualTo(StatusCodes.Good)); + ServiceResultException? sre = Assert.ThrowsAsync( + async () => await ReadServerStateAsync(sut, ct).ConfigureAwait(false)); + Assert.That(sre!.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid)); + Assert.That(sent, Is.Empty, "The request must not reach the channel."); + } + + [Test] + public async Task ActivateSessionIsStillSentAfterCloseAsync() + { + using var sut = SessionMock.Create(); + sut.SetConnectedAndResponsive(); + CancellationToken ct = CancellationToken.None; + SetupCloseSession(sut); + sut.Channel + .Setup(c => c.SendRequestAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ActivateSessionResponse()) + .Verifiable(Times.Once); + + await sut.CloseAsync(closeChannel: false, ct).ConfigureAwait(false); + ActivateSessionResponse response = await sut.ActivateSessionAsync( + null, + new SignatureData(), + default, + default, + new ExtensionObject(), + new SignatureData(), + ct).ConfigureAwait(false); + + Assert.That(response, Is.Not.Null); + sut.Channel.Verify(); + } + + [Test] + public async Task RequestAfterANewSessionIsCreatedCarriesItsTokenAsync() + { + using var sut = SessionMock.Create(); + sut.SetConnectedAndResponsive(); + CancellationToken ct = CancellationToken.None; + SetupCloseSession(sut); + List sent = SetupRead(sut); + + await sut.CloseAsync(closeChannel: false, ct).ConfigureAwait(false); + sut.SessionCreated(NodeId.Parse("s=second"), NodeId.Parse("s=auth2")); + await ReadServerStateAsync(sut, ct).ConfigureAwait(false); + + Assert.That(sent, Has.Count.EqualTo(1)); + Assert.That(sent[0].RequestHeader.AuthenticationToken, Is.EqualTo(NodeId.Parse("s=auth2"))); + } + + [Test] + public async Task RequestBeforeASessionIsCreatedIsNotRefusedAsync() + { + using var sut = SessionMock.Create(); + CancellationToken ct = CancellationToken.None; + List sent = SetupRead(sut); + + await ReadServerStateAsync(sut, ct).ConfigureAwait(false); + + Assert.That(sent, Has.Count.EqualTo(1)); + Assert.That(sent[0].RequestHeader.AuthenticationToken.IsNull, Is.True); + } + [Test] public async Task DisposeAsyncSendsCloseSessionAsync() { @@ -2842,5 +2923,49 @@ private static ArrayOf CreateOperationLimitsRead(uint operationLimit, new DataValue(new Variant(1000u)) // MaxSelectClauseParameters ]; } + + private static void SetupCloseSession(SessionMock sut) + { + sut.Channel + .Setup(c => c.SendRequestAsync( + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(new CloseSessionResponse + { + ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good } + })); + } + + private static List SetupRead(SessionMock sut) + { + var sent = new List(); + sut.Channel + .Setup(c => c.SendRequestAsync( + It.IsAny(), + It.IsAny())) + .Callback((IServiceRequest request, CancellationToken _) => sent.Add((ReadRequest)request)) + .Returns(new ValueTask(new ReadResponse + { + ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.Good }, + Results = [new DataValue(new Variant((int)ServerState.Running))] + })); + return sent; + } + + private static async Task ReadServerStateAsync(SessionMock sut, CancellationToken ct) + { + await sut.ReadAsync( + null, + 0, + TimestampsToReturn.Neither, + [ + new ReadValueId + { + NodeId = VariableIds.Server_ServerStatus_State, + AttributeId = Attributes.Value + } + ], + ct).ConfigureAwait(false); + } } } diff --git a/tests/Opc.Ua.Sessions.Tests/ClientTest.cs b/tests/Opc.Ua.Sessions.Tests/ClientTest.cs index 3d3dc3e8f4..61e161ddb7 100644 --- a/tests/Opc.Ua.Sessions.Tests/ClientTest.cs +++ b/tests/Opc.Ua.Sessions.Tests/ClientTest.cs @@ -1032,13 +1032,14 @@ await session1.ReadValueAsync( await session1.ReadValueAsync( VariableIds.Server_ServerStatus, ct).ConfigureAwait(false)); - if (StatusCodes.BadSecureChannelClosed != sre.StatusCode) - { - Assert.That( - sre.StatusCode, - Is.EqualTo(StatusCodes.BadNotConnected), - sre.Message); - } + // the Session is closed as well, so the client refuses the request + // (it would go out without authenticationToken, which is a + // session-less invocation per Part 4 §6.3.1) before it reaches the + // closed channel. + Assert.That( + sre.StatusCode, + Is.EqualTo(StatusCodes.BadSessionIdInvalid), + sre.Message); session1.Dispose(); }