From bb5aa0abdb45d27eef8f3db126f07d18d52f46eb Mon Sep 17 00:00:00 2001 From: Enrico Vianello Date: Tue, 19 Mar 2024 23:50:49 +0100 Subject: [PATCH] Fix caching of remote JWK sets --- .../storm/webdav/config/OAuthProperties.java | 11 + .../DefaultOidcConfigurationFetcher.java | 76 +++-- .../oauth/utils/NoExpirationStringCache.java | 69 +++++ .../oauth/utils/OidcConfigurationFetcher.java | 5 + .../utils/TrustedJwtDecoderCacheLoader.java | 11 +- src/main/resources/application.yml | 3 +- .../jwk/OidcConfigurationFetcherTest.java | 262 ++++++++++++++++++ .../jwk/TrustedJwtDecoderCacheLoaderTest.java | 109 ++++++++ .../jwt/NoExpirationStringCacheTest.java | 45 +++ src/test/resources/jwk/test-keystore.jwks | 16 ++ 10 files changed, 582 insertions(+), 25 deletions(-) create mode 100644 src/main/java/org/italiangrid/storm/webdav/oauth/utils/NoExpirationStringCache.java create mode 100644 src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/OidcConfigurationFetcherTest.java create mode 100644 src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/TrustedJwtDecoderCacheLoaderTest.java create mode 100644 src/test/java/org/italiangrid/storm/webdav/test/oauth/jwt/NoExpirationStringCacheTest.java create mode 100644 src/test/resources/jwk/test-keystore.jwks diff --git a/src/main/java/org/italiangrid/storm/webdav/config/OAuthProperties.java b/src/main/java/org/italiangrid/storm/webdav/config/OAuthProperties.java index bd833381..aab8d1f6 100644 --- a/src/main/java/org/italiangrid/storm/webdav/config/OAuthProperties.java +++ b/src/main/java/org/italiangrid/storm/webdav/config/OAuthProperties.java @@ -96,6 +96,9 @@ public boolean isEnforceAudienceChecks() { @Min(value = 1, message = "The refresh period must be a positive integer") int refreshPeriodMinutes = 60; + @Min(value = 1, message = "The refresh timeout must be a positive integer") + int refreshTimeoutSeconds = 30; + public List getIssuers() { return issuers; } @@ -112,6 +115,14 @@ public void setRefreshPeriodMinutes(int refreshPeriodMinutes) { this.refreshPeriodMinutes = refreshPeriodMinutes; } + public int getRefreshTimeoutSeconds() { + return refreshTimeoutSeconds; + } + + public void setRefreshTimeoutSeconds(int refreshTimeoutSeconds) { + this.refreshTimeoutSeconds = refreshTimeoutSeconds; + } + public void setEnableOidc(boolean enableOidc) { this.enableOidc = enableOidc; } diff --git a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/DefaultOidcConfigurationFetcher.java b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/DefaultOidcConfigurationFetcher.java index 090ee2db..d80a5a16 100644 --- a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/DefaultOidcConfigurationFetcher.java +++ b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/DefaultOidcConfigurationFetcher.java @@ -18,34 +18,45 @@ import static java.lang.String.format; import java.net.URI; +import java.time.Duration; +import java.util.Arrays; import java.util.Map; +import org.italiangrid.storm.webdav.config.OAuthProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; +import com.nimbusds.jose.RemoteKeySourceException; + @Service public class DefaultOidcConfigurationFetcher implements OidcConfigurationFetcher { public static final String WELL_KNOWN_FRAGMENT = "/.well-known/openid-configuration"; public static final String ISSUER_MISMATCH_ERROR_TEMPLATE = "Issuer in metadata '%s' does not match with requested issuer '%s'"; - public static final String NO_JWKS_URI_ERROR_TEMPLATE = + public static final String NO_JWKS_URI_ERROR_TEMPLATE = "No jwks_uri found in metadata for issuer '%s'"; + private static final MediaType APPLICATION_JWK_SET_JSON = + new MediaType("application", "jwk-set+json"); + public static final Logger LOG = LoggerFactory.getLogger(DefaultOidcConfigurationFetcher.class); - final RestTemplateBuilder restBuilder; + final RestTemplate restTemplate; - @Autowired - public DefaultOidcConfigurationFetcher(RestTemplateBuilder restBuilder) { - this.restBuilder = restBuilder; + public DefaultOidcConfigurationFetcher(RestTemplateBuilder restBuilder, + OAuthProperties oAuthProperties) { + final Duration TIMEOUT = Duration.ofSeconds(oAuthProperties.getRefreshTimeoutSeconds()); + this.restTemplate = restBuilder.setConnectTimeout(TIMEOUT).setReadTimeout(TIMEOUT).build(); } private void metadataChecks(String issuer, Map oidcConfiguration) { @@ -59,40 +70,63 @@ private void metadataChecks(String issuer, Map oidcConfiguration throw new OidcConfigurationResolutionError( format(ISSUER_MISMATCH_ERROR_TEMPLATE, metadataIssuer, issuer)); } - + if (!oidcConfiguration.containsKey("jwks_uri")) { - throw new OidcConfigurationResolutionError(format(NO_JWKS_URI_ERROR_TEMPLATE,issuer)); + throw new OidcConfigurationResolutionError(format(NO_JWKS_URI_ERROR_TEMPLATE, issuer)); } } @Override public Map loadConfigurationForIssuer(String issuer) { LOG.debug("Fetching OpenID configuration for {}", issuer); - + ParameterizedTypeReference> typeReference = new ParameterizedTypeReference>() {}; - RestTemplate rest = restBuilder.build(); - URI uri = UriComponentsBuilder.fromUriString(issuer + WELL_KNOWN_FRAGMENT).build().toUri(); - + ResponseEntity> response = null; try { RequestEntity request = RequestEntity.get(uri).build(); - Map conf = rest.exchange(request, typeReference).getBody(); - metadataChecks(issuer, conf); - return conf; + response = restTemplate.exchange(request, typeReference); + if (response.getStatusCodeValue() != 200) { + throw new RuntimeException( + format("Received status code: %s", response.getStatusCodeValue())); + } + metadataChecks(issuer, response.getBody()); + return response.getBody(); } catch (RuntimeException e) { - final String errorMsg = - format("Unable to resolve OpenID configuration for issuer '%s' from '%s': %s", issuer, - uri, e.getMessage()); - + final String errorMsg = format("Unable to resolve OpenID configuration from '%s'", uri); if (LOG.isDebugEnabled()) { - LOG.error(errorMsg, e); + LOG.error("{}: {}", errorMsg, e.getMessage()); } - throw new OidcConfigurationResolutionError(errorMsg, e); } } + @Override + public String loadJWKSourceForURL(URI uri) throws RemoteKeySourceException { + + LOG.debug("Fetching JWK from {}", uri); + + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Arrays.asList(MediaType.APPLICATION_JSON, APPLICATION_JWK_SET_JSON)); + ResponseEntity response = null; + try { + RequestEntity request = RequestEntity.get(uri).headers(headers).build(); + response = restTemplate.exchange(request, String.class); + if (response.getStatusCodeValue() != 200) { + throw new RuntimeException( + format("Received status code: %s", response.getStatusCodeValue())); + } + } catch (RuntimeException e) { + final String errorMsg = format("Unable to get JWK from '%s'", uri); + if (LOG.isDebugEnabled()) { + LOG.error("{}: {}", errorMsg, e.getMessage()); + } + throw new RemoteKeySourceException(errorMsg, e); + } + + return response.getBody(); + } } diff --git a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/NoExpirationStringCache.java b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/NoExpirationStringCache.java new file mode 100644 index 00000000..2fb17297 --- /dev/null +++ b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/NoExpirationStringCache.java @@ -0,0 +1,69 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare, 2014-2023. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.italiangrid.storm.webdav.oauth.utils; + +import java.util.concurrent.Callable; + +import org.springframework.cache.support.AbstractValueAdaptingCache; +import org.springframework.lang.Nullable; + +public class NoExpirationStringCache extends AbstractValueAdaptingCache { + + private static final String NAME = "NoExpirationCache"; + private final String value; + + public NoExpirationStringCache(String value) { + super(false); + this.value = value; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public Object getNativeCache() { + return this; + } + + @Override + @Nullable + protected Object lookup(Object key) { + return value; + } + + @Override + public void put(Object key, Object value) { + return; + } + + @Override + public void evict(Object key) { + return; + } + + @Override + public void clear() { + return; + } + + @SuppressWarnings("unchecked") + @Override + public T get(Object key, Callable valueLoader) { + return (T) fromStoreValue(value); + } +} \ No newline at end of file diff --git a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/OidcConfigurationFetcher.java b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/OidcConfigurationFetcher.java index 786e9f1b..f1632e65 100644 --- a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/OidcConfigurationFetcher.java +++ b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/OidcConfigurationFetcher.java @@ -15,10 +15,15 @@ */ package org.italiangrid.storm.webdav.oauth.utils; +import java.net.URI; import java.util.Map; +import com.nimbusds.jose.RemoteKeySourceException; + public interface OidcConfigurationFetcher { Map loadConfigurationForIssuer(String issuer); + String loadJWKSourceForURL(URI uri) throws RemoteKeySourceException; + } diff --git a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/TrustedJwtDecoderCacheLoader.java b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/TrustedJwtDecoderCacheLoader.java index efde8ee9..68d18d10 100644 --- a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/TrustedJwtDecoderCacheLoader.java +++ b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/TrustedJwtDecoderCacheLoader.java @@ -15,6 +15,7 @@ */ package org.italiangrid.storm.webdav.oauth.utils; +import java.net.URI; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; @@ -28,8 +29,8 @@ import org.italiangrid.storm.webdav.oauth.validator.WlcgProfileValidator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.cache.Cache; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.jwt.Jwt; @@ -53,7 +54,6 @@ public class TrustedJwtDecoderCacheLoader extends CacheLoader oidcConfiguration = fetcher.loadConfigurationForIssuer(issuer); + URI jwksUri = URI.create(oidcConfiguration.get("jwks_uri").toString()); + Cache noExpirationCache = + new NoExpirationStringCache(fetcher.loadJWKSourceForURL(jwksUri).toString()); NimbusJwtDecoder decoder = - NimbusJwtDecoder.withJwkSetUri((oidcConfiguration.get("jwks_uri").toString())).build(); + NimbusJwtDecoder.withJwkSetUri((oidcConfiguration.get("jwks_uri").toString())) + .cache(noExpirationCache) + .build(); OAuth2TokenValidator jwtValidator = JwtValidators.createDefaultWithIssuer(issuer); OAuth2TokenValidator wlcgProfileValidator = new WlcgProfileValidator(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 67245d86..f933c705 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -50,7 +50,8 @@ tpc: enable-expect-continue-threshold: ${STORM_WEBDAV_TPC_ENABLE_EXPECT_CONTINUE_THRESHOLD:1048576} oauth: - refresh-period-minutes: 60 + refresh-period-minutes: ${STORM_WEBDAV_OAUTH_REFRESH_PERIOD_MINUTES:60} + refresh-timeout-seconds: ${STORM_WEBDAV_OAUTH_REFRESH_TIMEOUT_SECONDS:30} issuers: storm: diff --git a/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/OidcConfigurationFetcherTest.java b/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/OidcConfigurationFetcherTest.java new file mode 100644 index 00000000..0bd16b59 --- /dev/null +++ b/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/OidcConfigurationFetcherTest.java @@ -0,0 +1,262 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare, 2014-2023. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.italiangrid.storm.webdav.test.oauth.jwk; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.OK; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.text.ParseException; +import java.util.Map; + +import org.apache.commons.io.FileUtils; +import org.italiangrid.storm.webdav.config.OAuthProperties; +import org.italiangrid.storm.webdav.oauth.utils.DefaultOidcConfigurationFetcher; +import org.italiangrid.storm.webdav.oauth.utils.OidcConfigurationFetcher; +import org.italiangrid.storm.webdav.oauth.utils.OidcConfigurationResolutionError; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import com.google.common.collect.Maps; +import com.nimbusds.jose.RemoteKeySourceException; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyType; + +@ExtendWith(MockitoExtension.class) +public class OidcConfigurationFetcherTest { + + final static String ISSUER = "https://iam-dev.cloud.cnaf.infn.it/"; + final static String JWK_URI = ISSUER + "jwk"; + + final static String ANOTHER_ISSUER = "https://iam.cloud.infn.it/"; + final static String ANOTHER_JWK_URI = ANOTHER_ISSUER + "jwk"; + + final static String KID = "rsa1"; + + + final ParameterizedTypeReference> typeReference = + new ParameterizedTypeReference>() {}; + + @Mock + RestTemplate restTemplate; + @Mock + RestTemplateBuilder restBuilder; + @Mock + OAuthProperties oAuthProperties; + + private Map getMapWithIssuerAndJwkUri(String issuer, String jwkUri) { + Map m = Maps.newHashMap(); + m.put("issuer", issuer); + m.put("jwks_uri", jwkUri); + return m; + } + + @SuppressWarnings("unchecked") + private ResponseEntity> getWellKnownResponse(HttpStatus status, Map map) { + + ResponseEntity> mockedEntity = (ResponseEntity>) Mockito.mock(ResponseEntity.class); + lenient().when(mockedEntity.getStatusCode()).thenReturn(status); + lenient().when(mockedEntity.getStatusCodeValue()).thenReturn(status.value()); + lenient().when(mockedEntity.getBody()).thenReturn(map); + return mockedEntity; + } + + private String loadJwkFromFile() throws IOException { + + ClassLoader classLoader = getClass().getClassLoader(); + File file = new File(classLoader.getResource("jwk/test-keystore.jwks").getFile()); + return FileUtils.readFileToString(file, "UTF-8"); + } + + @SuppressWarnings("unchecked") + private ResponseEntity getJWKURIResponse(HttpStatus status, String data) throws IOException { + + ResponseEntity mockedEntity = (ResponseEntity) Mockito.mock(ResponseEntity.class); + lenient().when(mockedEntity.getBody()).thenReturn(data); + lenient().when(mockedEntity.getStatusCode()).thenReturn(status); + lenient().when(mockedEntity.getStatusCodeValue()).thenReturn(status.value()); + return mockedEntity; + } + + private OidcConfigurationFetcher getFetcher(ResponseEntity> wellKnownResponse, ResponseEntity jwkResponse) { + + lenient().when(restTemplate.exchange(any(), eq(typeReference))).thenReturn(wellKnownResponse); + lenient().when(restTemplate.exchange(any(), eq(String.class))).thenReturn(jwkResponse); + lenient().when(restBuilder.build()).thenReturn(restTemplate); + lenient().when(restBuilder.setConnectTimeout(any())).thenReturn(restBuilder); + lenient().when(restBuilder.setReadTimeout(any())).thenReturn(restBuilder); + lenient().when(oAuthProperties.getRefreshTimeoutSeconds()).thenReturn(30); + lenient().when(oAuthProperties.getRefreshPeriodMinutes()).thenReturn(1); + return new DefaultOidcConfigurationFetcher(restBuilder, oAuthProperties); + } + + private OidcConfigurationFetcher getSuccessfulFetcher() throws RestClientException, IOException { + + ResponseEntity> mockedResponseMapEntity = getWellKnownResponse(OK, getMapWithIssuerAndJwkUri(ISSUER, JWK_URI)); + ResponseEntity mockedResponseStringEntity = getJWKURIResponse(OK, loadJwkFromFile()); + return getFetcher(mockedResponseMapEntity, mockedResponseStringEntity); + } + + private OidcConfigurationFetcher getSuccessfulFetcherWithWrongIssuer() throws RestClientException, IOException { + + ResponseEntity> mockedResponseMapEntity = getWellKnownResponse(OK, getMapWithIssuerAndJwkUri(ANOTHER_ISSUER, ANOTHER_JWK_URI)); + ResponseEntity mockedResponseStringEntity = getJWKURIResponse(OK, loadJwkFromFile()); + return getFetcher(mockedResponseMapEntity, mockedResponseStringEntity); + } + + private OidcConfigurationFetcher getSuccessfulFetcherWithNoIssuer() throws RestClientException, IOException { + + Map map = getMapWithIssuerAndJwkUri(ANOTHER_ISSUER, ANOTHER_JWK_URI); + map.remove("issuer"); + ResponseEntity> mockedResponseMapEntity = getWellKnownResponse(OK, map); + ResponseEntity mockedResponseStringEntity = getJWKURIResponse(OK, loadJwkFromFile()); + return getFetcher(mockedResponseMapEntity, mockedResponseStringEntity); + } + + private OidcConfigurationFetcher getSuccessfulFetcherWithNoJwk() throws RestClientException, IOException { + + Map map = getMapWithIssuerAndJwkUri(ISSUER, JWK_URI); + map.remove("jwks_uri"); + ResponseEntity> mockedResponseMapEntity = getWellKnownResponse(OK, map); + ResponseEntity mockedResponseStringEntity = getJWKURIResponse(OK, loadJwkFromFile()); + return getFetcher(mockedResponseMapEntity, mockedResponseStringEntity); + } + + private OidcConfigurationFetcher getFetcherWithErrorOnFetch() throws RestClientException, IOException { + + ResponseEntity> mockedResponseMapEntity = getWellKnownResponse(NOT_FOUND, null); + return getFetcher(mockedResponseMapEntity, null); + } + + private OidcConfigurationFetcher getFetcherWithErrorOnGetJwk() throws RestClientException, IOException { + + ResponseEntity> mockedResponseMapEntity = getWellKnownResponse(OK, getMapWithIssuerAndJwkUri(ISSUER, JWK_URI)); + ResponseEntity mockedResponseStringEntity = getJWKURIResponse(NOT_FOUND, null); + return getFetcher(mockedResponseMapEntity, mockedResponseStringEntity); + } + + @BeforeEach + public void setDebugLevel() { + System.setProperty("logging.level.org.italiangrid.storm", "DEBUG"); + } + + @Test + public void fetchWellKnownEndpointWithSuccessTests() throws RestClientException, IOException { + + OidcConfigurationFetcher fetcher = getSuccessfulFetcher(); + Map conf = fetcher.loadConfigurationForIssuer(ISSUER); + assertNotNull(conf); + assertThat(conf.get("issuer"), is(ISSUER)); + assertThat(conf.get("jwks_uri"), is(JWK_URI)); + } + + @Test + public void fetchWellKnownEndpointWithErrorTests() throws RestClientException, IOException { + + OidcConfigurationFetcher fetcher = getFetcherWithErrorOnFetch(); + RuntimeException exception = assertThrows(RuntimeException.class, () -> { + fetcher.loadConfigurationForIssuer(ISSUER); + }); + String expectedMessage = "Unable to resolve OpenID configuration from '" + ISSUER + ".well-known/openid-configuration'"; + String actualMessage = exception.getMessage(); + + assertEquals(expectedMessage, actualMessage); + } + + @Test + public void fetchWellKnownEndpointWrongIssuerTests() throws RestClientException, IOException { + + OidcConfigurationFetcher fetcher = getSuccessfulFetcherWithWrongIssuer(); + OidcConfigurationResolutionError exception = assertThrows(OidcConfigurationResolutionError.class, () -> { + fetcher.loadConfigurationForIssuer(ISSUER); + }); + String expectedMessage = "Unable to resolve OpenID configuration from '" + ISSUER + ".well-known/openid-configuration'"; + String actualMessage = exception.getMessage(); + + assertEquals(expectedMessage, actualMessage); + + } + + @Test + public void fetchWellKnownEndpointNoIssuerTests() throws RestClientException, IOException { + + OidcConfigurationFetcher fetcher = getSuccessfulFetcherWithNoIssuer(); + OidcConfigurationResolutionError exception = assertThrows(OidcConfigurationResolutionError.class, () -> { + fetcher.loadConfigurationForIssuer(ISSUER); + }); + String expectedMessage = "Unable to resolve OpenID configuration from '" + ISSUER + ".well-known/openid-configuration'"; + String actualMessage = exception.getMessage(); + + assertEquals(expectedMessage, actualMessage); + } + + @Test + public void fetchWellKnownEndpointNoJwkTests() throws RestClientException, IOException { + + OidcConfigurationFetcher fetcher = getSuccessfulFetcherWithNoJwk(); + OidcConfigurationResolutionError exception = assertThrows(OidcConfigurationResolutionError.class, () -> { + fetcher.loadConfigurationForIssuer(ISSUER); + }); + String expectedMessage = "Unable to resolve OpenID configuration from '" + ISSUER + ".well-known/openid-configuration'"; + String actualMessage = exception.getMessage(); + + assertEquals(expectedMessage, actualMessage); + } + + @Test + public void fetchJWKEndpointTests() throws RestClientException, IOException, RemoteKeySourceException, ParseException { + + OidcConfigurationFetcher fetcher = getSuccessfulFetcher(); + JWKSet key = JWKSet.parse(fetcher.loadJWKSourceForURL(URI.create(JWK_URI))); + + assertNotNull(key.getKeyByKeyId(KID)); + assertThat(key.getKeyByKeyId(KID).getKeyType(), is(KeyType.RSA)); + } + + @Test + public void fetcJWKEndpointWithErrorTests() throws RestClientException, IOException { + + OidcConfigurationFetcher fetcher = getFetcherWithErrorOnGetJwk(); + final URI jwkUri = URI.create(JWK_URI); + RemoteKeySourceException exception = assertThrows(RemoteKeySourceException.class, () -> { + fetcher.loadJWKSourceForURL(jwkUri); + }); + String expectedMessage = "Unable to get JWK from '" + jwkUri + "'"; + String actualMessage = exception.getMessage(); + + assertEquals(expectedMessage, actualMessage); + } +} \ No newline at end of file diff --git a/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/TrustedJwtDecoderCacheLoaderTest.java b/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/TrustedJwtDecoderCacheLoaderTest.java new file mode 100644 index 00000000..99857006 --- /dev/null +++ b/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/TrustedJwtDecoderCacheLoaderTest.java @@ -0,0 +1,109 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare, 2014-2023. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.italiangrid.storm.webdav.test.oauth.jwk; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.lenient; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.apache.commons.io.FileUtils; +import org.italiangrid.storm.webdav.config.OAuthProperties; +import org.italiangrid.storm.webdav.config.ServiceConfigurationProperties; +import org.italiangrid.storm.webdav.config.ServiceConfigurationProperties.AuthorizationServerProperties; +import org.italiangrid.storm.webdav.config.OAuthProperties.AuthorizationServer; +import org.italiangrid.storm.webdav.oauth.utils.OidcConfigurationFetcher; +import org.italiangrid.storm.webdav.oauth.utils.TrustedJwtDecoderCacheLoader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.util.concurrent.ListenableFuture; +import com.nimbusds.jose.RemoteKeySourceException; + +@ExtendWith(MockitoExtension.class) +public class TrustedJwtDecoderCacheLoaderTest { + + private final String ISSUER = "https://wlcg.cloud.cnaf.infn.it/"; + private final String JWK_URI = "https://wlcg.cloud.cnaf.infn.it/jwks"; + + @Mock + ServiceConfigurationProperties properties; + @Mock + OAuthProperties oauthProperties; + @Mock + RestTemplateBuilder builder; + @Mock + OidcConfigurationFetcher fetcher; + + private ExecutorService executor; + private TrustedJwtDecoderCacheLoader jwtLoader; + + @BeforeEach + public void setup() throws IOException, RemoteKeySourceException { + + AuthorizationServer as = new AuthorizationServer(); + as.setIssuer(ISSUER); + as.setJwkUri(JWK_URI); + List issuerServers = Lists.newArrayList(as); + lenient().when(oauthProperties.getIssuers()).thenReturn(issuerServers); + + Map oidcConfiguration = Maps.newHashMap(); + oidcConfiguration.put("issuer", ISSUER); + oidcConfiguration.put("jwks_uri", JWK_URI); + + ClassLoader classLoader = getClass().getClassLoader(); + File file = new File(classLoader.getResource("jwk/test-keystore.jwks").getFile()); + String data = FileUtils.readFileToString(file, "UTF-8"); + + lenient().when(fetcher.loadConfigurationForIssuer(ISSUER)).thenReturn(oidcConfiguration); + lenient().when(fetcher.loadJWKSourceForURL(URI.create(JWK_URI))).thenReturn(data); + + AuthorizationServerProperties props = new AuthorizationServerProperties(); + props.setEnabled(false); + props.setIssuer("http://localhost"); + lenient().when(properties.getAuthzServer()).thenReturn(props); + + executor = Executors.newScheduledThreadPool(1); + + jwtLoader = + new TrustedJwtDecoderCacheLoader(properties, oauthProperties, builder, fetcher, executor); + + } + + @Test + public void testLoadRemoteIssuerConfiguration() throws Exception { + + JwtDecoder decoder = jwtLoader.load(ISSUER); + assertTrue(decoder instanceof NimbusJwtDecoder); + ListenableFuture reloaded = jwtLoader.reload(ISSUER, decoder); + JwtDecoder newDecoder = reloaded.get(); + assertTrue(newDecoder instanceof NimbusJwtDecoder); + } +} diff --git a/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwt/NoExpirationStringCacheTest.java b/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwt/NoExpirationStringCacheTest.java new file mode 100644 index 00000000..4482e101 --- /dev/null +++ b/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwt/NoExpirationStringCacheTest.java @@ -0,0 +1,45 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare, 2014-2023. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.italiangrid.storm.webdav.test.oauth.jwt; + +import static org.junit.Assert.assertEquals; + +import org.italiangrid.storm.webdav.oauth.utils.NoExpirationStringCache; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class NoExpirationStringCacheTest { + + final String CACHED_VALUE = "this-is-my-cached-value"; + final String FAKE_ISSUER = "http://localhost"; + + @Test + public void noExpirationCacheWorks() { + + NoExpirationStringCache cache = new NoExpirationStringCache(CACHED_VALUE); + + assertEquals("NoExpirationCache", cache.getName()); + assertEquals(cache, cache.getNativeCache()); + assertEquals(CACHED_VALUE, cache.get(FAKE_ISSUER).get()); + cache.clear(); + cache.put(FAKE_ISSUER, CACHED_VALUE); + assertEquals(CACHED_VALUE, cache.get(FAKE_ISSUER).get()); + cache.evict(FAKE_ISSUER); + assertEquals(CACHED_VALUE, cache.get(FAKE_ISSUER).get()); + } +} \ No newline at end of file diff --git a/src/test/resources/jwk/test-keystore.jwks b/src/test/resources/jwk/test-keystore.jwks new file mode 100644 index 00000000..cde1441e --- /dev/null +++ b/src/test/resources/jwk/test-keystore.jwks @@ -0,0 +1,16 @@ +{ + "keys": [ + { + "p": "-qdvzeHU7w_ToV2RlS2QlVggXNL2YfpRWQxvrO8pHZC_dVgYFwKz5nadOMzR1BK0tPuCTWuuI66sFgaA9VENGypdIYoCF2O1FBLFK6GjOO-uc0LZEbIDa6Xn0G7UYOWcLaiYriHTtC_Pzp11L7VGjrUlX4HRgU_B3X1oeGn0mbM", + "kty": "RSA", + "q": "5S2b9tYHi9zBcNGZ8X6GM4TAL4UU9mABH0rKIyzbudkG7Wxxbj6I18skuHzfOOPI4c8sTQSv6IVAr2n1bn3_E5RSyPpbtDSCTYGzhijXl9wZ0ba2NidFrVjnL-KPx_gcHKnUHebKvsIEdjxeuqaaZ1kqEJX326b450Frghd78p8", + "d": "oDb1qfQTaP73jEZHgkOG5C9dY5EJZW57fX_BYUJ-yYTuTHPWZBVDKw9I_Ir1tSYyuTF-Bfb4iPim46gnEBM3AdvMian2iajvrN_rJFUJHo65vtY9xCXCD0d_Jct5JMyOafP5LF3cP38yDcyZRS_JeyKGB6U1KhbL-gG4hrQGS8qO3rdY_JQiLDLVdRRptHsPphS44JHXdP2qeVNJ41-CTfPWKiMIUOC0fj-As-dbTzRXuLXs04NayAdM-yhvRiwKujEfL8YbKW9CDJIgJfm2vzWHXFus5Y11S2Zr65cWxxVvRfnAFbFO1AkIkJc2jHZ4xLxfDU2kTi20sOMq1UFrvQ", + "e": "AQAB", + "kid": "rsa1", + "qi": "bN1wnq_0VkJlECMGPmeRFdZCX2LgAMrgwbJpysRw5J04vO9YsVmAcB_4xqoDjDUg7koioAp3IOhMGjOJWpYzCqWzsaA_84kX4WKGsr2xz6oaFSgt1FvtBY4GqEeZj8RG0LMtEtKSyjHGieA0hd4TUcqSdNZ4osT98Bfd7z3peYc", + "dp": "YfyaxI2IRHyXavm9M-hAIWH2JNOD5gGJU5p8_cnw9NHlRuZNZJF16p5sEAxh6tn1MtsvsTxrMx_RvjqEp2IsEXaaOcZN0v7zhwlfcxMZT-TC-eQkH7rLg4Wz_dOVytt4FpFWPpySuloGjusXKLNhBeDi31dMo5SeYQvpj0k8iek", + "dq": "9_lhyLPNdohmxqwE5kkA7L23NbPJ-svmavWBwo3HMlCiLkQoeCEx8EzebsCux9-wfKSuSqfHrtCALU15QxUR6x2SdeRvVY17cGHm3kNTA_4j8cbBYdccjXSksitzZ-wOfvVDjxcqST2llkm8NjoO18Siv0-F4SXKLG-c5CaE9w", + "n": "4GRvJuFantVV3JdjwQOAkfREnwUFp2znRBTOIJhPamyH4gf4YlI5PQT79415NV4_HrWYzgooH5AK6-7WE-TLLGEAVK5vdk4vv79bG7ukvjvBPxAjEhQn6-Amln88iXtvicEGbh--3CKbQj1jryVU5aWM6jzweaabFSeCILVEd6ZT7ofXaAqan9eLzU5IEtTPy5MfrrOvWw5Q7D2yzMqc5LksmaQSw8XtmhA8gnENnIqjAMmPtRltf93wjtmiamgVENOVPdN-93Nd5w-pnMwEyoO6Q9JqXxV6lD6qBRxI7_5t4_vmVxcbbxcZbSAMoHqA2pbSMJ4Jcw-27Hct9jesLQ" + } + ] +} \ No newline at end of file