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