+ * This class extends {@link AbstractOAuth2TokenService} and provides the HTTP client specific logic to perform token
+ * requests using Apache HttpClient 5 instead of HttpClient 4.
+ */
+@Slf4j
+class HttpClient5OAuth2TokenService extends AbstractOAuth2TokenService
+{
+ private static final char[] EMPTY_PASSWORD = {};
+
+ private final CloseableHttpClient httpClient;
+ private final DefaultTokenClientConfiguration config = DefaultTokenClientConfiguration.getInstance();
+
+ /**
+ * Creates a new instance with the given HTTP client and default cache configuration.
+ *
+ * @param httpClient
+ * The HTTP client to use for token requests.
+ */
+ HttpClient5OAuth2TokenService( @Nonnull final CloseableHttpClient httpClient )
+ {
+ this(httpClient, TokenCacheConfiguration.defaultConfiguration());
+ }
+
+ /**
+ * Creates a new instance with the given HTTP client and cache configuration.
+ *
+ * @param httpClient
+ * The HTTP client to use for token requests.
+ * @param tokenCacheConfiguration
+ * The cache configuration to use.
+ */
+ HttpClient5OAuth2TokenService(
+ @Nonnull final CloseableHttpClient httpClient,
+ @Nonnull final TokenCacheConfiguration tokenCacheConfiguration )
+ {
+ super(tokenCacheConfiguration);
+ Assertions.assertNotNull(httpClient, "http client is required");
+ this.httpClient = httpClient;
+ }
+
+ @Override
+ protected
+ OAuth2TokenResponse
+ requestAccessToken( final URI tokenUri, final HttpHeaders headers, final Map
+ * For ClientIdentity that is certificate based it will resolve HTTPS client using the provided ClientIdentity. If
+ * the ClientIdentity wasn't provided or is not certificate-based, it will return default HttpClient.
+ *
+ * @param clientIdentity
+ * for X.509 certificate based communication {@link ClientCertificate} implementation of ClientIdentity
+ * interface should be provided
+ * @return HTTP or HTTPS client (HttpClient5)
+ * @throws HttpClientException
+ * in case HTTPS Client could not be setup
+ */
+ @Nonnull
+ static CloseableHttpClient createHttpClient( @Nullable final ClientIdentity clientIdentity )
+ throws HttpClientException
+ {
+ return createHttpClient(clientIdentity, null);
+ }
+
+ /**
+ * Creates a CloseableHttpClient (HttpClient5) based on ClientIdentity details and optional KeyStore.
+ *
+ * For ClientIdentity that is certificate based it will resolve HTTPS client using the provided ClientIdentity. If a
+ * KeyStore is provided (e.g., for ZTIS), it will be used directly. If the ClientIdentity wasn't provided or is not
+ * certificate-based, it will return default HttpClient.
+ *
+ * @param clientIdentity
+ * for X.509 certificate based communication {@link ClientCertificate} implementation of ClientIdentity
+ * interface should be provided
+ * @param keyStore
+ * optional KeyStore to use for mTLS (e.g., for ZTIS)
+ * @return HTTP or HTTPS client (HttpClient5)
+ * @throws HttpClientException
+ * in case HTTPS Client could not be setup
+ */
+ @Nonnull
+ static
+ CloseableHttpClient
+ createHttpClient( @Nullable final ClientIdentity clientIdentity, @Nullable final KeyStore keyStore )
+ throws HttpClientException
+ {
+ // If a KeyStore is provided directly (e.g., for ZTIS), use it
+ if( keyStore != null ) {
+ log
+ .debug(
+ "Creating HTTPS HttpClient5 with provided KeyStore for client '{}'",
+ clientIdentity != null ? clientIdentity.getId() : "unknown");
+ return createHttpClientWithKeyStore(keyStore);
+ }
+
+ if( clientIdentity == null ) {
+ log.debug("No ClientIdentity provided, creating default HttpClient5");
+ return createDefaultHttpClient();
+ }
+
+ if( !clientIdentity.isCertificateBased() ) {
+ log.debug("ClientIdentity is not certificate-based, creating default HttpClient5");
+ return createDefaultHttpClient();
+ }
+
+ log
+ .debug(
+ "Creating HTTPS HttpClient5 with certificate-based authentication for client '{}'",
+ clientIdentity.getId());
+
+ try {
+ final KeyStore identityKeyStore = SSLContextFactory.getInstance().createKeyStore(clientIdentity);
+ return createHttpClientWithKeyStore(identityKeyStore);
+ }
+ catch( final Exception e ) {
+ final var exception =
+ new HttpClientException(
+ "Failed to create HTTPS HttpClient5 with certificate authentication: " + e.getMessage());
+ exception.initCause(e);
+ throw exception;
+ }
+ }
+
+ @Nonnull
+ private static CloseableHttpClient createDefaultHttpClient()
+ {
+ return HttpClients.custom().useSystemProperties().build();
+ }
+
+ @Nonnull
+ private static CloseableHttpClient createHttpClientWithKeyStore( @Nonnull final KeyStore keyStore )
+ throws HttpClientException
+ {
+ try {
+ final SSLContext sslContext = SSLContextBuilder.create().loadKeyMaterial(keyStore, EMPTY_PASSWORD).build();
+
+ final var tlsStrategy = new DefaultClientTlsStrategy(sslContext);
+ final var connectionManager =
+ PoolingHttpClientConnectionManagerBuilder.create().setTlsSocketStrategy(tlsStrategy).build();
+
+ return HttpClientBuilder.create().useSystemProperties().setConnectionManager(connectionManager).build();
+ }
+ catch( final Exception e ) {
+ final var exception =
+ new HttpClientException("Failed to create HTTPS HttpClient5 with KeyStore: " + e.getMessage());
+ exception.initCause(e);
+ throw exception;
+ }
+ }
+}
diff --git a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java
index ea3b29d8a..7dec1c128 100644
--- a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java
+++ b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java
@@ -10,7 +10,7 @@
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
-import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.github.benmanes.caffeine.cache.Cache;
@@ -33,10 +33,8 @@
import com.sap.cloud.sdk.cloudplatform.tenant.Tenant;
import com.sap.cloud.sdk.cloudplatform.tenant.TenantAccessor;
import com.sap.cloud.sdk.cloudplatform.tenant.TenantWithSubdomain;
-import com.sap.cloud.security.client.HttpClientFactory;
import com.sap.cloud.security.config.ClientIdentity;
import com.sap.cloud.security.token.Token;
-import com.sap.cloud.security.xsuaa.client.DefaultOAuth2TokenService;
import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException;
import com.sap.cloud.security.xsuaa.client.OAuth2TokenResponse;
import com.sap.cloud.security.xsuaa.client.OAuth2TokenService;
@@ -113,29 +111,13 @@ private OAuth2TokenService createTokenService( @Nonnull final CacheKey ignored )
tokenCacheParameters.getTokenExpirationDelta(),
false); // disable cache statistics
- if( !(identity instanceof ZtisClientIdentity) ) {
- return new DefaultOAuth2TokenService(HttpClientFactory.create(identity), tokenCacheConfiguration);
- }
+ // For ZTIS, use the KeyStore directly from the identity
+ final CloseableHttpClient httpClient =
+ identity instanceof final ZtisClientIdentity ztisIdentity
+ ? HttpClient5OAuth2TokenService.createHttpClient(identity, ztisIdentity.getKeyStore())
+ : HttpClient5OAuth2TokenService.createHttpClient(identity);
- final DefaultHttpDestination destination =
- DefaultHttpDestination
- // Giving an empty URL here as a workaround
- // If we were to give the token URL here we can't change the subdomain later
- // But the subdomain represents the tenant in case of IAS, so we have to change the subdomain per-tenant
- .builder("")
- .name("oauth-destination-ztis-" + identity.getId().hashCode())
- .keyStore(((ZtisClientIdentity) identity).getKeyStore())
- .build();
- try {
- return new DefaultOAuth2TokenService(
- (CloseableHttpClient) HttpClientAccessor.getHttpClient(destination),
- tokenCacheConfiguration);
- }
- catch( final ClassCastException e ) {
- final String msg =
- "For the X509_ATTESTED credential type the 'HttpClientAccessor' must return instances of 'CloseableHttpClient'";
- throw new DestinationAccessException(msg, e);
- }
+ return new HttpClient5OAuth2TokenService(httpClient, tokenCacheConfiguration);
}
@Nonnull
diff --git a/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/HttpClient5OAuth2TokenServiceTest.java b/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/HttpClient5OAuth2TokenServiceTest.java
new file mode 100644
index 000000000..115cb6310
--- /dev/null
+++ b/cloudplatform/connectivity-oauth/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/HttpClient5OAuth2TokenServiceTest.java
@@ -0,0 +1,695 @@
+/*
+ * Copyright (c) 2024 SAP SE or an SAP affiliate company. All rights reserved.
+ */
+
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyStore;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.io.HttpClientResponseHandler;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import com.sap.cloud.sdk.cloudplatform.connectivity.SecurityLibWorkarounds.ZtisClientIdentity;
+import com.sap.cloud.sdk.testutil.TestContext;
+import com.sap.cloud.security.client.HttpClientException;
+import com.sap.cloud.security.config.ClientCertificate;
+import com.sap.cloud.security.config.ClientCredentials;
+import com.sap.cloud.security.config.ClientIdentity;
+import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException;
+import com.sap.cloud.security.xsuaa.client.OAuth2TokenResponse;
+import com.sap.cloud.security.xsuaa.http.HttpHeaders;
+
+import lombok.SneakyThrows;
+
+/**
+ * Unit tests for the HTTP client creation methods in {@link HttpClient5OAuth2TokenService}.
+ *
+ * These tests verify the different scenarios for creating HTTP clients based on various ClientIdentity types and
+ * KeyStore configurations, including proper handling of null inputs and error conditions.
+ */
+@Isolated( "Tests HTTP client creation which may have global implications" )
+class HttpClient5OAuth2TokenServiceTest
+{
+ @RegisterExtension
+ static TestContext context = TestContext.withThreadContext();
+
+ private CloseableHttpClient mockHttpClient;
+ private ClassicHttpResponse mockResponse;
+ private HttpClient5OAuth2TokenService tokenService;
+
+ @BeforeEach
+ void setUp()
+ {
+ mockHttpClient = mock(CloseableHttpClient.class);
+ mockResponse = mock(ClassicHttpResponse.class);
+ tokenService = new HttpClient5OAuth2TokenService(mockHttpClient);
+ }
+
+ @Test
+ @DisplayName( "createHttpClient with null ClientIdentity should return default HTTP client" )
+ void testCreateHttpClientWithNullClientIdentity()
+ throws HttpClientException
+ {
+ final CloseableHttpClient result = HttpClient5OAuth2TokenService.createHttpClient(null);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isInstanceOf(CloseableHttpClient.class);
+ }
+
+ @Test
+ @DisplayName( "createHttpClient with non-certificate-based ClientIdentity should return default HTTP client" )
+ void testCreateHttpClientWithNonCertificateBasedClientIdentity()
+ throws HttpClientException
+ {
+ final ClientIdentity clientCredentials = new ClientCredentials("client-id", "client-secret");
+
+ final CloseableHttpClient result = HttpClient5OAuth2TokenService.createHttpClient(clientCredentials);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isInstanceOf(CloseableHttpClient.class);
+ }
+
+ @Test
+ @DisplayName( "createHttpClient with non-certificate-based ClientIdentity and null KeyStore should return default HTTP client" )
+ void testCreateHttpClientWithNonCertificateBasedClientIdentityAndNullKeyStore()
+ throws HttpClientException
+ {
+ final ClientIdentity clientCredentials = new ClientCredentials("client-id", "client-secret");
+
+ final CloseableHttpClient result = HttpClient5OAuth2TokenService.createHttpClient(clientCredentials, null);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isInstanceOf(CloseableHttpClient.class);
+ }
+
+ @Test
+ @DisplayName( "createHttpClient with provided KeyStore should use KeyStore regardless of ClientIdentity" )
+ @SneakyThrows
+ void testCreateHttpClientWithProvidedKeyStore()
+ {
+ final KeyStore keyStore = createEmptyKeyStore();
+ final ClientIdentity clientCredentials = new ClientCredentials("client-id", "client-secret");
+
+ final CloseableHttpClient result = HttpClient5OAuth2TokenService.createHttpClient(clientCredentials, keyStore);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isInstanceOf(CloseableHttpClient.class);
+ }
+
+ @Test
+ @DisplayName( "createHttpClient with only KeyStore provided should use KeyStore" )
+ @SneakyThrows
+ void testCreateHttpClientWithOnlyKeyStore()
+ {
+ final KeyStore keyStore = createEmptyKeyStore();
+
+ final CloseableHttpClient result = HttpClient5OAuth2TokenService.createHttpClient(null, keyStore);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isInstanceOf(CloseableHttpClient.class);
+ }
+
+ @Test
+ @DisplayName( "createHttpClient with certificate-based ClientIdentity should handle invalid certificates gracefully" )
+ void testCreateHttpClientWithCertificateBasedClientIdentity()
+ {
+ final ClientIdentity certificateIdentity = createMockCertificateBasedIdentity();
+
+ // These should fail because of invalid certificate format
+ assertThatThrownBy(() -> HttpClient5OAuth2TokenService.createHttpClient(certificateIdentity))
+ .isInstanceOf(HttpClientException.class)
+ .hasMessageContaining("Failed to create HTTPS HttpClient5 with certificate authentication");
+
+ assertThatThrownBy(() -> HttpClient5OAuth2TokenService.createHttpClient(certificateIdentity, null))
+ .isInstanceOf(HttpClientException.class)
+ .hasMessageContaining("Failed to create HTTPS HttpClient5 with certificate authentication");
+ }
+
+ @Test
+ @DisplayName( "createHttpClient with ZTIS ClientIdentity should handle certificate validation" )
+ @SneakyThrows
+ void testCreateHttpClientWithZtisClientIdentity()
+ {
+ final KeyStore keyStore = createEmptyKeyStore();
+ final ClientIdentity ztisIdentity = new ZtisClientIdentity("ztis-client-id", keyStore);
+
+ // ZtisClientIdentity is certificate-based but doesn't implement certificate methods properly
+ // This should fail with certificate validation error
+ assertThatThrownBy(() -> HttpClient5OAuth2TokenService.createHttpClient(ztisIdentity))
+ .isInstanceOf(HttpClientException.class)
+ .hasMessageContaining("Failed to create HTTPS HttpClient5 with certificate authentication");
+ }
+
+ @Test
+ @DisplayName( "createHttpClient with ZTIS ClientIdentity and explicit KeyStore should prefer explicit KeyStore" )
+ @SneakyThrows
+ void testCreateHttpClientWithZtisClientIdentityAndExplicitKeyStore()
+ {
+ final KeyStore embeddedKeyStore = createEmptyKeyStore();
+ final KeyStore explicitKeyStore = createEmptyKeyStore();
+ final ClientIdentity ztisIdentity = new ZtisClientIdentity("ztis-client-id", embeddedKeyStore);
+
+ final CloseableHttpClient result =
+ HttpClient5OAuth2TokenService.createHttpClient(ztisIdentity, explicitKeyStore);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isInstanceOf(CloseableHttpClient.class);
+ }
+
+ @Test
+ @DisplayName( "createHttpClient should handle certificate creation failures gracefully" )
+ void testCreateHttpClientWithInvalidCertificateIdentity()
+ {
+ final ClientIdentity invalidCertificateIdentity = createInvalidCertificateBasedIdentity();
+
+ assertThatThrownBy(() -> HttpClient5OAuth2TokenService.createHttpClient(invalidCertificateIdentity))
+ .isInstanceOf(HttpClientException.class)
+ .hasMessageContaining("Failed to create HTTPS HttpClient5 with certificate authentication");
+ }
+
+ @Test
+ @DisplayName( "createHttpClient with invalid KeyStore should throw HttpClientException" )
+ void testCreateHttpClientWithInvalidKeyStore()
+ {
+ final KeyStore invalidKeyStore = mock(KeyStore.class);
+ final ClientIdentity clientCredentials = new ClientCredentials("client-id", "client-secret");
+
+ assertThatThrownBy(() -> HttpClient5OAuth2TokenService.createHttpClient(clientCredentials, invalidKeyStore))
+ .isInstanceOf(HttpClientException.class)
+ .hasMessageContaining("Failed to create HTTPS HttpClient5 with KeyStore");
+ }
+
+ @Test
+ @DisplayName( "Multiple calls to createHttpClient should return different instances" )
+ void testCreateHttpClientReturnsNewInstances()
+ throws HttpClientException
+ {
+ final CloseableHttpClient client1 = HttpClient5OAuth2TokenService.createHttpClient(null);
+ final CloseableHttpClient client2 = HttpClient5OAuth2TokenService.createHttpClient(null);
+
+ assertThat(client1).isNotNull();
+ assertThat(client2).isNotNull();
+ assertThat(client1).isNotSameAs(client2);
+ }
+
+ @Test
+ @DisplayName( "createHttpClient with different ClientIdentity types should handle appropriately" )
+ void testCreateHttpClientWithDifferentIdentityTypes()
+ throws HttpClientException
+ {
+ final ClientIdentity credentials = new ClientCredentials("client-id", "client-secret");
+
+ final CloseableHttpClient credentialsClient = HttpClient5OAuth2TokenService.createHttpClient(credentials);
+
+ assertThat(credentialsClient).isNotNull();
+ assertThat(credentialsClient).isInstanceOf(CloseableHttpClient.class);
+
+ // Test that certificate-based identity throws exception due to invalid certificate
+ final ClientIdentity certificate = createMockCertificateBasedIdentity();
+ assertThatThrownBy(() -> HttpClient5OAuth2TokenService.createHttpClient(certificate))
+ .isInstanceOf(HttpClientException.class)
+ .hasMessageContaining("Failed to create HTTPS HttpClient5 with certificate authentication");
+ }
+
+ @Test
+ @DisplayName( "createHttpClient should handle concurrent access safely" )
+ void testCreateHttpClientConcurrentAccess()
+ throws InterruptedException
+ {
+ final int threadCount = 10;
+ final Thread[] threads = new Thread[threadCount];
+ final CloseableHttpClient[] results = new CloseableHttpClient[threadCount];
+ final Exception[] exceptions = new Exception[threadCount];
+
+ for( int i = 0; i < threadCount; i++ ) {
+ final int index = i;
+ threads[i] = new Thread(() -> {
+ try {
+ results[index] = HttpClient5OAuth2TokenService.createHttpClient(null);
+ }
+ catch( final Exception e ) {
+ exceptions[index] = e;
+ }
+ });
+ }
+ // Start all threads
+ for( final Thread thread : threads ) {
+ thread.start();
+ }
+ // Wait for all threads to complete
+ for( final Thread thread : threads ) {
+ thread.join();
+ }
+ // Verify results
+ for( int i = 0; i < threadCount; i++ ) {
+ assertThat(exceptions[i]).isNull();
+ assertThat(results[i]).isNotNull();
+ }
+ }
+
+ @Test
+ @DisplayName( "requestAccessToken should successfully retrieve access token with valid response" )
+ void testRequestAccessTokenSuccess()
+ throws IOException
+ {
+ // Given
+ final URI tokenUri = URI.create("https://oauth.server.com/oauth/token");
+ final HttpHeaders headers = new HttpHeaders();
+ headers.withHeader("Content-Type", "application/x-www-form-urlencoded");
+
+ final Map