From 5a314acc714f983afc32f6abb7a4ff0d72ed2e95 Mon Sep 17 00:00:00 2001 From: Joshua Nathaniel Miller Date: Fri, 28 Nov 2025 20:07:11 -0600 Subject: [PATCH 1/6] feat: Add OAuth2 proxy support for authentication requests Fixes #196 - Add SimpleOAuthProxyConfig to handle proxy configuration for OAuth2 - Support both system proxy properties and explicit proxy configuration - Wire proxy-aware WebClient into OAuth2 user services - Add unit tests following repository patterns OAuth2 authentication now respects proxy settings when enabled, allowing Kafbat UI to work behind corporate firewalls. The feature is disabled by default to maintain backward compatibility. --- .../ui/config/auth/OAuthSecurityConfig.java | 22 ++- .../config/auth/SimpleOAuthProxyConfig.java | 53 ++++++ ...SimpleOAuthProxyConfigIntegrationTest.java | 151 ++++++++++++++++++ .../auth/SimpleOAuthProxyConfigTest.java | 77 +++++++++ 4 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 api/src/main/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfig.java create mode 100644 api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigIntegrationTest.java create mode 100644 api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigTest.java diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java index 1787ad847..d175edb43 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java @@ -11,6 +11,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.Nullable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesMapper; @@ -36,6 +38,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; +import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; @Configuration @@ -84,8 +87,14 @@ public SecurityWebFilterChain configure(ServerHttpSecurity http, OAuthLogoutSucc } @Bean - public ReactiveOAuth2UserService customOidcUserService(AccessControlService acs) { + public ReactiveOAuth2UserService customOidcUserService( + AccessControlService acs, + ReactiveOAuth2UserService oauth2UserService) { final OidcReactiveOAuth2UserService delegate = new OidcReactiveOAuth2UserService(); + + // Use our custom OAuth2 user service which may have proxy support + delegate.setOauth2UserService(oauth2UserService); + return request -> delegate.loadUser(request) .flatMap(user -> { var provider = getProviderByProviderId(request.getClientRegistration().getRegistrationId()); @@ -100,8 +109,17 @@ public ReactiveOAuth2UserService customOidcUserServic } @Bean - public ReactiveOAuth2UserService customOauth2UserService(AccessControlService acs) { + public ReactiveOAuth2UserService customOauth2UserService( + AccessControlService acs, + @Autowired(required = false) @Qualifier("oauth2WebClient") WebClient oauth2WebClient) { final DefaultReactiveOAuth2UserService delegate = new DefaultReactiveOAuth2UserService(); + + // If proxy-configured WebClient is available, use it + if (oauth2WebClient != null) { + delegate.setWebClient(oauth2WebClient); + log.debug("OAuth2 user service configured with custom WebClient (proxy support)"); + } + return request -> delegate.loadUser(request) .flatMap(user -> { var provider = getProviderByProviderId(request.getClientRegistration().getRegistrationId()); diff --git a/api/src/main/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfig.java new file mode 100644 index 000000000..46b36199f --- /dev/null +++ b/api/src/main/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfig.java @@ -0,0 +1,53 @@ +package io.kafbat.ui.config.auth; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +@Slf4j +@Configuration +@ConditionalOnProperty(value = "auth.type", havingValue = "OAUTH2") +@EnableConfigurationProperties(SimpleOAuthProxyConfig.ProxyProperties.class) +public class SimpleOAuthProxyConfig { + + @Data + @ConfigurationProperties("auth.oauth2.proxy") + public static class ProxyProperties { + private boolean enabled = false; + private String host; + private Integer port; + } + + @Bean(name = "oauth2WebClient") + @ConditionalOnProperty(value = "auth.oauth2.proxy.enabled", havingValue = "true") + public WebClient oauth2WebClient(ProxyProperties proxyProperties) { + HttpClient httpClient; + + if (proxyProperties.getHost() != null && proxyProperties.getPort() != null) { + // Use explicit proxy configuration + log.info("OAuth2 configured with explicit proxy: {}:{}", + proxyProperties.getHost(), proxyProperties.getPort()); + + httpClient = HttpClient.create() + .proxy(proxy -> proxy + .type(reactor.netty.transport.ProxyProvider.Proxy.HTTP) + .host(proxyProperties.getHost()) + .port(proxyProperties.getPort())); + } else { + // Use system proxy properties + log.info("OAuth2 configured to use system proxy properties"); + httpClient = HttpClient.create().proxyWithSystemProperties(); + } + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .build(); + } +} \ No newline at end of file diff --git a/api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigIntegrationTest.java b/api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigIntegrationTest.java new file mode 100644 index 000000000..371485a24 --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigIntegrationTest.java @@ -0,0 +1,151 @@ +package io.kafbat.ui.config.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.reactive.function.client.WebClient; + +class SimpleOAuthProxyConfigIntegrationTest { + + private final MockWebServer oauth2Server = new MockWebServer(); + + @BeforeEach + void startMockServer() throws IOException { + oauth2Server.start(); + } + + @AfterEach + void stopMockServer() throws IOException { + oauth2Server.close(); + } + + @Test + void testWebClientWithExplicitProxyCanMakeRequests() throws Exception { + // Create proxy config - in real usage, this would point to a proxy server + var proxyProps = new SimpleOAuthProxyConfig.ProxyProperties(); + proxyProps.setEnabled(true); + proxyProps.setHost("proxy.example.com"); + proxyProps.setPort(8080); + + // Create WebClient with proxy configuration + var config = new SimpleOAuthProxyConfig(); + WebClient webClient = config.oauth2WebClient(proxyProps); + assertThat(webClient).isNotNull(); + + // Mock OAuth2 server response + oauth2Server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\": \"test-token\", \"token_type\": \"Bearer\"}") + .addHeader("Content-Type", "application/json")); + + // Make a request to the mock server directly (not through proxy, since MockWebServer can't act as proxy) + // This tests that WebClient works and doesn't throw exceptions with proxy config + String response = webClient + .get() + .uri(oauth2Server.url("/oauth/token").toString()) + .retrieve() + .bodyToMono(String.class) + .onErrorReturn("{\"error\": \"proxy_not_available\"}") // Expected since proxy doesn't exist + .block(); + + // In a real environment with a proxy, this would succeed + // Here we just verify the WebClient was created with proxy config + assertThat(response).contains("proxy_not_available"); + } + + @Test + void testWebClientWithSystemProxyProperties() { + // Set system properties + System.setProperty("https.proxyHost", "proxy.example.com"); + System.setProperty("https.proxyPort", "8080"); + + try { + var proxyProps = new SimpleOAuthProxyConfig.ProxyProperties(); + proxyProps.setEnabled(true); + // Don't set host/port - should use system properties + + var config = new SimpleOAuthProxyConfig(); + WebClient webClient = config.oauth2WebClient(proxyProps); + + // Verify WebClient was created successfully with system proxy + assertThat(webClient).isNotNull(); + // Note: Can't easily test actual system proxy routing without a real proxy server + // But this verifies the configuration doesn't throw exceptions + } finally { + // Clean up system properties + System.clearProperty("https.proxyHost"); + System.clearProperty("https.proxyPort"); + } + } + + @Test + void testProxyDisabledDoesNotCreateWebClient() { + var proxyProps = new SimpleOAuthProxyConfig.ProxyProperties(); + proxyProps.setEnabled(false); + + // When proxy is disabled, the bean should not be created + // This would normally be handled by Spring's @ConditionalOnProperty + // In unit test, we just verify the properties default + assertThat(proxyProps.isEnabled()).isFalse(); + } + + @Test + void testWebClientCanMakeRequestsToOAuth2Provider() throws Exception { + // This test verifies the WebClient can communicate with an OAuth2 provider + // In production, the proxy would be between the WebClient and the OAuth2 provider + + // Create a simple WebClient without proxy (simulating direct connection) + var config = new SimpleOAuthProxyConfig(); + var proxyProps = new SimpleOAuthProxyConfig.ProxyProperties(); + proxyProps.setEnabled(true); + proxyProps.setHost("localhost"); + proxyProps.setPort(oauth2Server.getPort()); + + // Note: We can't actually test proxy behavior with MockWebServer + // as it doesn't implement proper proxy protocol (CONNECT method) + WebClient webClient = config.oauth2WebClient(proxyProps); + + // Mock OAuth2 token endpoint + oauth2Server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"access_token\": \"test-token\", \"token_type\": \"Bearer\", \"expires_in\": 3600}") + .addHeader("Content-Type", "application/json")); + + // Mock OAuth2 userinfo endpoint + oauth2Server.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"sub\": \"123456\", \"email\": \"user@example.com\"}") + .addHeader("Content-Type", "application/json")); + + // Test token request (typical OAuth2 flow) + String tokenResponse = webClient.post() + .uri(oauth2Server.url("/oauth/token").toString()) + .bodyValue("grant_type=authorization_code&code=test-code") + .retrieve() + .bodyToMono(String.class) + .onErrorReturn("{\"error\": \"connection_failed\"}") + .block(); + + // Test userinfo request (typical OAuth2 flow) + String userResponse = webClient.get() + .uri(oauth2Server.url("/oauth/userinfo").toString()) + .header("Authorization", "Bearer test-token") + .retrieve() + .bodyToMono(String.class) + .onErrorReturn("{\"error\": \"connection_failed\"}") + .block(); + + // Verify WebClient was created and can attempt requests + // Actual proxy routing can only be verified with integration tests using real proxy + assertThat(webClient).isNotNull(); + // The requests will fail because localhost:port is not a real proxy server + assertThat(tokenResponse).contains("connection_failed"); + assertThat(userResponse).contains("connection_failed"); + } +} \ No newline at end of file diff --git a/api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigTest.java b/api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigTest.java new file mode 100644 index 000000000..801353cc3 --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigTest.java @@ -0,0 +1,77 @@ +package io.kafbat.ui.config.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.web.reactive.function.client.WebClient; + +class SimpleOAuthProxyConfigTest { + + @Test + void proxyDisabledByDefault() { + var props = new SimpleOAuthProxyConfig.ProxyProperties(); + + assertThat(props.isEnabled()).isFalse(); + assertThat(props.getHost()).isNull(); + assertThat(props.getPort()).isNull(); + } + + @Test + void proxyPropertiesCanBeSet() { + var props = new SimpleOAuthProxyConfig.ProxyProperties(); + + props.setEnabled(true); + props.setHost("proxy.example.com"); + props.setPort(8080); + + assertThat(props.isEnabled()).isTrue(); + assertThat(props.getHost()).isEqualTo("proxy.example.com"); + assertThat(props.getPort()).isEqualTo(8080); + } + + @Test + void createsWebClientWithExplicitProxy() { + var config = new SimpleOAuthProxyConfig(); + var props = new SimpleOAuthProxyConfig.ProxyProperties(); + props.setEnabled(true); + props.setHost("proxy.example.com"); + props.setPort(8080); + + WebClient webClient = config.oauth2WebClient(props); + + assertThat(webClient).isNotNull(); + } + + @Test + void createsWebClientWithSystemProxy() { + var config = new SimpleOAuthProxyConfig(); + var props = new SimpleOAuthProxyConfig.ProxyProperties(); + props.setEnabled(true); + // No host/port - uses system properties + + WebClient webClient = config.oauth2WebClient(props); + + assertThat(webClient).isNotNull(); + } + + @Test + void logsCorrectProxyTypeWhenCreatingWebClient() { + var config = new SimpleOAuthProxyConfig(); + + // Test explicit proxy logging + var explicitProps = new SimpleOAuthProxyConfig.ProxyProperties(); + explicitProps.setEnabled(true); + explicitProps.setHost("proxy.example.com"); + explicitProps.setPort(3128); + + WebClient explicitClient = config.oauth2WebClient(explicitProps); + assertThat(explicitClient).isNotNull(); + + // Test system proxy logging + var systemProps = new SimpleOAuthProxyConfig.ProxyProperties(); + systemProps.setEnabled(true); + + WebClient systemClient = config.oauth2WebClient(systemProps); + assertThat(systemClient).isNotNull(); + } +} \ No newline at end of file From cf5e4f48850cacdcfa75fe73c029dc96e41686f6 Mon Sep 17 00:00:00 2001 From: Joshua Nathaniel Miller Date: Mon, 1 Dec 2025 06:43:12 -0600 Subject: [PATCH 2/6] feat: Add OAuth2 proxy support using JVM system properties Simplify OAuth2 authentication to automatically respect JVM proxy settings (e.g., -Dhttps.proxyHost, -Dhttps.proxyPort). The implementation uses Reactor Netty's built-in proxyWithSystemProperties() method, which gracefully handles both proxy and non-proxy scenarios without additional configuration. --- .../ui/config/auth/OAuthSecurityConfig.java | 20 ++- .../config/auth/SimpleOAuthProxyConfig.java | 53 ------ ...SimpleOAuthProxyConfigIntegrationTest.java | 151 ------------------ .../auth/SimpleOAuthProxyConfigTest.java | 77 --------- 4 files changed, 9 insertions(+), 292 deletions(-) delete mode 100644 api/src/main/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfig.java delete mode 100644 api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigIntegrationTest.java delete mode 100644 api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigTest.java diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java index d175edb43..bdf720f66 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java @@ -11,8 +11,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.Nullable; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesMapper; @@ -20,6 +18,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; @@ -40,6 +39,7 @@ import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; @Configuration @ConditionalOnProperty(value = "auth.type", havingValue = "OAUTH2") @@ -92,7 +92,6 @@ public ReactiveOAuth2UserService customOidcUserServic ReactiveOAuth2UserService oauth2UserService) { final OidcReactiveOAuth2UserService delegate = new OidcReactiveOAuth2UserService(); - // Use our custom OAuth2 user service which may have proxy support delegate.setOauth2UserService(oauth2UserService); return request -> delegate.loadUser(request) @@ -109,16 +108,15 @@ public ReactiveOAuth2UserService customOidcUserServic } @Bean - public ReactiveOAuth2UserService customOauth2UserService( - AccessControlService acs, - @Autowired(required = false) @Qualifier("oauth2WebClient") WebClient oauth2WebClient) { + public ReactiveOAuth2UserService customOauth2UserService(AccessControlService acs) { final DefaultReactiveOAuth2UserService delegate = new DefaultReactiveOAuth2UserService(); - // If proxy-configured WebClient is available, use it - if (oauth2WebClient != null) { - delegate.setWebClient(oauth2WebClient); - log.debug("OAuth2 user service configured with custom WebClient (proxy support)"); - } + // Configure WebClient to use system proxy properties (if set) + delegate.setWebClient( + WebClient.builder() + .clientConnector(new ReactorClientHttpConnector( + HttpClient.create().proxyWithSystemProperties())) + .build()); return request -> delegate.loadUser(request) .flatMap(user -> { diff --git a/api/src/main/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfig.java deleted file mode 100644 index 46b36199f..000000000 --- a/api/src/main/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfig.java +++ /dev/null @@ -1,53 +0,0 @@ -package io.kafbat.ui.config.auth; - -import lombok.Data; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.netty.http.client.HttpClient; - -@Slf4j -@Configuration -@ConditionalOnProperty(value = "auth.type", havingValue = "OAUTH2") -@EnableConfigurationProperties(SimpleOAuthProxyConfig.ProxyProperties.class) -public class SimpleOAuthProxyConfig { - - @Data - @ConfigurationProperties("auth.oauth2.proxy") - public static class ProxyProperties { - private boolean enabled = false; - private String host; - private Integer port; - } - - @Bean(name = "oauth2WebClient") - @ConditionalOnProperty(value = "auth.oauth2.proxy.enabled", havingValue = "true") - public WebClient oauth2WebClient(ProxyProperties proxyProperties) { - HttpClient httpClient; - - if (proxyProperties.getHost() != null && proxyProperties.getPort() != null) { - // Use explicit proxy configuration - log.info("OAuth2 configured with explicit proxy: {}:{}", - proxyProperties.getHost(), proxyProperties.getPort()); - - httpClient = HttpClient.create() - .proxy(proxy -> proxy - .type(reactor.netty.transport.ProxyProvider.Proxy.HTTP) - .host(proxyProperties.getHost()) - .port(proxyProperties.getPort())); - } else { - // Use system proxy properties - log.info("OAuth2 configured to use system proxy properties"); - httpClient = HttpClient.create().proxyWithSystemProperties(); - } - - return WebClient.builder() - .clientConnector(new ReactorClientHttpConnector(httpClient)) - .build(); - } -} \ No newline at end of file diff --git a/api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigIntegrationTest.java b/api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigIntegrationTest.java deleted file mode 100644 index 371485a24..000000000 --- a/api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigIntegrationTest.java +++ /dev/null @@ -1,151 +0,0 @@ -package io.kafbat.ui.config.auth; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.web.reactive.function.client.WebClient; - -class SimpleOAuthProxyConfigIntegrationTest { - - private final MockWebServer oauth2Server = new MockWebServer(); - - @BeforeEach - void startMockServer() throws IOException { - oauth2Server.start(); - } - - @AfterEach - void stopMockServer() throws IOException { - oauth2Server.close(); - } - - @Test - void testWebClientWithExplicitProxyCanMakeRequests() throws Exception { - // Create proxy config - in real usage, this would point to a proxy server - var proxyProps = new SimpleOAuthProxyConfig.ProxyProperties(); - proxyProps.setEnabled(true); - proxyProps.setHost("proxy.example.com"); - proxyProps.setPort(8080); - - // Create WebClient with proxy configuration - var config = new SimpleOAuthProxyConfig(); - WebClient webClient = config.oauth2WebClient(proxyProps); - assertThat(webClient).isNotNull(); - - // Mock OAuth2 server response - oauth2Server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody("{\"access_token\": \"test-token\", \"token_type\": \"Bearer\"}") - .addHeader("Content-Type", "application/json")); - - // Make a request to the mock server directly (not through proxy, since MockWebServer can't act as proxy) - // This tests that WebClient works and doesn't throw exceptions with proxy config - String response = webClient - .get() - .uri(oauth2Server.url("/oauth/token").toString()) - .retrieve() - .bodyToMono(String.class) - .onErrorReturn("{\"error\": \"proxy_not_available\"}") // Expected since proxy doesn't exist - .block(); - - // In a real environment with a proxy, this would succeed - // Here we just verify the WebClient was created with proxy config - assertThat(response).contains("proxy_not_available"); - } - - @Test - void testWebClientWithSystemProxyProperties() { - // Set system properties - System.setProperty("https.proxyHost", "proxy.example.com"); - System.setProperty("https.proxyPort", "8080"); - - try { - var proxyProps = new SimpleOAuthProxyConfig.ProxyProperties(); - proxyProps.setEnabled(true); - // Don't set host/port - should use system properties - - var config = new SimpleOAuthProxyConfig(); - WebClient webClient = config.oauth2WebClient(proxyProps); - - // Verify WebClient was created successfully with system proxy - assertThat(webClient).isNotNull(); - // Note: Can't easily test actual system proxy routing without a real proxy server - // But this verifies the configuration doesn't throw exceptions - } finally { - // Clean up system properties - System.clearProperty("https.proxyHost"); - System.clearProperty("https.proxyPort"); - } - } - - @Test - void testProxyDisabledDoesNotCreateWebClient() { - var proxyProps = new SimpleOAuthProxyConfig.ProxyProperties(); - proxyProps.setEnabled(false); - - // When proxy is disabled, the bean should not be created - // This would normally be handled by Spring's @ConditionalOnProperty - // In unit test, we just verify the properties default - assertThat(proxyProps.isEnabled()).isFalse(); - } - - @Test - void testWebClientCanMakeRequestsToOAuth2Provider() throws Exception { - // This test verifies the WebClient can communicate with an OAuth2 provider - // In production, the proxy would be between the WebClient and the OAuth2 provider - - // Create a simple WebClient without proxy (simulating direct connection) - var config = new SimpleOAuthProxyConfig(); - var proxyProps = new SimpleOAuthProxyConfig.ProxyProperties(); - proxyProps.setEnabled(true); - proxyProps.setHost("localhost"); - proxyProps.setPort(oauth2Server.getPort()); - - // Note: We can't actually test proxy behavior with MockWebServer - // as it doesn't implement proper proxy protocol (CONNECT method) - WebClient webClient = config.oauth2WebClient(proxyProps); - - // Mock OAuth2 token endpoint - oauth2Server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody("{\"access_token\": \"test-token\", \"token_type\": \"Bearer\", \"expires_in\": 3600}") - .addHeader("Content-Type", "application/json")); - - // Mock OAuth2 userinfo endpoint - oauth2Server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody("{\"sub\": \"123456\", \"email\": \"user@example.com\"}") - .addHeader("Content-Type", "application/json")); - - // Test token request (typical OAuth2 flow) - String tokenResponse = webClient.post() - .uri(oauth2Server.url("/oauth/token").toString()) - .bodyValue("grant_type=authorization_code&code=test-code") - .retrieve() - .bodyToMono(String.class) - .onErrorReturn("{\"error\": \"connection_failed\"}") - .block(); - - // Test userinfo request (typical OAuth2 flow) - String userResponse = webClient.get() - .uri(oauth2Server.url("/oauth/userinfo").toString()) - .header("Authorization", "Bearer test-token") - .retrieve() - .bodyToMono(String.class) - .onErrorReturn("{\"error\": \"connection_failed\"}") - .block(); - - // Verify WebClient was created and can attempt requests - // Actual proxy routing can only be verified with integration tests using real proxy - assertThat(webClient).isNotNull(); - // The requests will fail because localhost:port is not a real proxy server - assertThat(tokenResponse).contains("connection_failed"); - assertThat(userResponse).contains("connection_failed"); - } -} \ No newline at end of file diff --git a/api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigTest.java b/api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigTest.java deleted file mode 100644 index 801353cc3..000000000 --- a/api/src/test/java/io/kafbat/ui/config/auth/SimpleOAuthProxyConfigTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package io.kafbat.ui.config.auth; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; -import org.springframework.web.reactive.function.client.WebClient; - -class SimpleOAuthProxyConfigTest { - - @Test - void proxyDisabledByDefault() { - var props = new SimpleOAuthProxyConfig.ProxyProperties(); - - assertThat(props.isEnabled()).isFalse(); - assertThat(props.getHost()).isNull(); - assertThat(props.getPort()).isNull(); - } - - @Test - void proxyPropertiesCanBeSet() { - var props = new SimpleOAuthProxyConfig.ProxyProperties(); - - props.setEnabled(true); - props.setHost("proxy.example.com"); - props.setPort(8080); - - assertThat(props.isEnabled()).isTrue(); - assertThat(props.getHost()).isEqualTo("proxy.example.com"); - assertThat(props.getPort()).isEqualTo(8080); - } - - @Test - void createsWebClientWithExplicitProxy() { - var config = new SimpleOAuthProxyConfig(); - var props = new SimpleOAuthProxyConfig.ProxyProperties(); - props.setEnabled(true); - props.setHost("proxy.example.com"); - props.setPort(8080); - - WebClient webClient = config.oauth2WebClient(props); - - assertThat(webClient).isNotNull(); - } - - @Test - void createsWebClientWithSystemProxy() { - var config = new SimpleOAuthProxyConfig(); - var props = new SimpleOAuthProxyConfig.ProxyProperties(); - props.setEnabled(true); - // No host/port - uses system properties - - WebClient webClient = config.oauth2WebClient(props); - - assertThat(webClient).isNotNull(); - } - - @Test - void logsCorrectProxyTypeWhenCreatingWebClient() { - var config = new SimpleOAuthProxyConfig(); - - // Test explicit proxy logging - var explicitProps = new SimpleOAuthProxyConfig.ProxyProperties(); - explicitProps.setEnabled(true); - explicitProps.setHost("proxy.example.com"); - explicitProps.setPort(3128); - - WebClient explicitClient = config.oauth2WebClient(explicitProps); - assertThat(explicitClient).isNotNull(); - - // Test system proxy logging - var systemProps = new SimpleOAuthProxyConfig.ProxyProperties(); - systemProps.setEnabled(true); - - WebClient systemClient = config.oauth2WebClient(systemProps); - assertThat(systemClient).isNotNull(); - } -} \ No newline at end of file From e97eae760cb7cdfc61f097fad55c7ec395b28375 Mon Sep 17 00:00:00 2001 From: Joshua Nathaniel Miller Date: Mon, 1 Dec 2025 20:34:59 -0600 Subject: [PATCH 3/6] feat: Extend OAuth2 proxy support to all authentication endpoints Add proxy-aware WebClient configuration for JWT decoder, opaque token introspection, and OIDC authentication manager. All OAuth2 HTTP calls now respect JVM system proxy properties. - Add ReactiveJwtDecoder bean with proxy-aware JWKS fetching - Add ReactiveOpaqueTokenIntrospector bean with proxy support - Add ReactiveOAuth2AccessTokenResponseClient for token endpoint - Configure OidcAuthorizationCodeReactiveAuthenticationManager - Add WireMock integration tests for with/without proxy scenarios --- api/build.gradle | 1 + .../ui/config/auth/OAuthSecurityConfig.java | 102 +++++-- .../ui/config/auth/OAuthTestSupport.java | 272 ++++++++++++++++++ .../ui/config/auth/OAuthWithProxyTest.java | 236 +++++++++++++++ .../ui/config/auth/OAuthWithoutProxyTest.java | 234 +++++++++++++++ gradle/libs.versions.toml | 3 + 6 files changed, 824 insertions(+), 24 deletions(-) create mode 100644 api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java create mode 100644 api/src/test/java/io/kafbat/ui/config/auth/OAuthWithProxyTest.java create mode 100644 api/src/test/java/io/kafbat/ui/config/auth/OAuthWithoutProxyTest.java diff --git a/api/build.gradle b/api/build.gradle index 156f1d022..73ddff619 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -117,6 +117,7 @@ dependencies { testImplementation libs.okhttp3 testImplementation libs.okhttp3.mockwebserver + testImplementation libs.wiremock testImplementation libs.prometheus.metrics.core } diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java index bdf720f66..17500326b 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java @@ -10,7 +10,6 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.Nullable; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesMapper; @@ -18,12 +17,17 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeReactiveAuthenticationManager; import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.client.oidc.web.server.logout.OidcClientInitiatedServerLogoutSuccessHandler; @@ -35,6 +39,10 @@ import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector; +import org.springframework.security.oauth2.server.resource.introspection.SpringReactiveOpaqueTokenIntrospector; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; import org.springframework.web.reactive.function.client.WebClient; @@ -52,33 +60,39 @@ public class OAuthSecurityConfig extends AbstractAuthSecurityConfig { private final OAuthProperties properties; + /** + * WebClient configured to use system proxy properties (-Dhttps.proxyHost, -Dhttps.proxyPort). + */ + private final WebClient proxyAwareWebClient = WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(HttpClient.create().proxyWithSystemProperties())) + .build(); + @Bean - public SecurityWebFilterChain configure(ServerHttpSecurity http, OAuthLogoutSuccessHandler logoutHandler) { + public SecurityWebFilterChain configure( + ServerHttpSecurity http, + OAuthLogoutSuccessHandler logoutHandler, + ReactiveOAuth2AccessTokenResponseClient tokenResponseClient, + ReactiveOAuth2UserService oidcUserService + ) { log.info("Configuring OAUTH2 authentication."); + var oidcAuthManager = + new OidcAuthorizationCodeReactiveAuthenticationManager(tokenResponseClient, oidcUserService); + var builder = http.authorizeExchange(spec -> spec .pathMatchers(AUTH_WHITELIST) .permitAll() .anyExchange() .authenticated() ) - .oauth2Login(Customizer.withDefaults()) + .oauth2Login(oauth2 -> oauth2.authenticationManager(oidcAuthManager)) .logout(spec -> spec.logoutSuccessHandler(logoutHandler)) .csrf(ServerHttpSecurity.CsrfSpec::disable); - if (properties.getResourceServer() != null) { - OAuth2ResourceServerProperties resourceServer = properties.getResourceServer(); - if (resourceServer.getJwt() != null) { - builder.oauth2ResourceServer((c) -> c.jwt((j) -> j.jwkSetUri(resourceServer.getJwt().getJwkSetUri()))); - } else if (resourceServer.getOpaquetoken() != null) { - OAuth2ResourceServerProperties.Opaquetoken opaquetoken = resourceServer.getOpaquetoken(); - builder.oauth2ResourceServer( - (c) -> c.opaqueToken( - (o) -> o.introspectionUri(opaquetoken.getIntrospectionUri()) - .introspectionClientCredentials(opaquetoken.getClientId(), opaquetoken.getClientSecret()) - ) - ); - } + if (getJwkSetUri() != null) { + builder.oauth2ResourceServer(c -> c.jwt(Customizer.withDefaults())); + } else if (getOpaqueTokenConfig() != null) { + builder.oauth2ResourceServer(c -> c.opaqueToken(Customizer.withDefaults())); } builder.addFilterAt(new StaticFileWebFilter(), SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING); @@ -86,6 +100,40 @@ public SecurityWebFilterChain configure(ServerHttpSecurity http, OAuthLogoutSucc return builder.build(); } + @Bean + public ReactiveOAuth2AccessTokenResponseClient + authorizationCodeTokenResponseClient() { + var client = new WebClientReactiveAuthorizationCodeTokenResponseClient(); + client.setWebClient(proxyAwareWebClient); + return client; + } + + @Bean + @Primary + public ReactiveJwtDecoder jwtDecoder() { + String jwkSetUri = getJwkSetUri(); + if (jwkSetUri == null) { + return token -> Mono.error(new IllegalStateException("JWT decoder not configured")); + } + log.info("Configuring JWT decoder with JWKS URI: {}", jwkSetUri); + return NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).webClient(proxyAwareWebClient).build(); + } + + @Bean + @Primary + public ReactiveOpaqueTokenIntrospector opaqueTokenIntrospector() { + var config = getOpaqueTokenConfig(); + if (config == null) { + return token -> Mono.error(new IllegalStateException("Opaque token introspector not configured")); + } + log.info("Configuring opaque token introspector with URI: {}", config.getIntrospectionUri()); + return new SpringReactiveOpaqueTokenIntrospector( + config.getIntrospectionUri(), + proxyAwareWebClient.mutate() + .defaultHeaders(h -> h.setBasicAuth(config.getClientId(), config.getClientSecret())) + .build()); + } + @Bean public ReactiveOAuth2UserService customOidcUserService( AccessControlService acs, @@ -110,13 +158,7 @@ public ReactiveOAuth2UserService customOidcUserServic @Bean public ReactiveOAuth2UserService customOauth2UserService(AccessControlService acs) { final DefaultReactiveOAuth2UserService delegate = new DefaultReactiveOAuth2UserService(); - - // Configure WebClient to use system proxy properties (if set) - delegate.setWebClient( - WebClient.builder() - .clientConnector(new ReactorClientHttpConnector( - HttpClient.create().proxyWithSystemProperties())) - .build()); + delegate.setWebClient(proxyAwareWebClient); return request -> delegate.loadUser(request) .flatMap(user -> { @@ -147,7 +189,19 @@ public ServerLogoutSuccessHandler defaultOidcLogoutHandler(final ReactiveClientR return new OidcClientInitiatedServerLogoutSuccessHandler(repository); } - @Nullable + private String getJwkSetUri() { + var rs = properties.getResourceServer(); + return rs != null && rs.getJwt() != null ? rs.getJwt().getJwkSetUri() : null; + } + + private OAuth2ResourceServerProperties.Opaquetoken getOpaqueTokenConfig() { + var rs = properties.getResourceServer(); + if (rs == null || rs.getOpaquetoken() == null) { + return null; + } + return rs.getOpaquetoken().getIntrospectionUri() != null ? rs.getOpaquetoken() : null; + } + private ProviderAuthorityExtractor getExtractor(final OAuthProperties.OAuth2Provider provider, AccessControlService acs) { Optional extractor = acs.getOauthExtractors() diff --git a/api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java b/api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java new file mode 100644 index 000000000..40432e2b4 --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java @@ -0,0 +1,272 @@ +package io.kafbat.ui.config.auth; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import io.kafbat.ui.config.auth.logout.OAuthLogoutSuccessHandler; +import io.kafbat.ui.service.rbac.AccessControlService; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler; + +/** + * Shared test support for OAuth2 proxy integration tests. + * + *

