Skip to content

Commit 5a314ac

Browse files
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.
1 parent f51df4c commit 5a314ac

File tree

4 files changed

+301
-2
lines changed

4 files changed

+301
-2
lines changed

api/src/main/java/io/kafbat/ui/config/auth/OAuthSecurityConfig.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import lombok.RequiredArgsConstructor;
1212
import lombok.extern.slf4j.Slf4j;
1313
import org.jetbrains.annotations.Nullable;
14+
import org.springframework.beans.factory.annotation.Autowired;
15+
import org.springframework.beans.factory.annotation.Qualifier;
1416
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
1517
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
1618
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesMapper;
@@ -36,6 +38,7 @@
3638
import org.springframework.security.oauth2.core.user.OAuth2User;
3739
import org.springframework.security.web.server.SecurityWebFilterChain;
3840
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
41+
import org.springframework.web.reactive.function.client.WebClient;
3942
import reactor.core.publisher.Mono;
4043

4144
@Configuration
@@ -84,8 +87,14 @@ public SecurityWebFilterChain configure(ServerHttpSecurity http, OAuthLogoutSucc
8487
}
8588

8689
@Bean
87-
public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> customOidcUserService(AccessControlService acs) {
90+
public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> customOidcUserService(
91+
AccessControlService acs,
92+
ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService) {
8893
final OidcReactiveOAuth2UserService delegate = new OidcReactiveOAuth2UserService();
94+
95+
// Use our custom OAuth2 user service which may have proxy support
96+
delegate.setOauth2UserService(oauth2UserService);
97+
8998
return request -> delegate.loadUser(request)
9099
.flatMap(user -> {
91100
var provider = getProviderByProviderId(request.getClientRegistration().getRegistrationId());
@@ -100,8 +109,17 @@ public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> customOidcUserServic
100109
}
101110

102111
@Bean
103-
public ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> customOauth2UserService(AccessControlService acs) {
112+
public ReactiveOAuth2UserService<OAuth2UserRequest, OAuth2User> customOauth2UserService(
113+
AccessControlService acs,
114+
@Autowired(required = false) @Qualifier("oauth2WebClient") WebClient oauth2WebClient) {
104115
final DefaultReactiveOAuth2UserService delegate = new DefaultReactiveOAuth2UserService();
116+
117+
// If proxy-configured WebClient is available, use it
118+
if (oauth2WebClient != null) {
119+
delegate.setWebClient(oauth2WebClient);
120+
log.debug("OAuth2 user service configured with custom WebClient (proxy support)");
121+
}
122+
105123
return request -> delegate.loadUser(request)
106124
.flatMap(user -> {
107125
var provider = getProviderByProviderId(request.getClientRegistration().getRegistrationId());
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package io.kafbat.ui.config.auth;
2+
3+
import lombok.Data;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
6+
import org.springframework.boot.context.properties.ConfigurationProperties;
7+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
8+
import org.springframework.context.annotation.Bean;
9+
import org.springframework.context.annotation.Configuration;
10+
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
11+
import org.springframework.web.reactive.function.client.WebClient;
12+
import reactor.netty.http.client.HttpClient;
13+
14+
@Slf4j
15+
@Configuration
16+
@ConditionalOnProperty(value = "auth.type", havingValue = "OAUTH2")
17+
@EnableConfigurationProperties(SimpleOAuthProxyConfig.ProxyProperties.class)
18+
public class SimpleOAuthProxyConfig {
19+
20+
@Data
21+
@ConfigurationProperties("auth.oauth2.proxy")
22+
public static class ProxyProperties {
23+
private boolean enabled = false;
24+
private String host;
25+
private Integer port;
26+
}
27+
28+
@Bean(name = "oauth2WebClient")
29+
@ConditionalOnProperty(value = "auth.oauth2.proxy.enabled", havingValue = "true")
30+
public WebClient oauth2WebClient(ProxyProperties proxyProperties) {
31+
HttpClient httpClient;
32+
33+
if (proxyProperties.getHost() != null && proxyProperties.getPort() != null) {
34+
// Use explicit proxy configuration
35+
log.info("OAuth2 configured with explicit proxy: {}:{}",
36+
proxyProperties.getHost(), proxyProperties.getPort());
37+
38+
httpClient = HttpClient.create()
39+
.proxy(proxy -> proxy
40+
.type(reactor.netty.transport.ProxyProvider.Proxy.HTTP)
41+
.host(proxyProperties.getHost())
42+
.port(proxyProperties.getPort()));
43+
} else {
44+
// Use system proxy properties
45+
log.info("OAuth2 configured to use system proxy properties");
46+
httpClient = HttpClient.create().proxyWithSystemProperties();
47+
}
48+
49+
return WebClient.builder()
50+
.clientConnector(new ReactorClientHttpConnector(httpClient))
51+
.build();
52+
}
53+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package io.kafbat.ui.config.auth;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.io.IOException;
6+
import okhttp3.mockwebserver.MockResponse;
7+
import okhttp3.mockwebserver.MockWebServer;
8+
import okhttp3.mockwebserver.RecordedRequest;
9+
import org.junit.jupiter.api.AfterEach;
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.Test;
12+
import org.springframework.web.reactive.function.client.WebClient;
13+
14+
class SimpleOAuthProxyConfigIntegrationTest {
15+
16+
private final MockWebServer oauth2Server = new MockWebServer();
17+
18+
@BeforeEach
19+
void startMockServer() throws IOException {
20+
oauth2Server.start();
21+
}
22+
23+
@AfterEach
24+
void stopMockServer() throws IOException {
25+
oauth2Server.close();
26+
}
27+
28+
@Test
29+
void testWebClientWithExplicitProxyCanMakeRequests() throws Exception {
30+
// Create proxy config - in real usage, this would point to a proxy server
31+
var proxyProps = new SimpleOAuthProxyConfig.ProxyProperties();
32+
proxyProps.setEnabled(true);
33+
proxyProps.setHost("proxy.example.com");
34+
proxyProps.setPort(8080);
35+
36+
// Create WebClient with proxy configuration
37+
var config = new SimpleOAuthProxyConfig();
38+
WebClient webClient = config.oauth2WebClient(proxyProps);
39+
assertThat(webClient).isNotNull();
40+
41+
// Mock OAuth2 server response
42+
oauth2Server.enqueue(new MockResponse()
43+
.setResponseCode(200)
44+
.setBody("{\"access_token\": \"test-token\", \"token_type\": \"Bearer\"}")
45+
.addHeader("Content-Type", "application/json"));
46+
47+
// Make a request to the mock server directly (not through proxy, since MockWebServer can't act as proxy)
48+
// This tests that WebClient works and doesn't throw exceptions with proxy config
49+
String response = webClient
50+
.get()
51+
.uri(oauth2Server.url("/oauth/token").toString())
52+
.retrieve()
53+
.bodyToMono(String.class)
54+
.onErrorReturn("{\"error\": \"proxy_not_available\"}") // Expected since proxy doesn't exist
55+
.block();
56+
57+
// In a real environment with a proxy, this would succeed
58+
// Here we just verify the WebClient was created with proxy config
59+
assertThat(response).contains("proxy_not_available");
60+
}
61+
62+
@Test
63+
void testWebClientWithSystemProxyProperties() {
64+
// Set system properties
65+
System.setProperty("https.proxyHost", "proxy.example.com");
66+
System.setProperty("https.proxyPort", "8080");
67+
68+
try {
69+
var proxyProps = new SimpleOAuthProxyConfig.ProxyProperties();
70+
proxyProps.setEnabled(true);
71+
// Don't set host/port - should use system properties
72+
73+
var config = new SimpleOAuthProxyConfig();
74+
WebClient webClient = config.oauth2WebClient(proxyProps);
75+
76+
// Verify WebClient was created successfully with system proxy
77+
assertThat(webClient).isNotNull();
78+
// Note: Can't easily test actual system proxy routing without a real proxy server
79+
// But this verifies the configuration doesn't throw exceptions
80+
} finally {
81+
// Clean up system properties
82+
System.clearProperty("https.proxyHost");
83+
System.clearProperty("https.proxyPort");
84+
}
85+
}
86+
87+
@Test
88+
void testProxyDisabledDoesNotCreateWebClient() {
89+
var proxyProps = new SimpleOAuthProxyConfig.ProxyProperties();
90+
proxyProps.setEnabled(false);
91+
92+
// When proxy is disabled, the bean should not be created
93+
// This would normally be handled by Spring's @ConditionalOnProperty
94+
// In unit test, we just verify the properties default
95+
assertThat(proxyProps.isEnabled()).isFalse();
96+
}
97+
98+
@Test
99+
void testWebClientCanMakeRequestsToOAuth2Provider() throws Exception {
100+
// This test verifies the WebClient can communicate with an OAuth2 provider
101+
// In production, the proxy would be between the WebClient and the OAuth2 provider
102+
103+
// Create a simple WebClient without proxy (simulating direct connection)
104+
var config = new SimpleOAuthProxyConfig();
105+
var proxyProps = new SimpleOAuthProxyConfig.ProxyProperties();
106+
proxyProps.setEnabled(true);
107+
proxyProps.setHost("localhost");
108+
proxyProps.setPort(oauth2Server.getPort());
109+
110+
// Note: We can't actually test proxy behavior with MockWebServer
111+
// as it doesn't implement proper proxy protocol (CONNECT method)
112+
WebClient webClient = config.oauth2WebClient(proxyProps);
113+
114+
// Mock OAuth2 token endpoint
115+
oauth2Server.enqueue(new MockResponse()
116+
.setResponseCode(200)
117+
.setBody("{\"access_token\": \"test-token\", \"token_type\": \"Bearer\", \"expires_in\": 3600}")
118+
.addHeader("Content-Type", "application/json"));
119+
120+
// Mock OAuth2 userinfo endpoint
121+
oauth2Server.enqueue(new MockResponse()
122+
.setResponseCode(200)
123+
.setBody("{\"sub\": \"123456\", \"email\": \"[email protected]\"}")
124+
.addHeader("Content-Type", "application/json"));
125+
126+
// Test token request (typical OAuth2 flow)
127+
String tokenResponse = webClient.post()
128+
.uri(oauth2Server.url("/oauth/token").toString())
129+
.bodyValue("grant_type=authorization_code&code=test-code")
130+
.retrieve()
131+
.bodyToMono(String.class)
132+
.onErrorReturn("{\"error\": \"connection_failed\"}")
133+
.block();
134+
135+
// Test userinfo request (typical OAuth2 flow)
136+
String userResponse = webClient.get()
137+
.uri(oauth2Server.url("/oauth/userinfo").toString())
138+
.header("Authorization", "Bearer test-token")
139+
.retrieve()
140+
.bodyToMono(String.class)
141+
.onErrorReturn("{\"error\": \"connection_failed\"}")
142+
.block();
143+
144+
// Verify WebClient was created and can attempt requests
145+
// Actual proxy routing can only be verified with integration tests using real proxy
146+
assertThat(webClient).isNotNull();
147+
// The requests will fail because localhost:port is not a real proxy server
148+
assertThat(tokenResponse).contains("connection_failed");
149+
assertThat(userResponse).contains("connection_failed");
150+
}
151+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package io.kafbat.ui.config.auth;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import org.junit.jupiter.api.Test;
6+
import org.springframework.web.reactive.function.client.WebClient;
7+
8+
class SimpleOAuthProxyConfigTest {
9+
10+
@Test
11+
void proxyDisabledByDefault() {
12+
var props = new SimpleOAuthProxyConfig.ProxyProperties();
13+
14+
assertThat(props.isEnabled()).isFalse();
15+
assertThat(props.getHost()).isNull();
16+
assertThat(props.getPort()).isNull();
17+
}
18+
19+
@Test
20+
void proxyPropertiesCanBeSet() {
21+
var props = new SimpleOAuthProxyConfig.ProxyProperties();
22+
23+
props.setEnabled(true);
24+
props.setHost("proxy.example.com");
25+
props.setPort(8080);
26+
27+
assertThat(props.isEnabled()).isTrue();
28+
assertThat(props.getHost()).isEqualTo("proxy.example.com");
29+
assertThat(props.getPort()).isEqualTo(8080);
30+
}
31+
32+
@Test
33+
void createsWebClientWithExplicitProxy() {
34+
var config = new SimpleOAuthProxyConfig();
35+
var props = new SimpleOAuthProxyConfig.ProxyProperties();
36+
props.setEnabled(true);
37+
props.setHost("proxy.example.com");
38+
props.setPort(8080);
39+
40+
WebClient webClient = config.oauth2WebClient(props);
41+
42+
assertThat(webClient).isNotNull();
43+
}
44+
45+
@Test
46+
void createsWebClientWithSystemProxy() {
47+
var config = new SimpleOAuthProxyConfig();
48+
var props = new SimpleOAuthProxyConfig.ProxyProperties();
49+
props.setEnabled(true);
50+
// No host/port - uses system properties
51+
52+
WebClient webClient = config.oauth2WebClient(props);
53+
54+
assertThat(webClient).isNotNull();
55+
}
56+
57+
@Test
58+
void logsCorrectProxyTypeWhenCreatingWebClient() {
59+
var config = new SimpleOAuthProxyConfig();
60+
61+
// Test explicit proxy logging
62+
var explicitProps = new SimpleOAuthProxyConfig.ProxyProperties();
63+
explicitProps.setEnabled(true);
64+
explicitProps.setHost("proxy.example.com");
65+
explicitProps.setPort(3128);
66+
67+
WebClient explicitClient = config.oauth2WebClient(explicitProps);
68+
assertThat(explicitClient).isNotNull();
69+
70+
// Test system proxy logging
71+
var systemProps = new SimpleOAuthProxyConfig.ProxyProperties();
72+
systemProps.setEnabled(true);
73+
74+
WebClient systemClient = config.oauth2WebClient(systemProps);
75+
assertThat(systemClient).isNotNull();
76+
}
77+
}

0 commit comments

Comments
 (0)