Repository: SAP/cloud-security-xsuaa-integration
Pull request #367 (2020-09-07 11:16:27)

Cache statistics
This PR adds the possibility to enable cache statistics in the `CacheConfiguration`. Every `Cacheable` can return their specific cache statistic object. 
This PR is motivated by #340


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0f22fc0..a3386ab3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
   - spring.security.version: 5.3.3.RELEASE --> 5.3.4.RELEASE
 - [java-security-test] `SecurityTest` and `SecurityTestRule` provides a better support for integration tests with a mockserver. It is now possible to upload the configuration and the token from a json file. Take our integration tests as a sample [java-security-it](/java-security-it).
 - [java-security] `DefaultJsonObject.getAsStringList(propertyName)` parses the JSON object for a given property and returns a `String` list. This works also in case the property is not a JSON array but a JSON String.
+- [java-security-it] New integration test project. Existing integration tests have been restructured and moved here. Additionally, JWT token validation performance tests have been added for java-security and spring-xsuaa.
 
 ## 2.7.6
 - Fixes `ClientCredentialsTokenFlow.scopes()` and `UserTokenFlow.scopes()` to support multiple scopes. The scope form parameter has to provide a space-delimited list (and not comma-delimited list).
diff --git a/java-api/src/main/java/com/sap/cloud/security/config/CacheConfiguration.java b/java-api/src/main/java/com/sap/cloud/security/config/CacheConfiguration.java
index d4217c1f..ebf7e14a 100644
--- a/java-api/src/main/java/com/sap/cloud/security/config/CacheConfiguration.java
+++ b/java-api/src/main/java/com/sap/cloud/security/config/CacheConfiguration.java
@@ -30,4 +30,14 @@ public interface CacheConfiguration {
 	default boolean isCacheDisabled() {
 		return false;
 	}
+
+	/**
+	 * Returns {@code true} if cache statistics recording has been enabled. If it is
+	 * enabled, cache statistics might be obtained from the {@code Cacheable}.
+	 *
+	 * @return {@code true} if cache statistics is enabled
+	 */
+	default boolean isCacheStatisticsEnabled() {
+		return false;
+	}
 }
diff --git a/java-security-it/pom.xml b/java-security-it/pom.xml
index bc2974a4..92c4a5ec 100644
--- a/java-security-it/pom.xml
+++ b/java-security-it/pom.xml
@@ -18,6 +18,7 @@
         <dependency>
             <groupId>com.sap.cloud.security</groupId>
             <artifactId>java-security-test</artifactId>
+            <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>org.assertj</groupId>
@@ -45,9 +46,40 @@
             <version>1.19.0</version>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.security</groupId>
+            <artifactId>spring-security-oauth2-jose</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-api</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-engine</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.vintage</groupId>
+            <artifactId>junit-vintage-engine</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.sap.cloud.security.xsuaa</groupId>
+            <artifactId>spring-xsuaa</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
     <build>
         <plugins>
+            <plugin>
+                <!--  Run with mvn failsafe:integration-test -->
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-failsafe-plugin</artifactId>
+                <version>3.0.0-M4</version>
+            </plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-surefire-plugin</artifactId>
diff --git a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/OAuth2TokenKeyServiceWithCache.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/OAuth2TokenKeyServiceWithCache.java
index ca25b30d..9d868624 100644
--- a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/OAuth2TokenKeyServiceWithCache.java
+++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/OAuth2TokenKeyServiceWithCache.java
@@ -77,7 +77,7 @@ class OAuth2TokenKeyServiceWithCache implements Cacheable {
 	@Deprecated
 	public OAuth2TokenKeyServiceWithCache withCacheTime(int timeInSeconds) {
 		withCacheConfiguration(TokenKeyCacheConfiguration
-				.getInstance(Duration.ofSeconds(timeInSeconds), this.cacheConfiguration.getCacheSize()));
+				.getInstance(Duration.ofSeconds(timeInSeconds), this.cacheConfiguration.getCacheSize(), false));
 		return this;
 	}
 
@@ -91,14 +91,15 @@ class OAuth2TokenKeyServiceWithCache implements Cacheable {
 	 */
 	@Deprecated
 	public OAuth2TokenKeyServiceWithCache withCacheSize(int size) {
-		withCacheConfiguration(TokenKeyCacheConfiguration.getInstance(cacheConfiguration.getCacheDuration(), size));
+		withCacheConfiguration(TokenKeyCacheConfiguration
+				.getInstance(cacheConfiguration.getCacheDuration(), size, false));
 		return this;
 	}
 
 	/**
 	 * Configures the token key cache. Use
-	 * {@link TokenKeyCacheConfiguration#getInstance(Duration, int)} to pass a
-	 * custom configuration.
+	 * {@link TokenKeyCacheConfiguration#getInstance(Duration, int, boolean)} to
+	 * pass a custom configuration.
 	 *
 	 * Note that the cache size must be 1000 or more and the cache duration must be
 	 * at least 600 seconds!
@@ -109,8 +110,10 @@ class OAuth2TokenKeyServiceWithCache implements Cacheable {
 	 */
 	public OAuth2TokenKeyServiceWithCache withCacheConfiguration(CacheConfiguration cacheConfiguration) {
 		this.cacheConfiguration = getCheckedConfiguration(cacheConfiguration);
-		LOGGER.debug("Configured token key cache with cacheDuration={} seconds and cacheSize={}",
-				getCacheConfiguration().getCacheDuration().getSeconds(), getCacheConfiguration().getCacheSize());
+		LOGGER.debug(
+				"Configured token key cache with cacheDuration={} seconds, cacheSize={} and statisticsRecording={}",
+				getCacheConfiguration().getCacheDuration().getSeconds(), getCacheConfiguration().getCacheSize(),
+				getCacheConfiguration().isCacheStatisticsEnabled());
 		return this;
 	}
 
@@ -181,7 +184,7 @@ class OAuth2TokenKeyServiceWithCache implements Cacheable {
 					duration.getSeconds(), currentDuration.getSeconds());
 			duration = currentDuration;
 		}
