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 2df15aebb04..9021c539512 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.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; @@ -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 requiredClaims; + private Set requiredClaims; + private boolean requiredClaimsConfigured = false; /** * "JSON paths" to claims (possibly nested) which should point to JSON strings or JSON string arrays, which @@ -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 local role. + */ + private Map roleMapping = new HashMap<>(); + /** *

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.

@@ -194,6 +201,7 @@ public OIDCLoginModule() { */ OIDCLoginModule(Set requiredClaims) { this.requiredClaims = requiredClaims == null ? defaultRequiredClaims : requiredClaims; + this.requiredClaimsConfigured = true; } @Override @@ -223,8 +231,13 @@ public void initialize(Subject subject, CallbackHandler callbackHandler, Map 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 claimsVerifier = new DefaultJWTClaimsVerifier<>( - audience, exactMatchClaims, requiredClaims, prohibitedClaims + audience, exactMatchClaims, this.requiredClaims, prohibitedClaims ); claimsVerifier.setMaxClockSkew(maxClockSkew); @@ -236,6 +249,7 @@ public void initialize(Subject subject, CallbackHandler callbackHandler, Map 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 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 mappingOption(ConfigKey configKey, Map 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 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(); } /** @@ -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 @@ -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 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 6bc3aaa39a2..8f3178bd1d8 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 @@ -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); @@ -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 keys = new ArrayList<>(); + // directly from the public key + keys.add(new RSAKey.Builder((RSAPublicKey) pairRSA.getPublic()).keyID("k1").build()); + + Map 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 principals = subject.getPrincipals(); + assertEquals(4, principals.size()); + Set identities = new HashSet<>(Set.of(uuid)); + // one should be mapped, the other should be used as in the token + Set 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 f2cd498910f..9c0a158d036 100644 --- a/docs/user-manual/security.adoc +++ b/docs/user-manual/security.adoc @@ -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). + @@ -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