Repository: SAP/cloud-security-xsuaa-integration
Pull request #564 (2021-07-08 11:52:15)

Improves Jwks cache handling
The zoneId should not be part of the cache key, because the same set of keys is returned for the same issuer url / jwks endpoint. This is especially relevant when supporting shared IAS tenants.

JWKS cache definition - before change

````
cache key := JWKS url + algorithm + keyId + zoneId
cache content := PublicKey
````

... and now:

````
cache key :=  JWKS url
cache content := JWKS keys, map[zoneID][accepted]
````

Other changes:
- less calls to identity service, as it caches invalid zones as well
- limits jwks cache duration to 15 Minutes
- **DOES NOT call jwks endpoints before cache entry gets expired => tokens signed with new token keys are not accepted immediately but after a delay of up to 15 minutes.**

Related BLI (XSUAA)
- XSTWOSEC-2778


Furthemore, it respects the log improvement from PR #846 with these additional logs
**1. jku url does not provide any keys:**
> ERROR Retrieved no token keys from <uri>

**2. keys mismatch** 
> WARN No matching key found. Keys cached: key-id-0(RS256)|key-id-1(RS256)


**3. token keys endpoint is called** 
> DEBUG Successfully retrieved token keys from <uri> for zone <zone>


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 69d647ae..495ca143 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,10 @@
 # Change Log
 All notable changes to this project will be documented in this file.
 
+## 2.11.13
+- [java-security] [spring-security]
+  -  Improves JWKS cache handling for OIDC token validation. This is especially relevant when using a shared IAS tenant.
+
 
 ## 2.11.12
 - [java-security] `XsuaaTokenAuthenticator` should support validation of tokens issued by Cloud Foundry UAA (NGPBUG-175120)