-		return TokenKeyCacheConfiguration.getInstance(duration, size);
+		return TokenKeyCacheConfiguration.getInstance(duration, size, cacheConfiguration.isCacheStatisticsEnabled());
 	}
 
 	private void retrieveTokenKeysAndFillCache(URI jwksUri)
@@ -198,11 +201,14 @@ class OAuth2TokenKeyServiceWithCache implements Cacheable {
 
 	private Cache<String, PublicKey> getCache() {
 		if (cache == null) {
-			cache = Caffeine.newBuilder()
+			Caffeine<Object, Object> cacheBuilder = Caffeine.newBuilder()
 					.ticker(cacheTicker)
-					.expireAfterWrite(cacheConfiguration.getCacheDuration())
-					.maximumSize(cacheConfiguration.getCacheSize())
-					.build();
+					.expireAfterWrite(getCacheConfiguration().getCacheDuration())
+					.maximumSize(getCacheConfiguration().getCacheSize());
+			if (getCacheConfiguration().isCacheStatisticsEnabled()) {
+				cacheBuilder.recordStats();
+			}
+			cache = cacheBuilder.build();
 		}
 		return cache;
 	}
@@ -227,6 +233,11 @@ class OAuth2TokenKeyServiceWithCache implements Cacheable {
 		}
 	}
 
+	@Override
+	public Object getCacheStatistics() {
+		return getCacheConfiguration().isCacheStatisticsEnabled() ? getCache().stats() : null;
+	}
+
 	public static String getUniqueCacheKey(JwtSignatureAlgorithm keyAlgorithm, String keyId, URI jwksUri) {
 		return jwksUri + String.valueOf(JsonWebKeyImpl.calculateUniqueId(keyAlgorithm, keyId));
 	}
