From 90e060c227b44b148071f12847ca3e137b715559 Mon Sep 17 00:00:00 2001 From: Chris Bonilla Date: Mon, 25 May 2026 14:31:02 +0200 Subject: [PATCH] feat: honour JVM proxy system properties in reactive OAuth2 client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `customWebClient` used for OIDC token exchange, userinfo, and refresh-token calls is built on Reactor Netty's `HttpClient`, which does not consult JVM system properties for proxy configuration by default. Customers behind a corporate egress proxy who set the standard `-Dhttps.proxyHost`/`-Dhttp.proxyHost`/`-Dhttp.nonProxyHosts` JVM args (or `HTTPS_PROXY` env vars picked up by their JVM) see those settings honoured by the JDK-based `RestTemplate` paths in this library (OIDC discovery via `ClientRegistrations.fromIssuerLocation`, JWKS fetch via `SimpleRemoteJwkSource`) but silently bypassed by every Reactor Netty call. The result is that login completes the browser-side redirect but hangs on the server-side token exchange callback. Adding `.proxyWithSystemProperties()` to the `HttpClient` builder closes that gap and makes the reactive OAuth2 client consistent with the JDK paths already present in this configuration. The call is a no-op when no proxy properties are set, so customers who do not need a proxy see no behaviour change. Added unit coverage that verifies both behaviours (no proxy props → `hasProxy()` is false; `https.proxyHost`/`https.proxyPort` set → supplier-backed HTTP `ProxyProvider` matches host/port). JIRA: TRIVIAL risk: low --- ...activeCommunicationClientsConfiguration.kt | 5 + ...veCommunicationClientsConfigurationTest.kt | 101 ++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 gooddata-server-oauth2-autoconfigure/src/test/kotlin/ReactiveCommunicationClientsConfigurationTest.kt diff --git a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/ReactiveCommunicationClientsConfiguration.kt b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/ReactiveCommunicationClientsConfiguration.kt index aec5943..b70af92 100644 --- a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/ReactiveCommunicationClientsConfiguration.kt +++ b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/ReactiveCommunicationClientsConfiguration.kt @@ -59,6 +59,11 @@ class ReactiveCommunicationClientsConfiguration(private val httpProperties: Http val httpClient = HttpClient.create(connectionProvider()) .responseTimeout(Duration.ofMillis(httpProperties.readTimeoutMillis.toLong())) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, httpProperties.connectTimeoutMillis) + // Honour standard JVM proxy system properties (https.proxyHost, http.proxyHost, + // http.nonProxyHosts, etc.). Without this, the Reactor Netty client used for + // OIDC token, userinfo, and refresh-token calls silently bypasses any proxy that + // the JDK-based RestTemplate paths in this library already respect. + .proxyWithSystemProperties() return WebClient.builder() .clientConnector(ReactorClientHttpConnector(httpClient)) .filter { request, next -> diff --git a/gooddata-server-oauth2-autoconfigure/src/test/kotlin/ReactiveCommunicationClientsConfigurationTest.kt b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/ReactiveCommunicationClientsConfigurationTest.kt new file mode 100644 index 0000000..77a556c --- /dev/null +++ b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/ReactiveCommunicationClientsConfigurationTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2026 GoodData Corporation + * + * 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 + * + * https://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 com.gooddata.oauth2.server + +import org.junit.jupiter.api.Test +import org.springframework.http.client.reactive.ClientHttpConnector +import org.springframework.web.reactive.function.client.ExchangeFunction +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient +import reactor.netty.transport.ProxyProvider +import strikt.api.expectThat +import strikt.assertions.isEqualTo +import strikt.assertions.isFalse +import strikt.assertions.isNotNull +import strikt.assertions.isTrue +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.isAccessible + +class ReactiveCommunicationClientsConfigurationTest { + + private val httpProperties = HttpProperties( + readTimeoutMillis = 10_000, + connectTimeoutMillis = 30_000, + connectionIdleTimeoutMillis = 300_000, + ) + + @Test + fun `customWebClient is not configured with a proxy when no JVM proxy system properties are set`() { + withSystemProperties(emptyMap()) { + val webClient = ReactiveCommunicationClientsConfiguration(httpProperties).customWebClient() + + expectThat(webClient.httpClient().configuration().hasProxy()).isFalse() + } + } + + @Suppress("DEPRECATION") // ProxyProvider.getAddress has no non-deprecated replacement in Reactor Netty 1.2.x + @Test + fun `customWebClient picks up the standard JVM https proxy system properties`() { + withSystemProperties( + mapOf( + "https.proxyHost" to PROXY_HOST, + "https.proxyPort" to PROXY_PORT.toString(), + ) + ) { + val webClient = ReactiveCommunicationClientsConfiguration(httpProperties).customWebClient() + val config = webClient.httpClient().configuration() + + expectThat(config.hasProxy()).isTrue() + val provider = config.proxyProviderSupplier()!!.get() + expectThat(provider).isNotNull().and { + get { type }.isEqualTo(ProxyProvider.Proxy.HTTP) + get { address.get().hostString }.isEqualTo(PROXY_HOST) + get { address.get().port }.isEqualTo(PROXY_PORT) + } + } + } + + private fun WebClient.httpClient(): HttpClient { + val exchangeFunction: ExchangeFunction = readField("exchangeFunction", this) + val connector: ClientHttpConnector = readField("connector", exchangeFunction) + return readField("httpClient", connector) + } + + private fun withSystemProperties(properties: Map, block: () -> Unit) { + val previous = properties.keys.associateWith { System.getProperty(it) } + try { + properties.forEach { (key, value) -> System.setProperty(key, value) } + block() + } finally { + previous.forEach { (key, value) -> + if (value == null) System.clearProperty(key) else System.setProperty(key, value) + } + } + } + + @Suppress("UNCHECKED_CAST") + private fun readField(fieldName: String, instance: R): T { + val field = instance::class.memberProperties.find { it.name == fieldName } as KProperty1 + field.isAccessible = true + return field.get(instance) as T + } + + companion object { + private const val PROXY_HOST = "proxy.example.com" + private const val PROXY_PORT = 8443 + } +}