diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 14d6672469..7659b5e199 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -104,9 +104,9 @@ jobs: env: APIML_SECURITY_OIDC_CLIENTID: ${{ secrets.OKTA_CLIENT_ID }} APIML_SECURITY_OIDC_CLIENTSECRET: ${{ secrets.OKTA_CLIENT_PASSWORD }} - APIML_SECURITY_OIDC_INTROSPECTURL: ${{ secrets.OKTA_INTROSPECT_URL }} APIML_SECURITY_OIDC_ENABLED: true APIML_SECURITY_OIDC_REGISTRY: zowe.okta.com + APIML_SECURITY_OIDC_JWKS_URI: ${{ secrets.OKTA_JWK_URI }} APIML_SECURITY_OIDC_IDENTITYMAPPERUSER: APIMTST APIML_SECURITY_OIDC_IDENTITYMAPPERURL: https://gateway-service:10010/zss/api/v1/certificate/dn discovery-service: diff --git a/config/docker/gateway-service.yml b/config/docker/gateway-service.yml index 3de27bd441..7d42310843 100644 --- a/config/docker/gateway-service.yml +++ b/config/docker/gateway-service.yml @@ -11,10 +11,11 @@ apiml: enabled: true clientId: clientSecret: - introspectUrl: registry: identityMapperUrl: identityMapperUser: + jwks: + uri: auth: zosmf: serviceId: mockzosmf # Replace me with the correct z/OSMF service id diff --git a/config/local/gateway-service.yml b/config/local/gateway-service.yml index e2c4348c08..6383a69bb8 100644 --- a/config/local/gateway-service.yml +++ b/config/local/gateway-service.yml @@ -18,10 +18,11 @@ apiml: enabled: false clientId: clientSecret: - introspectUrl: registry: identityMapperUrl: identityMapperUser: + jwks: + uri: auth: jwt: customAuthHeader: diff --git a/gateway-package/src/main/resources/bin/start.sh b/gateway-package/src/main/resources/bin/start.sh index 9e6cf90389..aedecf2c2c 100755 --- a/gateway-package/src/main/resources/bin/start.sh +++ b/gateway-package/src/main/resources/bin/start.sh @@ -49,10 +49,11 @@ # - ZWE_configs_apiml_security_oidc_enabled # - ZWE_configs_apiml_security_oidc_clientId # - ZWE_configs_apiml_security_oidc_clientSecret -# - ZWE_configs_apiml_security_oidc_introspectUrl # - ZWE_configs_apiml_security_oidc_registry # - ZWE_configs_apiml_security_oidc_identityMapperUrl # - ZWE_configs_apiml_security_oidc_identityMapperUser +# - ZWE_configs_apiml_security_oidc_jwks_uri +# - ZWE_configs_apiml_security_oidc_jwks_refreshInternalHours # - ZWE_configs_apiml_service_allowEncodedSlashes - Allows encoded slashes on on URLs through gateway # - ZWE_configs_apiml_service_centralRegistryUrls - List of additional Discovery Services URLs to register with # - ZWE_configs_apiml_service_corsEnabled @@ -269,10 +270,11 @@ _BPX_JOBNAME=${ZWE_zowe_job_prefix}${GATEWAY_CODE} java \ -Dapiml.security.oidc.enabled=${ZWE_configs_apiml_security_oidc_enabled:-false} \ -Dapiml.security.oidc.clientId=${ZWE_configs_apiml_security_oidc_clientId:-} \ -Dapiml.security.oidc.clientSecret=${ZWE_configs_apiml_security_oidc_clientSecret:-} \ - -Dapiml.security.oidc.introspectUrl=${ZWE_configs_apiml_security_oidc_introspectUrl:-} \ -Dapiml.security.oidc.registry=${ZWE_configs_apiml_security_oidc_registry:-} \ -Dapiml.security.oidc.identityMapperUrl=${ZWE_configs_apiml_security_oidc_identityMapperUrl:-"https://${ZWE_haInstance_hostname:-localhost}:${ZWE_configs_port:-7554}/zss/api/v1/certificate/dn"} \ -Dapiml.security.oidc.identityMapperUser=${ZWE_configs_apiml_security_oidc_identityMapperUser:-${ZWE_zowe_setup_security_users_zowe:-ZWESVUSR}} \ + -Dapiml.security.oidc.jwks.uri=${ZWE_configs_apiml_security_oidc_jwks_uri} \ + -Dapiml.security.oidc.jwks.refreshInternalHours=${ZWE_configs_apiml_security_oidc_jwks_refreshInternalHours:-1} \ -Djava.protocol.handler.pkgs=com.ibm.crypto.provider \ -Dloader.path=${GATEWAY_LOADER_PATH} \ -Djava.library.path=${LIBPATH} \ diff --git a/gateway-package/src/main/resources/manifest.yaml b/gateway-package/src/main/resources/manifest.yaml index 2bca9e52e4..e9c2dd7545 100644 --- a/gateway-package/src/main/resources/manifest.yaml +++ b/gateway-package/src/main/resources/manifest.yaml @@ -63,7 +63,6 @@ configs: enabled: false clientId: clientSecret: - introspectUrl: registry: # default value is https://${ZWE_haInstance_hostname:-localhost}:${ZWE_configs_port}/zss/api/v1/certificate/dn identityMapperUrl: diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/cache/ServiceCacheEvictor.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/cache/ServiceCacheEvictor.java index fc0a663103..7e9acfc9cc 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/cache/ServiceCacheEvictor.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/cache/ServiceCacheEvictor.java @@ -76,7 +76,6 @@ public void evict() { serviceCacheEvicts.forEach(x -> x.evictCacheService(serviceId)); } - } } diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/ApimlAccessTokenProvider.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/ApimlAccessTokenProvider.java index d73263b1e3..a4de9ddc25 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/ApimlAccessTokenProvider.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/ApimlAccessTokenProvider.java @@ -12,9 +12,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.zowe.apiml.gateway.cache.CachingServiceClient; import org.zowe.apiml.gateway.cache.CachingServiceClientException; @@ -40,18 +40,16 @@ @Slf4j public class ApimlAccessTokenProvider implements AccessTokenProvider { - - private final CachingServiceClient cachingServiceClient; - private final AuthenticationService authenticationService; - private static final ObjectMapper objectMapper = new ObjectMapper(); - private byte[] salt; static final String INVALID_TOKENS_KEY = "invalidTokens"; static final String INVALID_USERS_KEY = "invalidUsers"; static final String INVALID_SCOPES_KEY = "invalidScopes"; - static { - objectMapper.registerModule(new JavaTimeModule()); - } + private final CachingServiceClient cachingServiceClient; + private final AuthenticationService authenticationService; + @Qualifier("oidcMapper") + private final ObjectMapper objectMapper; + + private byte[] salt; public void invalidateToken(String token) throws CachingServiceClientException, JsonProcessingException { String hashedValue = getHash(token); diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/JwkKeys.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/JwkKeys.java new file mode 100644 index 0000000000..a613948374 --- /dev/null +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/JwkKeys.java @@ -0,0 +1,60 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.security.service.token; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class JwkKeys { + + private List keys; + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Key { + + // Cryptographic algorithm family for the certificate's Key pair. i.e. RSA + @JsonProperty("kty") + private String kty; + + // The algorithm used with the Key. i.e. RS256 + @JsonProperty("alg") + private String alg; + + // The certificate's Key ID + @JsonProperty("kid") + private String kid; + + // How the Key is used. i.e. sig + @JsonProperty("use") + private String use; + + // RSA Key value (exponent) for Key blinding + @JsonProperty("e") + private String e; + + // RSA modulus value + @JsonProperty("n") + private String n; + + } + +} diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/OIDCConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/OIDCConfig.java new file mode 100644 index 0000000000..4aa81a36fe --- /dev/null +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/OIDCConfig.java @@ -0,0 +1,36 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.gateway.security.service.token; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.jsonwebtoken.Clock; +import io.jsonwebtoken.impl.DefaultClock; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OIDCConfig { + + @Bean + public Clock clock() { + return new DefaultClock(); + } + + @Bean + @Qualifier("oidcMapper") + public ObjectMapper mapper() { + return new ObjectMapper() + .registerModule(new JavaTimeModule()); + } + +} diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/OIDCTokenProvider.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/OIDCTokenProvider.java index ed303e374a..c47704e33a 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/OIDCTokenProvider.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/service/token/OIDCTokenProvider.java @@ -12,35 +12,49 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Clock; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.http.HttpEntity; import org.apache.http.HttpStatus; -import org.apache.http.NameValuePair; -import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.message.BasicHeader; -import org.apache.http.message.BasicNameValuePair; import org.apache.http.util.EntityUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; +import org.zowe.apiml.message.core.MessageType; +import org.zowe.apiml.message.log.ApimlLogger; +import org.zowe.apiml.product.logging.annotations.InjectApimlLogger; import org.zowe.apiml.security.common.token.OIDCProvider; -import org.zowe.apiml.util.UrlUtils; +import org.zowe.apiml.security.common.token.TokenNotValidException; + +import javax.annotation.PostConstruct; import java.io.IOException; +import java.math.BigInteger; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Base64; -import java.util.List; +import java.security.Key; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; @RequiredArgsConstructor @Service @@ -48,8 +62,11 @@ @ConditionalOnProperty(value = "apiml.security.oidc.enabled", havingValue = "true") public class OIDCTokenProvider implements OIDCProvider { - @Value("${apiml.security.oidc.introspectUrl:}") - String introspectUrl; + @InjectApimlLogger + protected final ApimlLogger logger = ApimlLogger.empty(); + + @Value("${apiml.security.oidc.registry:}") + String registry; @Value("${apiml.security.oidc.clientId:}") String clientId; @@ -57,50 +74,43 @@ public class OIDCTokenProvider implements OIDCProvider { @Value("${apiml.security.oidc.clientSecret:}") String clientSecret; + @Value("${apiml.security.oidc.jwks.uri}") + private String jwksUri; + + @Value("${apiml.security.oidc.jwks.refreshInternalHours:1}") + private int jwkRefreshInterval; + @Autowired @Qualifier("secureHttpClientWithoutKeystore") @NonNull private final CloseableHttpClient httpClient; - private static final ObjectMapper mapper = new ObjectMapper(); + @Autowired + private final Clock clock; - @Override - public boolean isValid(String token) { - OIDCTokenClaims claims = introspect(token); - if (claims != null) { - return claims.getActive(); - } - return false; + @Autowired + @Qualifier("oidcMapper") + private final ObjectMapper mapper; + + private Map jwks = new ConcurrentHashMap<>(); + + @PostConstruct + public void afterPropertiesSet() { + this.fetchJwksUrls(); + Executors.newSingleThreadScheduledExecutor(r -> new Thread("OIDC JWK Refresh")) + .scheduleAtFixedRate(this::fetchJwksUrls , jwkRefreshInterval, jwkRefreshInterval, TimeUnit.HOURS); } - private OIDCTokenClaims introspect(String token) { - if (StringUtils.isBlank(token)) { - log.debug("No token has been provided."); - return null; + @Retryable + void fetchJwksUrls() { + if (StringUtils.isBlank(jwksUri)) { + log.debug("OIDC JWK URI not provided, JWK refresh not performed"); + return; } - if (StringUtils.isBlank(introspectUrl) || !UrlUtils.isValidUrl(introspectUrl)) { - log.warn("Missing or invalid introspectUrl configuration. Cannot proceed with token validation."); - return null; - } - if (StringUtils.isBlank(clientId) || StringUtils.isBlank(clientSecret)) { - log.warn("Missing clientId or clientSecret configuration. Cannot proceed with token validation."); - return null; - } - HttpPost post = new HttpPost(introspectUrl); - List bodyParams = new ArrayList<>(); - bodyParams.add(new BasicNameValuePair("token", token)); - bodyParams.add(new BasicNameValuePair("token_type_hint", "access_token")); - post.setEntity(new UrlEncodedFormEntity(bodyParams, StandardCharsets.UTF_8)); - - String credentials = clientId + ":" + clientSecret; - byte[] base64encoded = Base64.getEncoder().encode(credentials.getBytes()); - final String headerValue = "Basic " + new String(base64encoded); - post.setHeader(new BasicHeader(HttpHeaders.AUTHORIZATION, headerValue)); - post.setHeader(new BasicHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)); - post.setHeader(new BasicHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)); - + log.debug("Refreshing JWK endpoints {}", jwksUri); + HttpGet getRequest = new HttpGet(jwksUri + "?client_id=" + clientId); try { - CloseableHttpResponse response = httpClient.execute(post); + CloseableHttpResponse response = httpClient.execute(getRequest); final int statusCode = response.getStatusLine() != null ? response.getStatusLine().getStatusCode() : 0; final HttpEntity responseEntity = response.getEntity(); String responseBody = ""; @@ -108,15 +118,77 @@ private OIDCTokenClaims introspect(String token) { responseBody = EntityUtils.toString(responseEntity, StandardCharsets.UTF_8); } if (statusCode == HttpStatus.SC_OK && !responseBody.isEmpty()) { - return mapper.readValue(responseBody, OIDCTokenClaims.class); + jwks.clear(); + JwkKeys jwkKeys = mapper.readValue(responseBody, JwkKeys.class); + jwks.putAll(processKeys(jwkKeys)); } else { - log.error("Failed to validate the OIDC access token. Unexpected response: {}", statusCode); - return null; + log.error("Failed to obtain JWKs from URI {}. Unexpected response: {}, response text: {}", jwksUri, statusCode, responseBody); } - } catch (IOException e) { - log.error("Failed to validate the OIDC access token. ", e); + } catch (IOException | IllegalStateException e) { + log.error("Error processing response from URI {}", jwksUri, e.getMessage()); + } + } + + private Map processKeys(JwkKeys jwkKeys) { + return jwkKeys.getKeys().stream() + .filter(jwkKey -> "sig".equals(jwkKey.getUse())) + .filter(jwkKey -> "RSA".equals(jwkKey.getKty())) + .collect(Collectors.toMap(JwkKeys.Key::getKid, jwkKey -> { + BigInteger modulus = base64ToBigInteger(jwkKey.getN()); + BigInteger exponent = base64ToBigInteger(jwkKey.getE()); + RSAPublicKeySpec rsaPublicKeySpec = new RSAPublicKeySpec(modulus, exponent); + try { + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePublic(rsaPublicKeySpec); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new IllegalStateException("Failed to parse public key"); + } + })); + } + + private BigInteger base64ToBigInteger(String value) { + return new BigInteger(1, Decoders.BASE64URL.decode(value)); + } + + @Override + public boolean isValid(String token) { + if (StringUtils.isBlank(token)) { + log.debug("No token has been provided."); + return false; + } + String kid = getKeyId(token); + logger.log(MessageType.DEBUG, "Token signed by key {}", kid); + return Optional.ofNullable(jwks.get(kid)) + .map(key -> validate(token, key)) + .map(claims -> claims != null && !claims.isEmpty()) + .orElse(false); + } + + private String getKeyId(String token) { + try { + return String.valueOf(Jwts.parserBuilder() + .setClock(clock) + .build() + .parseClaimsJwt(token.substring(0, token.lastIndexOf('.') + 1)) + .getHeader() + .get("kid")); + } catch (JwtException e) { + log.error("OIDC Token is not valid: {}", e.getMessage()); + return ""; } - return null; } + private Claims validate(String token, Key key) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .setClock(clock) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (TokenNotValidException | JwtException e) { + log.debug("OIDC Token is not valid: {}", e.getMessage()); + return null; // NOSONAR + } + } } diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/webfinger/StaticWebFingerProvider.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/webfinger/StaticWebFingerProvider.java index 14c12e608e..63e3735620 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/webfinger/StaticWebFingerProvider.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/webfinger/StaticWebFingerProvider.java @@ -13,7 +13,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -25,7 +24,6 @@ @RequiredArgsConstructor @Service -@Slf4j public class StaticWebFingerProvider implements WebFingerProvider { @Value("${apiml.security.webfinger.fileLocation:-}") diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index eb1b64dea3..697840948c 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -74,10 +74,11 @@ apiml: enabled: false clientId: clientSecret: - introspectUrl: registry: identityMapperUrl: identityMapperUser: + jwks: + uri: auth: jwt: customAuthHeader: diff --git a/gateway-service/src/test/java/org/zowe/apiml/acceptance/config/GatewayOverrideConfig.java b/gateway-service/src/test/java/org/zowe/apiml/acceptance/config/GatewayOverrideConfig.java index 4e94c2fbc5..d1651bc09d 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/acceptance/config/GatewayOverrideConfig.java +++ b/gateway-service/src/test/java/org/zowe/apiml/acceptance/config/GatewayOverrideConfig.java @@ -10,6 +10,8 @@ package org.zowe.apiml.acceptance.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.apache.http.impl.client.CloseableHttpClient; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.context.TestConfiguration; @@ -32,10 +34,13 @@ import java.util.HashMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @TestConfiguration public class GatewayOverrideConfig { + protected static final String ZOSMF_CSRF_HEADER = "X-CSRF-ZOSMF-HEADER"; @Bean @@ -44,7 +49,6 @@ public ServiceRouteMapper serviceRouteMapper() { return new SimpleServiceRouteMapper(); } - @MockBean @Qualifier("mockProxy") public CloseableHttpClient mockProxy; @@ -64,7 +68,6 @@ public RestTemplate restTemplateWithoutKeystore() { return restTemplate; } - @Bean public SimpleRouteLocator simpleRouteLocator() { ZuulProperties properties = new ZuulProperties(); @@ -85,5 +88,11 @@ public ApplicationRegistry registry() { return applicationRegistry; } + @Bean + @Qualifier("oidcMapper") + public ObjectMapper mapper() { + return new ObjectMapper() + .registerModule(new JavaTimeModule()); + } } diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/security/service/token/ApimlAccessTokenProviderTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/security/service/token/ApimlAccessTokenProviderTest.java index 51179cc64b..32c7a107b2 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/security/service/token/ApimlAccessTokenProviderTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/security/service/token/ApimlAccessTokenProviderTest.java @@ -29,6 +29,9 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; class ApimlAccessTokenProviderTest { @@ -48,7 +51,7 @@ void setup() throws CachingServiceClientException { cachingServiceClient = mock(CachingServiceClient.class); as = mock(AuthenticationService.class); when(cachingServiceClient.read("salt")).thenReturn(new CachingServiceClient.KeyValue("salt", new String(ApimlAccessTokenProvider.generateSalt()))); - accessTokenProvider = new ApimlAccessTokenProvider(cachingServiceClient, as); + accessTokenProvider = new ApimlAccessTokenProvider(cachingServiceClient, as, new ObjectMapper().registerModule(new JavaTimeModule())); } @BeforeAll diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/security/service/token/OIDCTokenProviderTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/security/service/token/OIDCTokenProviderTest.java index f60b1beb07..bd31b0eeb9 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/security/service/token/OIDCTokenProviderTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/security/service/token/OIDCTokenProviderTest.java @@ -10,6 +10,9 @@ package org.zowe.apiml.gateway.security.service.token; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.impl.DefaultClock; +import io.jsonwebtoken.impl.FixedClock; import org.apache.commons.io.IOUtils; import org.apache.http.HttpStatus; import org.apache.http.StatusLine; @@ -20,95 +23,196 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EmptySource; import org.junit.jupiter.params.provider.NullSource; -import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import org.zowe.apiml.gateway.cache.CachingServiceClientException; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.time.Instant; +import java.util.Date; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +@ExtendWith(MockitoExtension.class) class OIDCTokenProviderTest { + private static final String JWKS_KEYS_BODY = "\n" + + "{\n" + + " \"keys\": [\n" + + " {\n" + + " \"kty\": \"RSA\",\n" + + " \"alg\": \"RS256\",\n" + + " \"kid\": \"Lcxckkor94qkrunxHP7Tkib547rzmkXvsYV-nc6U-N4\",\n" + + " \"use\": \"sig\",\n" + + " \"e\": \"AQAB\",\n" + + " \"n\": \"v6wT5k7uLto_VPTV8fW9_wRqWHuqnZbyEYAwNYRdffe9WowwnzUAr0Z93-4xDvCRuVfTfvCe9orEWdjZMaYlDq_Dj5BhLAqmBAF299Kv1GymOioLRDvoVWy0aVHYXXNaqJCPsaWIDiCly-_kJBbnda_rmB28a_878TNxom0mDQ20TI5SgdebqqMBOdHEqIYH1ER9euybekeqJX24EqE9YW4Yug5BOkZ9KcUkiEsH_NPyRlozihj18Qab181PRyKHE6M40W7w67XcRq2llTy-z9RrQupcyvLD7L62KN0ey8luKWnVg4uIOldpyBYyiRX2WPM-2K00RVC0e4jQKs34Gw\"\n" + + " },\n" + + " {\n" + + " \"kty\": \"RSA\",\n" + + " \"alg\": \"RS256\",\n" + + " \"kid\": \"-716sp3XBB_v30lGj2mu5MdXkdh8poa9zJQlAwC46n4\",\n" + + " \"use\": \"sig\",\n" + + " \"e\": \"AQAB\",\n" + + " \"n\": \"5rYyqFsxel0Pv-xRDHPbg3IfumE4ks9ffLvJrfZVgrTQyiFmFfBnyD3r7y6626Yr5-68Pj0I5SHlCBPkkgTU_e9Z3tCYiegtIOeJdSdumWR2JDVAsbpwFJDG_kxP9czgX7HL0T2BPSapx7ba0ZBXd2-SfSDDL-c1Q0rJ1uQEJwDXAGZV4qy_oXuQf5DuV65Xj8y2Qn1DtVEBThxita-kis_H35CTWgW2zyyaS_08wa00R98mnQ2SHfmO5fZABITmH0DO0coDHqKZ429VNNpELLX9e95dirQ1jfngDbBCmy-XsT8yc6NpAaXmd8P2NHdsO2oK46EQEaFRyMcoDTs3-w\"\n" + + " }\n" + + " ]\n" + + "}"; + + private static final String JWKS_KEYS_BODY_INVALID = "\n" + + "{\n" + + " \"keys\": [\n" + + " {\n" + + " \"kty\": \"RSA\",\n" + + " \"alg\": \"RS256\",\n" + + " \"kid\": \"Lcxckkor94qkrunxHP7Tkib547rzmkXvsYV-nc6U-N4\",\n" + + " \"use\": \"sig\",\n" + + " \"e\": \"AQAB\",\n" + + " \"n\": \"invalid\"\n" + + " }\n" + + " ]\n" + + "}"; + + private static final String EXPIRED_TOKEN = "eyJraWQiOiJMY3hja2tvcjk0cWtydW54SFA3VGtpYjU0N3J6bWtYdnNZVi1uYzZVLU40IiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULlExakp2UkZ0dUhFUFpGTXNmM3A0enQ5aHBRRHZrSU1CQ3RneU9IcTdlaEkiLCJpc3MiOiJodHRwczovL2Rldi05NTcyNzY4Ni5va3RhLmNvbS9vYXV0aDIvZGVmYXVsdCIsImF1ZCI6ImFwaTovL2RlZmF1bHQiLCJpYXQiOjE2OTcwNjA3NzMsImV4cCI6MTY5NzA2NDM3MywiY2lkIjoiMG9hNmE0OG1uaVhBcUVNcng1ZDciLCJ1aWQiOiIwMHU5OTExOGgxNmtQT1dBbTVkNyIsInNjcCI6WyJvcGVuaWQiXSwiYXV0aF90aW1lIjoxNjk3MDYwMDY0LCJzdWIiOiJzajg5NTA5MkBicm9hZGNvbS5uZXQiLCJncm91cHMiOlsiRXZlcnlvbmUiXX0.Cuf1JVq_NnfBxaCwiLsR5O6DBmVV1fj9utAfKWIF1hlek2hCJsDLQM4ii_ucQ0MM1V3nVE1ZatPB-W7ImWPlGz7NeNBv7jEV9DkX70hchCjPHyYpaUhAieTG75obdufiFpI55bz3qH5cPRvsKv0OKKI9T8D7GjEWsOhv6CevJJZZvgCFLGFfnacKLOY5fEBN82bdmCulNfPVrXF23rOregFjOBJ1cKWfjmB0UGWgI8VBGGemMNm3ACX3OYpTOek2PBfoCIZWOSGnLZumFTYA0F_3DsWYhIJNoFv16_EBBJcp_C0BYE_fiuXzeB0fieNUXASsKp591XJMflDQS_Zt1g"; + + private static final String TOKEN = "token"; + private OIDCTokenProvider oidcTokenProvider; + + @Mock + private OIDCTokenProvider underTest; + @Mock private CloseableHttpClient httpClient; + @Mock + private CloseableHttpResponse response; private StatusLine responseStatusLine; private BasicHttpEntity responseEntity; - private static final String BODY = "{\n" + - " \"active\": true,\n" + - " \"scope\": \"scope\",\n" + - " \"exp\": 1664538493,\n" + - " \"iat\": 1664534893,\n" + - " \"sub\": \"sub\",\n" + - " \"aud\": \"aud\",\n" + - " \"iss\": \"iss\",\n" + - " \"jti\": \"jti\",\n" + - " \"token_type\": \"Bearer\",\n" + - " \"client_id\": \"id\"\n" + - "}"; - - private static final String NOT_VALID_BODY = "{\n" + - " \"active\": false\n" + - "}"; - - private static final String TOKEN = "token"; - @BeforeEach void setup() throws CachingServiceClientException, IOException { - httpClient = mock(CloseableHttpClient.class); - CloseableHttpResponse response = mock(CloseableHttpResponse.class); responseStatusLine = mock(StatusLine.class); responseEntity = new BasicHttpEntity(); responseEntity.setContent(IOUtils.toInputStream("", StandardCharsets.UTF_8)); - when(responseStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_OK); - when(response.getStatusLine()).thenReturn(responseStatusLine); - when(response.getEntity()).thenReturn(responseEntity); - when(httpClient.execute(any())).thenReturn(response); - oidcTokenProvider = new OIDCTokenProvider(httpClient); - oidcTokenProvider.introspectUrl = "https://acme.com/introspect"; + oidcTokenProvider = new OIDCTokenProvider(httpClient, new DefaultClock(), new ObjectMapper()); + ReflectionTestUtils.setField(oidcTokenProvider, "jwkRefreshInterval",1); + ReflectionTestUtils.setField(oidcTokenProvider, "jwksUri", "https://jwksurl"); oidcTokenProvider.clientId = "client_id"; oidcTokenProvider.clientSecret = "client_secret"; } @Nested - class GivenTokenForValidation { + class GivenInitializationWithJwks { + + @BeforeEach + void setup() throws IOException { + responseEntity.setContent(IOUtils.toInputStream(JWKS_KEYS_BODY, StandardCharsets.UTF_8)); + } + @Test - void tokenIsActive_thenReturnValid() { - responseEntity.setContent(IOUtils.toInputStream(BODY, StandardCharsets.UTF_8)); - assertTrue(oidcTokenProvider.isValid(TOKEN)); + @SuppressWarnings("unchecked") + void initialized_thenJwksFullfilled() throws IOException { + Map jwks = (Map) ReflectionTestUtils.getField(oidcTokenProvider, "jwks"); + when(responseStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_OK); + when(response.getStatusLine()).thenReturn(responseStatusLine); + when(response.getEntity()).thenReturn(responseEntity); + when(httpClient.execute(any())).thenReturn(response); + oidcTokenProvider.afterPropertiesSet(); + assertFalse(jwks.isEmpty()); + assertTrue(jwks.containsKey("Lcxckkor94qkrunxHP7Tkib547rzmkXvsYV-nc6U-N4")); + assertTrue(jwks.containsKey("-716sp3XBB_v30lGj2mu5MdXkdh8poa9zJQlAwC46n4")); + assertNotNull(jwks.get("Lcxckkor94qkrunxHP7Tkib547rzmkXvsYV-nc6U-N4")); + assertInstanceOf(Key.class, jwks.get("Lcxckkor94qkrunxHP7Tkib547rzmkXvsYV-nc6U-N4")); } @Test - void tokenIsExpired_thenReturnInvalid() { - responseEntity.setContent(IOUtils.toInputStream(NOT_VALID_BODY, StandardCharsets.UTF_8)); - assertFalse(oidcTokenProvider.isValid(TOKEN)); + @SuppressWarnings("unchecked") + void whenRequestFails_thenNotInitialized() throws ClientProtocolException, IOException { + Map jwks = (Map) ReflectionTestUtils.getField(oidcTokenProvider, "jwks"); + when(responseStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_INTERNAL_SERVER_ERROR); + when(response.getStatusLine()).thenReturn(responseStatusLine); + when(response.getEntity()).thenReturn(responseEntity); + when(httpClient.execute(any())).thenReturn(response); + oidcTokenProvider.afterPropertiesSet(); + assertTrue(jwks.isEmpty()); } @Test - void whenClientThrowsException_thenReturnInvalid() throws IOException { - ClientProtocolException exception = new ClientProtocolException("http error"); - when(httpClient.execute(any())).thenThrow(exception); - assertFalse(oidcTokenProvider.isValid(TOKEN)); + @SuppressWarnings("unchecked") + void whenUriNotProvided_thenNotInitialized() { + ReflectionTestUtils.setField(oidcTokenProvider, "jwksUri", ""); + Map jwks = (Map) ReflectionTestUtils.getField(oidcTokenProvider, "jwks"); + oidcTokenProvider.afterPropertiesSet(); + assertTrue(jwks.isEmpty()); + } + + @Test + @SuppressWarnings("unchecked") + void whenInvalidKey_thenNotInitialized() throws ClientProtocolException, IOException { + responseEntity.setContent(IOUtils.toInputStream(JWKS_KEYS_BODY_INVALID, StandardCharsets.UTF_8)); + Map jwks = (Map) ReflectionTestUtils.getField(oidcTokenProvider, "jwks"); + when(responseStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_OK); + when(response.getStatusLine()).thenReturn(responseStatusLine); + when(response.getEntity()).thenReturn(responseEntity); + when(httpClient.execute(any())).thenReturn(response); + oidcTokenProvider.afterPropertiesSet(); + assertTrue(jwks.isEmpty()); + } + } + + @Nested + class GivenTokenForValidation { + + @SuppressWarnings("unchecked") + private void initJwks() throws ClientProtocolException, IOException { + Map jwks = (Map) ReflectionTestUtils.getField(oidcTokenProvider, "jwks"); + responseEntity.setContent(IOUtils.toInputStream(JWKS_KEYS_BODY, StandardCharsets.UTF_8)); + when(responseStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_OK); + when(response.getStatusLine()).thenReturn(responseStatusLine); + when(response.getEntity()).thenReturn(responseEntity); + when(httpClient.execute(any())).thenReturn(response); + oidcTokenProvider.afterPropertiesSet(); + assertFalse(jwks.isEmpty()); } @Test - void whenResponseIsNotValidJson_thenReturnInvalid() { - responseEntity.setContent(IOUtils.toInputStream("{notValid}", StandardCharsets.UTF_8)); + void whenValidTokenExpired_thenReturnInvalid() throws ClientProtocolException, IOException { + initJwks(); + assertFalse(oidcTokenProvider.isValid(EXPIRED_TOKEN)); + } + + @Test + void whenValidtoken_thenReturnValid() throws ClientProtocolException, IOException { + initJwks(); + ReflectionTestUtils.setField(oidcTokenProvider, "clock", new FixedClock(new Date(Instant.ofEpochSecond(1697060773 + 1000L).toEpochMilli()))); + assertTrue(oidcTokenProvider.isValid(EXPIRED_TOKEN)); + } + + @Test + void whenInvalidToken_thenReturnInvalid() throws ClientProtocolException, IOException { + initJwks(); assertFalse(oidcTokenProvider.isValid(TOKEN)); } @Test - void whenResponseStatusIsNotOk_thenReturnInvalid() { - when(responseStatusLine.getStatusCode()).thenReturn(HttpStatus.SC_UNAUTHORIZED); + @SuppressWarnings("unchecked") + void whenNoJwks_thenReturnInvalid() { + Map jwks = (Map) ReflectionTestUtils.getField(oidcTokenProvider, "jwks"); + assumeTrue(jwks.isEmpty()); assertFalse(oidcTokenProvider.isValid(TOKEN)); } @@ -129,15 +233,6 @@ void whenTokenIsEmpty_thenReturnInvalid() { @Nested class GivenInvalidConfiguration { - @ParameterizedTest - @NullSource - @EmptySource - @ValueSource(strings = {"not_an_URL", "https//\\:"}) - void whenInvalidIntrospectUrl_thenReturnInvalid(String url) { - oidcTokenProvider.introspectUrl = url; - assertFalse(oidcTokenProvider.isValid(TOKEN)); - } - @ParameterizedTest @NullSource @EmptySource diff --git a/gateway-service/src/test/resources/application.yml b/gateway-service/src/test/resources/application.yml index dda5fb2ff8..31f53f964c 100644 --- a/gateway-service/src/test/resources/application.yml +++ b/gateway-service/src/test/resources/application.yml @@ -35,10 +35,11 @@ apiml: enabled: false clientId: clientSecret: - introspectUrl: registry: identityMapperUrl: http://localhost:8542/certificate/dn identityMapperUser: validUserForMap + jwks: + uri: filterChainConfiguration: new allowTokenRefresh: true jwtInitializerTimeout: 5