diff --git a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/TokenKeyCacheConfiguration.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/TokenKeyCacheConfiguration.java
index 64131f38..6997c734 100644
--- a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/TokenKeyCacheConfiguration.java
+++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/TokenKeyCacheConfiguration.java
@@ -12,41 +12,48 @@ import java.time.Duration;
 class TokenKeyCacheConfiguration implements CacheConfiguration {
 
 	private static final TokenKeyCacheConfiguration DEFAULT = TokenKeyCacheConfiguration
-			.getInstance(Duration.ofMinutes(10), 1000);
+			.getInstance(Duration.ofMinutes(10), 1000, false);
 
-	private Duration cacheDuration;
-	private int cacheSize;
+	private final Duration cacheDuration;
+	private final int cacheSize;
+	private final boolean cacheStatisticsEnabled;
 
 	/**
 	 * Creates a new {@link TokenKeyCacheConfiguration} instance with the given
-	 * properties. See {@link CacheConfiguration#getCacheDuration()} and
-	 * {@link CacheConfiguration#getCacheSize()} for an explanation of the
-	 * respective properties.
+	 * properties. See {@link CacheConfiguration#getCacheDuration()},
+	 * {@link CacheConfiguration#getCacheSize()} and
+	 * {@link CacheConfiguration#isCacheStatisticsEnabled()} for an explanation of
+	 * the respective properties.
 	 *
 	 * @param cacheDuration
 	 *            the cache duration property.
 	 * @param cacheSize
 	 *            the cache size property.
+	 * @param cacheStatisticsEnabled
+	 *            set to {@code true} if cache statists should be recorded
 	 * @return a new {@link TokenKeyCacheConfiguration} instance.
 	 */
-	static TokenKeyCacheConfiguration getInstance(Duration cacheDuration, int cacheSize) {
+	static TokenKeyCacheConfiguration getInstance(Duration cacheDuration, int cacheSize,
+			boolean cacheStatisticsEnabled) {
 		Assertions.assertNotNull(cacheDuration, "The cache duration write must not be null!");
-		return new TokenKeyCacheConfiguration(cacheDuration, cacheSize);
+		return new TokenKeyCacheConfiguration(cacheDuration, cacheSize, cacheStatisticsEnabled);
 	}
 
 	/**
 	 * The default configuration for the token key cache. The default cache size is
-	 * 1000. The default cache duration is 10 minutes.
-	 * 
+	 * 1000. The default cache duration is 10 minutes. Cache statistics are not
+	 * enabled.
+	 *
 	 * @return the default configuration
 	 */
 	static TokenKeyCacheConfiguration defaultConfiguration() {
 		return DEFAULT;
 	}
 
-	private TokenKeyCacheConfiguration(Duration cacheDuration, int cacheSize) {
+	private TokenKeyCacheConfiguration(Duration cacheDuration, int cacheSize, boolean cacheStatisticsEnabled) {
 		this.cacheDuration = cacheDuration;
 		this.cacheSize = cacheSize;
+		this.cacheStatisticsEnabled = cacheStatisticsEnabled;
 	}
 
 	@Override
@@ -59,4 +66,9 @@ class TokenKeyCacheConfiguration implements CacheConfiguration {
 	public int getCacheSize() {
 		return cacheSize;
 	}
+
+	@Override
+	public boolean isCacheStatisticsEnabled() {
+		return cacheStatisticsEnabled;
+	}
 }
diff --git a/pom.xml b/pom.xml
index 1fb2fb41..b6b9e5f6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -72,6 +72,7 @@
 		<javax.servlet.api.version>3.0.1</javax.servlet.api.version>
 		<mockwebserver.version>3.9.1</mockwebserver.version>
 		<junit.version>4.12</junit.version>
+		<junit-jupiter.version>5.6.2</junit-jupiter.version>
 		<hamcrest.version>1.3</hamcrest.version>
 		<mockito.version>2.23.0</mockito.version>
 		<assertj.version>3.13.2</assertj.version>
@@ -263,6 +264,21 @@
 				<artifactId>junit</artifactId>
 				<version>${junit.version}</version>
 			</dependency>
+			<dependency>
+				<groupId>org.junit.jupiter</groupId>
+				<artifactId>junit-jupiter-api</artifactId>
+				<version>${junit-jupiter.version}</version>
+			</dependency>
+			<dependency>
+				<groupId>org.junit.jupiter</groupId>
+				<artifactId>junit-jupiter-engine</artifactId>
+				<version>${junit-jupiter.version}</version>
+			</dependency>
+			<dependency>
+				<groupId>org.junit.vintage</groupId>
+				<artifactId>junit-vintage-engine</artifactId>
+				<version>${junit-jupiter.version}</version>
+			</dependency>
 			<dependency>
 				<groupId>org.hamcrest</groupId>
 				<artifactId>hamcrest-all</artifactId>
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 388cbe22..d8437ba2 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
@@ -280,9 +280,17 @@ public abstract class AbstractOAuth2TokenService implements OAuth2TokenService,
 		if (sameThreadCache) {
 			cacheBuilder.executor(Runnable::run);
 		}
+		if (getCacheConfiguration().isCacheStatisticsEnabled()) {
+			cacheBuilder.recordStats();
+		}
 		return cacheBuilder.build();
 	}
 
+	@Override
+	public Object getCacheStatistics() {
+		return getCacheConfiguration().isCacheStatisticsEnabled() ? responseCache.stats() : null;
+	}
+
 	private class CacheKey {
 
 		private final URI tokenEndpointUri;
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
index b19a5da8..da0bb0b5 100644
--- 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
@@ -3,6 +3,7 @@ package com.sap.cloud.security.xsuaa.tokenflows;
 import com.sap.cloud.security.config.CacheConfiguration;
 
 import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 
 /**
  * Interface for components that manage a cache configured with
@@ -22,4 +23,17 @@ public interface Cacheable {
 	 * Clears the cache of the component.
 	 */
 	void clearCache();
+
+	/**
+	 * This returns an implementation specific statistics object if the underlying
+	 * cache supports it and cache statistics have been enabled in the
+	 * {@link CacheConfiguration}.
+	 *
+	 * Use with care. The type of the statistics object might change in later
+	 * versions.
+	 *
+	 * @return the cache statistics object.
+	 */
+	@Nullable
+	Object getCacheStatistics();
 }
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/TokenCacheConfiguration.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/TokenCacheConfiguration.java
index 53c19a38..25529963 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/TokenCacheConfiguration.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/TokenCacheConfiguration.java
@@ -13,13 +13,14 @@ import java.util.Objects;
 public class TokenCacheConfiguration implements CacheConfiguration {
 
 	private static final TokenCacheConfiguration DEFAULT = new TokenCacheConfiguration(Duration.ofMinutes(10), 1000,
-			Duration.ofSeconds(30));
+			Duration.ofSeconds(30), false);
 
 	private static final TokenCacheConfiguration CACHE_DISABLED = new DisabledCache();
 
 	private final Duration cacheDuration;
 	private final int cacheSize;
 	private final Duration tokenExpirationDelta;
+	private final boolean cacheStatisticsEnabled;
 
 	/**
 	 * Creates a new {@link TokenCacheConfiguration} instance with the given
@@ -39,7 +40,31 @@ public class TokenCacheConfiguration implements CacheConfiguration {
 	public static TokenCacheConfiguration getInstance(@Nonnull Duration cacheDuration, int cacheSize,
 			@Nonnull Duration tokenExpirationDelta) {
 		Assertions.assertNotNull(cacheDuration, "The cache duration write must not be null!");
-		return new TokenCacheConfiguration(cacheDuration, cacheSize, tokenExpirationDelta);
+		return new TokenCacheConfiguration(cacheDuration, cacheSize, tokenExpirationDelta, false);
+	}
+
+	/**
+	 * Creates a new {@link TokenCacheConfiguration} instance with the given
+	 * properties. See {@link CacheConfiguration#getCacheDuration()},
+	 * {@link CacheConfiguration#getCacheSize()},
+	 * {@link TokenCacheConfiguration#getTokenExpirationDelta()} and
+	 * {@link CacheConfiguration#isCacheStatisticsEnabled()} for an explanation of
+	 * the respective properties.
+	 *
+	 * @param cacheDuration
+	 *            the cache duration property.
+	 * @param cacheSize
+	 *            the cache size property.
+	 * @param tokenExpirationDelta
+	 *            the token expiration delta.
+	 * @param cacheStatisticsEnabled
+	 *            {@code true} if cache statistic recording has been enabled
+	 *
+	 * @return a new {@link TokenCacheConfiguration} instance.
+	 */
+	public static TokenCacheConfiguration getInstance(Duration cacheDuration, int cacheSize,
+			Duration tokenExpirationDelta, boolean cacheStatisticsEnabled) {
+		return new TokenCacheConfiguration(cacheDuration, cacheSize, tokenExpirationDelta, cacheStatisticsEnabled);
 	}
 
 	/**
@@ -56,10 +81,12 @@ public class TokenCacheConfiguration implements CacheConfiguration {
 		return DEFAULT;
 	}
 
-	private TokenCacheConfiguration(Duration cacheDuration, int cacheSize, Duration tokenExpirationDelta) {
+	private TokenCacheConfiguration(Duration cacheDuration, int cacheSize, Duration tokenExpirationDelta,
+			boolean cacheStatisticsEnabled) {
 		this.cacheDuration = cacheDuration;
 		this.cacheSize = cacheSize;
 		this.tokenExpirationDelta = tokenExpirationDelta;
+		this.cacheStatisticsEnabled = cacheStatisticsEnabled;
 	}
 
 	@Nonnull
@@ -89,6 +116,11 @@ public class TokenCacheConfiguration implements CacheConfiguration {
 		return tokenExpirationDelta;
 	}
 
+	@Override
+	public boolean isCacheStatisticsEnabled() {
+		return cacheStatisticsEnabled;
+	}
+
 	@Override
 	public boolean equals(Object o) {
 		if (this == o)
@@ -117,7 +149,7 @@ public class TokenCacheConfiguration implements CacheConfiguration {
 	private static class DisabledCache extends TokenCacheConfiguration {
 
 		private DisabledCache() {
-			super(Duration.ZERO, 0, Duration.ZERO);
+			super(Duration.ZERO, 0, Duration.ZERO, false);
 		}
 
 		@Override


Test patch:
diff --git a/java-security-it/src/test/java/com/sap/cloud/security/test/performance/JavaSecurityPerformanceIT.java b/java-security-it/src/test/java/com/sap/cloud/security/test/performance/JavaSecurityPerformanceIT.java
new file mode 100644
index 00000000..a5b33de9
--- /dev/null
+++ b/java-security-it/src/test/java/com/sap/cloud/security/test/performance/JavaSecurityPerformanceIT.java
@@ -0,0 +1,95 @@
+package com.sap.cloud.security.test.performance;
+
+import com.sap.cloud.security.config.OAuth2ServiceConfiguration;
+import com.sap.cloud.security.config.OAuth2ServiceConfigurationBuilder;
+import com.sap.cloud.security.config.cf.CFConstants;
+import com.sap.cloud.security.test.SecurityTest;
+import com.sap.cloud.security.test.performance.util.BenchmarkUtil;
+import com.sap.cloud.security.token.Token;
+import com.sap.cloud.security.token.XsuaaToken;
+import com.sap.cloud.security.token.validation.CombiningValidator;
+import com.sap.cloud.security.token.validation.ValidationResult;
+import com.sap.cloud.security.token.validation.validators.JwtValidatorBuilder;
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import static com.sap.cloud.security.config.Service.XSUAA;
+import static com.sap.cloud.security.config.cf.CFConstants.XSUAA.VERIFICATION_KEY;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Performance test for java-security jwt token validation.
+ */
+public class JavaSecurityPerformanceIT {
+
+	private static final Logger LOGGER = LoggerFactory.getLogger(JavaSecurityPerformanceIT.class);
+	private static SecurityTest securityTest;
+
+	@BeforeAll
+	static void setUp() throws Exception {
+		securityTest = new SecurityTest(XSUAA).setKeys("/publicKey.txt", "/privateKey.txt");
+		securityTest.setup();
+		LOGGER.debug(BenchmarkUtil.getSystemInfo());
+	}
+
+	@AfterAll
+	static void tearDown() {
+		securityTest.tearDown();
+	}
+
+	@Test
+	public void onlineValidation() throws Exception {
+		Token token = securityTest.createToken();
+		CombiningValidator<Token> tokenValidator = createOnlineTokenValidator();
+		ValidationResult validationResult = tokenValidator.validate(token);
+		assertThat(validationResult.isValid()).isTrue();
+		String tokenValue = token.getTokenValue();
+
+		BenchmarkUtil.Result result = BenchmarkUtil.execute(() -> tokenValidator.validate(new XsuaaToken(tokenValue)));
+		LOGGER.info("Online validation result: {}", result.toString());
+	}
+
+	@Test
+	public void offlineValidation() throws Exception {
+		Token token = securityTest.createToken();
+		CombiningValidator<Token> tokenValidator = createOfflineTokenValidator();
+		ValidationResult validationResult = tokenValidator.validate(token);
+		assertThat(validationResult.isValid()).isTrue();
+		String tokenValue = token.getTokenValue();
+
+		BenchmarkUtil.Result result = BenchmarkUtil.execute(() -> tokenValidator.validate(new XsuaaToken(tokenValue)));
+		LOGGER.info("Offline validation result: {}", result.toString());
+	}
+
+	private CombiningValidator<Token> createOfflineTokenValidator() throws IOException {
+		String publicKey = IOUtils.resourceToString("/publicKey.txt", StandardCharsets.UTF_8);
+		OAuth2ServiceConfiguration configuration = createConfigurationBuilder()
+				.withProperty(VERIFICATION_KEY, publicKey)
+				.build();
+		return JwtValidatorBuilder.getInstance(configuration)
+				// oAuth2TokenKeyService mocked because verificationkey property is used for offline token validation
+				.withOAuth2TokenKeyService((uri) -> "{\"keys\": []}")
+				.build();
+	}
+
+	private CombiningValidator<Token> createOnlineTokenValidator() {
+		return JwtValidatorBuilder.getInstance(createConfigurationBuilder().build()).build();
+	}
+
+	private OAuth2ServiceConfigurationBuilder createConfigurationBuilder() {
+		return OAuth2ServiceConfigurationBuilder.forService(XSUAA)
+				.withProperty(CFConstants.XSUAA.UAA_DOMAIN, SecurityTest.DEFAULT_DOMAIN)
+				.withProperty(CFConstants.XSUAA.APP_ID, SecurityTest.DEFAULT_APP_ID)
+				.withClientId(SecurityTest.DEFAULT_CLIENT_ID);
+	}
+
+}
+
diff --git a/java-security-it/src/test/java/com/sap/cloud/security/test/performance/SpringXsuaaPerformanceIT.java b/java-security-it/src/test/java/com/sap/cloud/security/test/performance/SpringXsuaaPerformanceIT.java
new file mode 100644
index 00000000..68930f2b
--- /dev/null
+++ b/java-security-it/src/test/java/com/sap/cloud/security/test/performance/SpringXsuaaPerformanceIT.java
@@ -0,0 +1,93 @@
+package com.sap.cloud.security.test.performance;
+
+import com.sap.cloud.security.test.SecurityTest;
+import com.sap.cloud.security.test.performance.util.BenchmarkUtil;
+import com.sap.cloud.security.xsuaa.XsuaaCredentials;
+import com.sap.cloud.security.xsuaa.XsuaaServiceConfiguration;
+import com.sap.cloud.security.xsuaa.XsuaaServiceConfigurationCustom;
+import com.sap.cloud.security.xsuaa.token.authentication.XsuaaJwtDecoderBuilder;
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import static com.sap.cloud.security.config.Service.XSUAA;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Performance test for spring-xsuaa jwt token validation.
+ */
+public class SpringXsuaaPerformanceIT {
+
+	private static final Logger LOGGER = LoggerFactory.getLogger(SpringXsuaaPerformanceIT.class);
+	private static SecurityTest securityTest;
+
+	@BeforeAll
+	static void setUp() throws Exception {
+		LOGGER.debug(BenchmarkUtil.getSystemInfo());
+		securityTest = new SecurityTest(XSUAA).setKeys("/publicKey.txt", "/privateKey.txt");
+		securityTest.setup();
+	}
+
+	@AfterAll
+	static void tearDown() {
+		securityTest.tearDown();
+	}
+
+	@Test
+	public void onlineValidation() throws Exception {
+		String token = securityTest.createToken().getTokenValue();
+		JwtDecoder jwtDecoder = createOnlineJwtDecoder();
+		assertThat(jwtDecoder.decode(token)).isNotNull();
+
+		BenchmarkUtil.Result result = BenchmarkUtil.execute(() -> jwtDecoder.decode(token));
+		LOGGER.info("Online validation result: {}", result.toString());
+	}
+
+	@Test
+	public void offlineValidation() throws Exception {
+		String token = securityTest.createToken().getTokenValue();
+		JwtDecoder jwtDecoder = createOfflineJwtDecoder();
+		assertThat(jwtDecoder.decode(token)).isNotNull();
+
+		BenchmarkUtil.Result result = BenchmarkUtil.execute(() -> jwtDecoder.decode(token));
+		LOGGER.info("Offline validation result: {}", result.toString());
+	}
+
+	private JwtDecoder createOnlineJwtDecoder() {
+		XsuaaServiceConfigurationCustom configuration = new XsuaaServiceConfigurationCustom(createXsuaaCredentials());
+		JwtDecoder jwtDecoder = new XsuaaJwtDecoderBuilder(configuration).build();
+		return jwtDecoder;
+	}
+
+	private JwtDecoder createOfflineJwtDecoder() throws IOException {
+		final XsuaaCredentials xsuaaCredentials = createXsuaaCredentials();
+		// Workaround because RestOperations cannot easily be switched off
+		xsuaaCredentials.setUaaDomain("__nonExistingUaaDomainForOfflineTesting__");
+		final String publicKey = IOUtils.resourceToString("/publicKey.txt", StandardCharsets.UTF_8);
+		final XsuaaServiceConfiguration xsuaaConfig = new XsuaaServiceConfigurationCustom(xsuaaCredentials) {
+			@Override
+			public String getVerificationKey() {
+				return publicKey.replace("\n", "");
+			}
+		};
+		return new XsuaaJwtDecoderBuilder(xsuaaConfig).build();
+	}
+
+	private XsuaaCredentials createXsuaaCredentials() {
+		XsuaaCredentials xsuaaCredentials = new XsuaaCredentials();
+		xsuaaCredentials.setUaaDomain(SecurityTest.DEFAULT_DOMAIN);
+		xsuaaCredentials.setClientId(SecurityTest.DEFAULT_CLIENT_ID);
+		xsuaaCredentials.setXsAppName(SecurityTest.DEFAULT_APP_ID);
+		return xsuaaCredentials;
+	}
+
+}
+
diff --git a/java-security-it/src/test/java/com/sap/cloud/security/test/performance/util/BenchmarkUtil.java b/java-security-it/src/test/java/com/sap/cloud/security/test/performance/util/BenchmarkUtil.java
new file mode 100644
index 00000000..29d4bf38
--- /dev/null
+++ b/java-security-it/src/test/java/com/sap/cloud/security/test/performance/util/BenchmarkUtil.java
@@ -0,0 +1,67 @@
+package com.sap.cloud.security.test.performance.util;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.function.Supplier;
+
+public class BenchmarkUtil {
+
+	static final int WARMUP_ITERATIONS = 10_000;
+	static final int BENCHMARK_ITERATIONS = 100_000;
+
+	public static String getSystemInfo() {
+		String systemInfo =  String.format("Running on %s's %s %s, home is %s", System.getProperty("java.vm.vendor"),
+				System.getProperty("java.vm.name"), System.getProperty("java.vm.version"),
+				System.getProperty("java.home"));
+		String compilerInfo = String.format("   %s, %s", System.getProperty("sun.management.compiler"),
+				System.getProperty("java.vm.info"));
+		return systemInfo + System.lineSeparator() + compilerInfo;
+	}
+
+	public static Result execute(int iterations, Supplier<Object> toTestFn) {
+		// Warm up phase
+		// 10 000 iterations shall giver the JVM sufficient time to native-compile the code, so
+		// we're hopefully not hitting a compile phase when benchmarking.
+		// It is observable that higher values lead to effectively higher measured throughput.
+		executeInternal(WARMUP_ITERATIONS, toTestFn);
+
+		// actual benchmark run
+		Duration duration = executeInternal(iterations, toTestFn);
+		return new Result(iterations, duration);
+	}
+
+	public static Result execute(Supplier<Object> toTestFn) {
+		return execute(BENCHMARK_ITERATIONS, toTestFn);
+	}
+
+	private static Duration executeInternal(int localIterations, Supplier<Object> validationFn) {
+		// Trick JVM escape analysis optimizations that may optimize out calls to validate entirely
+		Object obj;
+		final Instant start = Instant.now();
+		for (int i = 0; i < localIterations; ++i) {
+			obj = validationFn.get();
+		}
+		final Instant stop = Instant.now();
+		return Duration.between(start, stop);
+	}
+
+	public static class Result {
+		public final int iterations;
+		public final Duration duration;
+
+		public Result(int iterations, Duration duration) {
+			this.iterations = iterations;
+			this.duration = duration;
+		}
+
+		public String getFormattedResult() {
+			return String.format("%d runs in %d milliseconds -> %.2f runs/second",
+					iterations, duration.toMillis(), (iterations * 1000.0) / duration.toMillis());
+		}
+
+		@Override
+		public String toString() {
+			return String.format("Benchmark result: %s", getFormattedResult());
+		}
+	}
+}
diff --git a/java-security-it/src/test/resources/simplelogger.properties b/java-security-it/src/test/resources/simplelogger.properties
new file mode 100644
index 00000000..e44572c3
--- /dev/null
+++ b/java-security-it/src/test/resources/simplelogger.properties
@@ -0,0 +1,5 @@
+org.slf4j.simpleLogger.defaultLogLevel: WARN
+org.slf4j.simpleLogger.log.com.sap.cloud.security.test: DEBUG
+org.slf4j.simpleLogger.log.com.sap.cloud.security.token.validation.validators.JwtAudienceValidator: WARN
+org.slf4j.simpleLogger.log.com.sap.cloud.security.token.validation.ValidationResults: ERROR
+org.slf4j.simpleLogger.log.com.sap.cloud.security.xsuaa.token.authentication.XsuaaJwtDecoder: ERROR
\ No newline at end of file
diff --git a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/OAuth2TokenKeyServiceWithCacheTest.java b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/OAuth2TokenKeyServiceWithCacheTest.java
index a23fd48f..e9fecc90 100644
--- a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/OAuth2TokenKeyServiceWithCacheTest.java
+++ b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/OAuth2TokenKeyServiceWithCacheTest.java
@@ -16,16 +16,17 @@ import java.security.spec.InvalidKeySpecException;
 import java.time.Duration;
 
 import com.github.benmanes.caffeine.cache.Ticker;
+import com.github.benmanes.caffeine.cache.stats.CacheStats;
 import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException;
 import com.sap.cloud.security.xsuaa.client.OAuth2TokenKeyService;
-import com.sap.cloud.security.xsuaa.tokenflows.TokenCacheConfiguration;
 import org.apache.commons.io.IOUtils;
 import org.junit.Before;
 import org.junit.Test;
 
 public class OAuth2TokenKeyServiceWithCacheTest {
 
-	public static final TokenCacheConfiguration CACHE_CONFIGURATION = TokenCacheConfiguration.defaultConfiguration();
+	public static final TokenKeyCacheConfiguration CACHE_CONFIGURATION = TokenKeyCacheConfiguration
+			.defaultConfiguration();
 
 	OAuth2TokenKeyServiceWithCache cut;
 	OAuth2TokenKeyService tokenKeyServiceMock;
@@ -39,10 +40,7 @@ public class OAuth2TokenKeyServiceWithCacheTest {
 				.thenReturn(IOUtils.resourceToString("/jsonWebTokenKeys.json", StandardCharsets.UTF_8));
 
 		testCacheTicker = new TestCacheTicker();
-		cut = OAuth2TokenKeyServiceWithCache
-				.getInstance(testCacheTicker)
-				.withTokenKeyService(tokenKeyServiceMock)
-				.withCacheConfiguration(CACHE_CONFIGURATION);
+		cut = createCut(CACHE_CONFIGURATION);
 	}
 
 	@Test
@@ -128,6 +126,22 @@ public class OAuth2TokenKeyServiceWithCacheTest {
 		verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(any());
 	}
 
+	@Test
+	public void cacheStatistics_isDisabled_statisticsObjectIsNull() {
+		cut = createCut(TokenKeyCacheConfiguration
+				.getInstance(CACHE_CONFIGURATION.getCacheDuration(), CACHE_CONFIGURATION.getCacheSize(), false));
+
+		assertThat(cut.getCacheStatistics()).isNull();
+	}
+
+	@Test
+	public void cacheStatistics_isEnabled_returnsStatisticsObject() {
+		cut = createCut(TokenKeyCacheConfiguration
+				.getInstance(CACHE_CONFIGURATION.getCacheDuration(), CACHE_CONFIGURATION.getCacheSize(), true));
+
+		assertThat(cut.getCacheStatistics()).isInstanceOf(CacheStats.class);
+	}
+
 	@Test
 	public void retrieveTokenKeysForNewKeyId()
 			throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException {
@@ -137,7 +151,6 @@ public class OAuth2TokenKeyServiceWithCacheTest {
 		verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(any());
 	}
 
-
 	@Test
 	public void retrieveTokenKeysForNewEndpoint()
 			throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException {
@@ -147,7 +160,14 @@ public class OAuth2TokenKeyServiceWithCacheTest {
 		verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(any());
 	}
 
-	private class TestCacheTicker implements  Ticker {
+	private OAuth2TokenKeyServiceWithCache createCut(TokenKeyCacheConfiguration cacheConfiguration) {
+		return OAuth2TokenKeyServiceWithCache
+				.getInstance(testCacheTicker)
+				.withTokenKeyService(tokenKeyServiceMock)
+				.withCacheConfiguration(cacheConfiguration);
+	}
+
+	private class TestCacheTicker implements Ticker {
 		long elapsed = 0;
 
 		@Override
diff --git a/samples/spring-security-xsuaa-usage/src/test/java/sample/spring/xsuaa/TestControllerTest.java b/samples/spring-security-xsuaa-usage/src/test/java/sample/spring/xsuaa/TestControllerTest.java
index 2fbbf923..97e18d74 100644
--- a/samples/spring-security-xsuaa-usage/src/test/java/sample/spring/xsuaa/TestControllerTest.java
+++ b/samples/spring-security-xsuaa-usage/src/test/java/sample/spring/xsuaa/TestControllerTest.java
@@ -3,6 +3,7 @@ package sample.spring.xsuaa;
 import com.sap.cloud.security.config.Service;
 import com.sap.cloud.security.test.SecurityTestRule;
 import com.sap.cloud.security.token.TokenClaims;
+import org.junit.Before;
 import org.junit.ClassRule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -29,19 +30,25 @@ public class TestControllerTest {
     @Autowired
     private MockMvc mvc;
 
-    private String jwt = rule.getPreconfiguredJwtGenerator()
-            .withLocalScopes("Read")
-            //.withClaimValue(TokenClaims.XSUAA.ORIGIN, "sap-default") // optional
-            //.withClaimValue(TokenClaims.USER_NAME, "John") // optional
-            .createToken().getTokenValue();
+    private String jwt;
 
-    private String jwtAdmin = rule.getPreconfiguredJwtGenerator()
-            .withLocalScopes("Read", "Admin")
-            .createToken().getTokenValue();
+    private String jwtAdmin;
 
     @ClassRule
     public static SecurityTestRule rule = SecurityTestRule.getInstance(Service.XSUAA);
 
+    @Before
+    public void setUp() throws Exception {
+        jwt = rule.getPreconfiguredJwtGenerator()
+                .withLocalScopes("Read")
+                //.withClaimValue(TokenClaims.XSUAA.ORIGIN, "sap-default") // optional
+                //.withClaimValue(TokenClaims.USER_NAME, "John") // optional
+                .createToken().getTokenValue();
+        jwtAdmin = rule.getPreconfiguredJwtGenerator()
+                .withLocalScopes("Read", "Admin")
+                .createToken().getTokenValue();
+    }
+
     @Test
     public void v1_sayHello() throws Exception {
         String response = mvc
diff --git a/samples/spring-security-xsuaa-usage/src/test/java/sample/spring/xsuaa/junitjupiter/TestControllerTest.java b/samples/spring-security-xsuaa-usage/src/test/java/sample/spring/xsuaa/junitjupiter/TestControllerTest.java
index 63418e5c..0d2ddc6b 100644
--- a/samples/spring-security-xsuaa-usage/src/test/java/sample/spring/xsuaa/junitjupiter/TestControllerTest.java
+++ b/samples/spring-security-xsuaa-usage/src/test/java/sample/spring/xsuaa/junitjupiter/TestControllerTest.java
@@ -32,17 +32,19 @@ public class TestControllerTest {
 
 	private static SecurityTest securityTest = new SecurityTest(Service.XSUAA);
 
-	private String jwt = securityTest.getPreconfiguredJwtGenerator()
-			.withLocalScopes("Read")
-			.createToken().getTokenValue();
+	private String jwt;
 
-	private String jwtAdmin = securityTest.getPreconfiguredJwtGenerator()
-			.withLocalScopes("Read", "Admin")
-			.createToken().getTokenValue();
+	private String jwtAdmin;
 
 	@BeforeEach
 	public void setup() throws Exception {
 		securityTest.setup();
+		jwt = securityTest.getPreconfiguredJwtGenerator()
+				.withLocalScopes("Read")
+				.createToken().getTokenValue();
+		jwtAdmin = securityTest.getPreconfiguredJwtGenerator()
+				.withLocalScopes("Read", "Admin")
+				.createToken().getTokenValue();
 	}
 
 	@AfterAll
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
index 81c5744f..1ca404aa 100644
--- 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
@@ -1,6 +1,7 @@
 package com.sap.cloud.security.xsuaa.client;
 
 import com.github.benmanes.caffeine.cache.Ticker;
+import com.github.benmanes.caffeine.cache.stats.CacheStats;
 import com.sap.cloud.security.xsuaa.http.HttpHeaders;
 import com.sap.cloud.security.xsuaa.tokenflows.TokenCacheConfiguration;
 import org.assertj.core.util.Maps;
@@ -216,6 +217,22 @@ public class AbstractOAuth2TokenServiceTest {
 		assertThat(cut.tokenRequestCallCount).isEqualTo(2);
 	}
 
+	@Test
+	public void cacheStatistics_isDisabled_statisticsObjectIsNull() {
+		TokenCacheConfiguration tokenCacheConfiguration = cacheConfigurationWithCacheStatistics(false);
+		cut = new TestOAuth2TokenService(tokenCacheConfiguration);
+
+		assertThat(cut.getCacheStatistics()).isNull();
+	}
+
+	@Test
+	public void cacheStatistics_isEnabled_returnsStatisticsObject() {
+		TokenCacheConfiguration tokenCacheConfiguration = cacheConfigurationWithCacheStatistics(true);
+		cut = new TestOAuth2TokenService(tokenCacheConfiguration);
+
+		assertThat(cut.getCacheStatistics()).isInstanceOf(CacheStats.class);
+	}
+
 	private OAuth2TokenResponse retrieveAccessTokenViaJwtBearerTokenGrant(String token) throws OAuth2ServiceException {
 		return retrieveAccessTokenViaJwtBearerTokenGrant(token, null);
 	}
@@ -272,6 +289,12 @@ public class AbstractOAuth2TokenServiceTest {
 				TEST_CACHE_CONFIGURATION.getTokenExpirationDelta());
 	}
 
+	private TokenCacheConfiguration cacheConfigurationWithCacheStatistics(boolean enableCacheStatistics) {
+		return TokenCacheConfiguration.getInstance(TEST_CACHE_CONFIGURATION.getCacheDuration(),
+				TEST_CACHE_CONFIGURATION.getCacheSize(), TEST_CACHE_CONFIGURATION.getTokenExpirationDelta(),
+				enableCacheStatistics);
+	}
+
 	private static class TestOAuth2TokenService extends AbstractOAuth2TokenService {
 
 		private final static TestCacheTicker testCacheTicker = new TestCacheTicker();