Uses TWO WireMock instances: + *

    + *
  • proxyServer - Acts as HTTP proxy, forwards requests to oauthServer
  • + *
  • oauthServer - Acts as the OAuth provider (token endpoint, userinfo, etc.)
  • + *
+ * + *

The proxy uses WireMock's proxiedFrom() to forward requests. + * Tests verify requests arrive at BOTH servers (with proxy) or ONLY OAuth server (without proxy). + */ +public final class OAuthTestSupport { + + // OAuth endpoint paths + public static final String TOKEN_PATH = "/oauth/token"; + public static final String USERINFO_PATH = "/oauth/userinfo"; + public static final String INTROSPECT_PATH = "/oauth/introspect"; + public static final String JWKS_PATH = "/.well-known/jwks.json"; + public static final String AUTHORIZE_PATH = "/oauth/authorize"; + + // Test client credentials + public static final String CLIENT_ID = "test-client"; + public static final String CLIENT_SECRET = "test-secret"; + public static final String REGISTRATION_ID = "test"; + + @Getter + private static WireMockServer proxyServer; + private static WireMockServer oauthServer; + @Getter + private static RSAKey rsaKey; + private static boolean initialized = false; + @Getter + private static boolean proxyEnabled = false; + + private OAuthTestSupport() { + } + + /** + * Initialize both WireMock servers. + * + * @param enableProxy if true, sets system properties to route traffic through proxy + */ + public static synchronized void ensureStarted(boolean enableProxy) { + if (!initialized) { + try { + rsaKey = new RSAKeyGenerator(2048).keyID("test-key").generate(); + } catch (Exception e) { + throw new RuntimeException("Failed to generate RSA key", e); + } + + // OAuth server - the actual destination + oauthServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); + oauthServer.start(); + + // Proxy server - forwards to OAuth server + proxyServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); + proxyServer.start(); + + // Configure proxy to forward all requests to OAuth server + proxyServer.stubFor(WireMock.any(urlMatching(".*")) + .willReturn(aResponse().proxiedFrom("http://localhost:" + oauthServer.port()))); + + initialized = true; + } + + // Set or clear proxy system properties based on enableProxy + if (enableProxy) { + System.setProperty("http.proxyHost", "localhost"); + System.setProperty("http.proxyPort", String.valueOf(proxyServer.port())); + } else { + System.clearProperty("http.proxyHost"); + System.clearProperty("http.proxyPort"); + } + proxyEnabled = enableProxy; + } + + public static void stopServers() { + System.clearProperty("http.proxyHost"); + System.clearProperty("http.proxyPort"); + if (proxyServer != null) { + proxyServer.stop(); + proxyServer = null; + } + if (oauthServer != null) { + oauthServer.stop(); + oauthServer = null; + } + initialized = false; + proxyEnabled = false; + } + + public static void resetServers() { + if (oauthServer != null) { + oauthServer.resetAll(); + } + if (proxyServer != null) { + proxyServer.resetAll(); + // Re-stub proxy forwarding after reset + proxyServer.stubFor(WireMock.any(urlMatching(".*")) + .willReturn(aResponse().proxiedFrom("http://localhost:" + oauthServer.port()))); + } + } + + public static WireMockServer getOAuthServer() { + return oauthServer; + } + + public static String oauthBaseUrl() { + return "http://localhost:" + oauthServer.port(); + } + + public static ClientRegistration testClientRegistration() { + return ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid", "profile", "email") + .authorizationUri(oauthBaseUrl() + AUTHORIZE_PATH) + .tokenUri(oauthBaseUrl() + TOKEN_PATH) + .userInfoUri(oauthBaseUrl() + USERINFO_PATH) + .jwkSetUri(oauthBaseUrl() + JWKS_PATH) + .userNameAttributeName("sub") + .build(); + } + + public static OAuthProperties createOAuthProperties() { + OAuthProperties props = mock(OAuthProperties.class); + OAuthProperties.OAuth2Provider provider = mock(OAuthProperties.OAuth2Provider.class); + when(provider.getProvider()).thenReturn(REGISTRATION_ID); + Map clients = new HashMap<>(); + clients.put(REGISTRATION_ID, provider); + when(props.getClient()).thenReturn(clients); + when(props.getResourceServer()).thenReturn(null); + return props; + } + + public static OAuthProperties createOAuthPropertiesWithJwt() { + OAuthProperties props = createOAuthProperties(); + OAuth2ResourceServerProperties rs = new OAuth2ResourceServerProperties(); + rs.getJwt().setJwkSetUri(oauthBaseUrl() + JWKS_PATH); + when(props.getResourceServer()).thenReturn(rs); + return props; + } + + public static OAuthProperties createOAuthPropertiesWithOpaqueToken() { + OAuthProperties props = createOAuthProperties(); + OAuth2ResourceServerProperties rs = new OAuth2ResourceServerProperties(); + rs.getOpaquetoken().setIntrospectionUri(oauthBaseUrl() + INTROSPECT_PATH); + rs.getOpaquetoken().setClientId(CLIENT_ID); + rs.getOpaquetoken().setClientSecret(CLIENT_SECRET); + when(props.getResourceServer()).thenReturn(rs); + return props; + } + + /** + * Initializer that starts WireMock servers WITH proxy enabled. + */ + public static class WithProxyInitializer + implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext context) { + ensureStarted(true); + } + } + + /** + * Initializer that starts WireMock servers WITHOUT proxy enabled. + */ + public static class WithoutProxyInitializer + implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext context) { + ensureStarted(false); + } + } + + /** + * Abstract base configuration with common test beans. + */ + @Import(OAuthSecurityConfig.class) + public abstract static class AbstractTestConfig { + @Bean(name = "testOAuthProperties") + @Primary + public abstract OAuthProperties authProperties(); + + @Bean + @Primary + public AccessControlService accessControlService() { + AccessControlService acs = mock(AccessControlService.class); + when(acs.getOauthExtractors()).thenReturn(Collections.emptySet()); + return acs; + } + + @Bean + @Primary + public ReactiveClientRegistrationRepository clientRegistrationRepository() { + return new InMemoryReactiveClientRegistrationRepository(testClientRegistration()); + } + + @Bean(name = "testLogoutHandler") + @Primary + public ServerLogoutSuccessHandler defaultOidcLogoutHandler() { + return mock(ServerLogoutSuccessHandler.class); + } + + @Bean + @Primary + public OAuthLogoutSuccessHandler logoutSuccessHandler( + @Qualifier("testOAuthProperties") OAuthProperties props, + @Qualifier("testLogoutHandler") ServerLogoutSuccessHandler handler) { + return new OAuthLogoutSuccessHandler(props, Collections.emptyList(), handler); + } + } + + @TestConfiguration + public static class BaseTestConfig extends AbstractTestConfig { + @Override + public OAuthProperties authProperties() { + return createOAuthProperties(); + } + } + + @TestConfiguration + public static class JwtTestConfig extends AbstractTestConfig { + @Override + public OAuthProperties authProperties() { + return createOAuthPropertiesWithJwt(); + } + } + + @TestConfiguration + public static class OpaqueTokenTestConfig extends AbstractTestConfig { + @Override + public OAuthProperties authProperties() { + return createOAuthPropertiesWithOpaqueToken(); + } + } +} diff --git a/api/src/test/java/io/kafbat/ui/config/auth/OAuthWithProxyTest.java b/api/src/test/java/io/kafbat/ui/config/auth/OAuthWithProxyTest.java new file mode 100644 index 000000000..264df8703 --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/config/auth/OAuthWithProxyTest.java @@ -0,0 +1,236 @@ +package io.kafbat.ui.config.auth; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static io.kafbat.ui.config.auth.OAuthTestSupport.INTROSPECT_PATH; +import static io.kafbat.ui.config.auth.OAuthTestSupport.JWKS_PATH; +import static io.kafbat.ui.config.auth.OAuthTestSupport.TOKEN_PATH; +import static io.kafbat.ui.config.auth.OAuthTestSupport.USERINFO_PATH; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import reactor.test.StepVerifier; + +/** + * Tests that OAuth2 requests go THROUGH the proxy when proxy is configured. + * Verifies requests arrive at BOTH proxy and OAuth server. + */ +class OAuthWithProxyTest { + + @AfterAll + static void stopServers() { + OAuthTestSupport.stopServers(); + } + + @Nested + @SpringBootTest( + classes = {OAuthSecurityConfig.class, OAuthTestSupport.BaseTestConfig.class}, + properties = {"spring.main.allow-bean-definition-overriding=true", "auth.type=OAUTH2"}) + @ContextConfiguration(initializers = OAuthTestSupport.WithProxyInitializer.class) + @DirtiesContext + @ActiveProfiles("test") + class TokenEndpoint { + + @Autowired + ReactiveOAuth2AccessTokenResponseClient tokenClient; + + @Autowired + ReactiveClientRegistrationRepository clientRepo; + + @BeforeEach + void setup() { + OAuthTestSupport.resetServers(); + OAuthTestSupport.getOAuthServer().stubFor(post(urlPathEqualTo(TOKEN_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .withBody("{\"access_token\":\"proxy-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}"))); + } + + @Test + void tokenRequestGoesThruProxy() { + var registration = clientRepo.findByRegistrationId("test").block(); + assertThat(registration).isNotNull(); + + var authRequest = OAuth2AuthorizationRequest.authorizationCode() + .clientId(registration.getClientId()) + .authorizationUri(registration.getProviderDetails().getAuthorizationUri()) + .redirectUri("http://localhost/callback") + .scopes(registration.getScopes()) + .state("state") + .build(); + var authResponse = OAuth2AuthorizationResponse.success("code") + .redirectUri("http://localhost/callback") + .state("state") + .build(); + var grantRequest = new OAuth2AuthorizationCodeGrantRequest( + registration, new OAuth2AuthorizationExchange(authRequest, authResponse)); + + StepVerifier.create(tokenClient.getTokenResponse(grantRequest)) + .assertNext(response -> assertThat(response.getAccessToken().getTokenValue()).isEqualTo("proxy-token")) + .verifyComplete(); + + OAuthTestSupport.getOAuthServer().verify( + postRequestedFor(urlPathEqualTo(TOKEN_PATH)).withRequestBody(containing("code=code"))); + OAuthTestSupport.getProxyServer().verify( + postRequestedFor(urlPathEqualTo(TOKEN_PATH)).withRequestBody(containing("code=code"))); + } + } + + @Nested + @SpringBootTest( + classes = {OAuthSecurityConfig.class, OAuthTestSupport.BaseTestConfig.class}, + properties = {"spring.main.allow-bean-definition-overriding=true", "auth.type=OAUTH2"}) + @ContextConfiguration(initializers = OAuthTestSupport.WithProxyInitializer.class) + @DirtiesContext + @ActiveProfiles("test") + class UserInfo { + + @Autowired + ReactiveOAuth2UserService userService; + + @BeforeEach + void setup() { + OAuthTestSupport.resetServers(); + OAuthTestSupport.getOAuthServer().stubFor(get(urlPathEqualTo(USERINFO_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .withBody("{\"sub\":\"user123\",\"name\":\"Test User\"}"))); + } + + @Test + void userInfoRequestGoesThruProxy() { + var token = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "token", + Instant.now(), Instant.now().plus(Duration.ofHours(1))); + var request = new OAuth2UserRequest(OAuthTestSupport.testClientRegistration(), token); + + StepVerifier.create(userService.loadUser(request)) + .assertNext(user -> { + assertThat(user.getName()).isEqualTo("user123"); + assertThat(user.getAttributes()).containsEntry("name", "Test User"); + }) + .verifyComplete(); + + OAuthTestSupport.getOAuthServer().verify(getRequestedFor(urlPathEqualTo(USERINFO_PATH))); + OAuthTestSupport.getProxyServer().verify(getRequestedFor(urlPathEqualTo(USERINFO_PATH))); + } + } + + @Nested + @SpringBootTest( + classes = {OAuthSecurityConfig.class, OAuthTestSupport.JwtTestConfig.class}, + properties = {"spring.main.allow-bean-definition-overriding=true", "auth.type=OAUTH2"}) + @ContextConfiguration(initializers = OAuthTestSupport.WithProxyInitializer.class) + @DirtiesContext + @ActiveProfiles("test") + class JwtDecoder { + + @Autowired + ReactiveJwtDecoder jwtDecoder; + + @BeforeEach + void setup() { + OAuthTestSupport.resetServers(); + OAuthTestSupport.getOAuthServer().stubFor(get(urlPathEqualTo(JWKS_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .withBody(new JWKSet(OAuthTestSupport.getRsaKey().toPublicJWK()).toString()))); + } + + @Test + void jwksRequestGoesThruProxy() throws Exception { + var claims = new JWTClaimsSet.Builder() + .subject("user") + .issuer(OAuthTestSupport.oauthBaseUrl()) + .expirationTime(new Date(System.currentTimeMillis() + 3600000)) + .build(); + var jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(OAuthTestSupport.getRsaKey().getKeyID()).build(), + claims); + jwt.sign(new RSASSASigner(OAuthTestSupport.getRsaKey())); + + StepVerifier.create(jwtDecoder.decode(jwt.serialize())) + .assertNext(decoded -> assertThat(decoded.getSubject()).isEqualTo("user")) + .verifyComplete(); + + OAuthTestSupport.getOAuthServer().verify(getRequestedFor(urlPathEqualTo(JWKS_PATH))); + OAuthTestSupport.getProxyServer().verify(getRequestedFor(urlPathEqualTo(JWKS_PATH))); + } + } + + @Nested + @SpringBootTest( + classes = {OAuthSecurityConfig.class, OAuthTestSupport.OpaqueTokenTestConfig.class}, + properties = {"spring.main.allow-bean-definition-overriding=true", "auth.type=OAUTH2"}) + @ContextConfiguration(initializers = OAuthTestSupport.WithProxyInitializer.class) + @DirtiesContext + @ActiveProfiles("test") + class OpaqueToken { + + @Autowired + ReactiveOpaqueTokenIntrospector introspector; + + @BeforeEach + void setup() { + OAuthTestSupport.resetServers(); + OAuthTestSupport.getOAuthServer().stubFor(post(urlPathEqualTo(INTROSPECT_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .withBody("{\"active\":true,\"sub\":\"user\",\"username\":\"testuser\"}"))); + } + + @Test + void introspectionRequestGoesThruProxy() { + StepVerifier.create(introspector.introspect("opaque-token")) + .assertNext(principal -> { + assertThat(principal.getName()).isEqualTo("user"); + assertThat(principal.getAttributes()).containsEntry("username", "testuser"); + }) + .verifyComplete(); + + OAuthTestSupport.getOAuthServer().verify( + postRequestedFor(urlPathEqualTo(INTROSPECT_PATH)).withRequestBody(containing("token=opaque-token"))); + OAuthTestSupport.getProxyServer().verify( + postRequestedFor(urlPathEqualTo(INTROSPECT_PATH)).withRequestBody(containing("token=opaque-token"))); + } + } +} diff --git a/api/src/test/java/io/kafbat/ui/config/auth/OAuthWithoutProxyTest.java b/api/src/test/java/io/kafbat/ui/config/auth/OAuthWithoutProxyTest.java new file mode 100644 index 000000000..6cd4a6cf5 --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/config/auth/OAuthWithoutProxyTest.java @@ -0,0 +1,234 @@ +package io.kafbat.ui.config.auth; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static io.kafbat.ui.config.auth.OAuthTestSupport.INTROSPECT_PATH; +import static io.kafbat.ui.config.auth.OAuthTestSupport.JWKS_PATH; +import static io.kafbat.ui.config.auth.OAuthTestSupport.TOKEN_PATH; +import static io.kafbat.ui.config.auth.OAuthTestSupport.USERINFO_PATH; +import static org.assertj.core.api.Assertions.assertThat; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import reactor.test.StepVerifier; + +/** + * Tests that OAuth2 requests BYPASS the proxy when proxy is NOT configured. + * Verifies requests arrive ONLY at OAuth server, NOT at proxy. + */ +class OAuthWithoutProxyTest { + + @AfterAll + static void stopServers() { + OAuthTestSupport.stopServers(); + } + + @Nested + @SpringBootTest( + classes = {OAuthSecurityConfig.class, OAuthTestSupport.BaseTestConfig.class}, + properties = {"spring.main.allow-bean-definition-overriding=true", "auth.type=OAUTH2"}) + @ContextConfiguration(initializers = OAuthTestSupport.WithoutProxyInitializer.class) + @DirtiesContext + @ActiveProfiles("test") + class TokenEndpoint { + + @Autowired + ReactiveOAuth2AccessTokenResponseClient tokenClient; + + @Autowired + ReactiveClientRegistrationRepository clientRepo; + + @BeforeEach + void setup() { + OAuthTestSupport.resetServers(); + OAuthTestSupport.getOAuthServer().stubFor(post(urlPathEqualTo(TOKEN_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .withBody("{\"access_token\":\"direct-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}"))); + } + + @Test + void tokenRequestBypassesProxy() { + var registration = clientRepo.findByRegistrationId("test").block(); + assertThat(registration).isNotNull(); + + var authRequest = OAuth2AuthorizationRequest.authorizationCode() + .clientId(registration.getClientId()) + .authorizationUri(registration.getProviderDetails().getAuthorizationUri()) + .redirectUri("http://localhost/callback") + .scopes(registration.getScopes()) + .state("state") + .build(); + var authResponse = OAuth2AuthorizationResponse.success("code") + .redirectUri("http://localhost/callback") + .state("state") + .build(); + var grantRequest = new OAuth2AuthorizationCodeGrantRequest( + registration, new OAuth2AuthorizationExchange(authRequest, authResponse)); + + StepVerifier.create(tokenClient.getTokenResponse(grantRequest)) + .assertNext(response -> assertThat(response.getAccessToken().getTokenValue()).isEqualTo("direct-token")) + .verifyComplete(); + + OAuthTestSupport.getOAuthServer().verify( + postRequestedFor(urlPathEqualTo(TOKEN_PATH)).withRequestBody(containing("code=code"))); + assertThat(OAuthTestSupport.getProxyServer().getAllServeEvents()).isEmpty(); + } + } + + @Nested + @SpringBootTest( + classes = {OAuthSecurityConfig.class, OAuthTestSupport.BaseTestConfig.class}, + properties = {"spring.main.allow-bean-definition-overriding=true", "auth.type=OAUTH2"}) + @ContextConfiguration(initializers = OAuthTestSupport.WithoutProxyInitializer.class) + @DirtiesContext + @ActiveProfiles("test") + class UserInfo { + + @Autowired + ReactiveOAuth2UserService userService; + + @BeforeEach + void setup() { + OAuthTestSupport.resetServers(); + OAuthTestSupport.getOAuthServer().stubFor(get(urlPathEqualTo(USERINFO_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .withBody("{\"sub\":\"direct-user\",\"name\":\"Direct User\"}"))); + } + + @Test + void userInfoRequestBypassesProxy() { + var token = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "token", + Instant.now(), Instant.now().plus(Duration.ofHours(1))); + var request = new OAuth2UserRequest(OAuthTestSupport.testClientRegistration(), token); + + StepVerifier.create(userService.loadUser(request)) + .assertNext(user -> { + assertThat(user.getName()).isEqualTo("direct-user"); + assertThat(user.getAttributes()).containsEntry("name", "Direct User"); + }) + .verifyComplete(); + + OAuthTestSupport.getOAuthServer().verify(getRequestedFor(urlPathEqualTo(USERINFO_PATH))); + assertThat(OAuthTestSupport.getProxyServer().getAllServeEvents()).isEmpty(); + } + } + + @Nested + @SpringBootTest( + classes = {OAuthSecurityConfig.class, OAuthTestSupport.JwtTestConfig.class}, + properties = {"spring.main.allow-bean-definition-overriding=true", "auth.type=OAUTH2"}) + @ContextConfiguration(initializers = OAuthTestSupport.WithoutProxyInitializer.class) + @DirtiesContext + @ActiveProfiles("test") + class JwtDecoder { + + @Autowired + ReactiveJwtDecoder jwtDecoder; + + @BeforeEach + void setup() { + OAuthTestSupport.resetServers(); + OAuthTestSupport.getOAuthServer().stubFor(get(urlPathEqualTo(JWKS_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .withBody(new JWKSet(OAuthTestSupport.getRsaKey().toPublicJWK()).toString()))); + } + + @Test + void jwksRequestBypassesProxy() throws Exception { + var claims = new JWTClaimsSet.Builder() + .subject("direct-user") + .issuer(OAuthTestSupport.oauthBaseUrl()) + .expirationTime(new Date(System.currentTimeMillis() + 3600000)) + .build(); + var jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(OAuthTestSupport.getRsaKey().getKeyID()).build(), + claims); + jwt.sign(new RSASSASigner(OAuthTestSupport.getRsaKey())); + + StepVerifier.create(jwtDecoder.decode(jwt.serialize())) + .assertNext(decoded -> assertThat(decoded.getSubject()).isEqualTo("direct-user")) + .verifyComplete(); + + OAuthTestSupport.getOAuthServer().verify(getRequestedFor(urlPathEqualTo(JWKS_PATH))); + assertThat(OAuthTestSupport.getProxyServer().getAllServeEvents()).isEmpty(); + } + } + + @Nested + @SpringBootTest( + classes = {OAuthSecurityConfig.class, OAuthTestSupport.OpaqueTokenTestConfig.class}, + properties = {"spring.main.allow-bean-definition-overriding=true", "auth.type=OAUTH2"}) + @ContextConfiguration(initializers = OAuthTestSupport.WithoutProxyInitializer.class) + @DirtiesContext + @ActiveProfiles("test") + class OpaqueToken { + + @Autowired + ReactiveOpaqueTokenIntrospector introspector; + + @BeforeEach + void setup() { + OAuthTestSupport.resetServers(); + OAuthTestSupport.getOAuthServer().stubFor(post(urlPathEqualTo(INTROSPECT_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .withBody("{\"active\":true,\"sub\":\"direct-user\",\"username\":\"directuser\"}"))); + } + + @Test + void introspectionRequestBypassesProxy() { + StepVerifier.create(introspector.introspect("opaque-token")) + .assertNext(principal -> { + assertThat(principal.getName()).isEqualTo("direct-user"); + assertThat(principal.getAttributes()).containsEntry("username", "directuser"); + }) + .verifyComplete(); + + OAuthTestSupport.getOAuthServer().verify( + postRequestedFor(urlPathEqualTo(INTROSPECT_PATH)).withRequestBody(containing("token=opaque-token"))); + assertThat(OAuthTestSupport.getProxyServer().getAllServeEvents()).isEmpty(); + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index df563b834..8c369a422 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ cel = '0.3.0' junit = '5.12.2' mockito = '5.20.0' okhttp3 = '4.12.0' +wiremock = '3.9.1' testcontainers = '1.20.6' swagger-integration-jakarta = '2.2.28' jakarta-annotation-api = '2.1.1' @@ -109,6 +110,8 @@ okhttp3 = { module = 'com.squareup.okhttp3:okhttp', version.ref = 'okhttp3' } okhttp3-mockwebserver = { module = 'com.squareup.okhttp3:mockwebserver', version.ref = 'okhttp3' } okhttp3-logging-intercepter = { module = 'com.squareup.okhttp3:logging-interceptor', version.ref = 'okhttp3' } +wiremock = { module = 'org.wiremock:wiremock-standalone', version.ref = 'wiremock' } + opendatadiscovery-oddrn = { module = 'org.opendatadiscovery:oddrn-generator-java', version.ref = 'odd-oddrn-generator' } opendatadiscovery-client = { module = 'org.opendatadiscovery:ingestion-contract-client', version.ref = 'odd-oddrn-client' } From b74c3ac1f2d0fd33421149cc524ec338349dba30 Mon Sep 17 00:00:00 2001 From: Joshua Nathaniel Miller Date: Mon, 1 Dec 2025 21:15:30 -0600 Subject: [PATCH 4/6] refactor: Make OAuth2 WebClient a qualified Spring bean --- .../ui/config/auth/OAuthSecurityConfig.java | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java index 17500326b..e68205b7d 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java @@ -10,6 +10,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesMapper; @@ -62,10 +63,14 @@ public class OAuthSecurityConfig extends AbstractAuthSecurityConfig { /** * WebClient configured to use system proxy properties (-Dhttps.proxyHost, -Dhttps.proxyPort). + * Created as a bean to ensure system properties are read after context initialization. */ - private final WebClient proxyAwareWebClient = WebClient.builder() - .clientConnector(new ReactorClientHttpConnector(HttpClient.create().proxyWithSystemProperties())) - .build(); + @Bean(name = "oauthWebClient") + public WebClient oauthWebClient() { + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(HttpClient.create().proxyWithSystemProperties())) + .build(); + } @Bean public SecurityWebFilterChain configure( @@ -102,26 +107,26 @@ public SecurityWebFilterChain configure( @Bean public ReactiveOAuth2AccessTokenResponseClient - authorizationCodeTokenResponseClient() { + authorizationCodeTokenResponseClient(@Qualifier("oauthWebClient") WebClient webClient) { var client = new WebClientReactiveAuthorizationCodeTokenResponseClient(); - client.setWebClient(proxyAwareWebClient); + client.setWebClient(webClient); return client; } @Bean @Primary - public ReactiveJwtDecoder jwtDecoder() { + public ReactiveJwtDecoder jwtDecoder(@Qualifier("oauthWebClient") WebClient webClient) { String jwkSetUri = getJwkSetUri(); if (jwkSetUri == null) { return token -> Mono.error(new IllegalStateException("JWT decoder not configured")); } log.info("Configuring JWT decoder with JWKS URI: {}", jwkSetUri); - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).webClient(proxyAwareWebClient).build(); + return NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).webClient(webClient).build(); } @Bean @Primary - public ReactiveOpaqueTokenIntrospector opaqueTokenIntrospector() { + public ReactiveOpaqueTokenIntrospector opaqueTokenIntrospector(@Qualifier("oauthWebClient") WebClient webClient) { var config = getOpaqueTokenConfig(); if (config == null) { return token -> Mono.error(new IllegalStateException("Opaque token introspector not configured")); @@ -129,7 +134,7 @@ public ReactiveOpaqueTokenIntrospector opaqueTokenIntrospector() { log.info("Configuring opaque token introspector with URI: {}", config.getIntrospectionUri()); return new SpringReactiveOpaqueTokenIntrospector( config.getIntrospectionUri(), - proxyAwareWebClient.mutate() + webClient.mutate() .defaultHeaders(h -> h.setBasicAuth(config.getClientId(), config.getClientSecret())) .build()); } @@ -156,9 +161,10 @@ public ReactiveOAuth2UserService customOidcUserServic } @Bean - public ReactiveOAuth2UserService customOauth2UserService(AccessControlService acs) { + public ReactiveOAuth2UserService customOauth2UserService( + AccessControlService acs, @Qualifier("oauthWebClient") WebClient webClient) { final DefaultReactiveOAuth2UserService delegate = new DefaultReactiveOAuth2UserService(); - delegate.setWebClient(proxyAwareWebClient); + delegate.setWebClient(webClient); return request -> delegate.loadUser(request) .flatMap(user -> { From 7964032cd2b7fc09a5be276d458d5ba6808a1b79 Mon Sep 17 00:00:00 2001 From: Joshua Nathaniel Miller Date: Mon, 1 Dec 2025 22:51:18 -0600 Subject: [PATCH 5/6] test: fix issue with netty proxy not allowing localhost as proxy on linux --- .../test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java b/api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java index 40432e2b4..a118e93ac 100644 --- a/api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java +++ b/api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java @@ -100,9 +100,12 @@ public static synchronized void ensureStarted(boolean enableProxy) { if (enableProxy) { System.setProperty("http.proxyHost", "localhost"); System.setProperty("http.proxyPort", String.valueOf(proxyServer.port())); + // Override default nonProxyHosts ("localhost|127.*|[::1]") to allow proxying localhost for tests + System.setProperty("http.nonProxyHosts", "none"); } else { System.clearProperty("http.proxyHost"); System.clearProperty("http.proxyPort"); + System.clearProperty("http.nonProxyHosts"); } proxyEnabled = enableProxy; } @@ -110,6 +113,7 @@ public static synchronized void ensureStarted(boolean enableProxy) { public static void stopServers() { System.clearProperty("http.proxyHost"); System.clearProperty("http.proxyPort"); + System.clearProperty("http.nonProxyHosts"); if (proxyServer != null) { proxyServer.stop(); proxyServer = null; From 7f4906520804031d5b37f20e67bdc87971ec9b1b Mon Sep 17 00:00:00 2001 From: Joshua Nathaniel Miller Date: Tue, 2 Dec 2025 22:36:59 -0600 Subject: [PATCH 6/6] test: provide additional comments and guards for oauth test support --- .../java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java | 3 ++- .../test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java index e68205b7d..c7c835dcf 100644 --- a/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java +++ b/api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java @@ -62,7 +62,8 @@ public class OAuthSecurityConfig extends AbstractAuthSecurityConfig { private final OAuthProperties properties; /** - * WebClient configured to use system proxy properties (-Dhttps.proxyHost, -Dhttps.proxyPort). + * WebClient configured to use system proxy properties (http.proxyHost/https.proxyHost, + * http.proxyPort/https.proxyPort, http.nonProxyHosts/https.nonProxyHosts). * Created as a bean to ensure system properties are read after context initialization. */ @Bean(name = "oauthWebClient") diff --git a/api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java b/api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java index a118e93ac..9476ebd26 100644 --- a/api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java +++ b/api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java @@ -96,6 +96,7 @@ public static synchronized void ensureStarted(boolean enableProxy) { initialized = true; } + // Intentionally outside the initialized block - different tests may toggle proxy on/off // Set or clear proxy system properties based on enableProxy if (enableProxy) { System.setProperty("http.proxyHost", "localhost"); @@ -132,7 +133,9 @@ public static void resetServers() { } if (proxyServer != null) { proxyServer.resetAll(); - // Re-stub proxy forwarding after reset + } + // Re-stub proxy forwarding after reset + if (proxyServer != null && oauthServer != null) { proxyServer.stubFor(WireMock.any(urlMatching(".*")) .willReturn(aResponse().proxiedFrom("http://localhost:" + oauthServer.port()))); }