Skip to content

Commit f96e819

Browse files
Jonas-Isrnewtork
andauthored
feat: Migrate to HttpClient5 in OAuth2Service (#1103)
Co-authored-by: Alexander Dümont <alexander.duemont@sap.com>
1 parent 02ca74c commit f96e819

5 files changed

Lines changed: 1068 additions & 26 deletions

File tree

cloudplatform/connectivity-oauth/pom.xml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
<dependency>
4949
<groupId>com.sap.cloud.sdk.cloudplatform</groupId>
5050
<artifactId>connectivity-apache-httpclient4</artifactId>
51+
<scope>test</scope>
5152
</dependency>
5253
<dependency>
5354
<groupId>com.sap.cloud.sdk.cloudplatform</groupId>
@@ -112,6 +113,10 @@
112113
<groupId>com.github.ben-manes.caffeine</groupId>
113114
<artifactId>caffeine</artifactId>
114115
</dependency>
116+
<dependency>
117+
<groupId>org.json</groupId>
118+
<artifactId>json</artifactId>
119+
</dependency>
115120
<dependency>
116121
<groupId>org.apache.httpcomponents</groupId>
117122
<artifactId>httpclient</artifactId>
@@ -122,6 +127,14 @@
122127
</exclusion>
123128
</exclusions>
124129
</dependency>
130+
<dependency>
131+
<groupId>org.apache.httpcomponents.client5</groupId>
132+
<artifactId>httpclient5</artifactId>
133+
</dependency>
134+
<dependency>
135+
<groupId>org.apache.httpcomponents.core5</groupId>
136+
<artifactId>httpcore5</artifactId>
137+
</dependency>
125138
<dependency>
126139
<groupId>org.apache.commons</groupId>
127140
<artifactId>commons-lang3</artifactId>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
/*
2+
* Copyright (c) 2024 SAP SE or an SAP affiliate company. All rights reserved.
3+
*/
4+
5+
package com.sap.cloud.sdk.cloudplatform.connectivity;
6+
7+
import java.io.IOException;
8+
import java.net.URI;
9+
import java.nio.charset.StandardCharsets;
10+
import java.security.KeyStore;
11+
import java.util.AbstractMap;
12+
import java.util.Arrays;
13+
import java.util.List;
14+
import java.util.Map;
15+
16+
import javax.annotation.Nonnull;
17+
import javax.annotation.Nullable;
18+
import javax.net.ssl.SSLContext;
19+
20+
import org.apache.hc.client5.http.classic.methods.HttpPost;
21+
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
22+
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
23+
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
24+
import org.apache.hc.client5.http.impl.classic.HttpClients;
25+
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
26+
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
27+
import org.apache.hc.core5.http.Header;
28+
import org.apache.hc.core5.http.HttpStatus;
29+
import org.apache.hc.core5.http.io.entity.EntityUtils;
30+
import org.apache.hc.core5.http.message.BasicNameValuePair;
31+
import org.apache.hc.core5.ssl.SSLContextBuilder;
32+
import org.json.JSONObject;
33+
34+
import com.sap.cloud.security.client.DefaultTokenClientConfiguration;
35+
import com.sap.cloud.security.client.HttpClientException;
36+
import com.sap.cloud.security.config.ClientCertificate;
37+
import com.sap.cloud.security.config.ClientIdentity;
38+
import com.sap.cloud.security.mtls.SSLContextFactory;
39+
import com.sap.cloud.security.servlet.MDCHelper;
40+
import com.sap.cloud.security.xsuaa.Assertions;
41+
import com.sap.cloud.security.xsuaa.client.AbstractOAuth2TokenService;
42+
import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException;
43+
import com.sap.cloud.security.xsuaa.client.OAuth2TokenResponse;
44+
import com.sap.cloud.security.xsuaa.client.OAuth2TokenServiceConstants;
45+
import com.sap.cloud.security.xsuaa.http.HttpHeaders;
46+
import com.sap.cloud.security.xsuaa.tokenflows.TokenCacheConfiguration;
47+
import com.sap.cloud.security.xsuaa.util.HttpClientUtil;
48+
49+
import lombok.extern.slf4j.Slf4j;
50+
51+
/**
52+
* OAuth2 token service implementation using Apache HttpClient 5.
53+
* <p>
54+
* This class extends {@link AbstractOAuth2TokenService} and provides the HTTP client specific logic to perform token
55+
* requests using Apache HttpClient 5 instead of HttpClient 4.
56+
*/
57+
@Slf4j
58+
class HttpClient5OAuth2TokenService extends AbstractOAuth2TokenService
59+
{
60+
private static final char[] EMPTY_PASSWORD = {};
61+
62+
private final CloseableHttpClient httpClient;
63+
private final DefaultTokenClientConfiguration config = DefaultTokenClientConfiguration.getInstance();
64+
65+
/**
66+
* Creates a new instance with the given HTTP client and default cache configuration.
67+
*
68+
* @param httpClient
69+
* The HTTP client to use for token requests.
70+
*/
71+
HttpClient5OAuth2TokenService( @Nonnull final CloseableHttpClient httpClient )
72+
{
73+
this(httpClient, TokenCacheConfiguration.defaultConfiguration());
74+
}
75+
76+
/**
77+
* Creates a new instance with the given HTTP client and cache configuration.
78+
*
79+
* @param httpClient
80+
* The HTTP client to use for token requests.
81+
* @param tokenCacheConfiguration
82+
* The cache configuration to use.
83+
*/
84+
HttpClient5OAuth2TokenService(
85+
@Nonnull final CloseableHttpClient httpClient,
86+
@Nonnull final TokenCacheConfiguration tokenCacheConfiguration )
87+
{
88+
super(tokenCacheConfiguration);
89+
Assertions.assertNotNull(httpClient, "http client is required");
90+
this.httpClient = httpClient;
91+
}
92+
93+
@Override
94+
protected
95+
OAuth2TokenResponse
96+
requestAccessToken( final URI tokenUri, final HttpHeaders headers, final Map<String, String> parameters )
97+
throws OAuth2ServiceException
98+
{
99+
Assertions.assertNotNull(tokenUri, "Token endpoint URI must not be null!");
100+
return convertToOAuth2TokenResponse(
101+
executeRequest(tokenUri, headers, parameters, config.isRetryEnabled() ? config.getMaxRetryAttempts() : 0));
102+
}
103+
104+
private String executeRequest(
105+
final URI tokenUri,
106+
final HttpHeaders headers,
107+
final Map<String, String> parameters,
108+
final int attemptsLeft )
109+
throws OAuth2ServiceException
110+
{
111+
final HttpPost httpPost = createHttpPost(tokenUri, createRequestHeaders(headers), parameters);
112+
log.debug("Requesting access token with {} retries left", attemptsLeft);
113+
try {
114+
return httpClient.execute(httpPost, response -> {
115+
final int statusCode = response.getCode();
116+
final String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
117+
log.debug("Received statusCode {} from token endpoint", statusCode);
118+
if( HttpStatus.SC_OK == statusCode ) {
119+
log.debug("Successfully retrieved access token from token endpoint");
120+
return body;
121+
} else if( attemptsLeft > 0 && config.getRetryStatusCodes().contains(statusCode) ) {
122+
log.warn("Request failed with status {} but is retryable. Retrying...", statusCode);
123+
pauseBeforeNextAttempt(config.getRetryDelayTime());
124+
return executeRequest(tokenUri, headers, parameters, attemptsLeft - 1);
125+
}
126+
throw OAuth2ServiceException
127+
.builder("Error requesting access token!")
128+
.withStatusCode(statusCode)
129+
.withUri(tokenUri)
130+
.withRequestHeaders(getHeadersAsStringArray(httpPost.getHeaders()))
131+
.withResponseHeaders(getHeadersAsStringArray(response.getHeaders()))
132+
.withResponseBody(body)
133+
.build();
134+
});
135+
}
136+
catch( final OAuth2ServiceException e ) {
137+
throw e;
138+
}
139+
catch( final IOException e ) {
140+
final var exception =
141+
OAuth2ServiceException
142+
.builder("Error requesting access token!")
143+
.withUri(tokenUri)
144+
.withRequestHeaders(getHeadersAsStringArray(httpPost.getHeaders()))
145+
.withResponseBody(e.getMessage())
146+
.build();
147+
exception.initCause(e);
148+
throw exception;
149+
}
150+
}
151+
152+
private HttpHeaders createRequestHeaders( final HttpHeaders headers )
153+
{
154+
final HttpHeaders requestHeaders = new HttpHeaders();
155+
headers.getHeaders().forEach(h -> requestHeaders.withHeader(h.getName(), h.getValue()));
156+
requestHeaders.withHeader(MDCHelper.CORRELATION_HEADER, MDCHelper.getOrCreateCorrelationId());
157+
return requestHeaders;
158+
}
159+
160+
private void logRequest( final HttpHeaders headers, final Map<String, String> parameters )
161+
{
162+
log.debug("access token request {} - {}", headers, parameters.entrySet().stream().map(e -> {
163+
if( e.getKey().contains(OAuth2TokenServiceConstants.PASSWORD)
164+
|| e.getKey().contains(OAuth2TokenServiceConstants.CLIENT_SECRET)
165+
|| e.getKey().contains(OAuth2TokenServiceConstants.ASSERTION) ) {
166+
return new AbstractMap.SimpleImmutableEntry<>(e.getKey(), "****");
167+
}
168+
return e;
169+
}).toList());
170+
}
171+
172+
private HttpPost createHttpPost( final URI uri, final HttpHeaders headers, final Map<String, String> parameters )
173+
throws OAuth2ServiceException
174+
{
175+
final HttpPost httpPost = new HttpPost(uri);
176+
headers.getHeaders().forEach(header -> httpPost.setHeader(header.getName(), header.getValue()));
177+
final List<BasicNameValuePair> basicNameValuePairs =
178+
parameters
179+
.entrySet()
180+
.stream()
181+
.map(entry -> new BasicNameValuePair(entry.getKey(), entry.getValue()))
182+
.toList();
183+
try {
184+
httpPost.setEntity(new UrlEncodedFormEntity(basicNameValuePairs, StandardCharsets.UTF_8));
185+
httpPost.addHeader(org.apache.hc.core5.http.HttpHeaders.USER_AGENT, HttpClientUtil.getUserAgent());
186+
}
187+
catch( final Exception e ) {
188+
final var exception = new OAuth2ServiceException("Unexpected error parsing URI: " + e.getMessage());
189+
exception.initCause(e);
190+
throw exception;
191+
}
192+
logRequest(headers, parameters);
193+
return httpPost;
194+
}
195+
196+
private OAuth2TokenResponse convertToOAuth2TokenResponse( final String responseBody )
197+
throws OAuth2ServiceException
198+
{
199+
final Map<String, Object> accessTokenMap = new JSONObject(responseBody).toMap();
200+
final String accessToken = getParameter(accessTokenMap, OAuth2TokenServiceConstants.ACCESS_TOKEN);
201+
final String refreshToken = getParameter(accessTokenMap, OAuth2TokenServiceConstants.REFRESH_TOKEN);
202+
final String expiresIn = getParameter(accessTokenMap, OAuth2TokenServiceConstants.EXPIRES_IN);
203+
final String tokenType = getParameter(accessTokenMap, OAuth2TokenServiceConstants.TOKEN_TYPE);
204+
return new OAuth2TokenResponse(accessToken, convertExpiresInToLong(expiresIn), refreshToken, tokenType);
205+
}
206+
207+
private Long convertExpiresInToLong( final String expiresIn )
208+
throws OAuth2ServiceException
209+
{
210+
try {
211+
return Long.parseLong(expiresIn);
212+
}
213+
catch( final NumberFormatException e ) {
214+
final var exception =
215+
new OAuth2ServiceException(
216+
String.format("Cannot convert expires_in from response (%s) to long", expiresIn));
217+
exception.initCause(e);
218+
throw exception;
219+
}
220+
}
221+
222+
private String getParameter( final Map<String, Object> accessTokenMap, final String key )
223+
{
224+
return String.valueOf(accessTokenMap.get(key));
225+
}
226+
227+
private static String[] getHeadersAsStringArray( final Header[] headers )
228+
{
229+
return headers != null ? Arrays.stream(headers).map(Header::toString).toArray(String[]::new) : new String[0];
230+
}
231+
232+
private void pauseBeforeNextAttempt( final long sleepTime )
233+
{
234+
try {
235+
log.info("Retry again in {} ms", sleepTime);
236+
Thread.sleep(sleepTime);
237+
}
238+
catch( final InterruptedException e ) {
239+
log.warn("Thread.sleep has been interrupted. Retry starts now.");
240+
}
241+
}
242+
243+
/**
244+
* Creates a CloseableHttpClient (HttpClient5) based on ClientIdentity details.
245+
* <p>
246+
* For ClientIdentity that is certificate based it will resolve HTTPS client using the provided ClientIdentity. If
247+
* the ClientIdentity wasn't provided or is not certificate-based, it will return default HttpClient.
248+
*
249+
* @param clientIdentity
250+
* for X.509 certificate based communication {@link ClientCertificate} implementation of ClientIdentity
251+
* interface should be provided
252+
* @return HTTP or HTTPS client (HttpClient5)
253+
* @throws HttpClientException
254+
* in case HTTPS Client could not be setup
255+
*/
256+
@Nonnull
257+
static CloseableHttpClient createHttpClient( @Nullable final ClientIdentity clientIdentity )
258+
throws HttpClientException
259+
{
260+
return createHttpClient(clientIdentity, null);
261+
}
262+
263+
/**
264+
* Creates a CloseableHttpClient (HttpClient5) based on ClientIdentity details and optional KeyStore.
265+
* <p>
266+
* For ClientIdentity that is certificate based it will resolve HTTPS client using the provided ClientIdentity. If a
267+
* KeyStore is provided (e.g., for ZTIS), it will be used directly. If the ClientIdentity wasn't provided or is not
268+
* certificate-based, it will return default HttpClient.
269+
*
270+
* @param clientIdentity
271+
* for X.509 certificate based communication {@link ClientCertificate} implementation of ClientIdentity
272+
* interface should be provided
273+
* @param keyStore
274+
* optional KeyStore to use for mTLS (e.g., for ZTIS)
275+
* @return HTTP or HTTPS client (HttpClient5)
276+
* @throws HttpClientException
277+
* in case HTTPS Client could not be setup
278+
*/
279+
@Nonnull
280+
static
281+
CloseableHttpClient
282+
createHttpClient( @Nullable final ClientIdentity clientIdentity, @Nullable final KeyStore keyStore )
283+
throws HttpClientException
284+
{
285+
// If a KeyStore is provided directly (e.g., for ZTIS), use it
286+
if( keyStore != null ) {
287+
log
288+
.debug(
289+
"Creating HTTPS HttpClient5 with provided KeyStore for client '{}'",
290+
clientIdentity != null ? clientIdentity.getId() : "unknown");
291+
return createHttpClientWithKeyStore(keyStore);
292+
}
293+
294+
if( clientIdentity == null ) {
295+
log.debug("No ClientIdentity provided, creating default HttpClient5");
296+
return createDefaultHttpClient();
297+
}
298+
299+
if( !clientIdentity.isCertificateBased() ) {
300+
log.debug("ClientIdentity is not certificate-based, creating default HttpClient5");
301+
return createDefaultHttpClient();
302+
}
303+
304+
log
305+
.debug(
306+
"Creating HTTPS HttpClient5 with certificate-based authentication for client '{}'",
307+
clientIdentity.getId());
308+
309+
try {
310+
final KeyStore identityKeyStore = SSLContextFactory.getInstance().createKeyStore(clientIdentity);
311+
return createHttpClientWithKeyStore(identityKeyStore);
312+
}
313+
catch( final Exception e ) {
314+
final var exception =
315+
new HttpClientException(
316+
"Failed to create HTTPS HttpClient5 with certificate authentication: " + e.getMessage());
317+
exception.initCause(e);
318+
throw exception;
319+
}
320+
}
321+
322+
@Nonnull
323+
private static CloseableHttpClient createDefaultHttpClient()
324+
{
325+
return HttpClients.custom().useSystemProperties().build();
326+
}
327+
328+
@Nonnull
329+
private static CloseableHttpClient createHttpClientWithKeyStore( @Nonnull final KeyStore keyStore )
330+
throws HttpClientException
331+
{
332+
try {
333+
final SSLContext sslContext = SSLContextBuilder.create().loadKeyMaterial(keyStore, EMPTY_PASSWORD).build();
334+
335+
final var tlsStrategy = new DefaultClientTlsStrategy(sslContext);
336+
final var connectionManager =
337+
PoolingHttpClientConnectionManagerBuilder.create().setTlsSocketStrategy(tlsStrategy).build();
338+
339+
return HttpClientBuilder.create().useSystemProperties().setConnectionManager(connectionManager).build();
340+
}
341+
catch( final Exception e ) {
342+
final var exception =
343+
new HttpClientException("Failed to create HTTPS HttpClient5 with KeyStore: " + e.getMessage());
344+
exception.initCause(e);
345+
throw exception;
346+
}
347+
}
348+
}

0 commit comments

Comments
 (0)