|
| 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