This is an automated email from the ASF dual-hosted git repository. Croway pushed a commit to branch camel-4.22.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit 245038ce4ea77bc7344474c1773a6e407c271541 Author: Andrea Cosentino <[email protected]> AuthorDate: Mon Aug 31 10:08:26 2026 +0200 CAMEL-24452: camel-http - do not send credentials to an authority the endpoint was not configured with (#25830) CAMEL-24452: camel-http - do not send credentials to a host the endpoint was not configured with Two paths handed credentials to a redirect target, which is a host chosen by the remote server rather than by the route, once followRedirects=true. OAuth2ClientConfigurer registers its interceptor with addRequestInterceptorFirst, and HttpClient runs protocol-level request interceptors inside ProtocolExec, which sits below RedirectExec in the exec chain. The interceptor therefore ran again for every redirect hop and re-attached Authorization: Bearer <token> to whatever host the Location header named. It now attaches the token only for the endpoint's own host. HttpCredentialsHelper.getCredentialsProvider() was called with the endpoint's authHost, which is optional and null in the common basic-auth configuration, making the scope new AuthScope(null, -1) - any host, any port, any scheme. HttpClient then offered the credentials to whichever host issued a 401 challenge. The scope now falls back to the endpoint's host when authHost is not set; an explicit authHost still takes precedence. Both need to know the host the endpoint addresses, which createHttpClientConfigurer did not receive. Rather than change that protected signature, a three argument overload carries the target URI and the existing two argument form delegates to it with null, so any subclass overriding or calling it keeps the previous behaviour. The added test drives a real redirect from a server answering to localhost to a second one answering to 127.0.0.1 - a single server will not do, because the bootstrap sets a canonical host name and answers 421 to a mismatched Host. Without the fix the first case delivers "Bearer xxx.yyy.zzz" and the second "Basic c2NvdHQ6dGlnZXI=" to the redirect target. Signed-off-by: Andrea Cosentino <[email protected]> Signed-off-by: Croway <[email protected]> (cherry picked from commit 3a6c27bfae04e528f5a76494db6d6d97b9234551) Co-authored-by: Claude Opus 5 (1M context) <[email protected]> --- .../DefaultAuthenticationHttpClientConfigurer.java | 12 +- .../apache/camel/component/http/HttpComponent.java | 66 +++++++-- .../component/http/HttpCredentialsHelper.java | 10 +- .../component/http/OAuth2ClientConfigurer.java | 53 +++++++ .../http/HttpClientConfigurerOverrideTest.java | 48 +++++++ .../http/HttpOAuth2RedirectTokenLeakTest.java | 155 +++++++++++++++++++++ 6 files changed, 333 insertions(+), 11 deletions(-) diff --git a/components/camel-http/src/main/java/org/apache/camel/component/http/DefaultAuthenticationHttpClientConfigurer.java b/components/camel-http/src/main/java/org/apache/camel/component/http/DefaultAuthenticationHttpClientConfigurer.java index 8bcad925eb64..13a1a9b4f160 100644 --- a/components/camel-http/src/main/java/org/apache/camel/component/http/DefaultAuthenticationHttpClientConfigurer.java +++ b/components/camel-http/src/main/java/org/apache/camel/component/http/DefaultAuthenticationHttpClientConfigurer.java @@ -37,16 +37,26 @@ public class DefaultAuthenticationHttpClientConfigurer implements HttpClientConf private final String username; private final char[] password; private final String domain; + private final String scheme; private final String host; + private final Integer port; private final String bearerToken; private final HttpCredentialsHelper credentialsHelper; public DefaultAuthenticationHttpClientConfigurer(String user, String pwd, String domain, String host, String bearerToken, HttpCredentialsHelper credentialsHelper) { + this(user, pwd, domain, null, host, null, bearerToken, credentialsHelper); + } + + DefaultAuthenticationHttpClientConfigurer(String user, String pwd, String domain, String scheme, String host, + Integer port, String bearerToken, + HttpCredentialsHelper credentialsHelper) { this.username = user; this.password = pwd == null ? new char[0] : pwd.toCharArray(); this.domain = domain; + this.scheme = scheme; this.host = host; + this.port = port; this.bearerToken = bearerToken; this.credentialsHelper = credentialsHelper; } @@ -80,7 +90,7 @@ public class DefaultAuthenticationHttpClientConfigurer implements HttpClientConf defaultcreds = new UsernamePasswordCredentials(username, password); } clientBuilder.setDefaultCredentialsProvider(credentialsHelper - .getCredentialsProvider(host, null, defaultcreds)); + .getCredentialsProvider(scheme, host, port, defaultcreds)); } } diff --git a/components/camel-http/src/main/java/org/apache/camel/component/http/HttpComponent.java b/components/camel-http/src/main/java/org/apache/camel/component/http/HttpComponent.java index d7706108022e..72f74b56e534 100644 --- a/components/camel-http/src/main/java/org/apache/camel/component/http/HttpComponent.java +++ b/components/camel-http/src/main/java/org/apache/camel/component/http/HttpComponent.java @@ -84,6 +84,7 @@ import org.slf4j.LoggerFactory; public class HttpComponent extends HttpCommonComponent implements RestProducerFactory, SSLContextParametersAware { private static final Logger LOG = LoggerFactory.getLogger(HttpComponent.class); + private static final String TARGET_URI_PARAMETER = HttpComponent.class.getName() + ".targetUri"; @Metadata(label = "advanced", description = "To use the custom HttpClientConfigurer to perform configuration of the HttpClient that will be used.") @@ -247,6 +248,12 @@ public class HttpComponent extends HttpCommonComponent implements RestProducerFa * @throws Exception is thrown if error creating configurer */ protected HttpClientConfigurer createHttpClientConfigurer(Map<String, Object> parameters, boolean secure) throws Exception { + URI targetUri = (URI) parameters.remove(TARGET_URI_PARAMETER); + return createHttpClientConfigurer(parameters, secure, targetUri); + } + + private HttpClientConfigurer createHttpClientConfigurer(Map<String, Object> parameters, boolean secure, URI targetUri) + throws Exception { // prefer to use endpoint configured over component configured HttpClientConfigurer configurer = resolveAndRemoveReferenceParameter(parameters, "httpClientConfigurer", HttpClientConfigurer.class); @@ -255,15 +262,15 @@ public class HttpComponent extends HttpCommonComponent implements RestProducerFa configurer = getHttpClientConfigurer(); } HttpCredentialsHelper credentialsProvider = new HttpCredentialsHelper(); - configurer = configureBasicAuthentication(parameters, configurer, credentialsProvider); + configurer = configureBasicAuthentication(parameters, configurer, credentialsProvider, targetUri); configurer = configureHttpProxy(parameters, configurer, secure, credentialsProvider); - configurer = configureOAuth2Authentication(parameters, configurer); + configurer = configureOAuth2Authentication(parameters, configurer, targetUri); return configurer; } private HttpClientConfigurer configureOAuth2Authentication( - Map<String, Object> parameters, HttpClientConfigurer configurer) { + Map<String, Object> parameters, HttpClientConfigurer configurer, URI targetUri) { String clientId = getParameter(parameters, "oauth2ClientId", String.class); String clientSecret = getParameter(parameters, "oauth2ClientSecret", String.class); @@ -302,14 +309,15 @@ public class HttpComponent extends HttpCommonComponent implements RestProducerFa cacheTokens, cachedTokensDefaultExpirySeconds, cachedTokensExpirationMarginSeconds, - useBodyAuthentication)); + useBodyAuthentication, + targetUri)); } return configurer; } private HttpClientConfigurer configureBasicAuthentication( Map<String, Object> parameters, HttpClientConfigurer configurer, - HttpCredentialsHelper credentialsProvider) { + HttpCredentialsHelper credentialsProvider, URI targetUri) { String authUsername = getParameter(parameters, "authUsername", String.class); String authPassword = getParameter(parameters, "authPassword", String.class); @@ -319,7 +327,9 @@ public class HttpComponent extends HttpCommonComponent implements RestProducerFa return CompositeHttpConfigurer.combineConfigurers(configurer, new DefaultAuthenticationHttpClientConfigurer( - authUsername, authPassword, authDomain, authHost, null, credentialsProvider)); + authUsername, authPassword, authDomain, authScopeScheme(authHost, targetUri), + authScopeHost(authHost, targetUri), authScopePort(authHost, targetUri), null, + credentialsProvider)); } else if (this.httpConfiguration != null) { if ("basic".equalsIgnoreCase(this.httpConfiguration.getAuthMethod()) || "bearer".equalsIgnoreCase(this.httpConfiguration.getAuthMethod())) { @@ -327,7 +337,10 @@ public class HttpComponent extends HttpCommonComponent implements RestProducerFa new DefaultAuthenticationHttpClientConfigurer( this.httpConfiguration.getAuthUsername(), this.httpConfiguration.getAuthPassword(), this.httpConfiguration.getAuthDomain(), - this.httpConfiguration.getAuthHost(), this.httpConfiguration.getAuthBearerToken(), + authScopeScheme(this.httpConfiguration.getAuthHost(), targetUri), + authScopeHost(this.httpConfiguration.getAuthHost(), targetUri), + authScopePort(this.httpConfiguration.getAuthHost(), targetUri), + this.httpConfiguration.getAuthBearerToken(), credentialsProvider)); } } @@ -335,6 +348,35 @@ public class HttpComponent extends HttpCommonComponent implements RestProducerFa return configurer; } + /** + * The host the credentials are scoped to. + * <p> + * {@code authHost} is optional and is unset in the common basic-auth configuration, which made the scope + * {@code new AuthScope(null, -1)} - matching any host, any port, any scheme. HttpClient then offers the credentials + * to whichever host issues a 401 challenge, so with {@code followRedirects=true} a redirect chosen by the remote + * server could collect them. Fall back to the authority the endpoint actually addresses. + */ + private static String authScopeHost(String authHost, URI targetUri) { + if (authHost != null) { + return authHost; + } + return targetUri != null ? targetUri.getHost() : null; + } + + private static String authScopeScheme(String authHost, URI targetUri) { + return authHost == null && targetUri != null ? targetUri.getScheme() : null; + } + + private static Integer authScopePort(String authHost, URI targetUri) { + if (authHost != null || targetUri == null) { + return null; + } + if (targetUri.getPort() >= 0) { + return targetUri.getPort(); + } + return "https".equalsIgnoreCase(targetUri.getScheme()) ? 443 : 80; + } + private HttpClientConfigurer configureHttpProxy( Map<String, Object> parameters, HttpClientConfigurer configurer, boolean secure, HttpCredentialsHelper credentialsProvider) { @@ -450,8 +492,14 @@ public class HttpComponent extends HttpCommonComponent implements RestProducerFa // uri part should be without protocol as that was how this component was originally created uri = org.apache.camel.component.http.HttpUtil.removeHttpOrHttpsProtocol(uri); - // create the configurer to use for this endpoint - HttpClientConfigurer configurer = createHttpClientConfigurer(parameters, secure); + // Keep dispatching through the existing protected method so subclasses overriding it continue to be invoked. + HttpClientConfigurer configurer; + parameters.put(TARGET_URI_PARAMETER, uriHttpUriAddress); + try { + configurer = createHttpClientConfigurer(parameters, secure); + } finally { + parameters.remove(TARGET_URI_PARAMETER); + } URI endpointUri = URISupport.createRemainingURI(uriHttpUriAddress, httpClientParameters); endpointUri = URISupport.createRemainingURI( diff --git a/components/camel-http/src/main/java/org/apache/camel/component/http/HttpCredentialsHelper.java b/components/camel-http/src/main/java/org/apache/camel/component/http/HttpCredentialsHelper.java index 6185b6e9da8e..8d9032af31d9 100644 --- a/components/camel-http/src/main/java/org/apache/camel/component/http/HttpCredentialsHelper.java +++ b/components/camel-http/src/main/java/org/apache/camel/component/http/HttpCredentialsHelper.java @@ -39,9 +39,17 @@ public final class HttpCredentialsHelper { public CredentialsProvider getCredentialsProvider( String host, Integer port, Credentials credentials) { + return getCredentialsProvider(null, host, port, credentials); + } + + CredentialsProvider getCredentialsProvider( + String scheme, String host, Integer port, Credentials credentials) { this.credentialsProvider.setCredentials(new AuthScope( + scheme, host, - Objects.requireNonNullElse(port, -1)), credentials); + Objects.requireNonNullElse(port, -1), + null, + null), credentials); return credentialsProvider; } diff --git a/components/camel-http/src/main/java/org/apache/camel/component/http/OAuth2ClientConfigurer.java b/components/camel-http/src/main/java/org/apache/camel/component/http/OAuth2ClientConfigurer.java index 8edb187e02f2..c98d980a3ac6 100644 --- a/components/camel-http/src/main/java/org/apache/camel/component/http/OAuth2ClientConfigurer.java +++ b/components/camel-http/src/main/java/org/apache/camel/component/http/OAuth2ClientConfigurer.java @@ -41,9 +41,13 @@ import org.apache.hc.core5.http.HttpRequest; import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.protocol.HttpContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class OAuth2ClientConfigurer extends ServiceSupport implements HttpClientConfigurer { + private static final Logger LOG = LoggerFactory.getLogger(OAuth2ClientConfigurer.class); + private final String clientId; private final String clientSecret; private final String tokenEndpoint; @@ -54,12 +58,27 @@ public class OAuth2ClientConfigurer extends ServiceSupport implements HttpClient private final static ConcurrentMap<OAuth2URIAndCredentials, TokenCache> tokenCache = new ConcurrentHashMap<>(); private final boolean useBodyAuthentication; private final String resourceIndicator; + private final URI targetUri; private HttpClient httpClient; public OAuth2ClientConfigurer(String clientId, String clientSecret, String tokenEndpoint, String resourceIndicator, String scope, boolean cacheTokens, long cachedTokensDefaultExpirySeconds, long cachedTokensExpirationMarginSeconds, boolean useBodyAuthentication) { + this(clientId, clientSecret, tokenEndpoint, resourceIndicator, scope, cacheTokens, + cachedTokensDefaultExpirySeconds, cachedTokensExpirationMarginSeconds, useBodyAuthentication, null); + } + + /** + * @param targetUri the URI the endpoint addresses. The bearer token is only attached to requests for the same + * authority, so that a redirect chosen by the remote server cannot collect it. Null keeps the + * previous behaviour of attaching it to whatever authority the request names. + */ + OAuth2ClientConfigurer(String clientId, String clientSecret, String tokenEndpoint, String resourceIndicator, + String scope, boolean cacheTokens, + long cachedTokensDefaultExpirySeconds, long cachedTokensExpirationMarginSeconds, + boolean useBodyAuthentication, URI targetUri) { + this.targetUri = targetUri; this.clientId = clientId; this.clientSecret = clientSecret; this.tokenEndpoint = tokenEndpoint; @@ -78,6 +97,14 @@ public class OAuth2ClientConfigurer extends ServiceSupport implements HttpClient clientBuilder.addRequestInterceptorFirst((HttpRequest request, EntityDetails entity, HttpContext context) -> { URI requestUri = getUriFromRequest(request); + if (!isTargetAuthority(requestUri)) { + // HttpClient runs protocol-level request interceptors inside ProtocolExec, which sits below + // RedirectExec, so this runs again for every redirect hop. Without this check the bearer token is + // re-attached to whichever authority the Location header named. + LOG.debug("Not attaching the OAuth2 bearer token to {}, which is not the endpoint's authority {}", + requestUri, targetUri); + return; + } OAuth2URIAndCredentials uriAndCredentials = new OAuth2URIAndCredentials( requestUri, clientId, clientSecret, tokenEndpoint, scope, resourceIndicator); if (cacheTokens) { @@ -103,6 +130,32 @@ public class OAuth2ClientConfigurer extends ServiceSupport implements HttpClient }); } + private boolean isTargetAuthority(URI requestUri) { + if (targetUri == null) { + return true; + } + if (targetUri.getScheme() == null || targetUri.getHost() == null + || requestUri == null || requestUri.getScheme() == null || requestUri.getHost() == null) { + return false; + } + return targetUri.getScheme().equalsIgnoreCase(requestUri.getScheme()) + && targetUri.getHost().equalsIgnoreCase(requestUri.getHost()) + && effectivePort(targetUri) == effectivePort(requestUri); + } + + private static int effectivePort(URI uri) { + if (uri.getPort() >= 0) { + return uri.getPort(); + } + if ("http".equalsIgnoreCase(uri.getScheme())) { + return 80; + } + if ("https".equalsIgnoreCase(uri.getScheme())) { + return 443; + } + return -1; + } + private JsonObject getAccessTokenResponse(HttpClient httpClient) throws IOException { String bodyStr = "grant_type=client_credentials"; if (scope != null) { diff --git a/components/camel-http/src/test/java/org/apache/camel/component/http/HttpClientConfigurerOverrideTest.java b/components/camel-http/src/test/java/org/apache/camel/component/http/HttpClientConfigurerOverrideTest.java new file mode 100644 index 000000000000..074cd08cbba9 --- /dev/null +++ b/components/camel-http/src/test/java/org/apache/camel/component/http/HttpClientConfigurerOverrideTest.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.http; + +import java.util.Map; + +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HttpClientConfigurerOverrideTest extends CamelTestSupport { + + @Test + public void existingTwoArgumentOverrideIsStillInvoked() { + TrackingHttpComponent component = new TrackingHttpComponent(); + context.addComponent("http-tracking", component); + + assertThat(context.getEndpoint("http-tracking://localhost:8080")).isNotNull(); + assertThat(component.invoked).isTrue(); + } + + private static final class TrackingHttpComponent extends HttpComponent { + + private boolean invoked; + + @Override + protected HttpClientConfigurer createHttpClientConfigurer(Map<String, Object> parameters, boolean secure) + throws Exception { + invoked = true; + return super.createHttpClientConfigurer(parameters, secure); + } + } +} diff --git a/components/camel-http/src/test/java/org/apache/camel/component/http/HttpOAuth2RedirectTokenLeakTest.java b/components/camel-http/src/test/java/org/apache/camel/component/http/HttpOAuth2RedirectTokenLeakTest.java new file mode 100644 index 000000000000..13b6b72c5a5e --- /dev/null +++ b/components/camel-http/src/test/java/org/apache/camel/component/http/HttpOAuth2RedirectTokenLeakTest.java @@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.http; + +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.camel.component.http.handler.OAuth2TokenRequestHandler; +import org.apache.camel.util.IOHelper; +import org.apache.hc.core5.http.impl.bootstrap.HttpServer; +import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * HttpClient runs protocol-level request interceptors inside {@code ProtocolExec}, which sits below + * {@code RedirectExec} in the exec chain, so the OAuth2 interceptor runs once per redirect hop. Without a check it + * re-attaches the bearer token to whichever host the {@code Location} header named - a host chosen by the remote + * server, not by the route. + * <p> + * The tests cover both a different host and a different port because credentials are scoped to an authority, not just a + * host name. + */ +public class HttpOAuth2RedirectTokenLeakTest extends BaseHttpTest { + + private static final String FAKE_TOKEN = "xxx.yyy.zzz"; + private static final String CLIENT_ID = "test-client"; + private static final String CLIENT_SECRET = "test-secret"; + + private final AtomicReference<String> authorizationSeenAfterRedirect = new AtomicReference<>(); + + private HttpServer localServer; + private HttpServer differentHostRedirectTarget; + private HttpServer differentPortRedirectTarget; + + @Override + public void setupResources() throws Exception { + differentHostRedirectTarget = createRedirectTarget("127.0.0.1"); + differentHostRedirectTarget.start(); + differentPortRedirectTarget = createRedirectTarget("localhost"); + differentPortRedirectTarget.start(); + + localServer = ServerBootstrap.bootstrap() + .setCanonicalHostName("localhost").setHttpProcessor(getBasicHttpProcessor()) + .setConnectionReuseStrategy(getConnectionReuseStrategy()).setResponseFactory(getHttpResponseFactory()) + .setSslContext(getSSLContext()) + .register("/token", new OAuth2TokenRequestHandler(FAKE_TOKEN, CLIENT_ID, CLIENT_SECRET)) + .register("/redirect-to-different-host", (request, response, context) -> { + response.setHeader("Location", + "http://127.0.0.1:" + differentHostRedirectTarget.getLocalPort() + "/elsewhere"); + response.setCode(302); + }) + .register("/redirect-to-different-port", (request, response, context) -> { + response.setHeader("Location", + "http://localhost:" + differentPortRedirectTarget.getLocalPort() + "/elsewhere"); + response.setCode(302); + }) + .register("/challenge-on-different-host", (request, response, context) -> { + response.setHeader("Location", + "http://127.0.0.1:" + differentHostRedirectTarget.getLocalPort() + "/challenge"); + response.setCode(302); + }) + .register("/challenge-on-different-port", (request, response, context) -> { + response.setHeader("Location", + "http://localhost:" + differentPortRedirectTarget.getLocalPort() + "/challenge"); + response.setCode(302); + }) + .create(); + + localServer.start(); + } + + private HttpServer createRedirectTarget(String canonicalHostName) { + return ServerBootstrap.bootstrap() + .setCanonicalHostName(canonicalHostName).setHttpProcessor(getBasicHttpProcessor()) + .setConnectionReuseStrategy(getConnectionReuseStrategy()).setResponseFactory(getHttpResponseFactory()) + .register("/elsewhere", (request, response, context) -> { + authorizationSeenAfterRedirect.set( + request.containsHeader("Authorization") + ? request.getFirstHeader("Authorization").getValue() : null); + response.setCode(200); + response.setEntity(new StringEntity("Bye World")); + }) + .register("/challenge", (request, response, context) -> { + authorizationSeenAfterRedirect.set( + request.containsHeader("Authorization") + ? request.getFirstHeader("Authorization").getValue() : null); + response.setHeader("WWW-Authenticate", "Basic realm=\"elsewhere\""); + response.setCode(401); + }) + .create(); + } + + @Override + public void cleanupResources() throws Exception { + IOHelper.close(localServer, differentHostRedirectTarget, differentPortRedirectTarget); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + public void theBearerTokenIsNotSentToARedirectTarget(boolean differentHost) { + HttpComponent http = context.getComponent("http", HttpComponent.class); + http.setFollowRedirects(true); + + String tokenEndpoint = "http://localhost:" + localServer.getLocalPort() + "/token"; + String redirectPath = differentHost ? "/redirect-to-different-host" : "/redirect-to-different-port"; + String uri = "http://localhost:" + localServer.getLocalPort() + redirectPath + "?oauth2ClientId=" + CLIENT_ID + + "&oauth2ClientSecret=" + CLIENT_SECRET + "&oauth2TokenEndpoint=" + tokenEndpoint; + + String body = fluentTemplate.to(uri).request(String.class); + + assertThat(body).as("the redirect should still have been followed").isEqualTo("Bye World"); + assertThat(authorizationSeenAfterRedirect.get()) + .as("the bearer token must not be re-attached to the authority the Location header named").isNull(); + } + + /** + * The basic-auth half of the same problem: authHost is optional and unset in the common configuration, which made + * the credentials scope {@code new AuthScope(null, -1)} - any host, any port, any scheme. HttpClient then offers + * the credentials to whichever host issues a 401 challenge, including one reached by following a redirect the + * remote server chose. + */ + @ParameterizedTest + @ValueSource(booleans = { true, false }) + public void basicCredentialsAreNotOfferedToARedirectTarget(boolean differentHost) { + HttpComponent http = context.getComponent("http", HttpComponent.class); + http.setFollowRedirects(true); + + String challengePath = differentHost ? "/challenge-on-different-host" : "/challenge-on-different-port"; + String uri = "http://localhost:" + localServer.getLocalPort() + challengePath + + "?throwExceptionOnFailure=false" + + "&authUsername=scott&authPassword=tiger"; + + fluentTemplate.to(uri).request(String.class); + + assertThat(authorizationSeenAfterRedirect.get()) + .as("basic credentials must not be offered to the authority the Location header named").isNull(); + } +}
