feat(http): ApacheOutboundHttpClientFactory with memoization and startup validation

Adds ApacheOutboundHttpClientFactory (Apache HttpClient 5) that memoizes
CloseableHttpClient instances keyed on effective TLS + timeout config, and
OutboundHttpConfig (@ConfigurationProperties) that validates trusted CA paths
at startup and exposes OutboundHttpClientFactory as a Spring bean.

TRUST_ALL mode disables both cert validation (TrustAllManager in SslContextBuilder)
and hostname verification (NoopHostnameVerifier on SSLConnectionSocketFactoryBuilder).
WireMock HTTPS integration test covers trust-all bypass, system-default PKIX rejection,
and client memoization.

OIDC audit: OidcProviderHelper and OidcTokenExchanger use Nimbus SDK's own HTTP layer
(DefaultResourceRetriever for JWKS, HTTPRequest.send() for token exchange) plus the
bespoke InsecureTlsHelper for TLS skip-verify; neither uses OutboundHttpClientFactory.
Retrofit deferred to a separate follow-up per plan §20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 16:03:56 +02:00
parent 4922748599
commit 000e9d2847
5 changed files with 251 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
package com.cameleer.server.app.http;
import com.cameleer.server.core.http.OutboundHttpProperties;
import com.cameleer.server.core.http.OutboundHttpRequestContext;
import com.cameleer.server.core.http.TrustMode;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.List;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ApacheOutboundHttpClientFactoryIT {
private WireMockServer wm;
private ApacheOutboundHttpClientFactory factory;
@BeforeEach
void setUp() {
wm = new WireMockServer(WireMockConfiguration.options()
.httpDisabled(true).dynamicHttpsPort());
wm.start();
wm.stubFor(get("/ping").willReturn(ok("pong")));
OutboundHttpProperties props = new OutboundHttpProperties(
false, List.of(), Duration.ofSeconds(2), Duration.ofSeconds(5), null, null, null);
factory = new ApacheOutboundHttpClientFactory(props, new SslContextBuilder());
}
@AfterEach
void tearDown() {
wm.stop();
}
@Test
void trustAllBypassesWiremockSelfSignedCert() throws Exception {
CloseableHttpClient client = factory.clientFor(
new OutboundHttpRequestContext(TrustMode.TRUST_ALL, List.of(), null, null));
try (var resp = client.execute(new HttpGet("https://localhost:" + wm.httpsPort() + "/ping"))) {
assertThat(resp.getCode()).isEqualTo(200);
assertThat(EntityUtils.toString(resp.getEntity())).isEqualTo("pong");
}
}
@Test
void systemDefaultRejectsSelfSignedCert() {
CloseableHttpClient client = factory.clientFor(OutboundHttpRequestContext.systemDefault());
assertThatThrownBy(() -> client.execute(new HttpGet("https://localhost:" + wm.httpsPort() + "/ping")))
.hasMessageContaining("PKIX");
}
@Test
void clientsAreMemoizedByContext() {
CloseableHttpClient c1 = factory.clientFor(OutboundHttpRequestContext.systemDefault());
CloseableHttpClient c2 = factory.clientFor(OutboundHttpRequestContext.systemDefault());
assertThat(c1).isSameAs(c2);
}
}