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