From febd07127e6d2f1c0e725ff42c175963624fe717 Mon Sep 17 00:00:00 2001 From: Grzegorz Grzybek Date: Thu, 12 Mar 2026 14:18:47 +0100 Subject: [PATCH 1/6] ARTEMIS-5200 Initial implementation of JAAS OIDC LoginModule * support for fetching OIDC metadata * caching and handling JWK keys * JAAS Login module that verifies claims and JWT signature * extensive test coverage * based on JDK HTTP Client * JAAS string-based configuration (etc/login.config) --- artemis-pom/pom.xml | 8 + artemis-server/pom.xml | 4 + .../security/jaas/JaasCallbackHandler.java | 3 + .../spi/core/security/jaas/JwtCallback.java | 37 + .../core/security/jaas/OIDCLoginModule.java | 281 +++++++ .../security/jaas/oidc/HttpClientAccess.java | 41 + .../core/security/jaas/oidc/OIDCMetadata.java | 181 +++++ .../jaas/oidc/OIDCMetadataAccess.java | 38 + .../core/security/jaas/oidc/OIDCSupport.java | 252 ++++++ .../jaas/oidc/SharedHttpClientAccess.java | 113 +++ .../jaas/oidc/SharedOIDCMetadataAccess.java | 218 +++++ .../security/jaas/OIDCLoginModuleTest.java | 508 ++++++++++++ .../jaas/oidc/HttpClientAccessTest.java | 103 +++ .../spi/core/security/jaas/oidc/JWTTest.java | 287 +++++++ .../jaas/oidc/OIDCMetadataAccessTest.java | 751 ++++++++++++++++++ .../security/jaas/oidc/OIDCMetadataTest.java | 59 ++ .../security/jaas/oidc/OIDCSupportTest.java | 148 ++++ pom.xml | 1 + 18 files changed, 3033 insertions(+) create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/JwtCallback.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModule.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccess.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadata.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadataAccess.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedHttpClientAccess.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedOIDCMetadataAccess.java create mode 100644 artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleTest.java create mode 100644 artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccessTest.java create mode 100644 artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/JWTTest.java create mode 100644 artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadataAccessTest.java create mode 100644 artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadataTest.java create mode 100644 artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java diff --git a/artemis-pom/pom.xml b/artemis-pom/pom.xml index 7f5f1f4cdb4..fb774f29c1b 100644 --- a/artemis-pom/pom.xml +++ b/artemis-pom/pom.xml @@ -943,6 +943,14 @@ pom import + + + com.nimbusds + nimbus-jose-jwt + ${nimbus.jwt.version} + + + diff --git a/artemis-server/pom.xml b/artemis-server/pom.xml index 9e6a7ef186d..789c4f28da9 100644 --- a/artemis-server/pom.xml +++ b/artemis-server/pom.xml @@ -160,6 +160,10 @@ io.micrometer micrometer-core + + com.nimbusds + nimbus-jose-jwt + org.apache.activemq activemq-artemis-native diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/JaasCallbackHandler.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/JaasCallbackHandler.java index 0110aa60a24..952dc1d40b1 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/JaasCallbackHandler.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/JaasCallbackHandler.java @@ -57,6 +57,9 @@ public void handle(Callback[] callbacks) throws IOException, UnsupportedCallback nameCallback.setName(username); } else if (callback instanceof CertificateCallback certificateCallback) { certificateCallback.setCertificates(getCertsFromConnection(remotingConnection)); + } else if (callback instanceof JwtCallback jwtCallback) { + // TODO: switch to obtaining the token from RemotingConnection and protocol-specific implementation (SASL frames) + jwtCallback.setJwtToken(password); } else if (callback instanceof PrincipalsCallback principalsCallback) { Subject peerSubject = remotingConnection.getSubject(); diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/JwtCallback.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/JwtCallback.java new file mode 100644 index 00000000000..c039a2d0e4d --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/JwtCallback.java @@ -0,0 +1,37 @@ +/* + * 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.artemis.spi.core.security.jaas; + +import javax.security.auth.callback.Callback; + +/** + * A {@link Callback} for passing JWT token to {@link javax.security.auth.spi.LoginModule login modules}. JWT + * tokens may come from {@code Bearer} HTTP header or SASL messages. + */ +public class JwtCallback implements Callback { + + private String jwtToken; + + public String getJwtToken() { + return jwtToken; + } + + public void setJwtToken(String jwtToken) { + this.jwtToken = jwtToken; + } + +} 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 new file mode 100644 index 00000000000..7374a086248 --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModule.java @@ -0,0 +1,281 @@ +/* + * 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.artemis.spi.core.security.jaas; + +import java.io.IOException; +import java.text.ParseException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.LoginException; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSecurityContextJWKSet; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.JWKSecurityContext; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimNames; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTParser; +import com.nimbusds.jwt.PlainJWT; +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import org.apache.activemq.artemis.spi.core.security.jaas.oidc.OIDCSupport; +import org.apache.activemq.artemis.spi.core.security.jaas.oidc.OIDCSupport.ConfigKey; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class OIDCLoginModule implements AuditLoginModule { + + public static final Logger logger = LoggerFactory.getLogger(OIDCLoginModule.class); + + // static configuration + + /** + * JWT claims (fields) that must be present in JWT token + */ + private static final Set defaultRequiredClaims = Set.of( + JWTClaimNames.AUDIENCE, + JWTClaimNames.ISSUER, + JWTClaimNames.SUBJECT, + "azp", + JWTClaimNames.EXPIRATION_TIME + ); + + /** + * JWT claims (fields) that must not be present in JWT token + */ + private static final Set prohibitedClaims = Collections.emptySet(); + + /** + * JWT claims with specific values that must be present in JWT token + */ + private static final JWTClaimsSet exactMatchClaims = new JWTClaimsSet.Builder().build(); + + /** + * Set of JWT signature algorithms we support + */ + private static final Set supportedJWSAlgorithms = new HashSet<>(); + + /** + * Key selector for JWT signature validation - crated once, because keys are fetched from the context + */ + private static final JWSKeySelector jwsKeySelector; + + // options from the configuration + + static { + // don't add JWSAlgorithm.Family.HMAC_SHA - these are for symmetric keys + supportedJWSAlgorithms.addAll(JWSAlgorithm.Family.RSA); + supportedJWSAlgorithms.addAll(JWSAlgorithm.Family.EC); + + jwsKeySelector = new JWSVerificationKeySelector<>(supportedJWSAlgorithms, new JWKSecurityContextJWKSet()); + } + + private final Set requiredClaims; + + // JAAS state from initialization + private boolean debug; + private OIDCSupport oidcSupport; + + // Nimbus JOSE + JWT state from initialization + private Subject subject; + private CallbackHandler handler; + + // state for the authentication (between login and commit) + private ConfigurableDefaultJWTProcessor processor = null; + private String token; + private JWT jwt; + + /** + * Public constructor to be used by {@link javax.security.auth.login.LoginContext} + */ + public OIDCLoginModule() { + this.requiredClaims = defaultRequiredClaims; + } + + /** + * Constructor for tests, where required claims may be configured + * + * @param requiredClaims tests may configure non-default required JWT claims here + */ + OIDCLoginModule(Set requiredClaims) { + this.requiredClaims = requiredClaims == null ? defaultRequiredClaims : requiredClaims; + } + + @Override + public void initialize(Subject subject, CallbackHandler callbackHandler, Map sharedState, Map options) { + this.subject = subject; + this.handler = callbackHandler; + + debug = OIDCSupport.booleanOption(ConfigKey.DEBUG, options); + if (debug) { + logger.debug("OIDCLoginModule initialized with debug information"); + } + + // non-static helper which uses state, can be mocked, but uses static caches underneath for + // keys and http client (see also https://issues.apache.org/jira/browse/ARTEMIS-5700) + if (oidcSupport == null) { + oidcSupport = new OIDCSupport(options, debug); + oidcSupport.initialize(); + } + + boolean allowPlainJWT = OIDCSupport.booleanOption(ConfigKey.ALLOW_PLAIN_JWT, options); + String[] audiences = OIDCSupport.stringArrayOption(ConfigKey.AUDIENCE, options); + Set audience = audiences == null ? null : new HashSet<>(Arrays.asList(audiences)); + int maxClockSkew = OIDCSupport.intOption(ConfigKey.MAX_CLOCK_SKEW_SECONDS, options); + + DefaultJWTClaimsVerifier claimsVerifier = new DefaultJWTClaimsVerifier<>( + audience, exactMatchClaims, requiredClaims, prohibitedClaims + ); + claimsVerifier.setMaxClockSkew(maxClockSkew); + + // processor is created for each login, because audience is configured from the options + processor = new ConfigurableDefaultJWTProcessor(allowPlainJWT); + processor.setJWSKeySelector(jwsKeySelector); + processor.setJWTClaimsSetVerifier(claimsVerifier); + } + + @Override + public boolean login() throws LoginException { + if (handler == null) { + throw new LoginException("No callback handler available to retrieve the JWT token"); + } + try { + JwtCallback jwtCallback = new JwtCallback(); + handler.handle(new Callback[]{jwtCallback}); + + String token = jwtCallback.getJwtToken(); + + if (token == null) { + // no token at all. returning false here will allow other modules to check other credentials + // which may have arrived + return false; + } + + this.jwt = parseAndValidateToken(token); + this.token = token; + return true; + } catch (IOException | UnsupportedCallbackException e) { + throw new LoginException("Can't obtain the JWT token"); + } catch (ParseException e) { + // invalid token - base64 error, JSON error or similar + logger.error("JWT parsing error", e); + throw new RuntimeException(e); + } catch (BadJOSEException | JOSEException e) { + // invalid token - for example decryption error or claim validation error + logger.error("JWT processing error: {}", e.getMessage()); + throw new RuntimeException(e); + } + } + + @Override + public boolean commit() throws LoginException { + if (this.jwt != null) { + this.subject.getPrivateCredentials().add(jwt); + this.subject.getPrivateCredentials().add(token); + return true; + } + return false; + } + + @Override + public boolean abort() throws LoginException { + return this.jwt != null; + } + + @Override + public boolean logout() throws LoginException { + return this.jwt != null; + } + + @NonNull + JWT parseAndValidateToken(String token) throws ParseException, BadJOSEException, JOSEException { + JWT jwt = JWTParser.parse(token); + + // See https://www.iana.org/assignments/jwt/jwt.xhtml#claims for known claims + // Which claims do we want? + // OAuth2 / JWT (RFC 7519): + // - nbf <= iat <= exp - timestamps ("not before" <= "issued at" <= "expiration") + // - Keycloak doesn't have a mapper for "nbf", doesn't set nbf in org.keycloak.protocol.oidc.TokenManager#initToken() + // - jti "JWT ID" - not needed, but one of the "Registered Claim Names" from RFC 6749 (OAuth2) + // - sub "Subject Identifier" - the principal that is the subject of the JWT + // - in client credentials grant type, this represents the client itself + // - in authorization code grant type, this represents the resource owner + // - iss "Issuer" - the principal that issued the JWT. like Keycloak realm URL + // OpenID Connect Core 1.0: + // - azp "Authorized party" - the OAuth 2.0 Client ID of the party to which the token was issued (client id in Keycloak) + // - aud "Audience(s)" - the _target_ of the token - should be the broker to which the messages are sent + // - in Keycloak, with default configuration the "aud" is set to "account" and the roles are available in resource_access.account.roles + // - if the user (on behalf of which the token is issued) has additional "roles", related clients are added to "aud" + // RFC 7800 (Proof-of-Possession Key Semantics for JWT): https://datatracker.ietf.org/doc/html/rfc7800#section-3.1 + // - cnf "Confirmation" + // - cnf/x5t#S256 - Certificate Thumbprint - https://datatracker.ietf.org/doc/html/rfc8705#section-3.1 + + processor.process(jwt, oidcSupport.currentContext()); + + return jwt; + } + + /** + * Set custom {@link OIDCSupport} to not use default version prepared in {@link #initialize} + * + * @param oidcSupport {@link OIDCSupport} class to configure this login module + */ + void setOidcSupport(OIDCSupport oidcSupport) { + this.oidcSupport = oidcSupport; + } + + /** + * Extension of the only implementation of {@link com.nimbusds.jwt.proc.JWTProcessor}, so we can configure + * it a bit. + */ + static class ConfigurableDefaultJWTProcessor extends DefaultJWTProcessor { + + private final boolean allowPlainJWT; + + ConfigurableDefaultJWTProcessor(boolean allowPlainJWT) { + this.allowPlainJWT = allowPlainJWT; + } + + @Override + public JWTClaimsSet process(PlainJWT plainJWT, JWKSecurityContext context) throws BadJOSEException, JOSEException { + if (!allowPlainJWT) { + return super.process(plainJWT, context); + } + + if (getJWSTypeVerifier() == null) { + throw new BadJOSEException("Plain JWT rejected: No JWS header typ (type) verifier is configured"); + } + getJWSTypeVerifier().verify(plainJWT.getHeader().getType(), context); + + JWTClaimsSet claimsSet = extractJWTClaimsSet(plainJWT); + return verifyJWTClaimsSet(claimsSet, context); + } + } + +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccess.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccess.java new file mode 100644 index 00000000000..61f039c6d18 --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccess.java @@ -0,0 +1,41 @@ +/* + * 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.artemis.spi.core.security.jaas.oidc; + +import javax.security.auth.login.LoginContext; +import java.net.URI; +import java.net.http.HttpClient; + +/** + * Accessor for {@link HttpClient} used by login modules. The default implementation should return + * cached instance, because {@link javax.security.auth.spi.LoginModule login modules} are instantiated on each + * {@link LoginContext#login()}, but for test purposes we could return mocked instance. + */ +public interface HttpClientAccess { + + /** + * Get an instance of {@link HttpClient JDK HTTP client} to be used for OIDC operations like getting keys or + * OIDC metadata. Could be used by {@link org.apache.activemq.artemis.spi.core.security.jaas.KubernetesLoginModule} + * too if needed. When {@code baseURI} is passed, we may get a cached/shared client instance configured for this + * specific URI. + * + * @param baseURI URI for caching purpose + * @return {@link HttpClient} + */ + HttpClient getClient(URI baseURI); + +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadata.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadata.java new file mode 100644 index 00000000000..01a14d853e4 --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadata.java @@ -0,0 +1,181 @@ +/* + * 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.artemis.spi.core.security.jaas.oidc; + +import java.net.URI; +import java.text.ParseException; +import java.util.Collections; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.proc.JWKSecurityContext; +import org.apache.activemq.artemis.json.JsonObject; +import org.apache.activemq.artemis.json.JsonString; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Everything we need from {@code /.well-known/openid-configuration} endpoint - including the keys from + * the {@code jwks_uri} endpoint. + */ +public class OIDCMetadata { + + public static final Logger LOG = LoggerFactory.getLogger(OIDCMetadata.class); + + private final long timestamp; + private boolean recoverable; + private boolean valid; + + private long lastKeysCheck = 0L; + + /** + * Issuer field from the metadata - should actual match the issuer URL we got the metadata from + */ + private String issuer = null; + + /** + * URI of the JSON Web Key set (RFC 7517) from + * OpenID Provider Metadata + * {@code jwks_uri} endpoint + */ + private URI jwksURI; + + /** + * Exception during metadata fetching + */ + private Exception exception; + + /** + * Error message from metadata processing - could be literal or from an exception + */ + private String errorMessage; + + /** + * {@link JWKSecurityContext} used for actual JWT validation/processing + */ + private JWKSecurityContext currentContext = new JWKSecurityContext(Collections.emptyList()); + + public OIDCMetadata(String expectedIssuer, JsonObject json) { + this(expectedIssuer, json, true); + } + + public OIDCMetadata(String expectedIssuer, JsonObject json, boolean recoverable) { + this.timestamp = System.currentTimeMillis(); + this.recoverable = recoverable; + valid = false; + if (json != null) { + // we have to get this + JsonString issuerV = json.getJsonString("issuer"); + issuer = issuerV == null ? null : issuerV.getString(); + // we should get this - otherwise no token signature validation will be possible + JsonString jwksUriV = json.getJsonString("jwks_uri"); + jwksURI = jwksUriV == null ? null : URI.create(jwksUriV.getString()); + + if (issuer == null) { + errorMessage = "OIDC Metadata issuer is missing"; + this.recoverable = false; + } else if (!issuer.equals(expectedIssuer)) { + errorMessage = "OIDC Metadata issuer mismatch"; + this.recoverable = false; + } else { + valid = true; + } + } + } + + /** + * Whether this metadata should be refreshed because of {@code metadataRetryTime} in seconds + * + * @param metadataRetryTime delay between this metadata should fetched again (in seconds) + * @return {@code true} if the metadata should be fetched again + */ + public boolean shouldFetchAgain(int metadataRetryTime) { + return !valid && recoverable && System.currentTimeMillis() - timestamp >= metadataRetryTime * 1000L; + } + + /** + * Valid OIDC metadata contains {@code jwks_uri} which points to the endpoint from which we can get + * a "JWK set" = an array of keys in the format specified in RFC 7517 (JSON Web Key (JWK)). While we don't expect + * metadata to change (as of 2026-03-11), we're ready to refresh the signature keys sing a configured + * cache time. + * + * @param cacheKeysTime delay between fetching the keys from {@code jwks_uri} endpoint + * @return {@code true} if the keys should be fetched again from the provider's {@code jwks_uri} endpoint + */ + public boolean shouldRefreshPublicKeys(int cacheKeysTime) { + return valid && System.currentTimeMillis() - lastKeysCheck > cacheKeysTime * 1000L; + } + + // ---- OIDC state access + + /** + * Retrieve current instance of {@link JWKSecurityContext} with a list of {@link com.nimbusds.jose.jwk.JWK keys} + * for JWT validation. + * + * @return {@link JWKSecurityContext} with currently known public keys + */ + public JWKSecurityContext currentSecurityContext() { + return currentContext; + } + + public String getIssuer() { + return issuer; + } + + public URI getJwksURI() { + return jwksURI; + } + + public void configureKeys(String json) { + lastKeysCheck = System.currentTimeMillis(); + try { + JWKSet set = JWKSet.parse(json); + currentContext = new JWKSecurityContext(set.getKeys()); + } catch (ParseException e) { + LOG.warn("Failed to parse JWK JSON structure. No keys will be available for JWT signature validation: {}", e.getMessage()); + currentContext = new JWKSecurityContext(Collections.emptyList()); + } + } + + // ---- Lower level state access + + public boolean isValid() { + return valid; + } + + public boolean isRecoverable() { + return recoverable; + } + + public OIDCMetadata withException(Exception issue) { + exception = issue; + errorMessage = issue.getMessage() == null ? issue.getClass().getName() : issue.getMessage(); + return this; + } + + public Exception getException() { + return exception; + } + + public OIDCMetadata withErrorMessage(String message) { + errorMessage = message; + return this; + } + + public String getErrorMessage() { + return errorMessage; + } +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadataAccess.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadataAccess.java new file mode 100644 index 00000000000..f972923cbf3 --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadataAccess.java @@ -0,0 +1,38 @@ +/* + * 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.artemis.spi.core.security.jaas.oidc; + +import java.net.URI; +import javax.security.auth.login.LoginContext; + +/** + * Accessor for {@link OIDCMetadata} used by login modules. Helps with performance, because each + * {@link LoginContext#login()} creates new instances of each involved + * {@link javax.security.auth.spi.LoginModule JAAS login modules}. + */ +public interface OIDCMetadataAccess { + + /** + * Get access to {@link OIDCMetadata} for a given OIDC provider base URI. The result may be retrieved + * from cache. + * + * @param providerBaseURI URI of the OpenID Connect provider + * @return {@link OIDCMetadata} for given OpenID Connect provider + */ + OIDCMetadata getMetadata(URI providerBaseURI); + +} 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 new file mode 100644 index 00000000000..d00d934661e --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java @@ -0,0 +1,252 @@ +/* + * 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.artemis.spi.core.security.jaas.oidc; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.util.Map; +import javax.security.auth.login.LoginContext; + +import com.nimbusds.jose.proc.JWKSecurityContext; +import org.apache.activemq.artemis.spi.core.security.jaas.OIDCLoginModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + *

Services and helpers used by {@link OIDCLoginModule}.

+ * + *

{@link javax.security.auth.spi.LoginModule login modules} are instantiated on each {@link LoginContext#login()}, + * so this class delegates to {@code XXXAccess} interfaces which may provide caching for better performance.

+ */ +public class OIDCSupport { + + public static final Logger LOG = LoggerFactory.getLogger(OIDCSupport.class); + + private final String providerURL; + private final boolean debug; + + private final Map options; + + private URI providerBaseURI; + + /** + * Delegated access to {@link HttpClient JDK HTTP Client} per provider URI. Optional + * if {@link #oidcMetadataAccess} is configured externally. + */ + private HttpClientAccess httpClientAccess = null; + + /** + * Delegated access to OIDC metadata per provider URI. Optional - default will be provided if missing + * during {@link #initialize initialization}. + */ + private OIDCMetadataAccess oidcMetadataAccess = null; + + private JWKSecurityContext jwkSecurityContext = null; + + /** + * Construct a helper object to support single {@link LoginContext#login()}. Scoped to the lifetime of + * a single JAAS login operation. + * + * @param options options passed to {@link javax.security.auth.spi.LoginModule#initialize} + * @param debug verbosity flag + */ + public OIDCSupport(Map options, boolean debug) { + String providerURL = stringOption(ConfigKey.PROVIDER_URL, options); + if (providerURL == null) { + throw new IllegalArgumentException("Missing OpenID Connect provider URL"); + } + while (providerURL.endsWith("/")) { + providerURL = providerURL.substring(providerURL.length() - 1); + } + + this.providerURL = providerURL; + this.debug = debug; + + this.options = options; + } + + public static String stringOption(ConfigKey configKey, Map options) { + Object v = options != null ? options.get(configKey.name) : null; + + String vs = configKey.defaultValue; + if (v instanceof String s) { + vs = s; + } + return vs; + } + + public static boolean booleanOption(ConfigKey configKey, Map options) { + Object v = options != null ? options.get(configKey.name) : null; + + if (v instanceof Boolean b) { + return b; + } + if (v instanceof String s) { + return Boolean.parseBoolean(s); + } + return Boolean.parseBoolean(configKey.defaultValue); + } + + public static int intOption(ConfigKey configKey, Map options) { + Object v = options != null ? options.get(configKey.name) : null; + + if (v instanceof Number n) { + return n.intValue(); + } + if (v instanceof String s) { + return Integer.parseInt(s); + } + return Integer.parseInt(configKey.defaultValue); + } + + public static String[] stringArrayOption(ConfigKey configKey, Map options) { + Object v = options != null ? options.get(configKey.name) : null; + + String vs = configKey.defaultValue; + if (v instanceof String s) { + vs = s; + } + return vs == null ? null : vs.split("\\s*,\\s*"); + } + + /** + * Initialize the {@link OIDCSupport}, so we can do more configuration after calling the constructor + */ + public void initialize() { + if (this.providerURL == null) { + throw new IllegalArgumentException("OpenID Connect provider URL cannot be null"); + } + + if (this.oidcMetadataAccess == null) { + if (this.httpClientAccess == null) { + this.httpClientAccess = new SharedHttpClientAccess(options, debug); + } + this.oidcMetadataAccess = new SharedOIDCMetadataAccess(this.httpClientAccess, options, debug); + } + + try { + providerBaseURI = new URI(providerURL); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("OpenID Connect provider URL is invalid: " + this.providerURL, e); + } + + // this may use cached version + initializeOIDCMetadata(); + } + + // ---- Utilities for option parsing + + private void initializeOIDCMetadata() { + OIDCMetadata metadata = oidcMetadataAccess.getMetadata(providerBaseURI); + + // metadata can never be null, but can be invalid (no keys, no data when /.well-known/openid-configuration + // returned 404, ...). Whatever we got, the metadata will have some context - even without any keys + jwkSecurityContext = metadata.currentSecurityContext(); + } + + public void setHttpClientAccess(HttpClientAccess httpClientAccess) { + this.httpClientAccess = httpClientAccess; + } + + public void setOidcMetadataAccess(OIDCMetadataAccess oidcMetadataAccess) { + this.oidcMetadataAccess = oidcMetadataAccess; + } + + /** + * Return a {@link com.nimbusds.jose.proc.SecurityContext} with keys recently synchronized with the provider. + * + * @return {@link JWKSecurityContext} with currently known public keys + */ + public JWKSecurityContext currentContext() { + return jwkSecurityContext; + } + + public enum ConfigKey { + + // ---- Login module configuration + + // debug level for the login module + DEBUG("debug", "false"), + + // ---- OIDC configuration (including HTTP Client config to access it) + + // the provider URL - something we can append /.well-known/openid-configuration to + PROVIDER_URL("provider", null), + // time in seconds for caching they keys from the keys endpoint of the provider + CACHE_KEYS_TIME_SECONDS("cacheKeysSeconds", "3600"), + // time in seconds to wait before fetching /.well-known/openid-configuration again in case of errors. + // When initial fetch was successful, there won't be any reattempted fetching (keys are still refetched + // periodically) + METADATA_RETRY_TIME_SECONDS("metadataRetrySeconds", "30"), + // TLS version to use with http client when using https protocol + TLS_VERSION("tlsVersion", "TLSv1.3"), + // CA certificate (PEM (single or multiple) or DER format, X.509) for building TLS context for HTTP Client + CA_CERTIFICATE("caCertificate", null), + // connection & read timeout for HTTP Client + HTTP_TIMEOUT_MILLISECONDS("httpTimeout", "5000"), + + // ---- JWT configuration (fields, claims, verification) + + // whether plain JWTs are allowed ({"alg":"none"}) + ALLOW_PLAIN_JWT("allowPlainJWT", "false"), + // time skew in seconds for nbf/exp validation + // see com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier.maxClockSkew + MAX_CLOCK_SKEW_SECONDS("maxClockSkew", "60"), + // comma-separated required audience ("aud" string/string[] claim) + AUDIENCE("audience", null), + // "json path" to a field (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 + // "sub": from "basic" client scope -> "Subject (sub)" mapper + // "client_id": from "service_account" scope -> "User Session Note" mapper, "client_id" User Session Note + // only for grant_type=client_credentials + // "azp": hardcoded in org.keycloak.protocol.oidc.TokenManager#initToken() + PATH_SUBJECT("pathSubject", "sub"), + // "roles" scope + // - "aud" - "Audience Resolve" mapper + // - "realm_access.roles" - "User Realm Role" mapper + // - "resource_access.${client_id}.roles" - "User Client Role" mapper + PATH_ROLES("pathRoles", null); + + private final String name; + private final String defaultValue; + + ConfigKey(String name, String defaultValue) { + this.name = name; + this.defaultValue = defaultValue; + } + + static ConfigKey from(String name) { + for (ConfigKey k : values()) { + if (k.name.equals(name)) { + return k; + } + } + return null; + } + + public String getName() { + return name; + } + + public String getDefaultValue() { + return defaultValue; + } + } + +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedHttpClientAccess.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedHttpClientAccess.java new file mode 100644 index 00000000000..ef7e30f6bba --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedHttpClientAccess.java @@ -0,0 +1,113 @@ +/* + * 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.artemis.spi.core.security.jaas.oidc; + +import java.io.File; +import java.net.URI; +import java.net.http.HttpClient; +import java.security.KeyStore; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +import org.apache.activemq.artemis.core.remoting.impl.ssl.SSLSupport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Access class for {@link HttpClient} instances which keeps already created clients in a static + * cache, but creates new clients using instance options passed from just-initialized + * {@link javax.security.auth.spi.LoginModule}. + */ +public class SharedHttpClientAccess implements HttpClientAccess { + + public static final Logger LOG = LoggerFactory.getLogger(SharedHttpClientAccess.class); + + private static final Map cache = new ConcurrentHashMap<>(); + + // ---- Config options from just-initialized JAAS LoginModule + + private final int httpTimeout; + private final String tlsVersion; + private final String caCertificate; + private final boolean debug; + + public SharedHttpClientAccess(Map options, boolean debug) { + this.httpTimeout = OIDCSupport.intOption(OIDCSupport.ConfigKey.HTTP_TIMEOUT_MILLISECONDS, options); + this.tlsVersion = OIDCSupport.stringOption(OIDCSupport.ConfigKey.TLS_VERSION, options); + this.caCertificate = OIDCSupport.stringOption(OIDCSupport.ConfigKey.CA_CERTIFICATE, options); + this.debug = debug; + } + + @Override + public HttpClient getClient(URI baseURI) { + HttpClient client = cache.get(baseURI); + if (client == null) { + synchronized (SharedHttpClientAccess.class) { + client = cache.get(baseURI); + if (client == null) { + client = createDefaultHttpClient(); + if (debug) { + LOG.debug("Created new HTTP Client for accessing OIDC provider at {}", baseURI); + } + cache.put(baseURI, client); + } + } + } + + return client; + } + + /** + * Create a slightly customized {@link HttpClient} + * + * @return newly created {@link HttpClient} + */ + public HttpClient createDefaultHttpClient() { + HttpClient.Builder builder = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(Duration.ofMillis(httpTimeout)); + + if (tlsVersion != null && caCertificate != null) { + try { + File caCertificateFile = new File(caCertificate); + if (!caCertificateFile.isFile()) { + LOG.warn("The certificate file {} does not exist", caCertificate); + } else { + SSLContext sslContext = SSLContext.getInstance(tlsVersion); + KeyStore trustStore = SSLSupport.loadKeystore(null, "PEMCA", caCertificate, null); + TrustManagerFactory tmFactory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmFactory.init(trustStore); + sslContext.init(null, tmFactory.getTrustManagers(), new SecureRandom()); + builder.sslContext(sslContext); + } + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } catch (Exception e) { + throw new RuntimeException("Can't configure SSL Context for HTTP Client", e); + } + } + + return builder.build(); + } + +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedOIDCMetadataAccess.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedOIDCMetadataAccess.java new file mode 100644 index 00000000000..065dbfd1375 --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedOIDCMetadataAccess.java @@ -0,0 +1,218 @@ +/* + * 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.artemis.spi.core.security.jaas.oidc; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.ConnectException; +import java.net.ProtocolException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.activemq.artemis.json.JsonObject; +import org.apache.activemq.artemis.utils.JsonLoader; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Access class for {@link OIDCMetadata} which uses a static cache - necessary because of how + * the lifecycle of {@link javax.security.auth.login.LoginContext} is designed. + * Additionally public keys for the metadata may be refreshed using configurable validity time. + */ +public class SharedOIDCMetadataAccess implements OIDCMetadataAccess { + + public static final Logger LOG = LoggerFactory.getLogger(SharedOIDCMetadataAccess.class); + + static final Map cache = new ConcurrentHashMap<>(); + + private final HttpClientAccess httpClientAccess; + + private final boolean debug; + private final int cacheKeysTime; + private final int metadataRetryTime; + private final int httpTimeout; + + public SharedOIDCMetadataAccess(HttpClientAccess httpClientAccess, Map options, boolean debug) { + this.httpClientAccess = httpClientAccess; + this.debug = debug; + + this.cacheKeysTime = OIDCSupport.intOption(OIDCSupport.ConfigKey.CACHE_KEYS_TIME_SECONDS, options); + this.metadataRetryTime = OIDCSupport.intOption(OIDCSupport.ConfigKey.METADATA_RETRY_TIME_SECONDS, options); + this.httpTimeout = OIDCSupport.intOption(OIDCSupport.ConfigKey.HTTP_TIMEOUT_MILLISECONDS, options); + } + + @Override + public OIDCMetadata getMetadata(URI baseURI) { + OIDCMetadata metadata = cache.get(baseURI); + if (metadata == null || metadata.shouldFetchAgain(metadataRetryTime)) { + synchronized (SharedOIDCMetadataAccess.class) { + metadata = fetchOIDCMetadata(baseURI); + cache.put(baseURI, metadata); + } + } + + if (metadata.shouldRefreshPublicKeys(cacheKeysTime)) { + // we don't want the metadata to deal with HTTP client + if (metadata.getJwksURI() != null) { + metadata.configureKeys(fetchJwkSet(baseURI, metadata.getJwksURI())); + } + } + + return metadata; + } + + /** + * Actually fetch JSON metadata from {@code /.well-known/openid-configuration} and build {@link OIDCMetadata} + * + * @return {@link OIDCMetadata} for given provider's base URI + */ + private OIDCMetadata fetchOIDCMetadata(URI baseURI) { + HttpClient client = httpClientAccess.getClient(baseURI); + + String expectedIssuer = baseURI.toString(); + OIDCMetadata result; + + try { + URI metadataURI = new URI(baseURI + "/.well-known/openid-configuration"); + + HttpRequest request = HttpRequest.newBuilder().GET() + .timeout(Duration.ofMillis(httpTimeout)) + .uri(metadataURI).build(); + if (debug) { + LOG.debug("Fetching OIDC Metadata from {}", metadataURI); + } + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + int statusCode = response.statusCode(); + if (statusCode == 200) { + // separate try..catch because we actually start JSON parsing + InputStream body = response.body(); + if (body != null) { + try (body) { + JsonObject json = JsonLoader.readObject(new InputStreamReader(body)); + result = new OIDCMetadata(expectedIssuer, json); + } catch (IllegalStateException e) { + // when the body is empty + result = new OIDCMetadata(expectedIssuer, null, false).withErrorMessage("No OIDC metadata available"); + } catch (ClassCastException e) { + // we need to catch it because there's no generic "read" method in org.apache.activemq.artemis.utils.JsonLoader + // and we don't have access to javax.json.spi.JsonProvider + result = new OIDCMetadata(expectedIssuer, null, false).withErrorMessage("OIDC metadata invalid - JSON error"); + } catch (RuntimeException e) { + // hmm, we explicitly do not have access to javax.json library (optional in artemis-commons) + // so we have to be clever here + if (e.getClass().getName().startsWith("javax.json")) { + // we can assume it's a parsing exception + result = new OIDCMetadata(expectedIssuer, null, false).withErrorMessage("OIDC metadata invalid - JSON error"); + } else { + // well - generic issue, can't do much + result = new OIDCMetadata(expectedIssuer, null, false).withException(e); + } + } + } else { + result = new OIDCMetadata(expectedIssuer, null, false).withErrorMessage("No OIDC metadata available"); + } + } else { + LOG.warn("OIDC Metadata cannot be retrieved: HTTP status code {}", statusCode); + boolean recoverable = statusCode < 400 || statusCode >= 500; + result = new OIDCMetadata(expectedIssuer, null, recoverable).withErrorMessage("HTTP " + statusCode + " error when fetching OIDC metadata"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + result = new OIDCMetadata(expectedIssuer, null).withException(e); + } catch (ConnectException | /*HttpConnectTimeoutException | */ HttpTimeoutException connectionException) { + // ConnectException: server not responding + // HttpConnectTimeoutException: connection timeout + // HttpTimeoutException: read timeout + result = new OIDCMetadata(expectedIssuer, null).withException(connectionException); + } catch (URISyntaxException | ProtocolException e) { + // URISyntaxException: can't actually happen, because it would happen earlier, not when using + // baseURI + /.well-known + // ProtocolException: non-HTTP server responding + result = new OIDCMetadata(expectedIssuer, null, false).withException(e); + } catch (IOException e) { + // IOException: any other I/O issue + result = new OIDCMetadata(expectedIssuer, null).withException(e); + } + + return result; + } + + /** + * Retrieve JSON definition of JWK Set (JSON structure defined in RFC 7517) to be parsed later by Nimbus library + * (that's why we don't return {@link JsonObject}. + * + * @param baseURI base URI for the provider + * @param jwksURI {@code jwks_uri} endpoint from OIDC metadata + * @return String representation of the JWK (RFC 7517) JSON to be parsed by Nimbus Jose JWT library + */ + private String fetchJwkSet(@NonNull URI baseURI, @NonNull URI jwksURI) { + HttpClient client = httpClientAccess.getClient(baseURI); + + boolean jsonError = false; + String errorMessage = null; + Exception otherException = null; + try { + HttpRequest request = HttpRequest.newBuilder().GET() + .timeout(Duration.ofMillis(httpTimeout)) + .uri(jwksURI).build(); + if (debug) { + LOG.debug("Fetching JWK set from {}", jwksURI); + } + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + int statusCode = response.statusCode(); + if (statusCode == 200) { + String body = response.body(); + if (body != null) { + // it'll be passed to + return body; + } else { + jsonError = true; + } + } else { + errorMessage = "HTTP " + statusCode + " error when fetching public keys from " + jwksURI; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + otherException = e; + } catch (Exception e) { + otherException = e; + } + + if (errorMessage == null) { + errorMessage = otherException != null ? otherException.getMessage() : null; + } + if (jsonError) { + LOG.warn("Error processing JSON definition of keys from {}: {}", jwksURI, errorMessage); + } else { + LOG.warn("Error retrieving keys from {}: {}", jwksURI, errorMessage); + } + + return "{}"; + } + +} 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 new file mode 100644 index 00000000000..75c8354e011 --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleTest.java @@ -0,0 +1,508 @@ +/* + * 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.artemis.spi.core.security.jaas; + +import java.lang.reflect.Field; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.security.auth.Subject; +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.ECDSASigner; +import com.nimbusds.jose.crypto.ECDSAVerifier; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.JWKSecurityContext; +import com.nimbusds.jose.util.Base64URL; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.PlainJWT; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.BadJWTException; +import org.apache.activemq.artemis.spi.core.security.jaas.oidc.OIDCSupport; +import org.apache.activemq.artemis.spi.core.security.jaas.oidc.SharedHttpClientAccess; +import org.apache.activemq.artemis.spi.core.security.jaas.oidc.SharedOIDCMetadataAccess; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +public class OIDCLoginModuleTest { + + public static final Set NO_CLAIMS = Collections.emptySet(); + + private static Map configMap(String... entries) { + if (entries.length % 2 != 0) { + throw new IllegalArgumentException("Should contain even number of entries"); + } + Map map = new HashMap<>(); + map.put(OIDCSupport.ConfigKey.PROVIDER_URL.getName(), "http://localhost"); + for (int i = 0; i < entries.length; i += 2) { + map.put(entries[i], entries[i + 1]); + } + return map; + } + + @BeforeEach + public void setUp() throws NoSuchFieldException, IllegalAccessException { + Field f1 = SharedHttpClientAccess.class.getDeclaredField("cache"); + f1.setAccessible(true); + ((Map) f1.get(null)).clear(); + Field f2 = SharedOIDCMetadataAccess.class.getDeclaredField("cache"); + f2.setAccessible(true); + ((Map) f2.get(null)).clear(); + } + + @Test + public void noCallbackHandler() { + OIDCLoginModule lm = new OIDCLoginModule(); + lm.initialize(new Subject(), null, null, configMap()); + try { + lm.login(); + fail(); + } catch (LoginException e) { + assertTrue(e.getMessage().contains("No callback handler")); + } + } + + @Test + public void noToken() throws LoginException { + OIDCLoginModule lm = new OIDCLoginModule(); + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap()); + assertFalse(lm.login()); + assertFalse(lm.commit()); + + assertEquals(0, subject.getPrincipals().size()); + assertEquals(0, subject.getPublicCredentials().size()); + assertEquals(0, subject.getPrivateCredentials().size()); + lm.logout(); + } + + @Test + public void emptyToken() throws BadJOSEException, JOSEException { + OIDCLoginModule lm = new OIDCLoginModule(); + Subject subject = new Subject(); + try { + lm.parseAndValidateToken(""); + } catch (ParseException e) { + assertTrue(e.getMessage().contains("Missing dot delimiter")); + } + } + + // ---- Plain tokens tests + + @Test + public void plainJWTWhenNotAllowed() throws ParseException, JOSEException { + OIDCLoginModule lm = new OIDCLoginModule(NO_CLAIMS); + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap()); + + // https://datatracker.ietf.org/doc/html/rfc7519#section-6 - {"alg":"none"} + String token = new PlainJWT(new JWTClaimsSet.Builder().build()).serialize(); + try { + lm.parseAndValidateToken(token); + fail(); + } catch (BadJOSEException e) { + assertTrue(e.getMessage().contains("Unsecured (plain) JWTs are rejected")); + } + } + + @Test + public void plainJWTWhenAllowedWithCorrectDates() throws BadJOSEException, ParseException, JOSEException { + OIDCLoginModule lm = new OIDCLoginModule(NO_CLAIMS); + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap( + OIDCSupport.ConfigKey.ALLOW_PLAIN_JWT.getName(), "true" + )); + + String token = new PlainJWT(new JWTClaimsSet.Builder() + .notBeforeTime(new Date(new Date().getTime() - 5000L)) + .expirationTime(new Date(new Date().getTime() + 5000L)) + .build()).serialize(); + lm.parseAndValidateToken(token); + } + + @Test + public void plainJWTWithAndWithoutDates() throws BadJOSEException, ParseException, JOSEException { + OIDCLoginModule lm = new OIDCLoginModule(NO_CLAIMS); + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap( + OIDCSupport.ConfigKey.ALLOW_PLAIN_JWT.getName(), "true" + )); + + String token1 = new PlainJWT(new JWTClaimsSet.Builder() + .build()).serialize(); + lm.parseAndValidateToken(token1); + + String token2 = new PlainJWT(new JWTClaimsSet.Builder() + .notBeforeTime(new Date(new Date().getTime() - 5000L)) + .build()).serialize(); + lm.parseAndValidateToken(token2); + + String token3 = new PlainJWT(new JWTClaimsSet.Builder() + .expirationTime(new Date(new Date().getTime() + 5000L)) + .build()).serialize(); + lm.parseAndValidateToken(token3); + + String token4 = new PlainJWT(new JWTClaimsSet.Builder() + .notBeforeTime(new Date(new Date().getTime() - 5000L)) + .expirationTime(new Date(new Date().getTime() + 5000L)) + .build()).serialize(); + lm.parseAndValidateToken(token4); + } + + @Test + public void plainJWTWithIncorrectDates() throws BadJOSEException, ParseException, JOSEException { + OIDCLoginModule lm = new OIDCLoginModule(NO_CLAIMS); + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap( + OIDCSupport.ConfigKey.ALLOW_PLAIN_JWT.getName(), "true", + OIDCSupport.ConfigKey.MAX_CLOCK_SKEW_SECONDS.getName(), "1" + )); + + String tokenForFuture = new PlainJWT(new JWTClaimsSet.Builder() + .notBeforeTime(new Date(new Date().getTime() + 3000L)) + .expirationTime(new Date(new Date().getTime() + 6000L)) + .build()).serialize(); + try { + lm.parseAndValidateToken(tokenForFuture); + } catch (BadJWTException e) { + assertTrue(e.getMessage().contains("JWT before use time")); + } + + String tokenForPast = new PlainJWT(new JWTClaimsSet.Builder() + .notBeforeTime(new Date(new Date().getTime() - 6000L)) + .expirationTime(new Date(new Date().getTime() - 3000L)) + .build()).serialize(); + try { + lm.parseAndValidateToken(tokenForPast); + } catch (BadJWTException e) { + assertTrue(e.getMessage().contains("Expired JWT")); + } + } + + @Test + public void plainJWTWithIncorrectDatesButTolerated() throws BadJOSEException, ParseException, JOSEException { + OIDCLoginModule lm = new OIDCLoginModule(NO_CLAIMS); + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap( + OIDCSupport.ConfigKey.ALLOW_PLAIN_JWT.getName(), "true", + OIDCSupport.ConfigKey.MAX_CLOCK_SKEW_SECONDS.getName(), "10" + )); + + String tokenForFuture = new PlainJWT(new JWTClaimsSet.Builder() + .notBeforeTime(new Date(new Date().getTime() + 3000L)) + .expirationTime(new Date(new Date().getTime() + 6000L)) + .build()).serialize(); + lm.parseAndValidateToken(tokenForFuture); + + String tokenForPast = new PlainJWT(new JWTClaimsSet.Builder() + .notBeforeTime(new Date(new Date().getTime() - 6000L)) + .expirationTime(new Date(new Date().getTime() - 3000L)) + .build()).serialize(); + lm.parseAndValidateToken(tokenForPast); + } + + // ---- Signed tokens test - https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 + // RSA, RSASSA-PSS Java algorithm ids - RS256, RS384, RS512, PS256, PS384, PS512 JWT signatures + // - https://datatracker.ietf.org/doc/html/rfc7518#section-3.3 + // - https://datatracker.ietf.org/doc/html/rfc7518#section-3.5 + // EC Java algorithm - ES256, ES256K, ES384, ES512 JWT signatures + // - https://datatracker.ietf.org/doc/html/rfc7518#section-3.4 + // Ed25519, Ed448 Java algorithms - EdDSA, Ed25519 JWT signatures + // - require com.google.crypto.tink:tink additional dependency + + @Test + public void rsaSignedTokens() throws JOSEException, NoSuchAlgorithmException, BadJOSEException, ParseException { + OIDCLoginModule lm = new OIDCLoginModule(NO_CLAIMS); + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap()); + + // nimbus-jose-jwt supports these RSA/RSA-PSS algorithms + // - RS256: RSASSA-PKCS-v1_5 using SHA-256 + // - RS384: RSASSA-PKCS-v1_5 using SHA-384 + // - RS512: RSASSA-PKCS-v1_5 using SHA-512 + // - PS256: RSASSA-PSS using SHA-256 + // - PS384: RSASSA-PSS using SHA-384 + // - PS512: RSASSA-PSS using SHA-512 + + KeyPairGenerator kpgRSA = KeyPairGenerator.getInstance("RSA"); + KeyPair pairRSA = kpgRSA.generateKeyPair(); + // "RSASSA-PSS" is a specific Signature algorithm, this literal exists for KeyPairGenerator + // for symmetry reasons - the actual key pair is the same (RSA) + KeyPairGenerator kpgRSA_PSS = KeyPairGenerator.getInstance("RSASSA-PSS"); + KeyPair pairRSA_PSS = kpgRSA_PSS.generateKeyPair(); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("https://artemis.apache.org") + .subject("Alice") + .expirationTime(new Date(new Date().getTime() + 3_600_000)) + .build(); + + JWSAlgorithm[] algorithms = new JWSAlgorithm[]{ + JWSAlgorithm.RS256, + JWSAlgorithm.RS384, + JWSAlgorithm.RS512, + JWSAlgorithm.PS256, + JWSAlgorithm.PS384, + JWSAlgorithm.PS512, + }; + + List keys = new ArrayList<>(); + // directly from the public key + keys.add(new RSAKey.Builder((RSAPublicKey) pairRSA.getPublic()).keyID("rs-key").build()); + // from JWK format + Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + keys.add(new RSAKey.Builder( + new Base64URL(encoder.encodeToString(((RSAPublicKey) pairRSA_PSS.getPublic()).getModulus().toByteArray())), + new Base64URL(encoder.encodeToString(((RSAPublicKey) pairRSA_PSS.getPublic()).getPublicExponent().toByteArray())) + ).keyID("ps-key").build()); + JWKSecurityContext context = new JWKSecurityContext(keys); + + for (JWSAlgorithm algorithm : algorithms) { + SignedJWT signedJWT; + + if (algorithm.getName().startsWith("RS")) { + signedJWT = new SignedJWT(new JWSHeader.Builder(algorithm).keyID("rs-key").build(), claims); + JWSSigner signer = new RSASSASigner(pairRSA.getPrivate()); + signedJWT.sign(signer); + String token = signedJWT.serialize(); + + assertFalse(token.endsWith("."), "Should include any signature"); + assertEquals(3, token.split("\\.").length, "Should contain header, payload and signature parts"); + assertTrue(signedJWT.verify(new RSASSAVerifier((RSAPublicKey) pairRSA.getPublic()))); + } else { + signedJWT = new SignedJWT(new JWSHeader.Builder(algorithm).keyID("ps-key").build(), claims); + JWSSigner signer = new RSASSASigner(pairRSA_PSS.getPrivate()); + signedJWT.sign(signer); + String token = signedJWT.serialize(); + + assertFalse(token.endsWith("."), "Should include any signature"); + assertEquals(3, token.split("\\.").length, "Should contain header, payload and signature parts"); + assertTrue(signedJWT.verify(new RSASSAVerifier((RSAPublicKey) pairRSA_PSS.getPublic()))); + } + + lm.setOidcSupport(new OIDCSupport(configMap(), false) { + @Override + public JWKSecurityContext currentContext() { + return context; + } + }); + JWT jwt = lm.parseAndValidateToken(signedJWT.serialize()); + assertInstanceOf(SignedJWT.class, jwt); + } + } + + @Test + public void ecSignedTokens() throws JOSEException, NoSuchAlgorithmException, BadJOSEException, ParseException, InvalidAlgorithmParameterException { + OIDCLoginModule lm = new OIDCLoginModule(NO_CLAIMS); + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap()); + + // nimbus-jose-jwt supports these EC algorithms: + // NIST curves were standardized for government/enterprise use + // - ES256 - secp256r1 - P-256 + SHA-256 - 1.2.840.10045.3.1.7 + // - ES384 - secp384r1 - P-384 + SHA-384 - 1.3.132.0.34 + // - ES512 - secp521r1 - P-521 + SHA-512 - 1.3.132.0.35 + // secp256k1 was primarily used in Bitcoin + // - ES256K - secp256k1 - P-256K + SHA-256 - 1.3.132.0.10 + + KeyPairGenerator kpg1 = KeyPairGenerator.getInstance("EC"); + kpg1.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair pair1 = kpg1.generateKeyPair(); + KeyPairGenerator kpg2 = KeyPairGenerator.getInstance("EC"); + kpg2.initialize(new ECGenParameterSpec("secp384r1")); + KeyPair pair2 = kpg2.generateKeyPair(); + KeyPairGenerator kpg3 = KeyPairGenerator.getInstance("EC"); + kpg3.initialize(new ECGenParameterSpec("secp521r1")); + KeyPair pair3 = kpg3.generateKeyPair(); + // java.security.InvalidAlgorithmParameterException: Curve not supported: secp256k1 (1.3.132.0.10) +// KeyPairGenerator kpg4 = KeyPairGenerator.getInstance("EC"); +// kpg4.initialize(new ECGenParameterSpec("secp256k1")); +// KeyPair pair4 = kpg4.generateKeyPair(); + + // for the record: +// System.out.println(HexFormat.of().formatHex(pair1.getPublic().getEncoded())); + // $ xclip -o | xxd -p -r | openssl asn1parse -inform der -i + // 0:d=0 hl=2 l= 89 cons: SEQUENCE + // 2:d=1 hl=2 l= 19 cons: SEQUENCE + // 4:d=2 hl=2 l= 7 prim: OBJECT :id-ecPublicKey + // 13:d=2 hl=2 l= 8 prim: OBJECT :prime256v1 + // 23:d=1 hl=2 l= 66 prim: BIT STRING +// System.out.println(HexFormat.of().formatHex(pair1.getPrivate().getEncoded())); + // $ xclip -o | xxd -p -r | openssl asn1parse -inform der -i + // 0:d=0 hl=2 l= 65 cons: SEQUENCE + // 2:d=1 hl=2 l= 1 prim: INTEGER :00 + // 5:d=1 hl=2 l= 19 cons: SEQUENCE + // 7:d=2 hl=2 l= 7 prim: OBJECT :id-ecPublicKey + // 16:d=2 hl=2 l= 8 prim: OBJECT :prime256v1 + // 26:d=1 hl=2 l= 39 prim: OCTET STRING [HEX DUMP]:30250201010420CC4848D8216329CB08355AD22BC878A4FC8FBD69D8F6CF37FDD05A6A9E36DDF1 +// System.out.println(HexFormat.of().formatHex(pair2.getPublic().getEncoded())); + // $ xclip -o | xxd -p -r | openssl asn1parse -inform der -i + // 0:d=0 hl=2 l= 118 cons: SEQUENCE + // 2:d=1 hl=2 l= 16 cons: SEQUENCE + // 4:d=2 hl=2 l= 7 prim: OBJECT :id-ecPublicKey + // 13:d=2 hl=2 l= 5 prim: OBJECT :secp384r1 + // 20:d=1 hl=2 l= 98 prim: BIT STRING +// System.out.println(HexFormat.of().formatHex(pair2.getPrivate().getEncoded())); + // $ xclip -o | xxd -p -r | openssl asn1parse -inform der -i + // 0:d=0 hl=2 l= 78 cons: SEQUENCE + // 2:d=1 hl=2 l= 1 prim: INTEGER :00 + // 5:d=1 hl=2 l= 16 cons: SEQUENCE + // 7:d=2 hl=2 l= 7 prim: OBJECT :id-ecPublicKey + // 16:d=2 hl=2 l= 5 prim: OBJECT :secp384r1 + // 23:d=1 hl=2 l= 55 prim: OCTET STRING [HEX DUMP]:30350201010430424D0072CA80BAC3627FF1D55E9F2ECB7AE19F6C3BD40347CFB064A06D39D0C0D1FB789C312E1FF6B9B3A52320A63A7B +// System.out.println(HexFormat.of().formatHex(pair3.getPublic().getEncoded())); + // $ xclip -o | xxd -p -r | openssl asn1parse -inform der -i + // 0:d=0 hl=3 l= 155 cons: SEQUENCE + // 3:d=1 hl=2 l= 16 cons: SEQUENCE + // 5:d=2 hl=2 l= 7 prim: OBJECT :id-ecPublicKey + // 14:d=2 hl=2 l= 5 prim: OBJECT :secp521r1 + // 21:d=1 hl=3 l= 134 prim: BIT STRING +// System.out.println(HexFormat.of().formatHex(pair3.getPrivate().getEncoded())); + // $ xclip -o | xxd -p -r | openssl asn1parse -inform der -i + // 0:d=0 hl=2 l= 96 cons: SEQUENCE + // 2:d=1 hl=2 l= 1 prim: INTEGER :00 + // 5:d=1 hl=2 l= 16 cons: SEQUENCE + // 7:d=2 hl=2 l= 7 prim: OBJECT :id-ecPublicKey + // 16:d=2 hl=2 l= 5 prim: OBJECT :secp521r1 + // 23:d=1 hl=2 l= 73 prim: OCTET STRING [HEX DUMP]:3047020101044201B44B5ED5943BF08DC42BE2DB95F9D267B449F2D8A1522FD8C45F44B7DD06B7EE6991A4B38B882D232FC4054322C1C2A8B4A86DE03FAACB458B63CC71CBC35D21C7 + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("https://artemis.apache.org") + .subject("Alice") + .expirationTime(new Date(new Date().getTime() + 3_600_000)) + .build(); + + JWSAlgorithm[] algorithms = new JWSAlgorithm[]{ + JWSAlgorithm.ES256, + JWSAlgorithm.ES384, + JWSAlgorithm.ES512, +// JWSAlgorithm.ES256K, + }; + KeyPair[] pairs = new KeyPair[]{pair1, pair2, pair3/*, pair4*/}; + + List keys = new ArrayList<>(); + // directly from the public key + keys.add(new ECKey.Builder(Curve.P_256, (ECPublicKey) pair1.getPublic()).keyID("k1").build()); + keys.add(new ECKey.Builder(Curve.P_384, (ECPublicKey) pair2.getPublic()).keyID("k2").build()); + keys.add(new ECKey.Builder(Curve.P_521, (ECPublicKey) pair3.getPublic()).keyID("k3").build()); +// keys.add(new ECKey.Builder(Curve.SECP256K1, (ECPublicKey) pair4.getPublic()).keyID("k4").build()); + JWKSecurityContext context = new JWKSecurityContext(keys); + lm.setOidcSupport(new OIDCSupport(configMap(), false) { + @Override + public JWKSecurityContext currentContext() { + return context; + } + }); + + for (int i = 0; i < algorithms.length; i++) { + JWSAlgorithm algorithm = algorithms[i]; + SignedJWT signedJWT = new SignedJWT(new JWSHeader.Builder(algorithm).keyID(String.format("k%d", i + 1)).build(), claims); + JWSSigner signer = new ECDSASigner((ECPrivateKey) pairs[i].getPrivate()); + signedJWT.sign(signer); + String token = signedJWT.serialize(); + + assertFalse(token.endsWith("."), "Should include any signature"); + assertEquals(3, token.split("\\.").length, "Should contain header, payload and signature parts"); + assertTrue(signedJWT.verify(new ECDSAVerifier((ECPublicKey) pairs[i].getPublic()))); + + JWT jwt = lm.parseAndValidateToken(signedJWT.serialize()); + assertInstanceOf(SignedJWT.class, jwt); + } + } + + // ---- Tests for actual login + + @Test + public void properSignedToken() 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"); + + OIDCLoginModule lm = new OIDCLoginModule(); + lm.setOidcSupport(new OIDCSupport(config, true) { + @Override + public JWKSecurityContext currentContext() { + return new JWKSecurityContext(keys); + } + }); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("http://localhost") + .subject("Alice") + .audience(List.of("me-the-broker", "some-other-api")) + .claim("azp", "artemis-oidc-client") + .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(); + // TODO: the token should be passed in something better than a password field + lm.initialize(subject, new JaasCallbackHandler(null, token, null), null, config); + + assertTrue(subject.getPrincipals().isEmpty()); + assertTrue(subject.getPublicCredentials().isEmpty()); + assertTrue(subject.getPrivateCredentials().isEmpty()); + + // here's where the JAAS magic happens + assertTrue(lm.login()); + assertTrue(lm.commit()); + +// assertFalse(subject.getPrincipals().isEmpty()); + assertTrue(subject.getPublicCredentials().isEmpty()); + assertFalse(subject.getPrivateCredentials().isEmpty()); + } + +} diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccessTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccessTest.java new file mode 100644 index 00000000000..6986e073009 --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccessTest.java @@ -0,0 +1,103 @@ +/* + * 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.artemis.spi.core.security.jaas.oidc; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.NoSuchAlgorithmException; +import javax.net.ssl.SSLContext; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link HttpClientAccess} - we don't have to test much, because we'll be mocking {@link HttpClient} + * anyway. So HTTP issues should actually be covered in {@link OIDCMetadataAccessTest}. + */ +public class HttpClientAccessTest { + + @Test + @SuppressWarnings("unchecked") + public void justMockingHttpClient() throws IOException, InterruptedException { + HttpClient client = mock(HttpClient.class); + + HttpResponse notFoundResponse = mock(HttpResponse.class); + when(notFoundResponse.body()).thenReturn("Not Found"); + when(notFoundResponse.statusCode()).thenReturn(404); + + HttpResponse helloResponse = mock(HttpResponse.class); + when(helloResponse.body()).thenReturn("Hello"); + when(helloResponse.statusCode()).thenReturn(200); + + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = any(HttpResponse.BodyHandler.class); + when(client.send(req, res)).thenAnswer(inv -> { + HttpRequest r = inv.getArgument(0, HttpRequest.class); + if (r.uri().getPath().equals("/index.txt")) { + return helloResponse; + } + + return notFoundResponse; + }); + + HttpRequest indexHtmlReq = HttpRequest.newBuilder(URI.create("http://localhost/index.html")).GET().build(); + HttpResponse response = client.send(indexHtmlReq, HttpResponse.BodyHandlers.ofString()); + assertEquals(404, response.statusCode()); + assertEquals("Not Found", response.body()); + + HttpRequest indexTxtReq = HttpRequest.newBuilder(URI.create("http://localhost/index.txt")).GET().build(); + response = client.send(indexTxtReq, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + assertEquals("Hello", response.body()); + } + + @Test + public void sharedHttpClientAccess() { + SharedHttpClientAccess access1 = new SharedHttpClientAccess(null, true); + SharedHttpClientAccess access2 = new SharedHttpClientAccess(null, true); + + HttpClient client1 = access1.getClient(URI.create("http://localhost:8080")); + HttpClient client2 = access1.getClient(URI.create("https://localhost:8443")); + HttpClient client3 = access2.getClient(URI.create("http://localhost:8080")); + HttpClient client4 = access2.getClient(URI.create("https://localhost:8443")); + + assertNotNull(client1); + assertNotNull(client3); + assertSame(client1, client3); + assertSame(client2, client4); + } + + @Test + public void defaultClient() throws NoSuchAlgorithmException { + SharedHttpClientAccess access = new SharedHttpClientAccess(null, true); + + HttpClient client = access.getClient(URI.create("http://localhost:8081")); + + assertNotNull(client); + assertSame(client.sslContext(), SSLContext.getDefault()); + } + +} diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/JWTTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/JWTTest.java new file mode 100644 index 00000000000..f07412559db --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/JWTTest.java @@ -0,0 +1,287 @@ +/* + * 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.artemis.spi.core.security.jaas.oidc; + +import com.nimbusds.jose.EncryptionMethod; +import com.nimbusds.jose.JWEAlgorithm; +import com.nimbusds.jose.JWEEncrypter; +import com.nimbusds.jose.JWEHeader; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.crypto.ECDSAVerifier; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jose.crypto.RSADecrypter; +import com.nimbusds.jose.crypto.RSAEncrypter; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.EncryptedJWT; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTParser; +import com.nimbusds.jwt.PlainJWT; +import com.nimbusds.jwt.SignedJWT; +import org.apache.activemq.artemis.api.core.JsonUtil; +import org.apache.activemq.artemis.json.JsonObject; +import org.junit.jupiter.api.Test; + +import javax.crypto.KeyGenerator; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.text.ParseException; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for using {@code com.nimbusds:nimbus-jose-jwt} library. + */ +public class JWTTest { + + @Test + public void plainJWT() throws ParseException { + // https://datatracker.ietf.org/doc/html/rfc7519#section-6.1 - Example Unsecured JWT + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("https://artemis.apache.org") + .subject("Alice") + .expirationTime(new Date(new Date().getTime() + 3_600_000)) + .build(); + + PlainJWT plainJWT = new PlainJWT(claims); + String token = plainJWT.serialize(); + assertTrue(token.endsWith("."), "Should not include any signature"); + + JWT jwt = JWTParser.parse(token); + assertInstanceOf(PlainJWT.class, jwt); + } + + @Test + public void signedJWTWithRequiredHmacAlgorithm() throws Exception { + // https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 - HS256 is the only REQUIRED algorithm + + byte[] sharedSecret = new byte[32]; // 256-bit + new SecureRandom().nextBytes(sharedSecret); + + SecretKey hmacKey = new SecretKeySpec(sharedSecret, "HmacSHA256"); + + Mac mac = Mac.getInstance("HmacSHA256"); + KeyGenerator keyGenerator = KeyGenerator.getInstance("HmacSHA256"); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("https://artemis.apache.org") + .subject("Alice") + .expirationTime(new Date(new Date().getTime() + 3_600_000)) + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + + // there are 4 signers: + // - com.nimbusds.jose.crypto.ECDSASigner + // - com.nimbusds.jose.crypto.Ed25519Signer + // - com.nimbusds.jose.crypto.MACSigner + // - com.nimbusds.jose.crypto.RSASSASigner + JWSSigner signer = new MACSigner(hmacKey.getEncoded()); + signedJWT.sign(signer); + String token = signedJWT.serialize(); + + assertFalse(token.endsWith("."), "Should include any signature"); + assertEquals(3, token.split("\\.").length, "Should contain header, payload and signature parts"); + + JWT jwt = JWTParser.parse(token); + assertInstanceOf(SignedJWT.class, jwt); + assertTrue(((SignedJWT) jwt).verify(new MACVerifier(hmacKey))); + } + + @Test + public void signedJWTWithRecommendedRsaAlgorithm() throws Exception { + // https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 - RS256 is the RECOMMENDED algorithm + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair keyPair = kpg.generateKeyPair(); + PrivateKey privateKey = keyPair.getPrivate(); + PublicKey publicKey = keyPair.getPublic(); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("https://artemis.apache.org") + .subject("Alice") + .expirationTime(new Date(new Date().getTime() + 3_600_000)) + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claims); + + JWSSigner signer = new RSASSASigner(privateKey); + signedJWT.sign(signer); + String token = signedJWT.serialize(); + + assertFalse(token.endsWith("."), "Should include any signature"); + assertEquals(3, token.split("\\.").length, "Should contain header, payload and signature parts"); + + JWT jwt = JWTParser.parse(token); + assertInstanceOf(SignedJWT.class, jwt); + assertTrue(((SignedJWT) jwt).verify(new RSASSAVerifier((RSAPublicKey) publicKey))); + } + + @Test + public void signedJWTWithRecommendedEcAlgorithm() throws Exception { + // https://datatracker.ietf.org/doc/html/rfc7518#section-3.4 - ES256 is the RECOMMENDED+ algorithm + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(new ECGenParameterSpec("secp521r1")); + KeyPair keyPair = kpg.generateKeyPair(); + PrivateKey privateKey = keyPair.getPrivate(); + PublicKey publicKey = keyPair.getPublic(); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("https://artemis.apache.org") + .subject("Alice") + .expirationTime(new Date(new Date().getTime() + 3_600_000)) + .build(); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.ES512), claims); + + JWSSigner signer = new ECDSASigner((ECPrivateKey) privateKey); + signedJWT.sign(signer); + String token = signedJWT.serialize(); + + assertFalse(token.endsWith("."), "Should include any signature"); + assertEquals(3, token.split("\\.").length, "Should contain header, payload and signature parts"); + + JWT jwt = JWTParser.parse(token); + assertInstanceOf(SignedJWT.class, jwt); + assertTrue(((SignedJWT) jwt).verify(new ECDSAVerifier((ECPublicKey) publicKey))); + } + + @Test + public void encryptedJWT() throws Exception { + // https://datatracker.ietf.org/doc/html/rfc7516 + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair keyPair = kpg.generateKeyPair(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("https://artemis.apache.org") + .subject("Alice") + .expirationTime(new Date(new Date().getTime() + 3_600_000)) + .build(); + + JWEHeader header = new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM) + // JWS - https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.10 - "cty" is just a MIME + // JWE - https://datatracker.ietf.org/doc/html/rfc7516#section-4.1.12 - "cty" is just a MIME + // JWT - https://datatracker.ietf.org/doc/html/rfc7519#section-5.2 - REQUIRES "cty":"JWT" + .contentType("JWT") + .build(); + + EncryptedJWT encryptedJWT = new EncryptedJWT(header, claims); + + // there are 9 JWE Encrypters: + // - com.nimbusds.jose.crypto.AESEncrypter + // - com.nimbusds.jose.crypto.DirectEncrypter + // - com.nimbusds.jose.crypto.ECDH1PUEncrypter + // - com.nimbusds.jose.crypto.ECDH1PUX25519Encrypter + // - com.nimbusds.jose.crypto.ECDHEncrypter + // - com.nimbusds.jose.crypto.MultiEncrypter + // - com.nimbusds.jose.crypto.PasswordBasedEncrypter + // - com.nimbusds.jose.crypto.RSAEncrypter + // - com.nimbusds.jose.crypto.X25519Encrypter + JWEEncrypter encrypter = new RSAEncrypter(publicKey); + encryptedJWT.encrypt(encrypter); + String token = encryptedJWT.serialize(); + + assertEquals(5, token.split("\\.").length, "Should contain header, encrypted key, IV, cipher text and integrity value parts"); + + JWT jwt = JWTParser.parse(token); + assertInstanceOf(EncryptedJWT.class, jwt); + assertNull(jwt.getJWTClaimsSet()); + ((EncryptedJWT) jwt).decrypt(new RSADecrypter(privateKey)); + assertNotNull(jwt.getJWTClaimsSet()); + } + + @Test + public void jwkRepresentation() throws Exception { + KeyPairGenerator rsaKpg = KeyPairGenerator.getInstance("RSA"); + rsaKpg.initialize(2048); + KeyPair rsaKeyPair = rsaKpg.generateKeyPair(); + RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) rsaKeyPair.getPrivate(); + RSAPublicKey rsaPublicKey = (RSAPublicKey) rsaKeyPair.getPublic(); + + KeyPairGenerator ecKpg = KeyPairGenerator.getInstance("EC"); + ecKpg.initialize(new ECGenParameterSpec("secp521r1")); + KeyPair ecKeyPair = ecKpg.generateKeyPair(); + ECPrivateKey ecPrivateKey = (ECPrivateKey) ecKeyPair.getPrivate(); + ECPublicKey ecPublicKey = (ECPublicKey) ecKeyPair.getPublic(); + + RSAKey rsaPublicJWK = new RSAKey.Builder(rsaPublicKey).keyUse(KeyUse.SIGNATURE).build(); + String rsaPublicJson = rsaPublicJWK.toJSONString(); + JsonObject rsaPublic = JsonUtil.readJsonObject(rsaPublicJson); + assertTrue(rsaPublic.containsKey("e"), "should contain RSA modulus"); + assertTrue(rsaPublic.containsKey("n"), "should contain RSA exponent"); + + RSAKey rsaPrivateJWK = new RSAKey.Builder(rsaPublicKey).keyUse(KeyUse.SIGNATURE).privateKey(rsaPrivateKey).build(); + String rsaPrivateJson = rsaPrivateJWK.toJSONString(); + JsonObject rsaPrivate = JsonUtil.readJsonObject(rsaPrivateJson); + assertTrue(rsaPrivate.containsKey("e"), "should contain RSA modulus"); + assertTrue(rsaPrivate.containsKey("n"), "should contain RSA exponent"); + assertTrue(rsaPrivate.containsKey("d"), "should contain RSA private exponent"); + assertTrue(rsaPrivate.containsKey("p"), "should contain RSA first prime factor"); + assertTrue(rsaPrivate.containsKey("q"), "should contain RSA second prime factor"); + assertTrue(rsaPrivate.containsKey("dp"), "should contain RSA first factor CRT exponent"); + assertTrue(rsaPrivate.containsKey("dq"), "should contain RSA second factor CRT exponent"); + assertTrue(rsaPrivate.containsKey("qi"), "should contain RSA first CRT coefficient"); + + ECKey ecPublicJWK = new ECKey.Builder(Curve.P_521, ecPublicKey).keyUse(KeyUse.SIGNATURE).build(); + String ecPublicJson = ecPublicJWK.toJSONString(); + JsonObject ecPublic = JsonUtil.readJsonObject(ecPublicJson); + assertTrue(ecPublic.containsKey("crv"), "should contain EC curve"); + assertTrue(ecPublic.containsKey("x"), "should contain EC X coordinate"); + assertTrue(ecPublic.containsKey("y"), "should contain EC Y coordinate"); + + ECKey ecPrivateJWK = new ECKey.Builder(Curve.P_521, ecPublicKey).keyUse(KeyUse.SIGNATURE).privateKey(ecPrivateKey).build(); + String ecPrivateJson = ecPrivateJWK.toJSONString(); + JsonObject ecPrivate = JsonUtil.readJsonObject(ecPrivateJson); + assertTrue(ecPrivate.containsKey("crv"), "should contain EC curve"); + assertTrue(ecPrivate.containsKey("x"), "should contain EC X coordinate"); + assertTrue(ecPrivate.containsKey("y"), "should contain EC Y coordinate"); + assertTrue(ecPrivate.containsKey("d"), "should contain EC ECC private key"); + } + +} diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadataAccessTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadataAccessTest.java new file mode 100644 index 00000000000..1ad260c942d --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadataAccessTest.java @@ -0,0 +1,751 @@ +/* + * 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.artemis.spi.core.security.jaas.oidc; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.ConnectException; +import java.net.ProtocolException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.SignatureException; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.util.Collections; +import java.util.Map; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSAVerifier; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.crypto.impl.ECDSA; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.KeyType; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.util.Base64URL; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for getting OpenID Connect metadata under various scenarios and HTTP/connection issues. + */ +public class OIDCMetadataAccessTest { + + private HttpClient client; + private HttpResponse notFoundResponse; + + private SharedOIDCMetadataAccess access; + + @SuppressWarnings("unchecked") + private static HttpResponse.BodyHandler anyHandler() { + return any(HttpResponse.BodyHandler.class); + } + + // ---- Warming up + + @BeforeEach + @SuppressWarnings("unchecked") + public void setup() { + client = mock(HttpClient.class); + + notFoundResponse = mock(HttpResponse.class); + when(notFoundResponse.body()).thenReturn(new ByteArrayInputStream("Not Found".getBytes())); + when(notFoundResponse.statusCode()).thenReturn(404); + + SharedOIDCMetadataAccess.cache.clear(); + + access = new SharedOIDCMetadataAccess(baseURI -> client, Map.of( + OIDCSupport.ConfigKey.METADATA_RETRY_TIME_SECONDS.getName(), "10" + ), true); + } + + // ---- Transport failure scenarios + + @Test + @SuppressWarnings("unchecked") + public void gettingFreshMetadata() throws IOException, InterruptedException { + HttpHeaders headers = HttpHeaders.of(Map.of( + "Content-Type", Collections.singletonList("application/json") + ), (n, v) -> true); + HttpResponse emptyJSON = mock(HttpResponse.class); + when(emptyJSON.body()).thenReturn(new ByteArrayInputStream("{}".getBytes())); + when(emptyJSON.headers()).thenReturn(headers); + when(emptyJSON.statusCode()).thenReturn(200); + + HttpResponse partialMetadataJSON = mock(HttpResponse.class); + when(partialMetadataJSON.body()).thenReturn(new ByteArrayInputStream("{\"issuer\":\"http://localhost:8083\"}".getBytes())); + when(partialMetadataJSON.headers()).thenReturn(headers); + when(partialMetadataJSON.statusCode()).thenReturn(200); + + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> { + HttpRequest r = inv.getArgument(0, HttpRequest.class); + if (r.uri().getPath().equals("/.well-known/openid-configuration")) { + if (r.uri().getPort() == 8082) { + return emptyJSON; + } + if (r.uri().getPort() == 8083) { + return partialMetadataJSON; + } + return emptyJSON; + } + + return notFoundResponse; + }); + + OIDCMetadata metadata1 = access.getMetadata(URI.create("http://localhost:8082")); + OIDCMetadata metadata2 = access.getMetadata(URI.create("http://localhost:8082")); + OIDCMetadata metadata3 = access.getMetadata(URI.create("http://localhost:8083")); + + assertSame(metadata1, metadata2); + assertNotSame(metadata1, metadata3); + + assertNull(metadata1.getIssuer()); + assertEquals("http://localhost:8083", metadata3.getIssuer()); + } + + @Test + public void connectionRefused() throws IOException, InterruptedException { + // simply connecting to a non listening server + when(client.send(any(HttpRequest.class), anyHandler())).thenThrow(new ConnectException("Connection refused")); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertFalse(metadata.isValid()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertEquals("Connection refused", metadata.getErrorMessage()); + assertTrue("Time to fetch again", metadata.shouldFetchAgain(0)); + } + + @Test + public void connectionTimeout() throws IOException, InterruptedException { + // connection timeout - hard to simulate, but see https://en.wikipedia.org/wiki/List_of_reserved_IP_addresses + // there's special 192.0.2.0/24 which _should_ give you connection timeout + when(client.send(any(HttpRequest.class), anyHandler())).thenThrow(new HttpConnectTimeoutException("Connection timeout")); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertFalse(metadata.isValid()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertEquals("Connection timeout", metadata.getErrorMessage()); + assertTrue("Time to fetch again", metadata.shouldFetchAgain(0)); + } + + @Test + public void readTimeout() throws IOException, InterruptedException { + // simulating read timeouts: + // $ socat -v TCP-LISTEN:8080,reuseaddr,fork EXEC:'sleep 30' + when(client.send(any(HttpRequest.class), anyHandler())).thenThrow(new HttpTimeoutException("Read timeout")); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertFalse(metadata.isValid()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertEquals("Read timeout", metadata.getErrorMessage()); + assertTrue("Time to fetch again", metadata.shouldFetchAgain(0)); + } + + @Test + public void ioError() throws IOException, InterruptedException { + when(client.send(any(HttpRequest.class), anyHandler())).thenThrow(new IOException("Something's wrong")); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertFalse(metadata.isValid()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertEquals("Something's wrong", metadata.getErrorMessage()); + assertTrue("Time to fetch again", metadata.shouldFetchAgain(0)); + } + + // ---- HTTP failure scenarios + + @Test + public void noHTTP() throws IOException, InterruptedException { + // simulating non-HTTP server: + // $ socat -v TCP-LISTEN:1234,reuseaddr,fork EXEC:'echo HELLO' + when(client.send(any(HttpRequest.class), anyHandler())).thenThrow(new ProtocolException("Invalid status line: \"HELLO\"")); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertFalse(metadata.isValid()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertEquals("Invalid status line: \"HELLO\"", metadata.getErrorMessage()); + assertFalse("No time to fetch again - because this looks like unrecoverable error", metadata.shouldFetchAgain(0)); + assertFalse(metadata.isRecoverable()); + } + + @Test + public void http40x() throws IOException, InterruptedException { + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> notFoundResponse); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertFalse(metadata.isValid()); + assertFalse(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertTrue(metadata.getErrorMessage().contains("404")); + assertFalse("404 is not expected to change - let's treat as unrecoverable", metadata.shouldFetchAgain(0)); + } + + // ---- Low level OIDC metadata problems (empty HTTP body when HTTP status=200 or JSON parsing issues) + + @Test + @SuppressWarnings("unchecked") + public void http50x() throws IOException, InterruptedException { + HttpResponse response50x = mock(HttpResponse.class); + when(response50x.statusCode()).thenReturn(503); + HttpResponse finalResponse50x1 = response50x; + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> finalResponse50x1); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertFalse(metadata.isValid()); + assertTrue(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertTrue(metadata.getErrorMessage().contains("503")); + assertTrue("50x can hopefully change at some point", metadata.shouldFetchAgain(0)); + + response50x = mock(HttpResponse.class); + when(response50x.statusCode()).thenReturn(500); + HttpResponse finalResponse50x2 = response50x; + req = any(HttpRequest.class); + res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> finalResponse50x2); + + metadata = access.getMetadata(URI.create("http://localhost:8081")); + assertFalse(metadata.isValid()); + assertTrue(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertTrue(metadata.getErrorMessage().contains("500")); + assertTrue("50x can hopefully change at some point", metadata.shouldFetchAgain(0)); + } + + @Test + @SuppressWarnings("unchecked") + public void emptyOrBlankResponse() throws IOException, InterruptedException { + final HttpResponse r1 = mock(HttpResponse.class); + when(r1.statusCode()).thenReturn(200); + when(r1.body()).thenReturn(null); + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> r1); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertFalse(metadata.isValid()); + assertFalse(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertNull(metadata.getException()); + assertFalse("JSON errors can't be fixed any time soon - we can simply have bad provider URL", metadata.shouldFetchAgain(0)); + assertTrue(metadata.getErrorMessage().contains("No OIDC metadata available")); + + final HttpResponse r2 = mock(HttpResponse.class); + when(r2.statusCode()).thenReturn(200); + when(r2.body()).thenReturn(new ByteArrayInputStream(new byte[0])); + req = any(HttpRequest.class); + res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> r2); + + metadata = access.getMetadata(URI.create("http://localhost:8081")); + assertFalse(metadata.isValid()); + assertFalse(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertNull(metadata.getException()); + assertFalse("JSON errors can't be fixed any time soon - we can simply have bad provider URL", metadata.shouldFetchAgain(0)); + assertTrue(metadata.getErrorMessage().contains("No OIDC metadata available")); + + final HttpResponse r3 = mock(HttpResponse.class); + when(r3.statusCode()).thenReturn(200); + when(r3.body()).thenReturn(new ByteArrayInputStream(" ".getBytes())); + req = any(HttpRequest.class); + res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> r3); + + metadata = access.getMetadata(URI.create("http://localhost:8081")); + assertFalse(metadata.isValid()); + assertFalse(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertNull(metadata.getException()); + assertFalse("JSON errors can't be fixed any time soon - we can simply have bad provider URL", metadata.shouldFetchAgain(0)); + assertTrue(metadata.getErrorMessage().contains("No OIDC metadata available")); + + final HttpResponse r4 = mock(HttpResponse.class); + when(r4.statusCode()).thenReturn(200); + when(r4.body()).thenReturn(new ByteArrayInputStream("\n\t \n\t".getBytes())); + req = any(HttpRequest.class); + res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> r4); + + metadata = access.getMetadata(URI.create("http://localhost:8081")); + assertFalse(metadata.isValid()); + assertFalse(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertNull(metadata.getException()); + assertFalse("JSON errors can't be fixed any time soon - we can simply have bad provider URL", metadata.shouldFetchAgain(0)); + assertTrue(metadata.getErrorMessage().contains("No OIDC metadata available")); + } + + // ---- Proper JSON, but missing/invalid OIDC metadata + + @Test + @SuppressWarnings("unchecked") + public void invalidJSON() throws IOException, InterruptedException { + final HttpResponse r1 = mock(HttpResponse.class); + when(r1.statusCode()).thenReturn(200); + when(r1.body()).thenReturn(new ByteArrayInputStream("{".getBytes())); + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> r1); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertFalse(metadata.isValid()); + assertFalse(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertNull(metadata.getException()); + assertFalse("JSON errors can't be fixed any time soon", metadata.shouldFetchAgain(0)); + assertTrue(metadata.getErrorMessage().contains("OIDC metadata invalid - JSON error")); + + final HttpResponse r2 = mock(HttpResponse.class); + when(r2.statusCode()).thenReturn(200); + when(r2.body()).thenReturn(new ByteArrayInputStream("\"\"".getBytes())); + req = any(HttpRequest.class); + res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> r2); + + metadata = access.getMetadata(URI.create("http://localhost:8081")); + assertFalse(metadata.isValid()); + assertFalse(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertNull(metadata.getException()); + assertFalse("JSON errors can't be fixed any time soon", metadata.shouldFetchAgain(0)); + assertTrue(metadata.getErrorMessage().contains("OIDC metadata invalid - JSON error")); + + final HttpResponse r3 = mock(HttpResponse.class); + when(r3.statusCode()).thenReturn(200); + when(r3.body()).thenReturn(new ByteArrayInputStream("42".getBytes())); + req = any(HttpRequest.class); + res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> r3); + + metadata = access.getMetadata(URI.create("http://localhost:8081")); + assertFalse(metadata.isValid()); + assertFalse(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertNull(metadata.getException()); + assertFalse("JSON errors can't be fixed any time soon", metadata.shouldFetchAgain(0)); + assertTrue(metadata.getErrorMessage().contains("OIDC metadata invalid - JSON error")); + + final HttpResponse r4 = mock(HttpResponse.class); + when(r4.statusCode()).thenReturn(200); + when(r4.body()).thenReturn(new ByteArrayInputStream("[42]".getBytes())); + req = any(HttpRequest.class); + res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> r4); + + metadata = access.getMetadata(URI.create("http://localhost:8081")); + assertFalse(metadata.isValid()); + assertFalse(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertNull(metadata.getException()); + assertFalse("JSON errors can't be fixed any time soon", metadata.shouldFetchAgain(0)); + assertTrue(metadata.getErrorMessage().contains("OIDC metadata invalid - JSON error")); + } + + @Test + @SuppressWarnings("unchecked") + public void emptyJSONObject() throws IOException, InterruptedException { + final HttpResponse r1 = mock(HttpResponse.class); + when(r1.statusCode()).thenReturn(200); + when(r1.body()).thenReturn(new ByteArrayInputStream("{}".getBytes())); + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> r1); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertFalse(metadata.isValid()); + assertFalse(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertFalse("Missing issuer in the OIDC metadata - not expected to change anytime soon", metadata.shouldFetchAgain(0)); + assertNull(metadata.getException()); + assertTrue(metadata.getErrorMessage().contains("OIDC Metadata issuer is missing")); + } + + // ---- Proper JSON, checking keys + + @Test + @SuppressWarnings("unchecked") + public void differentIssuer() throws IOException, InterruptedException { + final HttpResponse r1 = mock(HttpResponse.class); + when(r1.statusCode()).thenReturn(200); + when(r1.body()).thenReturn(new ByteArrayInputStream("{\"issuer\":\"http://localhost:8081\"}".getBytes())); + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> r1); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertFalse(metadata.isValid()); + assertFalse(metadata.isRecoverable()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertFalse("No need to fetch again", metadata.shouldFetchAgain(0)); + assertNull(metadata.getException()); + assertNotNull(metadata.getErrorMessage()); + } + + @Test + @SuppressWarnings("unchecked") + public void problematicKeys() throws IOException, InterruptedException { + access = new SharedOIDCMetadataAccess(baseURI -> client, Map.of( + OIDCSupport.ConfigKey.CACHE_KEYS_TIME_SECONDS.getName(), "0" + ), true); + + // 404 fetching the keys + + final HttpResponse r1 = mock(HttpResponse.class); + when(r1.statusCode()).thenReturn(200); + when(r1.body()).thenReturn(new ByteArrayInputStream("{\"issuer\":\"http://localhost:8080\",\"jwks_uri\":\"http://localhost:8080/keys\"}".getBytes())); + final HttpResponse keys = mock(HttpResponse.class); + when(keys.statusCode()).thenReturn(404); + when(keys.body()).thenReturn(null); + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> { + HttpRequest argument = inv.getArgument(0); + if (argument.uri().toString().equals("http://localhost:8080/.well-known/openid-configuration")) { + return r1; + } + return notFoundResponse; + }); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertTrue(metadata.isValid()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertFalse("No need to fetch again", metadata.shouldFetchAgain(0)); + assertNull(metadata.getException()); + assertTrue(metadata.currentSecurityContext().getKeys().isEmpty()); + + // Bad JSON when fetching the keys + + final HttpResponse r2 = mock(HttpResponse.class); + when(r2.statusCode()).thenReturn(200); + when(r2.body()).thenReturn(new ByteArrayInputStream("{\"issuer\":\"http://localhost:8080\",\"jwks_uri\":\"http://localhost:8080/keys\"}".getBytes())); + final HttpResponse keys2 = mock(HttpResponse.class); + when(keys2.statusCode()).thenReturn(200); + when(keys2.body()).thenReturn("{"); + req = any(HttpRequest.class); + res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> { + HttpRequest argument = inv.getArgument(0); + return switch (argument.uri().toString()) { + case "http://localhost:8080/.well-known/openid-configuration" -> r2; + case "http://localhost:8080/keys" -> keys2; + default -> notFoundResponse; + }; + }); + + metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertTrue(metadata.isValid()); + assertTrue(metadata.currentSecurityContext().getKeys().isEmpty()); + + // Empty JSON when fetching the keys + + final HttpResponse r3 = mock(HttpResponse.class); + when(r3.statusCode()).thenReturn(200); + when(r3.body()).thenReturn(new ByteArrayInputStream("{\"issuer\":\"http://localhost:8080\",\"jwks_uri\":\"http://localhost:8080/keys\"}".getBytes())); + final HttpResponse keys3 = mock(HttpResponse.class); + when(keys3.statusCode()).thenReturn(200); + when(keys3.body()).thenReturn("{}"); + req = any(HttpRequest.class); + res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> { + HttpRequest argument = inv.getArgument(0); + return switch (argument.uri().toString()) { + case "http://localhost:8080/.well-known/openid-configuration" -> r3; + case "http://localhost:8080/keys" -> keys3; + default -> notFoundResponse; + }; + }); + + metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertTrue(metadata.isValid()); + assertTrue(metadata.currentSecurityContext().getKeys().isEmpty()); + + // Empty key array + + final HttpResponse r4 = mock(HttpResponse.class); + when(r4.statusCode()).thenReturn(200); + when(r4.body()).thenReturn(new ByteArrayInputStream("{\"issuer\":\"http://localhost:8080\",\"jwks_uri\":\"http://localhost:8080/keys\"}".getBytes())); + final HttpResponse keys4 = mock(HttpResponse.class); + when(keys4.statusCode()).thenReturn(200); + when(keys4.body()).thenReturn("{\"keys\":[]}"); + req = any(HttpRequest.class); + res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> { + HttpRequest argument = inv.getArgument(0); + return switch (argument.uri().toString()) { + case "http://localhost:8080/.well-known/openid-configuration" -> r4; + case "http://localhost:8080/keys" -> keys4; + default -> notFoundResponse; + }; + }); + + metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertTrue(metadata.isValid()); + assertTrue(metadata.currentSecurityContext().getKeys().isEmpty()); + } + + @Test + @SuppressWarnings("unchecked") + public void noKeys() throws IOException, InterruptedException { + final HttpResponse r1 = mock(HttpResponse.class); + when(r1.statusCode()).thenReturn(200); + when(r1.body()).thenReturn(new ByteArrayInputStream("{\"issuer\":\"http://localhost:8080\"}".getBytes())); + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> r1); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertTrue(metadata.isValid()); + assertFalse("No time to fetch again yet", metadata.shouldFetchAgain(10)); + assertFalse("No need to fetch again", metadata.shouldFetchAgain(0)); + assertNull(metadata.getException()); + assertTrue(metadata.currentSecurityContext().getKeys().isEmpty()); + } + + @Test + @SuppressWarnings("unchecked") + public void oneSimpleRSAPublicKeyWithoutID() throws IOException, InterruptedException, NoSuchAlgorithmException, InvalidKeyException, SignatureException, JOSEException { + KeyPairGenerator rsaKpg = KeyPairGenerator.getInstance("RSA"); + rsaKpg.initialize(2048); + KeyPair rsaKeyPair = rsaKpg.generateKeyPair(); + RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) rsaKeyPair.getPrivate(); + RSAPublicKey rsaPublicKey = (RSAPublicKey) rsaKeyPair.getPublic(); + + RSAKey rsaPublicJWK = new RSAKey.Builder(rsaPublicKey).keyUse(KeyUse.SIGNATURE).build(); + String rsaPublicJson = rsaPublicJWK.toJSONString(); + + final HttpResponse r = mock(HttpResponse.class); + when(r.statusCode()).thenReturn(200); + when(r.body()).thenReturn(new ByteArrayInputStream("{\"issuer\":\"http://localhost:8080\",\"jwks_uri\":\"http://localhost:8080/keys\"}".getBytes())); + final HttpResponse keys = mock(HttpResponse.class); + when(keys.statusCode()).thenReturn(200); + when(keys.body()).thenReturn("{\"keys\":[" + rsaPublicJson + "]}"); + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> { + HttpRequest argument = inv.getArgument(0); + return switch (argument.uri().toString()) { + case "http://localhost:8080/.well-known/openid-configuration" -> r; + case "http://localhost:8080/keys" -> keys; + default -> notFoundResponse; + }; + }); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertTrue(metadata.isValid()); + assertEquals(1, metadata.currentSecurityContext().getKeys().size()); + JWK jwk = metadata.currentSecurityContext().getKeys().get(0); + assertNull(jwk.getKeyID()); + assertEquals(KeyType.RSA, jwk.getKeyType()); + assertEquals(KeyUse.SIGNATURE, jwk.getKeyUse()); + + // sign with Java Crypto + Signature signer = Signature.getInstance("SHA256withRSA"); + byte[] data = new byte[32]; + new SecureRandom().nextBytes(data); + signer.initSign(rsaPrivateKey); + signer.update(data); + // with RSA it's a plain signature: + // signature = m^d mod n + byte[] signature = signer.sign(); + + // verify with Nimbus and a public key from JSON representation (JWK) + assertTrue(new RSASSAVerifier((RSAKey) jwk).verify(new JWSHeader(JWSAlgorithm.RS256), data, Base64URL.encode(signature))); + assertFalse(new RSASSAVerifier((RSAKey) jwk).verify(new JWSHeader(JWSAlgorithm.RS384), data, Base64URL.encode(signature))); + assertThrows(JOSEException.class, () -> new RSASSAVerifier((RSAKey) jwk).verify(new JWSHeader(JWSAlgorithm.ES256), data, Base64URL.encode(signature))); + } + + @Test + @SuppressWarnings("unchecked") + public void oneSimpleECPublicKeyWithID() throws IOException, InterruptedException, NoSuchAlgorithmException, InvalidKeyException, SignatureException, JOSEException, InvalidAlgorithmParameterException { + KeyPairGenerator ecKpg = KeyPairGenerator.getInstance("EC"); + ecKpg.initialize(new ECGenParameterSpec("secp521r1")); + KeyPair ecKeyPair = ecKpg.generateKeyPair(); + ECPrivateKey ecPrivateKey = (ECPrivateKey) ecKeyPair.getPrivate(); + ECPublicKey ecPublicKey = (ECPublicKey) ecKeyPair.getPublic(); + + ECKey ecPublicJWK = new ECKey.Builder(Curve.P_521, ecPublicKey).keyID("k1").keyUse(KeyUse.SIGNATURE).build(); + String ecPublicJson = ecPublicJWK.toJSONString(); + + final HttpResponse r = mock(HttpResponse.class); + when(r.statusCode()).thenReturn(200); + when(r.body()).thenReturn(new ByteArrayInputStream("{\"issuer\":\"http://localhost:8080\",\"jwks_uri\":\"http://localhost:8080/keys\"}".getBytes())); + final HttpResponse keys = mock(HttpResponse.class); + when(keys.statusCode()).thenReturn(200); + when(keys.body()).thenReturn("{\"keys\":[" + ecPublicJson + "]}"); + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> { + HttpRequest argument = inv.getArgument(0); + return switch (argument.uri().toString()) { + case "http://localhost:8080/.well-known/openid-configuration" -> r; + case "http://localhost:8080/keys" -> keys; + default -> notFoundResponse; + }; + }); + + OIDCMetadata metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertTrue(metadata.isValid()); + assertEquals(1, metadata.currentSecurityContext().getKeys().size()); + JWK jwk = metadata.currentSecurityContext().getKeys().get(0); + assertEquals("k1", jwk.getKeyID()); + assertEquals(KeyType.EC, jwk.getKeyType()); + assertEquals(KeyUse.SIGNATURE, jwk.getKeyUse()); + + // sign with Java Crypto + Signature signer = Signature.getInstance("SHA512withECDSA"); + byte[] data = new byte[32]; + new SecureRandom().nextBytes(data); + signer.initSign(ecPrivateKey); + signer.update(data); + // with ECDSA it's an ASN.1 structure: + // $ openssl asn1parse -inform der -in /data/tmp/xxx.der + // 0:d=0 hl=3 l= 136 cons: SEQUENCE + // 3:d=1 hl=2 l= 66 prim: INTEGER :011531C391609C77DEBF0CDED0B8E551A1945A13E6D67DCAF3F1E8925BEF9A09BBCAA54AA252B37DB2D4299D064A09EDB8683896A83E1B987B8F40B753F52D74A0F4 + // 71:d=1 hl=2 l= 66 prim: INTEGER :F1BCE5589620618A700C8738EF95BA3F4E4D9D068672E51755E5E391C3EC6B1F262F74BFDBD7A080D4FB29B4C45BF7B91F2B59E28F26169B53F4803DB963990495 + // see sun.security.util.ECUtil.encodeSignature + // see sun.security.ec.ECDSASignature.p1363Format + byte[] signature = signer.sign(); + byte[] jwsSignature = ECDSA.transcodeSignatureToConcat( + signature, + ECDSA.getSignatureByteArrayLength(JWSAlgorithm.ES512) + ); + + assertTrue(new ECDSAVerifier((ECKey) jwk).verify(new JWSHeader(JWSAlgorithm.ES512), data, Base64URL.encode(jwsSignature))); + assertThrows(JOSEException.class, () -> new ECDSAVerifier((ECKey) jwk).verify(new JWSHeader(JWSAlgorithm.ES384), data, Base64URL.encode(jwsSignature))); + assertThrows(JOSEException.class, () -> new ECDSAVerifier((ECKey) jwk).verify(new JWSHeader(JWSAlgorithm.RS256), data, Base64URL.encode(jwsSignature))); + + // sign with Java Crypto - less obvious + signer = Signature.getInstance("SHA512withECDSAinP1363Format"); + byte[] data2 = new byte[32]; + new SecureRandom().nextBytes(data2); + signer.initSign(ecPrivateKey); + signer.update(data2); + // raw format this time + byte[] signature2 = signer.sign(); + + assertTrue(new ECDSAVerifier((ECKey) jwk).verify(new JWSHeader(JWSAlgorithm.ES512), data2, Base64URL.encode(signature2))); + assertThrows(JOSEException.class, () -> new ECDSAVerifier((ECKey) jwk).verify(new JWSHeader(JWSAlgorithm.ES384), data2, Base64URL.encode(signature2))); + assertThrows(JOSEException.class, () -> new ECDSAVerifier((ECKey) jwk).verify(new JWSHeader(JWSAlgorithm.RS256), data2, Base64URL.encode(signature2))); + } + + @Test + @SuppressWarnings("unchecked") + public void cachingKeys() throws IOException, InterruptedException, NoSuchAlgorithmException { + KeyPairGenerator rsaKpg = KeyPairGenerator.getInstance("RSA"); + rsaKpg.initialize(2048); + KeyPair rsaKeyPair = rsaKpg.generateKeyPair(); + RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) rsaKeyPair.getPrivate(); + RSAPublicKey rsaPublicKey = (RSAPublicKey) rsaKeyPair.getPublic(); + + RSAKey rsaPublicJWK = new RSAKey.Builder(rsaPublicKey).keyUse(KeyUse.SIGNATURE).keyID("k1").build(); + String rsaPublicJson = rsaPublicJWK.toJSONString(); + RSAKey rsaPublicJWK2 = new RSAKey.Builder(rsaPublicKey).keyUse(KeyUse.SIGNATURE).keyID("k2").build(); + String rsaPublicJson2 = rsaPublicJWK2.toJSONString(); + + final HttpResponse r = mock(HttpResponse.class); + when(r.statusCode()).thenReturn(200); + when(r.body()).thenReturn(new ByteArrayInputStream("{\"issuer\":\"http://localhost:8080\",\"jwks_uri\":\"http://localhost:8080/keys\"}".getBytes())); + final HttpResponse keys = mock(HttpResponse.class); + when(keys.statusCode()).thenReturn(200); + final int[] keyNumber = {1}; + when(keys.body()).thenAnswer(inv -> { + if (keyNumber[0] == 1) { + return "{\"keys\":[" + rsaPublicJson + "]}"; + } else { + return "{\"keys\":[" + rsaPublicJson2 + "]}"; + } + }); + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = anyHandler(); + when(client.send(req, res)).thenAnswer(inv -> { + HttpRequest argument = inv.getArgument(0); + return switch (argument.uri().toString()) { + case "http://localhost:8080/.well-known/openid-configuration" -> r; + case "http://localhost:8080/keys" -> keys; + default -> notFoundResponse; + }; + }); + + access = new SharedOIDCMetadataAccess(baseURI -> client, Map.of( + OIDCSupport.ConfigKey.CACHE_KEYS_TIME_SECONDS.getName(), "2" + ), true); + + OIDCMetadata metadata; + + metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertTrue(metadata.isValid()); + assertEquals(1, metadata.currentSecurityContext().getKeys().size()); + JWK jwk = metadata.currentSecurityContext().getKeys().get(0); + assertEquals("k1", jwk.getKeyID()); + + keyNumber[0] = 2; + + metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertTrue(metadata.isValid()); + assertEquals(1, metadata.currentSecurityContext().getKeys().size()); + jwk = metadata.currentSecurityContext().getKeys().get(0); + assertEquals("k1", jwk.getKeyID(), "Should get cached key"); + + Thread.sleep(2100); + + metadata = access.getMetadata(URI.create("http://localhost:8080")); + assertTrue(metadata.isValid()); + assertEquals(1, metadata.currentSecurityContext().getKeys().size()); + jwk = metadata.currentSecurityContext().getKeys().get(0); + assertEquals("k2", jwk.getKeyID(), "Should get refreshed key"); + } + +} diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadataTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadataTest.java new file mode 100644 index 00000000000..4245a697b34 --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCMetadataTest.java @@ -0,0 +1,59 @@ +/* + * 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.artemis.spi.core.security.jaas.oidc; + +import org.apache.activemq.artemis.api.core.JsonUtil; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link OIDCMetadata} assuming some proper JSON was retrieved from the provider. + */ +public class OIDCMetadataTest { + + @Test + public void missingIssuer() { + OIDCMetadata md = new OIDCMetadata(null, JsonUtil.readJsonObject("{}")); + assertFalse(md.isValid()); + assertFalse(md.isRecoverable()); + assertNull(md.getException()); + assertTrue(md.getErrorMessage().contains("OIDC Metadata issuer is missing")); + } + + @Test + public void differentIssuer() { + OIDCMetadata md = new OIDCMetadata("http://localhost:8080", JsonUtil.readJsonObject("{\"issuer\":\"http://localhost:8081\"}")); + assertFalse(md.isValid()); + assertFalse(md.isRecoverable()); + assertNull(md.getException()); + assertTrue(md.getErrorMessage().contains("OIDC Metadata issuer mismatch")); + } + + @Test + public void noJwksURI() { + OIDCMetadata md = new OIDCMetadata("http://localhost:8080", JsonUtil.readJsonObject("{\"issuer\":\"http://localhost:8080\"}")); + assertTrue(md.isValid()); + assertNull(md.getJwksURI()); + assertNull(md.getException()); + assertNull(md.getErrorMessage()); + assertTrue(md.currentSecurityContext().getKeys().isEmpty()); + } + +} diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java new file mode 100644 index 00000000000..5585f430573 --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java @@ -0,0 +1,148 @@ +/* + * 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.artemis.spi.core.security.jaas.oidc; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.util.Map; + +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for everything just one layer below full + * {@link org.apache.activemq.artemis.spi.core.security.jaas.OIDCLoginModule}. + */ +public class OIDCSupportTest { + + private static String rsaPublicJson; + private static String ecKeyJson1; + private static String ecKeyJson2; + + private HttpClient client; + private HttpResponse notFoundResponse; + private HttpResponse oidcMetadataResponse; + private HttpResponse keysResponse; + private OIDCMetadataAccess oidcMetadataAccess; + private HttpClientAccess httpClientAccess; + + @BeforeAll + public static void oneSetup() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { + KeyPairGenerator rsaKpg = KeyPairGenerator.getInstance("RSA"); + rsaKpg.initialize(2048); + KeyPair rsaKeyPair = rsaKpg.generateKeyPair(); + RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) rsaKeyPair.getPrivate(); + RSAPublicKey rsaPublicKey = (RSAPublicKey) rsaKeyPair.getPublic(); + RSAKey rsaPublicJWK = new RSAKey.Builder(rsaPublicKey).keyID("rsa-key").keyUse(KeyUse.SIGNATURE).build(); + rsaPublicJson = rsaPublicJWK.toJSONString(); + + KeyPairGenerator ecKpg1 = KeyPairGenerator.getInstance("EC"); + ecKpg1.initialize(new ECGenParameterSpec("secp521r1")); + KeyPair ecKeyPair1 = ecKpg1.generateKeyPair(); + ECPrivateKey ecPrivateKey1 = (ECPrivateKey) ecKeyPair1.getPrivate(); + ECPublicKey ecPublicKey1 = (ECPublicKey) ecKeyPair1.getPublic(); + ECKey ecPublicJWK1 = new ECKey.Builder(Curve.P_521, ecPublicKey1).keyID("ec-key521").keyUse(KeyUse.SIGNATURE).build(); + ecKeyJson1 = ecPublicJWK1.toJSONString(); + + KeyPairGenerator ecKpg2 = KeyPairGenerator.getInstance("EC"); + ecKpg2.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair ecKeyPair2 = ecKpg1.generateKeyPair(); + ECPrivateKey ecPrivateKey2 = (ECPrivateKey) ecKeyPair1.getPrivate(); + ECPublicKey ecPublicKey2 = (ECPublicKey) ecKeyPair1.getPublic(); + ECKey ecPublicJWK2 = new ECKey.Builder(Curve.P_521, ecPublicKey2).keyID("ec-key256").keyUse(KeyUse.SIGNATURE).build(); + ecKeyJson2 = ecPublicJWK2.toJSONString(); + } + + @BeforeEach + @SuppressWarnings("unchecked") + public void setup() throws IOException, InterruptedException { + client = mock(HttpClient.class); + + notFoundResponse = mock(HttpResponse.class); + when(notFoundResponse.statusCode()).thenReturn(404); + when(notFoundResponse.body()).thenReturn(new ByteArrayInputStream("Not Found".getBytes())); + + oidcMetadataResponse = Mockito.mock(HttpResponse.class); + when(oidcMetadataResponse.statusCode()).thenReturn(200); + when(oidcMetadataResponse.body()).thenReturn(new ByteArrayInputStream(""" + { + "issuer":"http://localhost", + "jwks_uri":"http://localhost/keys" + } + """.getBytes())); + + keysResponse = Mockito.mock(HttpResponse.class); + when(keysResponse.statusCode()).thenReturn(200); + String json = String.format("{\"keys\":[%s,%s,%s]}", rsaPublicJson, ecKeyJson1, ecKeyJson2); + when(keysResponse.body()).thenReturn(json); + + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = any(HttpResponse.BodyHandler.class); + when(client.send(req, res)).thenAnswer(inv -> { + HttpRequest r = inv.getArgument(0, HttpRequest.class); + return switch (r.uri().getPath()) { + case "/.well-known/openid-configuration" -> oidcMetadataResponse; + case "/keys" -> keysResponse; + default -> notFoundResponse; + }; + }); + + SharedOIDCMetadataAccess.cache.clear(); + + httpClientAccess = baseURI -> client; + oidcMetadataAccess = new SharedOIDCMetadataAccess(httpClientAccess, Map.of( + OIDCSupport.ConfigKey.PROVIDER_URL.getName(), "http://localhost", + OIDCSupport.ConfigKey.METADATA_RETRY_TIME_SECONDS.getName(), "10" + ), true); + } + + @Test + public void threeKeysAvailable() { + Map config = Map.of(OIDCSupport.ConfigKey.PROVIDER_URL.getName(), "http://localhost"); + OIDCSupport support = new OIDCSupport(config, true); + support.setHttpClientAccess(httpClientAccess); + support.setOidcMetadataAccess(oidcMetadataAccess); + support.initialize(); + + assertEquals(3, support.currentContext().getKeys().size()); + } + +} diff --git a/pom.xml b/pom.xml index 4c0ec233b19..bba8690306e 100644 --- a/pom.xml +++ b/pom.xml @@ -128,6 +128,7 @@ 3.9.5 4.4.1 3.0.0 + 10.8 From 128e0d73bee6194c72bb790881e24d4487c0d232 Mon Sep 17 00:00:00 2001 From: Grzegorz Grzybek Date: Mon, 16 Mar 2026 11:31:29 +0100 Subject: [PATCH 2/6] ARTEMIS-5200 Extracting principal identities/roles from JWT --- .../core/security/jaas/OIDCLoginModule.java | 92 +++++- .../core/security/jaas/oidc/OIDCSupport.java | 113 +++++++- .../security/jaas/OIDCLoginModuleTest.java | 269 +++++++++++++++++- .../security/jaas/oidc/OIDCSupportTest.java | 68 +++++ 4 files changed, 529 insertions(+), 13 deletions(-) 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 7374a086248..428463157a4 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 @@ -17,6 +17,9 @@ package org.apache.activemq.artemis.spi.core.security.jaas; import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.security.Principal; import java.text.ParseException; import java.util.Arrays; import java.util.Collections; @@ -102,10 +105,16 @@ public class OIDCLoginModule implements AuditLoginModule { private boolean debug; private OIDCSupport oidcSupport; - // Nimbus JOSE + JWT state from initialization + private Constructor userPrincipalConstructor; + private Constructor rolePrincipalConstructor; + + // Nimbus JOSE + JWT state and config from initialization private Subject subject; private CallbackHandler handler; + private String[] identityPaths; + private String[] rolesPaths; + // state for the authentication (between login and commit) private ConfigurableDefaultJWTProcessor processor = null; private String token; @@ -137,6 +146,9 @@ public void initialize(Subject subject, CallbackHandler callbackHandler, Map determinePrincipalConstructor(ConfigKey configKey, Map options) { + String principalClass = OIDCSupport.stringOption(configKey, options); + ClassLoader[] loaders = new ClassLoader[] { + OIDCSupport.class.getClassLoader(), + Thread.currentThread().getContextClassLoader() + }; + Constructor constructor = null; + for (ClassLoader classLoader : loaders) { + try { + Class cls = (Class) classLoader.loadClass(principalClass); + if (Principal.class.isAssignableFrom(cls)) { + constructor = cls.getConstructor(String.class); + break; + } + } catch (ClassNotFoundException | NoSuchMethodException ignore) { + } + } + + if (constructor == null) { + throw new IllegalArgumentException("Principal class not available, incorrect or missing 1-arg constructor: " + principalClass); + } + + return constructor; + } + /** * Extension of the only implementation of {@link com.nimbusds.jwt.proc.JWTProcessor}, so we can configure * it a bit. 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 d00d934661e..4e81286c1f2 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 @@ -19,11 +19,17 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.http.HttpClient; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import javax.security.auth.login.LoginContext; import com.nimbusds.jose.proc.JWKSecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; import org.apache.activemq.artemis.spi.core.security.jaas.OIDCLoginModule; +import org.apache.activemq.artemis.spi.core.security.jaas.RolePrincipal; +import org.apache.activemq.artemis.spi.core.security.jaas.UserPrincipal; +import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -149,6 +155,61 @@ public void initialize() { initializeOIDCMetadata(); } + // ---- Utilities for JSON access (could be extracted when needed) + + /** + * Handy utility method to extract String array values (single or multi element) from the "claim set" + * of a JWT token. Used after basic token validation - mostly to extract roles for fields like + * {@code realm_access.roles}. The extracted value should be a String or String array. + * + * @param claims {@link JWTClaimsSet} for a JWT + * @param path JSON path for extracting specific values from the token + * @return {@link JWTStringArray} with the extracted value and the flag for marking if the value was found + */ + public static JWTStringArray stringArrayForPath(JWTClaimsSet claims, String path) { + if (claims == null || claims.getClaims() == null || path == null || path.trim().isEmpty()) { + return new JWTStringArray(null, false); + } + + String[] segments = path.split("\\."); + Map current = claims.getClaims(); + for (int i = 0; i < segments.length; i++) { + String segment = segments[i]; + Object v = current.get(segment); + if (i < segments.length - 1) { + // not the last one - should be a map + if (!(v instanceof Map m)) { + return new JWTStringArray(null, false); + } + current = m; + } else { + // the last one + if (v instanceof String s) { + // single String is split by whitespace + return new JWTStringArray(createValidResult(s.trim()), true); + } else if (v instanceof List l) { + // but String array elements are not further split by whitespace + List result = new ArrayList<>(); + int j = 0; + for (Object v2 : l) { + if (v2 instanceof String s2) { + result.add(s2.trim()); + } else { + return new JWTStringArray(null, false); + } + } + return new JWTStringArray(result.toArray(String[]::new), true); + } + } + } + + return new JWTStringArray(null, false); + } + + private static String[] createValidResult(@NonNull String s) { + return s.split("\\s+"); + } + // ---- Utilities for option parsing private void initializeOIDCMetadata() { @@ -176,12 +237,27 @@ public JWKSecurityContext currentContext() { return jwkSecurityContext; } + /** + * Record for returning values from {@link #stringArrayForPath} with indication if the path was correct. + * @param value value extracted from JWT. Strings are converted to one-element String arrays. Strings with whitespace + * characters are first converted to multi-value tokens (but not Strings in actual arrays). + * Null values are always treated as invalid. + * @param value an extracted value (can be null) + * @param valid whether the JSON path successfully lead to actual value (String or String array) + */ + public record JWTStringArray(String[] value, boolean valid) { + } + public enum ConfigKey { // ---- Login module configuration // debug level for the login module DEBUG("debug", "false"), + // java.security.Principal implementation to be used for user identities + USER_CLASS("userPrincipalClass", UserPrincipal.class.getName()), + // java.security.Principal implementation to be used for user roles + ROLE_CLASS("rolePrincipalClass", RolePrincipal.class.getName()), // ---- OIDC configuration (including HTTP Client config to access it) @@ -207,21 +283,42 @@ public enum ConfigKey { // time skew in seconds for nbf/exp validation // see com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier.maxClockSkew MAX_CLOCK_SKEW_SECONDS("maxClockSkew", "60"), - // comma-separated required audience ("aud" string/string[] claim) + // comma-separated required/expected audience ("aud" string/string[] claim) AUDIENCE("audience", null), - // "json path" to a field (could be nested using "." separator, but no complex array navigation. + // 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 // "sub": from "basic" client scope -> "Subject (sub)" mapper // "client_id": from "service_account" scope -> "User Session Note" mapper, "client_id" User Session Note // only for grant_type=client_credentials // "azp": hardcoded in org.keycloak.protocol.oidc.TokenManager#initToken() - PATH_SUBJECT("pathSubject", "sub"), - // "roles" scope - // - "aud" - "Audience Resolve" mapper - // - "realm_access.roles" - "User Realm Role" mapper - // - "resource_access.${client_id}.roles" - "User Client Role" mapper - PATH_ROLES("pathRoles", null); + // + // each value referred will be added as JAAS subject "user" principal + IDENTITY_PATHS("identityPaths", "sub"), + // comma-separated "json paths" to JWT fields representing "roles" of the caller (subject of the JWT token). + // In Keycloak we have "roles" scope with 3 mappers adding these 3 claims: + // "aud" - "Audience Resolve" mapper: + // Adds all client_ids of "allowed" clients to the audience field of the token. Allowed client means the client + // for which user has at least one client role + // This is an "indirect" role (user -> realm/client permissions -> actual clients) representing a client + // for which the subject has permissions + // "aud" - "Audience" mapper (alternative/complementary to the above): + // Add specified audience to the audience (aud) field of token + // When this mapper is added to a custom scope and this scope is added (explicitly or implicitly) + // we can use one of the: + // - "Included Client Audience" - refer to different client within Keycloak's realm + // - "Included Custom Audience" - just specify a value for "aud" which will be used/added to "aud" claim + // "realm_access.roles" - "User Realm Role" mapper + // "resource_access.${client_id}.roles" - "User Client Role" mapper + // We could also use: + // "scope" - whitespace-separated "scopes" which some OpenID Connect providers may interpret as roles/permissions. + // this claim most probably contain "openid profile email" _scopes_, but can also include other values. + // For example in Keycloak we can set "Include in token scope" option for each Client Scope + // + // There's no default, because JWT should be used to identify a subject and the roles may be loaded by + // different login module (like LDAP) + // each value referred will be added as JAAS subject "role" principal + ROLES_PATHS("rolesPaths", null); private final String name; private final String defaultValue; 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 75c8354e011..02cc1c6b9e4 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 @@ -21,6 +21,7 @@ import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; +import java.security.Principal; import java.security.interfaces.ECPrivateKey; import java.security.interfaces.ECPublicKey; import java.security.interfaces.RSAPublicKey; @@ -31,9 +32,11 @@ 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.login.LoginException; @@ -66,6 +69,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -500,9 +504,272 @@ public JWKSecurityContext currentContext() { assertTrue(lm.login()); assertTrue(lm.commit()); -// assertFalse(subject.getPrincipals().isEmpty()); + // only "sub" (default) configured as identity path + assertEquals(1, subject.getPrincipals().size()); + assertEquals("Alice", subject.getPrincipals().iterator().next().getName()); assertTrue(subject.getPublicCredentials().isEmpty()); assertFalse(subject.getPrivateCredentials().isEmpty()); } + @Test + public void tokenPrincipals() 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.IDENTITY_PATHS.getName(), "sub, azp", + OIDCSupport.ConfigKey.ROLES_PATHS.getName(), "realm_access.roles, groups, scope" + ); + + 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(List.of("me-the-broker", "some-other-api")) + .claim("sub", uuid) + .claim("azp", "artemis-oidc-client") + .claim("scope", "openid profile") + .claim("groups", List.of("admin", "viewer")) + .claim("realm_access", Map.of("roles", List.of("admin", "important observer \t"))) + .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(subject.getPrincipals().isEmpty()); + assertTrue(subject.getPublicCredentials().isEmpty()); + assertTrue(subject.getPrivateCredentials().isEmpty()); + + assertTrue(lm.login()); + + // still empty + assertTrue(subject.getPrincipals().isEmpty()); + assertTrue(subject.getPublicCredentials().isEmpty()); + assertTrue(subject.getPrivateCredentials().isEmpty()); + + assertTrue(lm.commit()); + + // should get principals for "users" (identities) and "roles" + Set principals = subject.getPrincipals(); + assertEquals(7, principals.size()); + Set identities = new HashSet<>(Set.of("artemis-oidc-client", uuid)); + Set roles = new HashSet<>(Set.of("admin", "viewer", "important observer", "openid", "profile")); + 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"); + 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.IDENTITY_PATHS.getName(), "xxx" + ); + + 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(List.of("me-the-broker", "some-other-api")) + .claim("sub", uuid) + .claim("azp", "artemis-oidc-client") + .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()); + + assertThrows(LoginException.class, lm::commit); + } + + @Test + public void customPrincipalClasses() 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.IDENTITY_PATHS.getName(), "sub", + OIDCSupport.ConfigKey.ROLES_PATHS.getName(), "roles", + OIDCSupport.ConfigKey.USER_CLASS.getName(), MyUserPrincipal.class.getName(), + OIDCSupport.ConfigKey.ROLE_CLASS.getName(), MyRolePrincipal.class.getName() + ); + + OIDCLoginModule lm = new OIDCLoginModule(); + lm.setOidcSupport(new OIDCSupport(config, true) { + @Override + public JWKSecurityContext currentContext() { + return new JWKSecurityContext(keys); + } + }); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("http://localhost") + .subject("Alice") + .audience(List.of("me-the-broker", "some-other-api")) + .claim("sub", "me") + .claim("roles", "admin") + .claim("azp", "artemis-oidc-client") + .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 identities = subject.getPrincipals(MyUserPrincipal.class); + Set roles = subject.getPrincipals(MyRolePrincipal.class); + assertEquals(1, identities.size()); + assertEquals(1, roles.size()); + assertEquals("me", identities.iterator().next().getName()); + assertEquals("admin", roles.iterator().next().getName()); + } + + @Test + public void principalClassWithWrongConstructor() { + Map config = configMap( + OIDCSupport.ConfigKey.DEBUG.getName(), "true", + OIDCSupport.ConfigKey.USER_CLASS.getName(), MyPrivatePrincipal.class.getName() + ); + + OIDCLoginModule lm = new OIDCLoginModule(); + lm.setOidcSupport(new OIDCSupport(config, true) { + @Override + public JWKSecurityContext currentContext() { + return null; + } + }); + + assertThrows(IllegalArgumentException.class, () -> lm.initialize(new Subject(), new JaasCallbackHandler(null, null, null), null, config)); + } + + @Test + public void principalClassWithWrongSuperInterface() { + Map config = configMap( + OIDCSupport.ConfigKey.DEBUG.getName(), "true", + OIDCSupport.ConfigKey.USER_CLASS.getName(), MyNonPrincipal.class.getName() + ); + + OIDCLoginModule lm = new OIDCLoginModule(); + lm.setOidcSupport(new OIDCSupport(config, true) { + @Override + public JWKSecurityContext currentContext() { + return null; + } + }); + + assertThrows(IllegalArgumentException.class, () -> lm.initialize(new Subject(), new JaasCallbackHandler(null, null, null), null, config)); + } + + public static class MyUserPrincipal implements Principal { + private final String name; + + public MyUserPrincipal(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + } + + public static class MyRolePrincipal implements Principal { + private final String name; + + public MyRolePrincipal(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + } + + public static class MyPrivatePrincipal implements Principal { + private final String name; + + private MyPrivatePrincipal(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + } + + public static class MyNonPrincipal { + private final String name; + + public MyNonPrincipal(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + } diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java index 5585f430573..d8d9c11e71d 100644 --- a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java @@ -31,18 +31,25 @@ import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.ECGenParameterSpec; +import java.util.Collections; +import java.util.List; import java.util.Map; import com.nimbusds.jose.jwk.Curve; import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -145,4 +152,65 @@ public void threeKeysAvailable() { assertEquals(3, support.currentContext().getKeys().size()); } + @Test + public void defaultConfigs() { + assertArrayEquals(new String[] {"sub"}, OIDCSupport.stringArrayOption(OIDCSupport.ConfigKey.IDENTITY_PATHS, Collections.emptyMap())); + assertNull(OIDCSupport.stringArrayOption(OIDCSupport.ConfigKey.ROLES_PATHS, Collections.emptyMap())); + } + + @Test + public void changedConfigs() { + assertArrayEquals(new String[] {"sub", "preferred_username"}, OIDCSupport.stringArrayOption(OIDCSupport.ConfigKey.IDENTITY_PATHS, Map.of("subjectPaths", "sub, preferred_username"))); + assertArrayEquals(new String[] {"realm_access.roles"}, OIDCSupport.stringArrayOption(OIDCSupport.ConfigKey.ROLES_PATHS, Map.of("rolesPaths", "realm_access.roles"))); + } + + @Test + public void jsonPathsIntoClaims() { + JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder(); + builder.issuer("Keycloak NG"); + // string value + builder.claim("sub", "some-uuid-value"); + // explicit null value + builder.claim("azp", null); + // array of strings + builder.claim("roles", List.of("admin", "viewer")); + // nested whitespace separated values in a string + builder.claim("realm_access", Map.of("roles", "admin viewer\tobserver")); + // scope which could be configured like this in Keycloak with "Include in token scope" option in Client Scope config + builder.claim("scope", "openid profile email artemis-2.47.2"); + // deeply nested as in Keycloak + builder.claim("resource_access", Map.of("account", Map.of("roles", List.of(" admin", "viewer", "some other role ")))); + JWTClaimsSet set = builder.build(); + + OIDCSupport.JWTStringArray v; + + v = OIDCSupport.stringArrayForPath(set, "sub"); + assertArrayEquals(new String[] {"some-uuid-value"}, v.value()); + assertTrue(v.valid()); + + v = OIDCSupport.stringArrayForPath(set, "azp"); + assertNull(v.value()); + assertFalse(v.valid()); + + v = OIDCSupport.stringArrayForPath(set, "roles"); + assertArrayEquals(new String[] {"admin", "viewer"}, v.value()); + assertTrue(v.valid()); + + v = OIDCSupport.stringArrayForPath(set, "realm_access.roles"); + assertArrayEquals(new String[] {"admin", "viewer", "observer"}, v.value()); + assertTrue(v.valid()); + + v = OIDCSupport.stringArrayForPath(set, "scope"); + assertArrayEquals(new String[] {"openid", "profile", "email", "artemis-2.47.2"}, v.value()); + assertTrue(v.valid()); + + v = OIDCSupport.stringArrayForPath(set, "resource_access.account.roles"); + assertArrayEquals(new String[] {"admin", "viewer", "some other role"}, v.value()); + assertTrue(v.valid()); + + v = OIDCSupport.stringArrayForPath(set, "resource_access.account"); + assertNull(v.value()); + assertFalse(v.valid()); + } + } From 2afa5449368ab4e823eb65b55983057c1cf64d05 Mon Sep 17 00:00:00 2001 From: Grzegorz Grzybek Date: Tue, 17 Mar 2026 11:14:50 +0100 Subject: [PATCH 3/6] ARTEMIS-5200 Add logging information and signature tests --- .../core/security/jaas/OIDCLoginModule.java | 161 ++++++++++----- .../jaas/oidc/SharedOIDCMetadataAccess.java | 3 +- .../security/jaas/OIDCLoginModuleTest.java | 192 ++++++++++++++---- .../security/jaas/oidc/OIDCSupportTest.java | 2 +- 4 files changed, 267 insertions(+), 91 deletions(-) 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 428463157a4..271d4a4d510 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 @@ -16,22 +16,6 @@ */ package org.apache.activemq.artemis.spi.core.security.jaas; -import java.io.IOException; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; -import java.security.Principal; -import java.text.ParseException; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import javax.security.auth.Subject; -import javax.security.auth.callback.Callback; -import javax.security.auth.callback.CallbackHandler; -import javax.security.auth.callback.UnsupportedCallbackException; -import javax.security.auth.login.LoginException; - import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.source.JWKSecurityContextJWKSet; @@ -46,12 +30,30 @@ import com.nimbusds.jwt.PlainJWT; import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import com.nimbusds.jwt.proc.JWTProcessor; import org.apache.activemq.artemis.spi.core.security.jaas.oidc.OIDCSupport; import org.apache.activemq.artemis.spi.core.security.jaas.oidc.OIDCSupport.ConfigKey; -import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.security.Principal; +import java.text.ParseException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + public class OIDCLoginModule implements AuditLoginModule { public static final Logger logger = LoggerFactory.getLogger(OIDCLoginModule.class); @@ -91,37 +93,41 @@ public class OIDCLoginModule implements AuditLoginModule { // options from the configuration - static { - // don't add JWSAlgorithm.Family.HMAC_SHA - these are for symmetric keys - supportedJWSAlgorithms.addAll(JWSAlgorithm.Family.RSA); - supportedJWSAlgorithms.addAll(JWSAlgorithm.Family.EC); - - jwsKeySelector = new JWSVerificationKeySelector<>(supportedJWSAlgorithms, new JWKSecurityContextJWKSet()); - } - - private final Set requiredClaims; + private boolean debug; // JAAS state from initialization - private boolean debug; + private OIDCSupport oidcSupport; private Constructor userPrincipalConstructor; private Constructor rolePrincipalConstructor; // Nimbus JOSE + JWT state and config from initialization + private Subject subject; private CallbackHandler handler; + private ConfigurableDefaultJWTProcessor processor = null; + + private final Set requiredClaims; private String[] identityPaths; private String[] rolesPaths; - // state for the authentication (between login and commit) - private ConfigurableDefaultJWTProcessor processor = null; + // state for the authentication (between login() and commit()) + private String token; private JWT jwt; + static { + // don't add JWSAlgorithm.Family.HMAC_SHA - these are for symmetric keys + supportedJWSAlgorithms.addAll(JWSAlgorithm.Family.RSA); + supportedJWSAlgorithms.addAll(JWSAlgorithm.Family.EC); + + jwsKeySelector = new JWSVerificationKeySelector<>(supportedJWSAlgorithms, new JWKSecurityContextJWKSet()); + } + /** - * Public constructor to be used by {@link javax.security.auth.login.LoginContext} + * Public constructor to be used by {@link LoginContext} */ public OIDCLoginModule() { this.requiredClaims = defaultRequiredClaims; @@ -181,6 +187,7 @@ public boolean login() throws LoginException { if (handler == null) { throw new LoginException("No callback handler available to retrieve the JWT token"); } + JWT jwt = null; try { JwtCallback jwtCallback = new JwtCallback(); handler.handle(new Callback[]{jwtCallback}); @@ -193,20 +200,37 @@ public boolean login() throws LoginException { return false; } - this.jwt = parseAndValidateToken(token); + // first parse + jwt = JWTParser.parse(token); + + // then validate + validateToken(jwt); + + // keep only if parsed & validated this.token = token; + this.jwt = jwt; + + if (debug) { + logger.debug("JAAS login successful for JWT token with {}", findTokenIdentifier(jwt)); + } return true; } catch (IOException | UnsupportedCallbackException e) { - throw new LoginException("Can't obtain the JWT token"); + throw new LoginException("Can't obtain the JWT token: " + e.getMessage()); } catch (ParseException e) { // invalid token - base64 error, JSON error or similar - logger.error("JWT parsing error", e); - throw new RuntimeException(e); + if (debug) { + logger.error("JWT parsing error: {}", e.getMessage()); + } + throw new LoginException("JWT parsing error: " + e.getMessage()); } catch (BadJOSEException | JOSEException e) { + String ref = findTokenIdentifier(jwt); + String msg = e.getMessage() + (ref == null ? "" : " (" + ref + ")"); // invalid token - for example decryption error or claim validation error - logger.error("JWT processing error: {}", e.getMessage()); - throw new RuntimeException(e); + if (debug) { + logger.error("JWT processing error: {}", msg); + } + throw new LoginException("JWT processing error: " + msg); } } @@ -220,26 +244,36 @@ public boolean commit() throws LoginException { this.subject.getPrivateCredentials().add(token); if (identityPaths != null) { + Set userPrincipalNames = new LinkedHashSet<>(); for (String identityPath : this.identityPaths) { OIDCSupport.JWTStringArray users = OIDCSupport.stringArrayForPath(claims, identityPath); if (!users.valid()) { throw new LoginException("Can't determine user identity from JWT using \"" + identityPath + "\" path"); } - for (String v : users.value()) { - this.subject.getPrincipals().add(userPrincipalConstructor.newInstance(v)); - } + userPrincipalNames.addAll(Arrays.asList(users.value())); + } + if (debug) { + logger.debug("Found identities: {}", String.join(", ", userPrincipalNames)); + } + for (String n : userPrincipalNames) { + this.subject.getPrincipals().add(userPrincipalConstructor.newInstance(n)); } } if (rolesPaths != null) { + Set rolePrincipalNames = new LinkedHashSet<>(); for (String rolePath : this.rolesPaths) { - OIDCSupport.JWTStringArray users = OIDCSupport.stringArrayForPath(claims, rolePath); - if (!users.valid()) { + OIDCSupport.JWTStringArray roles = OIDCSupport.stringArrayForPath(claims, rolePath); + if (!roles.valid()) { throw new LoginException("Can't determine user role from JWT using \"" + rolePath + "\" path"); } - for (String v : users.value()) { - this.subject.getPrincipals().add(rolePrincipalConstructor.newInstance(v)); - } + rolePrincipalNames.addAll(Arrays.asList(roles.value())); + } + if (debug) { + logger.debug("Found roles: {}", String.join(", ", rolePrincipalNames)); + } + for (String n : rolePrincipalNames) { + this.subject.getPrincipals().add(rolePrincipalConstructor.newInstance(n)); } } @@ -264,9 +298,7 @@ public boolean logout() throws LoginException { return this.jwt != null; } - @NonNull - JWT parseAndValidateToken(String token) throws ParseException, BadJOSEException, JOSEException { - JWT jwt = JWTParser.parse(token); + void validateToken(JWT jwt) throws BadJOSEException, JOSEException { // See https://www.iana.org/assignments/jwt/jwt.xhtml#claims for known claims // Which claims do we want? @@ -288,8 +320,6 @@ JWT parseAndValidateToken(String token) throws ParseException, BadJOSEException, // - cnf/x5t#S256 - Certificate Thumbprint - https://datatracker.ietf.org/doc/html/rfc8705#section-3.1 processor.process(jwt, oidcSupport.currentContext()); - - return jwt; } /** @@ -334,8 +364,39 @@ private Constructor determinePrincipalConstructor(ConfigKey configKey return constructor; } + private static final String[] idClaims = new String[] {"jti", "sid", "iat", "sub"}; + + /** + * Having parsed, but not necessarily validated token, this method returns some String reference to identify + * the token for logging purpose + * @param jwt token to investigate + * @return some information from the token for logging purpose + */ + private String findTokenIdentifier(JWT jwt) { + try { + JWTClaimsSet claims = jwt == null ? null : jwt.getJWTClaimsSet(); + if (claims != null) { + for (String claim : idClaims) { + Object v = claims.getClaim(claim); + if (v != null) { + if (v instanceof String s) { + return claim + "=" + s; + } + if (v instanceof Number n) { + return claim + "=" + n.longValue(); + } + } + } + return null; + } + return null; + } catch (ParseException ignored) { + return null; + } + } + /** - * Extension of the only implementation of {@link com.nimbusds.jwt.proc.JWTProcessor}, so we can configure + * Extension of the only implementation of {@link JWTProcessor}, so we can configure * it a bit. */ static class ConfigurableDefaultJWTProcessor extends DefaultJWTProcessor { diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedOIDCMetadataAccess.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedOIDCMetadataAccess.java index 065dbfd1375..ee0e1784e33 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedOIDCMetadataAccess.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedOIDCMetadataAccess.java @@ -124,7 +124,8 @@ private OIDCMetadata fetchOIDCMetadata(URI baseURI) { } catch (RuntimeException e) { // hmm, we explicitly do not have access to javax.json library (optional in artemis-commons) // so we have to be clever here - if (e.getClass().getName().startsWith("javax.json")) { + if (e.getClass().getName().startsWith("javax.json") + || e.getClass().getName().startsWith("org.apache.activemq.artemis.commons.shaded.json")) { // we can assume it's a parsing exception result = new OIDCMetadata(expectedIssuer, null, false).withErrorMessage("OIDC metadata invalid - JSON error"); } else { 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 02cc1c6b9e4..b97577ef7db 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 @@ -16,30 +16,6 @@ */ package org.apache.activemq.artemis.spi.core.security.jaas; -import java.lang.reflect.Field; -import java.security.InvalidAlgorithmParameterException; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.Principal; -import java.security.interfaces.ECPrivateKey; -import java.security.interfaces.ECPublicKey; -import java.security.interfaces.RSAPublicKey; -import java.security.spec.ECGenParameterSpec; -import java.text.ParseException; -import java.util.ArrayList; -import java.util.Base64; -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.login.LoginException; - import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSHeader; @@ -57,15 +33,46 @@ import com.nimbusds.jose.util.Base64URL; import com.nimbusds.jwt.JWT; import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTParser; import com.nimbusds.jwt.PlainJWT; import com.nimbusds.jwt.SignedJWT; import com.nimbusds.jwt.proc.BadJWTException; import org.apache.activemq.artemis.spi.core.security.jaas.oidc.OIDCSupport; import org.apache.activemq.artemis.spi.core.security.jaas.oidc.SharedHttpClientAccess; import org.apache.activemq.artemis.spi.core.security.jaas.oidc.SharedOIDCMetadataAccess; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.config.Configuration; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import javax.security.auth.Subject; +import javax.security.auth.login.LoginException; +import java.lang.reflect.Field; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.Principal; +import java.security.SecureRandom; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Base64; +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 static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -89,6 +96,12 @@ private static Map configMap(String... entries) { return map; } + @BeforeAll + public static void setUpLogging() { + Configuration configuration = ((LoggerContext) LogManager.getContext(false)).getConfiguration(); + configuration.getLoggerConfig(OIDCLoginModuleTest.class.getName()).setLevel(Level.DEBUG); + } + @BeforeEach public void setUp() throws NoSuchFieldException, IllegalAccessException { Field f1 = SharedHttpClientAccess.class.getDeclaredField("cache"); @@ -130,7 +143,7 @@ public void emptyToken() throws BadJOSEException, JOSEException { OIDCLoginModule lm = new OIDCLoginModule(); Subject subject = new Subject(); try { - lm.parseAndValidateToken(""); + lm.validateToken(JWTParser.parse("")); } catch (ParseException e) { assertTrue(e.getMessage().contains("Missing dot delimiter")); } @@ -147,7 +160,7 @@ public void plainJWTWhenNotAllowed() throws ParseException, JOSEException { // https://datatracker.ietf.org/doc/html/rfc7519#section-6 - {"alg":"none"} String token = new PlainJWT(new JWTClaimsSet.Builder().build()).serialize(); try { - lm.parseAndValidateToken(token); + lm.validateToken(JWTParser.parse(token)); fail(); } catch (BadJOSEException e) { assertTrue(e.getMessage().contains("Unsecured (plain) JWTs are rejected")); @@ -166,7 +179,7 @@ public void plainJWTWhenAllowedWithCorrectDates() throws BadJOSEException, Parse .notBeforeTime(new Date(new Date().getTime() - 5000L)) .expirationTime(new Date(new Date().getTime() + 5000L)) .build()).serialize(); - lm.parseAndValidateToken(token); + lm.validateToken(JWTParser.parse(token)); } @Test @@ -179,27 +192,27 @@ public void plainJWTWithAndWithoutDates() throws BadJOSEException, ParseExceptio String token1 = new PlainJWT(new JWTClaimsSet.Builder() .build()).serialize(); - lm.parseAndValidateToken(token1); + lm.validateToken(JWTParser.parse(token1)); String token2 = new PlainJWT(new JWTClaimsSet.Builder() .notBeforeTime(new Date(new Date().getTime() - 5000L)) .build()).serialize(); - lm.parseAndValidateToken(token2); + lm.validateToken(JWTParser.parse(token2)); String token3 = new PlainJWT(new JWTClaimsSet.Builder() .expirationTime(new Date(new Date().getTime() + 5000L)) .build()).serialize(); - lm.parseAndValidateToken(token3); + lm.validateToken(JWTParser.parse(token3)); String token4 = new PlainJWT(new JWTClaimsSet.Builder() .notBeforeTime(new Date(new Date().getTime() - 5000L)) .expirationTime(new Date(new Date().getTime() + 5000L)) .build()).serialize(); - lm.parseAndValidateToken(token4); + lm.validateToken(JWTParser.parse(token4)); } @Test - public void plainJWTWithIncorrectDates() throws BadJOSEException, ParseException, JOSEException { + public void plainJWTWithIncorrectDates() throws BadJOSEException, JOSEException, ParseException { OIDCLoginModule lm = new OIDCLoginModule(NO_CLAIMS); Subject subject = new Subject(); lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap( @@ -212,7 +225,7 @@ public void plainJWTWithIncorrectDates() throws BadJOSEException, ParseException .expirationTime(new Date(new Date().getTime() + 6000L)) .build()).serialize(); try { - lm.parseAndValidateToken(tokenForFuture); + lm.validateToken(JWTParser.parse(tokenForFuture)); } catch (BadJWTException e) { assertTrue(e.getMessage().contains("JWT before use time")); } @@ -222,14 +235,14 @@ public void plainJWTWithIncorrectDates() throws BadJOSEException, ParseException .expirationTime(new Date(new Date().getTime() - 3000L)) .build()).serialize(); try { - lm.parseAndValidateToken(tokenForPast); + lm.validateToken(JWTParser.parse(tokenForPast)); } catch (BadJWTException e) { assertTrue(e.getMessage().contains("Expired JWT")); } } @Test - public void plainJWTWithIncorrectDatesButTolerated() throws BadJOSEException, ParseException, JOSEException { + public void plainJWTWithIncorrectDatesButTolerated() throws BadJOSEException, JOSEException, ParseException { OIDCLoginModule lm = new OIDCLoginModule(NO_CLAIMS); Subject subject = new Subject(); lm.initialize(subject, new JaasCallbackHandler(null, null, null), null, configMap( @@ -241,13 +254,13 @@ public void plainJWTWithIncorrectDatesButTolerated() throws BadJOSEException, Pa .notBeforeTime(new Date(new Date().getTime() + 3000L)) .expirationTime(new Date(new Date().getTime() + 6000L)) .build()).serialize(); - lm.parseAndValidateToken(tokenForFuture); + lm.validateToken(JWTParser.parse(tokenForFuture)); String tokenForPast = new PlainJWT(new JWTClaimsSet.Builder() .notBeforeTime(new Date(new Date().getTime() - 6000L)) .expirationTime(new Date(new Date().getTime() - 3000L)) .build()).serialize(); - lm.parseAndValidateToken(tokenForPast); + lm.validateToken(JWTParser.parse(tokenForPast)); } // ---- Signed tokens test - https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 @@ -335,7 +348,8 @@ public JWKSecurityContext currentContext() { return context; } }); - JWT jwt = lm.parseAndValidateToken(signedJWT.serialize()); + JWT jwt = JWTParser.parse(signedJWT.serialize()); + lm.validateToken(jwt); assertInstanceOf(SignedJWT.class, jwt); } } @@ -454,7 +468,8 @@ public JWKSecurityContext currentContext() { assertEquals(3, token.split("\\.").length, "Should contain header, payload and signature parts"); assertTrue(signedJWT.verify(new ECDSAVerifier((ECPublicKey) pairs[i].getPublic()))); - JWT jwt = lm.parseAndValidateToken(signedJWT.serialize()); + JWT jwt = JWTParser.parse(signedJWT.serialize()); + lm.validateToken(jwt); assertInstanceOf(SignedJWT.class, jwt); } } @@ -511,6 +526,105 @@ public JWKSecurityContext currentContext() { assertFalse(subject.getPrivateCredentials().isEmpty()); } + @Test + public void unknownKey() throws NoSuchAlgorithmException, JOSEException { + 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"); + + OIDCLoginModule lm = new OIDCLoginModule(); + lm.setOidcSupport(new OIDCSupport(config, true) { + @Override + public JWKSecurityContext currentContext() { + return new JWKSecurityContext(keys); + } + }); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("http://localhost") + .subject("Alice") + .audience(List.of("me-the-broker", "some-other-api")) + .claim("azp", "artemis-oidc-client") + .expirationTime(new Date(new Date().getTime() + 3_600_000)) + .build(); + SignedJWT signedJWT = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("k2").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); + + assertThrows(LoginException.class, lm::login); + } + + @Test + public void badSignature() throws NoSuchAlgorithmException, JOSEException { + 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"); + + OIDCLoginModule lm = new OIDCLoginModule(); + lm.setOidcSupport(new OIDCSupport(config, true) { + @Override + public JWKSecurityContext currentContext() { + return new JWKSecurityContext(keys); + } + }); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("http://localhost") + .subject("Alice") + .jwtID(UUID.randomUUID().toString()) + .audience(List.of("me-the-broker", "some-other-api")) + .claim("azp", "artemis-oidc-client") + .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(); + + byte[] newSignature = new byte[256]; + new SecureRandom().nextBytes(newSignature); + String[] split = token.split("\\."); + Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + token = split[0] + "." + split[1] + "." + encoder.encodeToString(newSignature); + + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, token, null), null, config); + + assertThrows(LoginException.class, lm::login); + } + + @Test + public void badJWT() { + Map config = configMap(OIDCSupport.ConfigKey.DEBUG.getName(), "true"); + + OIDCLoginModule lm = new OIDCLoginModule(); + lm.setOidcSupport(new OIDCSupport(config, true) { + @Override + public JWKSecurityContext currentContext() { + return new JWKSecurityContext(Collections.emptyList()); + } + }); + + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, "some bad JWT token", null), null, config); + + assertThrows(LoginException.class, lm::login); + } + @Test public void tokenPrincipals() throws NoSuchAlgorithmException, JOSEException, LoginException { KeyPairGenerator kpgRSA = KeyPairGenerator.getInstance("RSA"); diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java index d8d9c11e71d..e06db247d6a 100644 --- a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java @@ -160,7 +160,7 @@ public void defaultConfigs() { @Test public void changedConfigs() { - assertArrayEquals(new String[] {"sub", "preferred_username"}, OIDCSupport.stringArrayOption(OIDCSupport.ConfigKey.IDENTITY_PATHS, Map.of("subjectPaths", "sub, preferred_username"))); + assertArrayEquals(new String[] {"sub", "preferred_username"}, OIDCSupport.stringArrayOption(OIDCSupport.ConfigKey.IDENTITY_PATHS, Map.of("identityPaths", "sub, preferred_username"))); assertArrayEquals(new String[] {"realm_access.roles"}, OIDCSupport.stringArrayOption(OIDCSupport.ConfigKey.ROLES_PATHS, Map.of("rolesPaths", "realm_access.roles"))); } From b25a0b21bfb2f0ded34fb59fafdb0dffcdaa02df Mon Sep 17 00:00:00 2001 From: Grzegorz Grzybek Date: Tue, 17 Mar 2026 16:55:34 +0100 Subject: [PATCH 4/6] ARTEMIS-5200 Implement RFC 8705 (OAuth2 + mTLS) --- .../core/security/jaas/OIDCLoginModule.java | 139 ++++++++++---- .../core/security/jaas/oidc/OIDCSupport.java | 96 +++++++++- .../security/jaas/OIDCLoginModuleTest.java | 180 +++++++++++++++++- .../security/jaas/oidc/OIDCSupportTest.java | 102 ++++++++-- 4 files changed, 467 insertions(+), 50 deletions(-) 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 271d4a4d510..0fb70d576fe 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 @@ -46,6 +46,7 @@ import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.security.Principal; +import java.security.cert.X509Certificate; import java.text.ParseException; import java.util.Arrays; import java.util.Collections; @@ -93,13 +94,27 @@ public class OIDCLoginModule implements AuditLoginModule { // options from the configuration + /** + * Well known {@code debug} flag for the login module + */ private boolean debug; // JAAS state from initialization + /** + * Helper object instantiated in each {@link #initialize} to support with the OpenID Connect/OAuth2 login + * process according to JAAS lifecycle + */ private OIDCSupport oidcSupport; + /** + * Discovered constructor to create instances of {@link Principal} representing user "identities" + */ private Constructor userPrincipalConstructor; + + /** + * Discovered constructor to create instances of {@link Principal} representing user "roles" (or "groups") + */ private Constructor rolePrincipalConstructor; // Nimbus JOSE + JWT state and config from initialization @@ -107,15 +122,49 @@ public class OIDCLoginModule implements AuditLoginModule { private Subject subject; private CallbackHandler handler; + /** + * {@link JWTProcessor} created for each login process reusing some "services" for key and claim management + */ private ConfigurableDefaultJWTProcessor processor = null; + /** + * 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; + + /** + * "JSON paths" to claims (possibly nested) which should point to JSON strings or JSON string arrays, which + * contain user "identities" + */ private String[] identityPaths; + + /** + * "JSON paths" to claims (possibly nested) which should point to JSON strings or JSON string arrays, which + * contain user "roles" (or "groups") + */ private String[] rolesPaths; + /** + *

Flag which turns on OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens + * (RFC 8705).

+ *

{@code cnf} claim itself comes from RFC 7800 (Proof-of-Possession Key Semantics for JSON Web Tokens (JWTs)) + * and represents a proof that the token was issued for the actual sender (and was not stolen). {@code x5t#256} + * is a specific type of proof from RFC 7515 (JSON Web Signature (JWS)) and represents an SHA-256 digest + * of DER encoded certificate ("x5" = X.509, "t" = thumbprint).

+ */ + private boolean requireOAuth2MTLS; + // state for the authentication (between login() and commit()) + /** + * The JWT token as it arrived by the wire - which is dot-separated JTW {@code header.claims.signature}. + */ private String token; + + /** + * The actual parsed (if it can be parsed) {@link JWT} to be processed during login + */ private JWT jwt; static { @@ -155,6 +204,8 @@ public void initialize(Subject subject, CallbackHandler callbackHandler, Map 0) { + msg = "OAuth2 mTLS failed - certificate from the transport layer doesn't match cnf/x5t#256 thumbprint" + + (ref == null ? "" : " (" + ref + ")"); + } + if (debug) { + logger.error(msg); + } + throw new LoginException(msg); + } + } + if (debug) { - logger.debug("JAAS login successful for JWT token with {}", findTokenIdentifier(jwt)); + if (requireOAuth2MTLS) { + logger.debug("JAAS login successful for JWT token with {} and X.509 thumbprint {}", + OIDCSupport.findTokenIdentifier(claims), + OIDCSupport.stringArrayForPath(claims, "cnf.x5t#256").value()[0]); + } else { + logger.debug("JAAS login successful for JWT token with {}", OIDCSupport.findTokenIdentifier(claims)); + } } return true; @@ -224,7 +310,7 @@ public boolean login() throws LoginException { } throw new LoginException("JWT parsing error: " + e.getMessage()); } catch (BadJOSEException | JOSEException e) { - String ref = findTokenIdentifier(jwt); + String ref = OIDCSupport.findTokenIdentifier(claims); String msg = e.getMessage() + (ref == null ? "" : " (" + ref + ")"); // invalid token - for example decryption error or claim validation error if (debug) { @@ -290,12 +376,22 @@ public boolean commit() throws LoginException { @Override public boolean abort() throws LoginException { - return this.jwt != null; + boolean result = this.jwt != null; + if (result) { + this.token = null; + this.jwt = null; + } + return result; } @Override public boolean logout() throws LoginException { - return this.jwt != null; + boolean result = this.jwt != null; + if (result) { + this.token = null; + this.jwt = null; + } + return result; } void validateToken(JWT jwt) throws BadJOSEException, JOSEException { @@ -364,37 +460,6 @@ private Constructor determinePrincipalConstructor(ConfigKey configKey return constructor; } - private static final String[] idClaims = new String[] {"jti", "sid", "iat", "sub"}; - - /** - * Having parsed, but not necessarily validated token, this method returns some String reference to identify - * the token for logging purpose - * @param jwt token to investigate - * @return some information from the token for logging purpose - */ - private String findTokenIdentifier(JWT jwt) { - try { - JWTClaimsSet claims = jwt == null ? null : jwt.getJWTClaimsSet(); - if (claims != null) { - for (String claim : idClaims) { - Object v = claims.getClaim(claim); - if (v != null) { - if (v instanceof String s) { - return claim + "=" + s; - } - if (v instanceof Number n) { - return claim + "=" + n.longValue(); - } - } - } - return null; - } - return null; - } catch (ParseException ignored) { - return null; - } - } - /** * Extension of the only implementation of {@link JWTProcessor}, so we can configure * it a bit. 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 4e81286c1f2..faaabb48b98 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 @@ -19,12 +19,18 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.http.HttpClient; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import java.util.Map; import javax.security.auth.login.LoginContext; import com.nimbusds.jose.proc.JWKSecurityContext; +import com.nimbusds.jwt.JWT; import com.nimbusds.jwt.JWTClaimsSet; import org.apache.activemq.artemis.spi.core.security.jaas.OIDCLoginModule; import org.apache.activemq.artemis.spi.core.security.jaas.RolePrincipal; @@ -43,6 +49,8 @@ public class OIDCSupport { public static final Logger LOG = LoggerFactory.getLogger(OIDCSupport.class); + private static final String[] idClaims = new String[] {"jti", "sid", "iat", "sub"}; + private final String providerURL; private final boolean debug; @@ -210,6 +218,74 @@ private static String[] createValidResult(@NonNull String s) { return s.split("\\s+"); } + /** + * Method which implements RFC 8705 OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens. + * The {@link JWT} should contain {@code cnf/x5t#256} field matching the SHA-256 of a certificate from the + * transport layer. + * + * @param peerCertificates X.509 certificate for (client) peer certificates + * @param claims {@link JWTClaimsSet} from the token which doesn't have to be fully validated (yet) + * @param debug verbose flag + * @return whether the {@code cnf/x5t#256} matches SHA256 digest of the peer certificate + */ + public static boolean tlsCertificateMatching(X509Certificate[] peerCertificates, JWTClaimsSet claims, boolean debug) { + if (claims == null || peerCertificates == null) { + return false; + } + + Object cnf = claims.getClaim("cnf"); + if (!(cnf instanceof Map confirmation)) { + return false; + } + Object v = confirmation.get("x5t#256"); + if (!(v instanceof String thumbprint)) { + return false; + } + + for (X509Certificate cert : peerCertificates) { + try { + MessageDigest md = MessageDigest.getInstance("SHA256"); + byte[] digest = md.digest(cert.getEncoded()); + String base64sha256 = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + return thumbprint.equals(base64sha256); + } catch (NoSuchAlgorithmException unexpected) { + return false; + } catch (CertificateEncodingException e) { + if (debug) { + String ref = OIDCSupport.findTokenIdentifier(claims); + LOG.warn("OAuth2 mTLS failed - can't get encoded X.509 certificate{}", (ref == null ? "" : " (" + ref + ")")); + } + return false; + } + } + + return false; + } + + /** + * Having parsed, but not necessarily validated token, this method returns some String reference to identify + * the token for logging purpose + * @param claims token claims to investigate + * @return some information from the token for logging purpose + */ + public static String findTokenIdentifier(JWTClaimsSet claims) { + if (claims != null) { + for (String claim : idClaims) { + Object v = claims.getClaim(claim); + if (v != null) { + if (v instanceof String s) { + return claim + "=" + s; + } + if (v instanceof Number n) { + return claim + "=" + n.longValue(); + } + } + } + return null; + } + return null; + } + // ---- Utilities for option parsing private void initializeOIDCMetadata() { @@ -254,8 +330,10 @@ public enum ConfigKey { // debug level for the login module DEBUG("debug", "false"), + // java.security.Principal implementation to be used for user identities USER_CLASS("userPrincipalClass", UserPrincipal.class.getName()), + // java.security.Principal implementation to be used for user roles ROLE_CLASS("rolePrincipalClass", RolePrincipal.class.getName()), @@ -263,16 +341,21 @@ public enum ConfigKey { // the provider URL - something we can append /.well-known/openid-configuration to PROVIDER_URL("provider", null), + // time in seconds for caching they keys from the keys endpoint of the provider CACHE_KEYS_TIME_SECONDS("cacheKeysSeconds", "3600"), + // time in seconds to wait before fetching /.well-known/openid-configuration again in case of errors. // When initial fetch was successful, there won't be any reattempted fetching (keys are still refetched // periodically) METADATA_RETRY_TIME_SECONDS("metadataRetrySeconds", "30"), + // TLS version to use with http client when using https protocol TLS_VERSION("tlsVersion", "TLSv1.3"), + // CA certificate (PEM (single or multiple) or DER format, X.509) for building TLS context for HTTP Client CA_CERTIFICATE("caCertificate", null), + // connection & read timeout for HTTP Client HTTP_TIMEOUT_MILLISECONDS("httpTimeout", "5000"), @@ -280,11 +363,14 @@ public enum ConfigKey { // whether plain JWTs are allowed ({"alg":"none"}) ALLOW_PLAIN_JWT("allowPlainJWT", "false"), + // time skew in seconds for nbf/exp validation // see com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier.maxClockSkew MAX_CLOCK_SKEW_SECONDS("maxClockSkew", "60"), + // comma-separated required/expected audience ("aud" string/string[] claim) AUDIENCE("audience", null), + // 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 @@ -295,6 +381,7 @@ public enum ConfigKey { // // each value referred will be added as JAAS subject "user" principal IDENTITY_PATHS("identityPaths", "sub"), + // comma-separated "json paths" to JWT fields representing "roles" of the caller (subject of the JWT token). // In Keycloak we have "roles" scope with 3 mappers adding these 3 claims: // "aud" - "Audience Resolve" mapper: @@ -318,7 +405,14 @@ public enum ConfigKey { // There's no default, because JWT should be used to identify a subject and the roles may be loaded by // different login module (like LDAP) // each value referred will be added as JAAS subject "role" principal - ROLES_PATHS("rolesPaths", null); + ROLES_PATHS("rolesPaths", 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 + // org.apache.activemq.artemis.spi.core.security.jaas.CertificateLoginModule - but this module is not + // required as a prerequisite of OIDCLoginModule, as it doesn't put the certificate as "public credential") + REQUIRE_OAUTH_MTLS("requireOAuth2MTLS", "false"); private final String name; private final String defaultValue; 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 b97577ef7db..998102cb33a 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 @@ -37,6 +37,9 @@ import com.nimbusds.jwt.PlainJWT; import com.nimbusds.jwt.SignedJWT; import com.nimbusds.jwt.proc.BadJWTException; +import org.apache.activemq.artemis.core.remoting.impl.netty.NettyServerConnection; +import org.apache.activemq.artemis.core.security.jaas.StubX509Certificate; +import org.apache.activemq.artemis.spi.core.protocol.RemotingConnection; import org.apache.activemq.artemis.spi.core.security.jaas.oidc.OIDCSupport; import org.apache.activemq.artemis.spi.core.security.jaas.oidc.SharedHttpClientAccess; import org.apache.activemq.artemis.spi.core.security.jaas.oidc.SharedOIDCMetadataAccess; @@ -54,9 +57,11 @@ import java.security.InvalidAlgorithmParameterException; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.Principal; import java.security.SecureRandom; +import java.security.cert.X509Certificate; import java.security.interfaces.ECPrivateKey; import java.security.interfaces.ECPublicKey; import java.security.interfaces.RSAPublicKey; @@ -79,12 +84,14 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class OIDCLoginModuleTest { public static final Set NO_CLAIMS = Collections.emptySet(); - private static Map configMap(String... entries) { + public static Map configMap(String... entries) { if (entries.length % 2 != 0) { throw new IllegalArgumentException("Should contain even number of entries"); } @@ -607,6 +614,177 @@ public JWKSecurityContext currentContext() { assertThrows(LoginException.class, lm::login); } + @Test + public void badProofOfPossessionButNotChecked() 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"); + + OIDCLoginModule lm = new OIDCLoginModule(); + lm.setOidcSupport(new OIDCSupport(config, true) { + @Override + public JWKSecurityContext currentContext() { + return new JWKSecurityContext(keys); + } + }); + + StubX509Certificate cert = new StubX509Certificate(new UserPrincipal("Alice")) { + @Override + public byte[] getEncoded() { + // see for example org.keycloak.crypto.elytron.ElytronPEMUtilsProvider#encode() + return new byte[] {0x42, 0x2a}; + } + }; + + byte[] digest = MessageDigest.getInstance("SHA256").digest(new byte[] {0x2a, 0x42}); + String x5t = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("http://localhost") + .subject("Alice") + .jwtID(UUID.randomUUID().toString()) + .audience(List.of("me-the-broker", "some-other-api")) + .claim("azp", "artemis-oidc-client") + .claim("cnf", Map.of("x5t#256", x5t)) + .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(); + + RemotingConnection remotingConnection = mock(RemotingConnection.class); + NettyServerConnection nettyConnection = mock(NettyServerConnection.class); + when(remotingConnection.getTransportConnection()).thenReturn(nettyConnection); + when(nettyConnection.getPeerCertificates()).thenReturn(new X509Certificate[] {cert}); + + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, token, remotingConnection), null, config); + + assertTrue(lm.login()); + } + + @Test + public void badProofOfPossession() throws NoSuchAlgorithmException, JOSEException { + 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.REQUIRE_OAUTH_MTLS.getName(), "true" + ); + + OIDCLoginModule lm = new OIDCLoginModule(); + lm.setOidcSupport(new OIDCSupport(config, true) { + @Override + public JWKSecurityContext currentContext() { + return new JWKSecurityContext(keys); + } + }); + + StubX509Certificate cert = new StubX509Certificate(new UserPrincipal("Alice")) { + @Override + public byte[] getEncoded() { + // see for example org.keycloak.crypto.elytron.ElytronPEMUtilsProvider#encode() + return new byte[] {0x42, 0x2a}; + } + }; + + byte[] digest = MessageDigest.getInstance("SHA256").digest(new byte[] {0x2a, 0x42}); + String x5t = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("http://localhost") + .subject("Alice") + .jwtID(UUID.randomUUID().toString()) + .audience(List.of("me-the-broker", "some-other-api")) + .claim("azp", "artemis-oidc-client") + .claim("cnf", Map.of("x5t#256", x5t)) + .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(); + + RemotingConnection remotingConnection = mock(RemotingConnection.class); + NettyServerConnection nettyConnection = mock(NettyServerConnection.class); + when(remotingConnection.getTransportConnection()).thenReturn(nettyConnection); + when(nettyConnection.getPeerCertificates()).thenReturn(new X509Certificate[] {cert}); + + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, token, remotingConnection), null, config); + + assertThrows(LoginException.class, lm::login); + } + + @Test + public void correctProofOfPossession() 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.REQUIRE_OAUTH_MTLS.getName(), "true" + ); + + OIDCLoginModule lm = new OIDCLoginModule(); + lm.setOidcSupport(new OIDCSupport(config, true) { + @Override + public JWKSecurityContext currentContext() { + return new JWKSecurityContext(keys); + } + }); + + StubX509Certificate cert = new StubX509Certificate(new UserPrincipal("Alice")) { + @Override + public byte[] getEncoded() { + // see for example org.keycloak.crypto.elytron.ElytronPEMUtilsProvider#encode() + return new byte[] {0x42, 0x2a}; + } + }; + + byte[] digest = MessageDigest.getInstance("SHA256").digest(cert.getEncoded()); + String x5t = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("http://localhost") + .subject("Alice") + .jwtID(UUID.randomUUID().toString()) + .audience(List.of("me-the-broker", "some-other-api")) + .claim("azp", "artemis-oidc-client") + .claim("cnf", Map.of("x5t#256", x5t)) + .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(); + + RemotingConnection remotingConnection = mock(RemotingConnection.class); + NettyServerConnection nettyConnection = mock(NettyServerConnection.class); + when(remotingConnection.getTransportConnection()).thenReturn(nettyConnection); + when(nettyConnection.getPeerCertificates()).thenReturn(new X509Certificate[] {cert}); + + Subject subject = new Subject(); + lm.initialize(subject, new JaasCallbackHandler(null, token, remotingConnection), null, config); + + assertTrue(lm.login()); + } + @Test public void badJWT() { Map config = configMap(OIDCSupport.ConfigKey.DEBUG.getName(), "true"); diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java index e06db247d6a..c88a81e773b 100644 --- a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java @@ -16,6 +16,22 @@ */ package org.apache.activemq.artemis.spi.core.security.jaas.oidc; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import org.apache.activemq.artemis.core.security.jaas.StubX509Certificate; +import org.apache.activemq.artemis.spi.core.security.jaas.UserPrincipal; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.config.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -25,26 +41,20 @@ import java.security.InvalidAlgorithmParameterException; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; import java.security.interfaces.ECPrivateKey; import java.security.interfaces.ECPublicKey; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.ECGenParameterSpec; +import java.util.Base64; import java.util.Collections; +import java.util.Date; import java.util.List; import java.util.Map; -import com.nimbusds.jose.jwk.Curve; -import com.nimbusds.jose.jwk.ECKey; -import com.nimbusds.jose.jwk.KeyUse; -import com.nimbusds.jose.jwk.RSAKey; -import com.nimbusds.jwt.JWTClaimsSet; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -72,7 +82,7 @@ public class OIDCSupportTest { private HttpClientAccess httpClientAccess; @BeforeAll - public static void oneSetup() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { + public static void setUp() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { KeyPairGenerator rsaKpg = KeyPairGenerator.getInstance("RSA"); rsaKpg.initialize(2048); KeyPair rsaKeyPair = rsaKpg.generateKeyPair(); @@ -98,6 +108,12 @@ public static void oneSetup() throws NoSuchAlgorithmException, InvalidAlgorithmP ecKeyJson2 = ecPublicJWK2.toJSONString(); } + @BeforeAll + public static void setUpLogging() { + Configuration configuration = ((LoggerContext) LogManager.getContext(false)).getConfiguration(); + configuration.getLoggerConfig(OIDCSupportTest.class.getName()).setLevel(Level.DEBUG); + } + @BeforeEach @SuppressWarnings("unchecked") public void setup() throws IOException, InterruptedException { @@ -213,4 +229,68 @@ public void jsonPathsIntoClaims() { assertFalse(v.valid()); } + @Test + public void oauth2MTLSWithMissingClientCertificate() { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("http://localhost") + .subject("Alice") + .audience("broker") + .claim("azp", "artemis-oidc-client") + .expirationTime(new Date(new Date().getTime() + 3_600_000)) + .build(); + + assertFalse(OIDCSupport.tlsCertificateMatching(null, claims, true)); + assertFalse(OIDCSupport.tlsCertificateMatching(new X509Certificate[0], claims, true)); + } + + @Test + public void oauth2MTLSWithProperClientCertificateAndCnfClaim() throws NoSuchAlgorithmException { + StubX509Certificate cert = new StubX509Certificate(new UserPrincipal("Alice")) { + @Override + public byte[] getEncoded() { + // see for example org.keycloak.crypto.elytron.ElytronPEMUtilsProvider#encode() + return new byte[] {0x42, 0x2a}; + } + }; + + byte[] digest = MessageDigest.getInstance("SHA256").digest(cert.getEncoded()); + String x5t = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("http://localhost") + .subject("Alice") + .audience("broker") + .claim("azp", "artemis-oidc-client") + .claim("cnf", Map.of("x5t#256", x5t)) + .expirationTime(new Date(new Date().getTime() + 3_600_000)) + .build(); + + assertTrue(OIDCSupport.tlsCertificateMatching(new X509Certificate[] {cert}, claims, true)); + } + + @Test + public void oauth2MTLSWithProperClientCertificateAndNonMatchingCnfClaim() throws NoSuchAlgorithmException { + StubX509Certificate cert = new StubX509Certificate(new UserPrincipal("Alice")) { + @Override + public byte[] getEncoded() { + // see for example org.keycloak.crypto.elytron.ElytronPEMUtilsProvider#encode() + return new byte[] {0x42, 0x2a}; + } + }; + + byte[] digest = MessageDigest.getInstance("SHA256").digest(new byte[] {0x2a, 0x42}); + String x5t = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("http://localhost") + .subject("Alice") + .audience("broker") + .claim("azp", "artemis-oidc-client") + .claim("cnf", Map.of("x5t#256", x5t)) + .expirationTime(new Date(new Date().getTime() + 3_600_000)) + .build(); + + assertFalse(OIDCSupport.tlsCertificateMatching(new X509Certificate[] {cert}, claims, true)); + } + } From 7fe6150b35e3cbf75f942ca78c14fa6ffbd02518 Mon Sep 17 00:00:00 2001 From: Grzegorz Grzybek Date: Wed, 18 Mar 2026 16:12:41 +0100 Subject: [PATCH 5/6] ARTEMIS-5200 Add test for full LoginContext usage with OIDC in login.config --- .../core/security/jaas/OIDCLoginModule.java | 8 +- .../core/security/jaas/oidc/OIDCSupport.java | 22 +- .../jaas/OIDCLoginModuleLoginContextTest.java | 216 ++++++++++++++++++ .../security/jaas/OIDCLoginModuleTest.java | 4 + .../security/jaas/oidc/OIDCSupportTest.java | 8 +- .../src/test/resources/login.config | 44 +++- 6 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleLoginContextTest.java 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 0fb70d576fe..7ca0475b1e0 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 @@ -278,7 +278,7 @@ public boolean login() throws LoginException { msg = "OAuth2 mTLS failed - no certificates found in transport layer"; } if (!OIDCSupport.tlsCertificateMatching(certificates, claims, debug)) { - String ref = OIDCSupport.findTokenIdentifier(claims); + String ref = OIDCSupport.getTokenSummary(claims); if (certificates != null && certificates.length > 0) { msg = "OAuth2 mTLS failed - certificate from the transport layer doesn't match cnf/x5t#256 thumbprint" + (ref == null ? "" : " (" + ref + ")"); @@ -293,10 +293,10 @@ public boolean login() throws LoginException { if (debug) { if (requireOAuth2MTLS) { logger.debug("JAAS login successful for JWT token with {} and X.509 thumbprint {}", - OIDCSupport.findTokenIdentifier(claims), + OIDCSupport.getTokenSummary(claims), OIDCSupport.stringArrayForPath(claims, "cnf.x5t#256").value()[0]); } else { - logger.debug("JAAS login successful for JWT token with {}", OIDCSupport.findTokenIdentifier(claims)); + logger.debug("JAAS login successful for JWT token with {}", OIDCSupport.getTokenSummary(claims)); } } @@ -310,7 +310,7 @@ public boolean login() throws LoginException { } throw new LoginException("JWT parsing error: " + e.getMessage()); } catch (BadJOSEException | JOSEException e) { - String ref = OIDCSupport.findTokenIdentifier(claims); + String ref = OIDCSupport.getTokenSummary(claims); String msg = e.getMessage() + (ref == null ? "" : " (" + ref + ")"); // invalid token - for example decryption error or claim validation error if (debug) { 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 faaabb48b98..4e8e6752c87 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 @@ -27,6 +27,7 @@ import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import javax.security.auth.login.LoginContext; import com.nimbusds.jose.proc.JWKSecurityContext; @@ -252,7 +253,7 @@ public static boolean tlsCertificateMatching(X509Certificate[] peerCertificates, return false; } catch (CertificateEncodingException e) { if (debug) { - String ref = OIDCSupport.findTokenIdentifier(claims); + String ref = OIDCSupport.getTokenSummary(claims); LOG.warn("OAuth2 mTLS failed - can't get encoded X.509 certificate{}", (ref == null ? "" : " (" + ref + ")")); } return false; @@ -268,20 +269,31 @@ public static boolean tlsCertificateMatching(X509Certificate[] peerCertificates, * @param claims token claims to investigate * @return some information from the token for logging purpose */ - public static String findTokenIdentifier(JWTClaimsSet claims) { + public static String getTokenSummary(JWTClaimsSet claims) { if (claims != null) { + String msg = null; for (String claim : idClaims) { Object v = claims.getClaim(claim); if (v != null) { if (v instanceof String s) { - return claim + "=" + s; + msg = claim + "=" + s; + break; } if (v instanceof Number n) { - return claim + "=" + n.longValue(); + msg = claim + "=" + n.longValue(); + break; } } } - return null; + if (msg != null) { + Object audV = claims.getClaim("aud"); + if (audV instanceof String s) { + msg += ", aud=" + s; + } else if (audV instanceof List l) { + msg += ", aud=[" + l.stream().map(e -> e == null ? "" : e.toString()).collect(Collectors.joining(",")) + "]"; + } + } + return msg; } return null; } diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleLoginContextTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleLoginContextTest.java new file mode 100644 index 00000000000..54dc863dfe6 --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleLoginContextTest.java @@ -0,0 +1,216 @@ +/* + * 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.artemis.spi.core.security.jaas; + +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.ECDSASigner; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.apache.activemq.artemis.spi.core.security.jaas.oidc.HttpClientAccess; +import org.apache.activemq.artemis.spi.core.security.jaas.oidc.OIDCMetadataAccess; +import org.apache.activemq.artemis.spi.core.security.jaas.oidc.OIDCSupport; +import org.apache.activemq.artemis.spi.core.security.jaas.oidc.SharedHttpClientAccess; +import org.apache.activemq.artemis.spi.core.security.jaas.oidc.SharedOIDCMetadataAccess; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.config.Configuration; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import javax.security.auth.Subject; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class OIDCLoginModuleLoginContextTest { + + public static final Set NO_CLAIMS = Collections.emptySet(); + + private static final String loginConfigSysPropName = "java.security.auth.login.config"; + private static String oldLoginConfig; + private static String ecKeyJson; + + private static ECPrivateKey privateKey; + + private HttpResponse notFoundResponse; + private HttpResponse oidcMetadataResponse; + private HttpResponse keysResponse; + private OIDCMetadataAccess oidcMetadataAccess; + private HttpClientAccess httpClientAccess; + + public static Map configMap(String... entries) { + if (entries.length % 2 != 0) { + throw new IllegalArgumentException("Should contain even number of entries"); + } + Map map = new HashMap<>(); + map.put(OIDCSupport.ConfigKey.PROVIDER_URL.getName(), "http://localhost"); + for (int i = 0; i < entries.length; i += 2) { + map.put(entries[i], entries[i + 1]); + } + return map; + } + + @BeforeAll + public static void setUpLogging() { + Configuration configuration = ((LoggerContext) LogManager.getContext(false)).getConfiguration(); + configuration.getLoggerConfig(OIDCLoginModuleLoginContextTest.class.getName()).setLevel(Level.DEBUG); + } + + @BeforeAll + public static void setUpJaas() { + oldLoginConfig = System.getProperty(loginConfigSysPropName, null); + System.setProperty(loginConfigSysPropName, "src/test/resources/login.config"); + } + + @BeforeAll + public static void setUpKeys() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair kp = kpg.generateKeyPair(); + privateKey = (ECPrivateKey) kp.getPrivate(); + ECPublicKey publicKey = (ECPublicKey) kp.getPublic(); + ECKey ecPublicKey = new ECKey.Builder(Curve.P_256, publicKey).keyID("ec-key256").keyUse(KeyUse.SIGNATURE).build(); + ecKeyJson = ecPublicKey.toJSONString(); + } + + @AfterAll + public static void resetJaas() { + if (oldLoginConfig != null) { + System.setProperty(loginConfigSysPropName, oldLoginConfig); + } + } + + @BeforeEach + public void setUp() throws NoSuchFieldException, IllegalAccessException { + Field f1 = SharedHttpClientAccess.class.getDeclaredField("cache"); + f1.setAccessible(true); + ((Map) f1.get(null)).clear(); + Field f2 = SharedOIDCMetadataAccess.class.getDeclaredField("cache"); + f2.setAccessible(true); + ((Map) f2.get(null)).clear(); + } + + @BeforeEach + @SuppressWarnings("unchecked") + public void setup() throws IOException, InterruptedException, NoSuchFieldException, IllegalAccessException { + HttpClient client = mock(HttpClient.class); + + notFoundResponse = mock(HttpResponse.class); + when(notFoundResponse.statusCode()).thenReturn(404); + when(notFoundResponse.body()).thenReturn(new ByteArrayInputStream("Not Found".getBytes())); + + oidcMetadataResponse = Mockito.mock(HttpResponse.class); + when(oidcMetadataResponse.statusCode()).thenReturn(200); + when(oidcMetadataResponse.body()).thenReturn(new ByteArrayInputStream(""" + { + "issuer":"http://localhost/testopenidprovider", + "jwks_uri":"http://localhost/testopenidprovider/keys" + } + """.getBytes())); + + keysResponse = Mockito.mock(HttpResponse.class); + when(keysResponse.statusCode()).thenReturn(200); + String json = String.format("{\"keys\":[%s]}", ecKeyJson); + when(keysResponse.body()).thenReturn(json); + + HttpRequest req = any(HttpRequest.class); + HttpResponse.BodyHandler res = any(HttpResponse.BodyHandler.class); + when(client.send(req, res)).thenAnswer(inv -> { + HttpRequest r = inv.getArgument(0, HttpRequest.class); + return switch (r.uri().getPath()) { + case "/testopenidprovider/.well-known/openid-configuration" -> oidcMetadataResponse; + case "/testopenidprovider/keys" -> keysResponse; + default -> notFoundResponse; + }; + }); + + Field f1 = SharedHttpClientAccess.class.getDeclaredField("cache"); + f1.setAccessible(true); + ((Map) f1.get(null)).put(URI.create("http://localhost/testopenidprovider"), client); + } + + @Test + public void properSignedToken() throws JOSEException, LoginException { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("http://localhost/testopenidprovider") + .subject("Alice") + .audience("artemis-broker") + .claim("azp", "artemis-oidc-client") + .claim("scope", "role1 role2") + .claim("realm_access", Map.of("roles", List.of("admin"))) + .expirationTime(new Date(new Date().getTime() + 3_600_000)) + .build(); + SignedJWT signedJWT = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.ES256).keyID("ec-key256").build(), claims); + JWSSigner signer = new ECDSASigner(privateKey); + signedJWT.sign(signer); + String token = signedJWT.serialize(); + + Subject subject = new Subject(); + LoginContext lc = new LoginContext("OIDCLogin", subject, new JaasCallbackHandler(null, token, null)); + + assertTrue(subject.getPrincipals().isEmpty()); + assertTrue(subject.getPublicCredentials().isEmpty()); + assertTrue(subject.getPrivateCredentials().isEmpty()); + + lc.login(); + + // only "sub" (default) configured as identity path + assertEquals(2, subject.getPrincipals().size()); + assertEquals("Alice", subject.getPrincipals(UserPrincipal.class).iterator().next().getName()); + assertEquals("admin", subject.getPrincipals(RolePrincipal.class).iterator().next().getName()); + assertTrue(subject.getPublicCredentials().isEmpty()); + assertFalse(subject.getPrivateCredentials().isEmpty()); + } + +} 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 998102cb33a..543b9b7ec67 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 @@ -87,6 +87,10 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +/** + * Unit tests of the {@link OIDCLoginModuleTest} which do not require reading {@code login.config} configuration + * and do not use {@link javax.security.auth.login.LoginContext}. + */ public class OIDCLoginModuleTest { public static final Set NO_CLAIMS = Collections.emptySet(); diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java index c88a81e773b..5edb7698ddb 100644 --- a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java @@ -101,10 +101,10 @@ public static void setUp() throws NoSuchAlgorithmException, InvalidAlgorithmPara KeyPairGenerator ecKpg2 = KeyPairGenerator.getInstance("EC"); ecKpg2.initialize(new ECGenParameterSpec("secp256r1")); - KeyPair ecKeyPair2 = ecKpg1.generateKeyPair(); - ECPrivateKey ecPrivateKey2 = (ECPrivateKey) ecKeyPair1.getPrivate(); - ECPublicKey ecPublicKey2 = (ECPublicKey) ecKeyPair1.getPublic(); - ECKey ecPublicJWK2 = new ECKey.Builder(Curve.P_521, ecPublicKey2).keyID("ec-key256").keyUse(KeyUse.SIGNATURE).build(); + KeyPair ecKeyPair2 = ecKpg2.generateKeyPair(); + ECPrivateKey ecPrivateKey2 = (ECPrivateKey) ecKeyPair2.getPrivate(); + ECPublicKey ecPublicKey2 = (ECPublicKey) ecKeyPair2.getPublic(); + ECKey ecPublicJWK2 = new ECKey.Builder(Curve.P_256, ecPublicKey2).keyID("ec-key256").keyUse(KeyUse.SIGNATURE).build(); ecKeyJson2 = ecPublicJWK2.toJSONString(); } diff --git a/artemis-server/src/test/resources/login.config b/artemis-server/src/test/resources/login.config index 515d0c28b36..65f9a815c99 100644 --- a/artemis-server/src/test/resources/login.config +++ b/artemis-server/src/test/resources/login.config @@ -214,10 +214,50 @@ HttpServerAuthenticator { org.apache.activemq.artemis.spi.core.security.jaas.KubernetesLoginModule sufficient - debug=true - org.apache.activemq.jaas.kubernetes.role="cert-roles.properties"; + debug=true + org.apache.activemq.jaas.kubernetes.role="cert-roles.properties"; }; testNoCacheLoginException { org.apache.activemq.artemis.core.security.jaas.NoCacheLoginModule required; }; + +OIDCLogin { + + org.apache.activemq.artemis.spi.core.security.jaas.OIDCLoginModule required + debug=true + + // OpenID Connect provider URL - for getting the public keys for token signature validation + provider="http://localhost/testopenidprovider" + // comma-separated required/expected audience ("aud" string/string[] claim) + audience=artemis-broker + // comma-separated "json paths" to JWT fields with the identity of the caller + identityPaths=sub + // comma-separated "json paths" to JWT fields with the roles of the caller + rolesPaths="realm_access.roles" + // Whether the token should contain cnf/x5t#256 claim according RFC 8705 (requires mTLS enabled) + requireOAuth2MTLS=false + + // these use defaults values: + + // java.security.Principal implementation to be used for user identities + userPrincipalClass="org.apache.activemq.artemis.spi.core.security.jaas.UserPrincipal" + // java.security.Principal implementation to be used for user roles + rolePrincipalClass="org.apache.activemq.artemis.spi.core.security.jaas.RolePrincipal" + // time in seconds to wait before fetching /.well-known/openid-configuration again in case of errors. + metadataRetrySeconds="30" + // time in seconds for caching they keys from the keys endpoint of the provider + cacheKeysSeconds="3600" + // TLS version to use with http client when using https protocol + tlsVersion=TLSv1.3 + // connection & read timeout for HTTP Client + httpTimeout="5000" + // CA certificate (PEM (single or multiple) or DER format, X.509) for building TLS context for HTTP Client + //caCertificate= + // whether plain JWTs are allowed ({"alg":"none"}) + allowPlainJWT=false + // time skew in seconds for nbf/exp validation + maxClockSkew="60" + ; + +}; From f575b3977b38713711ebcad898aaba209b4b6578 Mon Sep 17 00:00:00 2001 From: Grzegorz Grzybek Date: Tue, 24 Mar 2026 12:27:17 +0100 Subject: [PATCH 6/6] ARTEMIS-5200 Fix SSL Context initialization in HttpClient ARTEMIS-5200 Adjust OSGi headers and features for artemis-server-osgi --- artemis-features/src/main/resources/features.xml | 1 + artemis-server-osgi/pom.xml | 2 ++ .../spi/core/security/jaas/oidc/OIDCSupport.java | 3 +-- .../security/jaas/oidc/SharedHttpClientAccess.java | 13 +++++++++++++ .../jaas/oidc/SharedOIDCMetadataAccess.java | 3 +-- .../security/jaas/oidc/HttpClientAccessTest.java | 6 ++---- 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/artemis-features/src/main/resources/features.xml b/artemis-features/src/main/resources/features.xml index 28cf1b6a61a..f52772a189d 100644 --- a/artemis-features/src/main/resources/features.xml +++ b/artemis-features/src/main/resources/features.xml @@ -79,6 +79,7 @@ mvn:org.apache.commons/commons-pool2/${commons.pool2.version} + mvn:com.nimbusds/nimbus-jose-jwt/${nimbus.jwt.version} mvn:org.apache.activemq/activemq-artemis-native/${activemq-artemis-native-version} mvn:org.apache.artemis/artemis-lockmanager-api/${pom.version} diff --git a/artemis-server-osgi/pom.xml b/artemis-server-osgi/pom.xml index 891061aea12..e3a6b8a8d4c 100644 --- a/artemis-server-osgi/pom.xml +++ b/artemis-server-osgi/pom.xml @@ -132,6 +132,8 @@ io.netty.buffer;io.netty.*;version="[4.1,5)", java.net.http*;resolution:=optional, com.sun.net.httpserver*;resolution:=optional, + com.nimbusds.jose*;resolution:=optional, + com.nimbusds.jwt*;resolution:=optional, * <_exportcontents>org.apache.activemq.artemis.*;-noimport:=true 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 4e8e6752c87..2e7f79e7600 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 @@ -36,7 +36,6 @@ import org.apache.activemq.artemis.spi.core.security.jaas.OIDCLoginModule; import org.apache.activemq.artemis.spi.core.security.jaas.RolePrincipal; import org.apache.activemq.artemis.spi.core.security.jaas.UserPrincipal; -import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -215,7 +214,7 @@ public static JWTStringArray stringArrayForPath(JWTClaimsSet claims, String path return new JWTStringArray(null, false); } - private static String[] createValidResult(@NonNull String s) { + private static String[] createValidResult(String s) { return s.split("\\s+"); } diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedHttpClientAccess.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedHttpClientAccess.java index ef7e30f6bba..db48dca5ea2 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedHttpClientAccess.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedHttpClientAccess.java @@ -19,6 +19,7 @@ import java.io.File; import java.net.URI; import java.net.http.HttpClient; +import java.security.KeyManagementException; import java.security.KeyStore; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; @@ -86,6 +87,7 @@ public HttpClient createDefaultHttpClient() { .version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofMillis(httpTimeout)); + boolean sslContextSet = false; if (tlsVersion != null && caCertificate != null) { try { File caCertificateFile = new File(caCertificate); @@ -99,6 +101,7 @@ public HttpClient createDefaultHttpClient() { tmFactory.init(trustStore); sslContext.init(null, tmFactory.getTrustManagers(), new SecureRandom()); builder.sslContext(sslContext); + sslContextSet = true; } } catch (NoSuchAlgorithmException e) { throw new IllegalArgumentException(e.getMessage(), e); @@ -107,6 +110,16 @@ public HttpClient createDefaultHttpClient() { } } + if (!sslContextSet) { + try { + SSLContext sslContext = SSLContext.getInstance("TLSv1.2"); + sslContext.init(null, null, new SecureRandom()); + builder.sslContext(sslContext); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + throw new RuntimeException("Can't configure default SSL Context for HTTP Client", e); + } + } + return builder.build(); } diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedOIDCMetadataAccess.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedOIDCMetadataAccess.java index ee0e1784e33..7b4d3b36772 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedOIDCMetadataAccess.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedOIDCMetadataAccess.java @@ -33,7 +33,6 @@ import org.apache.activemq.artemis.json.JsonObject; import org.apache.activemq.artemis.utils.JsonLoader; -import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -170,7 +169,7 @@ private OIDCMetadata fetchOIDCMetadata(URI baseURI) { * @param jwksURI {@code jwks_uri} endpoint from OIDC metadata * @return String representation of the JWK (RFC 7517) JSON to be parsed by Nimbus Jose JWT library */ - private String fetchJwkSet(@NonNull URI baseURI, @NonNull URI jwksURI) { + private String fetchJwkSet(URI baseURI, URI jwksURI) { HttpClient client = httpClientAccess.getClient(baseURI); boolean jsonError = false; diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccessTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccessTest.java index 6986e073009..401b6010141 100644 --- a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccessTest.java +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccessTest.java @@ -21,8 +21,6 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.security.NoSuchAlgorithmException; -import javax.net.ssl.SSLContext; import org.junit.jupiter.api.Test; @@ -91,13 +89,13 @@ public void sharedHttpClientAccess() { } @Test - public void defaultClient() throws NoSuchAlgorithmException { + public void defaultClient() { SharedHttpClientAccess access = new SharedHttpClientAccess(null, true); HttpClient client = access.getClient(URI.create("http://localhost:8081")); assertNotNull(client); - assertSame(client.sslContext(), SSLContext.getDefault()); + assertNotNull(client.sslContext()); } }