Repository: SAP/cloud-security-xsuaa-integration
Pull request #294 (2020-05-28 12:01:16)

Improved logging
This PR improves the logging so that the requirements of #292 are fulfilled.
It also adds a TokenLogger util class that lets you safely log token content for debugging purposes.


hassler-d: @nenaraab Should #223 be included into this PR as well?

Patch:
diff --git a/token-client/README.md b/token-client/README.md
index 8889c1c4..de411011 100644
--- a/token-client/README.md
+++ b/token-client/README.md
@@ -165,6 +165,18 @@ OAuth2TokenResponse clientCredentialsToken = tokenFlows.passwordTokenFlow()
 
 Make sure to read the API documentation of the `XsuaaTokenFlows` API, to understand what the individual token flows' parameters are for.
 
+## Troubleshooting
+
+For troubleshooting problems with the token service, you can set the logging level for the 
+`com.sap.cloud.security` package to `DEBUG`. Have a look at the 
+[Logging](/java-security/README.md#logging) section for more information on logging.
+
+If you need more detailed network data in your logs, you can also enable debugging for your HTTP client.
+Note that this might leak encoded tokens into your logs. Use with care! 
+For java applications using `HttpClient`, see the  
+[logging documentation](https://hc.apache.org/httpcomponents-client-4.5.x/logging.html).
+For spring applications using rest template, you can set
+`org.springframework.web.client.RestTemplate` to log level `DEBUG`. 
 
 ## Issues
 
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/AbstractOAuth2TokenService.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/AbstractOAuth2TokenService.java
index 40f4bc8d..264a7df3 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/AbstractOAuth2TokenService.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/AbstractOAuth2TokenService.java
@@ -8,7 +8,10 @@ import com.sap.cloud.security.xsuaa.http.HttpHeaders;
 import com.sap.cloud.security.xsuaa.http.HttpHeadersFactory;
 import com.sap.cloud.security.xsuaa.tokenflows.CacheConfiguration;
 import com.sap.cloud.security.xsuaa.tokenflows.Cacheable;
+import com.sap.cloud.security.xsuaa.util.TokenLogger;
 import com.sap.cloud.security.xsuaa.util.UriUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -25,8 +28,10 @@ import static com.sap.cloud.security.xsuaa.client.OAuth2TokenServiceConstants.*;
 @java.lang.SuppressWarnings("squid:S1192")
 public abstract class AbstractOAuth2TokenService implements OAuth2TokenService, Cacheable {
 
+	private static final Logger LOGGER = LoggerFactory.getLogger(AbstractOAuth2TokenService.class);
 	private final Cache<CacheKey, OAuth2TokenResponse> responseCache;
 	private final CacheConfiguration cacheConfiguration;
+	private final TokenLogger tokenLogger;
 
 	public AbstractOAuth2TokenService() {
 		this(CacheConfiguration.DEFAULT, Ticker.systemTicker(), false);
@@ -40,7 +45,6 @@ public abstract class AbstractOAuth2TokenService implements OAuth2TokenService,
 	 */
 	public AbstractOAuth2TokenService(CacheConfiguration cacheConfiguration) {
 		this(cacheConfiguration, Ticker.systemTicker(), false);
-
 	}
 
 	/**
@@ -59,6 +63,12 @@ public abstract class AbstractOAuth2TokenService implements OAuth2TokenService,
 		Assertions.assertNotNull(cacheConfiguration, "cacheConfiguration is required");
 		this.cacheConfiguration = cacheConfiguration;
 		this.responseCache = createResponseCache(cacheTicker, sameThreadCache);
+		this.tokenLogger = TokenLogger.getInstance(LOGGER);
+		if (isCacheDisabled()) {
+			LOGGER.debug("Configured token service with cache disabled");
+		} else {
+			LOGGER.debug("Configured token service with {}", cacheConfiguration);
+		}
 	}
 
 	@Override
@@ -207,20 +217,34 @@ public abstract class AbstractOAuth2TokenService implements OAuth2TokenService,
 
 	private OAuth2TokenResponse getOrRequestAccessToken(URI tokenEndpoint, HttpHeaders headers,
 			Map<String, String> parameters) throws OAuth2ServiceException {
+		LOGGER.debug("Token was requested for endpoint uri={} with headers={} and parameters={}", tokenEndpoint,
+				headers, parameters);
 		CacheKey cacheKey = new CacheKey(tokenEndpoint, headers, parameters);
 		OAuth2TokenResponse oAuth2TokenResponse = responseCache.getIfPresent(cacheKey);
 		if (oAuth2TokenResponse == null) {
+			LOGGER.debug("Token not found in cache, requesting a new one");
 			getAndCacheToken(cacheKey);
 		} else {
+			LOGGER.debug("The token was found in cache");
 			// check if token in cache should be refreshed
 			Duration delta = getCacheConfiguration().getTokenExpirationDelta();
 			Instant expiration = oAuth2TokenResponse.getExpiredAt().minus(delta);
 			if (expiration.isBefore(Instant.now(getClock()))) {
 				// refresh (soon) expired token
+				LOGGER.debug("The cached token needs to be refreshed, requesting a new one");
 				getAndCacheToken(cacheKey);
 			}
 		}
-		return responseCache.getIfPresent(cacheKey);
+		OAuth2TokenResponse response = responseCache.getIfPresent(cacheKey);
+		logDebug(response);
+		return response;
+	}
+
+	private void logDebug(OAuth2TokenResponse response) {
+		String accessToken = response.getAccessToken();
+		if (accessToken != null) {
+			tokenLogger.logDebug(accessToken, "Access token:");
+		}
 	}
 
 	/**
@@ -235,6 +259,7 @@ public abstract class AbstractOAuth2TokenService implements OAuth2TokenService,
 	}
 
 	private void getAndCacheToken(CacheKey cacheKey) throws OAuth2ServiceException {
+		LOGGER.debug("Getting new token from url={} using cacheKey={}", cacheKey.tokenEndpointUri, cacheKey);
 		responseCache.put(cacheKey,
 				requestAccessToken(cacheKey.tokenEndpointUri, cacheKey.headers, cacheKey.parameters));
 	}
@@ -282,6 +307,15 @@ public abstract class AbstractOAuth2TokenService implements OAuth2TokenService,
 		public int hashCode() {
 			return Objects.hash(tokenEndpointUri, headers, parameters);
 		}
+
+		@Override
+		public String toString() {
+			return "CacheKey{" +
+					"tokenEndpointUri=" + tokenEndpointUri +
+					", headers=" + headers +
+					", parameters=" + parameters +
+					'}';
+		}
 	}
 
 }
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenKeyService.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenKeyService.java
index d0777cb4..10a8b72c 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenKeyService.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenKeyService.java
@@ -36,8 +36,11 @@ public class DefaultOAuth2TokenKeyService implements OAuth2TokenKeyService {
 			if (statusCode == HttpStatus.SC_OK) {
 				return bodyAsString;
 			} else {
-				throw OAuth2ServiceException
-						.createWithStatusCodeAndResponseBody("Error retrieving token keys", statusCode, bodyAsString);
+				throw OAuth2ServiceException.builder("Error retrieving token keys")
+						.withUri(tokenKeysEndpointUri)
+						.withStatusCode(statusCode)
+						.withResponseBody(bodyAsString)
+						.build();
 			}
 		} catch (IOException e) {
 			throw new OAuth2ServiceException("Error retrieving token keys: " + e.getMessage());
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenService.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenService.java
index 23ea4921..7e108d6a 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenService.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenService.java
@@ -28,7 +28,7 @@ import static com.sap.cloud.security.xsuaa.client.OAuth2TokenServiceConstants.*;
 
 public class DefaultOAuth2TokenService extends AbstractOAuth2TokenService {
 
-	private static final Logger logger = LoggerFactory.getLogger(DefaultOAuth2TokenService.class);
+	private static final Logger LOGGER = LoggerFactory.getLogger(DefaultOAuth2TokenService.class);
 
 	private final CloseableHttpClient httpClient;
 
@@ -59,16 +59,24 @@ public class DefaultOAuth2TokenService extends AbstractOAuth2TokenService {
 	}
 
 	private OAuth2TokenResponse executeRequest(HttpPost httpPost) throws OAuth2ServiceException {
+		LOGGER.debug("Requesting access token from url {} with headers {}", httpPost.getURI(),
+				httpPost.getAllHeaders());
 		try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
 			int statusCode = response.getStatusLine().getStatusCode();
+			LOGGER.debug("Received statusCode {}", statusCode);
 			if (statusCode == HttpStatus.SC_OK) {
 				return handleResponse(response);
 			} else {
 				String responseBodyAsString = HttpClientUtil.extractResponseBodyAsString(response);
-				throw OAuth2ServiceException
-						.createWithStatusCodeAndResponseBody("Error retrieving JWT token", statusCode,
-								responseBodyAsString);
+				LOGGER.debug("Received response body: {}", responseBodyAsString);
+				throw OAuth2ServiceException.builder("Error retrieving JWT token")
+						.withStatusCode(statusCode)
+						.withUri(httpPost.getURI())
+						.withResponseBody(responseBodyAsString)
+						.build();
 			}
+		} catch (OAuth2ServiceException e) {
+			throw e;
 		} catch (IOException e) {
 			throw new OAuth2ServiceException("Unexpected error retrieving JWT token: " + e.getMessage());
 		}
@@ -77,7 +85,6 @@ public class DefaultOAuth2TokenService extends AbstractOAuth2TokenService {
 	private OAuth2TokenResponse handleResponse(HttpResponse response) throws IOException {
 		String responseBody = HttpClientUtil.extractResponseBodyAsString(response);
 		Map<String, Object> accessTokenMap = new JSONObject(responseBody).toMap();
-		logger.debug("Request Access Token: {}", accessTokenMap);
 		return convertToOAuth2TokenResponse(accessTokenMap);
 	}
 
@@ -86,7 +93,6 @@ public class DefaultOAuth2TokenService extends AbstractOAuth2TokenService {
 		String accessToken = getParameter(accessTokenMap, ACCESS_TOKEN);
 		String refreshToken = getParameter(accessTokenMap, REFRESH_TOKEN);
 		String expiresIn = getParameter(accessTokenMap, EXPIRES_IN);
-
 		return new OAuth2TokenResponse(accessToken, convertExpiresInToLong(expiresIn),
 				refreshToken);
 	}
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOidcConfigurationService.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOidcConfigurationService.java
index 823fa385..ba067626 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOidcConfigurationService.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOidcConfigurationService.java
@@ -51,21 +51,25 @@ public class DefaultOidcConfigurationService implements OidcConfigurationService
 		try (CloseableHttpResponse response = httpClient.execute(request)) {
 			String bodyAsString = HttpClientUtil.extractResponseBodyAsString(response);
 			int statusCode = response.getStatusLine().getStatusCode();
-			return handleResponse(bodyAsString, statusCode);
+			return handleResponse(bodyAsString, statusCode, discoveryEndpointUri);
 		} catch (IOException e) {
-			throw new OAuth2ServiceException(
-					"Error retrieving configured oidc endpoints from " + discoveryEndpointUri + " : " + e.getMessage());
+			throw OAuth2ServiceException.builder("Error retrieving configured oidc endpoints: " + e.getMessage())
+					.withUri(discoveryEndpointUri)
+					.build();
 		}
 	}
 
-	private OAuth2ServiceEndpointsProvider handleResponse(String bodyAsString, int statusCode)
+	private OAuth2ServiceEndpointsProvider handleResponse(String bodyAsString, int statusCode,
+			URI discoveryEndpointUri)
 			throws OAuth2ServiceException {
 		if (statusCode == HttpStatus.SC_OK) {
 			return new OidcEndpointsProvider(bodyAsString);
 		} else {
-			throw OAuth2ServiceException
-					.createWithStatusCodeAndResponseBody("Error retrieving configured oidc endpoints", statusCode,
-							bodyAsString);
+			throw OAuth2ServiceException.builder("Error retrieving configured oidc endpoints")
+					.withUri(discoveryEndpointUri)
+					.withStatusCode(statusCode)
+					.withResponseBody(bodyAsString)
+					.build();
 		}
 	}
 
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2ServiceException.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2ServiceException.java
index 48d902e3..e82c6b4c 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2ServiceException.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2ServiceException.java
@@ -1,6 +1,10 @@
 package com.sap.cloud.security.xsuaa.client;
 
 import java.io.IOException;
+import java.net.URI;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 /**
  * Exception thrown to signal issues during communication with OAuth2 server.
@@ -13,10 +17,54 @@ public class OAuth2ServiceException extends IOException {
 		super(message);
 	}
 
-	public static OAuth2ServiceException createWithStatusCodeAndResponseBody(String message, int statusCode,
-			String responseBodyAsString) {
-		return new OAuth2ServiceException(
-				String.format("%s. Received status code %s. Call to OAuth2 server was not successful: %s",
-						message, statusCode, responseBodyAsString));
+	public static OAuth2ServiceExceptionBuilder builder(String message) {
+		return new OAuth2ServiceExceptionBuilder(message);
+	}
+
+	public static class OAuth2ServiceExceptionBuilder {
+		private String message;
+		private Integer statusCode;
+		private URI serverUri;
+		private String responseBody;
+
+		public OAuth2ServiceExceptionBuilder(String message) {
+			this.message = message;
+		}
+
+		public OAuth2ServiceExceptionBuilder withStatusCode(int statusCode) {
+			this.statusCode = statusCode;
+			return this;
+		}
+
+		public OAuth2ServiceExceptionBuilder withUri(URI serverUri) {
+			this.serverUri = serverUri;
+			return this;
+		}
+
+		public OAuth2ServiceExceptionBuilder withResponseBody(String responseBody) {
+			this.responseBody = responseBody;
+			return this;
+		}
+
+		public OAuth2ServiceException build() {
+			String message = Stream
+					.of(this.message, createUriMessage(), createStatusCodeMessage(), createResponseBodyMessage())
+					.filter(Objects::nonNull)
+					.collect(Collectors.joining(". "));
+			return new OAuth2ServiceException(message);
+		}
+
+		private String createResponseBodyMessage() {
+			return responseBody == null ? null : "Response body " + responseBody;
+		}
+
+		private String createStatusCodeMessage() {
+			return statusCode == null ? null : "Status code " + statusCode;
+		}
+
+		private String createUriMessage() {
+			return serverUri == null ? null : "Server URI " + serverUri;
+		}
+
 	}
 }
\ No newline at end of file
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyService.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyService.java
index 71a25a25..7bd44b94 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyService.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyService.java
@@ -26,15 +26,22 @@ public class SpringOAuth2TokenKeyService implements OAuth2TokenKeyService {
 			if (HttpStatus.OK.value() == response.getStatusCode().value()) {
 				return response.getBody();
 			} else {
-				throw OAuth2ServiceException.createWithStatusCodeAndResponseBody("Error retrieving token keys",
-						response.getStatusCodeValue(), response.getBody());
+				throw OAuth2ServiceException.builder("Error retrieving token keys")
+						.withUri(tokenKeysEndpointUri)
+						.withStatusCode(response.getStatusCodeValue())
+						.withResponseBody(response.getBody())
+						.build();
 			}
 		} catch (HttpClientErrorException ex) {
-			throw OAuth2ServiceException.createWithStatusCodeAndResponseBody("Error retrieving token keys",
-					ex.getStatusCode().value(), ex.getResponseBodyAsString());
+			throw OAuth2ServiceException.builder("Error retrieving token keys")
+					.withUri(tokenKeysEndpointUri)
+					.withStatusCode(ex.getStatusCode().value())
+					.withResponseBody(ex.getResponseBodyAsString())
+					.build();
 		} catch (Exception e) {
-			throw OAuth2ServiceException.createWithStatusCodeAndResponseBody("Unexpected error retrieving token keys",
-					500, e.getMessage());
+			throw OAuth2ServiceException.builder("Unexpected error retrieving token keys: " + e.getMessage())
+					.withUri(tokenKeysEndpointUri)
+					.build();
 		}
 	}
 
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/SpringOidcConfigurationService.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/SpringOidcConfigurationService.java
index 43422f76..f07e8a1c 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/SpringOidcConfigurationService.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/SpringOidcConfigurationService.java
@@ -28,14 +28,18 @@ public class SpringOidcConfigurationService implements OidcConfigurationService
 			if (HttpStatus.OK.value() == response.getStatusCode().value()) {
 				return new DefaultOidcConfigurationService.OidcEndpointsProvider(response.getBody());
 			} else {
-				throw OAuth2ServiceException.createWithStatusCodeAndResponseBody(
-						"Error retrieving configured oidc endpoints",
-						response.getStatusCodeValue(), response.getBody());
+				throw OAuth2ServiceException.builder("Error retrieving configured oidc endpoints")
+						.withUri(discoveryEndpointUri)
+						.withStatusCode(response.getStatusCodeValue())
+						.withResponseBody(response.getBody())
+						.build();
 			}
 		} catch (HttpClientErrorException ex) {
-			throw OAuth2ServiceException.createWithStatusCodeAndResponseBody(
-					"Error retrieving configured oidc endpoints",
-					ex.getStatusCode().value(), ex.getResponseBodyAsString());
+			throw OAuth2ServiceException.builder("Error retrieving configured oidc endpoints")
+					.withUri(discoveryEndpointUri)
+					.withStatusCode(ex.getStatusCode().value())
+					.withResponseBody(ex.getResponseBodyAsString())
+					.build();
 		}
 	}
 
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenService.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenService.java
index c89c46fd..6f82cfa4 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenService.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenService.java
@@ -2,6 +2,7 @@ package com.sap.cloud.security.xsuaa.client;
 
 import com.sap.cloud.security.xsuaa.http.HttpHeaders;
 import com.sap.cloud.security.xsuaa.tokenflows.CacheConfiguration;
+import com.sap.cloud.security.xsuaa.util.TokenLogger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.http.HttpEntity;
@@ -26,8 +27,8 @@ import static com.sap.cloud.security.xsuaa.client.OAuth2TokenServiceConstants.*;
  */
 public class XsuaaOAuth2TokenService extends AbstractOAuth2TokenService {
 
+	private static Logger LOGGER = LoggerFactory.getLogger(XsuaaOAuth2TokenService.class);
 	private final RestOperations restOperations;
-	private static Logger logger = LoggerFactory.getLogger(XsuaaOAuth2TokenService.class);
 
 	public XsuaaOAuth2TokenService() {
 		this(new RestTemplate(), CacheConfiguration.DEFAULT);
@@ -65,6 +66,7 @@ public class XsuaaOAuth2TokenService extends AbstractOAuth2TokenService {
 		@SuppressWarnings("rawtypes")
 		ResponseEntity<Map> responseEntity = null;
 		try {
+			LOGGER.debug("Requesting access token from url='{}' and headers={}", requestUri, springHeaders);
 			responseEntity = restOperations.postForEntity(requestUri, requestEntity, Map.class);
 		} catch (HttpClientErrorException ex) {
 			String warningMsg = String.format(
@@ -74,13 +76,13 @@ public class XsuaaOAuth2TokenService extends AbstractOAuth2TokenService {
 		} catch (HttpServerErrorException ex) {
 			String warningMsg = String.format("Server error while obtaining access token from XSUAA (%s): %s",
 					ex.getStatusCode(), ex.getResponseBodyAsString());
-			logger.error(warningMsg, ex);
+			LOGGER.error(warningMsg, ex);
 			throw new OAuth2ServiceException(warningMsg);
 		}
+		LOGGER.debug("Received statusCode {}", responseEntity.getStatusCode());
 
 		@SuppressWarnings("unchecked")
 		Map<String, String> accessTokenMap = responseEntity.getBody();
-		logger.debug("Request Access Token: {}", responseEntity.getBody());
 
 		String accessToken = accessTokenMap.get(ACCESS_TOKEN);
 		long expiresIn = Long.parseLong(String.valueOf(accessTokenMap.get(EXPIRES_IN)));
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/http/HttpHeaders.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/http/HttpHeaders.java
index 1cba9556..c0b683bf 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/http/HttpHeaders.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/http/HttpHeaders.java
@@ -37,4 +37,11 @@ public class HttpHeaders {
 	public int hashCode() {
 		return Objects.hash(headers);
 	}
+
+	@Override
+	public String toString() {
+		return "HttpHeaders{" +
+				"headers=" + headers +
+				'}';
+	}
 }
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/CacheConfiguration.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/CacheConfiguration.java
index 084803e7..6cc64cc7 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/CacheConfiguration.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/CacheConfiguration.java
@@ -104,4 +104,13 @@ public class CacheConfiguration {
 	public int hashCode() {
 		return Objects.hash(expireAfterWrite, cacheSize);
 	}
+
+	@Override
+	public String toString() {
+		return "CacheConfiguration{" +
+				"expireAfterWrite=" + expireAfterWrite +
+				", cacheSize=" + cacheSize +
+				", tokenExpirationDelta=" + tokenExpirationDelta +
+				'}';
+	}
 }
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/util/TokenLogger.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/util/TokenLogger.java
new file mode 100644
index 00000000..c14d07a5
--- /dev/null
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/util/TokenLogger.java
@@ -0,0 +1,90 @@
+package com.sap.cloud.security.xsuaa.util;
+
+import com.sap.cloud.security.xsuaa.Assertions;
+import com.sap.cloud.security.xsuaa.jwt.Base64JwtDecoder;
+import com.sap.cloud.security.xsuaa.jwt.DecodedJwt;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import static java.lang.System.*;
+
+/**
+ * Logs the decoded jwt token.
+ */
+public class TokenLogger {
+
+	private static final String TAB = "\t";
+	private static final Logger INTERNAL_LOGGER = LoggerFactory.getLogger(TokenLogger.class);
+
+	private final Logger wrappedLogger;
+
+	/**
+	 * Creates a new instance for the given {@code logger}.
+	 * 
+	 * @param logger
+	 *            the logger that is used to log the token
+	 * @return a new instance
+	 */
+	public static TokenLogger getInstance(Logger logger) {
+		return new TokenLogger(logger);
+	}
+
+	private TokenLogger(Logger logger) {
+		Assertions.assertNotNull(logger, "Logger must be provided!");
+		this.wrappedLogger = logger;
+	}
+
+	/**
+	 * Decodes the given {@code token} string using
+	 * {@link #convertToReadableFormat(String)} and logs the payload and header
+	 * content on debug logging level with the provided logger. A description can be
+	 * provided that will be logged alongside the token.
+	 *
+	 * @param token
+	 *            the encoded jwt token string
+	 * @param description
+	 *            an optional textual description of the token being logged
+	 */
+	public void logDebug(String token, @Nullable String description) {
+		if (!wrappedLogger.isDebugEnabled()) {
+			return;
+		}
+		String convertedToken = convertToReadableFormat(token);
+		if (convertedToken.isEmpty()) {
+			return;
+		}
+		if (description != null) {
+			wrappedLogger.debug(description + lineSeparator() + convertedToken);
+		} else {
+			wrappedLogger.debug(convertedToken);
+		}
+	}
+
+	/**
+	 * Utility method to transform a encoded token to a human readable format while
+	 * omitting the token signature. This can be used to inspect the token for
+	 * debugging purposes. This method does not log the token! Use
+	 * {@link #logDebug(String, String)} for this. If the token is malformed or
+	 * null, this method will return the empty string and not throw exceptions.
+	 *
+	 * @param token
+	 *            the jwt string
+	 * @return readable format of the token
+	 */
+	public static String convertToReadableFormat(String token) {
+		StringBuilder stringBuilder = new StringBuilder();
+		try {
+			DecodedJwt decodedJwt = Base64JwtDecoder.getInstance().decode(token);
+			stringBuilder.append("Jwt header" + lineSeparator());
+			stringBuilder.append(TAB + decodedJwt.getHeader() + lineSeparator());
+			stringBuilder.append("Jwt payload" + lineSeparator());
+			stringBuilder.append(TAB + decodedJwt.getPayload() + lineSeparator());
+		} catch (Exception e) {
+			INTERNAL_LOGGER.debug("Could not convert token to readable format:", e.getMessage());
+			return "";
+		}
+		return stringBuilder.toString();
+	}
+}


Test patch:
diff --git a/java-security/src/test/java/com/sap/cloud/security/adapter/xs/XSUserInfoAdapterIntegrationTest.java b/java-security/src/test/java/com/sap/cloud/security/adapter/xs/XSUserInfoAdapterIntegrationTest.java
index a71c8812..f953f93c 100644
--- a/java-security/src/test/java/com/sap/cloud/security/adapter/xs/XSUserInfoAdapterIntegrationTest.java
+++ b/java-security/src/test/java/com/sap/cloud/security/adapter/xs/XSUserInfoAdapterIntegrationTest.java
@@ -400,9 +400,12 @@ public class XSUserInfoAdapterIntegrationTest {
 				eq(clientCredentials),
 				eq(correctEnduserInfo.getAppToken()),
 				any(),
-				anyMap())).thenThrow(OAuth2ServiceException
-						.createWithStatusCodeAndResponseBody("Unauthorized", org.apache.http.HttpStatus.SC_UNAUTHORIZED,
-								"Unauthorized"));
+				anyMap()))
+				.thenThrow(OAuth2ServiceException.builder("Unauthorized")
+					.withStatusCode(org.apache.http.HttpStatus.SC_UNAUTHORIZED)
+					.withUri(tokenEndpointUri)
+					.build());
+
 		// execute flow
 		correctEnduserInfo.requestToken(request);
 	}
diff --git a/java-security/src/test/java/com/sap/cloud/security/servlet/XsuaaTokenAuthenticatorTest.java b/java-security/src/test/java/com/sap/cloud/security/servlet/XsuaaTokenAuthenticatorTest.java
index a290d2ef..cb7c4ac6 100644
--- a/java-security/src/test/java/com/sap/cloud/security/servlet/XsuaaTokenAuthenticatorTest.java
+++ b/java-security/src/test/java/com/sap/cloud/security/servlet/XsuaaTokenAuthenticatorTest.java
@@ -14,14 +14,12 @@ import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.methods.HttpGet;
 import org.apache.http.impl.client.CloseableHttpClient;
 import org.junit.Before;
-import org.junit.Ignore;
 import org.junit.Test;
 import org.mockito.Mockito;
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
-import java.util.Arrays;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.assertj.core.api.Assertions.assertThat;
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenServiceTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenServiceTest.java
index 4c691a26..fb9749c9 100644
--- a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenServiceTest.java
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenServiceTest.java
@@ -21,9 +21,9 @@ import java.io.IOException;
 import java.net.URI;
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
-import java.util.Collections;
 import java.util.Map;
 
+import static java.util.Collections.*;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
@@ -37,6 +37,7 @@ public class DefaultOAuth2TokenServiceTest {
 	private static final String VALID_JSON_RESPONSE = String
 			.format("{expires_in: 10000, access_token: %s, refresh_token: %s}",
 					ACCESS_TOKEN, REFRESH_TOKEN);
+	private static final URI TOKEN_ENDPOINT_URI = URI.create("https://subdomain.myauth.server.com/oauth/token");
 
 	private CloseableHttpClient mockHttpClient;
 	private DefaultOAuth2TokenService cut;
@@ -52,7 +53,7 @@ public class DefaultOAuth2TokenServiceTest {
 		CloseableHttpResponse response = HttpClientTestFactory.createHttpResponse("{}");
 		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
 
-		assertThatThrownBy(() -> requestAccessToken(Collections.emptyMap()))
+		assertThatThrownBy(() -> requestAccessToken(emptyMap()))
 				.isInstanceOf(OAuth2ServiceException.class)
 				.hasMessageContaining("expires_in");
 	}
@@ -62,7 +63,7 @@ public class DefaultOAuth2TokenServiceTest {
 		CloseableHttpResponse response = HttpClientTestFactory.createHttpResponse(VALID_JSON_RESPONSE);
 		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
 
-		OAuth2TokenResponse re = requestAccessToken(Collections.emptyMap());
+		OAuth2TokenResponse re = requestAccessToken(emptyMap());
 
 		assertThat(re.getAccessToken()).isEqualTo(ACCESS_TOKEN);
 		assertThat(re.getRefreshToken()).isEqualTo(REFRESH_TOKEN);
@@ -92,15 +93,16 @@ public class DefaultOAuth2TokenServiceTest {
 				.createHttpResponse(unauthorizedResponseText, HttpStatus.SC_UNAUTHORIZED);
 		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
 
-		assertThatThrownBy(() -> requestAccessToken(Collections.emptyMap()))
+		assertThatThrownBy(() -> requestAccessToken(emptyMap()))
 				.isInstanceOf(OAuth2ServiceException.class)
-				.hasMessageContaining(unauthorizedResponseText);
+				.hasMessageContaining(unauthorizedResponseText)
+				.hasMessageContaining(String.valueOf(HttpStatus.SC_UNAUTHORIZED))
+				.hasMessageContaining(TOKEN_ENDPOINT_URI.toString());
 	}
 
-	private OAuth2TokenResponse requestAccessToken(Map<String, String> parameters) throws OAuth2ServiceException {
-		URI tokenEndpointUri = URI.create("https://subdomain.myauth.server.com/oauth/token");
+	private OAuth2TokenResponse requestAccessToken(Map<String, String> optionalParameters) throws OAuth2ServiceException {
 		HttpHeaders withoutAuthorizationHeader = HttpHeadersFactory.createWithoutAuthorizationHeader();
-		return cut.requestAccessToken(tokenEndpointUri, withoutAuthorizationHeader, parameters);
+		return cut.requestAccessToken(TOKEN_ENDPOINT_URI, withoutAuthorizationHeader, optionalParameters);
 	}
 
 }
\ No newline at end of file
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyServiceTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyServiceTest.java
index bd3c972a..07ce2a56 100644
--- a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyServiceTest.java
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyServiceTest.java
@@ -63,6 +63,8 @@ public class SpringOAuth2TokenKeyServiceTest {
 		mockResponse(errorMessage, HttpStatus.BAD_REQUEST);
 		assertThatThrownBy(() -> cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI))
 				.isInstanceOf(OAuth2ServiceException.class)
+				.hasMessageContaining(TOKEN_KEYS_ENDPOINT_URI.toString())
+				.hasMessageContaining(String.valueOf(HttpStatus.BAD_REQUEST.value()))
 				.hasMessageContaining(errorMessage);
 	}
 
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/util/TokenLoggerTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/util/TokenLoggerTest.java
new file mode 100644
index 00000000..6c4da090
--- /dev/null
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/util/TokenLoggerTest.java
@@ -0,0 +1,103 @@
+package com.sap.cloud.security.xsuaa.util;
+
+import com.sap.cloud.security.xsuaa.jwt.Base64JwtDecoder;
+import com.sap.cloud.security.xsuaa.jwt.DecodedJwt;
+import com.sap.cloud.security.xsuaa.test.JwtGenerator;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.slf4j.Logger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.when;
+
+public class TokenLoggerTest {
+
+	private static final String JKU_HEADER = "http://my.jku/token_keys";
+	private static final String CLIENT_ID = "clientId123";
+	private static final String[] SCOPES = { "scope1", "scope2" };
+
+	private final static String TOKEN = new JwtGenerator(CLIENT_ID)
+			.addScopes(SCOPES)
+			.setJku(JKU_HEADER)
+			.getToken().getTokenValue();
+	private static final DecodedJwt DECODED_JWT = Base64JwtDecoder.getInstance().decode(TOKEN);
+
+	private TokenLogger cut;
+	private Logger loggerMock;
+
+	@Before
+	public void setUp() {
+		loggerMock = mock(Logger.class);
+		when(loggerMock.isDebugEnabled()).thenReturn(true);
+		cut = TokenLogger.getInstance(loggerMock);
+	}
+
+	@Test
+	public void convertToReadableFormat_displaysRelevantData() {
+		String logEntry = TokenLogger.convertToReadableFormat(TOKEN);
+		assertThat(logEntry)
+				.contains("Jwt header")
+				.contains(JKU_HEADER)
+				.contains("Jwt payload")
+				.contains(CLIENT_ID)
+				.contains(SCOPES);
+	}
+
+	@Test
+	public void convertToReadableFormat_doesNotContainSignatureNorEncodedToken() {
+		String logEntry = TokenLogger.convertToReadableFormat(TOKEN);
+		assertThat(logEntry)
+				.doesNotContain(DECODED_JWT.getSignature())
+				.doesNotContain(TOKEN);
+	}
+
+	@Test
+	public void convertToReadableFormat_inputIsNotAToken_returnsEmptyString() {
+		assertThat(TokenLogger.convertToReadableFormat("")).isEmpty();
+		assertThat(TokenLogger.convertToReadableFormat("not.a.token")).isEmpty();
+		assertThat(TokenLogger.convertToReadableFormat(null)).isEmpty();
+	}
+
+	@Test
+	public void logToken_containsDescriptionAndConvertedToken() {
+		ArgumentCaptor<String> debugLogCaptor = ArgumentCaptor.forClass(String.class);
+		cut.logDebug(TOKEN, "useful description");
+
+		verify(loggerMock, times(1)).debug(debugLogCaptor.capture());
+
+		assertThat(debugLogCaptor.getValue())
+				.contains("useful description")
+				.contains(TokenLogger.convertToReadableFormat(TOKEN));
+	}
+	@Test
+	public void logToken_descriptionIsNull_logsTokenOnly() {
+		ArgumentCaptor<String> debugLogCaptor = ArgumentCaptor.forClass(String.class);
+		cut.logDebug(TOKEN, null);
+
+		verify(loggerMock, times(1)).debug(debugLogCaptor.capture());
+		assertThat(debugLogCaptor.getValue()).isEqualTo(TokenLogger.convertToReadableFormat(TOKEN));
+	}
+
+	@Test
+	public void logToken_doesNotLogSignatureNorEncodedToken() {
+		ArgumentCaptor<String> debugLogCaptor = ArgumentCaptor.forClass(String.class);
+		cut.logDebug(TOKEN, "");
+
+		verify(loggerMock, times(1)).debug(debugLogCaptor.capture());
+
+		assertThat(debugLogCaptor.getValue())
+				.doesNotContain(TOKEN)
+				.doesNotContain(DECODED_JWT.getSignature());
+	}
+
+	@Test
+	public void logToken_tokenIsNotDecodable_doesNotLog() {
+		cut.logDebug("not.a.token", "useful description");
+
+		verify(loggerMock, times(1)).isDebugEnabled();
+		verifyNoMoreInteractions(loggerMock);
+	}
+}
\ No newline at end of file