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 1787ad847..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 @@ -10,7 +10,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.Nullable; +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; @@ -18,11 +18,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; @@ -34,9 +40,15 @@ 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; import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; @Configuration @ConditionalOnProperty(value = "auth.type", havingValue = "OAUTH2") @@ -49,33 +61,44 @@ public class OAuthSecurityConfig extends AbstractAuthSecurityConfig { private final OAuthProperties properties; + /** + * 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") + public WebClient oauthWebClient() { + return 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); @@ -84,8 +107,47 @@ public SecurityWebFilterChain configure(ServerHttpSecurity http, OAuthLogoutSucc } @Bean - public ReactiveOAuth2UserService customOidcUserService(AccessControlService acs) { + public ReactiveOAuth2AccessTokenResponseClient + authorizationCodeTokenResponseClient(@Qualifier("oauthWebClient") WebClient webClient) { + var client = new WebClientReactiveAuthorizationCodeTokenResponseClient(); + client.setWebClient(webClient); + return client; + } + + @Bean + @Primary + 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(webClient).build(); + } + + @Bean + @Primary + public ReactiveOpaqueTokenIntrospector opaqueTokenIntrospector(@Qualifier("oauthWebClient") WebClient webClient) { + 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(), + webClient.mutate() + .defaultHeaders(h -> h.setBasicAuth(config.getClientId(), config.getClientSecret())) + .build()); + } + + @Bean + public ReactiveOAuth2UserService customOidcUserService( + AccessControlService acs, + ReactiveOAuth2UserService oauth2UserService) { final OidcReactiveOAuth2UserService delegate = new OidcReactiveOAuth2UserService(); + + delegate.setOauth2UserService(oauth2UserService); + return request -> delegate.loadUser(request) .flatMap(user -> { var provider = getProviderByProviderId(request.getClientRegistration().getRegistrationId()); @@ -100,8 +162,11 @@ 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(webClient); + return request -> delegate.loadUser(request) .flatMap(user -> { var provider = getProviderByProviderId(request.getClientRegistration().getRegistrationId()); @@ -131,7 +196,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..9476ebd26 --- /dev/null +++ b/api/src/test/java/io/kafbat/ui/config/auth/OAuthTestSupport.java @@ -0,0 +1,279 @@ +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; + } + + // 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"); + 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; + } + + public static void stopServers() { + System.clearProperty("http.proxyHost"); + System.clearProperty("http.proxyPort"); + System.clearProperty("http.nonProxyHosts"); + 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 + if (proxyServer != null && oauthServer != null) { + 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' }