Skip to content

Commit f33ae03

Browse files
committed
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.
1 parent 1f2114e commit f33ae03

File tree

6 files changed

+1240
-0
lines changed

6 files changed

+1240
-0
lines changed

activemq-jaas/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@
106106
<artifactId>jasypt</artifactId>
107107
<optional>true</optional>
108108
</dependency>
109+
<dependency>
110+
<groupId>com.nimbusds</groupId>
111+
<artifactId>nimbus-jose-jwt</artifactId>
112+
<optional>true</optional>
113+
</dependency>
109114
</dependencies>
110115

111116
<profiles>
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.activemq.jaas;
18+
19+
import java.io.IOException;
20+
import java.net.MalformedURLException;
21+
import java.net.URL;
22+
import java.security.Principal;
23+
import java.text.ParseException;
24+
import java.util.HashSet;
25+
import java.util.List;
26+
import java.util.Map;
27+
import java.util.Set;
28+
29+
import javax.security.auth.Subject;
30+
import javax.security.auth.callback.Callback;
31+
import javax.security.auth.callback.CallbackHandler;
32+
import javax.security.auth.callback.PasswordCallback;
33+
import javax.security.auth.callback.UnsupportedCallbackException;
34+
import javax.security.auth.login.FailedLoginException;
35+
import javax.security.auth.login.LoginException;
36+
import javax.security.auth.spi.LoginModule;
37+
38+
import com.nimbusds.jose.JOSEException;
39+
import com.nimbusds.jose.JWSAlgorithm;
40+
import com.nimbusds.jose.jwk.source.JWKSource;
41+
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
42+
import com.nimbusds.jose.proc.BadJOSEException;
43+
import com.nimbusds.jose.proc.JWSKeySelector;
44+
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
45+
import com.nimbusds.jose.proc.SecurityContext;
46+
import com.nimbusds.jwt.JWTClaimsSet;
47+
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
48+
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
49+
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
50+
51+
import org.slf4j.Logger;
52+
import org.slf4j.LoggerFactory;
53+
54+
/**
55+
* A JAAS LoginModule that authenticates users via OAuth2 JWT access tokens.
56+
* <p>
57+
* The client passes the JWT access token as the password in the connection info.
58+
* The module validates the token signature using the JWKS endpoint and verifies
59+
* standard claims (issuer, audience, expiration).
60+
* <p>
61+
* Configuration options (in login.config):
62+
* <ul>
63+
* <li>{@code oauth2.jwksUrl} (required) - URL to the JWKS endpoint for signature verification</li>
64+
* <li>{@code oauth2.issuer} (required) - Expected token issuer (iss claim)</li>
65+
* <li>{@code oauth2.audience} (optional) - Expected token audience (aud claim)</li>
66+
* <li>{@code oauth2.usernameClaim} (optional, default: "sub") - JWT claim to use as username</li>
67+
* <li>{@code oauth2.groupsClaim} (optional, default: "groups") - JWT claim containing group memberships</li>
68+
* <li>{@code debug} (optional) - Enable debug logging</li>
69+
* </ul>
70+
* <p>
71+
* Example login.config:
72+
* <pre>
73+
* activemq-oauth2 {
74+
* org.apache.activemq.jaas.OAuth2LoginModule required
75+
* oauth2.jwksUrl="https://idp.example.com/.well-known/jwks.json"
76+
* oauth2.issuer="https://idp.example.com"
77+
* oauth2.audience="activemq"
78+
* oauth2.usernameClaim="preferred_username"
79+
* oauth2.groupsClaim="roles";
80+
* };
81+
* </pre>
82+
*/
83+
public class OAuth2LoginModule implements LoginModule {
84+
85+
private static final Logger LOG = LoggerFactory.getLogger(OAuth2LoginModule.class);
86+
87+
static final String JWKS_URL_OPTION = "oauth2.jwksUrl";
88+
static final String ISSUER_OPTION = "oauth2.issuer";
89+
static final String AUDIENCE_OPTION = "oauth2.audience";
90+
static final String USERNAME_CLAIM_OPTION = "oauth2.usernameClaim";
91+
static final String GROUPS_CLAIM_OPTION = "oauth2.groupsClaim";
92+
93+
private static final String DEFAULT_USERNAME_CLAIM = "sub";
94+
private static final String DEFAULT_GROUPS_CLAIM = "groups";
95+
96+
private Subject subject;
97+
private CallbackHandler callbackHandler;
98+
private boolean debug;
99+
100+
private String jwksUrl;
101+
private String issuer;
102+
private String audience;
103+
private String usernameClaim;
104+
private String groupsClaim;
105+
106+
private String user;
107+
private final Set<Principal> principals = new HashSet<>();
108+
private boolean succeeded;
109+
private boolean commitSucceeded;
110+
111+
private ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
112+
113+
@Override
114+
public void initialize(Subject subject, CallbackHandler callbackHandler, Map<String, ?> sharedState, Map<String, ?> options) {
115+
this.subject = subject;
116+
this.callbackHandler = callbackHandler;
117+
this.succeeded = false;
118+
this.debug = Boolean.parseBoolean((String) options.get("debug"));
119+
120+
this.jwksUrl = (String) options.get(JWKS_URL_OPTION);
121+
this.issuer = (String) options.get(ISSUER_OPTION);
122+
this.audience = (String) options.get(AUDIENCE_OPTION);
123+
124+
String userClaim = (String) options.get(USERNAME_CLAIM_OPTION);
125+
this.usernameClaim = (userClaim != null && !userClaim.isEmpty()) ? userClaim : DEFAULT_USERNAME_CLAIM;
126+
127+
String grpClaim = (String) options.get(GROUPS_CLAIM_OPTION);
128+
this.groupsClaim = (grpClaim != null && !grpClaim.isEmpty()) ? grpClaim : DEFAULT_GROUPS_CLAIM;
129+
130+
if (debug) {
131+
LOG.debug("OAuth2LoginModule initialized with jwksUrl={}, issuer={}, audience={}, usernameClaim={}, groupsClaim={}",
132+
jwksUrl, issuer, audience, usernameClaim, groupsClaim);
133+
}
134+
}
135+
136+
@Override
137+
public boolean login() throws LoginException {
138+
if (jwksUrl == null || jwksUrl.isEmpty()) {
139+
throw new LoginException("OAuth2 JWKS URL (oauth2.jwksUrl) is required");
140+
}
141+
if (issuer == null || issuer.isEmpty()) {
142+
throw new LoginException("OAuth2 issuer (oauth2.issuer) is required");
143+
}
144+
145+
String token = getToken();
146+
if (token == null || token.isEmpty()) {
147+
throw new FailedLoginException("No JWT token provided");
148+
}
149+
150+
try {
151+
JWTClaimsSet claims = validateToken(token);
152+
user = claims.getStringClaim(usernameClaim);
153+
if (user == null || user.isEmpty()) {
154+
throw new FailedLoginException("JWT token does not contain the username claim: " + usernameClaim);
155+
}
156+
157+
principals.add(new UserPrincipal(user));
158+
159+
List<String> groups = getGroupsFromClaims(claims);
160+
if (groups != null) {
161+
for (String group : groups) {
162+
principals.add(new GroupPrincipal(group));
163+
}
164+
}
165+
166+
succeeded = true;
167+
if (debug) {
168+
LOG.debug("OAuth2 login succeeded for user={} with groups={}", user, groups);
169+
}
170+
} catch (FailedLoginException e) {
171+
throw e;
172+
} catch (Exception e) {
173+
LoginException le = new FailedLoginException("JWT token validation failed: " + e.getMessage());
174+
le.initCause(e);
175+
throw le;
176+
}
177+
178+
return succeeded;
179+
}
180+
181+
@Override
182+
public boolean commit() throws LoginException {
183+
if (!succeeded) {
184+
clear();
185+
if (debug) {
186+
LOG.debug("commit, result: false");
187+
}
188+
return false;
189+
}
190+
191+
subject.getPrincipals().addAll(principals);
192+
commitSucceeded = true;
193+
194+
if (debug) {
195+
LOG.debug("commit, result: true");
196+
}
197+
return true;
198+
}
199+
200+
@Override
201+
public boolean abort() throws LoginException {
202+
if (debug) {
203+
LOG.debug("abort");
204+
}
205+
if (!succeeded) {
206+
return false;
207+
} else if (commitSucceeded) {
208+
logout();
209+
} else {
210+
clear();
211+
succeeded = false;
212+
}
213+
return true;
214+
}
215+
216+
@Override
217+
public boolean logout() throws LoginException {
218+
subject.getPrincipals().removeAll(principals);
219+
clear();
220+
if (debug) {
221+
LOG.debug("logout");
222+
}
223+
succeeded = false;
224+
commitSucceeded = false;
225+
return true;
226+
}
227+
228+
private String getToken() throws LoginException {
229+
// Try OAuth2TokenCallback first, then fall back to PasswordCallback
230+
try {
231+
OAuth2TokenCallback tokenCallback = new OAuth2TokenCallback();
232+
callbackHandler.handle(new Callback[]{tokenCallback});
233+
if (tokenCallback.getToken() != null) {
234+
return tokenCallback.getToken();
235+
}
236+
} catch (UnsupportedCallbackException e) {
237+
// OAuth2TokenCallback not supported, fall back to PasswordCallback
238+
if (debug) {
239+
LOG.debug("OAuth2TokenCallback not supported, falling back to PasswordCallback");
240+
}
241+
} catch (IOException e) {
242+
throw new LoginException("Error retrieving OAuth2 token: " + e.getMessage());
243+
}
244+
245+
// Fall back to PasswordCallback (token passed as password)
246+
try {
247+
PasswordCallback passwordCallback = new PasswordCallback("Token: ", false);
248+
callbackHandler.handle(new Callback[]{passwordCallback});
249+
char[] tokenChars = passwordCallback.getPassword();
250+
if (tokenChars != null) {
251+
return new String(tokenChars);
252+
}
253+
} catch (IOException | UnsupportedCallbackException e) {
254+
throw new LoginException("Error retrieving token from PasswordCallback: " + e.getMessage());
255+
}
256+
257+
return null;
258+
}
259+
260+
JWTClaimsSet validateToken(String token) throws LoginException {
261+
try {
262+
ConfigurableJWTProcessor<SecurityContext> processor = getJWTProcessor();
263+
return processor.process(token, null);
264+
} catch (ParseException e) {
265+
throw new FailedLoginException("Invalid JWT format: " + e.getMessage());
266+
} catch (BadJOSEException e) {
267+
throw new FailedLoginException("JWT validation failed: " + e.getMessage());
268+
} catch (JOSEException e) {
269+
throw new FailedLoginException("JWT processing error: " + e.getMessage());
270+
}
271+
}
272+
273+
private ConfigurableJWTProcessor<SecurityContext> getJWTProcessor() throws LoginException {
274+
if (jwtProcessor != null) {
275+
return jwtProcessor;
276+
}
277+
278+
try {
279+
URL jwksEndpoint = new URL(jwksUrl);
280+
JWKSource<SecurityContext> keySource = JWKSourceBuilder
281+
.create(jwksEndpoint)
282+
.retrying(true)
283+
.build();
284+
285+
JWSKeySelector<SecurityContext> keySelector = new JWSVerificationKeySelector<>(
286+
JWSAlgorithm.Family.RSA, keySource);
287+
288+
ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
289+
processor.setJWSKeySelector(keySelector);
290+
291+
// Build the claims verifier with issuer and optional audience
292+
JWTClaimsSet.Builder exactMatchBuilder = new JWTClaimsSet.Builder()
293+
.issuer(issuer);
294+
295+
Set<String> requiredClaims = new HashSet<>();
296+
requiredClaims.add("sub");
297+
requiredClaims.add("iss");
298+
requiredClaims.add("exp");
299+
300+
if (audience != null && !audience.isEmpty()) {
301+
exactMatchBuilder.audience(audience);
302+
requiredClaims.add("aud");
303+
}
304+
305+
processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
306+
exactMatchBuilder.build(),
307+
requiredClaims));
308+
309+
jwtProcessor = processor;
310+
return jwtProcessor;
311+
} catch (MalformedURLException e) {
312+
throw new LoginException("Invalid JWKS URL: " + jwksUrl);
313+
}
314+
}
315+
316+
@SuppressWarnings("unchecked")
317+
private List<String> getGroupsFromClaims(JWTClaimsSet claims) {
318+
try {
319+
Object groupsValue = claims.getClaim(groupsClaim);
320+
if (groupsValue instanceof List) {
321+
return (List<String>) groupsValue;
322+
} else if (groupsValue instanceof String) {
323+
return List.of(((String) groupsValue).split(","));
324+
}
325+
} catch (Exception e) {
326+
if (debug) {
327+
LOG.debug("Could not extract groups from claim '{}': {}", groupsClaim, e.getMessage());
328+
}
329+
}
330+
return null;
331+
}
332+
333+
private void clear() {
334+
user = null;
335+
principals.clear();
336+
}
337+
338+
// Visible for testing
339+
void setJwtProcessor(ConfigurableJWTProcessor<SecurityContext> jwtProcessor) {
340+
this.jwtProcessor = jwtProcessor;
341+
}
342+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.activemq.jaas;
18+
19+
import javax.security.auth.callback.Callback;
20+
21+
/**
22+
* A JAAS Callback for passing an OAuth2 JWT token.
23+
*/
24+
public class OAuth2TokenCallback implements Callback {
25+
26+
private String token;
27+
28+
public String getToken() {
29+
return token;
30+
}
31+
32+
public void setToken(String token) {
33+
this.token = token;
34+
}
35+
}

0 commit comments

Comments
 (0)