collisions) {
+
+ this.groups = groups;
+ this.collidingKeys = collidingKeys;
+ this.collisions = collisions;
+ }
+
+ /**
+ * Build an index over {@code endpoints}, recording any selection key collisions.
+ *
+ * Endpoints sharing a key are a collision unless they are {@link
+ * EndpointSelectionKey#isSessionEquivalent(EndpointDescription, EndpointDescription)
+ * Session-equivalent}, i.e. host/port substitution aliases of the same effective endpoint.
+ *
+ * @param endpoints the resolved endpoints to index.
+ * @return the index; call {@link #validate()} to fail on recorded collisions.
+ */
+ static EndpointSelectionIndex build(List endpoints) {
+ var groups = new LinkedHashMap>();
+
+ for (ResolvedEndpoint endpoint : endpoints) {
+ EndpointSelectionKey key = EndpointSelectionKey.of(endpoint.endpointDescription());
+ groups.computeIfAbsent(key, k -> new ArrayList<>()).add(endpoint);
+ }
+
+ var collidingKeys = new HashSet();
+ var collisions = new ArrayList();
+
+ groups.forEach(
+ (key, group) -> {
+ EndpointDescription first = group.get(0).endpointDescription();
+
+ boolean equivalent =
+ group.stream()
+ .allMatch(
+ r ->
+ EndpointSelectionKey.isSessionEquivalent(first, r.endpointDescription()));
+
+ if (!equivalent) {
+ collidingKeys.add(key);
+
+ String colliding =
+ group.stream()
+ .map(r -> describe(r.endpointDescription()))
+ .collect(Collectors.joining(", "));
+
+ collisions.add(
+ String.format(
+ "endpoints indistinguishable at OpenSecureChannel differ in"
+ + " Session-sensitive properties: selectionKey=%s, endpoints=[%s]",
+ key, colliding));
+ }
+ });
+
+ return new EndpointSelectionIndex(groups, collidingKeys, collisions);
+ }
+
+ /**
+ * Fail if any selection key is claimed by non-equivalent endpoints.
+ *
+ * Endpoints intended to differ only in supported authentication methods must instead combine
+ * their {@link UserTokenPolicy}s into a single endpoint configuration; endpoints intended to have
+ * distinct Session policy or access behavior must be distinguishable by a wire-observable
+ * selector (SecurityPolicy, MessageSecurityMode, or endpoint certificate).
+ *
+ * @throws UaException with {@link StatusCodes#Bad_ConfigurationError} identifying each colliding
+ * selection key and its endpoints.
+ */
+ void validate() throws UaException {
+ if (!collisions.isEmpty()) {
+ throw new UaException(
+ StatusCodes.Bad_ConfigurationError,
+ "ambiguous endpoint configuration: " + String.join("; ", collisions));
+ }
+ }
+
+ /**
+ * Resolve {@code key} to the unique effective endpoint it identifies.
+ *
+ *
When multiple host/port substitution aliases share the key, the alias whose URL matches
+ * {@code requestedEndpointUrl} is preferred (host and port, then host only); the first alias is
+ * used otherwise. Aliases carry identical Session-sensitive state, so the choice affects only the
+ * advertised URL.
+ *
+ * @param key the {@link EndpointSelectionKey} to resolve.
+ * @param requestedEndpointUrl the endpoint URL requested by the client, if available.
+ * @return the unique {@link ResolvedEndpoint} for {@code key}, or empty if there is none or the
+ * key is among the recorded collisions.
+ */
+ Optional select(
+ EndpointSelectionKey key, @Nullable String requestedEndpointUrl) {
+
+ List group = groups.get(key);
+
+ if (group == null || collidingKeys.contains(key)) {
+ return Optional.empty();
+ }
+
+ return Optional.of(
+ EndpointSelectionKey.preferRequestedUrl(
+ group, requestedEndpointUrl, r -> r.endpointDescription().getEndpointUrl()));
+ }
+
+ private static String describe(EndpointDescription endpoint) {
+ String tokenPolicies =
+ Stream.of(requireNonNullElse(endpoint.getUserIdentityTokens(), new UserTokenPolicy[0]))
+ .map(p -> p.getTokenType() + "/" + p.getPolicyId())
+ .collect(Collectors.joining(",", "[", "]"));
+
+ return endpoint.getEndpointUrl() + " userTokenPolicies=" + tokenPolicies;
+ }
+}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java
index af571ac08f..062e353616 100644
--- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java
@@ -116,6 +116,7 @@
import org.eclipse.milo.opcua.stack.core.util.LongSequence;
import org.eclipse.milo.opcua.stack.core.util.ManifestUtil;
import org.eclipse.milo.opcua.stack.core.util.NonceUtil;
+import org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey;
import org.eclipse.milo.opcua.stack.transport.server.OpcServerTransport;
import org.eclipse.milo.opcua.stack.transport.server.OpcServerTransportFactory;
import org.eclipse.milo.opcua.stack.transport.server.ServerApplicationContext;
@@ -170,6 +171,7 @@ public class OpcUaServer extends AbstractServiceHandler {
private final ServerDiagnosticsSummary diagnosticsSummary = new ServerDiagnosticsSummary(this);
private final Lazy> resolvedEndpoints = new Lazy<>();
+ private final Lazy endpointSelectionIndex = new Lazy<>();
private final List boundEndpoints = new CopyOnWriteArrayList<>();
private final CertificateIdentitySelector endpointCertificateIdentitySelector =
@@ -357,6 +359,15 @@ public ServerTable getServerTable() {
}
public CompletableFuture startup() {
+ try {
+ // Reject ambiguous endpoint configurations before binding anything: two Session-capable
+ // endpoints mapping to the same wire-observable selection key cannot be told apart at
+ // OpenSecureChannel time, so runtime selection would depend on collection ordering.
+ getEndpointSelectionIndex().validate();
+ } catch (UaException e) {
+ return CompletableFuture.failedFuture(e);
+ }
+
eventFactory.startup();
eventInstantiator.startup();
@@ -928,9 +939,28 @@ private OpcServerTransport getOrCreateTransport(TransportProfile transportProfil
* advertised without a listening socket behind it. Conversely, an endpoint bound at startup
* remains bound even if it no longer resolves. Re-binding after a reset is not currently
* supported; a server restart is required to change the set of bound sockets.
+ *
+ * When the endpoint set is next resolved, it is validated for selection-key collisions the
+ * same way {@link #startup()} validates the initial configuration. A collision cannot fail an
+ * already-running server, so it is logged as an error instead; the colliding endpoints are not
+ * selectable until the configuration is corrected and the cache reset again.
*/
public void resetEndpointDescriptionCache() {
resolvedEndpoints.reset();
+ endpointSelectionIndex.reset();
+ }
+
+ private EndpointSelectionIndex getEndpointSelectionIndex() {
+ return endpointSelectionIndex.get(
+ () -> {
+ var index = EndpointSelectionIndex.build(getResolvedEndpoints());
+ try {
+ index.validate();
+ } catch (UaException e) {
+ logger.error("Colliding endpoints will not be selectable: {}", e.getMessage());
+ }
+ return index;
+ });
}
private List getResolvedEndpoints() {
@@ -1167,6 +1197,15 @@ public List getEndpointDescriptions() {
return getResolvedEndpoints().stream().map(ResolvedEndpoint::endpointDescription).toList();
}
+ @Override
+ public Optional selectEndpoint(
+ EndpointSelectionKey key, @Nullable String requestedEndpointUrl) {
+
+ return getEndpointSelectionIndex()
+ .select(key, requestedEndpointUrl)
+ .map(ResolvedEndpoint::endpointDescription);
+ }
+
@Override
public EncodingContext getEncodingContext() {
return staticEncodingContext;
@@ -1211,6 +1250,11 @@ private void handleServiceRequest(
String path = EndpointUtil.getPath(context.getEndpointUrl());
if (context.getSecureChannel().getSecurityPolicy() == SecurityPolicy.None) {
+ // An unsecured channel is discovery-only unless an explicit SecurityPolicy.None endpoint
+ // currently exists for this transport and path. The current endpoint descriptions are
+ // re-checked on every request, rather than trusting a selection captured at
+ // OpenSecureChannel time, so that removing the None endpoint and resetting the endpoint
+ // description cache locks down already-open unsecured channels.
if (getEndpointDescriptions().stream()
.filter(e -> EndpointUtil.getPath(e.getEndpointUrl()).equals(path))
.filter(
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigBuilder.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigBuilder.java
index 1aad0bce1f..cd7ff2e80d 100644
--- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigBuilder.java
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigBuilder.java
@@ -13,6 +13,7 @@
import static java.util.Objects.requireNonNull;
import java.util.HashSet;
+import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@@ -33,7 +34,7 @@
public class OpcUaServerConfigBuilder {
- private Set endpoints = new HashSet<>();
+ private Set endpoints = new LinkedHashSet<>();
private Set reverseConnectTargets = new HashSet<>();
private LocalizedText applicationName =
@@ -63,8 +64,18 @@ public class OpcUaServerConfigBuilder {
private ExecutorService executor;
private ScheduledExecutorService scheduledExecutor;
+ /**
+ * Set the endpoints the server offers.
+ *
+ * The builder copies the supplied set, preserving its iteration order, so later changes to
+ * {@code endpointConfigs} do not affect the built configuration.
+ *
+ * @param endpointConfigs the complete set of endpoints the server offers.
+ * @return this builder.
+ */
public OpcUaServerConfigBuilder setEndpoints(Set endpointConfigs) {
- this.endpoints = endpointConfigs;
+ Objects.requireNonNull(endpointConfigs, "endpointConfigs");
+ this.endpoints = new LinkedHashSet<>(endpointConfigs);
return this;
}
diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/SessionManager.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/SessionManager.java
index 621d3cf983..0936afe3c9 100644
--- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/SessionManager.java
+++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/SessionManager.java
@@ -73,6 +73,7 @@
import org.eclipse.milo.opcua.stack.core.util.EndpointUtil;
import org.eclipse.milo.opcua.stack.core.util.NonceUtil;
import org.eclipse.milo.opcua.stack.core.util.TaskQueue;
+import org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey;
import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
@@ -921,9 +922,11 @@ public ActivateSessionResponse activateSession(
ByteString clientCertificateBytes =
context.getSecureChannel().getRemoteCertificateBytes();
+ // The identity token is presented against the endpoint selected by the replacement
+ // channel, so decode it with that endpoint's token policies, not the previous
+ // endpoint's.
UserIdentityToken identityToken =
- decodeIdentityToken(
- request.getUserIdentityToken(), session.getEndpoint().getUserIdentityTokens());
+ decodeIdentityToken(request.getUserIdentityToken(), endpoint.getUserIdentityTokens());
/*
* The user-token signature for enhanced (ECC/RSA-DH) policies is bound to the SecureChannel
@@ -1034,32 +1037,49 @@ public ActivateSessionResponse activateSession(
}
}
+ /**
+ * Get the {@link EndpointDescription} the Session carried by {@code context} must be associated
+ * with.
+ *
+ * The authoritative source is the endpoint the transport selected for the SecureChannel during
+ * OpenSecureChannel; a Session is always bound to that endpoint, never to an independent
+ * re-derivation. A context without a propagated selection (a discovery-only unsecured channel)
+ * falls back to the same selection-key lookup the transport uses, which yields exactly one
+ * endpoint or none -- never an arbitrary pick among multiple candidates.
+ *
+ * @param context the {@link ServiceRequestContext} carrying the channel's endpoint selection.
+ * @return the {@link EndpointDescription} to associate the Session with.
+ * @throws UaException with {@link StatusCodes#Bad_SecurityChecksFailed} if no unique endpoint is
+ * identified, including CreateSession/ActivateSession on a discovery-only unsecured channel.
+ */
private EndpointDescription findSessionEndpoint(ServiceRequestContext context)
throws UaException {
- return server.getApplicationContext().getEndpointDescriptions().stream()
- .filter(
- e -> {
- boolean transportMatch =
- java.util.Objects.equals(
- e.getTransportProfileUri(), context.getTransportProfile().getUri());
-
- boolean pathMatch =
- java.util.Objects.equals(
- EndpointUtil.getPath(e.getEndpointUrl()),
- EndpointUtil.getPath(context.getEndpointUrl()));
-
- boolean securityPolicyMatch =
- java.util.Objects.equals(
- e.getSecurityPolicyUri(),
- context.getSecureChannel().getSecurityPolicy().getUri());
-
- boolean securityModeMatch =
- java.util.Objects.equals(
- e.getSecurityMode(), context.getSecureChannel().getMessageSecurityMode());
-
- return transportMatch && pathMatch && securityPolicyMatch && securityModeMatch;
- })
- .findFirst()
+
+ Optional selectedEndpoint = context.getEndpoint();
+
+ if (selectedEndpoint.isPresent()) {
+ return selectedEndpoint.get();
+ }
+
+ SecureChannel secureChannel = context.getSecureChannel();
+ SecurityPolicy securityPolicy = secureChannel.getSecurityPolicy();
+
+ ByteString certificateThumbprint = ByteString.NULL_VALUE;
+ if (securityPolicy != SecurityPolicy.None) {
+ certificateThumbprint = ByteString.of(sha1(secureChannel.getLocalCertificateBytes().bytes()));
+ }
+
+ EndpointSelectionKey selectionKey =
+ EndpointSelectionKey.of(
+ context.getTransportProfile(),
+ context.getEndpointUrl(),
+ securityPolicy,
+ secureChannel.getMessageSecurityMode(),
+ certificateThumbprint);
+
+ return server
+ .getApplicationContext()
+ .selectEndpoint(selectionKey, context.getEndpointUrl())
.orElseThrow(
() -> {
String message =
@@ -1068,8 +1088,8 @@ private EndpointDescription findSessionEndpoint(ServiceRequestContext context)
+ "endpointUrl=%s, securityPolicy=%s, securityMode=%s",
context.getTransportProfile(),
context.getEndpointUrl(),
- context.getSecureChannel().getSecurityPolicy(),
- context.getSecureChannel().getMessageSecurityMode());
+ securityPolicy,
+ secureChannel.getMessageSecurityMode());
return new UaException(StatusCodes.Bad_SecurityChecksFailed, message);
});
diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigTest.java
index ff85753b0f..b89d34e5a6 100644
--- a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigTest.java
+++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigTest.java
@@ -72,7 +72,9 @@ public void testCopy() {
OpcUaServerConfig copy = OpcUaServerConfig.copy(original).build();
- assertSame(original.getEndpoints(), copy.getEndpoints());
+ // The builder defensively copies the endpoint set (like reverse-connect targets), so copies
+ // are equal but not the same instance.
+ assertEquals(original.getEndpoints(), copy.getEndpoints());
assertEquals(original.getReverseConnectTargets(), copy.getReverseConnectTargets());
assertSame(original.getApplicationName(), copy.getApplicationName());
assertEquals(original.getApplicationUri(), copy.getApplicationUri());
diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/SessionEndpointBindingTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/SessionEndpointBindingTest.java
new file mode 100644
index 0000000000..2708093cb6
--- /dev/null
+++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/SessionEndpointBindingTest.java
@@ -0,0 +1,741 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.sdk.server;
+
+import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.netty.channel.Channel;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.security.KeyPair;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.eclipse.milo.opcua.stack.core.NodeIds;
+import org.eclipse.milo.opcua.stack.core.StatusCodes;
+import org.eclipse.milo.opcua.stack.core.UaException;
+import org.eclipse.milo.opcua.stack.core.channel.SecureChannel;
+import org.eclipse.milo.opcua.stack.core.channel.ServerSecureChannel;
+import org.eclipse.milo.opcua.stack.core.security.CertificateFactory;
+import org.eclipse.milo.opcua.stack.core.security.CertificateGroup;
+import org.eclipse.milo.opcua.stack.core.security.CertificateManager;
+import org.eclipse.milo.opcua.stack.core.security.CertificateValidator;
+import org.eclipse.milo.opcua.stack.core.security.DefaultCertificateManager;
+import org.eclipse.milo.opcua.stack.core.security.MemoryCertificateQuarantine;
+import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy;
+import org.eclipse.milo.opcua.stack.core.security.TrustListManager;
+import org.eclipse.milo.opcua.stack.core.transport.TransportProfile;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ByteString;
+import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
+import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.ApplicationType;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.UserTokenType;
+import org.eclipse.milo.opcua.stack.core.types.structured.ActivateSessionRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.ApplicationDescription;
+import org.eclipse.milo.opcua.stack.core.types.structured.CreateSessionRequest;
+import org.eclipse.milo.opcua.stack.core.types.structured.CreateSessionResponse;
+import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription;
+import org.eclipse.milo.opcua.stack.core.types.structured.RequestHeader;
+import org.eclipse.milo.opcua.stack.core.types.structured.SignatureData;
+import org.eclipse.milo.opcua.stack.core.types.structured.UserTokenPolicy;
+import org.eclipse.milo.opcua.stack.core.util.CertificateUtil;
+import org.eclipse.milo.opcua.stack.core.util.NonceUtil;
+import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateBuilder;
+import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateGenerator;
+import org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey;
+import org.eclipse.milo.opcua.stack.transport.server.OpcServerTransport;
+import org.eclipse.milo.opcua.stack.transport.server.OpcServerTransportFactory;
+import org.eclipse.milo.opcua.stack.transport.server.ServerApplicationContext;
+import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext;
+import org.jspecify.annotations.Nullable;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * OPC UA does not transmit an EndpointDescription identifier during OpenSecureChannel (Part 6,
+ * 7.1.2.3), so the server must derive endpoint identity from wire-observable channel inputs. These
+ * tests protect the invariant that one selection key identifies exactly one effective Session
+ * endpoint: ambiguous configurations are rejected at startup, and Sessions are bound to the
+ * endpoint their SecureChannel selected rather than an ordering-dependent re-derivation.
+ */
+public class SessionEndpointBindingTest {
+
+ private static final NodeId GROUP_A = new NodeId(2, "certificate-group-a");
+ private static final NodeId GROUP_B = new NodeId(2, "certificate-group-b");
+
+ private static final UserTokenPolicy ANONYMOUS_POLICY =
+ new UserTokenPolicy("anonymous", UserTokenType.Anonymous, null, null, null);
+
+ private static final UserTokenPolicy USERNAME_POLICY =
+ new UserTokenPolicy("username", UserTokenType.UserName, null, null, null);
+
+ private static final OpcServerTransportFactory NO_OP_TRANSPORTS =
+ transportProfile ->
+ new OpcServerTransport() {
+ @Override
+ public void bind(
+ ServerApplicationContext applicationContext, InetSocketAddress bindAddress) {}
+
+ @Override
+ public void unbind() {}
+ };
+
+ private static CertificateMaterial certificateA;
+ private static CertificateMaterial certificateB;
+ private static CertificateMaterial clientCertificate;
+
+ @BeforeAll
+ static void generateCertificates() throws Exception {
+ certificateA = rsaCertificate("server-a");
+ certificateB = rsaCertificate("server-b");
+ clientCertificate = rsaCertificate("client");
+ }
+
+ @Nested
+ class StartupValidation {
+
+ /**
+ * Part 4, 5.5.4.1 distinguishes endpoints by security configuration, while user token policies
+ * are advertised properties of an endpoint, not selectors. Two endpoints indistinguishable at
+ * OpenSecureChannel that differ only in token policies would make Session authentication depend
+ * on endpoint collection ordering, so startup must reject them in either insertion order rather
+ * than document a sharp edge.
+ */
+ @Test
+ void startupRejectsEndpointsDifferingOnlyInUserTokenPoliciesInEitherOrder() {
+ EndpointConfig anonymousEndpoint = securedEndpoint(GROUP_A, ANONYMOUS_POLICY);
+ EndpointConfig usernameEndpoint = securedEndpoint(GROUP_A, USERNAME_POLICY);
+
+ for (List ordering :
+ List.of(
+ List.of(anonymousEndpoint, usernameEndpoint),
+ List.of(usernameEndpoint, anonymousEndpoint))) {
+
+ OpcUaServer server = server(manager(group(GROUP_A, certificateA)), ordering);
+
+ ExecutionException e =
+ assertThrows(
+ ExecutionException.class,
+ () -> server.startup().get(5, TimeUnit.SECONDS),
+ "ordering: " + ordering);
+
+ UaException cause = assertInstanceOf(UaException.class, e.getCause());
+ assertEquals(StatusCodes.Bad_ConfigurationError, cause.getStatusCode().value());
+ assertTrue(
+ cause.getMessage().contains("userTokenPolicies"),
+ "collision diagnostic should identify the colliding endpoints: " + cause.getMessage());
+ }
+ }
+
+ // The supported alternative to per-token-policy endpoints: one endpoint advertising all
+ // supported user token policies must be accepted.
+ @Test
+ void singleEndpointWithMultipleUserTokenPoliciesIsAccepted() throws Exception {
+ OpcUaServer server =
+ server(
+ manager(group(GROUP_A, certificateA)),
+ List.of(securedEndpoint(GROUP_A, ANONYMOUS_POLICY, USERNAME_POLICY)));
+
+ assertEquals(server, server.startup().get(5, TimeUnit.SECONDS));
+
+ server.shutdown().get(5, TimeUnit.SECONDS);
+ }
+
+ /**
+ * Otherwise-identical endpoints backed by distinct certificates in distinct CertificateGroups
+ * are distinguishable by the receiver thumbprint at OpenSecureChannel, so they are a valid
+ * configuration and must resolve uniquely regardless of insertion order.
+ */
+ @Test
+ void endpointsWithDistinctCertificatesStartAndResolveUniquelyInEitherOrder() throws Exception {
+ EndpointConfig endpointA = securedEndpoint(GROUP_A, ANONYMOUS_POLICY);
+ EndpointConfig endpointB = securedEndpoint(GROUP_B, ANONYMOUS_POLICY);
+
+ for (List ordering :
+ List.of(List.of(endpointA, endpointB), List.of(endpointB, endpointA))) {
+
+ OpcUaServer server =
+ server(manager(group(GROUP_A, certificateA), group(GROUP_B, certificateB)), ordering);
+
+ assertEquals(server, server.startup().get(5, TimeUnit.SECONDS), "ordering: " + ordering);
+
+ for (CertificateMaterial certificate : List.of(certificateA, certificateB)) {
+ EndpointDescription selected =
+ selectSecuredEndpoint(server, CertificateUtil.thumbprint(certificate.certificate()))
+ .orElseThrow();
+
+ assertArrayEquals(
+ certificate.certificate().getEncoded(),
+ selected.getServerCertificate().bytesOrEmpty(),
+ "selection must follow the thumbprint, not insertion order: " + ordering);
+ }
+
+ server.shutdown().get(5, TimeUnit.SECONDS);
+ }
+ }
+
+ /**
+ * Multi-hostname configurations advertise one endpoint per hostname with identical
+ * Session-sensitive state. These are host substitution aliases of the same effective endpoint
+ * (Part 6 permits clients to connect by IP or alternate hostname), not collisions, and the
+ * alias matching the client's requested URL is preferred.
+ */
+ @Test
+ void hostnameAliasesAreAcceptedAndResolvedByRequestedUrl() throws Exception {
+ EndpointConfig alphaEndpoint = securedEndpointForHostname("alpha", GROUP_A);
+ EndpointConfig betaEndpoint = securedEndpointForHostname("beta", GROUP_A);
+
+ OpcUaServer server =
+ server(manager(group(GROUP_A, certificateA)), List.of(alphaEndpoint, betaEndpoint));
+
+ assertEquals(server, server.startup().get(5, TimeUnit.SECONDS));
+
+ EndpointSelectionKey key =
+ EndpointSelectionKey.of(
+ TransportProfile.TCP_UASC_UABINARY,
+ "opc.tcp://beta:4840/test",
+ SecurityPolicy.Basic256Sha256,
+ MessageSecurityMode.SignAndEncrypt,
+ CertificateUtil.thumbprint(certificateA.certificate()));
+
+ EndpointDescription selected =
+ server
+ .getApplicationContext()
+ .selectEndpoint(key, "opc.tcp://beta:4840/test")
+ .orElseThrow();
+
+ assertEquals("opc.tcp://beta:4840/test", selected.getEndpointUrl());
+
+ server.shutdown().get(5, TimeUnit.SECONDS);
+ }
+
+ /**
+ * An unsecured channel may only select an explicit SecurityPolicy.None endpoint. When none is
+ * configured the selection is empty -- the discovery-only state -- rather than an arbitrary
+ * secured endpoint whose certificate and token policies the channel never negotiated.
+ */
+ @Test
+ void unsecuredKeySelectsNothingWhenNoExplicitNoneEndpointExists() {
+ OpcUaServer server =
+ server(
+ manager(group(GROUP_A, certificateA)),
+ List.of(securedEndpoint(GROUP_A, ANONYMOUS_POLICY)));
+
+ Optional selected =
+ server.getApplicationContext().selectEndpoint(noneKey("/test"), endpointUrl("/test"));
+
+ assertTrue(selected.isEmpty());
+ }
+ }
+
+ @Nested
+ class SessionBinding {
+
+ /**
+ * The certificate returned by CreateSession must belong to the endpoint whose thumbprint
+ * established the SecureChannel. Before endpoint propagation, SessionManager re-derived the
+ * endpoint without a certificate discriminator and could return the other endpoint's
+ * certificate depending on insertion order.
+ */
+ @Test
+ void createSessionBindsSessionToChannelSelectedEndpointInEitherOrder() throws Exception {
+ EndpointConfig endpointA = securedEndpoint(GROUP_A, ANONYMOUS_POLICY);
+ EndpointConfig endpointB = securedEndpoint(GROUP_B, ANONYMOUS_POLICY);
+
+ for (List ordering :
+ List.of(List.of(endpointA, endpointB), List.of(endpointB, endpointA))) {
+
+ OpcUaServer server =
+ server(manager(group(GROUP_A, certificateA), group(GROUP_B, certificateB)), ordering);
+
+ // The channel was established against certificate B; the transport propagates the
+ // endpoint it selected by receiver thumbprint.
+ EndpointDescription channelEndpoint =
+ selectSecuredEndpoint(server, CertificateUtil.thumbprint(certificateB.certificate()))
+ .orElseThrow();
+
+ ServiceRequestContext context =
+ new TestServiceRequestContext(
+ endpointUrl("/test"), securedChannel(1L, certificateB), channelEndpoint);
+
+ CreateSessionResponse response =
+ server
+ .getSessionManager()
+ .createSession(context, createSessionRequest(clientCertificate.byteString()));
+
+ assertArrayEquals(
+ certificateB.certificate().getEncoded(),
+ response.getServerCertificate().bytesOrEmpty(),
+ "CreateSession must return the channel endpoint's certificate; ordering: " + ordering);
+
+ Session session = server.getSessionManager().getAllSessions().get(0);
+ assertEquals(channelEndpoint, session.getEndpoint());
+
+ session.close(true);
+ }
+ }
+
+ /**
+ * Contexts that do not propagate a channel endpoint selection fall back to selection-key
+ * resolution, which discriminates by the channel certificate. The result must be the endpoint
+ * matching the channel certificate in either insertion order -- the direct regression test for
+ * ordering-dependent findFirst() selection.
+ */
+ @Test
+ void fallbackEndpointResolutionFollowsChannelCertificateInEitherOrder() throws Exception {
+ EndpointConfig endpointA = securedEndpoint(GROUP_A, ANONYMOUS_POLICY);
+ EndpointConfig endpointB = securedEndpoint(GROUP_B, ANONYMOUS_POLICY);
+
+ for (List ordering :
+ List.of(List.of(endpointA, endpointB), List.of(endpointB, endpointA))) {
+
+ OpcUaServer server =
+ server(manager(group(GROUP_A, certificateA), group(GROUP_B, certificateB)), ordering);
+
+ ServiceRequestContext context =
+ new TestServiceRequestContext(
+ endpointUrl("/test"), securedChannel(1L, certificateB), null);
+
+ CreateSessionResponse response =
+ server
+ .getSessionManager()
+ .createSession(context, createSessionRequest(clientCertificate.byteString()));
+
+ assertArrayEquals(
+ certificateB.certificate().getEncoded(),
+ response.getServerCertificate().bytesOrEmpty(),
+ "fallback resolution must follow the channel certificate; ordering: " + ordering);
+
+ server.getSessionManager().getAllSessions().forEach(s -> s.close(true));
+ }
+ }
+
+ /**
+ * A SecurityPolicy.None channel with no explicit None endpoint supports discovery only.
+ * CreateSession on such a channel must be rejected instead of associating the Session with an
+ * arbitrary secured endpoint's token policies and certificate.
+ */
+ @Test
+ void createSessionOnDiscoveryOnlyUnsecuredChannelIsRejected() {
+ OpcUaServer server =
+ server(
+ manager(group(GROUP_A, certificateA)),
+ List.of(securedEndpoint(GROUP_A, ANONYMOUS_POLICY)));
+
+ ServiceRequestContext context =
+ new TestServiceRequestContext(endpointUrl("/test"), noneChannel(1L), null);
+
+ UaException e =
+ assertThrows(
+ UaException.class,
+ () ->
+ server
+ .getSessionManager()
+ .createSession(context, createSessionRequest(ByteString.NULL_VALUE)));
+
+ assertEquals(StatusCodes.Bad_SecurityChecksFailed, e.getStatusCode().value());
+ }
+
+ // An explicitly configured SecurityPolicy.None endpoint continues to support unsecured
+ // Sessions, including for contexts that do not propagate a channel endpoint selection.
+ @Test
+ void createSessionOnExplicitNoneEndpointSucceeds() throws Exception {
+ OpcUaServer server =
+ server(
+ manager(group(GROUP_A, certificateA)),
+ List.of(noneEndpoint("/test"), securedEndpoint(GROUP_A, ANONYMOUS_POLICY)));
+
+ ServiceRequestContext context =
+ new TestServiceRequestContext(endpointUrl("/test"), noneChannel(1L), null);
+
+ server
+ .getSessionManager()
+ .createSession(context, createSessionRequest(ByteString.NULL_VALUE));
+
+ Session session = server.getSessionManager().getAllSessions().get(0);
+ assertEquals(SecurityPolicy.None.getUri(), session.getEndpoint().getSecurityPolicyUri());
+
+ session.close(true);
+ }
+
+ /**
+ * When a Session is reactivated onto a replacement SecureChannel, its endpoint association must
+ * follow the endpoint selected by that new channel; identity validation runs against the new
+ * endpoint's token policies before the Session's security state is changed.
+ */
+ @Test
+ void reactivationOnReplacementChannelUsesThatChannelsEndpoint() throws Exception {
+ OpcUaServer server =
+ server(
+ manager(group(GROUP_A, certificateA)),
+ List.of(noneEndpoint("/a"), noneEndpoint("/b")));
+
+ EndpointDescription endpointA = endpointForPath(server, "/a");
+ EndpointDescription endpointB = endpointForPath(server, "/b");
+
+ ServiceRequestContext context1 =
+ new TestServiceRequestContext(endpointUrl("/a"), noneChannel(1L), endpointA);
+
+ CreateSessionResponse createResponse =
+ server
+ .getSessionManager()
+ .createSession(context1, createSessionRequest(ByteString.NULL_VALUE));
+
+ NodeId authToken = createResponse.getAuthenticationToken();
+
+ server.getSessionManager().activateSession(context1, activateSessionRequest(authToken));
+
+ Session session = server.getSessionManager().getAllSessions().get(0);
+ assertEquals(endpointA, session.getEndpoint());
+ assertNotEquals(endpointA, endpointB, "control: the two endpoints must differ");
+
+ ServiceRequestContext context2 =
+ new TestServiceRequestContext(endpointUrl("/b"), noneChannel(2L), endpointB);
+
+ server.getSessionManager().activateSession(context2, activateSessionRequest(authToken));
+
+ assertEquals(endpointB, session.getEndpoint(), "session must follow the new channel");
+ assertEquals(2L, session.getSecureChannelId());
+
+ session.close(true);
+ }
+ }
+
+ private static Optional selectSecuredEndpoint(
+ OpcUaServer server, ByteString thumbprint) {
+
+ EndpointSelectionKey key =
+ EndpointSelectionKey.of(
+ TransportProfile.TCP_UASC_UABINARY,
+ endpointUrl("/test"),
+ SecurityPolicy.Basic256Sha256,
+ MessageSecurityMode.SignAndEncrypt,
+ thumbprint);
+
+ return server.getApplicationContext().selectEndpoint(key, endpointUrl("/test"));
+ }
+
+ private static EndpointSelectionKey noneKey(String path) {
+ return EndpointSelectionKey.of(
+ TransportProfile.TCP_UASC_UABINARY,
+ endpointUrl(path),
+ SecurityPolicy.None,
+ MessageSecurityMode.None,
+ null);
+ }
+
+ private static EndpointDescription endpointForPath(OpcUaServer server, String path) {
+ return server.getApplicationContext().getEndpointDescriptions().stream()
+ .filter(e -> e.getEndpointUrl().endsWith(path))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private static String endpointUrl(String path) {
+ return "opc.tcp://localhost:4840" + path;
+ }
+
+ private static EndpointConfig securedEndpoint(NodeId groupId, UserTokenPolicy... tokenPolicies) {
+ return EndpointConfig.newBuilder()
+ .setBindAddress("localhost")
+ .setBindPort(4840)
+ .setHostname("localhost")
+ .setPath("/test")
+ .setSecurityPolicy(SecurityPolicy.Basic256Sha256)
+ .setSecurityMode(MessageSecurityMode.SignAndEncrypt)
+ .setEndpointCertificateConfig(
+ EndpointCertificateConfig.newBuilder().setCertificateGroupId(groupId).build())
+ .addTokenPolicies(tokenPolicies)
+ .build();
+ }
+
+ private static EndpointConfig securedEndpointForHostname(String hostname, NodeId groupId) {
+ return EndpointConfig.newBuilder()
+ .setBindAddress("localhost")
+ .setBindPort(4840)
+ .setHostname(hostname)
+ .setPath("/test")
+ .setSecurityPolicy(SecurityPolicy.Basic256Sha256)
+ .setSecurityMode(MessageSecurityMode.SignAndEncrypt)
+ .setEndpointCertificateConfig(
+ EndpointCertificateConfig.newBuilder().setCertificateGroupId(groupId).build())
+ .addTokenPolicy(ANONYMOUS_POLICY)
+ .build();
+ }
+
+ private static EndpointConfig noneEndpoint(String path) {
+ return EndpointConfig.newBuilder()
+ .setBindAddress("localhost")
+ .setBindPort(4840)
+ .setHostname("localhost")
+ .setPath(path)
+ .setSecurityPolicy(SecurityPolicy.None)
+ .setSecurityMode(MessageSecurityMode.None)
+ .addTokenPolicy(ANONYMOUS_POLICY)
+ .build();
+ }
+
+ private static OpcUaServer server(
+ CertificateManager certificateManager, List endpoints) {
+
+ OpcUaServerConfig config =
+ OpcUaServerConfig.builder()
+ .setEndpoints(new LinkedHashSet<>(endpoints))
+ .setCertificateManager(certificateManager)
+ .setApplicationUri("urn:test:server")
+ .setProductUri("urn:test:product")
+ .build();
+
+ return new OpcUaServer(config, NO_OP_TRANSPORTS);
+ }
+
+ private static ServerSecureChannel noneChannel(long channelId) {
+ var secureChannel = new ServerSecureChannel();
+ secureChannel.setChannelId(channelId);
+ secureChannel.setSecurityPolicy(SecurityPolicy.None);
+ secureChannel.setMessageSecurityMode(MessageSecurityMode.None);
+ return secureChannel;
+ }
+
+ private static ServerSecureChannel securedChannel(
+ long channelId, CertificateMaterial serverCertificate) throws Exception {
+
+ var secureChannel = new ServerSecureChannel();
+ secureChannel.setChannelId(channelId);
+ secureChannel.setSecurityPolicy(SecurityPolicy.Basic256Sha256);
+ secureChannel.setMessageSecurityMode(MessageSecurityMode.SignAndEncrypt);
+ secureChannel.setLocalCertificate(serverCertificate.certificate());
+ secureChannel.setLocalCertificateChain(serverCertificate.certificateChain());
+ secureChannel.setKeyPair(serverCertificate.keyPair());
+ secureChannel.setRemoteCertificate(clientCertificate.byteString().bytesOrEmpty());
+ return secureChannel;
+ }
+
+ private static CreateSessionRequest createSessionRequest(ByteString clientCertificateBytes) {
+ return new CreateSessionRequest(
+ requestHeader(NodeId.NULL_VALUE),
+ new ApplicationDescription(
+ "urn:test:client",
+ "urn:test:client-product",
+ LocalizedText.english("client"),
+ ApplicationType.Client,
+ null,
+ null,
+ null),
+ null,
+ endpointUrl("/test"),
+ "test-session",
+ NonceUtil.generateNonce(32),
+ clientCertificateBytes,
+ 60_000.0,
+ uint(0));
+ }
+
+ private static ActivateSessionRequest activateSessionRequest(NodeId authToken) {
+ return new ActivateSessionRequest(
+ requestHeader(authToken),
+ new SignatureData(null, null),
+ null,
+ null,
+ null,
+ new SignatureData(null, null));
+ }
+
+ private static RequestHeader requestHeader(NodeId authToken) {
+ return new RequestHeader(authToken, DateTime.now(), uint(1), uint(0), null, uint(10_000), null);
+ }
+
+ private static CertificateMaterial rsaCertificate(String commonName) throws Exception {
+ KeyPair keyPair = SelfSignedCertificateGenerator.generateRsaKeyPair(2048);
+ X509Certificate certificate =
+ new SelfSignedCertificateBuilder(keyPair)
+ .setCommonName(commonName)
+ .setOrganization("Eclipse Milo")
+ .setApplicationUri("urn:test:" + commonName)
+ .build();
+
+ return new CertificateMaterial(
+ NodeIds.RsaSha256ApplicationCertificateType, keyPair, new X509Certificate[] {certificate});
+ }
+
+ private static CertificateManager manager(TestCertificateGroup... groups) {
+ List certificateGroups =
+ Arrays.stream(groups).map(CertificateGroup.class::cast).toList();
+
+ return new DefaultCertificateManager(new MemoryCertificateQuarantine(), certificateGroups);
+ }
+
+ private static TestCertificateGroup group(NodeId groupId, CertificateMaterial... certificates) {
+ return new TestCertificateGroup(groupId, List.of(certificates));
+ }
+
+ private record CertificateMaterial(
+ NodeId certificateTypeId, KeyPair keyPair, X509Certificate[] certificateChain) {
+
+ X509Certificate certificate() {
+ return certificateChain[0];
+ }
+
+ ByteString byteString() throws Exception {
+ return ByteString.of(certificate().getEncoded());
+ }
+ }
+
+ /** A {@link ServiceRequestContext} standing in for a request arriving over a UASC channel. */
+ private static final class TestServiceRequestContext implements ServiceRequestContext {
+
+ private final String endpointUrl;
+ private final SecureChannel secureChannel;
+ private final @Nullable EndpointDescription endpoint;
+
+ private TestServiceRequestContext(
+ String endpointUrl, SecureChannel secureChannel, @Nullable EndpointDescription endpoint) {
+
+ this.endpointUrl = endpointUrl;
+ this.secureChannel = secureChannel;
+ this.endpoint = endpoint;
+ }
+
+ @Override
+ public String getEndpointUrl() {
+ return endpointUrl;
+ }
+
+ @Override
+ public TransportProfile getTransportProfile() {
+ return TransportProfile.TCP_UASC_UABINARY;
+ }
+
+ @Override
+ public Channel getChannel() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public SecureChannel getSecureChannel() {
+ return secureChannel;
+ }
+
+ @Override
+ public Optional getEndpoint() {
+ return Optional.ofNullable(endpoint);
+ }
+
+ @Override
+ public Long receivedAtNanos() {
+ return System.nanoTime();
+ }
+
+ @Override
+ public InetAddress clientAddress() {
+ return InetAddress.getLoopbackAddress();
+ }
+ }
+
+ private record TestCertificateGroup(
+ NodeId certificateGroupId, Map certificates)
+ implements CertificateGroup {
+
+ private TestCertificateGroup(
+ NodeId certificateGroupId, List certificates) {
+ this(certificateGroupId, toCertificateMap(certificates));
+ }
+
+ private static Map toCertificateMap(
+ List certificates) {
+
+ Map certificateMap =
+ certificates.stream()
+ .collect(
+ Collectors.toMap(
+ CertificateMaterial::certificateTypeId,
+ Function.identity(),
+ (left, right) -> right,
+ LinkedHashMap::new));
+
+ return Collections.unmodifiableMap(certificateMap);
+ }
+
+ @Override
+ public NodeId getCertificateGroupId() {
+ return certificateGroupId;
+ }
+
+ @Override
+ public List getSupportedCertificateTypeIds() {
+ return List.copyOf(certificates.keySet());
+ }
+
+ @Override
+ public TrustListManager getTrustListManager() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List getCertificateEntries() {
+ return certificates.values().stream()
+ .map(
+ certificate ->
+ new CertificateGroup.Entry(
+ certificateGroupId,
+ certificate.certificateTypeId(),
+ certificate.certificateChain()))
+ .toList();
+ }
+
+ @Override
+ public Optional getKeyPair(NodeId certificateTypeId) {
+ return Optional.ofNullable(certificates.get(certificateTypeId))
+ .map(CertificateMaterial::keyPair);
+ }
+
+ @Override
+ public Optional getCertificateChain(NodeId certificateTypeId) {
+ return Optional.ofNullable(certificates.get(certificateTypeId))
+ .map(CertificateMaterial::certificateChain)
+ .map(X509Certificate[]::clone);
+ }
+
+ @Override
+ public void updateCertificate(
+ NodeId certificateTypeId, KeyPair keyPair, X509Certificate[] certificateChain) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public CertificateFactory getCertificateFactory() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public CertificateValidator getCertificateValidator() {
+ return new CertificateValidator.InsecureCertificateValidator();
+ }
+ }
+}
diff --git a/opc-ua-stack/stack-core/src/main/java/org/eclipse/milo/opcua/stack/core/util/PShaUtil.java b/opc-ua-stack/stack-core/src/main/java/org/eclipse/milo/opcua/stack/core/util/PShaUtil.java
index 64dd816c68..4f7f74edde 100644
--- a/opc-ua-stack/stack-core/src/main/java/org/eclipse/milo/opcua/stack/core/util/PShaUtil.java
+++ b/opc-ua-stack/stack-core/src/main/java/org/eclipse/milo/opcua/stack/core/util/PShaUtil.java
@@ -16,7 +16,7 @@
import org.eclipse.milo.opcua.stack.core.UaRuntimeException;
/**
- *
+ * Implements the P_SHA pseudo-random functions used to derive symmetric keys.
*
*
* P_SHA-1(secret, seed) =
diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/EndpointSelectionKey.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/EndpointSelectionKey.java
new file mode 100644
index 0000000000..dbc5e0351b
--- /dev/null
+++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/EndpointSelectionKey.java
@@ -0,0 +1,249 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.stack.transport.server;
+
+import static java.util.Objects.requireNonNullElse;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy;
+import org.eclipse.milo.opcua.stack.core.transport.TransportProfile;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ByteString;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode;
+import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription;
+import org.eclipse.milo.opcua.stack.core.types.structured.UserTokenPolicy;
+import org.eclipse.milo.opcua.stack.core.util.DigestUtil;
+import org.eclipse.milo.opcua.stack.core.util.EndpointUtil;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The wire-observable identity of a server endpoint at OpenSecureChannel time.
+ *
+ * OPC UA does not transmit an EndpointDescription identifier during OpenSecureChannel (Part 6,
+ * 7.1.2.3: the Hello message carries only an endpoint URL), so a server must derive which
+ * configured endpoint a new SecureChannel belongs to from the inputs the client does send. This key
+ * captures exactly those inputs:
+ *
+ *
+ * - the transport profile the connection arrived on
+ *
- the normalized path of the endpoint URL from the Hello message (host and port are excluded
+ * because clients may validly substitute them, e.g. connecting by IP address)
+ *
- the SecurityPolicy from the AsymmetricSecurityHeader
+ *
- the MessageSecurityMode from the OpenSecureChannel request
+ *
- the SHA-1 thumbprint of the endpoint application certificate (the receiver thumbprint from
+ * the AsymmetricSecurityHeader); absent for {@link SecurityPolicy#None}, where no certificate
+ * is observable on the wire
+ *
+ *
+ * The intended invariant is that each key identifies exactly one effective Session endpoint.
+ * {@link UserTokenPolicy}s are deliberately not part of the key: they are properties of the
+ * selected endpoint, not selectors. Endpoints that share a key must agree on every
+ * Session-sensitive property (see {@link #isSessionEquivalent(EndpointDescription,
+ * EndpointDescription)}); they may differ only in the substitutable host/port portion of their
+ * endpoint URL.
+ *
+ * @param transportProfileUri the URI of the {@link TransportProfile} the connection uses.
+ * @param path the normalized endpoint URL path, as returned by {@link EndpointUtil#getPath}.
+ * @param securityPolicyUri the URI of the channel {@link SecurityPolicy}.
+ * @param securityMode the {@link MessageSecurityMode} requested for the channel.
+ * @param certificateThumbprint the SHA-1 thumbprint of the endpoint application certificate, or
+ * {@link ByteString#NULL_VALUE} for {@link SecurityPolicy#None}.
+ */
+public record EndpointSelectionKey(
+ String transportProfileUri,
+ String path,
+ String securityPolicyUri,
+ MessageSecurityMode securityMode,
+ ByteString certificateThumbprint) {
+
+ public EndpointSelectionKey {
+ // Canonicalize so that a null and an empty thumbprint are the same key. ByteString#equals
+ // already treats them as equal but ByteString#hashCode does not, which would break map lookups.
+ if (certificateThumbprint == null || certificateThumbprint.isNullOrEmpty()) {
+ certificateThumbprint = ByteString.NULL_VALUE;
+ }
+ }
+
+ /**
+ * Create the key identifying the endpoint a new SecureChannel selects, from the inputs available
+ * to the server during OpenSecureChannel.
+ *
+ * @param transportProfile the {@link TransportProfile} the connection arrived on.
+ * @param endpointUrl the endpoint URL from the Hello message.
+ * @param securityPolicy the {@link SecurityPolicy} from the AsymmetricSecurityHeader.
+ * @param securityMode the {@link MessageSecurityMode} from the OpenSecureChannel request.
+ * @param certificateThumbprint the receiver certificate thumbprint from the
+ * AsymmetricSecurityHeader; ignored for {@link SecurityPolicy#None}.
+ * @return the {@link EndpointSelectionKey} for the channel.
+ */
+ public static EndpointSelectionKey of(
+ TransportProfile transportProfile,
+ @Nullable String endpointUrl,
+ SecurityPolicy securityPolicy,
+ MessageSecurityMode securityMode,
+ @Nullable ByteString certificateThumbprint) {
+
+ return new EndpointSelectionKey(
+ transportProfile.getUri(),
+ EndpointUtil.getPath(endpointUrl),
+ securityPolicy.getUri(),
+ securityMode,
+ securityPolicy == SecurityPolicy.None ? ByteString.NULL_VALUE : certificateThumbprint);
+ }
+
+ /**
+ * Create the key under which {@code endpoint} is selectable.
+ *
+ * @param endpoint the {@link EndpointDescription} to derive a key for.
+ * @return the {@link EndpointSelectionKey} under which {@code endpoint} is selectable.
+ */
+ public static EndpointSelectionKey of(EndpointDescription endpoint) {
+ String securityPolicyUri = endpoint.getSecurityPolicyUri();
+
+ ByteString certificateThumbprint = ByteString.NULL_VALUE;
+ if (!Objects.equals(securityPolicyUri, SecurityPolicy.None.getUri())) {
+ ByteString serverCertificate =
+ requireNonNullElse(endpoint.getServerCertificate(), ByteString.NULL_VALUE);
+ certificateThumbprint = ByteString.of(DigestUtil.sha1(serverCertificate.bytesOrEmpty()));
+ }
+
+ return new EndpointSelectionKey(
+ requireNonNullElse(endpoint.getTransportProfileUri(), ""),
+ EndpointUtil.getPath(endpoint.getEndpointUrl()),
+ requireNonNullElse(securityPolicyUri, ""),
+ endpoint.getSecurityMode(),
+ certificateThumbprint);
+ }
+
+ /**
+ * Select the unique endpoint identified by {@code key} from {@code endpoints}.
+ *
+ *
Multiple endpoints may share a key only when they are {@link
+ * #isSessionEquivalent(EndpointDescription, EndpointDescription) Session-equivalent}, i.e. they
+ * are host/port substitution aliases of the same effective endpoint. In that case the alias whose
+ * URL best matches {@code requestedEndpointUrl} is preferred (host and port match, then host
+ * match), falling back to the first alias; every alias carries identical Session-sensitive state,
+ * so the choice affects only the advertised URL.
+ *
+ *
If endpoints sharing the key are not Session-equivalent, no endpoint is returned: the key is
+ * ambiguous and selecting one arbitrarily would make security-sensitive Session state depend on
+ * collection ordering.
+ *
+ * @param endpoints the candidate {@link EndpointDescription}s.
+ * @param key the {@link EndpointSelectionKey} to resolve.
+ * @param requestedEndpointUrl the endpoint URL requested by the client, used to prefer among
+ * host/port substitution aliases; may be null.
+ * @return the unique endpoint identified by {@code key}, or empty if there is none or the key is
+ * ambiguous.
+ */
+ public static Optional selectUnique(
+ List endpoints,
+ EndpointSelectionKey key,
+ @Nullable String requestedEndpointUrl) {
+
+ List candidates =
+ endpoints.stream().filter(e -> key.equals(EndpointSelectionKey.of(e))).toList();
+
+ if (candidates.isEmpty()) {
+ return Optional.empty();
+ }
+
+ EndpointDescription first = candidates.get(0);
+ for (EndpointDescription candidate : candidates) {
+ if (!isSessionEquivalent(first, candidate)) {
+ return Optional.empty();
+ }
+ }
+
+ return Optional.of(
+ preferRequestedUrl(candidates, requestedEndpointUrl, EndpointDescription::getEndpointUrl));
+ }
+
+ /**
+ * Return whether two endpoints sharing a selection key are interchangeable for Session purposes.
+ *
+ * Endpoints sharing a key already agree on transport profile, path, SecurityPolicy,
+ * MessageSecurityMode, and (for secured policies) certificate thumbprint. What remains
+ * Session-sensitive is the advertised user token policy set (compared as a set, ignoring
+ * declaration order) and the advertised certificate itself, which for {@link SecurityPolicy#None}
+ * endpoints is not covered by the key.
+ *
+ * @param a an {@link EndpointDescription}.
+ * @param b an {@link EndpointDescription} sharing {@code a}'s selection key.
+ * @return {@code true} if the endpoints differ only in the substitutable host/port portion of
+ * their endpoint URL.
+ */
+ public static boolean isSessionEquivalent(EndpointDescription a, EndpointDescription b) {
+ return userTokenPolicies(a).equals(userTokenPolicies(b))
+ && Arrays.equals(
+ requireNonNullElse(a.getServerCertificate(), ByteString.NULL_VALUE).bytesOrEmpty(),
+ requireNonNullElse(b.getServerCertificate(), ByteString.NULL_VALUE).bytesOrEmpty());
+ }
+
+ private static Set userTokenPolicies(EndpointDescription endpoint) {
+ UserTokenPolicy[] tokens = endpoint.getUserIdentityTokens();
+ return tokens == null ? Set.of() : new HashSet<>(Arrays.asList(tokens));
+ }
+
+ /**
+ * Select from {@code candidates} the element whose endpoint URL best matches {@code
+ * requestedEndpointUrl}: an exact host and port match is preferred, then a host-only match,
+ * falling back to the first candidate.
+ *
+ * Intended for choosing among host/port substitution aliases that share a selection key, so
+ * the endpoint URL advertised to a client reflects the URL it actually requested.
+ *
+ * @param candidates the candidate elements; must not be empty.
+ * @param requestedEndpointUrl the endpoint URL requested by the client; may be null.
+ * @param endpointUrl extracts a candidate's endpoint URL; may return null.
+ * @param the candidate type.
+ * @return the candidate whose endpoint URL best matches {@code requestedEndpointUrl}, or the
+ * first candidate if none match.
+ */
+ public static T preferRequestedUrl(
+ List candidates,
+ @Nullable String requestedEndpointUrl,
+ Function endpointUrl) {
+
+ if (requestedEndpointUrl != null) {
+ String requestedHost = EndpointUtil.getHost(requestedEndpointUrl);
+ int requestedPort = EndpointUtil.getPort(requestedEndpointUrl);
+
+ if (requestedHost != null) {
+ T hostMatch = null;
+
+ for (T candidate : candidates) {
+ String url = requireNonNullElse(endpointUrl.apply(candidate), "");
+
+ if (requestedHost.equalsIgnoreCase(EndpointUtil.getHost(url))) {
+ if (requestedPort == EndpointUtil.getPort(url)) {
+ return candidate;
+ }
+ if (hostMatch == null) {
+ hostMatch = candidate;
+ }
+ }
+ }
+
+ if (hostMatch != null) {
+ return hostMatch;
+ }
+ }
+ }
+
+ return candidates.get(0);
+ }
+}
diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServerApplicationContext.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServerApplicationContext.java
index 57e9f78454..a5fd1288e0 100644
--- a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServerApplicationContext.java
+++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServerApplicationContext.java
@@ -11,6 +11,7 @@
package org.eclipse.milo.opcua.stack.transport.server;
import java.util.List;
+import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import org.eclipse.milo.opcua.stack.core.channel.SecurityKeysListener;
import org.eclipse.milo.opcua.stack.core.encoding.EncodingContext;
@@ -29,6 +30,25 @@ public interface ServerApplicationContext {
*/
List getEndpointDescriptions();
+ /**
+ * Select the unique {@link EndpointDescription} identified by {@code key}.
+ *
+ * Used by the server transport to associate exactly one endpoint with a SecureChannel during
+ * OpenSecureChannel. Implementations must never choose arbitrarily among multiple non-equivalent
+ * endpoints matching {@code key}: if the key is ambiguous, they must return empty.
+ *
+ * @param key the {@link EndpointSelectionKey} derived from the channel's wire-observable inputs.
+ * @param requestedEndpointUrl the endpoint URL requested by the client, used to prefer among
+ * host/port substitution aliases of the same effective endpoint; may be null.
+ * @return the unique endpoint identified by {@code key}, or empty if there is none or the key is
+ * ambiguous.
+ */
+ default Optional selectEndpoint(
+ EndpointSelectionKey key, @Nullable String requestedEndpointUrl) {
+
+ return EndpointSelectionKey.selectUnique(getEndpointDescriptions(), key, requestedEndpointUrl);
+ }
+
/**
* Get the server's {@link CertificateManager}.
*
diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequest.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequest.java
index 4103b5cb09..bc2af17d72 100644
--- a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequest.java
+++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequest.java
@@ -11,9 +11,12 @@
package org.eclipse.milo.opcua.stack.transport.server;
import io.netty.channel.Channel;
+import java.util.Optional;
import org.eclipse.milo.opcua.stack.core.channel.SecureChannel;
import org.eclipse.milo.opcua.stack.core.transport.TransportProfile;
import org.eclipse.milo.opcua.stack.core.types.UaRequestMessageType;
+import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription;
+import org.jspecify.annotations.Nullable;
/** Holds a received {@link UaRequestMessageType} with some additional transport layer details. */
public class ServiceRequest implements ServiceRequestContext {
@@ -24,6 +27,7 @@ public class ServiceRequest implements ServiceRequestContext {
private final TransportProfile transportProfile;
private final Channel channel;
private final SecureChannel secureChannel;
+ private final @Nullable EndpointDescription endpoint;
private final UaRequestMessageType requestMessage;
public ServiceRequest(
@@ -31,12 +35,14 @@ public ServiceRequest(
TransportProfile transportProfile,
Channel channel,
SecureChannel secureChannel,
+ @Nullable EndpointDescription endpoint,
UaRequestMessageType requestMessage) {
this.endpointUrl = endpointUrl;
this.transportProfile = transportProfile;
this.channel = channel;
this.secureChannel = secureChannel;
+ this.endpoint = endpoint;
this.requestMessage = requestMessage;
}
@@ -60,6 +66,11 @@ public SecureChannel getSecureChannel() {
return secureChannel;
}
+ @Override
+ public Optional getEndpoint() {
+ return Optional.ofNullable(endpoint);
+ }
+
@Override
public Long receivedAtNanos() {
return receivedAtNanos;
diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequestContext.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequestContext.java
index 73465f4409..e254613594 100644
--- a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequestContext.java
+++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequestContext.java
@@ -13,9 +13,11 @@
import io.netty.channel.Channel;
import java.net.InetAddress;
import java.net.InetSocketAddress;
+import java.util.Optional;
import org.eclipse.milo.opcua.stack.core.channel.SecureChannel;
import org.eclipse.milo.opcua.stack.core.transport.TransportProfile;
import org.eclipse.milo.opcua.stack.core.types.UaRequestMessageType;
+import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription;
/** Transport layer details that accompany an inbound {@link UaRequestMessageType}. */
public interface ServiceRequestContext {
@@ -51,6 +53,22 @@ public interface ServiceRequestContext {
*/
SecureChannel getSecureChannel();
+ /**
+ * Get the {@link EndpointDescription} the transport selected for the secure channel this request
+ * arrived on.
+ *
+ * The endpoint is selected once, during the initial OpenSecureChannel, from the channel's
+ * wire-observable inputs (see {@link EndpointSelectionKey}), and is the endpoint any Session
+ * created or activated on the channel must be associated with.
+ *
+ *
Empty when no endpoint is associated with the channel: an unsecured channel that matched no
+ * explicit SecurityPolicy.None endpoint is a discovery-only channel.
+ *
+ * @return the {@link EndpointDescription} selected for the channel, or empty if none is
+ * associated.
+ */
+ Optional getEndpoint();
+
/**
* Get the system time, in nanos, that this request was received at.
*
diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/package-info.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/package-info.java
new file mode 100644
index 0000000000..5771588047
--- /dev/null
+++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/package-info.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+/**
+ * Server-side transport SPI: the boundary between transport implementations and the server
+ * application that consumes their service requests.
+ *
+ * A transport implementation ({@link
+ * org.eclipse.milo.opcua.stack.transport.server.OpcServerTransport}, created by {@link
+ * org.eclipse.milo.opcua.stack.transport.server.OpcServerTransportFactory}) accepts connections and
+ * delivers each decoded request to the application through {@link
+ * org.eclipse.milo.opcua.stack.transport.server.ServerApplicationContext#handleServiceRequest}. The
+ * application supplies everything security-relevant the transport needs: the advertised {@link
+ * org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription}s, the {@link
+ * org.eclipse.milo.opcua.stack.core.security.CertificateManager}, and the encoding context.
+ *
+ *
Endpoint selection and propagation
+ *
+ * OPC UA does not transmit an EndpointDescription identifier during OpenSecureChannel, so the
+ * endpoint a SecureChannel belongs to must be derived from wire-observable inputs, captured by
+ * {@link org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey}. During the initial
+ * OpenSecureChannel the transport resolves its key through {@link
+ * org.eclipse.milo.opcua.stack.transport.server.ServerApplicationContext#selectEndpoint}, which
+ * yields exactly one endpoint or none -- never an ordering-dependent choice among several. The
+ * selected endpoint then accompanies every inbound request via {@link
+ * org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext#getEndpoint}, so the
+ * application layers (Session creation and activation in particular) consume the channel's
+ * selection instead of re-deriving it.
+ *
+ *
An unsecured (SecurityPolicy.None) channel that matches no explicit None endpoint carries no
+ * endpoint selection at all: it is a discovery-only channel, on which the application may still
+ * choose to serve Discovery services but must not create Sessions.
+ *
+ *
{@link org.eclipse.milo.opcua.stack.transport.server.ServiceRequest} is the concrete carrier
+ * of a request and its transport details; {@link
+ * org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext} is the read-only view handed
+ * to the application.
+ */
+package org.eclipse.milo.opcua.stack.transport.server;
diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerAsymmetricHandler.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerAsymmetricHandler.java
index 64ec903c3a..274ac0bf78 100644
--- a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerAsymmetricHandler.java
+++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerAsymmetricHandler.java
@@ -72,6 +72,7 @@
import org.eclipse.milo.opcua.stack.core.util.DigestUtil;
import org.eclipse.milo.opcua.stack.core.util.EndpointUtil;
import org.eclipse.milo.opcua.stack.core.util.NonceUtil;
+import org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey;
import org.eclipse.milo.opcua.stack.transport.server.ServerApplicationContext;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
@@ -259,6 +260,16 @@ private void onOpenSecureChannel(ChannelHandlerContext ctx, ByteBuf buffer) thro
secureChannel.setSecurityPolicy(securityPolicy);
if (securityPolicy != SecurityPolicy.None) {
+ if (header.getReceiverThumbprint().isNullOrEmpty()) {
+ // Part 6 6.7.2.3: the receiver certificate thumbprint identifies the public key used
+ // to encrypt the message and is required whenever the OPN is asymmetrically secured.
+ // Reject explicitly rather than relying on certificate or endpoint lookups to fail,
+ // so the client receives an ERR message identifying the actual problem.
+ throw new UaException(
+ StatusCodes.Bad_SecurityChecksFailed,
+ "receiverCertificateThumbprint must be present when SecurityPolicy is not None");
+ }
+
CertificateManager certificateManager = application.getCertificateManager();
Optional localCertificateChain =
@@ -537,59 +548,39 @@ private OpenSecureChannelResponse openSecureChannel(
String endpointUrl = ctx.channel().attr(UascServerHelloHandler.ENDPOINT_URL_KEY).get();
- EndpointDescription endpoint =
- application.getEndpointDescriptions().stream()
- .filter(
- e -> {
- boolean transportMatch =
- Objects.equals(e.getTransportProfileUri(), transportProfile.getUri());
-
- boolean pathMatch =
- Objects.equals(
- EndpointUtil.getPath(e.getEndpointUrl()),
- EndpointUtil.getPath(endpointUrl));
-
- boolean securityPolicyMatch =
- Objects.equals(
- e.getSecurityPolicyUri(), secureChannel.getSecurityPolicy().getUri());
-
- boolean securityModeMatch =
- Objects.equals(e.getSecurityMode(), request.getSecurityMode());
-
- boolean thumbprintMatch = true;
- if (!header.getReceiverThumbprint().isNullOrEmpty()) {
- thumbprintMatch =
- Arrays.equals(
- DigestUtil.sha1(e.getServerCertificate().bytesOrEmpty()),
- header.getReceiverThumbprint().bytesOrEmpty());
- }
-
- // allow a matched endpoint OR any unsecured connection, regardless of the
- // endpoint security, so that the receiving ServerApplication can decide if
- // it wants to allow unsecured Discovery services.
- return transportMatch
- && pathMatch
- && thumbprintMatch
- && (securityPolicyMatch && securityModeMatch
- || secureChannel.getSecurityPolicy() == SecurityPolicy.None);
- })
- .findFirst()
- .orElseThrow(
- () -> {
- String message =
- String.format(
- "no matching endpoint found: transportProfile=%s, endpointUrl=%s,"
- + " thumbprint=%s, securityPolicy=%s, securityMode=%s",
- transportProfile,
- endpointUrl,
- header.getReceiverThumbprint(),
- secureChannel.getSecurityPolicy(),
- request.getSecurityMode());
-
- return new UaException(StatusCodes.Bad_SecurityChecksFailed, message);
- });
-
- ctx.channel().attr(ENDPOINT_KEY).set(endpoint);
+ SecurityPolicy securityPolicy = secureChannel.getSecurityPolicy();
+
+ EndpointSelectionKey selectionKey =
+ EndpointSelectionKey.of(
+ transportProfile,
+ endpointUrl,
+ securityPolicy,
+ request.getSecurityMode(),
+ header.getReceiverThumbprint());
+
+ Optional endpoint =
+ application.selectEndpoint(selectionKey, endpointUrl);
+
+ if (endpoint.isPresent()) {
+ ctx.channel().attr(ENDPOINT_KEY).set(endpoint.get());
+ } else if (securityPolicy != SecurityPolicy.None) {
+ String message =
+ String.format(
+ "no matching endpoint found: transportProfile=%s, endpointUrl=%s,"
+ + " thumbprint=%s, securityPolicy=%s, securityMode=%s",
+ transportProfile,
+ endpointUrl,
+ header.getReceiverThumbprint(),
+ securityPolicy,
+ request.getSecurityMode());
+
+ throw new UaException(StatusCodes.Bad_SecurityChecksFailed, message);
+ }
+ // else: an unsecured channel that matched no explicit SecurityPolicy.None endpoint stays
+ // open with no endpoint association. This is a discovery-only state: the receiving
+ // ServerApplication decides whether to allow unsecured Discovery services, and no arbitrary
+ // secured endpoint is selected to represent the channel. The pre-decryption check in
+ // onOpenSecureChannel already guaranteed some endpoint exists for this transport and path.
} else if (requestType == SecurityTokenRequestType.Renew) {
if (secureChannel.getMessageSecurityMode() != request.getSecurityMode()) {
throw new UaException(
diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerSymmetricHandler.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerSymmetricHandler.java
index c53f8b4762..f4c35b24e1 100644
--- a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerSymmetricHandler.java
+++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerSymmetricHandler.java
@@ -38,6 +38,7 @@
import org.eclipse.milo.opcua.stack.core.types.UaRequestMessageType;
import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
+import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription;
import org.eclipse.milo.opcua.stack.core.types.structured.ResponseHeader;
import org.eclipse.milo.opcua.stack.core.types.structured.ServiceFault;
import org.eclipse.milo.opcua.stack.core.util.BufferUtil;
@@ -207,12 +208,16 @@ private void onSecureMessage(ChannelHandlerContext ctx, ByteBuf buffer, List