Repository: SAP/cloud-security-xsuaa-integration
Pull request #253 (2020-03-23 16:08:43)

Cloud bulletinboard migration
This PR adds two functionalities that made the migration of the [cloud-bulletinboard-ads](https://github.com/nenaraab/cloud-bulletinboard-ads) application easier. 

### withLocalScopes for JwtGenerator

  This makes it possible to set local scopes on the `JwtGenerator`. For this to work the `JwtGenerator`   needs to know which `appId` to use. I have solved this by adding the `withAppId` method to the   `JwtGenerator`. This is debatable and could also be added to the factory method of the   `JwtGenerator`. I chose the method because it is only needed when `withLocalScopes` is used.  `withLocalScopes` behaves like `withScopes` in the regard that consecutive calls always override the  
 old data.  This means if `withScopes("c", "d")` is called right after `withScopes("a", "b")` the resulting   token will only contain scopes ["c", "d"]. The same is true for `withLocalScopes`...
   **EDIT**: after discussion it overrides local scopes but not the (global) scopes provided with `withScopes("c", "d")`, see [comment](https://github.com/SAP/cloud-security-xsuaa-integration/pull/253#issuecomment-603235279) 

### Offline key verification for spring-xsuaa

  The token validator now falls back to the verification key of `VCAP_SERVICES` for the token signature 
  validation. This is basically the same behaviour like in java-security.


hassler-d: After discussion: `withLocalScopes` and `withScopes` do not overwrite each other anymore. This is implemented in commit [3abfe765f7f3bdacb388527b0dd446f28708350d](https://github.com/SAP/cloud-security-xsuaa-integration/pull/253/commits/3abfe765f7f3bdacb388527b0dd446f28708350d)
hassler-d: @nenaraab 
In regard to the versioning of the library dependency of the samples there are two different approaches:

1. Samples that use a variable like java-security-usage: https://github.com/SAP/cloud-security-xsuaa-integration/blob/master/samples/java-security-usage/pom.xml#L31

2. Others use the `${project.version}` since it is (by convention) set to the same value as the library version: https://github.com/SAP/cloud-security-xsuaa-integration/blob/master/samples/spring-security-basic-auth/pom.xml#L35

Personally I would prefer option 1. because the samples are for library users and they might get confused that the library version is set  `${project.version}` like in option 2. which does not work unless they set their application version to the same value as the library.
They are more likely to create an explicit version variable like in option 1.

If we take option 1. I would add a version variable to all the samples and name it `sap.cloud.security.version`.

nenaraab: hi @hassler-d 
prefer your prefered option as well. Is it okay, when i merge now? and you do the harmonization in another branch, as I'm done with code review :-)


Patch:
diff --git a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/XsuaaServiceConfiguration.java b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/XsuaaServiceConfiguration.java
index 22f44939..8603e9d1 100644
--- a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/XsuaaServiceConfiguration.java
+++ b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/XsuaaServiceConfiguration.java
@@ -39,6 +39,14 @@ public interface XsuaaServiceConfiguration {
 	 */
 	String getUaaDomain();
 
+	/**
+	 * The pem encoded public key for offline token verification.
+	 *
+	 * @return the pem encoded verification key
+	 */
+	@Nullable
+	String getVerificationKey();
+
 	/**
 	 * PEM encoded certificate chain.
 	 *
diff --git a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/XsuaaServiceConfigurationDefault.java b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/XsuaaServiceConfigurationDefault.java
index 5578ce0d..7840be78 100644
--- a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/XsuaaServiceConfigurationDefault.java
+++ b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/XsuaaServiceConfigurationDefault.java
@@ -33,6 +33,9 @@ public class XsuaaServiceConfigurationDefault implements XsuaaServiceConfigurati
 	@Value("${xsuaa.certificate:}")
 	private String certificates;
 
+	@Value("${xsuaa.verificationkey:}")
+	private String verificationKey;
+
 	/*
 	 * (non-Javadoc)
 	 *
@@ -63,6 +66,11 @@ public class XsuaaServiceConfigurationDefault implements XsuaaServiceConfigurati
 		return uaadomain;
 	}
 
+	@Override
+	public String getVerificationKey() {
+		return verificationKey;
+	}
+
 	/*
 	 * @Override public String getCertificates() { return certificates; }
 	 * 
diff --git a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/token/authentication/XsuaaJwtDecoder.java b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/token/authentication/XsuaaJwtDecoder.java
index ddd8df6b..7cb27cb4 100644
--- a/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/token/authentication/XsuaaJwtDecoder.java
+++ b/spring-xsuaa/src/main/java/com/sap/cloud/security/xsuaa/token/authentication/XsuaaJwtDecoder.java
@@ -5,6 +5,11 @@ import static com.sap.cloud.security.xsuaa.token.TokenClaims.CLAIM_KID;
 
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.X509EncodedKeySpec;
 import java.text.ParseException;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
@@ -12,11 +17,7 @@ import java.util.concurrent.TimeUnit;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.security.oauth2.core.OAuth2TokenValidator;
-import org.springframework.security.oauth2.jwt.Jwt;
-import org.springframework.security.oauth2.jwt.JwtDecoder;
-import org.springframework.security.oauth2.jwt.JwtException;
-import org.springframework.security.oauth2.jwt.JwtValidationException;
-import org.springframework.security.oauth2.jwt.NimbusJwtDecoderJwkSupport;
+import org.springframework.security.oauth2.jwt.*;
 import org.springframework.util.Assert;
 
 import com.github.benmanes.caffeine.cache.Cache;
@@ -28,6 +29,7 @@ import org.springframework.web.client.RestOperations;
 
 public class XsuaaJwtDecoder implements JwtDecoder {
 	private final Logger logger = LoggerFactory.getLogger(getClass());
+	private final XsuaaServiceConfiguration xsuaaServiceConfiguration;
 
 	Cache<String, JwtDecoder> cache;
 	private OAuth2TokenValidator<Jwt> tokenValidators;
@@ -42,6 +44,7 @@ public class XsuaaJwtDecoder implements JwtDecoder {
 				.maximumSize(cacheSize)
 				.build();
 		this.tokenValidators = tokenValidators;
+		this.xsuaaServiceConfiguration = xsuaaServiceConfiguration;
 
 		this.tokenInfoExtractor = new TokenInfoExtractor() {
 			@Override
@@ -59,8 +62,7 @@ public class XsuaaJwtDecoder implements JwtDecoder {
 				return xsuaaServiceConfiguration.getUaaDomain();
 			}
 		};
-
-		this.postValidationActions = postValidationActions != null ? postValidationActions : Collections.EMPTY_LIST;
+		this.postValidationActions = postValidationActions != null ? postValidationActions : Collections.emptyList();
 	}
 
 	@Override
@@ -73,18 +75,36 @@ public class XsuaaJwtDecoder implements JwtDecoder {
 		} catch (ParseException ex) {
 			throw new JwtException("Error initializing JWT decoder: " + ex.getMessage());
 		}
+		final Jwt verifiedToken = verifyToken(jwt);
+		postValidationActions.forEach(action -> action.perform(verifiedToken));
+		return verifiedToken;
+	}
 
-		String jku = tokenInfoExtractor.getJku(jwt);
-		String kid = tokenInfoExtractor.getKid(jwt);
-		String uaaDomain = tokenInfoExtractor.getUaaDomain(jwt);
+	public void setTokenInfoExtractor(TokenInfoExtractor tokenInfoExtractor) {
+		this.tokenInfoExtractor = tokenInfoExtractor;
+	}
+
+	public void setRestOperations(RestOperations restOperations) {
+		this.restOperations = restOperations;
+	}
+
+	private Jwt verifyToken(JWT jwt) {
+		try {
+			String jku = tokenInfoExtractor.getJku(jwt);
+			String kid = tokenInfoExtractor.getKid(jwt);
+			String uaaDomain = tokenInfoExtractor.getUaaDomain(jwt);
+			return verifyTokenOnline(jwt.getParsedString(), jku, kid, uaaDomain);
+		} catch (JwtException e) {
+			return tryToVerifyWithOfflineKey(jwt.getParsedString(), e);
+		}
+	}
 
+	private Jwt verifyTokenOnline(String token, String jku, String kid, String uaaDomain) {
 		try {
 			canVerifyWithOnlineKey(jku, kid, uaaDomain);
 			validateJKU(jku, uaaDomain);
 			Jwt verifiedToken = verifyWithOnlineKey(token, jku, kid);
 
-			postValidationActions.forEach(act -> act.perform(verifiedToken));
-
 			return verifiedToken;
 		} catch (JwtValidationException ex) {
 			throw ex;
@@ -138,11 +158,37 @@ public class XsuaaJwtDecoder implements JwtDecoder {
 		return decoder;
 	}
 
-	public void setTokenInfoExtractor(TokenInfoExtractor tokenInfoExtractor) {
-		this.tokenInfoExtractor = tokenInfoExtractor;
+	private Jwt tryToVerifyWithOfflineKey(String token, JwtException onlineVerificationException) {
+		String verificationKey = xsuaaServiceConfiguration.getVerificationKey();
+		if (verificationKey == null || verificationKey.isEmpty()) {
+			throw onlineVerificationException;
+		}
+		return verifyWithOfflineKey(token, verificationKey);
 	}
 
-	public void setRestOperations(RestOperations restOperations) {
-		this.restOperations = restOperations;
+	private Jwt verifyWithOfflineKey(String token, String verificationKey) {
+		try {
+			RSAPublicKey verficationKey = createPublicKey(verificationKey);
+			NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(verficationKey).build();
+			decoder.setJwtValidator(tokenValidators);
+			return decoder.decode(token);
+		} catch (NoSuchAlgorithmException | InvalidKeySpecException e){
+			throw new JwtException(e.getMessage());
+		}
+	}
+
+	// TODO: move this code into token-client?
+	private static String convertPEMKey(String pemEncodedKey) {
+		String key = pemEncodedKey;
+		key = key.replace("-----BEGIN PUBLIC KEY-----", "");
+		key = key.replace("-----END PUBLIC KEY-----", "");
+		return key;
 	}
+
+	private RSAPublicKey createPublicKey(String pemEncodedPublicKey) throws NoSuchAlgorithmException, InvalidKeySpecException {
+		byte[] decodedKey = Base64.getDecoder().decode(convertPEMKey(pemEncodedPublicKey));
+		X509EncodedKeySpec spec = new X509EncodedKeySpec(decodedKey);
+		return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(spec);
+	}
+
 }


Test patch:
diff --git a/java-security-test/src/main/java/com/sap/cloud/security/test/JwtGenerator.java b/java-security-test/src/main/java/com/sap/cloud/security/test/JwtGenerator.java
index 7e614d45..a1498929 100644
--- a/java-security-test/src/main/java/com/sap/cloud/security/test/JwtGenerator.java
+++ b/java-security-test/src/main/java/com/sap/cloud/security/test/JwtGenerator.java
@@ -24,6 +24,7 @@ import java.security.*;
 import java.time.Instant;
 import java.util.*;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 /**
  * Jwt {@link Token} builder class to generate tokes for testing purposes.
@@ -45,6 +46,9 @@ public class JwtGenerator {
 
 	private JwtSignatureAlgorithm signatureAlgorithm;
 	private PrivateKey privateKey;
+	private String appId;
+	private List<String> scopes = new ArrayList<>();
+	private List<String> localScopes = new ArrayList<>();
 
 	private JwtGenerator() {
 		// see factory method getInstance()
@@ -148,7 +152,8 @@ public class JwtGenerator {
 	 * @throws IOException
 	 *             when the file cannot be read or does not exist.
 	 * @param claimsJsonResource
-	 *            the resource path to the file containing the claims in json format, e.g. "/claims.json"
+	 *            the resource path to the file containing the claims in json
+	 *            format, e.g. "/claims.json"
 	 * @return the builder object.
 	 */
 	public JwtGenerator withClaimsFromFile(String claimsJsonResource) throws IOException {
@@ -213,8 +218,11 @@ public class JwtGenerator {
 	}
 
 	/**
-	 * Sets the roles as claim "scope" to the jwt. Note that this is specific to
-	 * tokens of service type {@link Service#XSUAA}.
+	 * Sets the roles as claim "scope" to the jwt. Consecutive calls of this method
+	 * will overwrite the data that has previously been set. Calls of this method
+	 * however do not overwrite the data set via
+	 * {@link #withLocalScopes(String...)}}. Note that this is specific to tokens of
+	 * service type {@link Service#XSUAA}.
 	 *
 	 * @param scopes
 	 *            the scopes that should be part of the token
@@ -224,13 +232,59 @@ public class JwtGenerator {
 	 */
 	public JwtGenerator withScopes(String... scopes) {
 		if (service == Service.XSUAA) {
-			withClaimValues(TokenClaims.XSUAA.SCOPES, scopes);
+			this.scopes = Arrays.asList(scopes);
+			putScopesInJsonPayload();
 		} else {
 			throw new UnsupportedOperationException("Scopes are not supported for service " + service);
 		}
 		return this;
 	}
 
+	/**
+	 * Works like {@link #withScopes(String...)}} but prefixes the scopes with
+	 * "appId.". For example if the appId is "xsapp", the scope "Read" will be
+	 * converted to "xsapp.Read". Make sure the appId has been via
+	 * {@link #withAppId(String)} before calling this method. Consecutive calls of
+	 * this method will overwrite the data that has previously been set. Calls of
+	 * this method however do not overwrite the data set via
+	 * {@link #withScopes(String...)}}. Note that this is specific to tokens of
+	 * service type {@link Service#XSUAA}.
+	 *
+	 *
+	 * @param scopes
+	 * @return the JwtGenerator itself
+	 * @throws IllegalStateException
+	 *             if the appId has not been set via {@link #withAppId(String)}
+	 */
+	public JwtGenerator withLocalScopes(String... scopes) {
+		if (appId == null) {
+			throw new IllegalStateException("Cannot create local scopes because appId has not been set!");
+		}
+		if (service == Service.XSUAA) {
+			localScopes = Stream.of(scopes)
+					.map(scope -> appId + "." + scope)
+					.collect(Collectors.toList());
+			putScopesInJsonPayload();
+		} else {
+			throw new UnsupportedOperationException("Scopes are not supported for service " + service);
+		}
+		return this;
+	}
+
+	/**
+	 * This method does not actually set data on the token itself but sets the appId
+	 * that is used by {@link #withLocalScopes(String...)} to create the local
+	 * scopes.
+	 *
+	 * @param appId
+	 *            the appId to be used for local scopes creation
+	 * @return the JwtGenerator itself
+	 */
+	public JwtGenerator withAppId(String appId) {
+		this.appId = appId;
+		return this;
+	}
+
 	/**
 	 * Builds and signs the token using the the algorithm set via
 	 * {@link #withSignatureAlgorithm(JwtSignatureAlgorithm)} and the given key. By
@@ -255,6 +309,12 @@ public class JwtGenerator {
 		}
 	}
 
+	private void putScopesInJsonPayload() {
+		List<String> resultingScopes = Stream.concat(localScopes.stream(), scopes.stream())
+				.collect(Collectors.toList());
+		jsonPayload.put(TokenClaims.XSUAA.SCOPES, resultingScopes);
+	}
+
 	private void createAudienceClaim() {
 		if (service == Service.IAS) {
 			jsonPayload.put(AUDIENCE, jsonPayload.getString(TokenClaims.XSUAA.CLIENT_ID));
diff --git a/java-security-test/src/main/java/com/sap/cloud/security/test/SecurityTestRule.java b/java-security-test/src/main/java/com/sap/cloud/security/test/SecurityTestRule.java
index aca50e34..4e1afb3b 100644
--- a/java-security-test/src/main/java/com/sap/cloud/security/test/SecurityTestRule.java
+++ b/java-security-test/src/main/java/com/sap/cloud/security/test/SecurityTestRule.java
@@ -214,6 +214,7 @@ public class SecurityTestRule extends ExternalResource {
 		case XSUAA:
 			jwtGenerator
 					.withHeaderParameter(TokenHeader.JWKS_URL, jwksUrl)
+					.withAppId(DEFAULT_APP_ID)
 					.withClaimValue(TokenClaims.XSUAA.GRANT_TYPE, OAuth2TokenServiceConstants.GRANT_TYPE_USER_TOKEN);
 			break;
 		default:
diff --git a/java-security-test/src/test/java/com/sap/cloud/security/test/JwtGeneratorTest.java b/java-security-test/src/test/java/com/sap/cloud/security/test/JwtGeneratorTest.java
index ebef3861..2a50a2e0 100644
--- a/java-security-test/src/test/java/com/sap/cloud/security/test/JwtGeneratorTest.java
+++ b/java-security-test/src/test/java/com/sap/cloud/security/test/JwtGeneratorTest.java
@@ -41,6 +41,7 @@ import java.util.Properties;
 import static com.sap.cloud.security.config.Service.IAS;
 import static com.sap.cloud.security.config.Service.XSUAA;
 import static com.sap.cloud.security.test.JwtGenerator.SignatureCalculator;
+import static com.sap.cloud.security.test.SecurityTestRule.DEFAULT_APP_ID;
 import static com.sap.cloud.security.test.SecurityTestRule.DEFAULT_CLIENT_ID;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -164,6 +165,55 @@ public class JwtGeneratorTest {
 		assertThat(token.getClaimAsStringList(TokenClaims.XSUAA.SCOPES)).containsExactly(scopes);
 	}
 
+	@Test
+	public void withLocalScopes_containsGivenScopesAsLocalScopesWhenServiceIsXsuaa() {
+		String scopeRead = "Read";
+		String scopeWrite = "Write";
+		Token token = cut
+				.withAppId(DEFAULT_APP_ID)
+				.withLocalScopes(scopeRead, scopeWrite).createToken();
+
+		assertThat(token.getClaimAsStringList(TokenClaims.XSUAA.SCOPES))
+				.containsExactlyInAnyOrder(DEFAULT_APP_ID + "." + scopeRead, DEFAULT_APP_ID + "." + scopeWrite);
+	}
+
+	@Test
+	public void consecutiveScopeCallsOverwriteOldData() {
+		String writeScope = "Write";
+		String openidScope = "openid";
+		Token token = cut
+				.withAppId(DEFAULT_APP_ID)
+				.withLocalScopes("Read")
+				.withLocalScopes(writeScope)
+				.withScopes("test")
+				.withScopes(openidScope)
+				.createToken();
+
+		assertThat(token.getClaimAsStringList(TokenClaims.XSUAA.SCOPES))
+				.containsExactlyInAnyOrder(openidScope, DEFAULT_APP_ID + "." + writeScope);
+	}
+
+	@Test
+	public void withScopesAndLocalScopes_containsBothScopeTypes() {
+		String openidScope = "openid";
+		String writeScope = "Write";
+		Token token = cut
+				.withAppId(DEFAULT_APP_ID)
+				.withLocalScopes(writeScope)
+				.withScopes(openidScope)
+				.createToken();
+
+		assertThat(token.getClaimAsStringList(TokenClaims.XSUAA.SCOPES))
+				.containsExactlyInAnyOrder(openidScope, DEFAULT_APP_ID + "." + writeScope);
+	}
+
+	@Test
+	public void withLocalScopes_withoutAppId_throwsException() {
+		assertThatThrownBy(() -> cut.withLocalScopes("Read").createToken())
+				.isInstanceOf(IllegalStateException.class)
+				.hasMessageContaining("appId has not been set!");
+	}
+
 	@Test
 	public void withScopes_serviceIsIAS_throwsUnsupportedOperationException() {
 		cut = JwtGenerator.getInstance(IAS, "T00001234").withPrivateKey(keys.getPrivate());
diff --git a/java-security-test/src/test/java/com/sap/cloud/security/test/SecurityTestRuleTest.java b/java-security-test/src/test/java/com/sap/cloud/security/test/SecurityTestRuleTest.java
index 6feee0bb..82d0abe4 100644
--- a/java-security-test/src/test/java/com/sap/cloud/security/test/SecurityTestRuleTest.java
+++ b/java-security-test/src/test/java/com/sap/cloud/security/test/SecurityTestRuleTest.java
@@ -75,12 +75,13 @@ public class SecurityTestRuleTest {
 	public void generatesTokenWithOtherClaimsAndHeaderParameter() {
 		Token generatedToken = cut.getPreconfiguredJwtGenerator()
 				.withClaimValue(TokenClaims.ISSUER, "issuer")
-				.withScopes(SecurityTestRule.DEFAULT_APP_ID + ".scope1")
+				.withLocalScopes("scope1")
 				.withHeaderParameter(TokenHeader.TYPE, "type").createToken();
 
 		assertThat(generatedToken.getClaimAsString(TokenClaims.XSUAA.CLIENT_ID))
 				.isEqualTo(SecurityTestRule.DEFAULT_CLIENT_ID);
-		assertThat(generatedToken.getClaimAsStringList(TokenClaims.XSUAA.SCOPES).size()).isEqualTo(1);
+		assertThat(generatedToken.getClaimAsStringList(TokenClaims.XSUAA.SCOPES))
+				.containsExactly(SecurityTestRule.DEFAULT_APP_ID + ".scope1");
 		assertThat(generatedToken.getClaimAsString(TokenClaims.ISSUER)).isEqualTo("issuer");
 		assertThat(generatedToken.getHeaderParameterAsString(TokenHeader.TYPE)).isEqualTo("type");
 	}
diff --git a/spring-xsuaa-it/src/test/java/com/sap/cloud/security/xsuaa/extractor/XsuaaServiceConfigurationDummy.java b/spring-xsuaa-it/src/test/java/com/sap/cloud/security/xsuaa/extractor/XsuaaServiceConfigurationDummy.java
index 1e77551d..495a930e 100644
--- a/spring-xsuaa-it/src/test/java/com/sap/cloud/security/xsuaa/extractor/XsuaaServiceConfigurationDummy.java
+++ b/spring-xsuaa-it/src/test/java/com/sap/cloud/security/xsuaa/extractor/XsuaaServiceConfigurationDummy.java
@@ -9,6 +9,7 @@ public class XsuaaServiceConfigurationDummy implements XsuaaServiceConfiguration
 	String uaaUrl;
 	String uaaDomain;
 	String appId;
+	String verificationKey;
 
 	@Override
 	public String getClientId() {
@@ -35,6 +36,11 @@ public class XsuaaServiceConfigurationDummy implements XsuaaServiceConfiguration
 		return uaaDomain;
 	}
 
+	@Override
+	public String getVerificationKey() {
+		return verificationKey;
+	}
+
 	/*
 	 * @Nullable
 	 * 
diff --git a/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/DummyXsuaaServiceConfiguration.java b/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/DummyXsuaaServiceConfiguration.java
index 9fa2d235..873b8a1d 100644
--- a/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/DummyXsuaaServiceConfiguration.java
+++ b/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/DummyXsuaaServiceConfiguration.java
@@ -41,6 +41,11 @@ public class DummyXsuaaServiceConfiguration implements XsuaaServiceConfiguration
 		return uaaDomain;
 	}
 
+	@Override
+	public String getVerificationKey() {
+		return null;
+	}
+
 	/*
 	 * @Nullable
 	 * 
diff --git a/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/token/authentication/XsuaaJwtDecoderTest.java b/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/token/authentication/XsuaaJwtDecoderTest.java
new file mode 100644
index 00000000..7316f906
--- /dev/null
+++ b/spring-xsuaa/src/test/java/com/sap/cloud/security/xsuaa/token/authentication/XsuaaJwtDecoderTest.java
@@ -0,0 +1,51 @@
+package com.sap.cloud.security.xsuaa.token.authentication;
+
+import com.sap.cloud.security.xsuaa.XsuaaServiceConfiguration;
+import com.sap.cloud.security.xsuaa.XsuaaServiceConfigurationDefault;
+import com.sap.cloud.security.xsuaa.token.TokenClaims;
+import org.apache.commons.io.IOUtils;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtException;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit4.SpringRunner;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@RunWith(SpringRunner.class)
+@TestPropertySource(
+		locations = "/XsuaaJwtDecoderTest.properties"
+)
+@ContextConfiguration(classes = XsuaaServiceConfigurationDefault.class)
+public class XsuaaJwtDecoderTest {
+
+	@Autowired
+	XsuaaServiceConfiguration configuration;
+
+	@Test
+	public void decode_withVerficationKey() throws IOException {
+		String token = IOUtils.resourceToString("/accessTokenRSA256WithVerificationKey.txt", StandardCharsets.UTF_8);
+		final JwtDecoder cut = new XsuaaJwtDecoderBuilder(configuration).build();
+
+		final Jwt jwt = cut.decode(token);
+
+		assertThat(jwt.getClaimAsString(TokenClaims.CLAIM_CLIENT_ID)).isEqualTo("sb-clientId!t0815");
+	}
+
+	@Test
+	public void decode_withNonMatchingVerificationKey_throwsException() throws IOException {
+		String token = IOUtils.resourceToString("/token_cc.txt", StandardCharsets.UTF_8);
+
+		final JwtDecoder cut = new XsuaaJwtDecoderBuilder(configuration).build();
+
+		assertThatThrownBy(() -> cut.decode(token)).isInstanceOf(JwtException.class);
+	}
+}
\ No newline at end of file
diff --git a/spring-xsuaa/src/test/resources/XsuaaJwtDecoderTest.properties b/spring-xsuaa/src/test/resources/XsuaaJwtDecoderTest.properties
new file mode 100644
index 00000000..b4ba8dac
--- /dev/null
+++ b/spring-xsuaa/src/test/resources/XsuaaJwtDecoderTest.properties
@@ -0,0 +1,3 @@
+xsuaa.uaadomain=localhost
+xsuaa.clientid=sb-clientId!t0815
+xsuaa.verificationkey=-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm1QaZzMjtEfHdimrHP3/2Yr+1z685eiOUlwybRVG9i8wsgOUh+PUGuQL8hgulLZWXU5MbwBLTECAEMQbcRTNVTolkq4i67EP6JesHJIFADbK1Ni0KuMcPuiyOLvDKiDEMnYG1XP3X3WCNfsCVT9YoU+lWIrZr/ZsIvQri8jczr4RkynbTBsPaAOygPUlipqDrpadMO1momNCbea/o6GPn38LxEw609ItfgDGhL6f/yVid5pFzZQWb+9l6mCuJww0hnhO6gt6Rv98OWDty9G0frWAPyEfuIW9B+mR/2vGhyU9IbbWpvFXiy9RVbbsM538TCjd5JF2dJvxy24addC4oQIDAQAB-----END PUBLIC KEY-----
\ No newline at end of file
diff --git a/spring-xsuaa/src/test/resources/accessTokenRSA256WithVerificationKey.txt b/spring-xsuaa/src/test/resources/accessTokenRSA256WithVerificationKey.txt
new file mode 100644
index 00000000..cc75d409
--- /dev/null
+++ b/spring-xsuaa/src/test/resources/accessTokenRSA256WithVerificationKey.txt
@@ -0,0 +1,1 @@
+eyJqa3UiOiJodHRwOi8vbG9jYWxob3N0OjY0MzEyL3Rva2VuX2tleXMiLCJraWQiOiJkZWZhdWx0LWtpZCIsImFsZyI6IlJTMjU2In0.eyJPcmlnaW4iOiJ4c3VhYSIsImF1ZCI6WyJzYi1jbGllbnRJZCF0MDgxNSJdLCJncmFudF90eXBlIjoidXNlcl90b2tlbiIsInNjb3BlIjpbInhzYXBwIXQwODE1LkRpc3BsYXkiLCJ4c2FwcCF0MDgxNS5VcGRhdGUiXSwiZXhwIjo2OTc0MDMxNjAwLCJjaWQiOiJzYi1jbGllbnRJZCF0MDgxNSJ9.Q3JsYsRLDprzcIb7mrmPFhK_UmodQfIuSVXQx5cK3XMCVXOQyhdglXjsn-M0BMM5CE31D_7kxbkZXEB507gIibGM7SCgcpuQ0rh0L7ZSuZMwDWcmvPZXvzs_reaq4c9_qn5hg0d9Wd17wmdIvkiiKojKtvfxQyx4sAiX-XRWrrYsK69QS9Yp2-cU7UDncvOZ-IYa8HCfUOo20XJ4ZfE4KzNnffeKMRfo9_J_jnD_j2MGURminAlbtKIzdqLzOXXjIzRz81pb5V0QRiIlPcBNN_Cgk6pcJwUyPPNH5ULHdHBepJbiDoL9-PaJ5nyA4DwTDi1QScWLhd5p7M6HDB-lKg
\ No newline at end of file