Repository: SAP/cloud-security-xsuaa-integration
Pull request #280 (2020-05-12 08:24:26)

Token client cache
This PR adds a Cache to the token client. The cache can be configured and disabled for the token service.


Patch:
diff --git a/README.md b/README.md
index 0e2696a1..675842ee 100644
--- a/README.md
+++ b/README.md
@@ -64,8 +64,7 @@ Spring Boot provides OAuth resource servers. Application developers requiring au
 See [spring-security-xsuaa-usage](samples/spring-security-xsuaa-usage) for an example.
 
 ### Additional (test) utilities
-- [spring-xsuaa-mock](./spring-xsuaa-mock) offers a Mock web server to provide token keys for offline token validation in case xsuaa service (OAuth resource-server) is not in place. Its use is only intended for unit tests and when running your Spring boot application on a Webserver locally.
-- [spring-xsuaa-test](./spring-xsuaa-test) offers test utilities to generate custom JWT tokens for the purpose of tests.
+- [java-security-test](./java-security-test) offers test utilities to generate custom JWT tokens for the purpose of tests. It pre-configures a [WireMock](http://wiremock.org/docs/getting-started/) web server to stub outgoing calls to the identity service (OAuth resource-server), e.g. to provide token keys for offline token validation. Its use is only intended for JUnit tests.
 
 Find [here](https://github.com/SAP/cloud-application-security-sample/tree/master/spring-security-basis) a more sophisticated sample.
 
diff --git a/api/src/main/java/com/sap/xsa/security/container/XSUserInfo.java b/api/src/main/java/com/sap/xsa/security/container/XSUserInfo.java
index f3f20edb..9747256f 100644
--- a/api/src/main/java/com/sap/xsa/security/container/XSUserInfo.java
+++ b/api/src/main/java/com/sap/xsa/security/container/XSUserInfo.java
@@ -34,7 +34,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public String getLogonName() throws XSUserInfoException;
+	String getLogonName() throws XSUserInfoException;
 
 	/**
 	 * Given name of the user.
@@ -47,7 +47,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public String getGivenName() throws XSUserInfoException;
+	String getGivenName() throws XSUserInfoException;
 
 	/**
 	 * Familiy name of the user.
@@ -60,7 +60,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public String getFamilyName() throws XSUserInfoException;
+	String getFamilyName() throws XSUserInfoException;
 
 	/**
 	 * Return the user origin. The origin is an alias that refers to a user store in
@@ -75,7 +75,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public String getOrigin() throws XSUserInfoException;
+	String getOrigin() throws XSUserInfoException;
 
 	/**
 	 * Return identity zone which is the same like the subaccount id (tenant id).
@@ -100,17 +100,19 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public String getSubaccountId() throws XSUserInfoException;
+	String getSubaccountId() throws XSUserInfoException;
 
 	/**
-	 * Still Supported via {@code XSUserInfoAdapter} from the
-	 * {@code com.sap.cloud.security.adapter.xs} package.
+	 *
+	 * Supported via {@code XSUserInfoAdapter} from the
+	 * {@code com.sap.cloud.security.adapter.xs} package. Also available on tokens
+	 * of type {@code XsuaaToken} from java-security.
 	 * 
 	 * @return the subdomain
 	 * @throws XSUserInfoException
 	 *             if subdomain is not available in the authentication token
 	 */
-	public String getSubdomain() throws XSUserInfoException;
+	String getSubdomain() throws XSUserInfoException;
 
 	/**
 	 * Return the client id of the authentication token
@@ -122,7 +124,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public String getClientId() throws XSUserInfoException;
+	String getClientId() throws XSUserInfoException;
 
 	/**
 	 * @deprecated Can be replaced with {@code token.getClaimAsString(attribute)}
@@ -134,7 +136,7 @@ public interface XSUserInfo {
 	 *             if attribute is not available in the authentication token
 	 */
 	@Deprecated
-	public String getJsonValue(String attribute) throws XSUserInfoException;
+	String getJsonValue(String attribute) throws XSUserInfoException;
 
 	/**
 	 * Return the email of the user
@@ -147,7 +149,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public String getEmail() throws XSUserInfoException;
+	String getEmail() throws XSUserInfoException;
 
 	/**
 	 * @deprecated use {@link #getHdbToken()} instead.
@@ -156,7 +158,7 @@ public interface XSUserInfo {
 	 *             if db token is not available in the authentication token
 	 */
 	@Deprecated
-	public String getDBToken() throws XSUserInfoException;
+	String getDBToken() throws XSUserInfoException;
 
 	/**
 	 * Still Supported via {@code XSUserInfoAdapter} from the
@@ -166,7 +168,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if db token is not available in the authentication token
 	 */
-	public String getHdbToken() throws XSUserInfoException;
+	String getHdbToken() throws XSUserInfoException;
 
 	/**
 	 * Return authentication token
@@ -175,7 +177,7 @@ public interface XSUserInfo {
 	 *             {@code com.sap.cloud.security.token} package.
 	 * @return authentication token
 	 */
-	public String getAppToken();
+	String getAppToken();
 
 	/**
 	 * Still Supported via {@code XSUserInfoAdapter} from the
@@ -192,7 +194,7 @@ public interface XSUserInfo {
 	 * @deprecated use {@link #getHdbToken()} instead.
 	 */
 	@Deprecated
-	public String getToken(String namespace, String name) throws XSUserInfoException;
+	String getToken(String namespace, String name) throws XSUserInfoException;
 
 	/**
 	 * Return user attributes.
@@ -206,7 +208,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public String[] getAttribute(String attributeName) throws XSUserInfoException;
+	String[] getAttribute(String attributeName) throws XSUserInfoException;
 
 	/**
 	 * Check if the authentication token contains user attributes. Still Supported
@@ -217,7 +219,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public boolean hasAttributes() throws XSUserInfoException;
+	boolean hasAttributes() throws XSUserInfoException;
 
 	/**
 	 * Still Supported via {@code XSUserInfoAdapter} from the
@@ -229,7 +231,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if system attribute is not available in the authentication token
 	 */
-	public String[] getSystemAttribute(String attributeName) throws XSUserInfoException;
+	String[] getSystemAttribute(String attributeName) throws XSUserInfoException;
 
 	/**
 	 * Check if a scope is present in the authentication token
@@ -242,7 +244,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public boolean checkScope(String scope) throws XSUserInfoException;
+	boolean checkScope(String scope) throws XSUserInfoException;
 
 	/**
 	 * Check if a "local" scope is available in the authentication token according
@@ -257,7 +259,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public boolean checkLocalScope(String scope) throws XSUserInfoException;
+	boolean checkLocalScope(String scope) throws XSUserInfoException;
 
 	/**
 	 * Return additional authentication attributes included by the OAuth client
@@ -272,7 +274,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public String getAdditionalAuthAttribute(String attributeName) throws XSUserInfoException;
+	String getAdditionalAuthAttribute(String attributeName) throws XSUserInfoException;
 
 	/**
 	 * In case of xsuaa broker plan tokens, it contains the service instance id
@@ -284,7 +286,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public String getCloneServiceInstanceId() throws XSUserInfoException;
+	String getCloneServiceInstanceId() throws XSUserInfoException;
 
 	/**
 	 * OAuth Grant Type used for this token
@@ -298,7 +300,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public String getGrantType() throws XSUserInfoException;
+	String getGrantType() throws XSUserInfoException;
 
 	/**
 	 * Check if a token issued for another OAuth client has been forwarded to a
@@ -309,7 +311,7 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if attribute is not available in the authentication token
 	 */
-	public boolean isInForeignMode() throws XSUserInfoException;
+	boolean isInForeignMode() throws XSUserInfoException;
 
 	/**
 	 * @param clientId
@@ -324,19 +326,22 @@ public interface XSUserInfo {
 	 * @throws XSUserInfoException
 	 *             if an error occurs during token request
 	 */
-	@Deprecated
-	public String requestTokenForClient(String clientId, String clientSecret, String uaaUrl) throws XSUserInfoException;
+	String requestTokenForClient(String clientId, String clientSecret, String uaaUrl) throws XSUserInfoException;
 
 	/**
 	 * Exchange a token into a token from another service instance
-	 * 
+	 *
 	 * @param tokenRequest
 	 *            request data
 	 * @deprecated can be replaced with token flows from the token-client library.
+	 *             For client credentials flow you can also use
+	 *             {@link #requestTokenForClient(String, String, String)}. Will be
+	 *             removed with version 3.0.0.
 	 * @return requested token
 	 * @throws XSUserInfoException
 	 *             if an error occurs during token exchange
 	 */
-	public String requestToken(XSTokenRequest tokenRequest) throws XSUserInfoException;
+	@Deprecated
+	String requestToken(XSTokenRequest tokenRequest) throws XSUserInfoException;
 
 }
diff --git a/java-api/src/main/java/com/sap/cloud/security/token/TokenClaims.java b/java-api/src/main/java/com/sap/cloud/security/token/TokenClaims.java
index 6c6356a5..e69a10be 100644
--- a/java-api/src/main/java/com/sap/cloud/security/token/TokenClaims.java
+++ b/java-api/src/main/java/com/sap/cloud/security/token/TokenClaims.java
@@ -31,8 +31,9 @@ public final class TokenClaims {
 		public static final String ZONE_ID = "zid"; // tenant GUID, identity zone id, subaccount id
 		public static final String CLIENT_ID = "cid"; // OAuth client identifier
 		public static final String SCOPES = "scope"; // list of scopes including app id, e.g. "my-app!t123.Display"
-		public static final String EXTERNAL_ATTRIBUTE = "ext_attr";
 		public static final String ISSUED_AT = "iat";
+		public static final String EXTERNAL_ATTRIBUTE = "ext_attr";
+		public static final String EXTERNAL_ATTRIBUTE_ZDN = "zdn";
 		public static final String EXTERNAL_ATTRIBUTE_ENHANCER = "enhancer";
 
 		public static final String TRUSTED_CLIENT_ID_SUFFIX = "trustedclientidsuffix";
diff --git a/java-security/Migration_SpringSecurityProjects.md b/java-security/Migration_SpringSecurityProjects.md
index 29c2b6f7..45043a6f 100644
--- a/java-security/Migration_SpringSecurityProjects.md
+++ b/java-security/Migration_SpringSecurityProjects.md
@@ -32,11 +32,6 @@ First make sure you have the following dependencies defined in your pom.xml:
   <artifactId>spring-security-oauth2</artifactId>
   <version>2.4.1.RELEASE</version> <!-- chose the latest from maven repository -->
 </dependency>
-<dependency>
-  <groupId>org.springframework</groupId>
-  <artifactId>spring-aop</artifactId>
-  <version>4.3.27.RELEASE</version> <!-- chose the latest from maven repository -->
-</dependency>
 
 <!-- new java-security dependencies -->
 <dependency>
@@ -259,14 +254,14 @@ application locally make sure that it is still working and finally test the appl
 	```
 	Configuration problem: You cannot use a spring-security-4.0.xsd or spring-security-4.1.xsd schema with Spring Security 4.2. Please update your schema declarations to the 4.2 schema. Offending resource: ServletContext resource [/WEB-INF/spring-security.xml]
 	```  
-	You can fix this by updating the Spring versions as part of the schema declaration in the file `/WEB-INF/spring-security.xml` as suggested as part of the error log, e.g:
+	You can fix this by removing the Spring versions of the schema declaration in the file `/WEB-INF/spring-security.xml`. Without explicit versions set, the latest version will be used.
 	```
 	xsi:schemaLocation="http://www.springframework.org/schema/security/oauth2
-			    http://www.springframework.org/schema/security/spring-security-oauth2-2.0.xsd
+			    http://www.springframework.org/schema/security/spring-security-oauth2.xsd
 			    http://www.springframework.org/schema/security
-			    http://www.springframework.org/schema/security/spring-security-4.2.xsd
+			    http://www.springframework.org/schema/security/spring-security.xsd
 			    http://www.springframework.org/schema/beans
-			    http://www.springframework.org/schema/beans/spring-beans-4.3.xsd
+			    http://www.springframework.org/schema/beans/spring-beans.xsd
 	```
 	
 - org.springframework.beans.factory.xml.XmlBeanDefinitionStoreException  
diff --git a/java-security/src/main/java/com/sap/cloud/security/token/XsuaaToken.java b/java-security/src/main/java/com/sap/cloud/security/token/XsuaaToken.java
index 1b017f83..b89c7386 100644
--- a/java-security/src/main/java/com/sap/cloud/security/token/XsuaaToken.java
+++ b/java-security/src/main/java/com/sap/cloud/security/token/XsuaaToken.java
@@ -10,6 +10,7 @@ import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import java.security.Principal;
 import java.util.LinkedHashSet;
+import java.util.Optional;
 import java.util.Set;
 
 import static com.sap.cloud.security.token.TokenClaims.USER_NAME;
@@ -145,4 +146,23 @@ public class XsuaaToken extends AbstractToken implements AccessToken {
 		return GrantType.from(getClaimAsString(GRANT_TYPE));
 	}
 
+	/**
+	 * Returns the value of the subdomain (zdn) from the external attribute ext_attr
+	 * (ext_attr) claim. If the external attribute or the subdomain is missing, it
+	 * returns {@code null}.
+	 *
+	 * @return the subdomain or {@code null}
+	 */
+	@Nullable
+	public String getSubdomain() {
+		return getAttributeFromClaimAsString(EXTERNAL_ATTRIBUTE, EXTERNAL_ATTRIBUTE_ZDN);
+	}
+
+	@Nullable
+	private String getAttributeFromClaimAsString(String claimName, String attributeName) {
+		return Optional.ofNullable(getClaimAsJsonObject(claimName))
+				.map(claim -> claim.getAsString(attributeName))
+				.orElse(null);
+	}
+
 }
diff --git a/pom.xml b/pom.xml
index 7819c36d..792de785 100644
--- a/pom.xml
+++ b/pom.xml
@@ -56,10 +56,10 @@
 		<maven.compiler.source>1.8</maven.compiler.source>
 		<maven.source.plugin.version>3.1.0</maven.source.plugin.version>
 		<!-- make sure that spring core and spring boot versions are compatible-->
-		<spring.boot.version>2.2.6.RELEASE</spring.boot.version>
-		<spring.core.version>5.2.5.RELEASE</spring.core.version>
-		<spring.security.version>5.3.1.RELEASE</spring.security.version>
-		<spring.security.oauth2.version>2.4.0.RELEASE</spring.security.oauth2.version>
+		<spring.boot.version>2.3.0.RELEASE</spring.boot.version>
+		<spring.core.version>5.2.6.RELEASE</spring.core.version>
+		<spring.security.version>5.3.2.RELEASE</spring.security.version>
+		<spring.security.oauth2.version>2.4.1.RELEASE</spring.security.oauth2.version>
 		<spring.security.jwt.version>1.1.0.RELEASE</spring.security.jwt.version>
 		<reactor.version>3.2.8.RELEASE</reactor.version>
 		<log4j.version>2.13.1</log4j.version>
@@ -67,7 +67,7 @@
 		<org.json.version>20190722</org.json.version>
 		<google.jsr305.version>3.0.2</google.jsr305.version>
 		<apache.httpclient.version>4.5.9</apache.httpclient.version>
-		<caffeine.version>2.6.1</caffeine.version>
+		<caffeine.version>2.8.2</caffeine.version>
 		<commons.io.version>2.6</commons.io.version>
 		<javax.servlet.api.version>3.0.1</javax.servlet.api.version>
 		<mockwebserver.version>3.9.1</mockwebserver.version>
diff --git a/samples/spring-security-basic-auth/pom.xml b/samples/spring-security-basic-auth/pom.xml
index d2d0086b..0d53413c 100644
--- a/samples/spring-security-basic-auth/pom.xml
+++ b/samples/spring-security-basic-auth/pom.xml
@@ -6,7 +6,7 @@
 	<parent>
 		<groupId>org.springframework.boot</groupId>
 		<artifactId>spring-boot-starter-parent</artifactId>
-		<version>2.2.4.RELEASE</version>
+		<version>2.3.0.RELEASE</version>
 		<relativePath/>
 	</parent>
 
diff --git a/samples/spring-security-xsuaa-usage/pom.xml b/samples/spring-security-xsuaa-usage/pom.xml
index 84ccff2c..7733ba29 100644
--- a/samples/spring-security-xsuaa-usage/pom.xml
+++ b/samples/spring-security-xsuaa-usage/pom.xml
@@ -8,7 +8,7 @@
 	<parent>
 		<groupId>org.springframework.boot</groupId>
 		<artifactId>spring-boot-starter-parent</artifactId>
-		<version>2.2.6.RELEASE</version>
+		<version>2.3.0.RELEASE</version>
 		<relativePath/>
 	</parent>
 
diff --git a/samples/spring-webflux-security-xsuaa-usage/pom.xml b/samples/spring-webflux-security-xsuaa-usage/pom.xml
index b8ef022b..875631f0 100644
--- a/samples/spring-webflux-security-xsuaa-usage/pom.xml
+++ b/samples/spring-webflux-security-xsuaa-usage/pom.xml
@@ -6,7 +6,7 @@
     <parent>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-parent</artifactId>
-        <version>2.2.4.RELEASE</version>
+        <version>2.3.0.RELEASE</version>
         <relativePath/>
     </parent>
 
diff --git a/spring-xsuaa/Migration_JavaContainerSecurityProjects.md b/spring-xsuaa/Migration_JavaContainerSecurityProjects.md
index 021f18c5..cf8de712 100644
--- a/spring-xsuaa/Migration_JavaContainerSecurityProjects.md
+++ b/spring-xsuaa/Migration_JavaContainerSecurityProjects.md
@@ -142,7 +142,7 @@ Then you need to adapt your **Spring Security Configuration** as following:
        return new XsuaaJwtDecoderBuilder(customXsuaaConfig()).withTokenValidators(customAudienceValidator).build();
     }
     ```
- 4. Right now, you have to make sure that the `TokenAuthenticationConverter` is NOT configured to check for local scopes (`setLocalScopeAsAuthorities(false)`). This will be supported in one of the next versions!
+ 4. Right now, you have to make sure that the `TokenAuthenticationConverter` is NOT configured to check for local scopes (`setLocalScopeAsAuthorities(false)`).
 
 ## Fetch data from token
 
diff --git a/spring-xsuaa/README.md b/spring-xsuaa/README.md
index 30bb56d5..10191158 100644
--- a/spring-xsuaa/README.md
+++ b/spring-xsuaa/README.md
@@ -1,4 +1,4 @@
-# XSUAA Security 
+# SAP CP Spring XSUAA Security Library 
 
 ## Integrate in a OAuth resource server
 
diff --git a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/XsuaaServicesParser.java b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/XsuaaServicesParser.java
index fe9784bc..e62c9404 100644
--- a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/XsuaaServicesParser.java
+++ b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/XsuaaServicesParser.java
@@ -139,9 +139,9 @@ public class XsuaaServicesParser {
 			JSONObject binding = (JSONObject) jsonArray.get(i);
 			JSONArray tags = (JSONArray) binding.get(TAGS);
 
+			Optional<String> planName = Optional.ofNullable(binding.getAsString("plan"));
+			boolean isApiAccessPlan = (planName.isPresent() && planName.get().equals("apiaccess")) ? true : false;
 			for (int j = 0; j < tags.size(); j++) {
-				boolean isApiAccessPlan = binding.getAsString("plan").equals("apiaccess");
-
 				if (tags.get(j).equals(tag) && !isApiAccessPlan) {
 					if (xsuaaBinding == null) {
 						xsuaaBinding = binding;
diff --git a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/extractor/LocalAuthoritiesExtractor.java b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/extractor/LocalAuthoritiesExtractor.java
index e8a6f937..366e8040 100644
--- a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/extractor/LocalAuthoritiesExtractor.java
+++ b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/extractor/LocalAuthoritiesExtractor.java
@@ -2,6 +2,8 @@ package com.sap.cloud.security.xsuaa.extractor;
 
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -12,30 +14,34 @@ import com.sap.cloud.security.xsuaa.token.XsuaaToken;
 
 public class LocalAuthoritiesExtractor implements AuthoritiesExtractor {
 
-	protected String appId;
+	protected Set<String> appIds = new HashSet<>();
 
-	public LocalAuthoritiesExtractor(String appId) {
-		this.appId = appId;
+	public LocalAuthoritiesExtractor(String... appIds) {
+		Collections.addAll(this.appIds, appIds);
 	}
 
 	@Override
 	public Collection<GrantedAuthority> getAuthorities(XsuaaToken jwt) {
-		Collection<String> scopeAuthorities = getScopes(jwt);
+		Set<String> scopeAuthorities = new HashSet<>();
+
+		appIds.stream().forEach((appId) -> {
+			scopeAuthorities.addAll(getScopes(jwt, appId));
+		});
 
 		Stream<String> authorities = Stream.of(scopeAuthorities).flatMap(Collection::stream);
 
 		return authorities.map(SimpleGrantedAuthority::new).collect(Collectors.toList());
 	}
 
-	protected Collection<String> getScopes(XsuaaToken jwt) {
+	protected Set<String> getScopes(XsuaaToken jwt, String appId) {
 		Collection<String> scopes = jwt.getScopes();
 		if (scopes == null) {
-			return Collections.emptyList();
+			return Collections.emptySet();
 		}
 		return scopes.stream()
 				.filter(scope -> scope.startsWith(appId + "."))
 				.map(scope -> scope.replaceFirst(appId + ".", ""))
-				.collect(Collectors.toList());
+				.collect(Collectors.toSet());
 
 	}
 
diff --git a/token-client/pom.xml b/token-client/pom.xml
index 478b7d90..6410c298 100644
--- a/token-client/pom.xml
+++ b/token-client/pom.xml
@@ -41,6 +41,10 @@
 			<groupId>com.google.code.findbugs</groupId>
 			<artifactId>jsr305</artifactId>
 		</dependency>
+		<dependency>
+			<groupId>com.github.ben-manes.caffeine</groupId>
+			<artifactId>caffeine</artifactId>
+		</dependency>
 
 		<dependency>
 			<groupId>junit</groupId>
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 01f421c9..89a9b53f 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
@@ -1,21 +1,72 @@
 package com.sap.cloud.security.xsuaa.client;
 
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Ticker;
+import com.sap.cloud.security.xsuaa.Assertions;
 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.UriUtil;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import java.net.URI;
 import java.util.Map;
+import java.util.Objects;
 
 import static com.sap.cloud.security.xsuaa.Assertions.assertNotNull;
 import static com.sap.cloud.security.xsuaa.client.OAuth2TokenServiceConstants.*;
 
 @java.lang.SuppressWarnings("squid:S1192")
-public abstract class AbstractOAuth2TokenService implements OAuth2TokenService {
+public abstract class AbstractOAuth2TokenService implements OAuth2TokenService, Cacheable {
+
+	private final Cache<CacheKey, OAuth2TokenResponse> responseCache;
+	private final CacheConfiguration cacheConfiguration;
 
 	public AbstractOAuth2TokenService() {
+		this(Ticker.systemTicker(), false, CacheConfiguration.DEFAULT);
+	}
+
+	/**
+	 * Constructor used to overwrite the default cache configuration.
+	 *
+	 * @param cacheConfiguration
+	 *            the cache configuration used to configure the cache.
+	 */
+	public AbstractOAuth2TokenService(CacheConfiguration cacheConfiguration) {
+		this(Ticker.systemTicker(), false, cacheConfiguration);
+
+	}
+
+	/**
+	 * This constructor is used for testing purposes only.
+	 *
+	 * @param cacheTicker
+	 *            will be used in the cache to determine the time.
+	 * @param sameThreadCache
+	 *            set to true disables maintenance jobs of the cache. This makes the
+	 *            cache slower but more predictable for testing.
+	 * @param cacheConfiguration
+	 *            sets the cache configuration used to configure or disable the
+	 *            cache.
+	 */
+	AbstractOAuth2TokenService(Ticker cacheTicker, boolean sameThreadCache, CacheConfiguration cacheConfiguration) {
+		Assertions.assertNotNull(cacheConfiguration, "cacheConfiguration is required");
+		this.cacheConfiguration = cacheConfiguration;
+		this.responseCache = createResponseCache(cacheTicker, sameThreadCache);
+	}
+
+	@Override
+	public void clearCache() {
+		responseCache.invalidateAll();
+	}
+
+	@Override
+	@Nonnull
+	public CacheConfiguration getCacheConfiguration() {
+		return cacheConfiguration;
 	}
 
 	@Override
@@ -34,7 +85,7 @@ public abstract class AbstractOAuth2TokenService implements OAuth2TokenService {
 
 		HttpHeaders headers = HttpHeadersFactory.createWithoutAuthorizationHeader();
 
-		return requestAccessToken(UriUtil.replaceSubdomain(tokenEndpointUri, subdomain), headers, parameters);
+		return getOAuth2TokenResponse(tokenEndpointUri, headers, parameters, subdomain);
 	}
 
 	@Override
@@ -54,7 +105,7 @@ public abstract class AbstractOAuth2TokenService implements OAuth2TokenService {
 
 		HttpHeaders headers = HttpHeadersFactory.createWithAuthorizationBearerHeader(token);
 
-		return requestAccessToken(UriUtil.replaceSubdomain(tokenEndpointUri, subdomain), headers, parameters);
+		return getOAuth2TokenResponse(tokenEndpointUri, headers, parameters, subdomain);
 	}
 
 	@Override
@@ -73,7 +124,7 @@ public abstract class AbstractOAuth2TokenService implements OAuth2TokenService {
 
 		HttpHeaders headers = HttpHeadersFactory.createWithoutAuthorizationHeader();
 
-		return requestAccessToken(UriUtil.replaceSubdomain(tokenEndpointUri, subdomain), headers, parameters);
+		return getOAuth2TokenResponse(tokenEndpointUri, headers, parameters, subdomain);
 	}
 
 	@Override
@@ -96,7 +147,7 @@ public abstract class AbstractOAuth2TokenService implements OAuth2TokenService {
 
 		HttpHeaders headers = HttpHeadersFactory.createWithoutAuthorizationHeader();
 
-		return requestAccessToken(UriUtil.replaceSubdomain(tokenEndpoint, subdomain), headers, parameters);
+		return getOAuth2TokenResponse(tokenEndpoint, headers, parameters, subdomain);
 	}
 
 	@Override
@@ -116,7 +167,7 @@ public abstract class AbstractOAuth2TokenService implements OAuth2TokenService {
 
 		HttpHeaders headers = HttpHeadersFactory.createWithoutAuthorizationHeader();
 
-		return requestAccessToken(UriUtil.replaceSubdomain(tokenEndpoint, subdomain), headers, parameters);
+		return getOAuth2TokenResponse(tokenEndpoint, headers, parameters, subdomain);
 	}
 
 	/**
@@ -137,4 +188,70 @@ public abstract class AbstractOAuth2TokenService implements OAuth2TokenService {
 	protected abstract OAuth2TokenResponse requestAccessToken(URI tokenEndpointUri, HttpHeaders headers,
 			Map<String, String> parameters) throws OAuth2ServiceException;
 
+	private OAuth2TokenResponse getOAuth2TokenResponse(@Nonnull URI tokenEndpointUri, HttpHeaders headers,
+			Map<String, String> additionalParameters,
+			@Nullable String subdomain) throws OAuth2ServiceException {
+		URI tokenEndpointUriWithSubdomainReplaced = UriUtil.replaceSubdomain(tokenEndpointUri, subdomain);
+		if (isCacheEnabled()) {
+			return getOrRequestAccessToken(tokenEndpointUriWithSubdomainReplaced, headers, additionalParameters);
+		}
+		return requestAccessToken(tokenEndpointUriWithSubdomainReplaced, headers, additionalParameters);
+	}
+
+	private OAuth2TokenResponse getOrRequestAccessToken(URI tokenEndpointUriWithSubdomainReplaced, HttpHeaders headers,
+			Map<String, String> parameters) throws OAuth2ServiceException {
+		CacheKey cacheKey = new CacheKey(tokenEndpointUriWithSubdomainReplaced, headers, parameters);
+		OAuth2TokenResponse oAuth2TokenResponse = responseCache.getIfPresent(cacheKey);
+		if (oAuth2TokenResponse == null) {
+			responseCache.put(cacheKey, requestAccessToken(tokenEndpointUriWithSubdomainReplaced, headers,
+					parameters));
+		}
+		return responseCache.getIfPresent(cacheKey);
+	}
+
+	private boolean isCacheEnabled() {
+		return !CacheConfiguration.CACHE_DISABLED.equals(getCacheConfiguration());
+	}
+
+	private Cache<CacheKey, OAuth2TokenResponse> createResponseCache(Ticker cacheTicker, boolean sameThreadCache) {
+		Caffeine<Object, Object> cacheBuilder = Caffeine.newBuilder()
+				.maximumSize(getCacheConfiguration().getCacheSize())
+				.ticker(cacheTicker)
+				.expireAfterWrite(getCacheConfiguration().getExpireAfterWrite());
+		if (sameThreadCache) {
+			cacheBuilder.executor(Runnable::run);
+		}
+		return cacheBuilder.build();
+	}
+
+	private class CacheKey {
+
+		private final URI tokenEndpointUri;
+		private final HttpHeaders headers;
+		private final Map<String, String> parameters;
+
+		public CacheKey(URI tokenEndpointUri, HttpHeaders headers, Map<String, String> parameters) {
+			this.tokenEndpointUri = tokenEndpointUri;
+			this.headers = headers;
+			this.parameters = parameters;
+		}
+
+		@Override
+		public boolean equals(Object o) {
+			if (this == o)
+				return true;
+			if (o == null || getClass() != o.getClass())
+				return false;
+			CacheKey cacheKey = (CacheKey) o;
+			return Objects.equals(tokenEndpointUri, cacheKey.tokenEndpointUri) &&
+					Objects.equals(headers, cacheKey.headers) &&
+					Objects.equals(parameters, cacheKey.parameters);
+		}
+
+		@Override
+		public int hashCode() {
+			return Objects.hash(tokenEndpointUri, headers, parameters);
+		}
+	}
+
 }
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 d0ee17a5..23ea4921 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
@@ -1,6 +1,8 @@
 package com.sap.cloud.security.xsuaa.client;
 
+import com.sap.cloud.security.xsuaa.Assertions;
 import com.sap.cloud.security.xsuaa.http.HttpHeaders;
+import com.sap.cloud.security.xsuaa.tokenflows.CacheConfiguration;
 import com.sap.cloud.security.xsuaa.util.HttpClientUtil;
 import org.apache.http.HttpResponse;
 import org.apache.http.HttpStatus;
@@ -14,6 +16,7 @@ import org.json.JSONObject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.annotation.Nonnull;
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.net.URI;
@@ -30,11 +33,22 @@ public class DefaultOAuth2TokenService extends AbstractOAuth2TokenService {
 	private final CloseableHttpClient httpClient;
 
 	public DefaultOAuth2TokenService() {
-		this.httpClient = HttpClients.createDefault();
+		this(HttpClients.createDefault(), CacheConfiguration.DEFAULT);
 	}
 
-	public DefaultOAuth2TokenService(CloseableHttpClient client) {
-		this.httpClient = client;
+	public DefaultOAuth2TokenService(@Nonnull CloseableHttpClient httpClient) {
+		this(httpClient, CacheConfiguration.DEFAULT);
+	}
+
+	public DefaultOAuth2TokenService(@Nonnull CacheConfiguration cacheConfiguration) {
+		this(HttpClients.createDefault(), cacheConfiguration);
+	}
+
+	public DefaultOAuth2TokenService(@Nonnull CloseableHttpClient httpClient,
+			@Nonnull CacheConfiguration cacheConfiguration) {
+		super(cacheConfiguration);
+		Assertions.assertNotNull(httpClient, "http client is required");
+		this.httpClient = httpClient;
 	}
 
 	@Override
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 e5accb6b..c89c46fd 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
@@ -1,6 +1,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 org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.http.HttpEntity;
@@ -29,10 +30,20 @@ public class XsuaaOAuth2TokenService extends AbstractOAuth2TokenService {
 	private static Logger logger = LoggerFactory.getLogger(XsuaaOAuth2TokenService.class);
 
 	public XsuaaOAuth2TokenService() {
-		this(new RestTemplate());
+		this(new RestTemplate(), CacheConfiguration.DEFAULT);
+	}
+
+	public XsuaaOAuth2TokenService(@Nonnull CacheConfiguration cacheConfiguration) {
+		this(new RestTemplate(), cacheConfiguration);
 	}
 
 	public XsuaaOAuth2TokenService(@Nonnull RestOperations restOperations) {
+		this(restOperations, CacheConfiguration.DEFAULT);
+	}
+
+	public XsuaaOAuth2TokenService(@Nonnull RestOperations restOperations,
+			@Nonnull CacheConfiguration cacheConfiguration) {
+		super(cacheConfiguration);
 		assertNotNull(restOperations, "restOperations is required");
 		this.restOperations = restOperations;
 	}
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 3cedead6..1cba9556 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
@@ -1,6 +1,7 @@
 package com.sap.cloud.security.xsuaa.http;
 
-import java.util.List;
+import java.util.*;
+import java.util.stream.Collectors;
 
 public class HttpHeaders {
 
@@ -8,13 +9,32 @@ public class HttpHeaders {
 	public static final String ACCEPT = "Accept";
 	public static final String CONTENT_TYPE = "Content-Type";
 
-	private final List<HttpHeader> headers;
+	private final Set<HttpHeader> headers;
 
-	public HttpHeaders(List<HttpHeader> headers) {
-		this.headers = headers;
+	public HttpHeaders(HttpHeader... headers) {
+		this(Arrays.asList(headers));
 	}
 
-	public List<HttpHeader> getHeaders() {
+	public HttpHeaders(Collection<HttpHeader> headers) {
+		this.headers = headers.stream().collect(Collectors.toSet());
+	}
+
+	public Set<HttpHeader> getHeaders() {
 		return headers;
 	}
+
+	@Override
+	public boolean equals(Object o) {
+		if (this == o)
+			return true;
+		if (o == null || getClass() != o.getClass())
+			return false;
+		HttpHeaders that = (HttpHeaders) o;
+		return Objects.equals(headers, that.headers);
+	}
+
+	@Override
+	public int hashCode() {
+		return Objects.hash(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
new file mode 100644
index 00000000..973022a2
--- /dev/null
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/CacheConfiguration.java
@@ -0,0 +1,84 @@
+package com.sap.cloud.security.xsuaa.tokenflows;
+
+import com.sap.cloud.security.xsuaa.Assertions;
+
+import javax.annotation.Nonnull;
+import java.time.Duration;
+import java.util.Objects;
+
+/**
+ * Data class to capture configuration options of caches.
+ */
+public class CacheConfiguration {
+
+	/**
+	 * Creates the default cache configuration.
+	 */
+	public static final CacheConfiguration DEFAULT = new CacheConfiguration(Duration.ofMinutes(10), 1000);
+
+	/**
+	 * Special cache configuration used to disable caching.
+	 */
+	public static final CacheConfiguration CACHE_DISABLED = new CacheConfiguration(Duration.ZERO, 0);
+
+	private final Duration expireAfterWrite;
+	private final int cacheSize;
+
+	/**
+	 * Creates a new {@link CacheConfiguration} instance with the given properties.
+	 * See {@link CacheConfiguration#getExpireAfterWrite()} and
+	 * {@link CacheConfiguration#getCacheSize()} for a explanation of the respective
+	 * properties.
+	 *
+	 * @param expireAfterWrite
+	 *            the expire after write cache property.
+	 * @param cacheSize
+	 *            the cache size property.
+	 * @return a new {@link CacheConfiguration} instance.
+	 */
+	public static CacheConfiguration getInstance(@Nonnull Duration expireAfterWrite, int cacheSize) {
+		Assertions.assertNotNull(expireAfterWrite, "Expire after write must not be null!");
+		return new CacheConfiguration(expireAfterWrite, cacheSize);
+	}
+
+	private CacheConfiguration(Duration expireAfterWrite, int cacheSize) {
+		this.expireAfterWrite = expireAfterWrite;
+		this.cacheSize = cacheSize;
+	}
+
+	/**
+	 * Returns the expire after write property of the cache. Cached elements are
+	 * automatically invalidated after this fixed duration has elapsed.
+	 *
+	 * @return duration of expire after write.
+	 */
+	@Nonnull
+	public Duration getExpireAfterWrite() {
+		return expireAfterWrite;
+	}
+
+	/**
+	 * Returns the number of elements the cache can hold.
+	 *
+	 * @return the size of the cache.
+	 */
+	public int getCacheSize() {
+		return cacheSize;
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		if (this == o)
+			return true;
+		if (o == null || getClass() != o.getClass())
+			return false;
+		CacheConfiguration that = (CacheConfiguration) o;
+		return cacheSize == that.cacheSize &&
+				Objects.equals(expireAfterWrite, that.expireAfterWrite);
+	}
+
+	@Override
+	public int hashCode() {
+		return Objects.hash(expireAfterWrite, cacheSize);
+	}
+}
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/Cacheable.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/Cacheable.java
new file mode 100644
index 00000000..7075c92f
--- /dev/null
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/Cacheable.java
@@ -0,0 +1,24 @@
+package com.sap.cloud.security.xsuaa.tokenflows;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Interface for components that manage a cache configured with
+ * {@link CacheConfiguration}.
+ */
+public interface Cacheable {
+
+	/**
+	 * Provides the cache configuration of the component. Must not be null. Use
+	 * {@link CacheConfiguration#CACHE_DISABLED} to disable caching.
+	 *
+	 * @return the cache configuration
+	 */
+	@Nonnull
+	CacheConfiguration getCacheConfiguration();
+
+	/**
+	 * Clears the cache of the component.
+	 */
+	void clearCache();
+}


Test patch:
diff --git a/java-security/src/test/java/com/sap/cloud/security/token/XsuaaTokenTest.java b/java-security/src/test/java/com/sap/cloud/security/token/XsuaaTokenTest.java
index 0d95ff71..9155bfa7 100644
--- a/java-security/src/test/java/com/sap/cloud/security/token/XsuaaTokenTest.java
+++ b/java-security/src/test/java/com/sap/cloud/security/token/XsuaaTokenTest.java
@@ -105,10 +105,13 @@ public class XsuaaTokenTest {
 
 	@Test
 	public void getAudiences() {
-		assertThat(clientCredentialsToken.getAudiences()).isNotEmpty();
-		assertThat(clientCredentialsToken.getAudiences()).hasSize(2);
-		assertThat(clientCredentialsToken.getAudiences()).contains("uaa");
-		assertThat(clientCredentialsToken.getAudiences()).contains("sap_osb");
+		assertThat(clientCredentialsToken.getAudiences()).containsExactlyInAnyOrder("uaa", "sap_osb");
+	}
+
+	@Test
+	public void getSubdomain() {
+		assertThat(clientCredentialsToken.getSubdomain()).isNull();
+		assertThat(userToken.getSubdomain()).isEqualTo("theSubdomain");
 	}
 
 }
\ No newline at end of file
diff --git a/java-security/src/test/resources/xsuaaUserAccessTokenRSA256.txt b/java-security/src/test/resources/xsuaaUserAccessTokenRSA256.txt
index fb179553..cf4c641d 100644
--- a/java-security/src/test/resources/xsuaaUserAccessTokenRSA256.txt
+++ b/java-security/src/test/resources/xsuaaUserAccessTokenRSA256.txt
@@ -1,1 +1,1 @@
-eyJqa3UiOiJodHRwOi8vYXV0aC5jb20vdG9rZW5fa2V5cyIsImtpZCI6ImtleS1pZC0wIiwiYWxnIjoiUlMyNTYifQ==.eyJhdWQiOlsiY2xpZW50SWQiXSwiZ3JhbnRfdHlwZSI6InVzZXJfdG9rZW4iLCJ1c2VyX25hbWUiOiJ0ZXN0VXNlciIsIm9yaWdpbiI6InVzZXJJZHAiLCJpc3MiOiJodHRwOi8vYXV0aC5jb20iLCJleHAiOiI2OTc0MDMxNjAwIiwiY2lkIjoiY2xpZW50SWQifQ==.Da8DJ3n46aVLEkJvk0a5Gj9g2h2w9kCVfaKEpnUr5PXJNtkY7VdnMRaZe9DoOuUIH3LUJjbw4NVh6j4V8tUe_jjqJwmYCn3DnlHuFL8jeCKyg6f3SmwQ0rkFopKANdSh3Uaq5l7NXHWAl-A3wQ0kFjEKC6DydF5EfcQzEH6K1EQcYZncEhRO4fUh4KxwUmF2ydyIeb5R7yyQBL7VchZgMi6oQrFhsR8za576Sz-lZTUB03CBRqnc24dqnM5VjTh-LPSXGCQVkT70u-3Nm9lXj4PQeGBz41C-Wiy3bKljBUefCO1f5jhOPdxU63A_w878_MKEEueH3PcMoNfsPAnpcQ==
\ No newline at end of file
+eyJqa3UiOiJodHRwOi8vYXV0aC5jb20vdG9rZW5fa2V5cyIsImtpZCI6ImtleS1pZC0wIiwiYWxnIjoiUlMyNTYifQ.eyJhdWQiOlsiY2xpZW50SWQiXSwiZXh0X2F0dHIiOnsiZW5oYW5jZXIiOiJYU1VBQSIsInpkbiI6InRoZVN1YmRvbWFpbiJ9LCJncmFudF90eXBlIjoidXNlcl90b2tlbiIsInVzZXJfbmFtZSI6InRlc3RVc2VyIiwib3JpZ2luIjoidXNlcklkcCIsImlzcyI6Imh0dHA6Ly9hdXRoLmNvbSIsImV4cCI6IjY5NzQwMzE2MDAiLCJjaWQiOiJjbGllbnRJZCJ9.eSagbm6JtHhy8C1YiF9seN8-oTilXHtacDQt0AVNFKAy9xpO10GJNfm9t6b7MgvON9rnwkvzGloThMRVVxZw2TjaPpZ5vzMIEei3gl4x_oVzrc8Cu2hDvxrH-w2u0keHCkoNratadXDSD3YrsObnouwWIYjqbZMo8zq_0rBmykyEq1kqQIKx6lmd4OZkUS8zjZ_0qaDEgYr--b4RYmkJh9Ee20jXqX2wxAhPrzSlMwsbj8V752WGAGTyx6LPReFbZNkEKSp0zDhDNQW2Yl2DtUuJFvF1Pn5CgEbPMPVqujO7sdjZSwy4wqPPqMUibS_XBdp1wK7Patnrm5DvGQFS-Q
\ No newline at end of file
diff --git a/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/XsuaaServicesParserTest.java b/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/XsuaaServicesParserTest.java
index 5ee59258..6631ddfe 100644
--- a/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/XsuaaServicesParserTest.java
+++ b/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/XsuaaServicesParserTest.java
@@ -26,4 +26,12 @@ public class XsuaaServicesParserTest {
 		XsuaaServicesParser cut = new XsuaaServicesParser(vcapMultipleBindings);
 		cut.parseCredentials();
 	}
+
+	@Test
+	public void acceptVcapServicesWithoutPlan() throws IOException {
+		String vcapMinimalWoPlan = "{\"xsuaa\":[{\"credentials\":{\"clientid\":\"client-id\",\"clientsecret\":\"client-secret\"},\"tags\":[\"xsuaa\"]}]}";
+		XsuaaServicesParser cut = new XsuaaServicesParser(vcapMinimalWoPlan);
+		Properties properties = cut.parseCredentials();
+		assertThat(properties.getProperty("clientid")).isEqualTo("client-id");
+	}
 }
diff --git a/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/extractor/LocalAuthoritiesExtractorTest.java b/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/extractor/LocalAuthoritiesExtractorTest.java
new file mode 100644
index 00000000..221bcf8f
--- /dev/null
+++ b/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/extractor/LocalAuthoritiesExtractorTest.java
@@ -0,0 +1,55 @@
+package com.sap.cloud.security.xsuaa.extractor;
+
+
+import com.sap.cloud.security.xsuaa.token.XsuaaToken;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+import java.util.Collection;
+import java.util.HashSet;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class LocalAuthoritiesExtractorTest {
+    LocalAuthoritiesExtractor cut;
+    XsuaaToken token;
+    Collection<String> scopes = new HashSet<>();
+
+    @Before
+    public void setup() {
+        cut = new LocalAuthoritiesExtractor("appId!1234");
+
+        token = Mockito.mock(XsuaaToken.class);
+        scopes.add("appId!1234.Scope1");
+        scopes.add("appId!1234.Scope2");
+        scopes.add("appId2!888.Scope1");
+        scopes.add("appId2!777.Scope3");
+        Mockito.when(token.getScopes()).thenReturn(scopes);
+    }
+
+    @Test
+    public void extractLocalScopes() {
+        assertThat(cut.getAuthorities(token)).hasSize(2);
+        assertThat(cut.getAuthorities(token)).contains(new SimpleGrantedAuthority("Scope1"));
+        assertThat(cut.getAuthorities(token)).contains(new SimpleGrantedAuthority("Scope2"));
+    }
+
+    @Test
+    public void extractLocalScopesOfTwoApps() {
+        cut = new LocalAuthoritiesExtractor("appId!1234", "appId2!777");
+        assertThat(cut.getAuthorities(token)).hasSize(3);
+        assertThat(cut.getAuthorities(token)).contains(new SimpleGrantedAuthority("Scope1"));
+        assertThat(cut.getAuthorities(token)).contains(new SimpleGrantedAuthority("Scope1"));
+        assertThat(cut.getAuthorities(token)).contains(new SimpleGrantedAuthority("Scope3"));
+    }
+
+    @Test
+    public void extractLocalScopesOfTwoApps_SameScopeName() {
+        cut = new LocalAuthoritiesExtractor("appId!1234", "appId2!888");
+        assertThat(cut.getAuthorities(token)).hasSize(2);
+        assertThat(cut.getAuthorities(token)).contains(new SimpleGrantedAuthority("Scope1"));
+        assertThat(cut.getAuthorities(token)).contains(new SimpleGrantedAuthority("Scope2"));
+    }
+}
\ No newline at end of file
diff --git a/spring-xsuaa/src/test/resources/vcap_multipleBindings.json b/spring-xsuaa/src/test/resources/vcap_multipleBindings.json
index fe1c6501..74e8b9dc 100644
--- a/spring-xsuaa/src/test/resources/vcap_multipleBindings.json
+++ b/spring-xsuaa/src/test/resources/vcap_multipleBindings.json
@@ -28,4 +28,3 @@
     }
   ]
 }
-}
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/AbstractOAuth2TokenServiceTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/AbstractOAuth2TokenServiceTest.java
new file mode 100644
index 00000000..a9715d84
--- /dev/null
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/AbstractOAuth2TokenServiceTest.java
@@ -0,0 +1,246 @@
+package com.sap.cloud.security.xsuaa.client;
+
+import com.github.benmanes.caffeine.cache.Ticker;
+import com.sap.cloud.security.xsuaa.http.HttpHeaders;
+import com.sap.cloud.security.xsuaa.tokenflows.CacheConfiguration;
+import org.assertj.core.util.Maps;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.net.URI;
+import java.time.Duration;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class AbstractOAuth2TokenServiceTest {
+
+	public static final URI TOKEN_ENDPOINT_URI = URI.create("http://test.token.endpoint/oauth/token");
+	public static final String SUBDOMAIN = "subdomain";
+	private final static TestCacheTicker TEST_CACHE_TICKER = new TestCacheTicker();
+	private TestOAuth2TokenService cut;
+
+	@Before
+	public void setUp() {
+		cut = new TestOAuth2TokenService(CacheConfiguration.DEFAULT);
+		TEST_CACHE_TICKER.reset();
+	}
+
+	@Test
+	public void retrieveAccessTokenViaClientCredentials_activeCache_responseNotNull() throws OAuth2ServiceException {
+		OAuth2TokenResponse oAuth2TokenResponse = retrieveAccessTokenViaClientCredentials();
+		assertThat(oAuth2TokenResponse).isNotNull();
+	}
+
+	@Test
+	public void retrieveAccessTokenViaClientCredentials_noCache_responseNotNull() throws OAuth2ServiceException {
+		cut = new TestOAuth2TokenService(CacheConfiguration.CACHE_DISABLED);
+		OAuth2TokenResponse oAuth2TokenResponse = retrieveAccessTokenViaClientCredentials();
+		assertThat(oAuth2TokenResponse).isNotNull();
+	}
+
+	@Test
+	public void retrieveAccessTokenViaRefreshToken_twoDistinctRequests_onlyTwoRequestCalls()
+			throws OAuth2ServiceException {
+		retrieveAccessTokenViaRefreshToken("refreshToken");
+		retrieveAccessTokenViaRefreshToken("another refreshToken");
+		retrieveAccessTokenViaRefreshToken("refreshToken");
+
+		assertThat(cut.tokenRequestCallCount).isEqualTo(2);
+	}
+
+	@Test
+	public void retrieveAccessTokenViaPasswordGrant_twoDistinctRequests_onlyTwoRequestCalls()
+			throws OAuth2ServiceException {
+		retrieveAccessTokenViaPasswordGrant("user1");
+		retrieveAccessTokenViaPasswordGrant("user2");
+		retrieveAccessTokenViaPasswordGrant("user1");
+
+		assertThat(cut.tokenRequestCallCount).isEqualTo(2);
+	}
+
+	@Test
+	public void retrieveAccessTokenViaClientCredentials_twoDistinctRequests_onlyTwoRequestCalls()
+			throws OAuth2ServiceException {
+		retrieveAccessTokenViaClientCredentials();
+		retrieveAccessTokenViaClientCredentials(new ClientCredentials("other client id", "secret"));
+		retrieveAccessTokenViaClientCredentials();
+
+		assertThat(cut.tokenRequestCallCount).isEqualTo(2);
+	}
+
+	@Test
+	public void retrieveAccessTokenViaJwtBearerTokenGrant_twoDistinctRequests_onlyTwoRequestCalls()
+			throws OAuth2ServiceException {
+		retrieveAccessTokenViaJwtBearerTokenGrant("token");
+		retrieveAccessTokenViaJwtBearerTokenGrant("differentToken");
+		retrieveAccessTokenViaJwtBearerTokenGrant("token");
+
+		assertThat(cut.tokenRequestCallCount).isEqualTo(2);
+	}
+
+	@Test
+	public void requestAccessToken_differentTokenEndpoint_requestsFreshToken() throws OAuth2ServiceException {
+		retrieveAccessTokenViaPasswordGrant(TOKEN_ENDPOINT_URI);
+		retrieveAccessTokenViaPasswordGrant(URI.create("http://another.token.endpoint"));
+
+		assertThat(cut.tokenRequestCallCount).isEqualTo(2);
+	}
+
+	@Test
+	public void requestAccessToken_differentSubdomain_requestsFreshToken() throws OAuth2ServiceException {
+		retrieveAccessTokenViaRefreshToken("token", "subdomain1");
+		retrieveAccessTokenViaRefreshToken("token", "subdomain2");
+
+		assertThat(cut.tokenRequestCallCount).isEqualTo(2);
+	}
+
+	@Test
+	public void requestAccessToken_differentAdditionalParameters_requestsFreshToken() throws OAuth2ServiceException {
+		retrieveAccessTokenViaJwtBearerTokenGrant("token", Maps.newHashMap("a", "b"));
+		retrieveAccessTokenViaJwtBearerTokenGrant("token", Maps.newHashMap("1", "2"));
+
+		assertThat(cut.tokenRequestCallCount).isEqualTo(2);
+	}
+
+	@Test
+	public void requestAccessToken_sameAdditionalParameters_onlyOneRequestCall() throws OAuth2ServiceException {
+		Map<String, String> parameters = Maps.newHashMap("a", "b");
+		parameters.put("c", "d");
+		Map<String, String> sameParametersDifferentOrder = Maps.newHashMap("c", "d");
+		sameParametersDifferentOrder.put("a", "b");
+
+		retrieveAccessTokenViaJwtBearerTokenGrant("token", parameters);
+		retrieveAccessTokenViaJwtBearerTokenGrant("token", sameParametersDifferentOrder);
+
+		assertThat(cut.tokenRequestCallCount).isOne();
+	}
+
+	@Test
+	public void requestAccessToken_cacheGloballyDisabled_requestsFreshTokens() throws OAuth2ServiceException {
+		cut = new TestOAuth2TokenService(CacheConfiguration.CACHE_DISABLED);
+
+		OAuth2TokenResponse firstResponse = retrieveAccessTokenViaClientCredentials();
+		OAuth2TokenResponse secondResponse = retrieveAccessTokenViaClientCredentials();
+
+		assertThat(cut.tokenRequestCallCount).isEqualTo(2);
+		assertThat(firstResponse).isNotSameAs(secondResponse);
+	}
+
+	@Test
+	public void clearCache_requestsFreshToken() throws OAuth2ServiceException {
+		OAuth2TokenResponse firstResponse = retrieveAccessTokenViaClientCredentials();
+		cut.clearCache();
+		OAuth2TokenResponse secondResponse = retrieveAccessTokenViaClientCredentials();
+
+		assertThat(cut.tokenRequestCallCount).isEqualTo(2);
+		assertThat(firstResponse).isNotSameAs(secondResponse);
+	}
+
+	@Test
+	public void requestAccessToken_cachedTokens_areTheSame() throws OAuth2ServiceException {
+		OAuth2TokenResponse firstResponse = retrieveAccessTokenViaClientCredentials();
+		OAuth2TokenResponse secondResponse = retrieveAccessTokenViaClientCredentials();
+
+		assertThat(firstResponse).isSameAs(secondResponse);
+	}
+
+	@Test
+	public void requestAccessToken_tokensAreInvalidatedAfterTime_requestsFreshToken() throws OAuth2ServiceException {
+		OAuth2TokenResponse firstResponse = retrieveAccessTokenViaClientCredentials();
+		TEST_CACHE_TICKER.advance(CacheConfiguration.DEFAULT.getExpireAfterWrite());
+		OAuth2TokenResponse secondResponse = retrieveAccessTokenViaClientCredentials();
+
+		assertThat(cut.tokenRequestCallCount).isEqualTo(2);
+		assertThat(firstResponse).isNotSameAs(secondResponse);
+	}
+
+	@Test
+	public void requestAccessToken_cacheIsFull_requestsFreshToken() throws OAuth2ServiceException {
+		cut = new TestOAuth2TokenService(CacheConfiguration.getInstance(Duration.ofMinutes(10), 1));
+		OAuth2TokenResponse user1Response = retrieveAccessTokenViaPasswordGrant("user1");
+		OAuth2TokenResponse user2Response = retrieveAccessTokenViaPasswordGrant("user2");
+		OAuth2TokenResponse secondUser1Response = retrieveAccessTokenViaPasswordGrant("user1");
+
+		assertThat(user1Response).isNotSameAs(secondUser1Response).isNotSameAs(user2Response);
+		assertThat(cut.tokenRequestCallCount).isEqualTo(3);
+	}
+
+	private OAuth2TokenResponse retrieveAccessTokenViaJwtBearerTokenGrant(String token) throws OAuth2ServiceException {
+		return retrieveAccessTokenViaJwtBearerTokenGrant(token, null);
+	}
+
+	private OAuth2TokenResponse retrieveAccessTokenViaJwtBearerTokenGrant(String token,
+			Map<String, String> optionalParameters) throws OAuth2ServiceException {
+		return cut.retrieveAccessTokenViaJwtBearerTokenGrant(TOKEN_ENDPOINT_URI, clientCredentials(), token, null,
+				optionalParameters);
+	}
+
+	private OAuth2TokenResponse retrieveAccessTokenViaClientCredentials() throws OAuth2ServiceException {
+		return retrieveAccessTokenViaClientCredentials(clientCredentials());
+	}
+
+	private OAuth2TokenResponse retrieveAccessTokenViaClientCredentials(ClientCredentials clientCredentials)
+			throws OAuth2ServiceException {
+		return cut.retrieveAccessTokenViaClientCredentialsGrant(TOKEN_ENDPOINT_URI, clientCredentials, SUBDOMAIN,
+				null);
+	}
+
+	private OAuth2TokenResponse retrieveAccessTokenViaPasswordGrant(String username) throws OAuth2ServiceException {
+		return cut.retrieveAccessTokenViaPasswordGrant(TOKEN_ENDPOINT_URI, clientCredentials(), username, "password",
+				SUBDOMAIN, null);
+	}
+
+	private OAuth2TokenResponse retrieveAccessTokenViaPasswordGrant(URI tokenEndpointUri)
+			throws OAuth2ServiceException {
+		return cut.retrieveAccessTokenViaPasswordGrant(tokenEndpointUri, clientCredentials(), "username", "password",
+				SUBDOMAIN, null);
+	}
+
+	private OAuth2TokenResponse retrieveAccessTokenViaRefreshToken(String refreshToken) throws OAuth2ServiceException {
+		return retrieveAccessTokenViaRefreshToken(refreshToken, SUBDOMAIN);
+	}
+
+	private OAuth2TokenResponse retrieveAccessTokenViaRefreshToken(String refreshToken, String subdomain)
+			throws OAuth2ServiceException {
+		return cut.retrieveAccessTokenViaRefreshToken(TOKEN_ENDPOINT_URI, clientCredentials(), refreshToken, subdomain);
+	}
+
+	private ClientCredentials clientCredentials() {
+		return new ClientCredentials("clientId", "clientSecret");
+	}
+
+	private static class TestOAuth2TokenService extends AbstractOAuth2TokenService {
+
+		private int tokenRequestCallCount = 0;
+
+		public TestOAuth2TokenService(CacheConfiguration cacheConfiguration) {
+			super(TEST_CACHE_TICKER, true, cacheConfiguration);
+		}
+
+		@Override
+		protected OAuth2TokenResponse requestAccessToken(URI tokenEndpointUri, HttpHeaders headers,
+				Map<String, String> parameters) {
+			tokenRequestCallCount++;
+			return new OAuth2TokenResponse("", 1L, null);
+		}
+
+	}
+
+	private static class TestCacheTicker implements Ticker {
+		long elapsed = 0;
+
+		@Override
+		public long read() {
+			return elapsed;
+		}
+
+		public void advance(Duration duration) {
+			this.elapsed = elapsed + duration.toNanos();
+		}
+
+		public void reset() {
+			elapsed = 0;
+		}
+	}
+}
\ No newline at end of file
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceClientCredentialsTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceClientCredentialsTest.java
index b7c6f712..6efcb352 100644
--- a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceClientCredentialsTest.java
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceClientCredentialsTest.java
@@ -50,11 +50,6 @@ public class XsuaaOAuth2TokenServiceClientCredentialsTest {
 		responseMap.putIfAbsent(EXPIRES_IN, "43199");
 	}
 
-	@Test(expected = IllegalArgumentException.class)
-	public void initialize_throwsIfRestOperationsIsNull() {
-		new XsuaaOAuth2TokenService(null);
-	}
-
 	@Test
 	public void retrieveToken_throwsOnNullValues() {
 		assertThatThrownBy(() -> {
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceRefreshTokenTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceRefreshTokenTest.java
index 64a2173f..934499f1 100644
--- a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceRefreshTokenTest.java
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceRefreshTokenTest.java
@@ -53,11 +53,6 @@ public class XsuaaOAuth2TokenServiceRefreshTokenTest {
 		responseMap.put(EXPIRES_IN, "43199");
 	}
 
-	@Test(expected = IllegalArgumentException.class)
-	public void initialize_throwsIfRestOperationsIsNull() {
-		new XsuaaOAuth2TokenService(null);
-	}
-
 	@Test
 	public void retrieveToken_throwsOnNullValues() {
 		assertThatThrownBy(() -> {
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceTest.java
new file mode 100644
index 00000000..15c81b38
--- /dev/null
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceTest.java
@@ -0,0 +1,20 @@
+package com.sap.cloud.security.xsuaa.client;
+
+import com.sap.cloud.security.xsuaa.tokenflows.CacheConfiguration;
+import org.junit.Test;
+import org.mockito.Mockito;
+import org.springframework.web.client.RestOperations;
+
+public class XsuaaOAuth2TokenServiceTest {
+
+	@Test(expected = IllegalArgumentException.class)
+	public void initialize_throwsIfRestOperationsIsNull() {
+		new XsuaaOAuth2TokenService(null, CacheConfiguration.CACHE_DISABLED);
+	}
+
+	@Test(expected = IllegalArgumentException.class)
+	public void initialize_throwsIfCacheConfigurationIsNull() {
+		new XsuaaOAuth2TokenService(Mockito.mock(RestOperations.class), null);
+	}
+
+}
\ No newline at end of file
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceUserTokenTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceUserTokenTest.java
index b1f3f6e1..0f92e32e 100644
--- a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceUserTokenTest.java
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/XsuaaOAuth2TokenServiceUserTokenTest.java
@@ -52,11 +52,6 @@ public class XsuaaOAuth2TokenServiceUserTokenTest {
 		responseMap.put(EXPIRES_IN, "43199");
 	}
 
-	@Test(expected = IllegalArgumentException.class)
-	public void initialize_throwsIfRestOperationsIsNull() {
-		new XsuaaOAuth2TokenService(null);
-	}
-
 	@Test
 	public void retrieveToken_throwsOnNullValues() {
 		assertThatThrownBy(() -> {
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/http/HttpHeadersTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/http/HttpHeadersTest.java
new file mode 100644
index 00000000..0613ed4c
--- /dev/null
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/http/HttpHeadersTest.java
@@ -0,0 +1,43 @@
+package com.sap.cloud.security.xsuaa.http;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.util.Lists.newArrayList;
+
+public class HttpHeadersTest {
+
+	private HttpHeader header = new HttpHeader("a", "a_value");
+	private HttpHeader anotherHeader = new HttpHeader("b", "b_value");
+	private HttpHeader sameKeyDifferentValue = new HttpHeader("a", "different_value");
+
+	@Test
+	public void equals_sameHeaders_isEqual() {
+		HttpHeaders headers1 = new HttpHeaders(header, anotherHeader);
+		HttpHeaders headers2 = new HttpHeaders(header, anotherHeader);
+
+		assertThat(headers1).isEqualTo(headers2);
+	}
+
+	@Test
+	public void equals_orderDoesNotMatter_isEqual() {
+		HttpHeaders firstHeaders = new HttpHeaders(newArrayList(anotherHeader, header));
+		HttpHeaders secondHeaders = new HttpHeaders(newArrayList(header, anotherHeader));
+
+		assertThat(firstHeaders).isEqualTo(secondHeaders);
+	}
+
+	@Test
+	public void equals_differentHeaders_isNotEqual() {
+		HttpHeaders headers1 = new HttpHeaders(header);
+		HttpHeaders headers2 = new HttpHeaders(anotherHeader);
+		HttpHeaders headers3 = new HttpHeaders(sameKeyDifferentValue);
+
+		assertThat(headers1).isNotEqualTo(headers2);
+		assertThat(headers1).isNotEqualTo(headers3);
+		assertThat(headers2).isNotEqualTo(headers3);
+	}
+
+
+
+}
\ No newline at end of file