You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@activemq.apache.org by ma...@apache.org on 2026/10/15 11:14:03 UTC

(activemq) branch main updated: Feature: Support for authoriztion of connection fields (#2461)

This is an automated email from the ASF dual-hosted git repository.

mattrpav pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/activemq.git


The following commit(s) were added to refs/heads/main by this push:
     new b67c5e0d40 Feature: Support for authoriztion of connection fields (#2461)
b67c5e0d40 is described below

commit b67c5e0d408449c652284c13957de2d6272489bf
Author: Matt Pavlovich <ma...@apache.org>
AuthorDate: Thu Oct 15 06:13:57 2026 -0500

    Feature: Support for authoriztion of connection fields (#2461)
    
    * Add isSsl to Connector
    * Feature: Add support for authorizing connection values such as clientId to userId
    - Includes support for clientId as a network connection authorization
    * Review changes
---
 .../java/org/apache/activemq/broker/Connector.java |   6 +
 .../activemq/broker/TransportConnection.java       |  30 +-
 .../apache/activemq/broker/TransportConnector.java |  23 ++
 .../apache/activemq/broker/jmx/ConnectorView.java  |   5 +
 .../activemq/broker/jmx/ConnectorViewMBean.java    |   3 +
 .../network/DemandForwardingBridgeSupport.java     |   7 +-
 .../security/JaasAuthenticationBroker.java         |  65 +++-
 .../security/JaasDualAuthenticationBroker.java     |   7 +-
 .../apache/activemq/security/SecurityContext.java  |  19 +
 .../transport/https/HttpsTransportServer.java      |   5 +
 .../apache/activemq/jaas/ConnectionCallback.java   | 113 ++++++
 .../apache/activemq/jaas/ConnectionPrincipal.java  | 134 +++++++
 .../jaas/JassCredentialCallbackHandler.java        |  17 +
 .../activemq/jaas/PropertiesLoginModule.java       | 152 +++++++-
 .../jaas/ClientIdPropertiesLoginModuleTest.java    | 238 ++++++++++++
 .../jaas/JassCredentialCallbackHandlerTest.java    |  72 ++++
 .../src/test/resources/clientid-users.properties   |  21 ++
 .../src/test/resources/clientids.properties        |  33 ++
 activemq-jaas/src/test/resources/login.config      |   8 +
 .../activemq/broker/TransportConnectorSslTest.java | 115 ++++++
 .../JaasNetworkConnectionAuthorizationTest.java    | 397 +++++++++++++++++++++
 .../src/test/resources/login.config                |  10 +-
 .../activemq/security/clientid-users.properties    |  19 +
 .../apache/activemq/security/clientids.properties  |  31 ++
 assembly/src/release/conf/clientids.properties     |  64 ++++
 assembly/src/release/conf/login.config             |   5 +-
 26 files changed, 1577 insertions(+), 22 deletions(-)

diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/Connector.java b/activemq-broker/src/main/java/org/apache/activemq/broker/Connector.java
index bf6fd7bec7..2133e66388 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/broker/Connector.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/broker/Connector.java
@@ -71,6 +71,12 @@ public interface Connector extends Service {
     @Deprecated(forRemoval = true)
     int connectionCount();
 
+    /**
+     * @return true if connections accepted by this connector are protected by
+     * SSL/TLS, that is the transport negotiates TLS with each client
+     */
+    boolean isSsl();
+
     /**
      * If enabled, older connections with the same clientID are stopped
      *
diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnection.java b/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnection.java
index 3315cf0225..9696d37c0d 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnection.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnection.java
@@ -82,6 +82,7 @@ import org.apache.activemq.network.NetworkBridgeConfiguration;
 import org.apache.activemq.network.NetworkBridgeFactory;
 import org.apache.activemq.network.NetworkConnector;
 import org.apache.activemq.security.MessageAuthorizationPolicy;
+import org.apache.activemq.security.SecurityContext;
 import org.apache.activemq.state.CommandVisitor;
 import org.apache.activemq.state.ConnectionState;
 import org.apache.activemq.state.ConsumerState;
@@ -1222,9 +1223,9 @@ public class TransportConnection implements Connection, Task, CommandVisitor {
         if (stopping.compareAndSet(false, true)) {
             // Let all the connection contexts know we are shutting down
             // so that in progress operations can notice and unblock.
-            List<TransportConnectionState> connectionStates = listConnectionStates();
-            for (TransportConnectionState cs : connectionStates) {
-                ConnectionContext connectionContext = cs.getContext();
+            var connectionStates = listConnectionStates();
+            for (var cs : connectionStates) {
+                var connectionContext = cs.getContext();
                 if (connectionContext != null) {
                     connectionContext.getStopping().set(true);
                 }
@@ -1298,8 +1299,8 @@ public class TransportConnection implements Connection, Task, CommandVisitor {
         // Remove all logical connection associated with this connection
         // from the broker.
         if (!broker.isStopped()) {
-            List<TransportConnectionState> connectionStates = listConnectionStates();
-            for (TransportConnectionState cs : connectionStates) {
+            var connectionStates = listConnectionStates();
+            for (var cs : connectionStates) {
                 cs.getContext().getStopping().set(true);
                 try {
                     LOG.debug("Cleaning up connection resources: {}", getRemoteAddress());
@@ -1480,9 +1481,26 @@ public class TransportConnection implements Connection, Task, CommandVisitor {
             setDuplexNetworkConnectorId(duplexNetworkConnectorId);
         }
 
+        // Every bridge sends BrokerInfo before ConnectionInfo, so authentication sees the
+        // network flag and the login module decides. A BrokerInfo arriving after a
+        // connection has authenticated is a client promoting itself after the fact; where
+        // network connection authorization is configured that is refused outright, as the
+        // decision was taken without the flag.
+        var connectionStates = listConnectionStates();
+        for (var cs : connectionStates) {
+            var securityContext = cs.getContext().getSecurityContext();
+            if (securityContext == null || !securityContext.isNetworkConnectionAuthorizationRequired()) {
+                LOG.debug("Network connection authorization not configured for {}", getRemoteAddress());
+                continue;
+            }
+            LOG.warn("Rejecting BrokerInfo from {}: user {} authenticated before declaring a network connection",
+                    getRemoteAddress(), securityContext.getUserName());
+            throw new IOException("BrokerInfo received after authentication from " + getRemoteAddress()
+                    + "; network connections must send BrokerInfo first");
+        }
+
         this.brokerInfo = info;
         networkConnection = true;
-        List<TransportConnectionState> connectionStates = listConnectionStates();
         for (TransportConnectionState cs : connectionStates) {
             cs.getContext().setNetworkConnection(true);
         }
diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java b/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java
index 15ede72a80..4887d8e83a 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java
@@ -22,6 +22,7 @@ import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Locale;
 import java.util.Optional;
 import java.util.StringTokenizer;
 import java.util.concurrent.CopyOnWriteArrayList;
@@ -730,6 +731,28 @@ public class TransportConnector implements Connector, BrokerServiceAware {
         return (server != null ? server.getMaxConnectionExceededCount() : 0l);
     }
 
+    /**
+     * Once the transport server is bound its own answer is authoritative. Before
+     * that the configured scheme decides, so the flag is also usable while a broker
+     * is still being configured: ssl, nio+ssl, auto+nio+ssl, mqtt+ssl and so on,
+     * plus https and wss.
+     */
+    @Override
+    public boolean isSsl() {
+        if (server != null) {
+            return server.isSslServer();
+        }
+        if (uri == null || uri.getScheme() == null) {
+            return false;
+        }
+        for (String part : uri.getScheme().toLowerCase(Locale.ROOT).split("\\+")) {
+            if ("ssl".equals(part) || "https".equals(part) || "wss".equals(part)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     @Override
     public boolean isStarted() {
         return started.get();
diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorView.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorView.java
index acd00377d0..7ff3e8927c 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorView.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorView.java
@@ -86,6 +86,11 @@ public class ConnectorView implements ConnectorViewMBean {
         return connector.getStatistics().isEnabled();
     }
 
+    @Override
+    public boolean isSsl() {
+        return connector.isSsl();
+    }
+
     /**
      * Returns the number of current connections
      */
diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorViewMBean.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorViewMBean.java
index 4d04022561..abf100d2f6 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorViewMBean.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorViewMBean.java
@@ -54,6 +54,9 @@ public interface ConnectorViewMBean extends Service {
     @MBeanInfo("Statistics gathering enabled")
     boolean isStatisticsEnabled();
 
+    @MBeanInfo("Connections are protected by SSL/TLS")
+    boolean isSsl();
+
     /**
      * Returns true if link stealing is enabled on this Connector
      *
diff --git a/activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java b/activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java
index 9db3fc8b56..965efa8b53 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java
@@ -530,6 +530,11 @@ public abstract class DemandForwardingBridgeSupport implements NetworkBridge, Br
                         X509Certificate[] peerCerts = originalTransport.getPeerCertificates();
                         localConnectionInfo.setTransportContext(peerCerts);
                     }
+                    // Let the local broker know the remote broker's ID before the connection
+                    // authenticates, so the local side declares itself a network connection the
+                    // same way the remote and duplex inbound sides already do. This is an in-VM
+                    // transport; nothing on the wire changes.
+                    localBroker.oneway(remoteBrokerInfo);
                     // sync requests that may fail
                     Object resp = localBroker.request(localConnectionInfo);
                     if (resp instanceof ExceptionResponse) {
@@ -571,8 +576,6 @@ public abstract class DemandForwardingBridgeSupport implements NetworkBridge, Br
                         l.onStart(this);
                     }
 
-                    // Let the local broker know the remote broker's ID.
-                    localBroker.oneway(remoteBrokerInfo);
                     // new peer broker (a consumer can work with remote broker also)
                     brokerService.getBroker().addBroker(null, remoteBrokerInfo);
 
diff --git a/activemq-broker/src/main/java/org/apache/activemq/security/JaasAuthenticationBroker.java b/activemq-broker/src/main/java/org/apache/activemq/security/JaasAuthenticationBroker.java
index 6756027361..a6b8b356a6 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/security/JaasAuthenticationBroker.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/security/JaasAuthenticationBroker.java
@@ -27,6 +27,10 @@ import org.apache.activemq.broker.Broker;
 import org.apache.activemq.broker.ConnectionContext;
 import org.apache.activemq.command.ConnectionInfo;
 import org.apache.activemq.jaas.JassCredentialCallbackHandler;
+import org.apache.activemq.broker.Connection;
+import org.apache.activemq.broker.Connector;
+import org.apache.activemq.jaas.ConnectionCallback;
+import org.apache.activemq.jaas.ConnectionPrincipal;
 
 /**
  * Logs a user in using JAAS.
@@ -55,6 +59,25 @@ public class JaasAuthenticationBroker extends AbstractAuthenticationBroker {
         public Set<Principal> getPrincipals() {
             return subject.getPrincipals();
         }
+
+        /**
+         * The login module records its decision on the ConnectionPrincipal when
+         * clientId authorization is configured; with no ConnectionPrincipal the
+         * feature is off and the connection is not restricted.
+         */
+        @Override
+        public boolean isNetworkConnectionAllowed() {
+            for (ConnectionPrincipal connection : subject.getPrincipals(ConnectionPrincipal.class)) {
+                return connection.isNetworkConnection();
+            }
+            return true;
+        }
+
+        /** a ConnectionPrincipal is only added when clientId authorization is configured */
+        @Override
+        public boolean isNetworkConnectionAuthorizationRequired() {
+            return !subject.getPrincipals(ConnectionPrincipal.class).isEmpty();
+        }
     }
 
     @Override
@@ -65,7 +88,7 @@ public class JaasAuthenticationBroker extends AbstractAuthenticationBroker {
             Thread.currentThread().setContextClassLoader(JaasAuthenticationBroker.class.getClassLoader());
             SecurityContext securityContext = null;
             try {
-                securityContext = authenticate(info.getUserName(), info.getPassword(), null);
+                securityContext = authenticate(info.getUserName(), info.getPassword(), connectionCallbackFrom(context, info));
                 context.setSecurityContext(securityContext);
                 securityContexts.add(securityContext);
                 super.addConnection(context, info);
@@ -83,14 +106,48 @@ public class JaasAuthenticationBroker extends AbstractAuthenticationBroker {
         }
     }
 
+    private ConnectionCallback connectionCallbackFrom(ConnectionContext context, ConnectionInfo info) {
+        var callback = new ConnectionCallback();
+        callback.setConnectionId(info.getConnectionId() != null ? info.getConnectionId().getValue() : null);
+        callback.setClientId(info.getClientId());
+        callback.setBrokerName(getBrokerName());
+        callback.setNetworkConnection(context.isNetworkConnection());
+        // the transport's view of the peer; ConnectionInfo.clientIp is client supplied
+        var connection = context.getConnection();
+        if (connection != null) {
+            callback.setRemoteAddress(connection.getRemoteAddress());
+        }
+        if (info.getTransportContext() instanceof X509Certificate[]) {
+            callback.setCertificates((X509Certificate[]) info.getTransportContext());
+        }
+        var connector = context.getConnector();
+        if (connector != null) {
+            callback.setSsl(connector.isSsl());
+            callback.setTransportConnectorName(connector.getName());
+        }
+        return callback;
+    }
+
     @Override
     public SecurityContext authenticate(String username, String password, X509Certificate[] certificates) throws SecurityException {
+        var connection = new ConnectionCallback();
+        connection.setCertificates(certificates);
+        return authenticate(username, password, connection);
+    }
+
+    /**
+     * Authenticates a connection, also handing the login module a description of
+     * the connection (id, clientId, broker name, network declaration, SSL, remote
+     * address, transport connector, client certificates) so clientId and network
+     * connection authorization can be applied.
+     */
+    public SecurityContext authenticate(String username, String password, ConnectionCallback connection) throws SecurityException {
         SecurityContext result = null;
-        JassCredentialCallbackHandler callback = new JassCredentialCallbackHandler(username, password);
+        var callback = new JassCredentialCallbackHandler(username, password, connection);
         try {
-            LoginContext lc = new LoginContext(jassConfiguration, callback);
+            var lc = new LoginContext(jassConfiguration, callback);
             lc.login();
-            Subject subject = lc.getSubject();
+            var subject = lc.getSubject();
 
             result = new JaasSecurityContext(username, subject);
         } catch (Exception ex) {
diff --git a/activemq-broker/src/main/java/org/apache/activemq/security/JaasDualAuthenticationBroker.java b/activemq-broker/src/main/java/org/apache/activemq/security/JaasDualAuthenticationBroker.java
index 9a8d4dfd0e..24accbb812 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/security/JaasDualAuthenticationBroker.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/security/JaasDualAuthenticationBroker.java
@@ -24,7 +24,6 @@ import org.apache.activemq.broker.BrokerFilter;
 import org.apache.activemq.broker.ConnectionContext;
 import org.apache.activemq.broker.Connector;
 import org.apache.activemq.broker.EmptyBroker;
-import org.apache.activemq.broker.TransportConnector;
 import org.apache.activemq.command.ActiveMQDestination;
 import org.apache.activemq.command.ConnectionInfo;
 
@@ -111,12 +110,8 @@ public class JaasDualAuthenticationBroker extends BrokerFilter implements Authen
     }
 
     protected boolean isSSL(ConnectionContext context, ConnectionInfo info) throws Exception {
-        boolean sslCapable = false;
         Connector connector = context.getConnector();
-        if (connector instanceof TransportConnector) {
-            TransportConnector transportConnector = (TransportConnector) connector;
-            sslCapable = transportConnector.getServer().isSslServer();
-        }
+        boolean sslCapable = connector != null && connector.isSsl();
         // AMQ-5943, also check if transport context carries X509 cert
         if (!sslCapable && info.getTransportContext() instanceof X509Certificate[]) {
             sslCapable = true;
diff --git a/activemq-broker/src/main/java/org/apache/activemq/security/SecurityContext.java b/activemq-broker/src/main/java/org/apache/activemq/security/SecurityContext.java
index 39835117fd..d1ab3094d0 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/security/SecurityContext.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/security/SecurityContext.java
@@ -83,6 +83,25 @@ public abstract class SecurityContext {
 
     public abstract Set<Principal> getPrincipals();
 
+    /**
+     * Whether the authenticated user may register this connection as a network
+     * connection. Consulted when a connection identifies itself as a network
+     * bridge after it has already been authenticated. Defaults to allowed so
+     * authentication plugins that do not make the distinction are unaffected.
+     */
+    public boolean isNetworkConnectionAllowed() {
+        return true;
+    }
+
+    /**
+     * Whether the authenticating plugin made a network connection decision for this
+     * connection. When false the broker applies no network connection restriction and
+     * {@link #isNetworkConnectionAllowed()} is not consulted.
+     */
+    public boolean isNetworkConnectionAuthorizationRequired() {
+        return false;
+    }
+
     public boolean contains(Object principal) {
         Set<Principal> principals = getPrincipals();
         return principals != null && principals.contains(principal);
diff --git a/activemq-http/src/main/java/org/apache/activemq/transport/https/HttpsTransportServer.java b/activemq-http/src/main/java/org/apache/activemq/transport/https/HttpsTransportServer.java
index f31bf4b01b..b082f14279 100644
--- a/activemq-http/src/main/java/org/apache/activemq/transport/https/HttpsTransportServer.java
+++ b/activemq-http/src/main/java/org/apache/activemq/transport/https/HttpsTransportServer.java
@@ -24,6 +24,11 @@ import org.apache.activemq.transport.http.HttpTransportServer;
 
 public class HttpsTransportServer extends HttpTransportServer {
 
+    @Override
+    public boolean isSslServer() {
+        return true;
+    }
+
     public HttpsTransportServer(URI uri, HttpsTransportFactory factory, SslContext context) {
         super(uri, factory);
         this.socketConnectorFactory = new SecureSocketConnectorFactory(context);
diff --git a/activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionCallback.java b/activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionCallback.java
new file mode 100644
index 0000000000..57a5c01a2d
--- /dev/null
+++ b/activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionCallback.java
@@ -0,0 +1,113 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.jaas;
+
+import java.security.cert.X509Certificate;
+
+import javax.security.auth.callback.Callback;
+
+/**
+ * Callback used to pass details of the connection being authenticated to a
+ * login module so it can authorize more than the user credentials: the
+ * connection id, the requested clientId, the name of the broker performing the
+ * authentication, whether the connection has declared itself a network
+ * connection, whether it arrived over SSL/TLS, its remote address, the name of
+ * the transport connector that accepted it and the client certificate chain it
+ * presented, if any.
+ */
+public class ConnectionCallback implements Callback {
+
+    private String connectionId;
+    private String clientId;
+    private String brokerName;
+    private boolean networkConnection;
+    private boolean ssl;
+    private String remoteAddress;
+    private String transportConnectorName;
+    private X509Certificate[] certificates;
+
+    /** the client certificate chain presented during the TLS handshake, or null when none */
+    public X509Certificate[] getCertificates() {
+        return certificates;
+    }
+
+    public void setCertificates(X509Certificate[] certificates) {
+        this.certificates = certificates;
+    }
+
+    /** true when the connection was accepted over SSL/TLS */
+    public boolean isSsl() {
+        return ssl;
+    }
+
+    public void setSsl(boolean ssl) {
+        this.ssl = ssl;
+    }
+
+    /** the client's remote address as seen by the transport, or null when unknown */
+    public String getRemoteAddress() {
+        return remoteAddress;
+    }
+
+    public void setRemoteAddress(String remoteAddress) {
+        this.remoteAddress = remoteAddress;
+    }
+
+    /** the name of the transport connector that accepted the connection, or null when unknown */
+    public String getTransportConnectorName() {
+        return transportConnectorName;
+    }
+
+    public void setTransportConnectorName(String transportConnectorName) {
+        this.transportConnectorName = transportConnectorName;
+    }
+
+    /** the connection's id as assigned by the client, or null when unknown */
+    public String getConnectionId() {
+        return connectionId;
+    }
+
+    public void setConnectionId(String connectionId) {
+        this.connectionId = connectionId;
+    }
+
+    public String getClientId() {
+        return clientId;
+    }
+
+    public void setClientId(String clientId) {
+        this.clientId = clientId;
+    }
+
+    /** name of the broker authenticating the connection, or null when unknown */
+    public String getBrokerName() {
+        return brokerName;
+    }
+
+    public void setBrokerName(String brokerName) {
+        this.brokerName = brokerName;
+    }
+
+    /** true when the connection has identified itself as a network bridge */
+    public boolean isNetworkConnection() {
+        return networkConnection;
+    }
+
+    public void setNetworkConnection(boolean networkConnection) {
+        this.networkConnection = networkConnection;
+    }
+}
diff --git a/activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionPrincipal.java b/activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionPrincipal.java
new file mode 100644
index 0000000000..e6aee78412
--- /dev/null
+++ b/activemq-jaas/src/main/java/org/apache/activemq/jaas/ConnectionPrincipal.java
@@ -0,0 +1,134 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.jaas;
+
+import java.security.Principal;
+import java.util.Objects;
+
+/**
+ * Principal describing the connection that was authenticated. Its name is the
+ * connection id, which identifies the connection uniquely; it also carries the
+ * clientId the connection was permitted to use, if it presented one, whether the
+ * user's rule allows it to act as a network connection, whether it arrived over
+ * SSL/TLS, its remote address and the transport connector that accepted it.
+ *
+ * <p>The connection id is generated by the client from its host name, port and a
+ * counter (bridges prefix it with the broker names). It is unique but not
+ * verified by the broker, so it identifies the connection and must not be the
+ * basis of an authorization decision.
+ *
+ * <p>Added to the Subject alongside the {@link UserPrincipal} whenever clientId
+ * authorization is enabled on the login module, so the broker can hold a
+ * connection to the network decision even if it declares itself a network
+ * connection after authenticating. Its absence means the feature is not
+ * configured.
+ */
+public class ConnectionPrincipal implements Principal {
+
+    private final String connectionId;
+    private final String clientId;
+    private final boolean networkConnection;
+    private final boolean ssl;
+    private final String remoteAddress;
+    private final String transportConnectorName;
+    private transient int hash;
+
+    public ConnectionPrincipal(String connectionId, String clientId, boolean networkConnection) {
+        this(connectionId, clientId, networkConnection, false, null, null);
+    }
+
+    public ConnectionPrincipal(String connectionId, String clientId, boolean networkConnection, boolean ssl,
+            String remoteAddress, String transportConnectorName) {
+        this.connectionId = connectionId;
+        this.clientId = clientId;
+        this.networkConnection = networkConnection;
+        this.ssl = ssl;
+        this.remoteAddress = remoteAddress;
+        this.transportConnectorName = transportConnectorName;
+    }
+
+    /** true when the connection was accepted over SSL/TLS */
+    public boolean isSsl() {
+        return ssl;
+    }
+
+    /** the client's remote address as seen by the transport, or null when unknown */
+    public String getRemoteAddress() {
+        return remoteAddress;
+    }
+
+    /** the name of the transport connector that accepted the connection, or null when unknown */
+    public String getTransportConnectorName() {
+        return transportConnectorName;
+    }
+
+    /** the connection id assigned by the client, or null when the callback handler did not supply one */
+    public String getConnectionId() {
+        return connectionId;
+    }
+
+    /** the clientId the connection presented and was allowed, or null when it set none */
+    public String getClientId() {
+        return clientId;
+    }
+
+    /** true when the user's rule permits this connection to be a network connection */
+    public boolean isNetworkConnection() {
+        return networkConnection;
+    }
+
+    /** the connection id, or an empty string when none was supplied */
+    @Override
+    public String getName() {
+        return connectionId != null ? connectionId : "";
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        final ConnectionPrincipal that = (ConnectionPrincipal) o;
+        return networkConnection == that.networkConnection
+                && ssl == that.ssl
+                && Objects.equals(connectionId, that.connectionId)
+                && Objects.equals(clientId, that.clientId)
+                && Objects.equals(remoteAddress, that.remoteAddress)
+                && Objects.equals(transportConnectorName, that.transportConnectorName);
+    }
+
+    @Override
+    public int hashCode() {
+        if (hash == 0) {
+            hash = Objects.hash(connectionId, clientId, networkConnection, ssl, remoteAddress, transportConnectorName);
+        }
+        return hash;
+    }
+
+    @Override
+    public String toString() {
+        return getName()
+                + (clientId != null ? " clientId=" + clientId : "")
+                + (transportConnectorName != null ? " connector=" + transportConnectorName : "")
+                + (remoteAddress != null ? " from " + remoteAddress : "")
+                + (ssl ? " [ssl]" : "")
+                + (networkConnection ? " [networkConnection]" : "");
+    }
+}
diff --git a/activemq-jaas/src/main/java/org/apache/activemq/jaas/JassCredentialCallbackHandler.java b/activemq-jaas/src/main/java/org/apache/activemq/jaas/JassCredentialCallbackHandler.java
index 3208d77497..da7449aa77 100644
--- a/activemq-jaas/src/main/java/org/apache/activemq/jaas/JassCredentialCallbackHandler.java
+++ b/activemq-jaas/src/main/java/org/apache/activemq/jaas/JassCredentialCallbackHandler.java
@@ -31,10 +31,17 @@ public class JassCredentialCallbackHandler implements CallbackHandler {
 
     private final String username;
     private final String password;
+    // describes the connection being authenticated; null when the caller has no connection details
+    private final ConnectionCallback connection;
 
     public JassCredentialCallbackHandler(String username, String password) {
+        this(username, password, null);
+    }
+
+    public JassCredentialCallbackHandler(String username, String password, ConnectionCallback connection) {
         this.username = username;
         this.password = password;
+        this.connection = connection;
     }
 
     @Override
@@ -55,6 +62,16 @@ public class JassCredentialCallbackHandler implements CallbackHandler {
                 } else {
                     nameCallback.setName(username);
                 }
+            } else if (callback instanceof ConnectionCallback && connection != null) {
+                ConnectionCallback connectionCallback = (ConnectionCallback)callback;
+                connectionCallback.setConnectionId(connection.getConnectionId());
+                connectionCallback.setClientId(connection.getClientId());
+                connectionCallback.setBrokerName(connection.getBrokerName());
+                connectionCallback.setNetworkConnection(connection.isNetworkConnection());
+                connectionCallback.setSsl(connection.isSsl());
+                connectionCallback.setRemoteAddress(connection.getRemoteAddress());
+                connectionCallback.setTransportConnectorName(connection.getTransportConnectorName());
+                connectionCallback.setCertificates(connection.getCertificates());
             }
         }
     }
diff --git a/activemq-jaas/src/main/java/org/apache/activemq/jaas/PropertiesLoginModule.java b/activemq-jaas/src/main/java/org/apache/activemq/jaas/PropertiesLoginModule.java
index 153a12534e..37929c4221 100644
--- a/activemq-jaas/src/main/java/org/apache/activemq/jaas/PropertiesLoginModule.java
+++ b/activemq-jaas/src/main/java/org/apache/activemq/jaas/PropertiesLoginModule.java
@@ -18,10 +18,13 @@ package org.apache.activemq.jaas;
 
 import java.io.IOException;
 import java.security.Principal;
-import java.util.HashSet;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Properties;
 import java.util.Set;
+import java.util.regex.Pattern;
 
 import javax.security.auth.Subject;
 import javax.security.auth.callback.Callback;
@@ -40,6 +43,27 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu
 
     private static final String USER_FILE_PROP_NAME = "org.apache.activemq.jaas.properties.user";
     private static final String GROUP_FILE_PROP_NAME = "org.apache.activemq.jaas.properties.group";
+    private static final String CLIENTID_FILE_PROP_NAME = "org.apache.activemq.jaas.properties.clientid";
+
+    /** matches the authenticated user name when expanded in a clientId pattern */
+    private static final String USER_TOKEN = "${userId}";
+    /** matches the name of the broker performing the authentication when expanded in a clientId pattern */
+    private static final String BROKER_TOKEN = "${brokerName}";
+
+    /**
+     * A parsed clientids.properties entry:
+     * {@code <userId> = true|false, <clientId pattern>, <clientId pattern>...}
+     * The leading boolean states whether the user may register a network connection.
+     */
+    private static final class ClientIdRule {
+        final boolean networkConnectionAllowed;
+        final List<String> patterns;
+
+        ClientIdRule(boolean networkConnectionAllowed, List<String> patterns) {
+            this.networkConnectionAllowed = networkConnectionAllowed;
+            this.patterns = patterns;
+        }
+    }
 
     private static final Logger LOG = LoggerFactory.getLogger(PropertiesLoginModule.class);
 
@@ -48,8 +72,18 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu
 
     private Properties users;
     private Map<String,Set<String>> groups;
+    // Optional: userId -> "true|false, clientId patterns". Null when clientId
+    // authentication is not configured (the CLIENTID_FILE_PROP_NAME option is absent).
+    private Properties clientIds;
     private String user;
-    private final Set<Principal> principals = new HashSet<Principal>();
+    // the connection being authenticated, as described by the callback handler
+    private ConnectionCallback connection;
+    private String clientId;
+    private boolean networkConnectionAllowed;
+    // LinkedHashSet so principal insertion order is preserved when copied into the
+    // Subject: UserPrincipal is always added first, then ConnectionPrincipal (when
+    // clientId authorization is enabled), then group principals.
+    private final Set<Principal> principals = new LinkedHashSet<Principal>();
 
     /** the authentication status*/
     private boolean succeeded = false;
@@ -63,6 +97,10 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu
         init(options);
         users = load(USER_FILE_PROP_NAME, "user", options).getProps();
         groups = load(GROUP_FILE_PROP_NAME, "group", options).invertedPropertiesValuesMap();
+        // clientId authentication is opt-in: only enabled when the file option is present
+        if (options.containsKey(CLIENTID_FILE_PROP_NAME)) {
+            clientIds = load(CLIENTID_FILE_PROP_NAME, "clientids", options).getProps();
+        }
     }
 
     @Override
@@ -94,6 +132,28 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu
         if (!password.equals(new String(tmpPassword))) {
             throw new FailedLoginException("Password does not match");
         }
+
+        // When enabled, also authenticate the connection's clientId. A connection
+        // that presents a clientId it is not permitted to use fails to log in. A
+        // connection with no clientId is allowed (it cannot own durable subscriptions).
+        if (clientIds != null) {
+            connection = getConnectionCallback();
+            ClientIdRule rule = ruleFor(user);
+            // A network connection is denied unless the administrator has explicitly
+            // allowed this user to register one. Application connections are unaffected.
+            networkConnectionAllowed = rule != null && rule.networkConnectionAllowed;
+            if (connection.isNetworkConnection() && !networkConnectionAllowed) {
+                throw new FailedLoginException("network connection is not allowed for user");
+            }
+            String requestedClientId = connection.getClientId();
+            if (requestedClientId != null && !requestedClientId.isEmpty()) {
+                if (rule == null || !isClientIdAllowed(rule, user, requestedClientId, connection.getBrokerName())) {
+                    throw new FailedLoginException("clientId is not allowed for user");
+                }
+                clientId = requestedClientId;
+            }
+        }
+
         succeeded = true;
 
         if (debug) {
@@ -112,8 +172,18 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu
             return false;
         }
 
+        // UserPrincipal is always added first; ConnectionPrincipal (when clientId
+        // authorization is enabled) is added second, ahead of any group principals. It
+        // carries the allowed clientId and the network connection decision so the broker
+        // can enforce the latter if the connection declares itself a network connection
+        // after authenticating.
         principals.add(new UserPrincipal(user));
 
+        if (clientIds != null) {
+            principals.add(new ConnectionPrincipal(connection.getConnectionId(), clientId, networkConnectionAllowed,
+                    connection.isSsl(), connection.getRemoteAddress(), connection.getTransportConnectorName()));
+        }
+
         Set<String> matchedGroups = groups.get(user);
         if (matchedGroups != null) {
             for (String entry : matchedGroups) {
@@ -164,7 +234,85 @@ public class PropertiesLoginModule extends PropertiesLoader implements LoginModu
 
     private void clear() {
         user = null;
+        connection = null;
+        clientId = null;
+        networkConnectionAllowed = false;
         principals.clear();
     }
 
+    private ConnectionCallback getConnectionCallback() throws LoginException {
+        ConnectionCallback connectionCallback = new ConnectionCallback();
+        try {
+            callbackHandler.handle(new Callback[] {connectionCallback});
+        } catch (IOException ioe) {
+            throw new LoginException(ioe.getMessage());
+        } catch (UnsupportedCallbackException uce) {
+            // callback handler does not describe the connection; treat as an
+            // application connection with no clientId
+        }
+        return connectionCallback;
+    }
+
+    /**
+     * The rule for a user: their own entry, or the {@code ${userId}} fallback when
+     * they have none. An explicit entry that fails to parse denies everything for
+     * that user rather than falling back.
+     */
+    private ClientIdRule ruleFor(String userId) {
+        String value = clientIds.getProperty(userId);
+        if (value != null) {
+            return parseRule(userId, value);
+        }
+        value = clientIds.getProperty(USER_TOKEN);
+        return value != null ? parseRule(USER_TOKEN, value) : null;
+    }
+
+    /**
+     * Parses {@code true|false, pattern, pattern...}. The boolean is required so
+     * that permission to register a network connection is always stated.
+     */
+    private static ClientIdRule parseRule(String key, String value) {
+        String[] tokens = value.split(",");
+        String first = tokens[0].trim();
+        if (!"true".equalsIgnoreCase(first) && !"false".equalsIgnoreCase(first)) {
+            LOG.warn("Ignoring clientId rule for '{}': the first value must be true or false (may register a network connection), found '{}'",
+                    key, first);
+            return null;
+        }
+        List<String> patterns = new ArrayList<String>();
+        for (int i = 1; i < tokens.length; i++) {
+            String pattern = tokens[i].trim();
+            if (!pattern.isEmpty()) {
+                patterns.add(pattern);
+            }
+        }
+        return new ClientIdRule(Boolean.parseBoolean(first), patterns);
+    }
+
+    private static boolean isClientIdAllowed(ClientIdRule rule, String userId, String clientId, String brokerName) {
+        for (String pattern : rule.patterns) {
+            pattern = pattern.replace(USER_TOKEN, userId);
+            if (brokerName != null) {
+                pattern = pattern.replace(BROKER_TOKEN, brokerName);
+            }
+            if (matches(pattern, clientId)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean matches(String pattern, String clientId) {
+        // '*' is a multi-character wildcard; all other characters match literally.
+        StringBuilder regex = new StringBuilder();
+        String[] segments = pattern.split("\\*", -1);
+        for (int i = 0; i < segments.length; i++) {
+            if (i > 0) {
+                regex.append(".*");
+            }
+            regex.append(Pattern.quote(segments[i]));
+        }
+        return clientId.matches(regex.toString());
+    }
+
 }
diff --git a/activemq-jaas/src/test/java/org/apache/activemq/jaas/ClientIdPropertiesLoginModuleTest.java b/activemq-jaas/src/test/java/org/apache/activemq/jaas/ClientIdPropertiesLoginModuleTest.java
new file mode 100644
index 0000000000..8fa946ef62
--- /dev/null
+++ b/activemq-jaas/src/test/java/org/apache/activemq/jaas/ClientIdPropertiesLoginModuleTest.java
@@ -0,0 +1,238 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.jaas;
+
+import java.io.IOException;
+import java.util.ArrayList;
+
+import javax.security.auth.Subject;
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.CallbackHandler;
+import javax.security.auth.callback.NameCallback;
+import javax.security.auth.callback.PasswordCallback;
+import javax.security.auth.callback.UnsupportedCallbackException;
+import javax.security.auth.login.FailedLoginException;
+import javax.security.auth.login.LoginContext;
+import javax.security.auth.login.LoginException;
+
+import junit.framework.TestCase;
+
+/**
+ * Verifies the optional clientId authentication of {@link PropertiesLoginModule}:
+ * a connection's clientId is authorized together with the user credentials.
+ */
+public class ClientIdPropertiesLoginModuleTest extends TestCase {
+
+    private static final String LOGIN_MODULE = "PropertiesLoginClientId";
+    private static final String CONNECTION_ID = "ID:test-host-61616-1-1:1";
+    private static final String REMOTE_ADDRESS = "tcp://127.0.0.1:52000";
+    private static final String CONNECTOR_NAME = "openwire";
+
+    static {
+        var path = System.getProperty("java.security.auth.login.config");
+        if (path == null) {
+            var resource = ClientIdPropertiesLoginModuleTest.class.getClassLoader().getResource("login.config");
+            if (resource != null) {
+                System.setProperty("java.security.auth.login.config", resource.getFile());
+            }
+        }
+    }
+
+    public void testExplicitClientIdAllowed() throws Exception {
+        // 'first = false, first-primary, first-*' — exact match
+        login("first", "secret", "first-primary");
+    }
+
+    public void testWildcardClientIdAllowed() throws Exception {
+        // 'first = false, ..., first-*' — wildcard match
+        login("first", "secret", "first-42");
+    }
+
+    public void testClientIdNotAllowedFailsLogin() throws Exception {
+        // 'first' is confined to first-*; a foreign clientId must fail login
+        try {
+            login("first", "secret", "quote-1");
+            fail("Should have thrown a FailedLoginException for a disallowed clientId");
+        } catch (FailedLoginException expected) {
+        }
+    }
+
+    public void testWildcardUserAllowedAnyClientId() throws Exception {
+        // 'admin = false, *' — any clientId permitted
+        login("admin", "admin", "anything-goes");
+    }
+
+    public void testFallbackRuleAppliesToUserWithoutEntry() throws Exception {
+        // 'second' has no explicit entry -> '${userId} = false, ${userId}-*' -> second-*
+        login("second", "password", "second-1");
+    }
+
+    public void testFallbackRuleRejectsForeignPrefix() throws Exception {
+        try {
+            login("second", "password", "first-1");
+            fail("Should have thrown a FailedLoginException; second may only use second-*");
+        } catch (FailedLoginException expected) {
+        }
+    }
+
+    public void testNoClientIdAllowed() throws Exception {
+        // a connection that presents no clientId still authenticates (no durable ownership)
+        var subject = login("first", "secret", null);
+        var connection = subject.getPrincipals(ConnectionPrincipal.class).iterator().next();
+        assertEquals("no clientId recorded", null, connection.getClientId());
+        assertFalse(connection.isNetworkConnection());
+    }
+
+    public void testUserPrincipalFirstConnectionPrincipalSecond() throws Exception {
+        var subject = login("first", "secret", "first-primary");
+
+        assertEquals("one user principal", 1, subject.getPrincipals(UserPrincipal.class).size());
+        assertEquals("one connection principal", 1, subject.getPrincipals(ConnectionPrincipal.class).size());
+        var connection = subject.getPrincipals(ConnectionPrincipal.class).iterator().next();
+        assertEquals("connection principal is named by the connection id", CONNECTION_ID, connection.getName());
+        assertEquals("connection principal carries the clientId", "first-primary", connection.getClientId());
+        assertTrue("connection principal carries the ssl flag", connection.isSsl());
+        assertEquals("connection principal carries the remote address", REMOTE_ADDRESS, connection.getRemoteAddress());
+        assertEquals("connection principal carries the connector name", CONNECTOR_NAME, connection.getTransportConnectorName());
+
+        var ordered = new ArrayList<>(subject.getPrincipals());
+        assertTrue("UserPrincipal must be first", ordered.get(0) instanceof UserPrincipal);
+        assertTrue("ConnectionPrincipal must be second", ordered.get(1) instanceof ConnectionPrincipal);
+    }
+
+    public void testNetworkConnectionAllowedForFlaggedUser() throws Exception {
+        // 'system = true, ...' and NC_*_outbound is an allowed clientId
+        var subject = login("system", "manager", "NC_broker2_outbound", "broker1", true);
+        assertTrue("decision must be recorded as allowed", networkDecision(subject));
+    }
+
+    public void testNetworkConnectionDeniedWithoutFlag() throws Exception {
+        // 'first = false, ...' has an allowed clientId but may not register a network connection
+        try {
+            login("first", "secret", "first-primary", "broker1", true);
+            fail("Should have thrown a FailedLoginException; first may not register a network connection");
+        } catch (FailedLoginException expected) {
+        }
+    }
+
+    public void testWildcardClientIdDoesNotGrantNetworkConnection() throws Exception {
+        // 'admin = false, *' permits any clientId, but the leading false denies network connections
+        try {
+            login("admin", "admin", "NC_broker2_outbound", "broker1", true);
+            fail("Should have thrown a FailedLoginException; admin rule starts with false");
+        } catch (FailedLoginException expected) {
+        }
+    }
+
+    public void testApplicationConnectionUnaffectedByNetworkFlag() throws Exception {
+        // a flagged user is still free to make an ordinary application connection
+        var subject = login("system", "manager", null, "broker1", false);
+        assertTrue(networkDecision(subject));
+    }
+
+    public void testBrokerNameMacroExpandsToAuthenticatingBroker() throws Exception {
+        // NC_*_inbound_${brokerName} with brokerName=broker1
+        login("system", "manager", "NC_broker2_inbound_broker1", "broker1", true);
+        try {
+            login("system", "manager", "NC_broker2_inbound_broker9", "broker1", true);
+            fail("Should have thrown a FailedLoginException; inbound clientId names another broker");
+        } catch (FailedLoginException expected) {
+        }
+    }
+
+    public void testNetworkDecisionRecordedAsDeniedByDefault() throws Exception {
+        // the principal is always present when clientId authorization is on, so a
+        // connection that later declares itself a network connection can be held to it
+        var subject = login("second", "password", "second-1");
+        assertFalse(networkDecision(subject));
+    }
+
+    public void testEntryWithoutLeadingBooleanDeniesClientIds() throws Exception {
+        // 'broken = broken-*' has no leading true|false and is ignored as a whole
+        try {
+            login("broken", "broken", "broken-1");
+            fail("Should have thrown a FailedLoginException; the malformed rule must not allow any clientId");
+        } catch (FailedLoginException expected) {
+        }
+    }
+
+    public void testEntryWithoutLeadingBooleanDeniesNetworkConnection() throws Exception {
+        try {
+            login("broken", "broken", null, "broker1", true);
+            fail("Should have thrown a FailedLoginException; the malformed rule must not allow a network connection");
+        } catch (FailedLoginException expected) {
+        }
+        // credentials alone still work for an application connection without a clientId
+        assertFalse(networkDecision(login("broken", "broken", null)));
+    }
+
+    private static boolean networkDecision(Subject subject) {
+        var connections = subject.getPrincipals(ConnectionPrincipal.class);
+        assertEquals("exactly one connection principal expected", 1, connections.size());
+        return connections.iterator().next().isNetworkConnection();
+    }
+
+    private Subject login(String user, String pass, String clientId) throws LoginException {
+        return login(user, pass, clientId, null, false);
+    }
+
+    private Subject login(String user, String pass, String clientId, String brokerName, boolean networkConnection) throws LoginException {
+        var context = new LoginContext(LOGIN_MODULE,
+                new UserPassClientIdHandler(user, pass, clientId, brokerName, networkConnection));
+        context.login();
+        return context.getSubject();
+    }
+
+    private static class UserPassClientIdHandler implements CallbackHandler {
+
+        private final String user;
+        private final String pass;
+        private final String clientId;
+        private final String brokerName;
+        private final boolean networkConnection;
+
+        UserPassClientIdHandler(String user, String pass, String clientId, String brokerName, boolean networkConnection) {
+            this.user = user;
+            this.pass = pass;
+            this.clientId = clientId;
+            this.brokerName = brokerName;
+            this.networkConnection = networkConnection;
+        }
+
+        @Override
+        public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
+            for (var callback : callbacks) {
+                if (callback instanceof NameCallback) {
+                    ((NameCallback) callback).setName(user);
+                } else if (callback instanceof PasswordCallback) {
+                    ((PasswordCallback) callback).setPassword(pass.toCharArray());
+                } else if (callback instanceof ConnectionCallback) {
+                    var connection = (ConnectionCallback) callback;
+                    connection.setConnectionId(CONNECTION_ID);
+                    connection.setClientId(clientId);
+                    connection.setSsl(true);
+                    connection.setRemoteAddress(REMOTE_ADDRESS);
+                    connection.setTransportConnectorName(CONNECTOR_NAME);
+                    connection.setBrokerName(brokerName);
+                    connection.setNetworkConnection(networkConnection);
+                } else {
+                    throw new UnsupportedCallbackException(callback);
+                }
+            }
+        }
+    }
+}
diff --git a/activemq-jaas/src/test/java/org/apache/activemq/jaas/JassCredentialCallbackHandlerTest.java b/activemq-jaas/src/test/java/org/apache/activemq/jaas/JassCredentialCallbackHandlerTest.java
new file mode 100644
index 0000000000..77907dc65b
--- /dev/null
+++ b/activemq-jaas/src/test/java/org/apache/activemq/jaas/JassCredentialCallbackHandlerTest.java
@@ -0,0 +1,72 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.jaas;
+
+import java.security.cert.X509Certificate;
+
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.NameCallback;
+import javax.security.auth.callback.PasswordCallback;
+
+import junit.framework.TestCase;
+
+/**
+ * The handler must hand every connection attribute it was given to the login
+ * module's ConnectionCallback, and tolerate having none.
+ */
+public class JassCredentialCallbackHandlerTest extends TestCase {
+
+    public void testCopiesEveryConnectionAttribute() throws Exception {
+        var certificates = new X509Certificate[0];
+        var given = new ConnectionCallback();
+        given.setConnectionId("ID:host-1-1:1");
+        given.setClientId("order-1");
+        given.setBrokerName("broker1");
+        given.setNetworkConnection(true);
+        given.setSsl(true);
+        given.setRemoteAddress("tcp://127.0.0.1:5000");
+        given.setTransportConnectorName("openwire");
+        given.setCertificates(certificates);
+
+        var name = new NameCallback("Username: ");
+        var password = new PasswordCallback("Password: ", false);
+        var asked = new ConnectionCallback();
+        new JassCredentialCallbackHandler("order", "secret", given).handle(new Callback[] {name, password, asked});
+
+        assertEquals("order", name.getName());
+        assertEquals("secret", new String(password.getPassword()));
+        assertEquals("ID:host-1-1:1", asked.getConnectionId());
+        assertEquals("order-1", asked.getClientId());
+        assertEquals("broker1", asked.getBrokerName());
+        assertTrue(asked.isNetworkConnection());
+        assertTrue(asked.isSsl());
+        assertEquals("tcp://127.0.0.1:5000", asked.getRemoteAddress());
+        assertEquals("openwire", asked.getTransportConnectorName());
+        assertSame(certificates, asked.getCertificates());
+    }
+
+    public void testNoConnectionLeavesCallbackUntouched() throws Exception {
+        var asked = new ConnectionCallback();
+        new JassCredentialCallbackHandler("order", "secret").handle(new Callback[] {asked});
+
+        assertNull(asked.getConnectionId());
+        assertNull(asked.getClientId());
+        assertFalse(asked.isNetworkConnection());
+        assertFalse(asked.isSsl());
+        assertNull(asked.getCertificates());
+    }
+}
diff --git a/activemq-jaas/src/test/resources/clientid-users.properties b/activemq-jaas/src/test/resources/clientid-users.properties
new file mode 100644
index 0000000000..2e1c390e1c
--- /dev/null
+++ b/activemq-jaas/src/test/resources/clientid-users.properties
@@ -0,0 +1,21 @@
+## ---------------------------------------------------------------------------
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements.  See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License.  You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+## ---------------------------------------------------------------------------
+first=secret
+second=password
+admin=admin
+system=manager
+broken=broken
diff --git a/activemq-jaas/src/test/resources/clientids.properties b/activemq-jaas/src/test/resources/clientids.properties
new file mode 100644
index 0000000000..7f5278e587
--- /dev/null
+++ b/activemq-jaas/src/test/resources/clientids.properties
@@ -0,0 +1,33 @@
+## ---------------------------------------------------------------------------
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements.  See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License.  You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+## ---------------------------------------------------------------------------
+##
+## clientId authorization rules used by ClientIdPropertiesLoginModuleTest.
+
+# Format: <userId> = <may register a network connection: true|false>, <clientId patterns...>
+
+# admin may use any clientId but is not a network bridge
+admin = false, *
+# 'first' has an explicit rule: an exact id plus a wildcard, no network connections
+first = false, first-primary, first-*
+# 'second' has no explicit entry and falls back to the generic per-user rule
+${userId} = false, ${userId}-*
+# 'system' operates network bridges: may use the bridge clientId formats and is
+# explicitly allowed to register network connections. ${brokerName} expands to the
+# name of the broker doing the authentication.
+system = true, NC_*_inbound_${brokerName}, NC_*_outbound
+# 'broken' is missing the leading boolean; the rule is ignored and denies everything
+broken = broken-*
diff --git a/activemq-jaas/src/test/resources/login.config b/activemq-jaas/src/test/resources/login.config
index 2dca7b45d6..a7524a2482 100644
--- a/activemq-jaas/src/test/resources/login.config
+++ b/activemq-jaas/src/test/resources/login.config
@@ -30,6 +30,14 @@ PropertiesLoginReload {
         org.apache.activemq.jaas.properties.group="groups.properties";
 };
 
+PropertiesLoginClientId {
+    org.apache.activemq.jaas.PropertiesLoginModule required
+        debug=true
+        org.apache.activemq.jaas.properties.user="clientid-users.properties"
+        org.apache.activemq.jaas.properties.group="groups.properties"
+        org.apache.activemq.jaas.properties.clientid="clientids.properties";
+};
+
 EncryptedPropertiesLogin {
     org.apache.activemq.jaas.PropertiesLoginModule required
         debug=true
diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/broker/TransportConnectorSslTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/broker/TransportConnectorSslTest.java
new file mode 100644
index 0000000000..bec8afce22
--- /dev/null
+++ b/activemq-unit-tests/src/test/java/org/apache/activemq/broker/TransportConnectorSslTest.java
@@ -0,0 +1,115 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.broker;
+
+import static org.junit.Assert.assertEquals;
+
+import java.net.URI;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.activemq.broker.jmx.ConnectorView;
+import org.apache.activemq.test.annotations.ParallelTest;
+import org.junit.After;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+/**
+ * Connector.isSsl(): the bound transport server's answer once started, the
+ * configured scheme before that.
+ */
+@Category(ParallelTest.class)
+public class TransportConnectorSslTest {
+
+    private static final String KEYSTORE = "src/test/resources/org/apache/activemq/security/broker1.ks";
+
+    private BrokerService broker;
+
+    @BeforeClass
+    public static void keystore() {
+        System.setProperty("javax.net.ssl.keyStore", KEYSTORE);
+        System.setProperty("javax.net.ssl.keyStorePassword", "password");
+        System.setProperty("javax.net.ssl.keyStoreType", "jks");
+        System.setProperty("javax.net.ssl.trustStore", KEYSTORE);
+        System.setProperty("javax.net.ssl.trustStorePassword", "password");
+        System.setProperty("javax.net.ssl.trustStoreType", "jks");
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        if (broker != null) {
+            broker.stop();
+            broker.waitUntilStopped();
+        }
+    }
+
+    @Test
+    public void testConfiguredSchemeDecidesBeforeStart() throws Exception {
+        var expectations = new LinkedHashMap<String, Boolean>();
+        expectations.put("tcp", false);
+        expectations.put("nio", false);
+        expectations.put("auto", false);
+        expectations.put("auto+nio", false);
+        expectations.put("vm", false);
+        expectations.put("http", false);
+        expectations.put("ws", false);
+        expectations.put("ssl", true);
+        expectations.put("nio+ssl", true);
+        expectations.put("auto+ssl", true);
+        expectations.put("auto+nio+ssl", true);
+        expectations.put("mqtt+ssl", true);
+        expectations.put("stomp+nio+ssl", true);
+        expectations.put("amqp+nio+ssl", true);
+        expectations.put("https", true);
+        expectations.put("wss", true);
+        expectations.put("SSL", true);
+
+        for (Map.Entry<String, Boolean> expectation : expectations.entrySet()) {
+            var connector = new TransportConnector();
+            connector.setUri(new URI(expectation.getKey() + "://localhost:0"));
+            assertEquals(expectation.getKey(), expectation.getValue(), connector.isSsl());
+        }
+        assertEquals("no uri configured", false, new TransportConnector().isSsl());
+    }
+
+    @Test(timeout = 60000)
+    public void testBoundServerDecidesAfterStart() throws Exception {
+        broker = new BrokerService();
+        broker.setPersistent(false);
+        broker.setUseJmx(false);
+        var expectations = new LinkedHashMap<TransportConnector, Boolean>();
+        expectations.put(broker.addConnector("tcp://localhost:0"), false);
+        expectations.put(broker.addConnector("nio://localhost:0"), false);
+        expectations.put(broker.addConnector("auto://localhost:0"), false);
+        expectations.put(broker.addConnector("ssl://localhost:0"), true);
+        expectations.put(broker.addConnector("nio+ssl://localhost:0"), true);
+        expectations.put(broker.addConnector("auto+ssl://localhost:0"), true);
+        expectations.put(broker.addConnector("auto+nio+ssl://localhost:0"), true);
+        expectations.put(broker.addConnector("mqtt+ssl://localhost:0"), true);
+        expectations.put(broker.addConnector("stomp+nio+ssl://localhost:0"), true);
+        broker.start();
+        broker.waitUntilStarted();
+
+        for (Map.Entry<TransportConnector, Boolean> expectation : expectations.entrySet()) {
+            var connector = expectation.getKey();
+            var label = connector.getUri().toString();
+            assertEquals(label, expectation.getValue(), connector.isSsl());
+            assertEquals(label + " via JMX view", expectation.getValue(), new ConnectorView(connector).isSsl());
+        }
+    }
+}
diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/security/JaasNetworkConnectionAuthorizationTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/security/JaasNetworkConnectionAuthorizationTest.java
new file mode 100644
index 0000000000..89d0987874
--- /dev/null
+++ b/activemq-unit-tests/src/test/java/org/apache/activemq/security/JaasNetworkConnectionAuthorizationTest.java
@@ -0,0 +1,397 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.security;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import jakarta.jms.Session;
+
+import org.apache.activemq.ActiveMQConnectionFactory;
+import org.apache.activemq.broker.Broker;
+import org.apache.activemq.broker.BrokerFilter;
+import org.apache.activemq.broker.BrokerPlugin;
+import org.apache.activemq.broker.BrokerService;
+import org.apache.activemq.broker.ConnectionContext;
+import org.apache.activemq.broker.Connection;
+import org.apache.activemq.broker.TransportConnection;
+import org.apache.activemq.command.ActiveMQQueue;
+import org.apache.activemq.command.BrokerId;
+import org.apache.activemq.command.BrokerInfo;
+import org.apache.activemq.command.ConnectionId;
+import org.apache.activemq.command.ConnectionInfo;
+import org.apache.activemq.command.Response;
+import org.apache.activemq.jaas.ConnectionPrincipal;
+import org.apache.activemq.network.NetworkConnector;
+import org.apache.activemq.test.annotations.ParallelTest;
+import org.apache.activemq.transport.DefaultTransportListener;
+import org.apache.activemq.transport.TransportFactory;
+import org.apache.activemq.util.Wait;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+
+/**
+ * Network connection authorization through the JAAS PropertiesLoginModule with
+ * clientId authorization enabled: only users whose rule starts with true
+ * ({@code <userId> = true, <patterns>}) may register a network connection, whether they announce it before authenticating (a real bridge) or
+ * after (an application promoting itself with a late BrokerInfo).
+ *
+ * Fixtures: activemq-clientid-domain in login.config, clientid-users.properties
+ * and clientids.properties under org/apache/activemq/security. User 'bridge' is
+ * 'true, NC_*'; user 'app' is 'false, *'.
+ */
+@Category(ParallelTest.class)
+public class JaasNetworkConnectionAuthorizationTest {
+
+    private static final ActiveMQQueue QUEUE = new ActiveMQQueue("TEST.NETWORK.AUTHZ");
+
+    private BrokerService brokerA;
+    private BrokerService brokerB;
+    private String brokerBUri;
+
+    @Before
+    public void setUp() throws Exception {
+        System.setProperty("java.security.auth.login.config", "src/test/resources/login.config");
+        brokerB = createBroker("brokerB");
+        brokerB.addConnector("tcp://localhost:0");
+        brokerB.start();
+        brokerB.waitUntilStarted();
+        brokerBUri = brokerB.getTransportConnectors().get(0).getPublishableConnectString();
+        brokerA = createBroker("brokerA");
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        for (var broker : new BrokerService[] {brokerA, brokerB}) {
+            if (broker != null) {
+                broker.stop();
+                broker.waitUntilStopped();
+            }
+        }
+    }
+
+    private static BrokerService createBroker(String name) {
+        return createBroker(name, "activemq-clientid-domain");
+    }
+
+    private static BrokerService createBroker(String name, String jaasConfiguration) {
+        var broker = new BrokerService();
+        broker.setBrokerName(name);
+        broker.setPersistent(false);
+        broker.setUseJmx(false);
+        // advisory support stays on: demand forwarding across the bridge relies on it
+        var jaas = new JaasAuthenticationPlugin();
+        jaas.setConfiguration(jaasConfiguration);
+        // plugins wrap in order, so the capture sits inside JAAS and sees the security context it set
+        broker.setPlugins(new BrokerPlugin[] {new PrincipalCapture(), jaas});
+        return broker;
+    }
+
+    /** records the ConnectionPrincipal JAAS attached to each admitted connection, keyed by clientId */
+    private static final Map<String, ConnectionPrincipal> PRINCIPALS = new ConcurrentHashMap<>();
+
+    private static final class PrincipalCapture implements BrokerPlugin {
+        @Override
+        public Broker installPlugin(Broker broker) {
+            return new BrokerFilter(broker) {
+                @Override
+                public void addConnection(ConnectionContext context, ConnectionInfo info) throws Exception {
+                    super.addConnection(context, info);
+                    var securityContext = context.getSecurityContext();
+                    if (securityContext != null && info.getClientId() != null) {
+                        for (var principal : securityContext.getPrincipals()) {
+                            if (principal instanceof ConnectionPrincipal) {
+                                PRINCIPALS.put(info.getClientId(), (ConnectionPrincipal) principal);
+                            }
+                        }
+                    }
+                }
+            };
+        }
+    }
+
+    @Test(timeout = 60000)
+    public void testFlaggedUserCanEstablishBridge() throws Exception {
+        addNetworkConnector(null, "bridge", "bridge");
+        brokerA.start();
+        brokerA.waitUntilStarted();
+
+        assertTrue("bridge should form for the flagged user",
+                Wait.waitFor(() -> admittedNetworkConnectionsOn(brokerB) == 1, 15000, 10));
+        assertMessageCrossesBridge("app", "app");
+    }
+
+    /**
+     * The configuration most existing deployments have: JAAS password authentication
+     * on a realm without the clientids file. A password authenticated bridge must be
+     * admitted with no rule at all, and the local side's late BrokerInfo must pass the
+     * guard, because no network connection decision was ever made.
+     */
+    @Test(timeout = 60000)
+    public void testBridgeAdmittedWhenClientIdAuthorizationNotConfigured() throws Exception {
+        useRealm("activemq-domain");
+        // 'system' / 'manager' from users.properties
+        addNetworkConnector(null, "system", "manager");
+        brokerA.start();
+        brokerA.waitUntilStarted();
+
+        assertTrue("bridge should form without any clientId rule",
+                Wait.waitFor(() -> admittedNetworkConnectionsOn(brokerB) == 1, 15000, 10));
+        assertMessageCrossesBridge("user", "password");
+    }
+
+    /** Replaces both brokers with ones authenticating against the given JAAS realm. */
+    private void useRealm(String jaasConfiguration) throws Exception {
+        brokerB.stop();
+        brokerB.waitUntilStopped();
+        brokerB = createBroker("brokerB", jaasConfiguration);
+        brokerB.addConnector("tcp://localhost:0");
+        brokerB.start();
+        brokerB.waitUntilStarted();
+        brokerBUri = brokerB.getTransportConnectors().get(0).getPublishableConnectString();
+        brokerA = createBroker("brokerA", jaasConfiguration);
+    }
+
+    /**
+     * The networkConnector name replaces the default NC prefix in the bridge
+     * clientIds: toB_brokerA_outbound on B and toB_brokerB_inbound_brokerA on A.
+     * 'bridge-named = true, toB_*' follows that, so the bridge is admitted.
+     */
+    @Test(timeout = 60000)
+    public void testNamedConnectorAdmittedWhenPatternFollowsConnectorName() throws Exception {
+        addNetworkConnector("toB", "bridge-named", "bridge-named");
+        brokerA.start();
+        brokerA.waitUntilStarted();
+
+        assertTrue("bridge should form when the pattern covers the connector name prefix",
+                Wait.waitFor(() -> admittedNetworkConnectionsOn(brokerB) == 1, 15000, 10));
+        assertMessageCrossesBridge("app", "app");
+    }
+
+    /**
+     * Same named connector, but 'bridge = true, NC_*' only covers the default
+     * prefix. The user may register network connections, yet the clientId check
+     * refuses toB_... so no bridge forms. This is the operator mistake to expect
+     * when a connector is given a name.
+     */
+    @Test(timeout = 60000)
+    public void testNamedConnectorRefusedWhenPatternOnlyCoversDefaultPrefix() throws Exception {
+        addNetworkConnector("toB", "bridge", "bridge");
+        brokerA.start();
+        brokerA.waitUntilStarted();
+
+        assertFalse("a bridge formed although the clientId pattern does not cover the connector name",
+                Wait.waitFor(() -> admittedNetworkConnectionsOn(brokerB) > 0, 3000, 10));
+    }
+
+    private void assertMessageCrossesBridge(String user, String password) throws Exception {
+        try (var consumerConnection = new ActiveMQConnectionFactory("vm://brokerB?create=false").createConnection(user, password);
+             var consumerSession = consumerConnection.createSession(false, Session.AUTO_ACKNOWLEDGE);
+             var consumer = consumerSession.createConsumer(QUEUE);
+             var producerConnection = new ActiveMQConnectionFactory("vm://brokerA?create=false").createConnection(user, password);
+             var producerSession = producerConnection.createSession(false, Session.AUTO_ACKNOWLEDGE);
+             var producer = producerSession.createProducer(QUEUE)) {
+            consumerConnection.start();
+            producerConnection.start();
+            producer.send(producerSession.createTextMessage("across the bridge"));
+            var received = consumer.receive(TimeUnit.SECONDS.toMillis(10));
+            assertNotNull("message did not cross the bridge", received);
+        }
+    }
+
+    @Test(timeout = 60000)
+    public void testUnflaggedUserCannotEstablishBridge() throws Exception {
+        // 'app = false, *' may use any clientId, so only the leading false can refuse it
+        var connector = addNetworkConnector(null, "app", "app");
+        brokerA.start();
+        brokerA.waitUntilStarted();
+
+        // give the connector several reconnect attempts; none may be admitted
+        assertFalse("a bridge formed for a user whose rule starts with false",
+                Wait.waitFor(() -> admittedNetworkConnectionsOn(brokerB) > 0, 3000, 10));
+        assertTrue("connector should still be trying", connector.activeBridges().size() >= 0);
+    }
+
+    @Test(timeout = 60000)
+    public void testUnflaggedUserCannotPromoteConnectionWithLateBrokerInfo() throws Exception {
+        var clientId = "app-late";
+        var transport = TransportFactory.connect(new URI(brokerBUri));
+        try {
+            transport.setTransportListener(new DefaultTransportListener());
+            transport.start();
+            var added = (Response) transport.request(connectionInfo("late-app", "app", "app", clientId));
+            assertFalse("application connection should authenticate: " + added, added.isException());
+
+            var rejected = requestBrokerInfo(transport);
+            assertTrue("late BrokerInfo from an unflagged user must be rejected", rejected);
+            assertFalse("connection must not have been promoted", isNetworkConnection(brokerB, clientId));
+        } finally {
+            transport.stop();
+        }
+    }
+
+    /**
+     * Every bridge side now sends BrokerInfo before it authenticates, so a BrokerInfo
+     * arriving afterwards is a client promoting itself. On a realm with clientId
+     * authorization that is refused even for a user whose rule allows network
+     * connections; the principal attributes are still checked here as this is the
+     * one case that authenticates a bridge user over tcp.
+     */
+    @Test(timeout = 60000)
+    public void testLateBrokerInfoRefusedEvenForFlaggedUser() throws Exception {
+        var clientId = "NC_brokerA_inbound_brokerB";
+        var transport = TransportFactory.connect(new URI(brokerBUri));
+        try {
+            transport.setTransportListener(new DefaultTransportListener());
+            transport.start();
+            var info = connectionInfo("late-bridge", "bridge", "bridge", clientId);
+            // a client can plant anything in clientIp; the principal must carry the transport's view instead
+            info.setClientIp("spoofed://10.0.0.1:1");
+            var added = (Response) transport.request(info);
+            assertFalse("bridge connection should authenticate: " + added, added.isException());
+
+            var principal = PRINCIPALS.get(clientId);
+            assertNotNull("JAAS should have attached a ConnectionPrincipal", principal);
+            assertEquals(clientId, principal.getClientId());
+            assertTrue("network connection permitted by the bridge rule", principal.isNetworkConnection());
+            assertEquals("connector name from the accepting connector",
+                    brokerB.getTransportConnectors().get(0).getName(), principal.getTransportConnectorName());
+            assertFalse("tcp connector is not ssl", principal.isSsl());
+            assertTrue("remote address must come from the transport, not clientIp: " + principal.getRemoteAddress(),
+                    principal.getRemoteAddress() != null && principal.getRemoteAddress().startsWith("tcp://"));
+            assertEquals("remote address matches the broker side connection", transportRemoteAddress(brokerB, clientId),
+                    principal.getRemoteAddress());
+
+            var rejected = requestBrokerInfo(transport);
+            assertTrue("late BrokerInfo must be refused once authentication has happened", rejected);
+            assertFalse("connection must not have been promoted", isNetworkConnection(brokerB, clientId));
+        } finally {
+            transport.stop();
+        }
+    }
+
+    /**
+     * A realm without the clientid file makes no network connection decision, so the
+     * broker must not apply the late BrokerInfo guard at all: an ordinary user may
+     * still promote a connection, exactly as before the feature existed.
+     */
+    @Test(timeout = 60000)
+    public void testLateBrokerInfoNotRestrictedWhenClientIdAuthorizationNotConfigured() throws Exception {
+        var brokerC = createBroker("brokerC", "activemq-domain");
+        brokerC.addConnector("tcp://localhost:0");
+        brokerC.start();
+        brokerC.waitUntilStarted();
+        var clientId = "plain-late";
+        var transport = TransportFactory.connect(new URI(brokerC.getTransportConnectors().get(0).getPublishableConnectString()));
+        try {
+            transport.setTransportListener(new DefaultTransportListener());
+            transport.start();
+            // 'system' / 'manager' from users.properties; the realm has no clientids file
+            var added = (Response) transport.request(connectionInfo("late-plain", "system", "manager", clientId));
+            assertFalse("connection should authenticate: " + added, added.isException());
+
+            var rejected = requestBrokerInfo(transport);
+            assertFalse("late BrokerInfo must be accepted when authorization is not configured", rejected);
+            assertTrue("connection should now be a network connection",
+                    Wait.waitFor(() -> isNetworkConnection(brokerC, clientId), 5000, 10));
+        } finally {
+            transport.stop();
+            brokerC.stop();
+            brokerC.waitUntilStopped();
+        }
+    }
+
+    /**
+     * With a null name the connector keeps the default, so the bridge clientIds take
+     * the NC_ prefix: NC_brokerA_outbound on B, NC_brokerB_inbound_brokerA on A. A
+     * given name replaces that prefix.
+     */
+    private NetworkConnector addNetworkConnector(String name, String user, String password) throws Exception {
+        var connector = brokerA.addNetworkConnector("static:(" + brokerBUri + ")");
+        if (name != null) {
+            connector.setName(name);
+        }
+        connector.setUserName(user);
+        connector.setPassword(password);
+        return connector;
+    }
+
+    private static ConnectionInfo connectionInfo(String connectionId, String user, String password, String clientId) {
+        var info = new ConnectionInfo(new ConnectionId(connectionId));
+        info.setClientId(clientId);
+        info.setUserName(user);
+        info.setPassword(password);
+        return info;
+    }
+
+    /** Sends a BrokerInfo on an already authenticated connection; true when the broker refused it. */
+    private static boolean requestBrokerInfo(org.apache.activemq.transport.Transport transport) throws Exception {
+        var brokerInfo = new BrokerInfo();
+        brokerInfo.setBrokerId(new BrokerId("rogue"));
+        brokerInfo.setBrokerName("rogue");
+        brokerInfo.setNetworkConnection(true);
+        try {
+            var response = (Response) transport.request(brokerInfo);
+            return response != null && response.isException();
+        } catch (IOException refusedAndClosed) {
+            return true;
+        }
+    }
+
+    /**
+     * Connections the broker actually admitted (authentication passed) that are
+     * flagged as network connections. The transport level flag alone is set on
+     * BrokerInfo before authentication, so it cannot distinguish a refused bridge.
+     */
+    private static long admittedNetworkConnectionsOn(BrokerService broker) {
+        try {
+            return Arrays.stream(broker.getBroker().getClients()).filter(Connection::isNetworkConnection).count();
+        } catch (Exception e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    private static String transportRemoteAddress(BrokerService broker, String clientId) {
+        for (TransportConnection connection : broker.getTransportConnectors().get(0).getConnections()) {
+            if (clientId.equals(connection.getConnectionId())) {
+                return connection.getRemoteAddress();
+            }
+        }
+        return null;
+    }
+
+    /** TransportConnection.getConnectionId() reports the clientId when the connection set one. */
+    private static boolean isNetworkConnection(BrokerService broker, String clientId) {
+        for (TransportConnection connection : broker.getTransportConnectors().get(0).getConnections()) {
+            if (clientId.equals(connection.getConnectionId())) {
+                return connection.isNetworkConnection();
+            }
+        }
+        return false;
+    }
+}
diff --git a/activemq-unit-tests/src/test/resources/login.config b/activemq-unit-tests/src/test/resources/login.config
index 1f5f77c805..f71cfe2a3e 100644
--- a/activemq-unit-tests/src/test/resources/login.config
+++ b/activemq-unit-tests/src/test/resources/login.config
@@ -84,4 +84,12 @@ LDAPLogin {
         roleSearchMatching="(uid={1})"
         roleSearchSubtree=true
         ;
-};
\ No newline at end of file
+};
+
+activemq-clientid-domain {
+    org.apache.activemq.jaas.PropertiesLoginModule required
+        debug=true
+        org.apache.activemq.jaas.properties.user="org/apache/activemq/security/clientid-users.properties"
+        org.apache.activemq.jaas.properties.group="org/apache/activemq/security/groups.properties"
+        org.apache.activemq.jaas.properties.clientid="org/apache/activemq/security/clientids.properties";
+};
diff --git a/activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientid-users.properties b/activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientid-users.properties
new file mode 100644
index 0000000000..2e26ab9a3e
--- /dev/null
+++ b/activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientid-users.properties
@@ -0,0 +1,19 @@
+## ---------------------------------------------------------------------------
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements.  See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License.  You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+## ---------------------------------------------------------------------------
+bridge=bridge
+app=app
+bridge-named=bridge-named
diff --git a/activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientids.properties b/activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientids.properties
new file mode 100644
index 0000000000..54f759f5df
--- /dev/null
+++ b/activemq-unit-tests/src/test/resources/org/apache/activemq/security/clientids.properties
@@ -0,0 +1,31 @@
+## ---------------------------------------------------------------------------
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements.  See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License.  You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+## ---------------------------------------------------------------------------
+##
+## Used by JaasNetworkConnectionAuthorizationTest.
+
+# Format: <userId> = <may register a network connection: true|false>, <clientId patterns...>
+
+# the bridge account may use the network connector clientId formats and is
+# explicitly allowed to register network connections
+bridge = true, NC_*
+
+# a bridge account for a networkConnector named 'toB': the connector name replaces
+# the NC prefix in the bridge clientIds, so the pattern has to follow it
+bridge-named = true, toB_*
+
+# an application account: any clientId, but no network connections
+app = false, *
diff --git a/assembly/src/release/conf/clientids.properties b/assembly/src/release/conf/clientids.properties
new file mode 100644
index 0000000000..85a6a8ed9b
--- /dev/null
+++ b/assembly/src/release/conf/clientids.properties
@@ -0,0 +1,64 @@
+## ---------------------------------------------------------------------------
+## Licensed to the Apache Software Foundation (ASF) under one or more
+## contributor license agreements.  See the NOTICE file distributed with
+## this work for additional information regarding copyright ownership.
+## The ASF licenses this file to You under the Apache License, Version 2.0
+## (the "License"); you may not use this file except in compliance with
+## the License.  You may obtain a copy of the License at
+##
+## http://www.apache.org/licenses/LICENSE-2.0
+##
+## Unless required by applicable law or agreed to in writing, software
+## distributed under the License is distributed on an "AS IS" BASIS,
+## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+## See the License for the specific language governing permissions and
+## limitations under the License.
+## ---------------------------------------------------------------------------
+##
+## Optional clientId authorization for the PropertiesLoginModule.
+## Enable by adding this option to the module in login.config:
+##     org.apache.activemq.jaas.properties.clientid="clientids.properties"
+##
+## When enabled, a connection's clientId is authenticated together with the user
+## credentials: the clientId must match one of the patterns allowed for the
+## authenticated user, otherwise login fails. A connection that sets no clientId
+## is allowed (it cannot own a durable subscription).
+##
+## Format:  <userId> = <true|false>, <comma-separated clientId patterns>
+##   the first value states whether the user may register a network connection
+##   and is required; an entry without it is ignored and denies the user everything
+##   '*'           is a multi-character wildcard
+##   ${userId}     in a pattern expands to the authenticated user name
+##   ${brokerName} in a pattern expands to the name of this broker
+##   an entry keyed ${userId} is the fallback applied to any user that has no
+##   explicit entry; with no matching entry the clientId is denied.
+##
+## Network connections are a separate permission from clientIds: a connection that
+## identifies itself as a network bridge is refused unless its user's rule starts
+## with true, no matter which clientIds the user may use. This applies both when the
+## bridge announces itself before authenticating and when an already authenticated
+## connection later declares itself a bridge, so an application account can never
+## register as a network connection.
+##
+## Network bridges use these clientIds (prefix is the networkConnector name,
+## default NC; token is the connector clientIdToken, default _):
+##   on the broker being connected to:    NC_<remoteBrokerName>_outbound
+##   on the broker running the connector: NC_<remoteBrokerName>_inbound_${brokerName}
+##   duplex, connector side:              NC_<remoteBrokerName>_inbound_duplex_${brokerName}
+## A bridge user therefore needs a true rule with matching patterns on both brokers.
+
+# admin may use any clientId, but is not a network bridge
+admin = false, *
+
+# examples of a per-user rule (explicit list or wildcard):
+# order = false, order-primary, order-batch
+# order = false, order-*
+
+# example bridge account: matches the network connector clientId formats and is
+# explicitly allowed to register network connections
+# bridge = true, NC_*_outbound, NC_*_inbound_${brokerName}, NC_*_inbound_duplex_${brokerName}
+
+# zero-provisioning default: each user may use clientIds prefixed with their own
+# id, e.g. user 'order' may use 'order-1' or 'order-<random>'. Pair this with
+# clientIDPrefix="${userId}-" on the client ConnectionFactory.
+${userId} = false, ${userId}-*
diff --git a/assembly/src/release/conf/login.config b/assembly/src/release/conf/login.config
index c22608290a..ba28562e62 100644
--- a/assembly/src/release/conf/login.config
+++ b/assembly/src/release/conf/login.config
@@ -17,5 +17,8 @@
 activemq {
     org.apache.activemq.jaas.PropertiesLoginModule required
         org.apache.activemq.jaas.properties.user="users.properties"
-        org.apache.activemq.jaas.properties.group="groups.properties";
+        org.apache.activemq.jaas.properties.group="groups.properties"
+        // Uncomment to also authenticate the connection clientId (see clientids.properties):
+        // org.apache.activemq.jaas.properties.clientid="clientids.properties"
+        ;
 };
\ No newline at end of file


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@activemq.apache.org
For additional commands, e-mail: commits-help@activemq.apache.org
For further information, visit: https://activemq.apache.org/contact