This is an automated email from the ASF dual-hosted git repository. jbertram pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/artemis.git
commit 16b28f6ca2323482efdb5c2aaeff6a976b7cf571 Author: Grzegorz Grzybek <[email protected]> AuthorDate: Fri May 15 11:54:58 2026 +0200 ARTEMIS-6063 Allow configuration of JWT role mapping --- .../spi/core/security/jaas/OIDCLoginModule.java | 16 +++++- .../spi/core/security/jaas/oidc/OIDCSupport.java | 33 ++++++++++++ .../core/security/jaas/OIDCLoginModuleTest.java | 62 ++++++++++++++++++++++ docs/user-manual/security.adoc | 7 +++ 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModule.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModule.java index 56ff61b0ed..9021c53951 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModule.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModule.java @@ -51,6 +51,7 @@ import java.text.ParseException; 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; @@ -148,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> @@ -243,6 +249,7 @@ public class OIDCLoginModule implements AuditLoginModule { // 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 @@ -373,7 +380,14 @@ public class OIDCLoginModule implements AuditLoginModule { 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)); diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java index 36f1f78a22..1fa44d73ea 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java @@ -25,6 +25,8 @@ import java.security.cert.CertificateEncodingException; 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; @@ -148,6 +150,31 @@ public class OIDCSupport { 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(); + } + /** * Initialize the {@link OIDCSupport}, so we can do more configuration after calling the constructor */ @@ -439,6 +466,12 @@ public class OIDCSupport { // 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 diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleTest.java index c97b4f6fca..8f3178bd1d 100644 --- a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleTest.java +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleTest.java @@ -1075,6 +1075,68 @@ public class OIDCLoginModuleTest { 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"); diff --git a/docs/user-manual/security.adoc b/docs/user-manual/security.adoc index 82c9e5cd07..9c0a158d03 100644 --- a/docs/user-manual/security.adoc +++ b/docs/user-manual/security.adoc @@ -1304,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 --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