@@ -193,6 +197,7 @@ See [sample](https://github.com/SAP/cloud-security-xsuaa-integration/blob/main/s
   - logs 'WARN' message, in case application has not overwritten the default http client. Find further information about that [here](/token-client#common-pitfalls).
 - [java-security] 
   - `IasXsuaaExchangeBroker` supports X.509 based token exchange. In case the token exchange is done via `XsuaaTokenAuthenticator` you need to provide a http client that is prepared with ssl context.
+  - `JwtIssuerValidator.java` supports custom domains of identity service. If `ias_iss` is given and not empty, `JwtIssuerValidator.java` checks whether its a valid url and checks whether this matches one of the valid domains of the identity service. The check whether `ias` matches to any given domains is skipped in that case.
   - The token keys cache does not accept cache time longer than 15 minutes.
 - [spring-xsuaa] and starter
   - As of Spring Security version 5.5.0 only `BadJwtException` results in `InvalidBearerTokenException`, which are handled and mapped to ``401`` status code. Consequently, `XsuaaJwtDecoder` raises `BadJwtException`s instead of `JwtException`s.
diff --git a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeyImpl.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeyImpl.java
index 5f2da436..b14d2c4a 100644
--- a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeyImpl.java
+++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeyImpl.java
@@ -96,11 +96,7 @@ class JsonWebKeyImpl implements JsonWebKey {
 
 	@Override
 	public int hashCode() {
-		return calculateUniqueId(keyAlgorithm, keyId);
-	}
-
-	public static int calculateUniqueId(JwtSignatureAlgorithm algorithm, String keyId) {
-		return Objects.hash(algorithm, keyId != null ? keyId : DEFAULT_KEY_ID);
+		return Objects.hash(keyAlgorithm, keyId != null ? keyId : DEFAULT_KEY_ID);
 	}
 
 	@Override
@@ -117,4 +113,9 @@ class JsonWebKeyImpl implements JsonWebKey {
 		return keyId.equals(that.keyId);
 	}
 
+	@Override
+	public String toString() {
+		return getId() + "(" + getKeyAlgorithm() + ")";
+	}
+
 }
diff --git a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeySet.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeySet.java
index e3bf1fcc..55268048 100644
--- a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeySet.java
+++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeySet.java
@@ -7,17 +7,17 @@ package com.sap.cloud.security.token.validation.validators;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 class JsonWebKeySet {
 
-	private Set<JsonWebKey> jsonWebKeys;
-
-	JsonWebKeySet() {
-		jsonWebKeys = new HashSet<>();
-	}
+	private final Set<JsonWebKey> jsonWebKeys = new HashSet<>();
+	private Map<String, Boolean> zoneIdAccepted = new HashMap<>();
 
 	@Nullable
 	public JsonWebKey getKeyByAlgorithmAndId(JwtSignatureAlgorithm keyAlgorithm, String keyId) {
@@ -44,4 +44,21 @@ class JsonWebKeySet {
 				.filter(jwk -> algorithm.equals(jwk.getKeyAlgorithm()))
 				.filter(jwk -> kid.equals(jwk.getId()));
 	}
+
+	public boolean containsZoneId(String zoneId) {
+		return zoneIdAccepted.containsKey(zoneId);
+	}
+
+	public boolean isZoneIdAccepted(String zoneId) {
+		return zoneIdAccepted.get(zoneId);
+	}
+
+	public JsonWebKeySet withZoneId(String zoneId, boolean isAccepted) {
+		zoneIdAccepted.put(zoneId, isAccepted);
+		return this;
+	}
+
+	public String toString() {
+		return jsonWebKeys.stream().map(String::valueOf).collect(Collectors.joining("|"));
+	}
 }
diff --git a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureValidator.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureValidator.java
index b77ff7ac..472e538e 100644
--- a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureValidator.java
+++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtSignatureValidator.java
@@ -214,8 +214,8 @@ class JwtSignatureValidator implements Validator<Token> {
 			}
 			if (this.publicKey == null) {
 				return createInvalid(
-						"There is no Json Web Token Key with keyId '{}' and type '{}' to prove the identity of the Jwt.",
-						keyId, jwtSignatureAlgorithm.type());
+						"There is no Json Web Token Key with keyId '{}' and type '{}' found on jwks uri {} for zone '{}' to prove the identity of the Jwt.",
+						keyId, jwtSignatureAlgorithm.type(), keyUri, zoneId);
 			}
 			return createValid();
 		}
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 c3350a19..9afd5ab3 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
@@ -16,8 +16,6 @@ import java.security.NoSuchAlgorithmException;
 import java.security.PublicKey;
 import java.security.spec.InvalidKeySpecException;
 import java.time.Duration;
-import java.util.Set;
-import java.util.stream.Collectors;
 
 import com.github.benmanes.caffeine.cache.Cache;
 import com.github.benmanes.caffeine.cache.Caffeine;
@@ -38,8 +36,8 @@ import org.slf4j.LoggerFactory;
 class OAuth2TokenKeyServiceWithCache implements Cacheable {
 	private static final Logger LOGGER = LoggerFactory.getLogger(OAuth2TokenKeyServiceWithCache.class);
 
-	private OAuth2TokenKeyService tokenKeyService; // access via getter
-	private Cache<String, PublicKey> cache; // access via getter
+	private OAuth2TokenKeyService tokenKeyService;
+	private Cache<String, JsonWebKeySet> cache;
 	private CacheConfiguration cacheConfiguration = TokenKeyCacheConfiguration.defaultConfiguration();
 	private Ticker cacheTicker;
 
@@ -147,20 +145,12 @@ class OAuth2TokenKeyServiceWithCache implements Cacheable {
 	 *            the Token Key Uri (jwks) of the Access Token (can be tenant
 	 *            specific).
 	 * @return a PublicKey
-	 * @throws OAuth2ServiceException
-	 *             in case the call to the jwks endpoint of the identity service
-	 *             failed.
-	 * @throws InvalidKeySpecException
-	 *             in case the PublicKey generation for the json web key failed.
-	 * @throws NoSuchAlgorithmException
-	 *             in case the algorithm of the json web key is not supported.
 	 * @deprecated in favor of
 	 *             {@link #getPublicKey(JwtSignatureAlgorithm, String, URI, String)}
 	 */
 	@Nullable
 	@Deprecated
-	public PublicKey getPublicKey(JwtSignatureAlgorithm keyAlgorithm, String keyId, URI keyUri)
-			throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException {
+	public PublicKey getPublicKey(JwtSignatureAlgorithm keyAlgorithm, String keyId, URI keyUri) {
 		throw new UnsupportedOperationException("use getPublicKey(keyAlgorithm, keyId, keyUri, zoneId) instead");
 	}
 
@@ -194,19 +184,24 @@ class OAuth2TokenKeyServiceWithCache implements Cacheable {
 		assertHasText(keyId, "keyId must not be null.");
 		assertNotNull(keyUri, "keyUrl must not be null.");
 
-		String cacheKey = getUniqueCacheKey(keyAlgorithm, keyId, keyUri, zoneId);
-
-		PublicKey publicKey = getCache().getIfPresent(cacheKey);
-		if (publicKey == null) {
-			retrieveTokenKeysAndFillCache(keyUri, zoneId);
+		JsonWebKeySet keySet = getCache().getIfPresent(keyUri.toString());
+		if (keySet == null || !keySet.containsZoneId(zoneId)) {
+			keySet = retrieveTokenKeysAndUpdateCache(keyUri, zoneId, keySet); // creates and updates cache entries
+		}
+		if (keySet == null || keySet.getAll().isEmpty()) {
+			LOGGER.error("Retrieved no token keys from {}", keyUri);
+			return null;
+		}
+		if (!keySet.isZoneIdAccepted(zoneId)) {
+			throw new OAuth2ServiceException("Keys not accepted for zone_uuid " + zoneId);
 		}
-		publicKey = getCache().getIfPresent(cacheKey);
-		if (publicKey == null && LOGGER.isDebugEnabled()) {
-			Set<String> keys = getCache().asMap().keySet();
-			LOGGER.debug("Key {} not found in cache. Keys cached: {}", cacheKey,
-					keys.stream().map(String::valueOf).collect(Collectors.joining("|")));
+		for (JsonWebKey jwk : keySet.getAll()) {
+			if (keyId.equals(jwk.getId()) && jwk.getKeyAlgorithm().equals(keyAlgorithm)) {
+				return jwk.getPublicKey();
+			}
 		}
-		return publicKey;
+		LOGGER.warn("No matching key found. Keys cached: {}", keySet.toString());
+		return null;
 	}
 
 	private TokenKeyCacheConfiguration getCheckedConfiguration(CacheConfiguration cacheConfiguration) {
@@ -227,24 +222,38 @@ class OAuth2TokenKeyServiceWithCache implements Cacheable {
 					duration.getSeconds(), currentDuration.getSeconds());
 			duration = currentDuration;
 		}
+		if (duration.getSeconds() > 900) {
+			Duration currentDuration = getCacheConfiguration().getCacheDuration();
+			LOGGER.error(
+					"Tried to set cache duration to {} seconds but the cache duration must be maximum 900 seconds."
+							+ " Cache duration will remain at: {} seconds",
+					duration.getSeconds(), currentDuration.getSeconds());
+			duration = currentDuration;
+		}
 		return TokenKeyCacheConfiguration.getInstance(duration, size, cacheConfiguration.isCacheStatisticsEnabled());
 	}
 
-	private void retrieveTokenKeysAndFillCache(URI jwksUri, String zoneId)
-			throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException {
-		JsonWebKeySet keySet = JsonWebKeySetFactory
-				.createFromJson(getTokenKeyService().retrieveTokenKeys(jwksUri, zoneId));
-		if (keySet == null || keySet.getAll().isEmpty()) {
-			LOGGER.error("Retrieved no token keys from {} for zone '{}'", jwksUri, zoneId);
-			return;
+	private JsonWebKeySet retrieveTokenKeysAndUpdateCache(URI jwksUri, String zoneId,
+			@Nullable JsonWebKeySet keySetCached)
+			throws OAuth2ServiceException {
+		String jwksJson;
+		try {
+			jwksJson = getTokenKeyService().retrieveTokenKeys(jwksUri, zoneId);
+		} catch (OAuth2ServiceException e) {
+			if (keySetCached != null) {
+				keySetCached.withZoneId(zoneId, false);
+			}
+			throw e;
 		}
-		Set<JsonWebKey> jwks = keySet.getAll();
-		for (JsonWebKey jwk : jwks) {
-			getCache().put(getUniqueCacheKey(jwk.getKeyAlgorithm(), jwk.getId(), jwksUri, zoneId), jwk.getPublicKey());
+		if (keySetCached != null) {
+			return keySetCached.withZoneId(zoneId, true);
 		}
+		JsonWebKeySet keySet = JsonWebKeySetFactory.createFromJson(jwksJson).withZoneId(zoneId, true);
+		getCache().put(jwksUri.toString(), keySet);
+		return keySet;
 	}
 
-	private Cache<String, PublicKey> getCache() {
+	private Cache<String, JsonWebKeySet> getCache() {
 		if (cache == null) {
 			Caffeine<Object, Object> cacheBuilder = Caffeine.newBuilder()
 					.ticker(cacheTicker)
@@ -283,9 +292,4 @@ class OAuth2TokenKeyServiceWithCache implements Cacheable {
 		return getCacheConfiguration().isCacheStatisticsEnabled() ? getCache().stats() : null;
 	}
 
-	public static String getUniqueCacheKey(JwtSignatureAlgorithm keyAlgorithm, String keyId, URI jwksUri,
-			String zoneId) {
-		return jwksUri + keyId + keyAlgorithm + zoneId;
-	}
-
 }
diff --git a/pom.xml b/pom.xml
index f3ac1f08..a0f78d6f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -58,7 +58,7 @@
 		<maven.compiler.source>1.8</maven.compiler.source>
 		<maven.source.plugin.version>3.2.1</maven.source.plugin.version>
 		<!-- make sure that spring core and spring boot versions are compatible-->
-		<spring.boot.version>2.6.4</spring.boot.version>
+		<spring.boot.version>2.6.5</spring.boot.version>
 		<spring.core.version>5.3.17</spring.core.version>
 		<spring.security.version>5.6.2</spring.security.version>
 		<spring.security.oauth2.version>2.5.1.RELEASE</spring.security.oauth2.version>
@@ -83,7 +83,7 @@
 		<wiremock.version>2.32.0</wiremock.version>
 		<javax.annotation.version>1.3.2</javax.annotation.version>
 		<spotbugs.annotations.version>4.6.0</spotbugs.annotations.version>
-		<spotbugs.version>4.5.3.0</spotbugs.version>
+		<spotbugs.version>4.6.0.0</spotbugs.version>
 		<skipTests>false</skipTests>
 		<jacoco.skip>${skipTests}</jacoco.skip>
 	</properties>
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenKeyService.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenKeyService.java
index 4263322b..092db101 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenKeyService.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenKeyService.java
@@ -48,8 +48,8 @@ public class DefaultOAuth2TokenKeyService implements OAuth2TokenKeyService {
 		try (CloseableHttpResponse response = httpClient.execute(request)) {
 			String bodyAsString = HttpClientUtil.extractResponseBodyAsString(response);
 			int statusCode = response.getStatusLine().getStatusCode();
-			LOGGER.debug("retrieve token keys {} for zone '{}'", tokenKeysEndpointUri, zoneId);
 			if (statusCode == HttpStatus.SC_OK) {
+				LOGGER.debug("Successfully retrieved token keys from {} for zone '{}'", tokenKeysEndpointUri, zoneId);
 				return bodyAsString;
 			} else {
 				throw OAuth2ServiceException.builder("Error retrieving token keys for x-zone_uuid " + zoneId)
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyService.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyService.java
index 62a384e7..93ba0b35 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyService.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyService.java
@@ -6,6 +6,8 @@
 package com.sap.cloud.security.xsuaa.client;
 
 import com.sap.cloud.security.xsuaa.Assertions;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.http.*;
 import org.springframework.web.client.HttpStatusCodeException;
 import org.springframework.web.client.RestOperations;
@@ -19,6 +21,9 @@ import static com.sap.cloud.security.xsuaa.http.HttpHeaders.X_ZONE_UUID;
 import static org.springframework.http.HttpMethod.GET;
 
 public class SpringOAuth2TokenKeyService implements OAuth2TokenKeyService {
+
+	private static final Logger LOGGER = LoggerFactory.getLogger(SpringOAuth2TokenKeyService.class);
+
 	private final RestOperations restOperations;
 
 	public SpringOAuth2TokenKeyService(@Nonnull RestOperations restOperations) {
@@ -27,7 +32,7 @@ public class SpringOAuth2TokenKeyService implements OAuth2TokenKeyService {
 	}
 
 	@Override
-	public String retrieveTokenKeys(URI tokenKeysEndpointUri, @Nullable String zoneId) throws OAuth2ServiceException {
+	public String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri, @Nullable String zoneId) throws OAuth2ServiceException {
 		Assertions.assertNotNull(tokenKeysEndpointUri, "Token key endpoint must not be null!");
 		try {
 			// create headers
@@ -36,10 +41,10 @@ public class SpringOAuth2TokenKeyService implements OAuth2TokenKeyService {
 			if (zoneId != null) {
 				headers.set(X_ZONE_UUID, zoneId);
 			}
-
 			ResponseEntity<String> response = restOperations.exchange(
 					tokenKeysEndpointUri, GET, new HttpEntity(headers), String.class);
 			if (HttpStatus.OK.value() == response.getStatusCode().value()) {
+				LOGGER.debug("Successfully retrieved token keys from {} for zone '{}'", tokenKeysEndpointUri, zoneId);
 				return response.getBody();
 			} else {
 				throw OAuth2ServiceException.builder("Error retrieving token keys")


Test patch:
diff --git a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/IdTokenSignatureValidatorTest.java b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/IdTokenSignatureValidatorTest.java
index 6bb10da5..1be30780 100644
--- a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/IdTokenSignatureValidatorTest.java
+++ b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/IdTokenSignatureValidatorTest.java
@@ -116,7 +116,7 @@ public class IdTokenSignatureValidatorTest {
 		assertThat(result.isErroneous(), is(true));
 		assertThat(result.getErrorDescription(),
 				containsString(
-						"There is no Json Web Token Key with keyId 'default-kid-2' and type 'RSA' to prove the identity of the Jwt."));
+						"There is no Json Web Token Key with keyId 'default-kid-2' and type 'RSA' found"));
 	}
 
 }
diff --git a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JsonWebKeySetTest.java b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JsonWebKeySetTest.java
index 7acc27f4..72a4bb40 100644
--- a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JsonWebKeySetTest.java
+++ b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JsonWebKeySetTest.java
@@ -106,4 +106,9 @@ public class JsonWebKeySetTest {
 				.isEqualTo(JSON_WEB_KEY_DEFAULT);
 	}
 
+	@Test
+	public void stringify() {
+		cut.put(JSON_WEB_KEY);
+		assertThat(cut.toString()).isEqualTo("key-id-1(RS256)");
+	}
 }
\ 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 ec12c9ae..9507d0bb 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
@@ -56,7 +56,7 @@ public class OAuth2TokenKeyServiceWithCacheTest {
 
 	@Test
 	public void changeCacheConfiguration() {
-		cut = cut.withCacheSize(1234).withCacheTime(678);
+		cut = cut.withCacheConfiguration(TokenKeyCacheConfiguration.getInstance(Duration.ofSeconds(678), 1234, false));
 
 		assertThat(cut.getCacheConfiguration().getCacheSize()).isEqualTo(1234);
 		assertThat(cut.getCacheConfiguration().getCacheDuration()).isEqualTo(Duration.ofSeconds(678));
@@ -64,16 +64,24 @@ public class OAuth2TokenKeyServiceWithCacheTest {
 
 	@Test
 	public void changeCacheConfiguration_valuesTooLow_leftUnchanged() {
-
 		Duration oldCacheDuration = cut.getCacheConfiguration().getCacheDuration();
 		int oldCacheSize = cut.getCacheConfiguration().getCacheSize();
 
-		cut = cut.withCacheSize(1).withCacheTime(1);
+		cut = cut.withCacheConfiguration(TokenKeyCacheConfiguration.getInstance(Duration.ofSeconds(1), 1, false));
 
 		assertThat(cut.getCacheConfiguration().getCacheSize()).isEqualTo(oldCacheSize);
 		assertThat(cut.getCacheConfiguration().getCacheDuration()).isEqualTo(oldCacheDuration);
 	}
 
+	@Test
+	public void changeCacheConfiguration_tooLongDuration_leftUnchanged() {
+		Duration oldCacheDuration = cut.getCacheConfiguration().getCacheDuration();
+
+		cut = cut.withCacheConfiguration(TokenKeyCacheConfiguration.getInstance(Duration.ofMinutes(16), 601, false));
+
+		assertThat(cut.getCacheConfiguration().getCacheDuration()).isEqualTo(oldCacheDuration);
+	}
+
 	@Test
 	public void retrieveTokenKeys() throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException {
 		PublicKey key = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, ZONE_ID);
@@ -154,7 +162,35 @@ public class OAuth2TokenKeyServiceWithCacheTest {
 		cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, ZONE_ID);
 		cut.getPublicKey(JwtSignatureAlgorithm.RS256, "not-seen-yet", TOKEN_KEYS_URI, ZONE_ID);
 
-		verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(any(), eq(ZONE_ID));
+		verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(any(), eq(ZONE_ID));
+	}
+
+	@Test
+	public void retrieveTokenKeysForNewZoneId()
+			throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException {
+		cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, ZONE_ID);
+		cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, ZONE_ID + "-2");
+
+		verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(any(), any());
+		verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(any(), eq(ZONE_ID));
+		verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(any(), eq(ZONE_ID + "-2"));
+	}
+
+	@Test
+	public void retrieveTokenKeysForAnotherInvalidZoneId()
+			throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException {
+		when(tokenKeyServiceMock.retrieveTokenKeys(any(), eq("invalid-zone")))
+				.thenThrow(new OAuth2ServiceException("Invalid zone_uuid provided"));
+		cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, ZONE_ID);
+
+		assertThatThrownBy(() -> {
+			cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, "invalid-zone");
+		}).isInstanceOf(OAuth2ServiceException.class).hasMessageStartingWith("Invalid");
+		assertThatThrownBy(() -> {
+			cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, "invalid-zone");
+		}).isInstanceOf(OAuth2ServiceException.class).hasMessageStartingWith("Keys not accepted for zone_uuid invalid-zone");
+
+		verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(any(), eq("invalid-zone"));
 	}
 
 	@Test