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-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-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/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..7ca0475b1e0 --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModule.java @@ -0,0 +1,491 @@ +/* + * 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.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 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.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.security.cert.X509Certificate; +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); + + // 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 + + /** + * 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 + + 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 { + // 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 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"); + } + + userPrincipalConstructor = determinePrincipalConstructor(ConfigKey.USER_CLASS, options); + rolePrincipalConstructor = determinePrincipalConstructor(ConfigKey.ROLE_CLASS, options); + + requireOAuth2MTLS = OIDCSupport.booleanOption(ConfigKey.REQUIRE_OAUTH_MTLS, options); + + // 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); + + // configuration for what to extract from the token + identityPaths = OIDCSupport.stringArrayOption(ConfigKey.IDENTITY_PATHS, options); + rolesPaths = OIDCSupport.stringArrayOption(ConfigKey.ROLES_PATHS, options); + } + + @Override + public boolean login() throws LoginException { + if (handler == null) { + throw new LoginException("No callback handler available to retrieve the JWT token"); + } + JWT jwt; + JWTClaimsSet claims = null; + try { + CertificateCallback x509Callback = null; + JwtCallback jwtCallback = new JwtCallback(); + if (requireOAuth2MTLS) { + x509Callback = new CertificateCallback(); + handler.handle(new Callback[] {x509Callback, jwtCallback}); + } else { + 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; + } + + // first parse + jwt = JWTParser.parse(token); + + // get the claims before validating (in case we want to add some info to the logs) + claims = jwt.getJWTClaimsSet(); + + // then validate + validateToken(jwt); + + // keep only if parsed & validated + this.token = token; + this.jwt = jwt; + + if (requireOAuth2MTLS && x509Callback != null) { + X509Certificate[] certificates = x509Callback.getCertificates(); + String msg = null; + if (certificates == null || certificates.length == 0) { + msg = "OAuth2 mTLS failed - no certificates found in transport layer"; + } + if (!OIDCSupport.tlsCertificateMatching(certificates, claims, debug)) { + 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 + ")"); + } + if (debug) { + logger.error(msg); + } + throw new LoginException(msg); + } + } + + if (debug) { + if (requireOAuth2MTLS) { + logger.debug("JAAS login successful for JWT token with {} and X.509 thumbprint {}", + OIDCSupport.getTokenSummary(claims), + OIDCSupport.stringArrayForPath(claims, "cnf.x5t#256").value()[0]); + } else { + logger.debug("JAAS login successful for JWT token with {}", OIDCSupport.getTokenSummary(claims)); + } + } + + return true; + } catch (IOException | UnsupportedCallbackException e) { + throw new LoginException("Can't obtain the JWT token: " + e.getMessage()); + } catch (ParseException e) { + // invalid token - base64 error, JSON error or similar + if (debug) { + logger.error("JWT parsing error: {}", e.getMessage()); + } + throw new LoginException("JWT parsing error: " + e.getMessage()); + } catch (BadJOSEException | JOSEException e) { + String ref = OIDCSupport.getTokenSummary(claims); + String msg = e.getMessage() + (ref == null ? "" : " (" + ref + ")"); + // invalid token - for example decryption error or claim validation error + if (debug) { + logger.error("JWT processing error: {}", msg); + } + throw new LoginException("JWT processing error: " + msg); + } + } + + @Override + public boolean commit() throws LoginException { + if (this.jwt != null) { + try { + JWTClaimsSet claims = this.jwt.getJWTClaimsSet(); + // let's extract anything we can from the token we got in login() + this.subject.getPrivateCredentials().add(jwt); + 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"); + } + 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 roles = OIDCSupport.stringArrayForPath(claims, rolePath); + if (!roles.valid()) { + throw new LoginException("Can't determine user role from JWT using \"" + rolePath + "\" path"); + } + 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)); + } + } + + return true; + } catch (ParseException e) { + throw new LoginException("Can't process the JWT token: " + e.getMessage()); + } catch (InvocationTargetException | InstantiationException | IllegalAccessException e) { + throw new LoginException("Can't create subject's principal: " + e.getMessage()); + } + } + + return false; + } + + @Override + public boolean abort() throws LoginException { + boolean result = this.jwt != null; + if (result) { + this.token = null; + this.jwt = null; + } + return result; + } + + @Override + public boolean logout() throws LoginException { + boolean result = this.jwt != null; + if (result) { + this.token = null; + this.jwt = null; + } + return result; + } + + 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? + // 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()); + } + + /** + * 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; + } + + /** + * Normally Artemis uses two default, {@link Principal} class implementations for "user" and "role". But + * we are open for configuring different implementations as long as there's one-String-arg constructor. + * @param configKey configuration key + * @param options available options + * @return a {@link Constructor} for creating the principal + */ + @SuppressWarnings("unchecked") + private Constructor 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 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..2e7f79e7600 --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java @@ -0,0 +1,454 @@ +/* + * 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.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 java.util.stream.Collectors; +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; +import org.apache.activemq.artemis.spi.core.security.jaas.UserPrincipal; +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 static final String[] idClaims = new String[] {"jti", "sid", "iat", "sub"}; + + 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 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(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.getTokenSummary(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 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) { + msg = claim + "=" + s; + break; + } + if (v instanceof Number n) { + msg = claim + "=" + n.longValue(); + break; + } + } + } + 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; + } + + // ---- 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; + } + + /** + * 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) + + // 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/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 + // "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() + // + // 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), + + // 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; + + 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..db48dca5ea2 --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/SharedHttpClientAccess.java @@ -0,0 +1,126 @@ +/* + * 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.KeyManagementException; +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)); + + boolean sslContextSet = false; + 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); + sslContextSet = true; + } + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } catch (Exception e) { + throw new RuntimeException("Can't configure SSL Context for HTTP Client", e); + } + } + + 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 new file mode 100644 index 00000000000..7b4d3b36772 --- /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.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") + || 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 { + // 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(URI baseURI, 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/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 new file mode 100644 index 00000000000..543b9b7ec67 --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleTest.java @@ -0,0 +1,1071 @@ +/* + * 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.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.JWTParser; +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; +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.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; +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; +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; + +/** + * 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(); + + 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(OIDCLoginModuleTest.class.getName()).setLevel(Level.DEBUG); + } + + @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.validateToken(JWTParser.parse("")); + } 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.validateToken(JWTParser.parse(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.validateToken(JWTParser.parse(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.validateToken(JWTParser.parse(token1)); + + String token2 = new PlainJWT(new JWTClaimsSet.Builder() + .notBeforeTime(new Date(new Date().getTime() - 5000L)) + .build()).serialize(); + lm.validateToken(JWTParser.parse(token2)); + + String token3 = new PlainJWT(new JWTClaimsSet.Builder() + .expirationTime(new Date(new Date().getTime() + 5000L)) + .build()).serialize(); + 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.validateToken(JWTParser.parse(token4)); + } + + @Test + 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( + 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.validateToken(JWTParser.parse(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.validateToken(JWTParser.parse(tokenForPast)); + } catch (BadJWTException e) { + assertTrue(e.getMessage().contains("Expired JWT")); + } + } + + @Test + 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( + 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.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.validateToken(JWTParser.parse(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 = JWTParser.parse(signedJWT.serialize()); + lm.validateToken(jwt); + 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 = JWTParser.parse(signedJWT.serialize()); + lm.validateToken(jwt); + 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()); + + // 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 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 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"); + + 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"); + 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/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..401b6010141 --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/HttpClientAccessTest.java @@ -0,0 +1,101 @@ +/* + * 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 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() { + SharedHttpClientAccess access = new SharedHttpClientAccess(null, true); + + HttpClient client = access.getClient(URI.create("http://localhost:8081")); + + assertNotNull(client); + assertNotNull(client.sslContext()); + } + +} 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..5edb7698ddb --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupportTest.java @@ -0,0 +1,296 @@ +/* + * 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.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; +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.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 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; + +/** + * 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 setUp() 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 = 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(); + } + + @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 { + 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()); + } + + @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("identityPaths", "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()); + } + + @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)); + } + +} 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" + ; + +}; 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