From 6c6bbb7a38c60da62c4f19aa4985bf795e318c5f Mon Sep 17 00:00:00 2001 From: Manuel Fink <123368068+finkmanAtSap@users.noreply.github.com> Date: Mon, 16 Oct 2023 16:16:07 +0200 Subject: [PATCH] Add azp header for Identity JWKS Retrieval (#1312) * add x-azp header to JWKS fetching and adjust JWKS cache key * refactor JwtSignatureValidator -> Split into XsuaaJwtSignatureValidator and SapIdJwtSignatureValidator * refactor OAuth2TokenKeyService and OAuth2TokenKeyServiceWithCache APIs to use generic Map instead of explicit IAS-specific parameters --------- Co-authored-by: liga-oz --- CHANGELOG.md | 5 +- .../validators/JsonWebKeyConstants.java | 6 +- .../validation/validators/JsonWebKeySet.java | 16 - .../validators/JsonWebKeySetFactory.java | 8 +- .../validators/JwtSignatureValidator.java | 338 +++++------------- .../validators/JwtValidatorBuilder.java | 32 +- .../OAuth2TokenKeyServiceWithCache.java | 131 ++++--- .../SapIdJwtSignatureValidator.java | 101 ++++++ .../XsuaaJwtSignatureValidator.java | 65 ++++ .../servlet/IasTokenAuthenticatorTest.java | 8 +- .../IasTokenAuthenticatorX509Test.java | 8 +- .../servlet/XsuaaTokenAuthenticatorTest.java | 8 +- .../IdTokenSignatureValidatorTest.java | 41 ++- .../validators/JwtValidatorBuilderTest.java | 3 +- .../OAuth2TokenKeyServiceWithCacheTest.java | 103 +++--- ...va => SapIdJwtSignatureValidatorTest.java} | 103 +++--- .../XsaJwtSignatureValidatorTest.java | 7 +- .../XsuaaJwtSignatureValidatorTest.java | 19 +- .../client/DefaultOAuth2TokenKeyService.java | 59 ++- .../xsuaa/client/OAuth2TokenKeyService.java | 27 +- .../client/SpringOAuth2TokenKeyService.java | 23 +- .../security/xsuaa/http/HttpHeaders.java | 1 + .../DefaultOAuth2TokenKeyServiceTest.java | 77 ++-- .../SpringOAuth2TokenKeyServiceTest.java | 33 +- 24 files changed, 622 insertions(+), 600 deletions(-) create mode 100644 java-security/src/main/java/com/sap/cloud/security/token/validation/validators/SapIdJwtSignatureValidator.java create mode 100644 java-security/src/main/java/com/sap/cloud/security/token/validation/validators/XsuaaJwtSignatureValidator.java rename java-security/src/test/java/com/sap/cloud/security/token/validation/validators/{JwtSignatureValidatorTest.java => SapIdJwtSignatureValidatorTest.java} (59%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3cb23d23..633cb55ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ All notable changes to this project will be documented in this file. ## 2.15.0 -- [token-client] +- [java-security] + - add x-azp header to IAS JWKS fetching and adjust JWKS cache key + - `OAuth2TokenKeyService` and `OAuth2TokenKeyServiceWithCache` + - Refactor API to use generic Map instead of explicit IAS-specific parameters #### Dependency upgrades - Bump spring.security.version from 5.8.6 to 5.8.7 diff --git a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeyConstants.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeyConstants.java index f230dd97e..d3ef64c9b 100644 --- a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeyConstants.java +++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeyConstants.java @@ -16,10 +16,10 @@ private JsonWebKeyConstants() { // Parameter names as defined in https://tools.ietf.org/html/rfc7517 static final String KEYS_PARAMETER_NAME = "keys"; static final String KEY_TYPE_PARAMETER_NAME = "kty"; - static final String ALGORITHM_PARAMETER_NAME = "alg"; + static final String ALG_PARAMETER_NAME = "alg"; static final String VALUE_PARAMETER_NAME = "value"; - static final String KEYS_URL_PARAMETER_NAME = "jku"; - static final String KEY_ID_PARAMETER_NAME = "kid"; + static final String JKU_PARAMETER_NAME = "jku"; + static final String KID_PARAMETER_NAME = "kid"; // Legacy Token Key ID static final String KEY_ID_VALUE_LEGACY = "legacy-token-key"; 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 a51fa2405..fe1c134a1 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,9 +7,7 @@ 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; @@ -17,7 +15,6 @@ class JsonWebKeySet { private final Set jsonWebKeys = new HashSet<>(); - private final Map appTidAccepted = new HashMap<>(); @Nullable public JsonWebKey getKeyByAlgorithmAndId(JwtSignatureAlgorithm keyAlgorithm, String keyId) { @@ -45,19 +42,6 @@ private Stream getTokenStreamWithTypeAndKeyId(JwtSignatureAlgorithm .filter(jwk -> kid.equals(jwk.getId())); } - public boolean containsAppTid(String appTid) { - return appTidAccepted.containsKey(appTid); - } - - public boolean isAppTidAccepted(String appTid) { - return appTidAccepted.get(appTid); - } - - public JsonWebKeySet withAppTid(String appTid, boolean isAccepted) { - appTidAccepted.put(appTid, 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/JsonWebKeySetFactory.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeySetFactory.java index 0fca4f400..d95f008d9 100644 --- a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeySetFactory.java +++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JsonWebKeySetFactory.java @@ -35,14 +35,14 @@ private static JsonWebKey createJsonWebKey(JSONObject key) { String publicExponent = null; String keyType = key.getString(JsonWebKeyConstants.KEY_TYPE_PARAMETER_NAME); - if (key.has(JsonWebKeyConstants.ALGORITHM_PARAMETER_NAME)) { - keyAlgorithm = key.getString(JsonWebKeyConstants.ALGORITHM_PARAMETER_NAME); + if (key.has(JsonWebKeyConstants.ALG_PARAMETER_NAME)) { + keyAlgorithm = key.getString(JsonWebKeyConstants.ALG_PARAMETER_NAME); } if (key.has(JsonWebKeyConstants.VALUE_PARAMETER_NAME)) { pemEncodedPublicKey = key.getString(JsonWebKeyConstants.VALUE_PARAMETER_NAME); } - if (key.has(JsonWebKeyConstants.KEY_ID_PARAMETER_NAME)) { - keyId = key.getString(JsonWebKeyConstants.KEY_ID_PARAMETER_NAME); + if (key.has(JsonWebKeyConstants.KID_PARAMETER_NAME)) { + keyId = key.getString(JsonWebKeyConstants.KID_PARAMETER_NAME); } if (key.has(JsonWebKeyConstants.RSA_KEY_MODULUS_PARAMETER_NAME)) { modulus = key.getString(JsonWebKeyConstants.RSA_KEY_MODULUS_PARAMETER_NAME); 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 e42dd01fe..6a915f482 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 @@ -1,278 +1,112 @@ /** * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors - *

+ *

* SPDX-License-Identifier: Apache-2.0 */ package com.sap.cloud.security.token.validation.validators; import com.sap.cloud.security.config.OAuth2ServiceConfiguration; -import com.sap.cloud.security.config.Service; import com.sap.cloud.security.token.Token; import com.sap.cloud.security.token.validation.ValidationResult; import com.sap.cloud.security.token.validation.Validator; -import com.sap.cloud.security.xsuaa.client.DefaultOidcConfigurationService; import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import java.net.URI; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.Signature; import java.security.spec.InvalidKeySpecException; import java.util.Base64; -import java.util.regex.Pattern; import static com.sap.cloud.security.token.validation.ValidationResults.createInvalid; import static com.sap.cloud.security.token.validation.ValidationResults.createValid; -import static com.sap.cloud.security.token.validation.validators.JsonWebKey.DEFAULT_KEY_ID; -import static com.sap.cloud.security.token.validation.validators.JsonWebKeyConstants.*; -import static com.sap.cloud.security.xsuaa.Assertions.assertHasText; +import static com.sap.cloud.security.token.validation.validators.JsonWebKeyConstants.ALG_PARAMETER_NAME; import static com.sap.cloud.security.xsuaa.Assertions.assertNotNull; import static java.nio.charset.StandardCharsets.UTF_8; /** - * Validates whether the jwt was signed with the public key of the trust-worthy - * identity service.
- * - asks the token key service for a set of (cached) json web token keys.
- * - creates a PublicKey for the json web key with the respective id and type. - *
- * - checks whether the jwt is unchanged and signed with a private key that - * matches the PublicKey. + * Validates the signature of the JWT.
+ * - retrieves the public key used for validation via the tokenKeyService.
+ * - checks whether the signature section of the JWT is a valid signature for the header and payload sections for this public key. */ -class JwtSignatureValidator implements Validator { - private final OAuth2TokenKeyServiceWithCache tokenKeyService; - private final OidcConfigurationServiceWithCache oidcConfigurationService; - private final OAuth2ServiceConfiguration configuration; - private boolean isTenantIdCheckEnabled = true; - - JwtSignatureValidator(OAuth2ServiceConfiguration configuration, OAuth2TokenKeyServiceWithCache tokenKeyService, - OidcConfigurationServiceWithCache oidcConfigurationService) { - assertNotNull(configuration, "JwtSignatureValidator requires configuration."); - assertNotNull(tokenKeyService, "JwtSignatureValidator requires a tokenKeyService."); - assertNotNull(oidcConfigurationService, "JwtSignatureValidator requires a oidcConfigurationService."); - - this.configuration = configuration; - this.tokenKeyService = tokenKeyService; - this.oidcConfigurationService = oidcConfigurationService; - } - - /** - * This method disables the tenant id check. In case Jwt issuer `iss` claim doesn't - * match with the `url` attribute from {@link OAuth2ServiceConfiguration)}, - * tenant-id (zid) claim needs to be present in token to ensure that the tenant belongs to - * this issuer. - *

- * Use with caution as it relaxes the validation rules! It is not recommended to - * disable this check for standard Identity service setup. - */ - void disableTenantIdCheck() { - this.isTenantIdCheckEnabled = false; - } - - @Override - @SuppressWarnings("lgtm[java/dereferenced-value-may-be-null]") - public ValidationResult validate(Token token) { - String jwksUri; - String keyId; - String appTidForTokenKeys = null; - - if (Service.IAS == configuration.getService()) { - appTidForTokenKeys = token.getAppTid(); - if (isTenantIdCheckEnabled && !token.getIssuer().equals("" + configuration.getUrl()) - && appTidForTokenKeys == null) { - return createInvalid("Error occurred during signature validation: OIDC token must provide app_tid."); - } - } - try { - jwksUri = getOrRequestJwksUri(token); - String fallbackPublicKey = null; - if (configuration != null && configuration.hasProperty("verificationkey")) { - fallbackPublicKey = configuration.getProperty("verificationkey"); - } - keyId = getOrDefaultKeyId(token); - return validate(token.getTokenValue(), - getOrDefaultSignatureAlgorithm(token), - keyId, - jwksUri, - fallbackPublicKey, - appTidForTokenKeys); - } catch (OAuth2ServiceException | IllegalArgumentException e) { - return createInvalid("Error occurred during jwks uri determination: {}", e.getMessage()); - } - } - - @Nonnull - private String getOrDefaultKeyId(Token token) { - if (configuration.isLegacyMode()) { - return KEY_ID_VALUE_LEGACY; - } - if (token.hasHeaderParameter(KEY_ID_PARAMETER_NAME)) { - return token.getHeaderParameterAsString(KEY_ID_PARAMETER_NAME); - } - return DEFAULT_KEY_ID; // TODO IAS default key-id can be removed when IAS provides key id - } - - @Nonnull - private String getOrDefaultSignatureAlgorithm(Token token) { - String algHeader = token.getHeaderParameterAsString(ALGORITHM_PARAMETER_NAME); - - if (token.hasHeaderParameter(ALGORITHM_PARAMETER_NAME) - && JwtSignatureAlgorithm.fromValue(algHeader) == null) { // check whether alg is supported - throw new IllegalArgumentException( - "Jwt token with signature algorithm '" + algHeader + "' is not supported."); - } - return JwtSignatureAlgorithm.RS256.value(); - } - - @Nonnull - private String getOrRequestJwksUri(Token token) throws OAuth2ServiceException { - if (configuration.isLegacyMode()) { - // hard-code with trusted url in case of XSA Auth Code tokens - return configuration.getUrl() + "/token_keys"; - } - if (configuration.getService() == Service.XSUAA && token.hasHeaderParameter(KEYS_URL_PARAMETER_NAME)) { - // 'jku' was validated by XsuaaJkuValidator - return token.getHeaderParameterAsString(KEYS_URL_PARAMETER_NAME); - } - if (configuration.getService() != Service.XSUAA && token.getIssuer() != null) { - // 'iss' claim was validated by JwtIssuerValidator - // don't call in case of XSA Auth Code tokens as issuer is not valid there - // as XSUAA issuer contains often localhost this was not validated as well - URI discoveryUri = DefaultOidcConfigurationService.getDiscoveryEndpointUri(token.getIssuer()); - URI jkuUri = oidcConfigurationService - .getOrRetrieveEndpoints(discoveryUri) - .getJwksUri(); - if (jkuUri != null) { - return jkuUri.toString(); - } - } - throw new IllegalArgumentException( - "Token signature can not be validated as jwks uri can not be determined: Token does not provide the required 'jku' header or issuer claim."); - } - - // for testing - ValidationResult validate(String token, String tokenAlgorithm, String tokenKeyId, String tokenKeysUrl, - @Nullable String fallbackPublicKey, @Nullable String zoneId) { - assertHasText(token, "token must not be null or empty."); - assertHasText(tokenAlgorithm, "tokenAlgorithm must not be null or empty."); - assertHasText(tokenKeyId, "tokenKeyId must not be null or empty."); - assertHasText(tokenKeysUrl, "tokenKeysUrl must not be null or empty."); - - return Validation.getInstance().validate(tokenKeyService, token, tokenAlgorithm, tokenKeyId, - URI.create(tokenKeysUrl), fallbackPublicKey, zoneId, configuration.getClientId()); - } - - private static class Validation { - JwtSignatureAlgorithm jwtSignatureAlgorithm; - PublicKey publicKey; - Signature publicSignature; - - private Validation() { - } - - static Validation getInstance() { - return new Validation(); - } - - ValidationResult validate(OAuth2TokenKeyServiceWithCache tokenKeyService, String token, - String tokenAlgorithm, String tokenKeyId, URI tokenKeysUrl, @Nullable String fallbackPublicKey, - @Nullable String zoneId, String clientId) { - ValidationResult validationResult; - - validationResult = setSupportedJwtAlgorithm(tokenAlgorithm); - if (validationResult.isErroneous()) { - return validationResult; - } - validationResult = setPublicKey(tokenKeyService, tokenKeyId, tokenKeysUrl, zoneId, clientId); - if (validationResult.isErroneous()) { - if (fallbackPublicKey != null) { - try { - this.publicKey = JsonWebKeyImpl.createPublicKeyFromPemEncodedPublicKey( - JwtSignatureAlgorithm.RS256, fallbackPublicKey); - - } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { - return createInvalid( - "Error occurred during signature validation: ({}). Fallback with configured 'verificationkey' was not successful.", - e.getMessage()); - } - } else { - return validationResult; - } - } - validationResult = setPublicSignatureForKeyType(); - if (validationResult.isErroneous()) { - return validationResult; - } - - return validateTokenSignature(token, publicKey, publicSignature); - } - - private ValidationResult setSupportedJwtAlgorithm(String tokenAlgorithm) { - if (tokenAlgorithm != null) { - jwtSignatureAlgorithm = JwtSignatureAlgorithm.fromValue(tokenAlgorithm); - if (jwtSignatureAlgorithm != null) { - return createValid(); - } - return createInvalid("Jwt token with signature algorithm '{}' is not supported.", tokenAlgorithm); - } - return createValid(); - } - - private ValidationResult setPublicKey(OAuth2TokenKeyServiceWithCache tokenKeyService, String keyId, - URI keyUri, String zoneId, String clientId) { - try { - this.publicKey = tokenKeyService.getPublicKey(jwtSignatureAlgorithm, keyId, keyUri, zoneId, clientId); - } catch (OAuth2ServiceException e) { - return createInvalid("Error retrieving Json Web Keys from Identity Service: {}.", e.getMessage()); - } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { - return createInvalid("Error creating PublicKey from Json Web Key received from {}: {}.", - keyUri, e.getMessage()); - } - if (this.publicKey == null) { - return createInvalid( - "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(); - } - - private ValidationResult setPublicSignatureForKeyType() { - try { - publicSignature = Signature.getInstance(jwtSignatureAlgorithm.javaSignature()); - return createValid(); - } catch (NoSuchAlgorithmException e) { - // should never happen - } - return createInvalid("Jwt token with signature algorithm '{}' can not be verified.", - jwtSignatureAlgorithm.javaSignature()); - } - - private static final Pattern DOT = Pattern.compile("\\.", 0); - - static ValidationResult validateTokenSignature(String token, PublicKey publicKey, Signature publicSignature) { - String[] tokenHeaderPayloadSignature = DOT.split(token); - if (tokenHeaderPayloadSignature.length != 3) { - return createInvalid("Jwt token does not consist of 'header'.'payload'.'signature'."); - } - String headerAndPayload = new StringBuilder(tokenHeaderPayloadSignature[0]).append(".") - .append(tokenHeaderPayloadSignature[1]).toString(); - try { - publicSignature.initVerify(publicKey); - publicSignature.update(headerAndPayload.getBytes(UTF_8)); // provide data - - byte[] decodedSignatureBytes = Base64.getUrlDecoder().decode(tokenHeaderPayloadSignature[2]); - - if (publicSignature.verify(decodedSignatureBytes)) { - return createValid(); - } - return createInvalid( - "Signature of Jwt Token is not valid: the identity provided by the JSON Web Token Key can not be verified (Signature: {}).", - tokenHeaderPayloadSignature[2]); - } catch (Exception e) { - return createInvalid("Error occurred during Json Web Signature Validation: {}.", e.getMessage()); - } - } - } - +abstract class JwtSignatureValidator implements Validator { + protected final OAuth2TokenKeyServiceWithCache tokenKeyService; + protected final OidcConfigurationServiceWithCache oidcConfigurationService; + protected final OAuth2ServiceConfiguration configuration; + + JwtSignatureValidator(OAuth2ServiceConfiguration configuration, OAuth2TokenKeyServiceWithCache tokenKeyService, + OidcConfigurationServiceWithCache oidcConfigurationService) { + assertNotNull(configuration, "JwtSignatureValidator requires configuration."); + assertNotNull(tokenKeyService, "JwtSignatureValidator requires a tokenKeyService."); + assertNotNull(oidcConfigurationService, "JwtSignatureValidator requires a oidcConfigurationService."); + + this.configuration = configuration; + this.tokenKeyService = tokenKeyService; + this.oidcConfigurationService = oidcConfigurationService; + } + + @Override + public ValidationResult validate(Token token) { + if(token.getTokenValue() == null) { + return createInvalid("JWT token validation failed because token content was null."); + } + + JwtSignatureAlgorithm algorithm = JwtSignatureAlgorithm.RS256; + if (token.hasHeaderParameter(ALG_PARAMETER_NAME)) { + String algHeader = token.getHeaderParameterAsString(ALG_PARAMETER_NAME); + algorithm = JwtSignatureAlgorithm.fromValue(algHeader); + if (algorithm == null) { + return createInvalid("JWT token validation with signature algorithm '" + algHeader + "' is not supported."); + } + } + + PublicKey publicKey; + try { + publicKey = getPublicKey(token, algorithm); + } catch (OAuth2ServiceException e) { + return createInvalid("Token signature can not be validated because JWKS could not be fetched: {}", e.getMessage()); + } catch (IllegalArgumentException | InvalidKeySpecException | NoSuchAlgorithmException e) { + return createInvalid("Token signature can not be validated because: {}", e.getMessage()); + } + + if(publicKey == null) { + return createInvalid("Token signature can not be validated because JWKS was empty."); + } + + return validateSignature(token, publicKey, algorithm); + } + + /** + * Service-specific implementation for the retrieval of the public key, e.g. via URL from JKU header (XSUAA) or OIDC .well-known endpoint (IAS) + */ + protected abstract PublicKey getPublicKey(Token token, JwtSignatureAlgorithm algorithm) throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException; + + protected ValidationResult validateSignature(Token token, PublicKey publicKey, JwtSignatureAlgorithm algorithm) { + Signature publicSignature; + try { + publicSignature = Signature.getInstance(algorithm.javaSignature()); + } catch (NoSuchAlgorithmException e) { + return createInvalid("Token signature can not be validated because implementation of algorithm could not be found: {}", e.getMessage()); + } + + String[] tokenSections = token.getTokenValue().split("\\."); + if (tokenSections.length != 3) { + return createInvalid("Jwt token does not consist of three sections: 'header'.'payload'.'signature'."); + } + + String headerAndPayload = tokenSections[0] + "." + tokenSections[1]; + String signature = tokenSections[2]; + try { + publicSignature.initVerify(publicKey); + publicSignature.update(headerAndPayload.getBytes(UTF_8)); + + byte[] decodedSignatureBytes = Base64.getUrlDecoder().decode(signature); + if (publicSignature.verify(decodedSignatureBytes)) { + return createValid(); + } + + return createInvalid("Signature of Jwt Token is not valid: the identity provided by the JSON Web Token Key can not be trusted (Signature: {}).", signature); + } catch (Exception e) { + return createInvalid("Unexpected Error occurred during Json Web Signature Validation: {}.", e.getMessage()); + } + } } diff --git a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtValidatorBuilder.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtValidatorBuilder.java index d75ab75bb..3accfe5d0 100644 --- a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtValidatorBuilder.java +++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/JwtValidatorBuilder.java @@ -215,31 +215,29 @@ private List> createDefaultValidators() { List> defaultValidators = new ArrayList<>(); defaultValidators.add(new JwtTimestampValidator()); + JwtSignatureValidator signatureValidator = null; + OAuth2TokenKeyServiceWithCache tokenKeyServiceWithCache = getTokenKeyServiceWithCache(); + Optional.ofNullable(tokenKeyCacheConfiguration).ifPresent(tokenKeyServiceWithCache::withCacheConfiguration); if (configuration.getService() == XSUAA) { if (!configuration.isLegacyMode()) { defaultValidators.add(new XsuaaJkuValidator(configuration.getProperty(UAA_DOMAIN))); } - } else if (configuration.getService() == IAS && configuration.getDomains() != null - && !configuration.getDomains().isEmpty()) { - defaultValidators.add(new JwtIssuerValidator(configuration.getDomains())); - } - OAuth2TokenKeyServiceWithCache tokenKeyServiceWithCache = getTokenKeyServiceWithCache(); - Optional.ofNullable(tokenKeyCacheConfiguration).ifPresent(tokenKeyServiceWithCache::withCacheConfiguration); - JwtSignatureValidator signatureValidator = new JwtSignatureValidator( - configuration, - tokenKeyServiceWithCache, - getOidcConfigurationServiceWithCache()); - if (configuration.getService() == IAS && isTenantIdCheckDisabled) { - signatureValidator.disableTenantIdCheck(); - } - defaultValidators.add(signatureValidator); + signatureValidator = new XsuaaJwtSignatureValidator(configuration, tokenKeyServiceWithCache, getOidcConfigurationServiceWithCache()); + } else if (configuration.getService() == IAS) { + if(configuration.getDomains() != null && !configuration.getDomains().isEmpty()) { + defaultValidators.add(new JwtIssuerValidator(configuration.getDomains())); + } - Optional.ofNullable(customAudienceValidator).ifPresent(defaultValidators::add); - if (customAudienceValidator == null) { - defaultValidators.add(createAudienceValidator()); + signatureValidator = new SapIdJwtSignatureValidator(configuration, tokenKeyServiceWithCache, getOidcConfigurationServiceWithCache()); + if(isTenantIdCheckDisabled) { + ((SapIdJwtSignatureValidator) signatureValidator).disableTenantIdCheck(); + } } + defaultValidators.add(signatureValidator); + defaultValidators.add(customAudienceValidator != null ? customAudienceValidator : createAudienceValidator()); + return defaultValidators; } 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 e015ce9b1..b928296db 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 @@ -1,6 +1,6 @@ /** * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors - *

+ *

* SPDX-License-Identifier: Apache-2.0 */ package com.sap.cloud.security.token.validation.validators; @@ -13,6 +13,7 @@ import com.sap.cloud.security.xsuaa.client.DefaultOAuth2TokenKeyService; import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException; import com.sap.cloud.security.xsuaa.client.OAuth2TokenKeyService; +import com.sap.cloud.security.xsuaa.http.HttpHeaders; import com.sap.cloud.security.xsuaa.tokenflows.Cacheable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,6 +25,9 @@ import java.security.PublicKey; import java.security.spec.InvalidKeySpecException; import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; import static com.sap.cloud.security.xsuaa.Assertions.assertHasText; import static com.sap.cloud.security.xsuaa.Assertions.assertNotNull; @@ -103,7 +107,7 @@ public OAuth2TokenKeyServiceWithCache withCacheSize(int size) { * Configures the token key cache. Use * {@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! * @@ -150,40 +154,16 @@ public OAuth2TokenKeyServiceWithCache withTokenKeyService(OAuth2TokenKeyService @Nullable @Deprecated public PublicKey getPublicKey(JwtSignatureAlgorithm keyAlgorithm, String keyId, URI keyUri) { - throw new UnsupportedOperationException("use getPublicKey(keyAlgorithm, keyId, keyUri, appTid) instead"); + throw new UnsupportedOperationException("use getPublicKey(keyAlgorithm, keyId, keyUri, params) instead"); } /** - * Returns the cached key by id and type or requests the keys from the jwks URI - * of the identity service. - * - * @param keyAlgorithm - * the Key Algorithm of the Access Token. - * @param keyId - * the Key Id of the Access Token. - * @param keyUri - * the Token Key Uri (jwks) of the Access Token (can be tenant - * specific). - * @param appTid - * the unique identifier of the tenant - * @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. - * + * Returns {@link OAuth2TokenKeyServiceWithCache#getPublicKey(JwtSignatureAlgorithm, String, URI, Map)} with {@link HttpHeaders#X_APP_TID} = appTid inside params. */ @Nullable public PublicKey getPublicKey(JwtSignatureAlgorithm keyAlgorithm, String keyId, URI keyUri, String appTid) throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { - assertNotNull(keyAlgorithm, "keyAlgorithm must not be null."); - assertHasText(keyId, "keyId must not be null."); - assertNotNull(keyUri, "keyUrl must not be null."); - - return getPublicKey(keyAlgorithm, keyId, keyUri, appTid, null); + return getPublicKey(keyAlgorithm, keyId, keyUri, Collections.singletonMap(HttpHeaders.X_APP_TID, appTid)); } /** @@ -197,11 +177,8 @@ public PublicKey getPublicKey(JwtSignatureAlgorithm keyAlgorithm, String keyId, * @param keyUri * the Token Key Uri (jwks) of the Access Token (can be tenant * specific). - * @param appTid - * the unique identifier of the tenant - * - * @param clientId - * client id from the service configuration + * @param params + * additional parameters that are sent along with the request. Use constants from {@link HttpHeaders} for the parameter keys. * @return a PublicKey * @throws OAuth2ServiceException * in case the call to the jwks endpoint of the identity service @@ -212,32 +189,43 @@ public PublicKey getPublicKey(JwtSignatureAlgorithm keyAlgorithm, String keyId, * in case the algorithm of the json web key is not supported. * */ - public PublicKey getPublicKey(JwtSignatureAlgorithm keyAlgorithm, String keyId, URI keyUri, String appTid, String clientId) + public PublicKey getPublicKey(JwtSignatureAlgorithm keyAlgorithm, String keyId, URI keyUri, Map params) throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { assertNotNull(keyAlgorithm, "keyAlgorithm must not be null."); assertHasText(keyId, "keyId must not be null."); assertNotNull(keyUri, "keyUrl must not be null."); - JsonWebKeySet keySet = getCache().getIfPresent(keyUri.toString()); - if (keySet == null || !keySet.containsAppTid(appTid)) { - keySet = retrieveTokenKeysAndUpdateCache(keyUri, appTid, keySet, clientId); // creates and updates cache entries - } - if (keySet == null || keySet.getAll().isEmpty()) { - LOGGER.error("Retrieved no token keys from {}", keyUri); + CacheKey cacheKey = new CacheKey(keyUri, params); + JsonWebKeySet jwks = getCache().getIfPresent(cacheKey.toString()); + + if(jwks == null) { + jwks = retrieveTokenKeysAndUpdateCache(cacheKey); + } + + if (jwks.getAll().isEmpty()) { + LOGGER.error("Retrieved no token keys from {} for the given header parameters.", keyUri); return null; } - if (!keySet.isAppTidAccepted(appTid)) { - throw new OAuth2ServiceException("Keys not accepted for app_tid " + appTid); - } - for (JsonWebKey jwk : keySet.getAll()) { + + for (JsonWebKey jwk : jwks.getAll()) { if (keyId.equals(jwk.getId()) && jwk.getKeyAlgorithm().equals(keyAlgorithm)) { return jwk.getPublicKey(); } } - LOGGER.warn("No matching key found. Keys cached: {}", keySet); - return null; + + LOGGER.warn("No matching key found. Cached keys: {}", jwks); + throw new IllegalArgumentException("Key with kid " + keyId + " not found in JWKS."); } + private JsonWebKeySet retrieveTokenKeysAndUpdateCache(CacheKey cacheKey) throws OAuth2ServiceException { + String jwksJson = getTokenKeyService().retrieveTokenKeys(cacheKey.keyUri(), cacheKey.params()); + + JsonWebKeySet keySet = JsonWebKeySetFactory.createFromJson(jwksJson); + getCache().put(cacheKey.toString(), keySet); + + return keySet; + } + private TokenKeyCacheConfiguration getCheckedConfiguration(CacheConfiguration cacheConfiguration) { Assertions.assertNotNull(cacheConfiguration, "CacheConfiguration must not be null!"); int size = cacheConfiguration.getCacheSize(); @@ -267,26 +255,6 @@ private TokenKeyCacheConfiguration getCheckedConfiguration(CacheConfiguration ca return TokenKeyCacheConfiguration.getInstance(duration, size, cacheConfiguration.isCacheStatisticsEnabled()); } - private JsonWebKeySet retrieveTokenKeysAndUpdateCache(URI jwksUri, String appTid, - @Nullable JsonWebKeySet keySetCached, String clientId) - throws OAuth2ServiceException { - String jwksJson; - try { - jwksJson = getTokenKeyService().retrieveTokenKeys(jwksUri, appTid, clientId); - } catch (OAuth2ServiceException e) { - if (keySetCached != null) { - keySetCached.withAppTid(appTid, false); - } - throw e; - } - if (keySetCached != null) { - return keySetCached.withAppTid(appTid, true); - } - JsonWebKeySet keySet = JsonWebKeySetFactory.createFromJson(jwksJson).withAppTid(appTid, true); - getCache().put(jwksUri.toString(), keySet); - return keySet; - } - private Cache getCache() { if (cache == null) { Caffeine cacheBuilder = Caffeine.newBuilder() @@ -326,4 +294,33 @@ public Object getCacheStatistics() { return getCacheConfiguration().isCacheStatisticsEnabled() ? getCache().stats() : null; } + private static class CacheKey { + final private URI keyUri; + final private Map params; + + public CacheKey(URI keyUri, Map params) { + this.keyUri = keyUri; + this.params = params; + } + + public URI keyUri() { + return keyUri; + } + + public Map params() { + return params; + } + + @Override + public String toString() { + // e.g. app_tid:|client_id:|azp: + String paramString = params.entrySet().stream() + .filter(e -> e.getValue() != null) + .map(e -> e.getKey() + ":" + e.getValue()) + .collect(Collectors.joining("|")); + + // e.g. url:|app_tid:|client_id:|azp: + return String.format("url:%s|%s", keyUri, paramString); + } + } } diff --git a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/SapIdJwtSignatureValidator.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/SapIdJwtSignatureValidator.java new file mode 100644 index 000000000..6e25ec7f5 --- /dev/null +++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/SapIdJwtSignatureValidator.java @@ -0,0 +1,101 @@ +package com.sap.cloud.security.token.validation.validators; + +import com.sap.cloud.security.config.OAuth2ServiceConfiguration; +import com.sap.cloud.security.token.Token; +import com.sap.cloud.security.token.TokenClaims; +import com.sap.cloud.security.xsuaa.client.DefaultOidcConfigurationService; +import com.sap.cloud.security.xsuaa.client.OAuth2ServiceEndpointsProvider; +import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException; +import com.sap.cloud.security.xsuaa.http.HttpHeaders; + +import javax.annotation.Nonnull; +import java.net.URI; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.util.HashMap; +import java.util.Map; + +import static com.sap.cloud.security.token.validation.validators.JsonWebKey.DEFAULT_KEY_ID; +import static com.sap.cloud.security.token.validation.validators.JsonWebKeyConstants.KID_PARAMETER_NAME; + +/** + * Jwt Signature validator for OIDC tokens issued by Identity service + */ +class SapIdJwtSignatureValidator extends JwtSignatureValidator { + private boolean isTenantIdCheckEnabled = true; + + SapIdJwtSignatureValidator(OAuth2ServiceConfiguration configuration, OAuth2TokenKeyServiceWithCache tokenKeyService, OidcConfigurationServiceWithCache oidcConfigurationService) { + super(configuration, tokenKeyService, oidcConfigurationService); + } + + /** + * Disables the tenant id check. In case JWT issuer (`iss` claim) differs from `url` attribute of + * {@link OAuth2ServiceConfiguration}, claim {@link TokenClaims#SAP_GLOBAL_APP_TID} needs to be + * present in token to ensure that the tenant belongs to this issuer. + *

+ * Use with caution as it relaxes the validation rules! It is not recommended to + * disable this check for standard Identity service setup. + */ + protected void disableTenantIdCheck() { + this.isTenantIdCheckEnabled = false; + } + + @Override + protected PublicKey getPublicKey(Token token, JwtSignatureAlgorithm algorithm) throws OAuth2ServiceException { + String keyId = DEFAULT_KEY_ID; + if (token.hasHeaderParameter(KID_PARAMETER_NAME)) { + keyId = token.getHeaderParameterAsString(KID_PARAMETER_NAME); + } + + URI jkuUri = getJwksUri(token); + Map params = new HashMap<>(3, 1); + params.put(HttpHeaders.X_APP_TID, token.getAppTid()); + params.put(HttpHeaders.X_CLIENT_ID, configuration.getClientId()); + params.put(HttpHeaders.X_AZP, token.getClaimAsString(TokenClaims.AUTHORIZATION_PARTY)); + + try { + return tokenKeyService.getPublicKey(algorithm, keyId, jkuUri, params); + } catch (InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new IllegalArgumentException(e); + } + } + + private URI getJwksUri(Token token) throws OAuth2ServiceException { + String domain = token.getIssuer(); + if (domain == null) { + throw new IllegalArgumentException("Token does not contain mandatory " + TokenClaims.ISSUER + " header."); + } + + if (isTenantIdCheckEnabled && !domain.equals("" + configuration.getUrl()) && token.getAppTid() == null) { + throw new IllegalArgumentException("OIDC token must provide a valid " + TokenClaims.SAP_GLOBAL_APP_TID + " header when issuer has a different domain than the url from the service credentials."); + } + + + return this.getOidcJwksUri(domain); + } + + /** + * Fetches the JWKS URI from the OIDC .well-known endpoint under the given domain that must have already been validated to be trustworthy in advance, e.g. with an additional {@link JwtIssuerValidator}. + * + * @param domain a trustworthy domain that supplies an OIDC .well-known endpoint + * @return the URI to the JWKS of the OIDC service under the given domain + * @throws OAuth2ServiceException if server call fails + */ + @Nonnull + private URI getOidcJwksUri(String domain) throws OAuth2ServiceException { + URI discoveryUri = DefaultOidcConfigurationService.getDiscoveryEndpointUri(domain); + + OAuth2ServiceEndpointsProvider endpointsProvider = oidcConfigurationService.getOrRetrieveEndpoints(discoveryUri); + if (endpointsProvider == null) { + throw new OAuth2ServiceException("OIDC .well-known configuration could not be retrieved."); + } + + URI jkuUri = endpointsProvider.getJwksUri(); + if (jkuUri == null) { + throw new IllegalArgumentException("OIDC .well-known response did not contain JWKS URI."); + } + + return jkuUri; + } +} diff --git a/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/XsuaaJwtSignatureValidator.java b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/XsuaaJwtSignatureValidator.java new file mode 100644 index 000000000..c350ebfd2 --- /dev/null +++ b/java-security/src/main/java/com/sap/cloud/security/token/validation/validators/XsuaaJwtSignatureValidator.java @@ -0,0 +1,65 @@ +package com.sap.cloud.security.token.validation.validators; + +import com.sap.cloud.security.config.OAuth2ServiceConfiguration; +import com.sap.cloud.security.config.cf.CFConstants; +import com.sap.cloud.security.token.Token; +import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException; +import com.sap.cloud.security.xsuaa.http.HttpHeaders; + +import java.net.URI; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.util.Collections; +import java.util.Map; + +import static com.sap.cloud.security.token.validation.validators.JsonWebKeyConstants.*; + +/** + * Jwt Signature validator for Access tokens issued by Xsuaa service + */ +class XsuaaJwtSignatureValidator extends JwtSignatureValidator { + XsuaaJwtSignatureValidator(OAuth2ServiceConfiguration configuration, OAuth2TokenKeyServiceWithCache tokenKeyService, OidcConfigurationServiceWithCache oidcConfigurationService) { + super(configuration, tokenKeyService, oidcConfigurationService); + } + + @Override + protected PublicKey getPublicKey(Token token, JwtSignatureAlgorithm algorithm) throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { + PublicKey key = null; + + try { + key = fetchPublicKey(token, algorithm); + } catch (OAuth2ServiceException | InvalidKeySpecException | NoSuchAlgorithmException | IllegalArgumentException e) { + if (!configuration.hasProperty(CFConstants.XSUAA.VERIFICATION_KEY)) { + throw e; + } + } + + if (key == null && configuration.hasProperty(CFConstants.XSUAA.VERIFICATION_KEY)) { + String fallbackKey = configuration.getProperty(CFConstants.XSUAA.VERIFICATION_KEY); + try { + key = JsonWebKeyImpl.createPublicKeyFromPemEncodedPublicKey(JwtSignatureAlgorithm.RS256, fallbackKey); + } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) { + throw new IllegalArgumentException("Fallback validation key supplied via " + CFConstants.XSUAA.VERIFICATION_KEY + " property in service credentials could not be used: {}", ex); + } + } + + return key; + } + + + private PublicKey fetchPublicKey(Token token, JwtSignatureAlgorithm algorithm) throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { + String keyId = configuration.isLegacyMode() ? KEY_ID_VALUE_LEGACY : token.getHeaderParameterAsString(KID_PARAMETER_NAME); + if (keyId == null) { + throw new IllegalArgumentException("Token does not contain the mandatory " + KID_PARAMETER_NAME + " header."); + } + + String jwksUri = configuration.isLegacyMode() ? configuration.getUrl() + "/token_keys" : token.getHeaderParameterAsString(JKU_PARAMETER_NAME); + if (jwksUri == null) { + throw new IllegalArgumentException("Token does not contain the mandatory " + JKU_PARAMETER_NAME + " header."); + } + + Map params = Collections.singletonMap(HttpHeaders.X_ZID, token.getAppTid()); + return tokenKeyService.getPublicKey(algorithm, keyId, URI.create(jwksUri), params); + } +} diff --git a/java-security/src/test/java/com/sap/cloud/security/servlet/IasTokenAuthenticatorTest.java b/java-security/src/test/java/com/sap/cloud/security/servlet/IasTokenAuthenticatorTest.java index f41c04e24..9e0d54f73 100644 --- a/java-security/src/test/java/com/sap/cloud/security/servlet/IasTokenAuthenticatorTest.java +++ b/java-security/src/test/java/com/sap/cloud/security/servlet/IasTokenAuthenticatorTest.java @@ -14,6 +14,7 @@ import com.sap.cloud.security.util.HttpClientTestFactory; import com.sap.cloud.security.xsuaa.http.HttpHeaders; import org.apache.commons.io.IOUtils; +import org.apache.http.client.ResponseHandler; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; @@ -58,8 +59,11 @@ public void setUp() throws IOException { CloseableHttpResponse tokenKeysResponse = HttpClientTestFactory .createHttpResponse(IOUtils.resourceToString("/iasJsonWebTokenKeys.json", UTF_8)); when(httpClientMock.execute(any(HttpGet.class))) - .thenReturn(oidcResponse) - .thenReturn(tokenKeysResponse); + .thenReturn(oidcResponse); + when(httpClientMock.execute(any(HttpGet.class), any(ResponseHandler.class))).thenAnswer(invocation -> { + ResponseHandler responseHandler = invocation.getArgument(1); + return responseHandler.handleResponse(tokenKeysResponse); + }); cut = new IasTokenAuthenticator() .withServiceConfiguration(configuration) diff --git a/java-security/src/test/java/com/sap/cloud/security/servlet/IasTokenAuthenticatorX509Test.java b/java-security/src/test/java/com/sap/cloud/security/servlet/IasTokenAuthenticatorX509Test.java index c75aa28b0..43976c912 100644 --- a/java-security/src/test/java/com/sap/cloud/security/servlet/IasTokenAuthenticatorX509Test.java +++ b/java-security/src/test/java/com/sap/cloud/security/servlet/IasTokenAuthenticatorX509Test.java @@ -14,6 +14,7 @@ import com.sap.cloud.security.util.HttpClientTestFactory; import com.sap.cloud.security.xsuaa.http.HttpHeaders; import org.apache.commons.io.IOUtils; +import org.apache.http.client.ResponseHandler; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; @@ -62,8 +63,11 @@ static void beforeAll() throws IOException { CloseableHttpResponse tokenKeysResponse = HttpClientTestFactory .createHttpResponse(IOUtils.resourceToString("/iasJsonWebTokenKeys.json", UTF_8)); when(httpClientMock.execute(any(HttpGet.class))) - .thenReturn(oidcResponse) - .thenReturn(tokenKeysResponse); + .thenReturn(oidcResponse); + when(httpClientMock.execute(any(HttpGet.class), any(ResponseHandler.class))).thenAnswer(invocation -> { + ResponseHandler responseHandler = invocation.getArgument(1); + return responseHandler.handleResponse(tokenKeysResponse); + }); JwtValidatorBuilder .getInstance(configuration) diff --git a/java-security/src/test/java/com/sap/cloud/security/servlet/XsuaaTokenAuthenticatorTest.java b/java-security/src/test/java/com/sap/cloud/security/servlet/XsuaaTokenAuthenticatorTest.java index 1077ebd77..764a4e8f6 100644 --- a/java-security/src/test/java/com/sap/cloud/security/servlet/XsuaaTokenAuthenticatorTest.java +++ b/java-security/src/test/java/com/sap/cloud/security/servlet/XsuaaTokenAuthenticatorTest.java @@ -15,6 +15,7 @@ import com.sap.cloud.security.xsuaa.http.HttpHeaders; import com.sap.cloud.security.xsuaa.tokenflows.TokenFlowException; import org.apache.commons.io.IOUtils; +import org.apache.http.client.ResponseHandler; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; @@ -28,7 +29,7 @@ import java.io.IOException; import static com.github.stefanbirkner.systemlambda.SystemLambda.withEnvironmentVariable; -import static com.sap.cloud.security.config.cf.CFConstants.*; +import static com.sap.cloud.security.config.cf.CFConstants.XSUAA; import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; @@ -63,7 +64,10 @@ public void setUp() throws IOException { CloseableHttpResponse xsuaaTokenKeysResponse = HttpClientTestFactory .createHttpResponse(IOUtils.resourceToString("/jsonWebTokenKeys.json", UTF_8)); - when(mockHttpClient.execute(any(HttpGet.class))).thenReturn(xsuaaTokenKeysResponse); + when(mockHttpClient.execute(any(HttpGet.class), any(ResponseHandler.class))).thenAnswer(invocation -> { + ResponseHandler responseHandler = invocation.getArgument(1); + return responseHandler.handleResponse(xsuaaTokenKeysResponse); + }); CloseableHttpResponse xsuaaTokenResponse = HttpClientTestFactory .createHttpResponse( 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 e7a4634b0..e8c05b9e6 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 @@ -14,6 +14,7 @@ import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException; import com.sap.cloud.security.xsuaa.client.OAuth2TokenKeyService; import com.sap.cloud.security.xsuaa.client.OidcConfigurationService; +import com.sap.cloud.security.xsuaa.http.HttpHeaders; import org.apache.commons.io.IOUtils; import org.junit.Before; import org.junit.Test; @@ -21,12 +22,16 @@ import java.io.IOException; import java.net.URI; +import java.util.HashMap; +import java.util.Map; import static java.nio.charset.StandardCharsets.UTF_8; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.when; public class IdTokenSignatureValidatorTest { @@ -38,13 +43,19 @@ public class IdTokenSignatureValidatorTest { OAuth2ServiceEndpointsProvider endpointsProviderMock; private OidcConfigurationService oidcConfigurationServiceMock; private static final URI JKU_URI = URI.create("https://application.myauth.com/jwks_uri"); - private static final String APP_TID = "the-app-tid"; private static final URI DISCOVERY_URI = URI .create("https://application.myauth.com" + OidcConfigurationService.DISCOVERY_ENDPOINT_DEFAULT); + private static final String APP_TID = "the-app-tid"; private static final String CLIENT_ID = "client-id"; + private static final String AZP = "T000310"; + private static final Map PARAMS = new HashMap<>(3, 1); @Before public void setup() throws IOException { + PARAMS.put(HttpHeaders.X_APP_TID, APP_TID); + PARAMS.put(HttpHeaders.X_CLIENT_ID, CLIENT_ID); + PARAMS.put(HttpHeaders.X_AZP, AZP); + /** * Header -------- { "alg": "RS256" } Payload -------- { "iss": * "https://application.myauth.com" } @@ -63,11 +74,10 @@ public void setup() throws IOException { .thenReturn(endpointsProviderMock); tokenKeyServiceMock = Mockito.mock(OAuth2TokenKeyService.class); - when(tokenKeyServiceMock - .retrieveTokenKeys(JKU_URI, APP_TID, CLIENT_ID)) + when(tokenKeyServiceMock.retrieveTokenKeys(JKU_URI, PARAMS)) .thenReturn(IOUtils.resourceToString("/iasJsonWebTokenKeys.json", UTF_8)); - cut = new JwtSignatureValidator( + cut = new SapIdJwtSignatureValidator( mockConfiguration, OAuth2TokenKeyServiceWithCache.getInstance().withTokenKeyService(tokenKeyServiceMock), OidcConfigurationServiceWithCache.getInstance() @@ -85,8 +95,7 @@ public void validationFails_WhenEndpointProvidesNullJku() { ValidationResult result = cut.validate(iasToken); assertThat(result.isErroneous(), is(true)); - assertThat(result.getErrorDescription(), - containsString("Error occurred during jwks uri determination")); + assertThat(result.getErrorDescription(), containsString("OIDC .well-known response did not contain JWKS URI")); } @Test @@ -96,29 +105,27 @@ public void validationFails_whenOAuthServerIsUnavailable_OIDC() throws OAuth2Ser ValidationResult result = cut.validate(iasToken); assertThat(result.isErroneous(), is(true)); - assertThat(result.getErrorDescription(), - containsString("Error occurred during jwks uri determination")); + assertThat(result.getErrorDescription(), containsString("JWKS could not be fetched")); } @Test public void validationFails_whenOAuthServerIsUnavailable_JKS() throws OAuth2ServiceException { - when(tokenKeyServiceMock - .retrieveTokenKeys(any(), any(), any())).thenThrow(OAuth2ServiceException.class); + when(tokenKeyServiceMock.retrieveTokenKeys(any(), anyMap())).thenThrow(OAuth2ServiceException.class); ValidationResult result = cut.validate(iasToken); assertThat(result.isErroneous(), is(true)); - assertThat(result.getErrorDescription(), - containsString("Error retrieving Json Web Keys from Identity Service")); + assertThat(result.getErrorDescription(), containsString("JWKS could not be fetched")); } @Test public void validationFails_whenNoMatchingKey() { - ValidationResult result = cut.validate(iasToken.getTokenValue(), "RS256", "default-kid-2", - JKU_URI.toString(), null, null); + String otherKid = "someOtherKid"; + Token tokenSpy = Mockito.spy(iasToken); + doReturn(otherKid).when(tokenSpy).getHeaderParameterAsString(JsonWebKeyConstants.KID_PARAMETER_NAME); + + ValidationResult result = cut.validate(tokenSpy); assertThat(result.isErroneous(), is(true)); - assertThat(result.getErrorDescription(), - containsString( - "There is no Json Web Token Key with keyId 'default-kid-2' and type 'RSA' found")); + assertThat(result.getErrorDescription(), containsString("Key with kid " + otherKid + " not found in JWKS")); } } diff --git a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JwtValidatorBuilderTest.java b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JwtValidatorBuilderTest.java index 136b1f1f2..ed023d10f 100644 --- a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JwtValidatorBuilderTest.java +++ b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JwtValidatorBuilderTest.java @@ -35,6 +35,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) @@ -127,7 +128,7 @@ public void build_ias_withTenantIdCheckDisabled() throws IOException { OAuth2ServiceEndpointsProvider endpointsProviderMock = Mockito.mock(OAuth2ServiceEndpointsProvider.class); OidcConfigurationService oidcConfigServiceMock = Mockito.mock(OidcConfigurationService.class); - when(tokenKeyServiceMock.retrieveTokenKeys(any(), any(), any())) + when(tokenKeyServiceMock.retrieveTokenKeys(any(), anyMap())) .thenReturn(IOUtils.resourceToString("/iasJsonWebTokenKeys.json", UTF_8)); when(endpointsProviderMock.getJwksUri()).thenReturn(URI.create("https://application.myauth.com/jwks_uri")); when(oidcConfigServiceMock.retrieveEndpoints(any())).thenReturn(endpointsProviderMock); 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 01f1a4641..f29dc0cf9 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 @@ -9,6 +9,7 @@ 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.http.HttpHeaders; import org.apache.commons.io.IOUtils; import org.junit.Before; import org.junit.Test; @@ -20,6 +21,9 @@ import java.security.PublicKey; import java.security.spec.InvalidKeySpecException; import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -37,11 +41,17 @@ public class OAuth2TokenKeyServiceWithCacheTest { private static final String APP_TID = "app_tid"; private TestCacheTicker testCacheTicker; private static final String CLIENT_ID = "client_id"; + private static final String AZP = "azp"; + private static final Map PARAMS = new HashMap<>(3, 1); @Before public void setup() throws IOException { + PARAMS.put(HttpHeaders.X_APP_TID, APP_TID); + PARAMS.put(HttpHeaders.X_CLIENT_ID, CLIENT_ID); + PARAMS.put(HttpHeaders.X_AZP, AZP); + tokenKeyServiceMock = mock(OAuth2TokenKeyService.class); - when(tokenKeyServiceMock.retrieveTokenKeys(eq(TOKEN_KEYS_URI), isNotNull(), any())) + when(tokenKeyServiceMock.retrieveTokenKeys(eq(TOKEN_KEYS_URI), anyMap())) .thenReturn(IOUtils.resourceToString("/jsonWebTokenKeys.json", StandardCharsets.UTF_8)); testCacheTicker = new TestCacheTicker(); @@ -82,23 +92,24 @@ public void changeCacheConfiguration_tooLongDuration_leftUnchanged() { } @Test - public void retrieveTokenKeys() throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { - PublicKey key1 = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID, CLIENT_ID); - PublicKey key2 = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, "other-zone-id", CLIENT_ID); + public void retrieveTokenKeysUsesCorrectParams() throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { + PublicKey key1 = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, PARAMS); + Map otherParams = Collections.singletonMap(HttpHeaders.X_APP_TID, "otherAppTid"); + PublicKey key2 = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, otherParams); assertThat(String.valueOf(key1.getAlgorithm())).isEqualTo("RSA"); assertThat(String.valueOf(key2.getAlgorithm())).isEqualTo("RSA"); - verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(TOKEN_KEYS_URI, APP_TID, CLIENT_ID); - verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(TOKEN_KEYS_URI, "other-zone-id", CLIENT_ID); + verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(TOKEN_KEYS_URI, PARAMS); + verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(TOKEN_KEYS_URI, otherParams); } @Test public void getCachedTokenKeys() throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { - PublicKey key = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID, CLIENT_ID); - PublicKey cachedKey = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID, CLIENT_ID); + PublicKey key = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, PARAMS); + PublicKey cachedKey = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, PARAMS); assertThat(cachedKey).isNotNull().isSameAs(key); - verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(eq(TOKEN_KEYS_URI), any(), eq(CLIENT_ID)); + verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(eq(TOKEN_KEYS_URI), eq(PARAMS)); } @Test @@ -112,44 +123,44 @@ public void retrieveNoTokenKeys_returnsNull() @Test public void requestFails_throwsException() throws OAuth2ServiceException { - when(tokenKeyServiceMock.retrieveTokenKeys(any(), any(), any())) + when(tokenKeyServiceMock.retrieveTokenKeys(any(), anyMap())) .thenThrow(new OAuth2ServiceException("Currently unavailable")); - assertThatThrownBy(() -> { - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID, CLIENT_ID); - }).isInstanceOf(OAuth2ServiceException.class).hasMessageStartingWith("Currently unavailable"); + assertThatThrownBy(() -> cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, PARAMS)) + .isInstanceOf(OAuth2ServiceException.class).hasMessageStartingWith("Currently unavailable"); } @Test public void retrieveTokenKeys_afterCacheWasCleared() throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID); + cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, PARAMS); cut.clearCache(); - PublicKey cachedKey = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID); + PublicKey cachedKey = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, PARAMS); assertThat(cachedKey).isNotNull(); - verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(eq(TOKEN_KEYS_URI), eq(APP_TID), any()); + verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(eq(TOKEN_KEYS_URI), eq(PARAMS)); } @Test - public void getCachedTokenKeys_noZoneId() throws IOException, InvalidKeySpecException, NoSuchAlgorithmException { - when(tokenKeyServiceMock.retrieveTokenKeys(eq(TOKEN_KEYS_URI), isNull(), eq(CLIENT_ID))) + public void getCachedTokenKeys_noAppTid_noAzp() throws IOException, InvalidKeySpecException, NoSuchAlgorithmException { + Map params = Collections.singletonMap(HttpHeaders.X_CLIENT_ID, CLIENT_ID); + when(tokenKeyServiceMock.retrieveTokenKeys(eq(TOKEN_KEYS_URI), eq(params))) .thenReturn(IOUtils.resourceToString("/jsonWebTokenKeys.json", StandardCharsets.UTF_8)); - PublicKey key = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, null, CLIENT_ID); - PublicKey cachedKey = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, null, CLIENT_ID); + PublicKey key = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, params); + PublicKey cachedKey = cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, params); assertThat(cachedKey).isNotNull().isSameAs(key); - verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(eq(TOKEN_KEYS_URI), isNull(), eq(CLIENT_ID)); + verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(eq(TOKEN_KEYS_URI), eq(params)); } @Test public void retrieveTokenKeys_doesRequestKeysAgainAfterCacheExpired() throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID, CLIENT_ID); + cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, PARAMS); testCacheTicker.advance(CACHE_CONFIGURATION.getCacheDuration()); // just expired - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID, CLIENT_ID); + cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, PARAMS); - verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(any(), eq(APP_TID), eq(CLIENT_ID)); + verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(any(), eq(PARAMS)); } @Test @@ -171,48 +182,36 @@ public void cacheStatistics_isEnabled_returnsStatisticsObject() { @Test public void retrieveTokenKeysForNewKeyId() throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID, CLIENT_ID); - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "not-seen-yet", TOKEN_KEYS_URI, APP_TID, CLIENT_ID); + cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, PARAMS); + cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-1", TOKEN_KEYS_URI, PARAMS); - verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(any(), eq(APP_TID), eq(CLIENT_ID)); + verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(any(), eq(PARAMS)); } @Test - public void retrieveTokenKeysForNewZoneId() + public void retrieveTokenKeysDoesNotCacheOnServerException() throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID, CLIENT_ID); - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID + "-2", CLIENT_ID); - - verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(any(), any(), any()); - verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(any(), eq(APP_TID), eq(CLIENT_ID)); - verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(any(), eq(APP_TID + "-2"), eq(CLIENT_ID)); - } + Map invalidParams = Collections.singletonMap(HttpHeaders.X_APP_TID, "invalidAppTid"); + when(tokenKeyServiceMock.retrieveTokenKeys(any(), eq(invalidParams))).thenThrow(new OAuth2ServiceException("Invalid parameters provided")); + cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, PARAMS); - @Test - public void retrieveTokenKeysForAnotherInvalidZoneId() - throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { - when(tokenKeyServiceMock.retrieveTokenKeys(any(), eq("invalid-tenant"), eq(CLIENT_ID))) - .thenThrow(new OAuth2ServiceException("Invalid app_tid provided")); - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID); + assertThatThrownBy(() -> cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, invalidParams)). + isInstanceOf(OAuth2ServiceException.class).hasMessageStartingWith("Invalid"); - assertThatThrownBy(() -> { - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, "invalid-tenant", CLIENT_ID); - }).isInstanceOf(OAuth2ServiceException.class).hasMessageStartingWith("Invalid"); - assertThatThrownBy(() -> { - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, "invalid-tenant", CLIENT_ID); - }).isInstanceOf(OAuth2ServiceException.class) - .hasMessageStartingWith("Keys not accepted for app_tid invalid-tenant"); + assertThatThrownBy(() -> cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, invalidParams)) + .isInstanceOf(OAuth2ServiceException.class).hasMessageStartingWith("Invalid"); - verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(any(), eq("invalid-tenant"), eq(CLIENT_ID)); + verify(tokenKeyServiceMock, times(1)).retrieveTokenKeys(any(), eq(PARAMS)); + verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(any(), eq(invalidParams)); } @Test public void retrieveTokenKeysForNewEndpoint() throws OAuth2ServiceException, InvalidKeySpecException, NoSuchAlgorithmException { - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, APP_TID, CLIENT_ID); - cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", URI.create("http://another/url"), APP_TID, CLIENT_ID); + cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", TOKEN_KEYS_URI, PARAMS); + cut.getPublicKey(JwtSignatureAlgorithm.RS256, "key-id-0", URI.create("http://another/url"), PARAMS); - verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(any(), eq(APP_TID), eq(CLIENT_ID)); + verify(tokenKeyServiceMock, times(2)).retrieveTokenKeys(any(), eq(PARAMS)); } private OAuth2TokenKeyServiceWithCache createCut(TokenKeyCacheConfiguration cacheConfiguration) { diff --git a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JwtSignatureValidatorTest.java b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/SapIdJwtSignatureValidatorTest.java similarity index 59% rename from java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JwtSignatureValidatorTest.java rename to java-security/src/test/java/com/sap/cloud/security/token/validation/validators/SapIdJwtSignatureValidatorTest.java index d221ac2ba..46f2c511d 100644 --- a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/JwtSignatureValidatorTest.java +++ b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/SapIdJwtSignatureValidatorTest.java @@ -24,15 +24,17 @@ import java.util.regex.Pattern; import static java.nio.charset.StandardCharsets.UTF_8; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.startsWith; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.isNotNull; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.when; -public class JwtSignatureValidatorTest { +public class SapIdJwtSignatureValidatorTest { private Token iasToken; private Token iasPaasToken; private static final URI DUMMY_JKU_URI = URI.create("https://application.myauth.com/jwks_uri"); @@ -41,6 +43,7 @@ public class JwtSignatureValidatorTest { private OAuth2TokenKeyService tokenKeyServiceMock; private OAuth2ServiceConfiguration mockConfiguration; private OidcConfigurationService oidcConfigServiceMock; + private OAuth2ServiceEndpointsProvider endpointsProviderMock; @Before public void setup() throws IOException { @@ -53,19 +56,18 @@ public void setup() throws IOException { when(mockConfiguration.getService()).thenReturn(Service.IAS); when(mockConfiguration.getClientId()).thenReturn("client-id"); - tokenKeyServiceMock = Mockito.mock(OAuth2TokenKeyService.class); when(tokenKeyServiceMock - .retrieveTokenKeys(any(), any(), isNotNull())) + .retrieveTokenKeys(any(), anyMap())) .thenReturn(IOUtils.resourceToString("/iasJsonWebTokenKeys.json", UTF_8)); - OAuth2ServiceEndpointsProvider endpointsProviderMock = Mockito.mock(OAuth2ServiceEndpointsProvider.class); + endpointsProviderMock = Mockito.mock(OAuth2ServiceEndpointsProvider.class); when(endpointsProviderMock.getJwksUri()).thenReturn(DUMMY_JKU_URI); oidcConfigServiceMock = Mockito.mock(OidcConfigurationService.class); when(oidcConfigServiceMock.retrieveEndpoints(any())).thenReturn(endpointsProviderMock); - cut = new JwtSignatureValidator( + cut = new SapIdJwtSignatureValidator( mockConfiguration, OAuth2TokenKeyServiceWithCache.getInstance().withTokenKeyService(tokenKeyServiceMock), OidcConfigurationServiceWithCache.getInstance() @@ -74,43 +76,54 @@ public void setup() throws IOException { @Test public void validate_throwsWhenTokenIsNull() { - assertThatThrownBy(() -> { - cut.validate(null, "RS256", "default-kid-ias", DUMMY_JKU_URI.toString(), null, null); - }).isInstanceOf(IllegalArgumentException.class).hasMessageStartingWith("token"); + Token tokenSpy = Mockito.spy(iasToken); + doReturn(null).when(tokenSpy).getTokenValue(); + + ValidationResult validationResult = cut.validate(tokenSpy); + assertTrue(validationResult.isErroneous()); + assertThat(validationResult.getErrorDescription(), containsString("JWT token validation failed because token content was null.")); } @Test public void validate_throwsWhenAlgorithmIsNull() { - assertThatThrownBy(() -> { - cut.validate("eyJhbGciOiJSUzI1NiJ9", null, "default-kid-ias", DUMMY_JKU_URI.toString(), null, null); - }).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("tokenAlgorithm"); + Token tokenSpy = Mockito.spy(iasToken); + doReturn(null).when(tokenSpy).getHeaderParameterAsString(JsonWebKeyConstants.ALG_PARAMETER_NAME); + + ValidationResult validationResult = cut.validate(tokenSpy); + assertTrue(validationResult.isErroneous()); + assertThat(validationResult.getErrorDescription(), containsString("JWT token validation with signature algorithm 'null' is not supported")); } @Test public void validate_throwsWhenKeyIdIsNull() { - assertThatThrownBy(() -> { - cut.validate("eyJhbGciOiJSUzI1NiJ9", "RS256", "", DUMMY_JKU_URI.toString(), null, null); - }).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("tokenKeyId"); + Token tokenSpy = Mockito.spy(iasToken); + doReturn(null).when(tokenSpy).getHeaderParameterAsString(JsonWebKeyConstants.KID_PARAMETER_NAME); + + ValidationResult validationResult = cut.validate(tokenSpy); + assertTrue(validationResult.isErroneous()); + assertThat(validationResult.getErrorDescription(), containsString("keyId must not be null")); } @Test public void validate_throwsWhenKeysUrlIsNull() { - assertThatThrownBy(() -> { - cut.validate("eyJhbGciOiJSUzI1NiJ9", "RS256", "default-kid-ias", "", null, null); - }).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("tokenKeysUrl"); + when(endpointsProviderMock.getJwksUri()).thenReturn(null); + + ValidationResult validationResult = cut.validate(iasToken); + assertTrue(validationResult.isErroneous()); + assertThat(validationResult.getErrorDescription(), containsString("OIDC .well-known response did not contain JWKS URI.")); } @Test - public void validationFails_WhenZoneIdIsNull() { + public void validationFails_WhenAppTidIsNull() { ValidationResult validationResult = cut.validate(iasPaasToken); assertTrue(validationResult.isErroneous()); assertThat(validationResult.getErrorDescription(), - startsWith("Error occurred during signature validation: OIDC token must provide app_tid.")); + containsString("OIDC token must provide a valid app_tid header when issuer has a different domain than the url from the service credentials.")); } @Test - public void validate_whenZoneIdIsNull_withDisabledZoneId() { - JwtSignatureValidator cut = new JwtSignatureValidator( + public void validate_whenAppTidIsNull_withDisabledAppTid() { + SapIdJwtSignatureValidator cut = new SapIdJwtSignatureValidator( mockConfiguration, OAuth2TokenKeyServiceWithCache.getInstance() .withTokenKeyService(tokenKeyServiceMock), @@ -149,42 +162,44 @@ public void validationFails_whenJwtProvidesNoSignature() { String tokenWithNoSignature = new StringBuilder(tokenHeaderPayloadSignature[0]) .append(".") .append(tokenHeaderPayloadSignature[1]).toString(); + Token tokenSpy = Mockito.spy(iasToken); + doReturn(tokenWithNoSignature).when(tokenSpy).getTokenValue(); - ValidationResult result = cut.validate(tokenWithNoSignature, "RS256", "default-kid-ias", - DUMMY_JKU_URI.toString(), null, null); + ValidationResult result = cut.validate(tokenSpy); assertThat(result.isErroneous(), is(true)); - assertThat(result.getErrorDescription(), - containsString("Jwt token does not consist of 'header'.'payload'.'signature'.")); + assertThat(result.getErrorDescription(), containsString("Jwt token does not consist of three sections: 'header'.'payload'.'signature'.")); } @Test - public void validationFails_whenTokenAlgorithmIsNotRSA256() { - ValidationResult validationResult = cut.validate(iasToken.getTokenValue(), "ES123", "default-kid-ias", - "https://myauth.com/jwks_uri", null, null); + public void validationFails_whenTokenAlgorithmIsNotSupported() { + Token tokenSpy = Mockito.spy(iasToken); + String unsupportedAlgorithm = "UnsupportedAlgorithm"; + doReturn(unsupportedAlgorithm).when(tokenSpy).getHeaderParameterAsString(JsonWebKeyConstants.ALG_PARAMETER_NAME); + + ValidationResult validationResult = cut.validate(tokenSpy); + assertThat(validationResult.isErroneous(), is(true)); - assertThat(validationResult.getErrorDescription(), - startsWith("Jwt token with signature algorithm 'ES123' is not supported.")); + assertThat(validationResult.getErrorDescription(), startsWith("JWT token validation with signature algorithm '" + unsupportedAlgorithm + "' is not supported.")); } @Test public void validationFails_whenTokenAlgorithmIsNone() { - ValidationResult validationResult = cut.validate(iasToken.getTokenValue(), "NONE", "default-kid-ias", - "https://myauth.com/jwks_uri", null, null); - assertThat(validationResult.isErroneous(), is(true)); - assertThat(validationResult.getErrorDescription(), - startsWith("Jwt token with signature algorithm 'NONE' is not supported.")); + Token tokenSpy = Mockito.spy(iasToken); + doReturn("NONE").when(tokenSpy).getHeaderParameterAsString(JsonWebKeyConstants.ALG_PARAMETER_NAME); + + ValidationResult validationResult = cut.validate(tokenSpy); + + assertThat(validationResult.isErroneous(), is(true)); + assertThat(validationResult.getErrorDescription(), startsWith("JWT token validation with signature algorithm 'NONE' is not supported.")); } @Test public void validationFails_whenOAuthServerIsUnavailable() throws OAuth2ServiceException { - when(tokenKeyServiceMock - .retrieveTokenKeys(any(), any(), any())).thenThrow(OAuth2ServiceException.class); + when(tokenKeyServiceMock.retrieveTokenKeys(any(), anyMap())).thenThrow(OAuth2ServiceException.class); - ValidationResult result = cut.validate(iasToken.getTokenValue(), "RS256", "default-kid-ias", - "http://unavailable.com/token_keys", null, null); + ValidationResult result = cut.validate(iasToken); assertThat(result.isErroneous(), is(true)); - assertThat(result.getErrorDescription(), - containsString("Error retrieving Json Web Keys from Identity Service")); + assertThat(result.getErrorDescription(), containsString("JWKS could not be fetched")); } } diff --git a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/XsaJwtSignatureValidatorTest.java b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/XsaJwtSignatureValidatorTest.java index cf4fbbdd6..433ae4f09 100644 --- a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/XsaJwtSignatureValidatorTest.java +++ b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/XsaJwtSignatureValidatorTest.java @@ -22,7 +22,8 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; public class XsaJwtSignatureValidatorTest { @@ -50,10 +51,10 @@ public void setup() throws IOException { tokenKeyServiceMock = Mockito.mock(OAuth2TokenKeyService.class); when(tokenKeyServiceMock - .retrieveTokenKeys(eq(JKU_URI), any(), isNull())) + .retrieveTokenKeys(eq(JKU_URI), anyMap())) .thenReturn(IOUtils.resourceToString("/jsonWebTokenKeys.json", UTF_8)); - cut = new JwtSignatureValidator( + cut = new XsuaaJwtSignatureValidator( mockConfiguration, OAuth2TokenKeyServiceWithCache.getInstance().withTokenKeyService(tokenKeyServiceMock), OidcConfigurationServiceWithCache.getInstance() diff --git a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/XsuaaJwtSignatureValidatorTest.java b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/XsuaaJwtSignatureValidatorTest.java index d8e1285c2..99bde9e15 100644 --- a/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/XsuaaJwtSignatureValidatorTest.java +++ b/java-security/src/test/java/com/sap/cloud/security/token/validation/validators/XsuaaJwtSignatureValidatorTest.java @@ -13,6 +13,7 @@ import com.sap.cloud.security.token.validation.ValidationResult; import com.sap.cloud.security.xsuaa.client.OAuth2TokenKeyService; import com.sap.cloud.security.xsuaa.client.OidcConfigurationService; +import com.sap.cloud.security.xsuaa.http.HttpHeaders; import org.apache.commons.io.IOUtils; import org.junit.Before; import org.junit.Test; @@ -20,13 +21,13 @@ import java.io.IOException; import java.net.URI; +import java.util.Collections; import static java.nio.charset.StandardCharsets.UTF_8; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.when; public class XsuaaJwtSignatureValidatorTest { @@ -59,10 +60,10 @@ public void setup() throws IOException { tokenKeyServiceMock = Mockito.mock(OAuth2TokenKeyService.class); when(tokenKeyServiceMock .retrieveTokenKeys(eq(URI.create("https://authentication.stagingaws.hanavlab.ondemand.com/token_keys")), - isNull(), isNull())) + eq(Collections.singletonMap(HttpHeaders.X_ZID, "uaa")))) .thenReturn(IOUtils.resourceToString("/jsonWebTokenKeys.json", UTF_8)); - cut = new JwtSignatureValidator( + cut = new XsuaaJwtSignatureValidator( mockConfiguration, OAuth2TokenKeyServiceWithCache.getInstance().withTokenKeyService(tokenKeyServiceMock), OidcConfigurationServiceWithCache.getInstance() @@ -84,8 +85,7 @@ public void validationFails_whenNoJkuHeaderButIssuerIsGiven() throws IOException Token tokenWithoutJkuButIssuer = new SapIdToken(IOUtils.resourceToString("/iasOidcTokenRSA256.txt", UTF_8)); ValidationResult result = cut.validate(tokenWithoutJkuButIssuer); assertThat(result.isErroneous(), is(true)); - assertThat(result.getErrorDescription(), - containsString("Token does not provide the required 'jku' header or issuer claim.")); + assertThat(result.getErrorDescription(), containsString("Token does not contain the mandatory " + JsonWebKeyConstants.JKU_PARAMETER_NAME + " header")); } @Test @@ -111,8 +111,7 @@ public void validationFails_whenVerificationkeyIsInvalid() { ValidationResult result = cut.validate(xsuaaTokenSignedWithVerificationKey); assertThat(result.isErroneous(), is(true)); - assertThat(result.getErrorDescription(), - containsString("Fallback with configured 'verificationkey' was not successful.")); + assertThat(result.getErrorDescription(), containsString("Fallback validation key")); } @Test @@ -123,10 +122,8 @@ public void validationFails_whenSignatureOfGeneratedTokenDoesNotMatchVerificatio ValidationResult result = cut.validate(xsuaaTokenSignedWithVerificationKey); assertThat(result.isErroneous(), is(true)); - assertThat(result.getErrorDescription(), - containsString("Signature of Jwt Token is not valid")); - assertThat(result.getErrorDescription(), - containsString("(Signature: CetA62rQSNRj93S9mqaHrKJyzONKeEKcEJ9O5wObRD_")); + assertThat(result.getErrorDescription(), containsString("Signature of Jwt Token is not valid")); + assertThat(result.getErrorDescription(), containsString("(Signature: CetA62rQSNRj93S9mqaHrKJyzONKeEKcEJ9O5wObRD_")); } } 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 fa5ad378a..ea53115f2 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 @@ -1,6 +1,6 @@ /** * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors - *

+ *

* SPDX-License-Identifier: Apache-2.0 */ package com.sap.cloud.security.xsuaa.client; @@ -11,23 +11,21 @@ import org.apache.http.Header; import org.apache.http.HttpHeaders; import org.apache.http.HttpStatus; -import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.util.EntityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; -import javax.annotation.Nullable; import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Map; import java.util.stream.Collectors; -import static com.sap.cloud.security.xsuaa.http.HttpHeaders.X_APP_TID; -import static com.sap.cloud.security.xsuaa.http.HttpHeaders.X_CLIENT_ID; - public class DefaultOAuth2TokenKeyService implements OAuth2TokenKeyService { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultOAuth2TokenKeyService.class); @@ -44,40 +42,35 @@ public DefaultOAuth2TokenKeyService(@Nonnull CloseableHttpClient httpClient) { } @Override - public String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri, @Nullable String tenantId) - throws OAuth2ServiceException { - return retrieveTokenKeys(tokenKeysEndpointUri, tenantId, null); - } - - @Override - public String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri, @Nullable String tenantId, @Nullable String clientId) throws OAuth2ServiceException { + public String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri, Map params) throws OAuth2ServiceException { Assertions.assertNotNull(tokenKeysEndpointUri, "Token key endpoint must not be null!"); - HttpUriRequest request = new HttpGet(tokenKeysEndpointUri); // lgtm[java/ssrf] tokenKeysEndpointUri is validated - // as part of XsuaaJkuValidator in java-security - if (tenantId != null && clientId != null) { - request.addHeader(X_APP_TID, tenantId); - request.addHeader(X_CLIENT_ID, clientId); + HttpUriRequest request = new HttpGet(tokenKeysEndpointUri); + + for(Map.Entry p : params.entrySet()) { + request.addHeader(p.getKey(), p.getValue()); } request.addHeader(HttpHeaders.USER_AGENT, HttpClientUtil.getUserAgent()); LOGGER.debug("Executing token key retrieval GET request to {} with headers: {} ", tokenKeysEndpointUri, request.getAllHeaders()); - try (CloseableHttpResponse response = httpClient.execute(request)) { - String body = HttpClientUtil.extractResponseBodyAsString(response); - int statusCode = response.getStatusLine().getStatusCode(); - if (statusCode == HttpStatus.SC_OK) { - LOGGER.debug("Successfully retrieved token keys from {} for tenant '{}'", tokenKeysEndpointUri, tenantId); + try { + return httpClient.execute(request, response -> { + int statusCode = response.getStatusLine().getStatusCode(); + LOGGER.debug("Received statusCode {}", statusCode); + String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + if (statusCode != HttpStatus.SC_OK) { + throw OAuth2ServiceException.builder("Error retrieving token keys. Request headers " + Arrays.stream(request.getAllHeaders()).collect(Collectors.toList())) + .withUri(tokenKeysEndpointUri) + .withHeaders(response.getAllHeaders() != null ? + Arrays.stream(response.getAllHeaders()).map(Header::toString).toArray(String[]::new) : null) + .withStatusCode(statusCode) + .withResponseBody(body) + .build(); + } + + LOGGER.debug("Successfully retrieved token keys from {} with params {}.", tokenKeysEndpointUri, params); return body; - } else { - throw OAuth2ServiceException.builder("Error retrieving token keys. Request headers " + Arrays.stream(request.getAllHeaders()).collect( - Collectors.toList())) - .withUri(tokenKeysEndpointUri) - .withHeaders(response.getAllHeaders() != null ? - Arrays.stream(response.getAllHeaders()).map(Header::toString).toArray(String[]::new) : null) - .withStatusCode(statusCode) - .withResponseBody(body) - .build(); - } + }); } catch (IOException e) { if (e instanceof OAuth2ServiceException) { throw (OAuth2ServiceException) e; diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenKeyService.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenKeyService.java index 47ff3575d..98d93c279 100644 --- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenKeyService.java +++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenKeyService.java @@ -5,9 +5,14 @@ */ package com.sap.cloud.security.xsuaa.client; +import com.sap.cloud.security.xsuaa.http.HttpHeaders; + import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; /** * Service that targets Identity service (xsuaa and identity) to request Json @@ -39,9 +44,12 @@ default String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri) throws OAuth * @throws OAuth2ServiceException * in case of an error during the http request. */ - String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri, @Nullable String tenantId) throws OAuth2ServiceException; + default String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri, @Nullable String tenantId) throws OAuth2ServiceException { + return retrieveTokenKeys(tokenKeysEndpointUri, Collections.singletonMap(HttpHeaders.X_APP_TID, tenantId)); + } /** + * @deprecated Use {@link OAuth2TokenKeyService#retrieveTokenKeys(URI, Map)} instead * Requests token web key set from IAS OAuth Server. * * @param tokenKeysEndpointUri @@ -57,7 +65,22 @@ default String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri) throws OAuth * @throws OAuth2ServiceException * in case of an error during the http request. */ + @Deprecated default String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri, @Nullable String tenantId, @Nullable String clientId) throws OAuth2ServiceException { - return retrieveTokenKeys(tokenKeysEndpointUri, tenantId); + Map params = new HashMap<>(2, 1); + params.put(HttpHeaders.X_APP_TID, tenantId); + params.put(HttpHeaders.X_CLIENT_ID, clientId); + + return retrieveTokenKeys(tokenKeysEndpointUri, params); } + + /** + * Retrieves the JWKS (JSON Web Key Set) from the OAuth2 Server. + * + * @param tokenKeysEndpointUri the JWKS endpoint URI. + * @param params additional header parameters that are sent along with the request. Use constants from {@link HttpHeaders} for the header keys. + * @return a JWKS in JSON format. + * @throws OAuth2ServiceException in case of an error during the http request. + */ + String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri, Map params) throws OAuth2ServiceException; } 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 41432d179..4244e0631 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 @@ -14,13 +14,11 @@ import org.springframework.web.client.RestOperations; import javax.annotation.Nonnull; -import javax.annotation.Nullable; import java.net.URI; import java.util.Collections; +import java.util.Map; import java.util.stream.Collectors; -import static com.sap.cloud.security.xsuaa.http.HttpHeaders.X_APP_TID; -import static com.sap.cloud.security.xsuaa.http.HttpHeaders.X_CLIENT_ID; import static org.springframework.http.HttpMethod.GET; public class SpringOAuth2TokenKeyService implements OAuth2TokenKeyService { @@ -35,27 +33,22 @@ public SpringOAuth2TokenKeyService(@Nonnull RestOperations restOperations) { } @Override - public String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri, @Nullable String tenantId) - throws OAuth2ServiceException { - return retrieveTokenKeys(tokenKeysEndpointUri, tenantId, null); - } - - @Override - public String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri, @Nullable String tenantId, @Nullable String clientId) + public String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri, Map params) throws OAuth2ServiceException { Assertions.assertNotNull(tokenKeysEndpointUri, "Token key endpoint must not be null!"); HttpHeaders headers = new HttpHeaders(); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); headers.set(HttpHeaders.USER_AGENT, HttpClientUtil.getUserAgent()); - if (tenantId != null && clientId != null) { - headers.set(X_APP_TID, tenantId); - headers.set(X_CLIENT_ID, clientId); + + for(Map.Entry p : params.entrySet()) { + headers.set(p.getKey(), p.getValue()); } + try { ResponseEntity 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 tenant '{}'", tokenKeysEndpointUri, tenantId); + LOGGER.debug("Successfully retrieved token keys from {} for params '{}'", tokenKeysEndpointUri, params); return response.getBody(); } else { throw OAuth2ServiceException.builder( @@ -66,7 +59,7 @@ public String retrieveTokenKeys(@Nonnull URI tokenKeysEndpointUri, @Nullable Str .withHeaders(response.getHeaders().size() != 0 ? response.getHeaders().entrySet().stream().map( h -> h.getKey() + ": " + String.join(",", h.getValue())) .toArray(String[]::new) : null) - .withStatusCode(response.getStatusCodeValue()) + .withStatusCode(response.getStatusCode().value()) .withResponseBody(response.getBody()) .build(); } 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 fac470e71..1d36e5e3c 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 @@ -29,6 +29,7 @@ public class HttpHeaders { public static final String X_ZONE_UUID = "x-zone_uuid"; public static final String X_APP_TID = "x-app_tid"; public static final String X_CLIENT_ID = "x-client_id"; + public static final String X_AZP = "x-azp"; private final Set headers; diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenKeyServiceTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenKeyServiceTest.java index 8978f7bc4..733106cd6 100644 --- a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenKeyServiceTest.java +++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenKeyServiceTest.java @@ -1,6 +1,6 @@ /** * SPDX-FileCopyrightText: 2018-2023 SAP SE or an SAP affiliate company and Cloud Security Client Java contributors - *

+ *

* SPDX-License-Identifier: Apache-2.0 */ package com.sap.cloud.security.xsuaa.client; @@ -9,6 +9,7 @@ import com.sap.cloud.security.xsuaa.util.HttpClientTestFactory; import org.apache.commons.io.IOUtils; import org.apache.http.HttpStatus; +import org.apache.http.client.ResponseHandler; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.impl.client.CloseableHttpClient; @@ -21,10 +22,11 @@ import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; -import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.times; @@ -35,6 +37,9 @@ public class DefaultOAuth2TokenKeyServiceTest { public static final URI TOKEN_KEYS_ENDPOINT_URI = URI.create("https://tokenKeys.io/token_keys"); public static final String APP_TID = "92768714-4c2e-4b79-bc1b-009a4127ee3c"; public static final String CLIENT_ID = "client-id"; + public static final String AZP = "azp"; + private static final Map PARAMS = new HashMap<>(3, 1); + private final String jsonWebKeysAsString; private DefaultOAuth2TokenKeyService cut; @@ -46,6 +51,9 @@ public DefaultOAuth2TokenKeyServiceTest() throws IOException { @Before public void setUp() { + PARAMS.put(HttpHeaders.X_APP_TID, APP_TID); + PARAMS.put(HttpHeaders.X_CLIENT_ID, CLIENT_ID); + PARAMS.put(HttpHeaders.X_AZP, AZP); httpClient = Mockito.mock(CloseableHttpClient.class); cut = new DefaultOAuth2TokenKeyService(httpClient); } @@ -61,46 +69,35 @@ public void retrieveTokenKeysForZone_responseNotOk_throwsException() throws IOEx String errorDescription = "Something went wrong"; CloseableHttpResponse response = HttpClientTestFactory .createHttpResponse(errorDescription, HttpStatus.SC_BAD_REQUEST); - when(httpClient.execute(any())).thenReturn(response); + when(httpClient.execute(any(), any(ResponseHandler.class))).thenAnswer(invocation -> { + ResponseHandler responseHandler = invocation.getArgument(1); + return responseHandler.handleResponse(response); + }); - assertThatThrownBy(() -> cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, APP_TID, CLIENT_ID)) + assertThatThrownBy(() -> cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, PARAMS)) .isInstanceOf(OAuth2ServiceException.class) .hasMessageContaining(errorDescription) - .hasMessageContaining("Request headers [x-app_tid: 92768714-4c2e-4b79-bc1b-009a4127ee3c, x-client_id: client-id, User-Agent: token-client/") + .hasMessageContaining("Request headers [") + .hasMessageContaining("x-app_tid: 92768714-4c2e-4b79-bc1b-009a4127ee3c") + .hasMessageContaining("x-client_id: client-id") + .hasMessageContaining("x-azp: azp") .hasMessageContaining("'Something went wrong'") .hasMessageContaining("Error retrieving token keys") .hasMessageContaining("Response Headers [testHeader: testValue]") .hasMessageContaining("Http status code 400"); } - @Test - public void retrieveTokenKeys_responseNotOk_throwsException_noAppTid() throws IOException { - String errorDescription = "Something went wrong"; - CloseableHttpResponse response = HttpClientTestFactory - .createHttpResponse(errorDescription, HttpStatus.SC_BAD_REQUEST); - when(httpClient.execute(any())).thenReturn(response); - - OAuth2ServiceException e = assertThrows(OAuth2ServiceException.class, - () -> cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, null, CLIENT_ID)); - - assertThat(e.getHeaders()).contains("testHeader: testValue"); - assertThat(e.getHttpStatusCode()).isEqualTo(400); - assertThat(e.getMessage()) - .contains(errorDescription) - .contains("Request headers [User-Agent: token-client/") - .contains("Response Headers [testHeader: testValue]") - .contains("Http status code 400") - .contains("Server URI https://tokenKeys.io/token_keys"); - } - @Test public void retrieveTokenKeys_responseNotOk_throwsException() throws IOException { String errorDescription = "Something went wrong"; CloseableHttpResponse response = HttpClientTestFactory .createHttpResponse(errorDescription, HttpStatus.SC_BAD_REQUEST); - when(httpClient.execute(any())).thenReturn(response); + when(httpClient.execute(any(), any(ResponseHandler.class))).thenAnswer(invocation -> { + ResponseHandler responseHandler = invocation.getArgument(1); + return responseHandler.handleResponse(response); + }); - assertThatThrownBy(() -> cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, null, null)) + assertThatThrownBy(() -> cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, Collections.emptyMap())) .isInstanceOf(OAuth2ServiceException.class) .hasMessageContaining(errorDescription) .hasMessageContaining("'Something went wrong'") @@ -109,37 +106,43 @@ public void retrieveTokenKeys_responseNotOk_throwsException() throws IOException @Test public void retrieveTokenKeys_tokenEndpointUriIsNull_throwsException() { - assertThatThrownBy(() -> cut.retrieveTokenKeys(null, APP_TID, null)) + assertThatThrownBy(() -> cut.retrieveTokenKeys(null, PARAMS)) .isInstanceOf(IllegalArgumentException.class); } @Test public void retrieveTokenKeys_errorOccurs_throwsServiceException() throws IOException { String errorMessage = "useful error message"; - when(httpClient.execute(any())).thenThrow(new IOException(errorMessage)); + when(httpClient.execute(any(), any(ResponseHandler.class))).thenThrow(new IOException(errorMessage)); - assertThatThrownBy(() -> cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, APP_TID, CLIENT_ID)) + assertThatThrownBy(() -> cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, PARAMS)) .isInstanceOf(OAuth2ServiceException.class) .hasMessageContaining(errorMessage); } @Test - public void retrieveTokenKeys_executesHttpGetRequestWithCorrectURI() throws IOException { + public void retrieveTokenKeys_executesCorrectHttpGetRequest() throws IOException { CloseableHttpResponse response = HttpClientTestFactory.createHttpResponse(jsonWebKeysAsString); - when(httpClient.execute(any())).thenReturn(response); - cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, APP_TID, CLIENT_ID); + when(httpClient.execute(any(), any(ResponseHandler.class))).thenAnswer(invocation -> { + ResponseHandler responseHandler = invocation.getArgument(1); + return responseHandler.handleResponse(response); + }); + + cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, PARAMS); - Mockito.verify(httpClient, times(1)).execute(argThat(isHttpGetAndContainsCorrectURI())); + Mockito.verify(httpClient, times(1)).execute(argThat(isCorrectHttpGetRequest()), + any(ResponseHandler.class)); } - private ArgumentMatcher isHttpGetAndContainsCorrectURI() { + private ArgumentMatcher isCorrectHttpGetRequest() { return (httpGet) -> { boolean hasCorrectURI = httpGet.getURI().equals(TOKEN_KEYS_ENDPOINT_URI); boolean correctMethod = httpGet.getMethod().equals(HttpMethod.GET.toString()); boolean correctTenantHeader = httpGet.getFirstHeader(HttpHeaders.X_APP_TID).getValue().equals(APP_TID); boolean correctClientId = httpGet.getFirstHeader(HttpHeaders.X_CLIENT_ID).getValue().equals(CLIENT_ID); - return hasCorrectURI && correctMethod && correctTenantHeader && correctClientId; + boolean correctAzp = httpGet.getFirstHeader(HttpHeaders.X_AZP).getValue().equals(AZP); + return hasCorrectURI && correctMethod && correctTenantHeader && correctClientId && correctAzp; }; } } \ No newline at end of file diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyServiceTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyServiceTest.java index 2b52ffd6a..ab858774e 100644 --- a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyServiceTest.java +++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/SpringOAuth2TokenKeyServiceTest.java @@ -21,6 +21,8 @@ import java.io.IOException; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -35,6 +37,8 @@ public class SpringOAuth2TokenKeyServiceTest { public static final URI TOKEN_KEYS_ENDPOINT_URI = URI.create("https://token.endpoint.io/token_keys"); public static final String APP_TID = "92768714-4c2e-4b79-bc1b-009a4127ee3c"; public static final String CLIENT_ID = "client-id"; + public static final String AZP = "azp"; + private static final Map PARAMS = new HashMap<>(3, 1); private RestOperations restOperationsMock; private SpringOAuth2TokenKeyService cut; @@ -47,6 +51,9 @@ public SpringOAuth2TokenKeyServiceTest() throws IOException { @Before public void setUp() { + PARAMS.put(HttpHeaders.X_APP_TID, APP_TID); + PARAMS.put(HttpHeaders.X_CLIENT_ID, CLIENT_ID); + PARAMS.put(HttpHeaders.X_AZP, AZP); restOperationsMock = mock(RestOperations.class); cut = new SpringOAuth2TokenKeyService(restOperationsMock); } @@ -67,7 +74,7 @@ public void retrieveTokenKeys_endpointUriIsNull_throwsException() { public void retrieveTokenKeys_usesGivenURI() throws OAuth2ServiceException { mockResponse(); - cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, APP_TID, CLIENT_ID); + cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, PARAMS); Mockito.verify(restOperationsMock, times(1)) .exchange(eq(TOKEN_KEYS_ENDPOINT_URI), eq(GET), argThat(httpEntityContainsMandatoryHeaders()), @@ -80,13 +87,15 @@ public void retrieveTokenKeys_badResponse_throwsException() { mockResponse(errorMessage, HttpStatus.BAD_REQUEST); OAuth2ServiceException e = assertThrows(OAuth2ServiceException.class, - () -> cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, APP_TID, CLIENT_ID)); + () -> cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, PARAMS)); assertThat(e.getMessage()) .contains(TOKEN_KEYS_ENDPOINT_URI.toString()) .contains(String.valueOf(HttpStatus.BAD_REQUEST.value())) .contains("Request headers [Accept: application/json, User-Agent: token-client/") - .contains("x-app_tid: 92768714-4c2e-4b79-bc1b-009a4127ee3c, x-client_id: client-id]") + .contains("x-app_tid: 92768714-4c2e-4b79-bc1b-009a4127ee3c") + .contains("x-client_id: client-id") + .contains("x-azp: azp") .contains("Response Headers ") .contains(errorMessage); assertThat(e.getHttpStatusCode()).isEqualTo(400); @@ -94,21 +103,6 @@ public void retrieveTokenKeys_badResponse_throwsException() { assertThat(e.getHeaders()).contains("Content-Type: application/json"); } - @Test - public void retrieveTokenKeys_badResponse_noAppTid() { - String errorMessage = "useful error message"; - mockResponse(errorMessage, HttpStatus.BAD_REQUEST); - - OAuth2ServiceException e = assertThrows(OAuth2ServiceException.class, - () -> cut.retrieveTokenKeys(TOKEN_KEYS_ENDPOINT_URI, null, CLIENT_ID)); - - assertThat(e.getMessage()) - .contains(TOKEN_KEYS_ENDPOINT_URI.toString()) - .contains(String.valueOf(HttpStatus.BAD_REQUEST.value())) - .contains("Request headers [Accept: application/json, User-Agent: token-client/") //if app_tid is missing the x-app_tid and x-client_id headers shouldn't be sent - .contains(errorMessage); - } - private void mockResponse() { mockResponse(jsonWebKeysAsString, HttpStatus.OK); } @@ -125,7 +119,8 @@ private ArgumentMatcher httpEntityContainsMandatoryHeaders() { return (httpGet) -> { boolean correctClientId = httpGet.getHeaders().get(HttpHeaders.X_CLIENT_ID).get(0).equals(CLIENT_ID); boolean correctAppTid = httpGet.getHeaders().get(HttpHeaders.X_APP_TID).get(0).equals(APP_TID); - return correctAppTid && correctClientId; + boolean correctAzp = httpGet.getHeaders().get(HttpHeaders.X_AZP).get(0).equals(AZP); + return correctAppTid && correctClientId && correctAzp; }; } } \ No newline at end of file