diff --git a/sdk/spring/CHANGELOG.md b/sdk/spring/CHANGELOG.md index bd5ef25e9497..3ee645f17555 100644 --- a/sdk/spring/CHANGELOG.md +++ b/sdk/spring/CHANGELOG.md @@ -12,6 +12,7 @@ This section includes changes in `spring-cloud-azure-autoconfigure` module. #### Bugs Fixed - Fixed Redis Lettuce passwordless autoconfiguration so a user-defined `LettuceClientConfigurationBuilderCustomizer` no longer suppresses the Azure customizer bean that configures Azure Redis credentials and RESP2 support. +- Applied `jwt-connect-timeout` and `jwt-read-timeout` properties to the RestTemplate used by the JWT decoder in AAD and B2C resource server configurations, preventing indefinite hanging when fetching JWK keys ([#49329](https://github.com/Azure/azure-sdk-for-java/pull/49329)). #### Other Changes diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/configuration/AadResourceServerConfiguration.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/configuration/AadResourceServerConfiguration.java index 8eaa7a62d71e..74ed2dadc5a9 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/configuration/AadResourceServerConfiguration.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/configuration/AadResourceServerConfiguration.java @@ -57,8 +57,10 @@ JwtDecoder jwtDecoder(AadAuthenticationProperties aadAuthenticationProperties) { aadAuthenticationProperties.getProfile().getEnvironment().getActiveDirectoryEndpoint(), tenantId); NimbusJwtDecoder nimbusJwtDecoder = NimbusJwtDecoder .withJwkSetUri(identityEndpoints.getJwkSetEndpoint()) - .restOperations(createRestTemplate(restTemplateBuilder)) - .build(); + .restOperations(createRestTemplate(restTemplateBuilder + .connectTimeout(aadAuthenticationProperties.getJwtConnectTimeout()) + .readTimeout(aadAuthenticationProperties.getJwtReadTimeout()))) + .build(); List> validators = createDefaultValidator(aadAuthenticationProperties); nimbusJwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators)); return nimbusJwtDecoder; diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/configuration/properties/AadAuthenticationProperties.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/configuration/properties/AadAuthenticationProperties.java index fcf14eecf569..933955952f33 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/configuration/properties/AadAuthenticationProperties.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/configuration/properties/AadAuthenticationProperties.java @@ -4,7 +4,7 @@ package com.azure.spring.cloud.autoconfigure.implementation.aad.configuration.properties; import com.azure.spring.cloud.autoconfigure.implementation.aad.security.properties.AuthorizationClientProperties; -import com.nimbusds.jose.jwk.source.RemoteJWKSet; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; @@ -90,25 +90,22 @@ public class AadAuthenticationProperties implements InitializingBean { private final Map authenticateAdditionalParameters = new HashMap<>(); /** - * Connection Timeout (duration) for the JWKSet Remote URL call. The default value is `500s`. - * @deprecated If you want to configure this, please provide a 'RestOperations' bean. + * Connection Timeout (duration) for the JWKSet Remote URL call. + * The default value is {@value com.nimbusds.jose.jwk.source.JWKSourceBuilder#DEFAULT_HTTP_CONNECT_TIMEOUT} milliseconds. */ - @Deprecated - private Duration jwtConnectTimeout = Duration.ofMillis(RemoteJWKSet.DEFAULT_HTTP_CONNECT_TIMEOUT); + private Duration jwtConnectTimeout = Duration.ofMillis(JWKSourceBuilder.DEFAULT_HTTP_CONNECT_TIMEOUT); /** - * Read Timeout (duration) for the JWKSet Remote URL call. The default value is `500s`. - * @deprecated If you want to configure this, please provide a 'RestOperations' bean. + * Read Timeout (duration) for the JWKSet Remote URL call. + * The default value is {@value com.nimbusds.jose.jwk.source.JWKSourceBuilder#DEFAULT_HTTP_READ_TIMEOUT} milliseconds. */ - @Deprecated - private Duration jwtReadTimeout = Duration.ofMillis(RemoteJWKSet.DEFAULT_HTTP_READ_TIMEOUT); + private Duration jwtReadTimeout = Duration.ofMillis(JWKSourceBuilder.DEFAULT_HTTP_READ_TIMEOUT); /** - * Size limit in Bytes of the JWKSet Remote URL call. The default value is `51200`. - * @deprecated If you want to configure this, please provide a 'RestOperations' bean. + * Size limit in Bytes of the JWKSet Remote URL call. + * The default value is {@value com.nimbusds.jose.jwk.source.JWKSourceBuilder#DEFAULT_HTTP_SIZE_LIMIT} bytes. */ - @Deprecated - private int jwtSizeLimit = RemoteJWKSet.DEFAULT_HTTP_SIZE_LIMIT; /* bytes */ + private int jwtSizeLimit = JWKSourceBuilder.DEFAULT_HTTP_SIZE_LIMIT; /* bytes */ /** * The lifespan (duration) of the cached JWK set before it expires. diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfiguration.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfiguration.java index 0d49961eb723..b212f1b762de 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfiguration.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfiguration.java @@ -53,7 +53,9 @@ public class AadB2cResourceServerAutoConfiguration { AadB2cResourceServerAutoConfiguration(AadB2cProperties properties, RestTemplateBuilder restTemplateBuilder) { this.properties = properties; - this.restTemplateBuilder = restTemplateBuilder; + this.restTemplateBuilder = restTemplateBuilder + .connectTimeout(properties.getJwtConnectTimeout()) + .readTimeout(properties.getJwtReadTimeout()); } @Bean diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/properties/AadB2cProperties.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/properties/AadB2cProperties.java index e13f26bfa666..2444c052aaf0 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/properties/AadB2cProperties.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/properties/AadB2cProperties.java @@ -3,7 +3,7 @@ package com.azure.spring.cloud.autoconfigure.implementation.aadb2c.configuration.properties; import com.azure.spring.cloud.autoconfigure.implementation.aadb2c.security.exception.AadB2cConfigurationException; -import com.nimbusds.jose.jwk.source.RemoteJWKSet; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.util.CollectionUtils; @@ -60,25 +60,22 @@ public class AadB2cProperties implements InitializingBean { private String appIdUri; /** - * Connection Timeout(duration) for the JWKSet Remote URL call. The default value is `500s`. - * @deprecated If you want to configure this, please provide a RestOperations bean. + * Connection Timeout (duration) for the JWKSet Remote URL call. + * The default value is {@value com.nimbusds.jose.jwk.source.JWKSourceBuilder#DEFAULT_HTTP_CONNECT_TIMEOUT} milliseconds. */ - @Deprecated - private Duration jwtConnectTimeout = Duration.ofMillis(RemoteJWKSet.DEFAULT_HTTP_CONNECT_TIMEOUT); + private Duration jwtConnectTimeout = Duration.ofMillis(JWKSourceBuilder.DEFAULT_HTTP_CONNECT_TIMEOUT); /** - * Read Timeout(duration) for the JWKSet Remote URL call. The default value is `500s`. - * @deprecated If you want to configure this, please provide a RestOperations bean. + * Read Timeout (duration) for the JWKSet Remote URL call. + * The default value is {@value com.nimbusds.jose.jwk.source.JWKSourceBuilder#DEFAULT_HTTP_READ_TIMEOUT} milliseconds. */ - @Deprecated - private Duration jwtReadTimeout = Duration.ofMillis(RemoteJWKSet.DEFAULT_HTTP_READ_TIMEOUT); + private Duration jwtReadTimeout = Duration.ofMillis(JWKSourceBuilder.DEFAULT_HTTP_READ_TIMEOUT); /** - * Size limit in Bytes of the JWKSet Remote URL call. The default value is `50*1024`. - * @deprecated If you want to configure this, please provide a RestOperations bean. + * Size limit in Bytes of the JWKSet Remote URL call. + * The default value is {@value com.nimbusds.jose.jwk.source.JWKSourceBuilder#DEFAULT_HTTP_SIZE_LIMIT} bytes. */ - @Deprecated - private int jwtSizeLimit = RemoteJWKSet.DEFAULT_HTTP_SIZE_LIMIT; /* bytes */ + private int jwtSizeLimit = JWKSourceBuilder.DEFAULT_HTTP_SIZE_LIMIT; /* bytes */ /** * Redirect URL after logout. diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aad/configuration/AadResourceServerConfigurationTests.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aad/configuration/AadResourceServerConfigurationTests.java index 6c493880048c..bb8cc538e012 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aad/configuration/AadResourceServerConfigurationTests.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aad/configuration/AadResourceServerConfigurationTests.java @@ -7,6 +7,7 @@ import com.azure.spring.cloud.autoconfigure.implementation.aad.security.jwt.AadJwtIssuerValidator; import com.azure.spring.cloud.autoconfigure.implementation.aad.security.AadResourceServerHttpSecurityConfigurer; import com.azure.spring.cloud.autoconfigure.implementation.context.AzureGlobalPropertiesAutoConfiguration; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; import com.nimbusds.jwt.proc.JWTClaimsSetAwareJWSKeySelector; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -32,6 +33,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.test.util.ReflectionTestUtils; +import java.time.Duration; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; @@ -67,6 +69,44 @@ void testCreateJwtDecoderByJwkKeySetUri() { }); } + @Test + void testJwtDecoderTimeoutDefaultValues() { + resourceServerContextRunner() + .withPropertyValues("spring.cloud.azure.active-directory.enabled=true") + .run(context -> { + AadAuthenticationProperties properties = context.getBean(AadAuthenticationProperties.class); + assertThat(properties.getJwtConnectTimeout()) + .isEqualTo(Duration.ofMillis(JWKSourceBuilder.DEFAULT_HTTP_CONNECT_TIMEOUT)); + assertThat(properties.getJwtReadTimeout()) + .isEqualTo(Duration.ofMillis(JWKSourceBuilder.DEFAULT_HTTP_READ_TIMEOUT)); + // Verify the default timeouts are applied to the RestTemplate used by the JwtDecoder + final JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); + verifyJwtDecoderRestTemplateTimeouts(jwtDecoder, + JWKSourceBuilder.DEFAULT_HTTP_CONNECT_TIMEOUT, + JWKSourceBuilder.DEFAULT_HTTP_READ_TIMEOUT); + }); + } + + @Test + void testJwtDecoderTimeoutCustomValues() { + resourceServerContextRunner() + .withPropertyValues( + "spring.cloud.azure.active-directory.enabled=true", + "spring.cloud.azure.active-directory.jwt-connect-timeout=2000", + "spring.cloud.azure.active-directory.jwt-read-timeout=3000") + .run(context -> { + AadAuthenticationProperties properties = context.getBean(AadAuthenticationProperties.class); + assertThat(properties.getJwtConnectTimeout()).isEqualTo(Duration.ofMillis(2000)); + assertThat(properties.getJwtReadTimeout()).isEqualTo(Duration.ofMillis(3000)); + // Verify JwtDecoder is still created successfully with custom timeouts + final JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); + assertThat(jwtDecoder).isNotNull(); + assertThat(jwtDecoder).isExactlyInstanceOf(NimbusJwtDecoder.class); + // Verify the configured timeouts are applied to the RestTemplate used by the JwtDecoder + verifyJwtDecoderRestTemplateTimeouts(jwtDecoder, 2000, 3000); + }); + } + @Test void testNotAudienceDefaultValidator() { resourceServerRunner() @@ -364,4 +404,52 @@ public Collection convert(Jwt source) { return null; } } + + /** + * Verifies that the RestTemplate used by the NimbusJwtDecoder for JWK retrieval + * has the expected connect and read timeouts applied to its ClientHttpRequestFactory. + */ + @SuppressWarnings("unchecked") + private static void verifyJwtDecoderRestTemplateTimeouts(JwtDecoder jwtDecoder, + int expectedConnectTimeoutMs, + int expectedReadTimeoutMs) { + // NimbusJwtDecoder -> jwtProcessor (DefaultJWTProcessor) + Object jwtProcessor = ReflectionTestUtils.getField(jwtDecoder, "jwtProcessor"); + assertThat(jwtProcessor).isInstanceOf(com.nimbusds.jwt.proc.DefaultJWTProcessor.class); + + // DefaultJWTProcessor -> JWSKeySelector (JWSVerificationKeySelector) + com.nimbusds.jose.proc.JWSKeySelector keySelector = + ((com.nimbusds.jwt.proc.DefaultJWTProcessor) jwtProcessor).getJWSKeySelector(); + assertThat(keySelector).isInstanceOf(com.nimbusds.jose.proc.JWSVerificationKeySelector.class); + + // JWSVerificationKeySelector -> JWKSource (JWKSetBasedJWKSource) + com.nimbusds.jose.jwk.source.JWKSource jwkSource = + ((com.nimbusds.jose.proc.JWSVerificationKeySelector) keySelector).getJWKSource(); + assertThat(jwkSource).isInstanceOf(com.nimbusds.jose.jwk.source.JWKSetBasedJWKSource.class); + + // JWKSetBasedJWKSource -> JWKSetSource (CachingJWKSetSource -> JWKSetSourceWrapper -> actual source) + Object jwkSetSource = + ((com.nimbusds.jose.jwk.source.JWKSetBasedJWKSource) jwkSource).getJWKSetSource(); + + // Unwrap JWKSetSourceWrapper chain to find the source with restOperations + while (jwkSetSource instanceof com.nimbusds.jose.jwk.source.JWKSetSourceWrapper wrapper) { + jwkSetSource = wrapper.getSource(); + } + + // actual source -> restOperations (RestTemplate) + Object restOperations = ReflectionTestUtils.getField(jwkSetSource, "restOperations"); + assertThat(restOperations).isInstanceOf(org.springframework.web.client.RestTemplate.class); + + // RestTemplate -> ClientHttpRequestFactory + org.springframework.http.client.ClientHttpRequestFactory requestFactory = + ((org.springframework.web.client.RestTemplate) restOperations).getRequestFactory(); + + // Verify timeouts on the request factory (may be stored as Duration or int) + Object connectTimeoutValue = ReflectionTestUtils.getField(requestFactory, "connectTimeout"); + Object readTimeoutValue = ReflectionTestUtils.getField(requestFactory, "readTimeout"); + int connectTimeout = connectTimeoutValue instanceof java.time.Duration d ? (int) d.toMillis() : (int) connectTimeoutValue; + int readTimeout = readTimeoutValue instanceof java.time.Duration d ? (int) d.toMillis() : (int) readTimeoutValue; + assertThat(connectTimeout).isEqualTo(expectedConnectTimeoutMs); + assertThat(readTimeout).isEqualTo(expectedReadTimeoutMs); + } } diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java index 805dba9d8ede..517abd3b0e0f 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aadb2c/configuration/AadB2cResourceServerAutoConfigurationTests.java @@ -9,6 +9,7 @@ import com.azure.spring.cloud.autoconfigure.implementation.aadb2c.configuration.properties.AadB2cProperties; import com.azure.spring.cloud.autoconfigure.implementation.aadb2c.security.jwt.AadB2cTrustedIssuerRepository; import com.azure.spring.cloud.autoconfigure.implementation.context.AzureGlobalPropertiesAutoConfiguration; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jwt.proc.DefaultJWTProcessor; import com.nimbusds.jwt.proc.JWTClaimsSetAwareJWSKeySelector; @@ -29,6 +30,7 @@ import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import java.time.Duration; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -141,6 +143,36 @@ void testB2COnlyResourceServerBean() { getResourceServerContextRunner().run(b2CResourceServerBean()); } + @Test + void testB2CTimeoutDefaultValues() { + getResourceServerContextRunner().run(context -> { + AadB2cProperties properties = context.getBean(AadB2cProperties.class); + assertThat(properties.getJwtConnectTimeout()) + .isEqualTo(Duration.ofMillis(JWKSourceBuilder.DEFAULT_HTTP_CONNECT_TIMEOUT)); + assertThat(properties.getJwtReadTimeout()) + .isEqualTo(Duration.ofMillis(JWKSourceBuilder.DEFAULT_HTTP_READ_TIMEOUT)); + // Verify the default timeouts are applied to the RestTemplate used by the ResourceRetriever + verifyResourceRetrieverRestTemplateTimeouts(context, + JWKSourceBuilder.DEFAULT_HTTP_CONNECT_TIMEOUT, + JWKSourceBuilder.DEFAULT_HTTP_READ_TIMEOUT); + }); + } + + @Test + void testB2CTimeoutCustomValues() { + getResourceServerContextRunner() + .withPropertyValues( + "spring.cloud.azure.active-directory.b2c.jwt-connect-timeout=2000", + "spring.cloud.azure.active-directory.b2c.jwt-read-timeout=3000") + .run(context -> { + AadB2cProperties properties = context.getBean(AadB2cProperties.class); + assertThat(properties.getJwtConnectTimeout()).isEqualTo(Duration.ofMillis(2000)); + assertThat(properties.getJwtReadTimeout()).isEqualTo(Duration.ofMillis(3000)); + // Verify the custom timeouts are applied to the RestTemplate used by the ResourceRetriever + verifyResourceRetrieverRestTemplateTimeouts(context, 2000, 3000); + }); + } + @Test void testResourceServerConditionsIsInvokedWhenAADB2CEnableFileExists() { try (MockedStatic beanUtils = mockStatic(BeanUtils.class, Mockito.CALLS_REAL_METHODS)) { @@ -301,4 +333,35 @@ void testExistJWTClaimsSetAwareJWSKeySelectorBean() { assertThat(jwsKeySelector).isExactlyInstanceOf(AadIssuerJwsKeySelector.class); }); } + + /** + * Verifies that the RestTemplate used by the ResourceRetriever for JWK retrieval + * has the expected connect and read timeouts applied to its ClientHttpRequestFactory. + */ + private static void verifyResourceRetrieverRestTemplateTimeouts(ApplicationContext context, + int expectedConnectTimeoutMs, + int expectedReadTimeoutMs) { + com.nimbusds.jose.util.ResourceRetriever resourceRetriever = + context.getBean(com.nimbusds.jose.util.ResourceRetriever.class); + assertThat(resourceRetriever).isNotNull(); + + // RestOperationsResourceRetriever -> restOperations (RestTemplate) + Object restOperations = org.springframework.test.util.ReflectionTestUtils + .getField(resourceRetriever, "restOperations"); + assertThat(restOperations).isInstanceOf(org.springframework.web.client.RestTemplate.class); + + // RestTemplate -> ClientHttpRequestFactory + org.springframework.http.client.ClientHttpRequestFactory requestFactory = + ((org.springframework.web.client.RestTemplate) restOperations).getRequestFactory(); + + // Verify timeouts on the request factory (may be stored as Duration or int) + Object connectTimeoutValue = org.springframework.test.util.ReflectionTestUtils + .getField(requestFactory, "connectTimeout"); + Object readTimeoutValue = org.springframework.test.util.ReflectionTestUtils + .getField(requestFactory, "readTimeout"); + int connectTimeout = connectTimeoutValue instanceof java.time.Duration d ? (int) d.toMillis() : (int) connectTimeoutValue; + int readTimeout = readTimeoutValue instanceof java.time.Duration d ? (int) d.toMillis() : (int) readTimeoutValue; + assertThat(connectTimeout).isEqualTo(expectedConnectTimeoutMs); + assertThat(readTimeout).isEqualTo(expectedReadTimeoutMs); + } }