You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@activemq.apache.org by jb...@apache.org on 2026/10/20 16:31:38 UTC

(activemq) branch main updated: feat(#1737): Add OAuth2 JWT authentication support via JAAS LoginModule (#1738)

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

jbonofre 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 ec05f19e49 feat(#1737): Add OAuth2 JWT authentication support via JAAS LoginModule (#1738)
ec05f19e49 is described below

commit ec05f19e4912defd9cf17cbc9bf4a98f2636e0f5
Author: JB Onofré <jb...@apache.org>
AuthorDate: Tue Oct 20 17:31:32 2026 +0100

    feat(#1737): Add OAuth2 JWT authentication support via JAAS LoginModule (#1738)
    
    * feat(#1737): Add OAuth2 JWT authentication support via JAAS LoginModule
    
    Add OAuth2LoginModule to activemq-jaas that validates JWT access tokens
    using JWKS endpoint for signature verification. Clients pass the JWT as
    the password field, and claims are mapped to UserPrincipal/GroupPrincipal
    for seamless integration with the existing JaasAuthenticationPlugin.
    
    * feat(#1737): Add nimbus-jose-jwt dependency to assembly distribution
    
    * Move jwksUrl and issuer in the init section instead of login
    
    * Use LinkedHashSet for principals to ensure ordering
    
    * feat(#1737): Move OAuth2 LoginModule to dedicated activemq-oauth2 module
    
    Extract OAuth2 JWT authentication into its own activemq-oauth2 module
    (similar to activemq-shiro) with package org.apache.activemq.oauth2.
    Remove nimbus-jose-jwt dependency from activemq-jaas and assembly,
    add activemq-oauth2 to parent modules/dependencyManagement and assembly.
    Include commented-out OAuth2 configuration example in the distribution
    login.config.
    
    * feat(#1737): Add processor cache, trim groups whitespace, include in assembly lib/optional and NOTICE
    
    * feat(#1737): Harden OAuth2 login module (https-only JWKS, asymmetric algorithms, nested groups, safer errors)
    
    * feat(#1737): Rename module to activemq-jaas-oauth2, add network connection group check, update binary LICENSE
    
    * feat(#1737): Move OAuth2 sample JAAS config to examples/conf/login-oauth2.config
    
    * feat(#1737): Flag OAuth2 login module as experimental
---
 activemq-jaas-oauth2/pom.xml                       |  101 ++
 .../apache/activemq/oauth2/OAuth2LoginModule.java  |  475 +++++++++
 .../activemq/oauth2/OAuth2TokenCallback.java       |   40 +
 .../activemq/oauth2/OAuth2LoginModuleTest.java     | 1013 ++++++++++++++++++++
 assembly/pom.xml                                   |    8 +
 assembly/src/main/descriptors/common-bin.xml       |    4 +
 assembly/src/release/LICENSE                       |    7 +
 assembly/src/release/NOTICE                        |   10 +
 .../src/release/examples/conf/login-oauth2.config  |   53 +
 pom.xml                                            |   12 +
 10 files changed, 1723 insertions(+)

diff --git a/activemq-jaas-oauth2/pom.xml b/activemq-jaas-oauth2/pom.xml
new file mode 100644
index 0000000000..5eb20db949
--- /dev/null
+++ b/activemq-jaas-oauth2/pom.xml
@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.activemq</groupId>
+        <artifactId>activemq-parent</artifactId>
+        <version>6.4.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>activemq-jaas-oauth2</artifactId>
+    <packaging>bundle</packaging>
+    <name>ActiveMQ :: JAAS OAuth2</name>
+    <description>ActiveMQ OAuth2 JWT authentication via JAAS LoginModule</description>
+
+    <dependencies>
+
+        <!-- =============================== -->
+        <!-- Required Dependencies -->
+        <!-- =============================== -->
+        <dependency>
+            <groupId>${project.groupId}</groupId>
+            <artifactId>activemq-jaas</artifactId>
+        </dependency>
+
+        <!-- Only needed at compile time for the @Experimental annotation (CLASS retention) -->
+        <dependency>
+            <groupId>${project.groupId}</groupId>
+            <artifactId>activemq-client</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>com.nimbusds</groupId>
+            <artifactId>nimbus-jose-jwt</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+        </dependency>
+
+        <!-- =============================== -->
+        <!-- Testing Dependencies            -->
+        <!-- =============================== -->
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.logging.log4j</groupId>
+            <artifactId>log4j-slf4j2-impl</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.logging.log4j</groupId>
+            <artifactId>log4j-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.felix</groupId>
+                <artifactId>maven-bundle-plugin</artifactId>
+                <extensions>true</extensions>
+                <configuration>
+                    <instructions>
+                        <Bundle-SymbolicName>org.apache.activemq.oauth2</Bundle-SymbolicName>
+                        <Export-Package>org.apache.activemq.oauth2*;version=${project.version};-noimport:=true</Export-Package>
+                        <Import-Package>
+                            com.nimbusds*;version="[9,11)",
+                            *
+                        </Import-Package>
+                        <_noee>true</_noee>
+                    </instructions>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>
diff --git a/activemq-jaas-oauth2/src/main/java/org/apache/activemq/oauth2/OAuth2LoginModule.java b/activemq-jaas-oauth2/src/main/java/org/apache/activemq/oauth2/OAuth2LoginModule.java
new file mode 100644
index 0000000000..99e0202493
--- /dev/null
+++ b/activemq-jaas-oauth2/src/main/java/org/apache/activemq/oauth2/OAuth2LoginModule.java
@@ -0,0 +1,475 @@
+/**
+ * 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.oauth2;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URL;
+import java.security.Principal;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.security.auth.Subject;
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.CallbackHandler;
+import javax.security.auth.callback.PasswordCallback;
+import javax.security.auth.callback.UnsupportedCallbackException;
+import javax.security.auth.login.FailedLoginException;
+import javax.security.auth.login.LoginException;
+import javax.security.auth.spi.LoginModule;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
+import com.nimbusds.jose.proc.BadJOSEException;
+import com.nimbusds.jose.proc.JWSKeySelector;
+import com.nimbusds.jose.proc.JWSVerificationKeySelector;
+import com.nimbusds.jose.proc.SecurityContext;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
+import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
+import com.nimbusds.jwt.proc.DefaultJWTProcessor;
+
+import org.apache.activemq.annotation.Experimental;
+import org.apache.activemq.jaas.ConnectionCallback;
+import org.apache.activemq.jaas.ConnectionPrincipal;
+import org.apache.activemq.jaas.GroupPrincipal;
+import org.apache.activemq.jaas.UserPrincipal;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A JAAS LoginModule that authenticates users via OAuth2 JWT access tokens.
+ * <p>
+ * <b>Experimental:</b> this module is new and still needs field testing. Its
+ * configuration options and behavior may change or be removed between any release.
+ * <p>
+ * The client passes the JWT access token as the password in the connection info.
+ * The module validates the token signature using the JWKS endpoint and verifies
+ * standard claims (issuer, audience, expiration).
+ * <p>
+ * Configuration options (in login.config):
+ * <ul>
+ *   <li>{@code oauth2.jwksUrl} (required) - URL to the JWKS endpoint for signature verification</li>
+ *   <li>{@code oauth2.issuer} (required) - Expected token issuer (iss claim)</li>
+ *   <li>{@code oauth2.audience} (optional) - Expected token audience (aud claim)</li>
+ *   <li>{@code oauth2.usernameClaim} (optional, default: "sub") - JWT claim to use as username</li>
+ *   <li>{@code oauth2.groupsClaim} (optional, default: "groups") - JWT claim containing group memberships</li>
+ *   <li>{@code oauth2.networkConnectionGroups} (optional) - Comma-separated list of groups whose members
+ *       may register a network connection (broker bridge). When set, a connection that declares itself a
+ *       network connection is rejected unless the user belongs to one of these groups, and a
+ *       {@link ConnectionPrincipal} carrying that decision is added to the Subject so the broker can
+ *       enforce it if the connection declares itself a network connection after authenticating.
+ *       When not set, network connections are not restricted by this module.</li>
+ *   <li>{@code debug} (optional) - Enable debug logging</li>
+ * </ul>
+ * <p>
+ * Example login.config:
+ * <pre>
+ * activemq-jaas-oauth2 {
+ *     org.apache.activemq.oauth2.OAuth2LoginModule required
+ *         oauth2.jwksUrl="https://idp.example.com/.well-known/jwks.json"
+ *         oauth2.issuer="https://idp.example.com"
+ *         oauth2.audience="activemq"
+ *         oauth2.usernameClaim="preferred_username"
+ *         oauth2.groupsClaim="roles"
+ *         oauth2.networkConnectionGroups="amq-network";
+ * };
+ * </pre>
+ */
+@Experimental("Tech Preview for OAuth2 JWT authentication")
+public class OAuth2LoginModule implements LoginModule {
+
+    private static final Logger LOG = LoggerFactory.getLogger(OAuth2LoginModule.class);
+
+    static final String JWKS_URL_OPTION = "oauth2.jwksUrl";
+    static final String ISSUER_OPTION = "oauth2.issuer";
+    static final String AUDIENCE_OPTION = "oauth2.audience";
+    static final String USERNAME_CLAIM_OPTION = "oauth2.usernameClaim";
+    static final String GROUPS_CLAIM_OPTION = "oauth2.groupsClaim";
+    static final String NETWORK_CONNECTION_GROUPS_OPTION = "oauth2.networkConnectionGroups";
+
+    private static final String DEFAULT_USERNAME_CLAIM = "sub";
+    private static final String DEFAULT_GROUPS_CLAIM = "groups";
+
+    private static final int MAX_CACHED_PROCESSORS = 100;
+
+    private static final Map<String, ConfigurableJWTProcessor<SecurityContext>> PROCESSOR_CACHE = new ConcurrentHashMap<>();
+
+    private Subject subject;
+    private CallbackHandler callbackHandler;
+    private boolean debug;
+
+    private String jwksUrl;
+    private String issuer;
+    private String audience;
+    private String usernameClaim;
+    private String groupsClaim;
+    private Set<String> networkConnectionGroups;
+    private ConnectionCallback connection;
+    private boolean networkConnectionAllowed;
+
+    private String user;
+    private final LinkedHashSet<Principal> principals = new LinkedHashSet<>();
+    private boolean succeeded;
+    private boolean commitSucceeded;
+
+    private ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
+
+    @Override
+    public void initialize(Subject subject, CallbackHandler callbackHandler, Map<String, ?> sharedState, Map<String, ?> options) {
+        this.subject = subject;
+        this.callbackHandler = callbackHandler;
+        this.succeeded = false;
+        this.debug = Boolean.parseBoolean(String.valueOf(options.get("debug")));
+
+        this.jwksUrl = option(options, JWKS_URL_OPTION);
+        if (jwksUrl == null || jwksUrl.isEmpty()) {
+            throw new IllegalArgumentException("OAuth2 JWKS URL (" + JWKS_URL_OPTION + ") is required");
+        }
+
+        this.issuer = option(options, ISSUER_OPTION);
+        if (issuer == null || issuer.isEmpty()) {
+            throw new IllegalArgumentException("OAuth2 issuer (" + ISSUER_OPTION + ") is required");
+        }
+
+        this.audience = option(options, AUDIENCE_OPTION);
+
+        String userClaim = option(options, USERNAME_CLAIM_OPTION);
+        this.usernameClaim = (userClaim != null && !userClaim.isEmpty()) ? userClaim : DEFAULT_USERNAME_CLAIM;
+
+        String grpClaim = option(options, GROUPS_CLAIM_OPTION);
+        this.groupsClaim = (grpClaim != null && !grpClaim.isEmpty()) ? grpClaim : DEFAULT_GROUPS_CLAIM;
+
+        String networkGroups = option(options, NETWORK_CONNECTION_GROUPS_OPTION);
+        if (networkGroups != null && !networkGroups.trim().isEmpty()) {
+            this.networkConnectionGroups = new HashSet<>();
+            for (String group : networkGroups.split(",")) {
+                String trimmed = group.trim();
+                if (!trimmed.isEmpty()) {
+                    this.networkConnectionGroups.add(trimmed);
+                }
+            }
+        } else {
+            this.networkConnectionGroups = null;
+        }
+
+        if (debug) {
+            LOG.debug("OAuth2LoginModule initialized with jwksUrl={}, issuer={}, audience={}, usernameClaim={}, groupsClaim={}",
+                    jwksUrl, issuer, audience, usernameClaim, groupsClaim);
+        }
+    }
+
+    private static String option(Map<String, ?> options, String key) {
+        Object value = options.get(key);
+        return value == null ? null : value.toString();
+    }
+
+    @Override
+    public boolean login() throws LoginException {
+        clear();
+        succeeded = false;
+        String token = getToken();
+        if (token == null || token.isEmpty()) {
+            throw new FailedLoginException("No JWT token provided");
+        }
+
+        try {
+            JWTClaimsSet claims = validateToken(token);
+            user = claims.getStringClaim(usernameClaim);
+            if (user == null || user.isEmpty()) {
+                throw new FailedLoginException("JWT token does not contain the username claim: " + usernameClaim);
+            }
+
+            List<String> groups = getGroupsFromClaims(claims);
+
+            // When enabled, a network connection is denied unless the user belongs to one of the
+            // configured groups. Application connections are unaffected.
+            if (networkConnectionGroups != null) {
+                connection = getConnectionCallback();
+                networkConnectionAllowed = groups != null && !Collections.disjoint(groups, networkConnectionGroups);
+                if (connection.isNetworkConnection() && !networkConnectionAllowed) {
+                    throw new FailedLoginException("network connection is not allowed for user");
+                }
+            }
+
+            principals.add(new UserPrincipal(user));
+
+            if (connection != null) {
+                principals.add(new ConnectionPrincipal(connection.getConnectionId(), connection.getClientId(),
+                        networkConnectionAllowed, connection.isSsl(), connection.getRemoteAddress(),
+                        connection.getTransportConnectorName()));
+            }
+
+            if (groups != null) {
+                for (String group : groups) {
+                    principals.add(new GroupPrincipal(group));
+                }
+            }
+
+            succeeded = true;
+            if (debug) {
+                LOG.debug("OAuth2 login succeeded for user={}", user);
+            }
+        } catch (FailedLoginException e) {
+            throw e;
+        } catch (Exception e) {
+            LOG.warn("OAuth2 login failed: {}", e.getMessage());
+            clear();
+            LoginException le = new FailedLoginException("JWT token validation failed");
+            le.initCause(e);
+            throw le;
+        }
+
+        return succeeded;
+    }
+
+    @Override
+    public boolean commit() throws LoginException {
+        if (!succeeded) {
+            clear();
+            if (debug) {
+                LOG.debug("commit, result: false");
+            }
+            return false;
+        }
+
+        subject.getPrincipals().addAll(principals);
+        commitSucceeded = true;
+
+        if (debug) {
+            LOG.debug("commit, result: true");
+        }
+        return true;
+    }
+
+    @Override
+    public boolean abort() throws LoginException {
+        if (debug) {
+            LOG.debug("abort");
+        }
+        if (!succeeded) {
+            return false;
+        } else if (commitSucceeded) {
+            logout();
+        } else {
+            clear();
+            succeeded = false;
+        }
+        return true;
+    }
+
+    @Override
+    public boolean logout() throws LoginException {
+        subject.getPrincipals().removeAll(principals);
+        clear();
+        if (debug) {
+            LOG.debug("logout");
+        }
+        succeeded = false;
+        commitSucceeded = false;
+        return true;
+    }
+
+    private ConnectionCallback getConnectionCallback() throws LoginException {
+        ConnectionCallback connectionCallback = new ConnectionCallback();
+        try {
+            callbackHandler.handle(new Callback[]{connectionCallback});
+        } catch (IOException e) {
+            LoginException le = new LoginException("Error retrieving connection details: " + e.getMessage());
+            le.initCause(e);
+            throw le;
+        } catch (UnsupportedCallbackException e) {
+            // callback handler does not describe the connection; treat as an application connection
+        }
+        return connectionCallback;
+    }
+
+    private String getToken() throws LoginException {
+        // Try OAuth2TokenCallback first, then fall back to PasswordCallback
+        try {
+            OAuth2TokenCallback tokenCallback = new OAuth2TokenCallback();
+            callbackHandler.handle(new Callback[]{tokenCallback});
+            if (tokenCallback.getToken() != null) {
+                return tokenCallback.getToken();
+            }
+        } catch (UnsupportedCallbackException e) {
+            // OAuth2TokenCallback not supported, fall back to PasswordCallback
+            if (debug) {
+                LOG.debug("OAuth2TokenCallback not supported, falling back to PasswordCallback");
+            }
+        } catch (IOException e) {
+            LoginException le = new LoginException("Error retrieving OAuth2 token: " + e.getMessage());
+            le.initCause(e);
+            throw le;
+        }
+
+        // Fall back to PasswordCallback (token passed as password)
+        PasswordCallback passwordCallback = new PasswordCallback("Token: ", false);
+        try {
+            callbackHandler.handle(new Callback[]{passwordCallback});
+            char[] tokenChars = passwordCallback.getPassword();
+            if (tokenChars != null) {
+                return new String(tokenChars);
+            }
+        } catch (IOException | UnsupportedCallbackException e) {
+            LoginException le = new LoginException("Error retrieving token from PasswordCallback: " + e.getMessage());
+            le.initCause(e);
+            throw le;
+        } finally {
+            passwordCallback.clearPassword();
+        }
+
+        return null;
+    }
+
+    JWTClaimsSet validateToken(String token) throws LoginException {
+        try {
+            ConfigurableJWTProcessor<SecurityContext> processor = getJWTProcessor();
+            return processor.process(token, null);
+        } catch (ParseException e) {
+            LOG.warn("Invalid JWT format: {}", e.getMessage());
+            throw new FailedLoginException("Invalid JWT format");
+        } catch (BadJOSEException e) {
+            LOG.warn("JWT validation failed: {}", e.getMessage());
+            throw new FailedLoginException("JWT validation failed");
+        } catch (JOSEException e) {
+            LOG.warn("JWT processing error: {}", e.getMessage());
+            throw new FailedLoginException("JWT processing error");
+        }
+    }
+
+    private ConfigurableJWTProcessor<SecurityContext> getJWTProcessor() throws LoginException {
+        if (jwtProcessor != null) {
+            return jwtProcessor;
+        }
+
+        URL jwksEndpoint;
+        try {
+            jwksEndpoint = URI.create(jwksUrl).toURL();
+        } catch (IllegalArgumentException | java.net.MalformedURLException e) {
+            throw new LoginException("Invalid JWKS URL: " + jwksUrl);
+        }
+        if (!"https".equalsIgnoreCase(jwksEndpoint.getProtocol())) {
+            throw new LoginException("JWKS URL must use https: " + jwksUrl);
+        }
+
+        String cacheKey = jwksUrl + "|" + issuer + "|" + (audience != null ? audience : "") + "|" + usernameClaim;
+        if (PROCESSOR_CACHE.size() >= MAX_CACHED_PROCESSORS && !PROCESSOR_CACHE.containsKey(cacheKey)) {
+            PROCESSOR_CACHE.clear();
+        }
+        return PROCESSOR_CACHE.computeIfAbsent(cacheKey, k -> createProcessor(jwksEndpoint));
+    }
+
+    private ConfigurableJWTProcessor<SecurityContext> createProcessor(URL jwksEndpoint) {
+        JWKSource<SecurityContext> keySource = JWKSourceBuilder
+                .create(jwksEndpoint)
+                .retrying(true)
+                .build();
+
+        // Only asymmetric algorithms: never accept HMAC-signed tokens
+        Set<JWSAlgorithm> algorithms = new HashSet<>();
+        algorithms.addAll(JWSAlgorithm.Family.RSA);
+        algorithms.addAll(JWSAlgorithm.Family.EC);
+        algorithms.add(JWSAlgorithm.EdDSA);
+        JWSKeySelector<SecurityContext> keySelector = new JWSVerificationKeySelector<>(algorithms, keySource);
+
+        ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
+        processor.setJWSKeySelector(keySelector);
+
+        JWTClaimsSet.Builder exactMatchBuilder = new JWTClaimsSet.Builder().issuer(issuer);
+
+        Set<String> requiredClaims = new HashSet<>();
+        requiredClaims.add(usernameClaim);
+        requiredClaims.add("iss");
+        requiredClaims.add("exp");
+
+        if (audience != null && !audience.isEmpty()) {
+            exactMatchBuilder.audience(audience);
+            requiredClaims.add("aud");
+        }
+
+        processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(exactMatchBuilder.build(), requiredClaims));
+        return processor;
+    }
+
+    private List<String> getGroupsFromClaims(JWTClaimsSet claims) {
+        Object groupsValue = resolveClaim(claims, groupsClaim);
+        if (groupsValue instanceof Collection<?>) {
+            List<String> groups = new ArrayList<>();
+            for (Object item : (Collection<?>) groupsValue) {
+                if (item != null && !item.toString().trim().isEmpty()) {
+                    groups.add(item.toString().trim());
+                }
+            }
+            return groups;
+        } else if (groupsValue instanceof String) {
+            return Arrays.stream(((String) groupsValue).split(","))
+                    .map(String::trim)
+                    .filter(s -> !s.isEmpty())
+                    .toList();
+        } else if (groupsValue != null) {
+            LOG.warn("Ignoring groups claim '{}': unsupported type {}", groupsClaim, groupsValue.getClass().getName());
+        }
+        return Collections.emptyList();
+    }
+
+    /**
+     * Resolves a claim by exact name first, then as a dotted path into nested JSON objects
+     * (e.g. {@code realm_access.roles}).
+     */
+    private static Object resolveClaim(JWTClaimsSet claims, String name) {
+        Object value = claims.getClaim(name);
+        if (value != null || name.indexOf('.') < 0) {
+            return value;
+        }
+        String[] parts = name.split("\\.");
+        Object current = claims.getClaim(parts[0]);
+        for (int i = 1; i < parts.length && current != null; i++) {
+            current = current instanceof Map<?, ?> ? ((Map<?, ?>) current).get(parts[i]) : null;
+        }
+        return current;
+    }
+
+    private void clear() {
+        user = null;
+        connection = null;
+        networkConnectionAllowed = false;
+        principals.clear();
+    }
+
+    // Visible for testing
+    void setJwtProcessor(ConfigurableJWTProcessor<SecurityContext> jwtProcessor) {
+        this.jwtProcessor = jwtProcessor;
+    }
+
+    // Visible for testing
+    static void clearProcessorCache() {
+        PROCESSOR_CACHE.clear();
+    }
+}
diff --git a/activemq-jaas-oauth2/src/main/java/org/apache/activemq/oauth2/OAuth2TokenCallback.java b/activemq-jaas-oauth2/src/main/java/org/apache/activemq/oauth2/OAuth2TokenCallback.java
new file mode 100644
index 0000000000..d126d391de
--- /dev/null
+++ b/activemq-jaas-oauth2/src/main/java/org/apache/activemq/oauth2/OAuth2TokenCallback.java
@@ -0,0 +1,40 @@
+/**
+ * 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.oauth2;
+
+import javax.security.auth.callback.Callback;
+
+import org.apache.activemq.annotation.Experimental;
+
+/**
+ * A JAAS Callback for passing an OAuth2 JWT token.
+ * <p>
+ * <b>Experimental:</b> may change or be removed between any release.
+ */
+@Experimental("Tech Preview for OAuth2 JWT authentication")
+public class OAuth2TokenCallback implements Callback {
+
+    private String token;
+
+    public String getToken() {
+        return token;
+    }
+
+    public void setToken(String token) {
+        this.token = token;
+    }
+}
diff --git a/activemq-jaas-oauth2/src/test/java/org/apache/activemq/oauth2/OAuth2LoginModuleTest.java b/activemq-jaas-oauth2/src/test/java/org/apache/activemq/oauth2/OAuth2LoginModuleTest.java
new file mode 100644
index 0000000000..6b753fa989
--- /dev/null
+++ b/activemq-jaas-oauth2/src/test/java/org/apache/activemq/oauth2/OAuth2LoginModuleTest.java
@@ -0,0 +1,1013 @@
+/**
+ * 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.oauth2;
+
+import java.io.IOException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import javax.security.auth.Subject;
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.CallbackHandler;
+import javax.security.auth.callback.PasswordCallback;
+import javax.security.auth.callback.UnsupportedCallbackException;
+import javax.security.auth.login.FailedLoginException;
+import javax.security.auth.login.LoginException;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
+import com.nimbusds.jose.proc.JWSVerificationKeySelector;
+import com.nimbusds.jose.proc.SecurityContext;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
+import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
+import com.nimbusds.jwt.proc.DefaultJWTProcessor;
+
+import org.apache.activemq.jaas.ConnectionCallback;
+import org.apache.activemq.jaas.ConnectionPrincipal;
+import org.apache.activemq.jaas.GroupPrincipal;
+import org.apache.activemq.jaas.UserPrincipal;
+
+import junit.framework.TestCase;
+
+public class OAuth2LoginModuleTest extends TestCase {
+
+    private KeyPair keyPair;
+    private RSAKey rsaKey;
+    private JWSSigner signer;
+    private ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
+
+    private static final String ISSUER = "https://idp.example.com";
+    private static final String AUDIENCE = "activemq";
+    private static final String KEY_ID = "test-key-1";
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+        OAuth2LoginModule.clearProcessorCache();
+
+        KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
+        gen.initialize(2048);
+        keyPair = gen.generateKeyPair();
+
+        rsaKey = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
+                .keyID(KEY_ID)
+                .build();
+
+        signer = new RSASSASigner(keyPair.getPrivate());
+
+        // Build a JWT processor with an in-memory JWK source
+        JWKSet jwkSet = new JWKSet(rsaKey);
+        ImmutableJWKSet<SecurityContext> keySource = new ImmutableJWKSet<>(jwkSet);
+
+        jwtProcessor = new DefaultJWTProcessor<>();
+        jwtProcessor.setJWSKeySelector(
+                new JWSVerificationKeySelector<>(JWSAlgorithm.Family.RSA, keySource));
+
+        Set<String> requiredClaims = new HashSet<>();
+        requiredClaims.add("sub");
+        requiredClaims.add("iss");
+        requiredClaims.add("exp");
+        requiredClaims.add("aud");
+
+        jwtProcessor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
+                new JWTClaimsSet.Builder()
+                        .issuer(ISSUER)
+                        .audience(AUDIENCE)
+                        .build(),
+                requiredClaims));
+    }
+
+    @Override
+    protected void tearDown() throws Exception {
+        OAuth2LoginModule.clearProcessorCache();
+        super.tearDown();
+    }
+
+    public void testSuccessfulLogin() throws Exception {
+        String token = createToken("testuser", Arrays.asList("admin", "users"), new Date(System.currentTimeMillis() + 300000));
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        assertTrue("Login should succeed", module.login());
+        assertTrue("Commit should succeed", module.commit());
+
+        Set<UserPrincipal> userPrincipals = subject.getPrincipals(UserPrincipal.class);
+        Set<GroupPrincipal> groupPrincipals = subject.getPrincipals(GroupPrincipal.class);
+
+        assertEquals("Should have one user principal", 1, userPrincipals.size());
+        assertEquals("Username should be testuser", "testuser", userPrincipals.iterator().next().getName());
+        assertEquals("Should have two group principals", 2, groupPrincipals.size());
+
+        boolean hasAdmin = false, hasUsers = false;
+        for (GroupPrincipal gp : groupPrincipals) {
+            if ("admin".equals(gp.getName())) hasAdmin = true;
+            if ("users".equals(gp.getName())) hasUsers = true;
+        }
+        assertTrue("Should have admin group", hasAdmin);
+        assertTrue("Should have users group", hasUsers);
+    }
+
+    public void testSuccessfulLogout() throws Exception {
+        String token = createToken("testuser", Arrays.asList("admin"), new Date(System.currentTimeMillis() + 300000));
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        module.login();
+        module.commit();
+
+        assertFalse("Should have principals after login", subject.getPrincipals().isEmpty());
+
+        module.logout();
+        assertTrue("Should have no principals after logout", subject.getPrincipals().isEmpty());
+    }
+
+    public void testExpiredToken() throws Exception {
+        String token = createToken("testuser", Arrays.asList("admin"), new Date(System.currentTimeMillis() - 300000));
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        try {
+            module.login();
+            fail("Should have thrown FailedLoginException for expired token");
+        } catch (FailedLoginException e) {
+            assertTrue("Error should mention validation", e.getMessage().contains("JWT validation failed"));
+        }
+    }
+
+    public void testWrongIssuer() throws Exception {
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("testuser")
+                .issuer("https://wrong-issuer.com")
+                .audience(AUDIENCE)
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        try {
+            module.login();
+            fail("Should have thrown FailedLoginException for wrong issuer");
+        } catch (FailedLoginException e) {
+            // expected
+        }
+    }
+
+    public void testWrongAudience() throws Exception {
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("testuser")
+                .issuer(ISSUER)
+                .audience("wrong-audience")
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        try {
+            module.login();
+            fail("Should have thrown FailedLoginException for wrong audience");
+        } catch (FailedLoginException e) {
+            // expected
+        }
+    }
+
+    public void testNoToken() throws Exception {
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(null, subject);
+        try {
+            module.login();
+            fail("Should have thrown FailedLoginException for no token");
+        } catch (FailedLoginException e) {
+            assertTrue("Error should mention no token", e.getMessage().contains("No JWT token"));
+        }
+    }
+
+    public void testEmptyToken() throws Exception {
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule("", subject);
+        try {
+            module.login();
+            fail("Should have thrown FailedLoginException for empty token");
+        } catch (FailedLoginException e) {
+            assertTrue("Error should mention no token", e.getMessage().contains("No JWT token"));
+        }
+    }
+
+    public void testInvalidTokenFormat() throws Exception {
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule("not-a-jwt-token", subject);
+        try {
+            module.login();
+            fail("Should have thrown FailedLoginException for invalid token");
+        } catch (FailedLoginException e) {
+            // expected
+        }
+    }
+
+    public void testCustomUsernameClaim() throws Exception {
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("sub-id-123")
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .claim("preferred_username", "jdoe")
+                .claim("groups", Arrays.asList("users"))
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "https://idp.example.com/.well-known/jwks.json");
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+        options.put(OAuth2LoginModule.AUDIENCE_OPTION, AUDIENCE);
+        options.put(OAuth2LoginModule.USERNAME_CLAIM_OPTION, "preferred_username");
+        options.put("debug", "true");
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        module.initialize(subject, new TokenCallbackHandler(token), new HashMap<>(), options);
+        module.setJwtProcessor(jwtProcessor);
+
+        assertTrue("Login should succeed", module.login());
+        assertTrue("Commit should succeed", module.commit());
+
+        Set<UserPrincipal> userPrincipals = subject.getPrincipals(UserPrincipal.class);
+        assertEquals("Should have one user principal", 1, userPrincipals.size());
+        assertEquals("Username should be jdoe", "jdoe", userPrincipals.iterator().next().getName());
+    }
+
+    public void testTokenWithNoGroups() throws Exception {
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("testuser")
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        assertTrue("Login should succeed", module.login());
+        assertTrue("Commit should succeed", module.commit());
+
+        assertEquals("Should have one user principal", 1, subject.getPrincipals(UserPrincipal.class).size());
+        assertEquals("Should have no group principals", 0, subject.getPrincipals(GroupPrincipal.class).size());
+    }
+
+    public void testAbortBeforeLogin() throws Exception {
+        String token = createToken("testuser", Arrays.asList("admin"), new Date(System.currentTimeMillis() + 300000));
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        assertFalse("Abort should return false when login hasn't succeeded", module.abort());
+    }
+
+    public void testAbortAfterLogin() throws Exception {
+        String token = createToken("testuser", Arrays.asList("admin"), new Date(System.currentTimeMillis() + 300000));
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        module.login();
+        module.commit();
+        assertTrue("Abort should return true after successful login", module.abort());
+
+        assertTrue("Should have no principals after abort", subject.getPrincipals().isEmpty());
+    }
+
+    public void testMissingJwksUrl() throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        try {
+            module.initialize(subject, new TokenCallbackHandler("token"), new HashMap<>(), options);
+            fail("Should have thrown IllegalArgumentException for missing JWKS URL");
+        } catch (IllegalArgumentException e) {
+            assertTrue("Error should mention JWKS URL", e.getMessage().contains("JWKS URL"));
+        }
+    }
+
+    public void testMissingIssuer() throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "https://idp.example.com/.well-known/jwks.json");
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        try {
+            module.initialize(subject, new TokenCallbackHandler("token"), new HashMap<>(), options);
+            fail("Should have thrown IllegalArgumentException for missing issuer");
+        } catch (IllegalArgumentException e) {
+            assertTrue("Error should mention issuer", e.getMessage().contains("issuer"));
+        }
+    }
+
+    public void testPasswordCallbackFallback() throws Exception {
+        String token = createToken("testuser", Arrays.asList("admin"), new Date(System.currentTimeMillis() + 300000));
+
+        // Use a callback handler that only supports PasswordCallback (not OAuth2TokenCallback)
+        CallbackHandler handler = callbacks -> {
+            for (Callback callback : callbacks) {
+                if (callback instanceof PasswordCallback) {
+                    ((PasswordCallback) callback).setPassword(token.toCharArray());
+                } else {
+                    throw new UnsupportedCallbackException(callback);
+                }
+            }
+        };
+
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "https://idp.example.com/.well-known/jwks.json");
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+        options.put(OAuth2LoginModule.AUDIENCE_OPTION, AUDIENCE);
+        options.put("debug", "true");
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        module.initialize(subject, handler, new HashMap<>(), options);
+        module.setJwtProcessor(jwtProcessor);
+
+        assertTrue("Login should succeed via PasswordCallback fallback", module.login());
+        module.commit();
+
+        assertEquals("Should have one user principal", 1, subject.getPrincipals(UserPrincipal.class).size());
+        assertEquals("testuser", subject.getPrincipals(UserPrincipal.class).iterator().next().getName());
+    }
+
+    public void testTokenSignedWithWrongKey() throws Exception {
+        // Generate a different key pair
+        KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
+        gen.initialize(2048);
+        KeyPair wrongKeyPair = gen.generateKeyPair();
+        JWSSigner wrongSigner = new RSASSASigner(wrongKeyPair.getPrivate());
+
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("testuser")
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
+                .keyID(KEY_ID)
+                .build();
+        SignedJWT signedJWT = new SignedJWT(header, claims);
+        signedJWT.sign(wrongSigner);
+        String token = signedJWT.serialize();
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        try {
+            module.login();
+            fail("Should have thrown FailedLoginException for wrong signing key");
+        } catch (FailedLoginException e) {
+            // expected - signature verification should fail
+        }
+    }
+
+    public void testTamperedToken() throws Exception {
+        String token = createToken("testuser", Arrays.asList("admin"), new Date(System.currentTimeMillis() + 300000));
+
+        // Tamper with the payload by changing a character
+        String[] parts = token.split("\\.");
+        assertEquals("JWT should have 3 parts", 3, parts.length);
+        // Modify the payload part
+        char[] payloadChars = parts[1].toCharArray();
+        payloadChars[0] = payloadChars[0] == 'a' ? 'b' : 'a';
+        String tamperedToken = parts[0] + "." + new String(payloadChars) + "." + parts[2];
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(tamperedToken, subject);
+        try {
+            module.login();
+            fail("Should have thrown FailedLoginException for tampered token");
+        } catch (FailedLoginException e) {
+            // expected - signature won't match tampered payload
+        }
+    }
+
+    public void testGroupsAsCommaSeparatedString() throws Exception {
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("testuser")
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .claim("groups", "admin,users,operators")
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        assertTrue("Login should succeed", module.login());
+        assertTrue("Commit should succeed", module.commit());
+
+        Set<GroupPrincipal> groupPrincipals = subject.getPrincipals(GroupPrincipal.class);
+        assertEquals("Should have three group principals", 3, groupPrincipals.size());
+
+        Set<String> groupNames = new HashSet<>();
+        for (GroupPrincipal gp : groupPrincipals) {
+            groupNames.add(gp.getName());
+        }
+        assertTrue("Should have admin group", groupNames.contains("admin"));
+        assertTrue("Should have users group", groupNames.contains("users"));
+        assertTrue("Should have operators group", groupNames.contains("operators"));
+    }
+
+    public void testGroupsAsCommaSeparatedStringWithWhitespace() throws Exception {
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("testuser")
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .claim("groups", "  admin , users ,  operators  ")
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        assertTrue("Login should succeed", module.login());
+        assertTrue("Commit should succeed", module.commit());
+
+        Set<GroupPrincipal> groupPrincipals = subject.getPrincipals(GroupPrincipal.class);
+        assertEquals("Should have three group principals", 3, groupPrincipals.size());
+
+        Set<String> groupNames = new HashSet<>();
+        for (GroupPrincipal gp : groupPrincipals) {
+            groupNames.add(gp.getName());
+        }
+        assertTrue("Should have admin group", groupNames.contains("admin"));
+        assertTrue("Should have users group", groupNames.contains("users"));
+        assertTrue("Should have operators group", groupNames.contains("operators"));
+    }
+
+    public void testCustomGroupsClaim() throws Exception {
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("testuser")
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .claim("roles", Arrays.asList("developer", "tester"))
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "https://idp.example.com/.well-known/jwks.json");
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+        options.put(OAuth2LoginModule.AUDIENCE_OPTION, AUDIENCE);
+        options.put(OAuth2LoginModule.GROUPS_CLAIM_OPTION, "roles");
+        options.put("debug", "true");
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        module.initialize(subject, new TokenCallbackHandler(token), new HashMap<>(), options);
+        module.setJwtProcessor(jwtProcessor);
+
+        assertTrue("Login should succeed", module.login());
+        assertTrue("Commit should succeed", module.commit());
+
+        Set<GroupPrincipal> groupPrincipals = subject.getPrincipals(GroupPrincipal.class);
+        assertEquals("Should have two group principals", 2, groupPrincipals.size());
+
+        Set<String> groupNames = new HashSet<>();
+        for (GroupPrincipal gp : groupPrincipals) {
+            groupNames.add(gp.getName());
+        }
+        assertTrue("Should have developer group", groupNames.contains("developer"));
+        assertTrue("Should have tester group", groupNames.contains("tester"));
+    }
+
+    public void testMissingUsernameClaim() throws Exception {
+        // Token with no "sub" claim and no custom username claim set
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .claim("sub", "")
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        try {
+            module.login();
+            fail("Should have thrown FailedLoginException for empty username claim");
+        } catch (FailedLoginException e) {
+            assertTrue("Error should mention username claim", e.getMessage().contains("username claim"));
+        }
+    }
+
+    public void testCustomUsernameClaimMissing() throws Exception {
+        // Token has "sub" but not the custom claim "email"
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("testuser")
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "https://idp.example.com/.well-known/jwks.json");
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+        options.put(OAuth2LoginModule.AUDIENCE_OPTION, AUDIENCE);
+        options.put(OAuth2LoginModule.USERNAME_CLAIM_OPTION, "email");
+        options.put("debug", "true");
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        module.initialize(subject, new TokenCallbackHandler(token), new HashMap<>(), options);
+        module.setJwtProcessor(jwtProcessor);
+
+        try {
+            module.login();
+            fail("Should have thrown FailedLoginException for missing email claim");
+        } catch (FailedLoginException e) {
+            assertTrue("Error should mention username claim", e.getMessage().contains("username claim"));
+            assertTrue("Error should mention email", e.getMessage().contains("email"));
+        }
+    }
+
+    public void testCommitWithoutLogin() throws Exception {
+        String token = createToken("testuser", Arrays.asList("admin"), new Date(System.currentTimeMillis() + 300000));
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+
+        // Commit without calling login first
+        assertFalse("Commit should return false when login hasn't succeeded", module.commit());
+        assertTrue("Should have no principals when commit fails", subject.getPrincipals().isEmpty());
+    }
+
+    public void testAbortAfterLoginBeforeCommit() throws Exception {
+        String token = createToken("testuser", Arrays.asList("admin"), new Date(System.currentTimeMillis() + 300000));
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+
+        module.login();
+        // Abort after login but before commit
+        assertTrue("Abort should return true after successful login", module.abort());
+        assertTrue("Should have no principals after abort without commit", subject.getPrincipals().isEmpty());
+    }
+
+    public void testLoginLogoutRelogin() throws Exception {
+        String token = createToken("testuser", Arrays.asList("admin", "users"), new Date(System.currentTimeMillis() + 300000));
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+
+        // First login
+        module.login();
+        module.commit();
+        assertEquals("Should have 3 principals after first login", 3, subject.getPrincipals().size());
+
+        // Logout
+        module.logout();
+        assertTrue("Should have no principals after logout", subject.getPrincipals().isEmpty());
+
+        // Re-login with a new module (same subject)
+        String token2 = createToken("anotheruser", Arrays.asList("viewers"), new Date(System.currentTimeMillis() + 300000));
+        OAuth2LoginModule module2 = createModule(token2, subject);
+        module2.login();
+        module2.commit();
+
+        Set<UserPrincipal> userPrincipals = subject.getPrincipals(UserPrincipal.class);
+        assertEquals("Should have one user principal after re-login", 1, userPrincipals.size());
+        assertEquals("Username should be anotheruser", "anotheruser", userPrincipals.iterator().next().getName());
+
+        Set<GroupPrincipal> groupPrincipals = subject.getPrincipals(GroupPrincipal.class);
+        assertEquals("Should have one group principal after re-login", 1, groupPrincipals.size());
+        assertEquals("Group should be viewers", "viewers", groupPrincipals.iterator().next().getName());
+    }
+
+    public void testNoAudienceConfigured() throws Exception {
+        // Build a processor that doesn't require audience
+        ConfigurableJWTProcessor<SecurityContext> noAudProcessor = new DefaultJWTProcessor<>();
+        JWKSet jwkSet = new JWKSet(rsaKey);
+        ImmutableJWKSet<SecurityContext> keySource = new ImmutableJWKSet<>(jwkSet);
+        noAudProcessor.setJWSKeySelector(
+                new JWSVerificationKeySelector<>(JWSAlgorithm.Family.RSA, keySource));
+
+        Set<String> requiredClaims = new HashSet<>();
+        requiredClaims.add("sub");
+        requiredClaims.add("iss");
+        requiredClaims.add("exp");
+        noAudProcessor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
+                new JWTClaimsSet.Builder().issuer(ISSUER).build(),
+                requiredClaims));
+
+        // Token without audience
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("testuser")
+                .issuer(ISSUER)
+                .claim("groups", Arrays.asList("admin"))
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+
+        // Configure module without audience
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "https://idp.example.com/.well-known/jwks.json");
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+        options.put("debug", "true");
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        module.initialize(subject, new TokenCallbackHandler(token), new HashMap<>(), options);
+        module.setJwtProcessor(noAudProcessor);
+
+        assertTrue("Login should succeed without audience", module.login());
+        module.commit();
+
+        assertEquals("Should have one user principal", 1, subject.getPrincipals(UserPrincipal.class).size());
+        assertEquals("Should have one group principal", 1, subject.getPrincipals(GroupPrincipal.class).size());
+    }
+
+    public void testMultipleAudiencesInTokenRejected() throws Exception {
+        // DefaultJWTClaimsVerifier requires exact audience match, so a token with
+        // multiple audiences should be rejected when only a single audience is configured
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("testuser")
+                .issuer(ISSUER)
+                .audience(Arrays.asList("activemq", "other-service"))
+                .claim("groups", Arrays.asList("admin"))
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+
+        try {
+            module.login();
+            fail("Should have thrown FailedLoginException for multiple audiences");
+        } catch (FailedLoginException e) {
+            // expected - exact audience match required
+        }
+    }
+
+    public void testSingleGroupAsList() throws Exception {
+        String token = createToken("testuser", Collections.singletonList("admin"), new Date(System.currentTimeMillis() + 300000));
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        module.login();
+        module.commit();
+
+        Set<GroupPrincipal> groupPrincipals = subject.getPrincipals(GroupPrincipal.class);
+        assertEquals("Should have one group principal", 1, groupPrincipals.size());
+        assertEquals("Group should be admin", "admin", groupPrincipals.iterator().next().getName());
+    }
+
+    public void testEmptyGroupsList() throws Exception {
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("testuser")
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .claim("groups", Collections.emptyList())
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        module.login();
+        module.commit();
+
+        assertEquals("Should have one user principal", 1, subject.getPrincipals(UserPrincipal.class).size());
+        assertEquals("Should have no group principals for empty list", 0, subject.getPrincipals(GroupPrincipal.class).size());
+    }
+
+    public void testCallbackHandlerIOException() throws Exception {
+        CallbackHandler handler = callbacks -> {
+            throw new IOException("Connection refused");
+        };
+
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "https://idp.example.com/.well-known/jwks.json");
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+        options.put(OAuth2LoginModule.AUDIENCE_OPTION, AUDIENCE);
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        module.initialize(subject, handler, new HashMap<>(), options);
+        module.setJwtProcessor(jwtProcessor);
+
+        try {
+            module.login();
+            fail("Should have thrown LoginException for callback IOException");
+        } catch (LoginException e) {
+            assertTrue("Error should mention retrieving token", e.getMessage().contains("Error retrieving"));
+        }
+    }
+
+    public void testDefaultClaimValues() throws Exception {
+        // Initialize with no username or groups claim options (should use defaults)
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "https://idp.example.com/.well-known/jwks.json");
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+        options.put(OAuth2LoginModule.AUDIENCE_OPTION, AUDIENCE);
+
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("defaultuser")
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .claim("groups", Arrays.asList("default-group"))
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString())
+                .build();
+
+        String token = signToken(claims);
+        Subject subject = new Subject();
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        module.initialize(subject, new TokenCallbackHandler(token), new HashMap<>(), options);
+        module.setJwtProcessor(jwtProcessor);
+
+        module.login();
+        module.commit();
+
+        // Default usernameClaim is "sub", default groupsClaim is "groups"
+        assertEquals("defaultuser", subject.getPrincipals(UserPrincipal.class).iterator().next().getName());
+        assertEquals("default-group", subject.getPrincipals(GroupPrincipal.class).iterator().next().getName());
+    }
+
+    public void testNonHttpsJwksUrlRejected() throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "http://idp.example.com/.well-known/jwks.json");
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+
+        String token = createToken("testuser", Arrays.asList("admin"), new Date(System.currentTimeMillis() + 300000));
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        module.initialize(new Subject(), new TokenCallbackHandler(token), new HashMap<>(), options);
+        try {
+            module.login();
+            fail("http JWKS URL should be rejected");
+        } catch (LoginException e) {
+            assertTrue(e.getCause().getMessage().contains("https"));
+        }
+    }
+
+    public void testNestedGroupsClaim() throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "https://idp.example.com/.well-known/jwks.json");
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+        options.put(OAuth2LoginModule.AUDIENCE_OPTION, AUDIENCE);
+        options.put(OAuth2LoginModule.GROUPS_CLAIM_OPTION, "realm_access.roles");
+
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("nesteduser")
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .claim("realm_access", Collections.singletonMap("roles", Arrays.asList("r1", "r2")))
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .build();
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        module.initialize(subject, new TokenCallbackHandler(signToken(claims)), new HashMap<>(), options);
+        module.setJwtProcessor(jwtProcessor);
+        assertTrue(module.login());
+        module.commit();
+
+        assertEquals(2, subject.getPrincipals(GroupPrincipal.class).size());
+    }
+
+    public void testUnsupportedGroupsClaimTypeYieldsNoGroups() throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "https://idp.example.com/.well-known/jwks.json");
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+        options.put(OAuth2LoginModule.AUDIENCE_OPTION, AUDIENCE);
+
+        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+                .subject("user")
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .claim("groups", Collections.singletonMap("a", "b"))
+                .expirationTime(new Date(System.currentTimeMillis() + 300000))
+                .build();
+
+        Subject subject = new Subject();
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        module.initialize(subject, new TokenCallbackHandler(signToken(claims)), new HashMap<>(), options);
+        module.setJwtProcessor(jwtProcessor);
+        assertTrue(module.login());
+        module.commit();
+
+        assertEquals(1, subject.getPrincipals(UserPrincipal.class).size());
+        assertEquals(0, subject.getPrincipals(GroupPrincipal.class).size());
+    }
+
+    public void testTokenWithManyGroups() throws Exception {
+        List<String> manyGroups = Arrays.asList("group1", "group2", "group3", "group4", "group5",
+                "group6", "group7", "group8", "group9", "group10");
+
+        String token = createToken("testuser", manyGroups, new Date(System.currentTimeMillis() + 300000));
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(token, subject);
+        module.login();
+        module.commit();
+
+        assertEquals("Should have 10 group principals", 10, subject.getPrincipals(GroupPrincipal.class).size());
+        assertEquals("Should have 1 user principal", 1, subject.getPrincipals(UserPrincipal.class).size());
+        assertEquals("Should have 11 total principals", 11, subject.getPrincipals().size());
+    }
+
+    public void testOAuth2TokenCallback() {
+        OAuth2TokenCallback callback = new OAuth2TokenCallback();
+        assertNull("Token should be null initially", callback.getToken());
+
+        callback.setToken("test-token-value");
+        assertEquals("Token should be set", "test-token-value", callback.getToken());
+
+        callback.setToken(null);
+        assertNull("Token should be null after reset", callback.getToken());
+    }
+
+    public void testNetworkConnectionAllowedForConfiguredGroup() throws Exception {
+        Subject subject = new Subject();
+        OAuth2LoginModule module = networkModule(subject, true, "amq-network", Arrays.asList("amq-network", "other"));
+        assertTrue(module.login());
+        module.commit();
+
+        ConnectionPrincipal principal = subject.getPrincipals(ConnectionPrincipal.class).iterator().next();
+        assertTrue(principal.isNetworkConnection());
+        assertEquals("ID:conn-1", principal.getName());
+    }
+
+    public void testNetworkConnectionDeniedWithoutConfiguredGroup() throws Exception {
+        OAuth2LoginModule module = networkModule(new Subject(), true, "amq-network", Arrays.asList("users"));
+        try {
+            module.login();
+            fail("network connection should be denied");
+        } catch (FailedLoginException e) {
+            assertEquals("network connection is not allowed for user", e.getMessage());
+        }
+    }
+
+    public void testApplicationConnectionUnaffectedAndNotAllowedAsNetwork() throws Exception {
+        Subject subject = new Subject();
+        OAuth2LoginModule module = networkModule(subject, false, "amq-network", Arrays.asList("users"));
+        assertTrue(module.login());
+        module.commit();
+
+        assertFalse(subject.getPrincipals(ConnectionPrincipal.class).iterator().next().isNetworkConnection());
+    }
+
+    public void testNoConnectionPrincipalWhenNetworkGroupsNotConfigured() throws Exception {
+        Subject subject = new Subject();
+        OAuth2LoginModule module = createModule(
+                createToken("testuser", Arrays.asList("users"), new Date(System.currentTimeMillis() + 300000)), subject);
+        assertTrue(module.login());
+        module.commit();
+
+        assertTrue(subject.getPrincipals(ConnectionPrincipal.class).isEmpty());
+    }
+
+    // --- Helper methods ---
+
+    private OAuth2LoginModule networkModule(Subject subject, final boolean network, String networkGroups,
+            List<String> tokenGroups) throws JOSEException {
+        String token = createToken("testuser", tokenGroups, new Date(System.currentTimeMillis() + 300000));
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "https://idp.example.com/.well-known/jwks.json");
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+        options.put(OAuth2LoginModule.AUDIENCE_OPTION, AUDIENCE);
+        options.put(OAuth2LoginModule.NETWORK_CONNECTION_GROUPS_OPTION, " " + networkGroups + " ,");
+
+        CallbackHandler handler = callbacks -> {
+            for (Callback callback : callbacks) {
+                if (callback instanceof ConnectionCallback) {
+                    ConnectionCallback cc = (ConnectionCallback) callback;
+                    cc.setConnectionId("ID:conn-1");
+                    cc.setNetworkConnection(network);
+                } else if (callback instanceof OAuth2TokenCallback) {
+                    ((OAuth2TokenCallback) callback).setToken(token);
+                } else {
+                    throw new UnsupportedCallbackException(callback);
+                }
+            }
+        };
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        module.initialize(subject, handler, new HashMap<>(), options);
+        module.setJwtProcessor(jwtProcessor);
+        return module;
+    }
+
+    private String createToken(String subject, List<String> groups, Date expiration) throws JOSEException {
+        JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
+                .subject(subject)
+                .issuer(ISSUER)
+                .audience(AUDIENCE)
+                .expirationTime(expiration)
+                .issueTime(new Date())
+                .jwtID(UUID.randomUUID().toString());
+
+        if (groups != null) {
+            builder.claim("groups", groups);
+        }
+
+        return signToken(builder.build());
+    }
+
+    private String signToken(JWTClaimsSet claims) throws JOSEException {
+        JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
+                .keyID(KEY_ID)
+                .build();
+        SignedJWT signedJWT = new SignedJWT(header, claims);
+        signedJWT.sign(signer);
+        return signedJWT.serialize();
+    }
+
+    private OAuth2LoginModule createModule(String token, Subject subject) {
+        Map<String, String> options = new HashMap<>();
+        options.put(OAuth2LoginModule.JWKS_URL_OPTION, "https://idp.example.com/.well-known/jwks.json");
+        options.put(OAuth2LoginModule.ISSUER_OPTION, ISSUER);
+        options.put(OAuth2LoginModule.AUDIENCE_OPTION, AUDIENCE);
+        options.put("debug", "true");
+
+        OAuth2LoginModule module = new OAuth2LoginModule();
+        module.initialize(subject, new TokenCallbackHandler(token), new HashMap<>(), options);
+        module.setJwtProcessor(jwtProcessor);
+        return module;
+    }
+
+    private static class TokenCallbackHandler implements CallbackHandler {
+        private final String token;
+
+        TokenCallbackHandler(String token) {
+            this.token = token;
+        }
+
+        @Override
+        public void handle(Callback[] callbacks) throws UnsupportedCallbackException {
+            for (Callback callback : callbacks) {
+                if (callback instanceof OAuth2TokenCallback) {
+                    ((OAuth2TokenCallback) callback).setToken(token);
+                } else if (callback instanceof PasswordCallback) {
+                    if (token != null) {
+                        ((PasswordCallback) callback).setPassword(token.toCharArray());
+                    }
+                } else {
+                    throw new UnsupportedCallbackException(callback);
+                }
+            }
+        }
+    }
+}
diff --git a/assembly/pom.xml b/assembly/pom.xml
index 3769b54a98..492b3e6ba0 100644
--- a/assembly/pom.xml
+++ b/assembly/pom.xml
@@ -608,6 +608,14 @@ o  'org.eclipse.jetty:apache-jsp'
       <groupId>com.fasterxml.jackson.core</groupId>
       <artifactId>jackson-databind</artifactId>
     </dependency>
+    <dependency>
+      <groupId>${project.groupId}</groupId>
+      <artifactId>activemq-jaas-oauth2</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>com.nimbusds</groupId>
+      <artifactId>nimbus-jose-jwt</artifactId>
+    </dependency>
 
     <!-- JAXB/Activation/iStack -->
     <dependency>
diff --git a/assembly/src/main/descriptors/common-bin.xml b/assembly/src/main/descriptors/common-bin.xml
index 5a1cbcf578..c22571fc5a 100644
--- a/assembly/src/main/descriptors/common-bin.xml
+++ b/assembly/src/main/descriptors/common-bin.xml
@@ -202,6 +202,7 @@
         <include>${pom.groupId}:activemq-jms-pool</include>
         <include>${pom.groupId}:activemq-pool</include>
         <include>${pom.groupId}:activemq-shiro</include>
+        <include>${pom.groupId}:activemq-jaas-oauth2</include>
         <include>commons-beanutils:commons-beanutils</include>
         <include>commons-collections:commons-collections</include>
         <include>org.apache.commons:commons-dbcp2</include>
@@ -235,6 +236,9 @@
         <!-- Apache Shiro dependencies: -->
         <include>org.apache.shiro:shiro-core</include>
         <include>org.apache.shiro:shiro-spring</include>
+
+        <!-- OAuth2 dependencies: -->
+        <include>com.nimbusds:nimbus-jose-jwt</include>
       </includes>
       <fileMode>0644</fileMode>
       <directoryMode>0755</directoryMode>
diff --git a/assembly/src/release/LICENSE b/assembly/src/release/LICENSE
index efee187bcd..7d1441e981 100755
--- a/assembly/src/release/LICENSE
+++ b/assembly/src/release/LICENSE
@@ -813,6 +813,13 @@ License: Eclipse Distribution License - v 1.0 (http://www.eclipse.org/org/docume
 
 -------------------------------------------------------------------------
 
+This binary distribution contains Nimbus JOSE+JWT.
+
+Project URL: https://connect2id.com/products/nimbus-jose-jwt
+License: The Apache Software License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0.txt)
+
+-------------------------------------------------------------------------
+
 This binary distribution contains SLF4J.
 
 Project URL: https://slf4j.org/
diff --git a/assembly/src/release/NOTICE b/assembly/src/release/NOTICE
index d0fb919805..83a98d9ec3 100644
--- a/assembly/src/release/NOTICE
+++ b/assembly/src/release/NOTICE
@@ -180,3 +180,13 @@ Jolokia NOTICE:
 | Guide, which are released under the APL license.
 
 -------------------------------------------------------------------------
+
+Nimbus JOSE+JWT NOTICE:
+| Nimbus JOSE+JWT
+| Copyright 2012-2026 Connect2id Ltd.
+|
+| This product includes software developed by
+| Connect2id Ltd. (https://connect2id.com/).
+| Licensed under the Apache License, Version 2.0.
+
+-------------------------------------------------------------------------
diff --git a/assembly/src/release/examples/conf/login-oauth2.config b/assembly/src/release/examples/conf/login-oauth2.config
new file mode 100644
index 0000000000..5349095117
--- /dev/null
+++ b/assembly/src/release/examples/conf/login-oauth2.config
@@ -0,0 +1,53 @@
+/**
+ * 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.
+ */
+
+/**
+ * OAuth2 JWT authentication example.
+ *
+ * EXPERIMENTAL: this login module is new and still needs field testing. Its
+ * options and behavior may change or be removed between any release.
+ *
+ * Authenticates clients using OAuth2/OIDC JWT access tokens. The token is
+ * passed as the password in the connection credentials.
+ *
+ * Required options:
+ *   oauth2.jwksUrl                 - URL to the JWKS endpoint for token signature verification
+ *   oauth2.issuer                  - Expected token issuer (iss claim)
+ *
+ * Optional options:
+ *   oauth2.audience                - Expected token audience (aud claim)
+ *   oauth2.usernameClaim           - JWT claim to extract the username from (default: "sub")
+ *   oauth2.groupsClaim             - JWT claim containing group memberships (default: "groups")
+ *   oauth2.networkConnectionGroups - Comma-separated groups allowed to register network connections
+ *
+ * To use this configuration:
+ *   1. Copy the OAuth2 jars (activemq-jaas-oauth2, nimbus-jose-jwt and dependencies)
+ *      from lib/optional to lib/ so they are on the broker classpath.
+ *   2. Copy the entry below into conf/login.config (or point the
+ *      java.security.auth.login.config system property to this file).
+ *   3. Configure the broker's JAAS authentication plugin to use this entry:
+ *        <jaasAuthenticationPlugin configuration="activemq-jaas-oauth2"/>
+ */
+activemq-jaas-oauth2 {
+    org.apache.activemq.oauth2.OAuth2LoginModule required
+        oauth2.jwksUrl="https://idp.example.com/.well-known/jwks.json"
+        oauth2.issuer="https://idp.example.com"
+        oauth2.audience="activemq"
+        oauth2.usernameClaim="preferred_username"
+        oauth2.groupsClaim="roles"
+        oauth2.networkConnectionGroups="amq-network";
+};
diff --git a/pom.xml b/pom.xml
index 5cc9e9a1fd..a16457dc0f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -70,6 +70,7 @@
     <httpcore-version>4.4.16</httpcore-version>
     <jackson-version>2.22.3</jackson-version>
     <jackson-annotations-version>2.22</jackson-annotations-version>
+    <nimbus-jose-jwt-version>10.3</nimbus-jose-jwt-version>
     <jakarta-jms-api-version>3.1.0</jakarta-jms-api-version>
     <jakarta-annotation-api-version>3.0.0</jakarta-annotation-api-version>
     <jakarta-servlet-api-version>6.1.0</jakarta-servlet-api-version>
@@ -219,6 +220,7 @@
     <module>activemq-run</module>
     <module>activemq-shiro</module>
     <module>activemq-opentelemetry</module>
+    <module>activemq-jaas-oauth2</module>
     <module>activemq-spring</module>
     <module>activemq-runtime-config</module>
     <module>activemq-tooling</module>
@@ -356,6 +358,11 @@
         <artifactId>activemq-opentelemetry</artifactId>
         <version>${project.version}</version>
       </dependency>
+      <dependency>
+        <groupId>org.apache.activemq</groupId>
+        <artifactId>activemq-jaas-oauth2</artifactId>
+        <version>${project.version}</version>
+      </dependency>
       <dependency>
         <groupId>org.apache.activemq</groupId>
         <artifactId>activemq-spring</artifactId>
@@ -613,6 +620,11 @@
         <artifactId>jackson-dataformat-xml</artifactId>
         <version>${jackson-version}</version>
       </dependency>
+      <dependency>
+        <groupId>com.nimbusds</groupId>
+        <artifactId>nimbus-jose-jwt</artifactId>
+        <version>${nimbus-jose-jwt-version}</version>
+      </dependency>
 
       <!-- Used to configure the activemq logs -->
       <dependency>


---------------------------------------------------------------------
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