Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
Expand Down Expand Up @@ -133,7 +134,8 @@ public class OIDCLoginModule implements AuditLoginModule {
* Set of required JWT claims that should be present (with any value - to be validated by different means)
* in each processed JWT token.
*/
private final Set<String> requiredClaims;
private Set<String> requiredClaims;
private boolean requiredClaimsConfigured = false;

/**
* "JSON paths" to claims (possibly nested) which should point to JSON strings or JSON string arrays, which
Expand All @@ -147,6 +149,11 @@ public class OIDCLoginModule implements AuditLoginModule {
*/
private String[] rolesPaths;

/**
* Mapping for roles, where a role taken from JWT token can be mapped (renamed) to <em>local</em> role.
*/
private Map<String, String> roleMapping = new HashMap<>();

/**
* <p>Flag which enforces OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
* (RFC 8705). If a token contains {@code cnf/x5t#256} claim, it is always verified and mTLS is required.</p>
Expand Down Expand Up @@ -194,6 +201,7 @@ public OIDCLoginModule() {
*/
OIDCLoginModule(Set<String> requiredClaims) {
this.requiredClaims = requiredClaims == null ? defaultRequiredClaims : requiredClaims;
this.requiredClaimsConfigured = true;
}

@Override
Expand Down Expand Up @@ -223,8 +231,13 @@ public void initialize(Subject subject, CallbackHandler callbackHandler, Map<Str
Set<String> audience = audiences == null ? null : new HashSet<>(Arrays.asList(audiences));
int maxClockSkew = OIDCSupport.intOption(ConfigKey.MAX_CLOCK_SKEW_SECONDS, options);

String[] requiredClaimsArray = OIDCSupport.stringArrayOption(ConfigKey.REQUIRED_CLAIMS, options);
if (!requiredClaimsConfigured && requiredClaimsArray != null) {
this.requiredClaims = new HashSet<>(Arrays.asList(requiredClaimsArray));
}

DefaultJWTClaimsVerifier<JWKSecurityContext> claimsVerifier = new DefaultJWTClaimsVerifier<>(
audience, exactMatchClaims, requiredClaims, prohibitedClaims
audience, exactMatchClaims, this.requiredClaims, prohibitedClaims
);
claimsVerifier.setMaxClockSkew(maxClockSkew);

Expand All @@ -236,6 +249,7 @@ public void initialize(Subject subject, CallbackHandler callbackHandler, Map<Str
// configuration for what to extract from the token
identityPaths = OIDCSupport.stringArrayOption(ConfigKey.IDENTITY_PATHS, options);
rolesPaths = OIDCSupport.stringArrayOption(ConfigKey.ROLES_PATHS, options);
roleMapping = OIDCSupport.mappingOption(ConfigKey.ROLE_MAPPING, options);
}

@Override
Expand Down Expand Up @@ -366,7 +380,14 @@ public boolean commit() throws LoginException {
if (!roles.valid()) {
throw new LoginException("Can't determine user role from JWT using \"" + rolePath + "\" path");
}
rolePrincipalNames.addAll(Arrays.asList(roles.value()));
String[] tab = roles.value();
for (String role : tab) {
String mapped = roleMapping.get(role);
if (mapped == null) {
mapped = role;
}
rolePrincipalNames.add(mapped);
}
}
if (debug) {
logger.debug("Found roles: {}", String.join(", ", rolePrincipalNames));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -135,7 +137,42 @@ public static String[] stringArrayOption(ConfigKey configKey, Map<String, ?> opt
if (v instanceof String s) {
vs = s;
}
return vs == null ? null : vs.split("\\s*,\\s*");
String[] values = vs == null ? null : vs.split("\\s*,\\s*");
if (values != null) {
List<String> result = new ArrayList<>(values.length);
for (String value : values) {
if (value != null && !value.trim().isEmpty()) {
result.add(value);
}
}
return result.toArray(new String[0]);
}
return null;
}

public static Map<String, String> mappingOption(ConfigKey configKey, Map<String, ?> options) {
Object v = options != null ? options.get(configKey.name) : null;

String vs = configKey.defaultValue;
if (v instanceof String s) {
vs = s;
}
String[] values = vs == null ? null : vs.split("\\s*,\\s*");
if (values != null) {
Map<String, String> result = new HashMap<>();
for (String value : values) {
if (value != null && !value.trim().isEmpty() && value.contains("=")) {
String[] kv = value.split("=", 2);
String jwtRole = kv[0].trim();
String localRole = kv[1].trim();
if (!jwtRole.isEmpty() && !localRole.isEmpty()) {
result.put(jwtRole, localRole);
}
}
}
return result;
}
return Collections.emptyMap();
}

/**
Expand Down Expand Up @@ -390,6 +427,9 @@ public enum ConfigKey {
// comma-separated required/expected audience ("aud" string/string[] claim)
AUDIENCE("audience", null),

// comma-separated required claims (must exist, but validation is performed with other options, like "audience")
REQUIRED_CLAIMS("requiredClaims", "aud, iss, sub, azp, exp"),

// comma-separated "json paths" to fields (could be nested using "." separator, but no complex array navigation.
// just field1.field2.xxx) with the identity of the caller. For Keycloak it could be:
// "preferred_username": from "profile" client scope -> "User Attribute" mapper, "username" field
Expand Down Expand Up @@ -426,6 +466,12 @@ public enum ConfigKey {
// Each value referred will be added as JAAS subject "role" principal
ROLES_PATHS("rolesPaths", null),

// comma-separated original-role=mapped-role list of role mapping.
// Without any mapping, roles found using `rolesPath` option are added as role principals to the JAAS subject
// However we can configure a literal mapping where JWT role names are mapped into other values.
// By default no mapping is performed
ROLE_MAPPING("roleMapping", null),

// Whether the token should contain cnf/x5t#256 claim according to https://datatracker.ietf.org/doc/html/rfc8705
// When enabled, the field contains a base64url(sha256(der(client certificate))) value which SHOULD
// match the certificate from actual mTLS (as handled by
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,83 @@ public void plainJWTWithAndWithoutDates() throws BadJOSEException, ParseExceptio
lm.validateToken(JWTParser.parse(token4));
}

@Test
public void plainJWTWithDefaultRequiredClaims() throws BadJOSEException, ParseException, JOSEException {
OIDCLoginModule lm = new OIDCLoginModule();
Subject subject = new Subject();
lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap(
OIDCSupport.ConfigKey.ALLOW_PLAIN_JWT.getName(), "true"
));

String jwt = new PlainJWT(new JWTClaimsSet.Builder()
.audience("anything")
.claim("azp", "authorized-party")
.claim("iss", "some-issuer")
.claim("sub", "some-subject")
.expirationTime(new Date(new Date().getTime() + 5000L))
.build()).serialize();
lm.validateToken(JWTParser.parse(jwt));
}

@Test
public void plainJWTWithCustomRequiredClaims() throws BadJOSEException, ParseException, JOSEException {
OIDCLoginModule lm = new OIDCLoginModule();
Subject subject = new Subject();
lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap(
OIDCSupport.ConfigKey.ALLOW_PLAIN_JWT.getName(), "true",
OIDCSupport.ConfigKey.REQUIRED_CLAIMS.getName(), "aud, sub"
));

String jwt1 = new PlainJWT(new JWTClaimsSet.Builder()
.audience("anything")
.claim("sub", "some-subject")
.build()).serialize();
lm.validateToken(JWTParser.parse(jwt1));

String jwt2 = new PlainJWT(new JWTClaimsSet.Builder()
.audience("anything")
.build()).serialize();
try {
lm.validateToken(JWTParser.parse(jwt2));
fail("Should fail with missing \"sub\" claim");
} catch (BadJWTException e) {
assertTrue(e.getMessage().contains("JWT missing required claims: [sub]"));
}
}

@Test
public void plainJWTWithNoRequiredClaims() throws BadJOSEException, ParseException, JOSEException {
OIDCLoginModule lm = new OIDCLoginModule();
Subject subject = new Subject();
lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap(
OIDCSupport.ConfigKey.ALLOW_PLAIN_JWT.getName(), "true",
OIDCSupport.ConfigKey.REQUIRED_CLAIMS.getName(), ""
));

String jwt1 = new PlainJWT(new JWTClaimsSet.Builder()
.build()).serialize();
lm.validateToken(JWTParser.parse(jwt1));
}

@Test
public void plainJWTWithoutCustomClaims() throws BadJOSEException, ParseException, JOSEException {
OIDCLoginModule lm = new OIDCLoginModule();
Subject subject = new Subject();
lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap(
OIDCSupport.ConfigKey.ALLOW_PLAIN_JWT.getName(), "true",
OIDCSupport.ConfigKey.REQUIRED_CLAIMS.getName(), null
));

String jwt1 = new PlainJWT(new JWTClaimsSet.Builder()
.build()).serialize();
try {
lm.validateToken(JWTParser.parse(jwt1));
fail("Should fail with missing default claim");
} catch (BadJWTException e) {
assertTrue(e.getMessage().contains("JWT missing required claims: [aud, azp, exp, iss, sub]"));
}
}

@Test
public void plainJWTWithIncorrectDates() throws BadJOSEException, JOSEException, ParseException {
OIDCLoginModule lm = new OIDCLoginModule(NO_CLAIMS);
Expand Down Expand Up @@ -998,6 +1075,68 @@ public JWKSecurityContext currentContext() {
assertTrue(roles.isEmpty());
}

@Test
public void tokenRolesWithMapping() throws NoSuchAlgorithmException, JOSEException, LoginException {
KeyPairGenerator kpgRSA = KeyPairGenerator.getInstance("RSA");
KeyPair pairRSA = kpgRSA.generateKeyPair();

List<JWK> keys = new ArrayList<>();
// directly from the public key
keys.add(new RSAKey.Builder((RSAPublicKey) pairRSA.getPublic()).keyID("k1").build());

Map<String, String> config = configMap(
OIDCSupport.ConfigKey.DEBUG.getName(), "true",
OIDCSupport.ConfigKey.ROLES_PATHS.getName(), "realm_access.roles",
OIDCSupport.ConfigKey.ROLE_MAPPING.getName(), "realm_admin=broker_admin, realm_viewer = broker=viewer"
);

OIDCLoginModule lm = new OIDCLoginModule();
lm.setOidcSupport(new OIDCSupport(config, true) {
@Override
public JWKSecurityContext currentContext() {
return new JWKSecurityContext(keys);
}
});

String uuid = UUID.randomUUID().toString();

JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer("http://localhost")
.subject("Alice")
.audience("me-the-broker")
.claim("sub", uuid)
.claim("azp", "artemis-oidc-client")
.claim("realm_access", Map.of("roles", List.of("realm_admin", "realm_manager", "realm_viewer")))
.expirationTime(new Date(new Date().getTime() + 3_600_000))
.build();

SignedJWT signedJWT = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("k1").build(), claims);
JWSSigner signer = new RSASSASigner(pairRSA.getPrivate());
signedJWT.sign(signer);
String token = signedJWT.serialize();

Subject subject = new Subject();
lm.initialize(subject, new JaasCallbackHandler(null, token, null), null, config);

assertTrue(lm.login());
assertTrue(lm.commit());

Set<Principal> principals = subject.getPrincipals();
assertEquals(4, principals.size());
Set<String> identities = new HashSet<>(Set.of(uuid));
// one should be mapped, the other should be used as in the token
Set<String> roles = new HashSet<>(Set.of("broker_admin", "realm_manager", "broker=viewer"));
principals.forEach(principal -> {
if (principal.getClass() == UserPrincipal.class) {
identities.remove(principal.getName());
} else if (principal.getClass() == RolePrincipal.class) {
roles.remove(principal.getName());
}
});
assertTrue(identities.isEmpty());
assertTrue(roles.isEmpty());
}

@Test
public void wrongPathsForToken() throws NoSuchAlgorithmException, JOSEException, LoginException {
KeyPairGenerator kpgRSA = KeyPairGenerator.getInstance("RSA");
Expand Down
12 changes: 12 additions & 0 deletions docs/user-manual/security.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,11 @@ audience::
Comma-separated list of values which must be present in the `aud` (_audience_) claim of the JWT token. +
There's no default value.

requiredClaims::
Comma-separated list of claim names that should be present (validation of the claim values is configured separately)
in the JWT token. +
Defaults to `aud, iss, sub, azp, exp` (audience, issuer, subject, authorize party, expiration date).

identityPaths::
Comma-separated _JSON paths_ that point to the fields (direct or nested) in the JWT token which contain values (strings, JSON string
arrays or whitespace-separated strings) used as _user identities_ (translated into `org.apache.activemq.artemis.spi.core.security.jaas.UserPrincipal` principals). +
Expand All @@ -1299,6 +1304,13 @@ Comma-separated _JSON paths_ that point to the fields (direct or nested) in the
arrays or whitespace-separated strings) used as _user roles_ (translated into `org.apache.activemq.artemis.spi.core.security.jaas.RolePrincipal` principals). +
There's no default value. For Keycloak OpenID Connect provider it could be for example `realm_access.roles`.

roleMapping::
Comma-separated list of `jwtRole=localRole` mappings. +
This option allows configuration of possible _role mapping_ if the roles present (as configured by `rolesPaths` option) in the JWT
token should be _translated_ into other role names. This behavior is usually specific to LDAP role mapping and is easy to avoid
with JWT, but it is still an option.
Defaults to no mapping and roles are taken directly from JWT token.

requireOAuth2MTLS::
This option adds extra layer of security and enforces https://datatracker.ietf.org/doc/html/rfc8705[OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens]. +
With this option enabled, JWT tokens must include `cnf/x5t#256` claim which contains
Expand Down