You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hc.apache.org by ab...@apache.org on 2026/02/14 18:20:22 UTC
(httpcomponents-client) branch master updated: HTTPCLIENT-2414 - Fix Basic auth cache scoping across path prefixes (#802)
This is an automated email from the ASF dual-hosted git repository.
abernal pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/httpcomponents-client.git
The following commit(s) were added to refs/heads/master by this push:
new 0a1895033 HTTPCLIENT-2414 - Fix Basic auth cache scoping across path prefixes (#802)
0a1895033 is described below
commit 0a18950331ef328c4a0bc61285601a2415aba249
Author: Arturo Bernal <ab...@apache.org>
AuthorDate: Sat Feb 14 18:20:16 2026 +0000
HTTPCLIENT-2414 - Fix Basic auth cache scoping across path prefixes (#802)
Preserve AuthExchange pathPrefix on reset to avoid preemptive Authorization reuse outside the protection space.
---
.../testing/sync/TestClientAuthentication.java | 143 +++++++++++++++++-
.../client5/http/impl/async/AsyncProtocolExec.java | 6 +-
.../hc/client5/http/impl/classic/ProtocolExec.java | 6 +-
.../http/impl/async/TestAsyncProtocolExec.java | 159 +++++++++++++++++++++
.../http/impl/classic/TestProtocolExec.java | 33 +++++
5 files changed, 334 insertions(+), 13 deletions(-)
diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestClientAuthentication.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestClientAuthentication.java
index b22c00ee7..da7767e3a 100644
--- a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestClientAuthentication.java
+++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestClientAuthentication.java
@@ -26,13 +26,17 @@
*/
package org.apache.hc.client5.testing.sync;
+import static org.hamcrest.MatcherAssert.assertThat;
+
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Collections;
+import java.util.List;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@@ -57,6 +61,7 @@
import org.apache.hc.client5.http.impl.auth.CredentialsProviderBuilder;
import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.client5.testing.BasicTestAuthenticator;
+import org.apache.hc.client5.testing.auth.AuthResult;
import org.apache.hc.client5.testing.auth.Authenticator;
import org.apache.hc.client5.testing.auth.BearerAuthenticationHandler;
import org.apache.hc.client5.testing.classic.AuthenticatingDecorator;
@@ -82,6 +87,7 @@
import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.http.support.BasicResponseBuilder;
import org.apache.hc.core5.net.URIAuthority;
+import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@@ -339,8 +345,9 @@ void testBasicAuthenticationCredentialsCaching() throws Exception {
Mockito.verify(authStrategy).select(Mockito.any(), Mockito.any(), Mockito.any());
- Assertions.assertEquals(Arrays.asList(401, 200, 200, 200, 200, 200),
- responseQueue.stream().map(HttpResponse::getCode).collect(Collectors.toList()));
+ assertThat(
+ responseQueue.stream().map(HttpResponse::getCode).collect(Collectors.toList()),
+ CoreMatchers.equalTo(Arrays.asList(401, 200, 200, 200, 200, 200)));
}
@Test
@@ -381,8 +388,9 @@ void testBasicAuthenticationCredentialsCachingByPathPrefix() throws Exception {
// There should be only single auth strategy call for all successful message exchanges
Mockito.verify(authStrategy).select(Mockito.any(), Mockito.any(), Mockito.any());
- Assertions.assertEquals(Arrays.asList(401, 200, 200, 200, 200),
- responseQueue.stream().map(HttpResponse::getCode).collect(Collectors.toList()));
+ assertThat(
+ responseQueue.stream().map(HttpResponse::getCode).collect(Collectors.toList()),
+ CoreMatchers.equalTo(Arrays.asList(401, 200, 200, 200, 200)));
responseQueue.clear();
authCache.clear();
@@ -400,10 +408,11 @@ void testBasicAuthenticationCredentialsCachingByPathPrefix() throws Exception {
}
// There should be an auth strategy call for all successful message exchanges
- Mockito.verify(authStrategy, Mockito.times(2)).select(Mockito.any(), Mockito.any(), Mockito.any());
+ Mockito.verify(authStrategy, Mockito.times(3)).select(Mockito.any(), Mockito.any(), Mockito.any());
- Assertions.assertEquals(Arrays.asList(200, 401, 200, 200, 401, 200),
- responseQueue.stream().map(HttpResponse::getCode).collect(Collectors.toList()));
+ assertThat(
+ responseQueue.stream().map(HttpResponse::getCode).collect(Collectors.toList()),
+ CoreMatchers.equalTo(Arrays.asList(200, 401, 200, 401, 200, 401, 200)));
}
@Test
@@ -814,4 +823,124 @@ void testBearerTokenAuthentication() throws Exception {
});
}
+ @Test
+ void testBasicAuthenticationCredentialsCachingDifferentPathPrefixesSameContext() throws Exception {
+ final List<RequestSnapshot> requests = new CopyOnWriteArrayList<>();
+ final Authenticator authenticator = new BasicTestAuthenticator("test:test", "test realm") {
+ @Override
+ public AuthResult perform(final URIAuthority authority,
+ final String requestUri,
+ final String credentials) {
+ requests.add(new RequestSnapshot(requestUri, credentials != null));
+ return super.perform(authority, requestUri, credentials);
+ }
+ };
+ configureServerWithBasicAuth(authenticator, bootstrap -> bootstrap.register("*", new EchoHandler()));
+ final HttpHost target = startServer();
+
+ final List<Integer> responseCodes = new CopyOnWriteArrayList<>();
+
+ configureClient(builder -> builder
+ .addResponseInterceptorLast((response, entity, context) -> responseCodes.add(response.getCode())));
+
+ final TestClient client = client();
+
+ final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create()
+ .add(target, "test", "test".toCharArray())
+ .build();
+
+ final HttpClientContext context = HttpClientContext.create();
+ context.setAuthCache(new BasicAuthCache());
+ context.setCredentialsProvider(credentialsProvider);
+
+ for (final String requestPath : new String[]{"/blah/a", "/blubb/b"}) {
+ final HttpGet httpGet = new HttpGet(requestPath);
+ client.execute(target, httpGet, context, response -> {
+ EntityUtils.consume(response.getEntity());
+ return null;
+ });
+ }
+
+ Assertions.assertEquals(Arrays.asList(401, 200, 401, 200), responseCodes);
+
+ assertHandshakePerPath(requests, "/blah/a");
+ assertHandshakePerPath(requests, "/blubb/b");
+ }
+
+ private static void assertHandshakePerPath(final List<RequestSnapshot> requests, final String path) {
+ final List<RequestSnapshot> filtered = requests.stream()
+ .filter(r -> path.equals(r.path))
+ .collect(Collectors.toList());
+
+ Assertions.assertEquals(2, filtered.size(), "Expected 2 requests for " + path + " (challenge + retry)");
+ Assertions.assertFalse(filtered.get(0).hasAuthorization, "First request to " + path + " must not be preemptive");
+ Assertions.assertTrue(filtered.get(1).hasAuthorization, "Second request to " + path + " must carry Authorization");
+ }
+
+ private static final class RequestSnapshot {
+ private final String path;
+ private final boolean hasAuthorization;
+
+ private RequestSnapshot(final String path, final boolean hasAuthorization) {
+ this.path = path;
+ this.hasAuthorization = hasAuthorization;
+ }
+ }
+
+ @Test
+ void testBasicAuthenticationCredentialsCachingPerPrefixAndReuseWithinPrefix() throws Exception {
+ final List<RequestSnapshot> requests = new CopyOnWriteArrayList<>();
+ final Authenticator authenticator = new BasicTestAuthenticator("test:test", "test realm") {
+ @Override
+ public AuthResult perform(final URIAuthority authority,
+ final String requestUri,
+ final String credentials) {
+ requests.add(new RequestSnapshot(requestUri, credentials != null));
+ return super.perform(authority, requestUri, credentials);
+ }
+ };
+ configureServerWithBasicAuth(authenticator, bootstrap -> bootstrap.register("*", new EchoHandler()));
+ final HttpHost target = startServer();
+
+ final List<Integer> responseCodes = new CopyOnWriteArrayList<>();
+ configureClient(builder -> builder
+ .addResponseInterceptorLast((response, entity, context) -> responseCodes.add(response.getCode())));
+
+ final TestClient client = client();
+
+ final CredentialsProvider credentialsProvider = CredentialsProviderBuilder.create()
+ .add(target, "test", "test".toCharArray())
+ .build();
+
+ final HttpClientContext context = HttpClientContext.create();
+ context.setAuthCache(new BasicAuthCache());
+ context.setCredentialsProvider(credentialsProvider);
+
+ // First hit per prefix must challenge; subsequent hits under same prefix should be preemptive (200 only)
+ for (final String requestPath : new String[]{"/blah/a", "/blubb/b", "/blubb/c", "/blah/d"}) {
+ final HttpGet httpGet = new HttpGet(requestPath);
+ client.execute(target, httpGet, context, response -> {
+ EntityUtils.consume(response.getEntity());
+ return null;
+ });
+ }
+
+ Assertions.assertEquals(Arrays.asList(401, 200, 401, 200, 200, 200), responseCodes);
+
+ assertHandshakePerPath(requests, "/blah/a");
+ assertHandshakePerPath(requests, "/blubb/b");
+ assertPreemptivePerPath(requests, "/blubb/c");
+ assertPreemptivePerPath(requests, "/blah/d");
+ }
+
+ private static void assertPreemptivePerPath(final List<RequestSnapshot> requests, final String path) {
+ final List<RequestSnapshot> filtered = requests.stream()
+ .filter(r -> path.equals(r.path))
+ .collect(Collectors.toList());
+
+ Assertions.assertEquals(1, filtered.size(), "Expected 1 request for " + path + " (preemptive)");
+ Assertions.assertTrue(filtered.get(0).hasAuthorization, "Request to " + path + " must carry Authorization");
+ }
+
+
}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncProtocolExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncProtocolExec.java
index 1b35f8cb3..706793e98 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncProtocolExec.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncProtocolExec.java
@@ -159,9 +159,6 @@ public void execute(
// that of the previous authentication exchange.
targetAuthExchange.reset();
}
- if (targetAuthExchange.getPathPrefix() == null) {
- targetAuthExchange.setPathPrefix(pathPrefix);
- }
if (authCacheKeeper != null) {
authCacheKeeper.loadPreemptively(target, pathPrefix, targetAuthExchange, clientContext);
@@ -340,6 +337,9 @@ private boolean needAuthentication(
targetNeedsAuth = authenticator.handleResponse(target, ChallengeType.TARGET, response,
targetAuthStrategy, targetAuthExchange, context);
+ if (!targetAuthExchange.isConnectionBased() && targetAuthExchange.getPathPrefix() == null) {
+ targetAuthExchange.setPathPrefix(pathPrefix);
+ }
if (authCacheKeeper != null) {
authCacheKeeper.updateOnResponse(target, pathPrefix, targetAuthExchange, context);
}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ProtocolExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ProtocolExec.java
index b927dbc79..4514ddfe9 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ProtocolExec.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/ProtocolExec.java
@@ -163,9 +163,6 @@ public ClassicHttpResponse execute(
// that of the previous authentication exchange.
targetAuthExchange.reset();
}
- if (targetAuthExchange.getPathPrefix() == null) {
- targetAuthExchange.setPathPrefix(pathPrefix);
- }
if (authCacheKeeper != null) {
authCacheKeeper.loadPreemptively(target, pathPrefix, targetAuthExchange, context);
@@ -301,6 +298,9 @@ private boolean needAuthentication(
targetNeedsAuth = authenticator.handleResponse(target, ChallengeType.TARGET, response,
targetAuthStrategy, targetAuthExchange, context);
+ if (!targetAuthExchange.isConnectionBased() && targetAuthExchange.getPathPrefix() == null) {
+ targetAuthExchange.setPathPrefix(pathPrefix);
+ }
if (authCacheKeeper != null) {
authCacheKeeper.updateOnResponse(target, pathPrefix, targetAuthExchange, context);
}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncProtocolExec.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncProtocolExec.java
new file mode 100644
index 000000000..d9f993969
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncProtocolExec.java
@@ -0,0 +1,159 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.hc.client5.http.AuthenticationStrategy;
+import org.apache.hc.client5.http.HttpRoute;
+import org.apache.hc.client5.http.async.AsyncExecCallback;
+import org.apache.hc.client5.http.async.AsyncExecChain;
+import org.apache.hc.client5.http.async.AsyncExecRuntime;
+import org.apache.hc.client5.http.auth.AuthExchange;
+import org.apache.hc.client5.http.auth.AuthScope;
+import org.apache.hc.client5.http.auth.ChallengeType;
+import org.apache.hc.client5.http.auth.StandardAuthScheme;
+import org.apache.hc.client5.http.impl.auth.BasicScheme;
+import org.apache.hc.client5.http.impl.auth.CredentialsProviderBuilder;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.concurrent.CancellableDependency;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.message.BasicHttpResponse;
+import org.apache.hc.core5.http.nio.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+import org.apache.hc.core5.http.support.BasicRequestBuilder;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+class TestAsyncProtocolExec {
+
+ @Mock
+ private AuthenticationStrategy targetAuthStrategy;
+ @Mock
+ private AuthenticationStrategy proxyAuthStrategy;
+ @Mock
+ private AsyncExecChain chain;
+ @Mock
+ private AsyncExecRuntime execRuntime;
+
+ private AsyncProtocolExec protocolExec;
+ private HttpHost target;
+
+ @BeforeEach
+ void setup() {
+ MockitoAnnotations.openMocks(this);
+ protocolExec = new AsyncProtocolExec(targetAuthStrategy, proxyAuthStrategy, null, true);
+ target = new HttpHost("http", "foo", 80);
+ }
+
+ @Test
+ void testAuthExchangePathPrefixRestoredAfterChallenge() throws Exception {
+ final HttpRoute route = new HttpRoute(target);
+ final HttpClientContext context = HttpClientContext.create();
+ context.setCredentialsProvider(CredentialsProviderBuilder.create()
+ .add(new AuthScope(target), "user", "pass".toCharArray())
+ .build());
+
+ Mockito.when(targetAuthStrategy.select(
+ Mockito.eq(ChallengeType.TARGET),
+ Mockito.any(),
+ Mockito.<HttpClientContext>any()))
+ .thenReturn(Collections.singletonList(new BasicScheme()));
+
+ Mockito.when(execRuntime.isEndpointConnected()).thenReturn(true);
+
+ final AtomicInteger proceedCount = new AtomicInteger(0);
+
+ Mockito.doAnswer(invocation -> {
+ final AsyncExecCallback cb = invocation.getArgument(3, AsyncExecCallback.class);
+ final int i = proceedCount.getAndIncrement();
+
+ if (i == 0) {
+ final HttpResponse resp1 = new BasicHttpResponse(401, "Huh?");
+ resp1.setHeader(HttpHeaders.WWW_AUTHENTICATE, StandardAuthScheme.BASIC + " realm=test");
+ cb.handleResponse(resp1, (EntityDetails) null);
+ cb.completed();
+ } else {
+ final HttpResponse resp2 = new BasicHttpResponse(200, "OK");
+ cb.handleResponse(resp2, (EntityDetails) null);
+ cb.completed();
+ }
+ return null;
+ }).when(chain).proceed(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any());
+
+ final HttpRequest request = BasicRequestBuilder.get("http://foo/blah/a").build();
+
+ final CancellableDependency dependency = Mockito.mock(CancellableDependency.class);
+ final AsyncExecChain.Scope scope = new AsyncExecChain.Scope(
+ "test",
+ route,
+ request,
+ dependency,
+ context,
+ execRuntime,
+ null,
+ new AtomicInteger(1));
+
+ final AsyncExecCallback asyncCallback = new AsyncExecCallback() {
+ @Override
+ public AsyncDataConsumer handleResponse(final HttpResponse response, final EntityDetails entityDetails)
+ throws IOException {
+ return Mockito.mock(AsyncDataConsumer.class);
+ }
+
+ @Override
+ public void handleInformationResponse(final HttpResponse response) {
+ }
+
+ @Override
+ public void completed() {
+ }
+
+ @Override
+ public void failed(final Exception cause) {
+ Assertions.fail(cause);
+ }
+ };
+
+ protocolExec.execute(request, (AsyncEntityProducer) null, scope, chain, asyncCallback);
+
+ Assertions.assertEquals(2, proceedCount.get());
+
+ final AuthExchange authExchange = context.getAuthExchange(target);
+ Assertions.assertEquals("/blah/", authExchange.getPathPrefix());
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestProtocolExec.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestProtocolExec.java
index 9f92bec24..7bf32fea5 100644
--- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestProtocolExec.java
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestProtocolExec.java
@@ -276,4 +276,37 @@ void testExecEntityEnclosingRequest() throws Exception {
Assertions.assertEquals(401, response.getCode());
}
+ @Test
+ void testAuthExchangePathPrefixRestoredAfterChallenge() throws Exception {
+ final HttpRoute route = new HttpRoute(target);
+ final ClassicHttpRequest request = new HttpGet("http://foo/blah/a");
+ final HttpClientContext context = HttpClientContext.create();
+
+ context.setCredentialsProvider(CredentialsProviderBuilder.create()
+ .add(new AuthScope(target), "user", "pass".toCharArray())
+ .build());
+
+ final ClassicHttpResponse response1 = new BasicClassicHttpResponse(401, "Huh?");
+ response1.setHeader(HttpHeaders.WWW_AUTHENTICATE, StandardAuthScheme.BASIC + " realm=test");
+ final ClassicHttpResponse response2 = new BasicClassicHttpResponse(200, "OK");
+
+ Mockito.when(chain.proceed(Mockito.same(request), Mockito.any()))
+ .thenReturn(response1, response2);
+
+ Mockito.when(targetAuthStrategy.select(
+ Mockito.eq(ChallengeType.TARGET),
+ Mockito.any(),
+ Mockito.<HttpClientContext>any()))
+ .thenReturn(Collections.singletonList(new BasicScheme()));
+
+ Mockito.when(execRuntime.isConnectionReusable()).thenReturn(true);
+
+ final ExecChain.Scope scope = new ExecChain.Scope("test", route, request, execRuntime, context);
+ final ClassicHttpResponse finalResponse = protocolExec.execute(request, scope, chain);
+
+ Assertions.assertEquals(200, finalResponse.getCode());
+
+ final AuthExchange authExchange = context.getAuthExchange(target);
+ Assertions.assertEquals("/blah/", authExchange.getPathPrefix());
+ }
}