diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java
index c743a05..a06550f 100644
--- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java
@@ -2,6 +2,7 @@
import static com.blazemeter.jmeter.http2.core.LowLevelDebugLog.lowLevelDebug;
+import com.blazemeter.jmeter.http2.core.jetty.CustomWwwAuthenticationProtocolHandler;
import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomClientConnectionFactoryOverHTTP2;
import com.blazemeter.jmeter.http2.core.jetty.custom.http3.CustomClientConnectionFactoryOverHTTP3;
import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler;
@@ -1049,6 +1050,7 @@ public void start() throws Exception {
lowLevelDebug("Starting HttpClient: name={}, http1UpgradeRequired={}",
httpClient.getName(), http1UpgradeRequired);
httpClient.start();
+ CustomWwwAuthenticationProtocolHandler.install(httpClient);
lowLevelDebug("HttpClient started successfully");
} else {
lowLevelDebug("HttpClient already started");
@@ -1056,22 +1058,26 @@ public void start() throws Exception {
if (httpClientNoH3 != httpClient && !httpClientNoH3.isStarted()) {
lowLevelDebug("Starting HttpClient (no HTTP/3): name={}", httpClientNoH3.getName());
httpClientNoH3.start();
+ CustomWwwAuthenticationProtocolHandler.install(httpClientNoH3);
lowLevelDebug("HttpClient (no HTTP/3) started successfully");
}
if (!httpClientHttp1Only.isStarted()) {
lowLevelDebug("Starting HttpClient (HTTP/1.1 only): name={}", httpClientHttp1Only.getName());
httpClientHttp1Only.start();
+ CustomWwwAuthenticationProtocolHandler.install(httpClientHttp1Only);
lowLevelDebug("HttpClient (HTTP/1.1 only) started successfully");
}
if (!httpClientH2cPrior.isStarted()) {
lowLevelDebug("Starting HttpClient (H2C prior knowledge): name={}",
httpClientH2cPrior.getName());
httpClientH2cPrior.start();
+ CustomWwwAuthenticationProtocolHandler.install(httpClientH2cPrior);
lowLevelDebug("HttpClient (H2C prior knowledge) started successfully");
}
if (!httpClientH2cUpgrade.isStarted()) {
lowLevelDebug("Starting HttpClient (H2C upgrade): name={}", httpClientH2cUpgrade.getName());
httpClientH2cUpgrade.start();
+ CustomWwwAuthenticationProtocolHandler.install(httpClientH2cUpgrade);
lowLevelDebug("HttpClient (H2C upgrade) started successfully");
}
}
@@ -1107,6 +1113,7 @@ private HttpClient createHTTP11OnlyClient(String name) throws Exception {
// Start the client
if (!http11Client.isStarted()) {
http11Client.start();
+ CustomWwwAuthenticationProtocolHandler.install(http11Client);
lowLevelDebug("HTTP/1.1-only fallback client started");
}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java
new file mode 100644
index 0000000..027153c
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java
@@ -0,0 +1,379 @@
+package com.blazemeter.jmeter.http2.core.jetty;
+
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.eclipse.jetty.client.Authentication;
+import org.eclipse.jetty.client.Authentication.HeaderInfo;
+import org.eclipse.jetty.client.AuthenticationProtocolHandler;
+import org.eclipse.jetty.client.Connection;
+import org.eclipse.jetty.client.ContentResponse;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.HttpRequestException;
+import org.eclipse.jetty.client.ProtocolHandler;
+import org.eclipse.jetty.client.Request;
+import org.eclipse.jetty.client.Response;
+import org.eclipse.jetty.client.Result;
+import org.eclipse.jetty.client.RetainingResponseListener;
+import org.eclipse.jetty.client.WWWAuthenticationProtocolHandler;
+import org.eclipse.jetty.client.internal.HttpContentResponse;
+import org.eclipse.jetty.client.transport.HttpConversation;
+import org.eclipse.jetty.client.transport.HttpRequest;
+import org.eclipse.jetty.client.transport.ResponseListeners;
+import org.eclipse.jetty.http.HttpField;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.eclipse.jetty.http.QuotedCSV;
+import org.eclipse.jetty.util.NanoTime;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Custom replacement for Jetty's {@link WWWAuthenticationProtocolHandler}.
+ *
+ *
Why Jetty's default is too strict for load-testing clients
+ *
+ * Jetty's handler assumes that every HTTP 401 is an HTTP Authentication challenge
+ * (Basic, Digest, etc.) in the sense of RFC 7235 / RFC 9110. Its {@code accept()} matches on
+ * status 401 alone (before response headers are available). Later, if {@code WWW-Authenticate} is
+ * missing or unparseable, it fails the exchange with {@code HttpResponseException}:
+ * {@code "HTTP protocol violation: Authentication challenge without WWW-Authenticate header"}.
+ *
+ *
That reading of 401 is too narrow. A service is not required to use the
+ * {@code WWW-Authenticate} / {@code Authorization} HTTP Auth framework. It may return 401 simply
+ * to mean "not authorized" while using another mechanism entirely, for example:
+ *
+ * - Bearer / JWT (or other) tokens in {@code Authorization} or a custom header
+ * - API keys, cookies / session, OAuth / OIDC flows
+ * - Application-level errors with a JSON/XML body and no challenge header
+ *
+ * In those cases there is no HTTP Auth challenge to advertise, so omitting
+ * {@code WWW-Authenticate} is normal. Treating it as a protocol violation turns a usable 401
+ * sample into a Non-HTTP error in the sampler.
+ *
+ * Parity with JMeter HTTP (HttpClient4)
+ *
+ * Apache JMeter's HttpClient4 path does not fail the exchange when 401 lacks
+ * {@code WWW-Authenticate}: the sampler receives a normal 401 result. This handler restores that
+ * behavior for the BlazeMeter HTTP (Jetty) client.
+ *
+ *
What this changes vs Jetty's original
+ *
+ *
+ * - 401 without a usable {@code WWW-Authenticate} challenge: forward the response to
+ * the application (same outcome as HttpClient4).
+ * - 401 with a valid challenge and matching credentials in the
+ * {@link org.eclipse.jetty.client.AuthenticationStore}: keep Jetty-style challenge-response
+ * Basic/Digest retry.
+ *
+ *
+ * Why a full custom handler (not a small subclass override)
+ *
+ * The protocol-violation branch lives in Jetty's private inner class
+ * {@code AuthenticationProtocolHandler.AuthenticationListener#onComplete}. There is no protected
+ * hook to override only the "401 without {@code WWW-Authenticate}" path, and helpers such as
+ * {@code forwardSuccessComplete} are private to that listener. Extending
+ * {@link WWWAuthenticationProtocolHandler} therefore cannot change that behavior without
+ * reimplementing {@link #getResponseListener()}. This class replaces the stock handler and keeps
+ * challenge-response Basic/Digest when a real challenge is present.
+ *
+ *
Install via {@link #install(HttpClient)} after {@link HttpClient#start()} so it replaces the
+ * handler Jetty registers in {@code doStart()}.
+ */
+public final class CustomWwwAuthenticationProtocolHandler implements ProtocolHandler {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(CustomWwwAuthenticationProtocolHandler.class);
+ private static final String ATTRIBUTE =
+ CustomWwwAuthenticationProtocolHandler.class.getName() + ".attribute";
+ private static final Pattern CHALLENGE_PATTERN = Pattern.compile(
+ "(?[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)"
+ + "|(?:(?[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)\\s+)?"
+ + "(?:(?[a-zA-Z0-9\\-._~+/]+=*)"
+ + "|(?[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)\\s*=\\s*(?:(?.*)))");
+
+ private final HttpClient client;
+ private final int maxContentLength;
+
+ public CustomWwwAuthenticationProtocolHandler(HttpClient client) {
+ this(client, AuthenticationProtocolHandler.DEFAULT_MAX_CONTENT_LENGTH);
+ }
+
+ public CustomWwwAuthenticationProtocolHandler(HttpClient client, int maxContentLength) {
+ this.client = client;
+ this.maxContentLength = maxContentLength;
+ }
+
+ /**
+ * Removes Jetty's default www-authenticate handler and installs this one. Must be called after
+ * {@link HttpClient#start()}.
+ */
+ public static void install(HttpClient httpClient) {
+ if (httpClient == null) {
+ return;
+ }
+ httpClient.getProtocolHandlers().remove(WWWAuthenticationProtocolHandler.NAME);
+ httpClient.getProtocolHandlers()
+ .put(new CustomWwwAuthenticationProtocolHandler(httpClient));
+ }
+
+ @Override
+ public String getName() {
+ return WWWAuthenticationProtocolHandler.NAME;
+ }
+
+ @Override
+ public boolean accept(Request request, Response response) {
+ return response.getStatus() == HttpStatus.UNAUTHORIZED_401;
+ }
+
+ @Override
+ public Response.Listener getResponseListener() {
+ return new AuthenticationListener();
+ }
+
+ private List getHeaderInfo(String header) {
+ List headerInfos = new ArrayList<>();
+ for (String value : new QuotedCSV(true, header)) {
+ Matcher m = CHALLENGE_PATTERN.matcher(value);
+ if (!m.matches()) {
+ continue;
+ }
+ if (m.group("schemeOnly") != null) {
+ headerInfos.add(new HeaderInfo(HttpHeader.AUTHORIZATION, m.group(1), new HashMap<>()));
+ continue;
+ }
+ if (m.group("scheme") != null) {
+ headerInfos.add(
+ new HeaderInfo(HttpHeader.AUTHORIZATION, m.group("scheme"), new HashMap<>()));
+ }
+ if (headerInfos.isEmpty()) {
+ throw new IllegalArgumentException("Parameters without auth-scheme");
+ }
+ Map authParams = headerInfos.get(headerInfos.size() - 1).getParameters();
+ if (m.group("paramName") != null) {
+ String paramVal = QuotedCSV.unquote(m.group("paramValue"));
+ authParams.put(m.group("paramName"), paramVal);
+ } else if (m.group("token68") != null) {
+ if (!authParams.isEmpty()) {
+ throw new IllegalArgumentException("token68 after auth-params");
+ }
+ authParams.put("base64", m.group("token68"));
+ }
+ }
+ return headerInfos;
+ }
+
+ private class AuthenticationListener extends RetainingResponseListener {
+ private AuthenticationListener() {
+ super(maxContentLength);
+ }
+
+ @Override
+ public void onSuccess(Response response) {
+ super.onSuccess(response);
+ Request request = response.getRequest();
+ if (request.getBody() != null) {
+ request.abort(new HttpRequestException(
+ "Aborting request after receiving a %d response".formatted(response.getStatus()),
+ request));
+ }
+ }
+
+ @Override
+ public void onComplete(Result result) {
+ HttpRequest request = (HttpRequest) result.getRequest();
+ ContentResponse response =
+ new HttpContentResponse(result.getResponse(), getContent(), getMediaType(),
+ getEncoding());
+
+ HttpConversation conversation = request.getConversation();
+ if (conversation.getAttribute(ATTRIBUTE) != null) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Bad credentials for {}", request);
+ }
+ forwardSuccessComplete(request, response);
+ return;
+ }
+
+ List headerInfos = parseAuthenticateHeader(response);
+ if (headerInfos.isEmpty()) {
+ // JMeter HttpClient4 parity: tolerate 401 without WWW-Authenticate.
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("401 without WWW-Authenticate for {}; returning response to application",
+ request);
+ }
+ forwardSuccessComplete(request, response);
+ return;
+ }
+
+ Authentication authentication = null;
+ HeaderInfo headerInfo = null;
+ URI authURI = resolveURI(request, request.getURI());
+ for (HeaderInfo element : headerInfos) {
+ authentication = client.getAuthenticationStore()
+ .findAuthentication(element.getType(), authURI, element.getRealm());
+ if (authentication != null) {
+ headerInfo = element;
+ break;
+ }
+ }
+ if (authentication == null) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("No authentication available for {}", request);
+ }
+ forwardSuccessComplete(request, response);
+ return;
+ }
+
+ Request.Content content = request.getBody();
+ if (content != null && !content.rewind()) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Request content not reproducible for {}", request);
+ }
+ forwardSuccessComplete(request, response);
+ return;
+ }
+
+ try {
+ Authentication.Result authnResult =
+ authentication.authenticate(request, response, headerInfo, conversation);
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Authentication result {}", authnResult);
+ }
+ if (authnResult == null) {
+ forwardSuccessComplete(request, response);
+ return;
+ }
+
+ conversation.setAttribute(ATTRIBUTE, true);
+
+ Request newRequest = copyRequest(request, request.getURI());
+ if (HttpMethod.CONNECT.is(newRequest.getMethod())) {
+ newRequest.path(request.getPath());
+ }
+
+ long timeoutNanoTime = request.getTimeoutNanoTime();
+ if (timeoutNanoTime < Long.MAX_VALUE) {
+ long newTimeout = NanoTime.until(timeoutNanoTime);
+ if (newTimeout > 0) {
+ newRequest.timeout(newTimeout, TimeUnit.NANOSECONDS);
+ } else {
+ TimeoutException failure = new TimeoutException(
+ "Total timeout " + request.getConversation().getTimeout() + " ms elapsed");
+ forwardFailureComplete(request, failure, response, failure);
+ return;
+ }
+ }
+
+ authnResult.apply(newRequest);
+ copyIfAbsent(request, newRequest, HttpHeader.AUTHORIZATION);
+ copyIfAbsent(request, newRequest, HttpHeader.PROXY_AUTHORIZATION);
+
+ AfterAuthenticationListener listener = new AfterAuthenticationListener(authnResult);
+ Connection connection =
+ (Connection) request.getAttributes().get(Connection.class.getName());
+ if (connection != null) {
+ connection.send(newRequest, listener);
+ } else {
+ newRequest.send(listener);
+ }
+ } catch (Throwable ex) {
+ if (LOG.isDebugEnabled()) {
+ LOG.atDebug().setCause(ex).log("Authentication failed");
+ }
+ forwardFailureComplete(request, null, response, ex);
+ }
+ }
+
+ private URI resolveURI(HttpRequest request, URI uri) {
+ if (uri != null) {
+ return uri;
+ }
+ String target = request.getScheme() + "://" + request.getHost();
+ int port = request.getPort();
+ if (port > 0) {
+ target += ":" + port;
+ }
+ return URI.create(target);
+ }
+
+ private void copyIfAbsent(HttpRequest oldRequest, Request newRequest, HttpHeader header) {
+ HttpField field = oldRequest.getHeaders().getField(header);
+ if (field != null && !newRequest.getHeaders().contains(header)) {
+ newRequest.headers(headers -> headers.put(field));
+ }
+ }
+
+ private void forwardSuccessComplete(HttpRequest request, Response response) {
+ HttpConversation conversation = request.getConversation();
+ conversation.updateResponseListeners(null);
+ ResponseListeners responseListeners = conversation.getResponseListeners();
+ responseListeners.emitSuccessComplete(new Result(request, response));
+ }
+
+ private void forwardFailureComplete(HttpRequest request, Throwable requestFailure,
+ Response response, Throwable responseFailure) {
+ HttpConversation conversation = request.getConversation();
+ conversation.updateResponseListeners(null);
+ ResponseListeners responseListeners = conversation.getResponseListeners();
+ if (responseFailure == null) {
+ responseListeners.emitSuccess(response);
+ } else {
+ responseListeners.emitFailure(response, responseFailure);
+ }
+ responseListeners.notifyComplete(
+ new Result(request, requestFailure, response, responseFailure));
+ }
+
+ private List parseAuthenticateHeader(Response response) {
+ List result = new ArrayList<>();
+ List values = response.getHeaders().getValuesList(HttpHeader.WWW_AUTHENTICATE);
+ for (String value : values) {
+ try {
+ result.addAll(getHeaderInfo(value));
+ } catch (IllegalArgumentException e) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Failed to parse authentication header", e);
+ }
+ }
+ }
+ return result;
+ }
+ }
+
+ private Request copyRequest(Request request, URI uri) {
+ try {
+ Method copyRequest =
+ HttpClient.class.getDeclaredMethod("copyRequest", Request.class, URI.class);
+ copyRequest.setAccessible(true);
+ return (Request) copyRequest.invoke(client, request, uri);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException("Unable to copy Jetty request for authentication retry", e);
+ }
+ }
+
+ private class AfterAuthenticationListener implements Response.Listener {
+ private final Authentication.Result authenticationResult;
+
+ private AfterAuthenticationListener(Authentication.Result authenticationResult) {
+ this.authenticationResult = authenticationResult;
+ }
+
+ @Override
+ public void onSuccess(Response response) {
+ int status = response.getStatus();
+ if (HttpStatus.isSuccess(status) || HttpStatus.isRedirectionWithLocation(status)) {
+ client.getAuthenticationStore().addAuthenticationResult(authenticationResult);
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java
index e163088..8a66b07 100644
--- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java
+++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java
@@ -20,6 +20,7 @@
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_200_WITH_BODY;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_302;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_400;
+import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_401_NO_WWW_AUTHENTICATE;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_BIG_RESPONSE;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_200_DEFLATE;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_JSON_ONLY;
@@ -602,6 +603,33 @@ public void shouldReturnFailureSampleResultWhenResponse400() throws Exception {
validateResponse(sampleWithGet(SERVER_PATH_400), expected);
}
+ /**
+ * 401 without {@code WWW-Authenticate} is valid when the server uses a non-HTTP-Auth mechanism
+ * (token, API key, etc.). Jetty's default would throw; {@code CustomWwwAuthenticationProtocolHandler}
+ * returns the 401 like JMeter HttpClient4.
+ */
+ @Test
+ public void shouldReturn401WhenServerOmitsWwwAuthenticateHeader() throws Exception {
+ buildStartedServer();
+ HTTPSampleResult result = sampleWithGet(SERVER_PATH_401_NO_WWW_AUTHENTICATE);
+ softly.assertThat(result.getResponseCode()).isEqualTo("401");
+ softly.assertThat(result.isSuccessful()).isFalse();
+ softly.assertThat(result.getResponseDataAsString()).contains("Unauthorized");
+ }
+
+ /**
+ * Same as {@link #shouldReturn401WhenServerOmitsWwwAuthenticateHeader()} with Auth Manager
+ * configured: without a challenge header there is nothing to match, so the 401 is returned.
+ */
+ @Test
+ public void shouldReturn401WithoutWwwAuthenticateEvenWhenAuthManagerConfigured() throws Exception {
+ buildStartedServer();
+ configureAuthManager(Mechanism.BASIC);
+ HTTPSampleResult result = sampleWithGet(SERVER_PATH_401_NO_WWW_AUTHENTICATE);
+ softly.assertThat(result.getResponseCode()).isEqualTo("401");
+ softly.assertThat(result.isSuccessful()).isFalse();
+ }
+
@Test(expected = UnsupportedOperationException.class)
public void shouldReturnErrorMessageWhenMethodIsNotSupported() throws Exception {
client.sample(sampler, buildBaseResult(createURL(SERVER_PATH_200), "MethodNotSupported"), false,
diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java b/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java
index 65fc790..818e0fa 100644
--- a/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java
+++ b/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java
@@ -81,6 +81,11 @@ public class ServerBuilder {
public static final String SERVER_PATH_200_FILE_SENT = "/test/file";
public static final String SERVER_PATH_BIG_RESPONSE = "/test/big-response";
public static final String SERVER_PATH_400 = "/test/400";
+ /**
+ * 401 without {@code WWW-Authenticate}: common for app-level auth (JWT, API key, etc.), not an
+ * HTTP Auth challenge. Used to assert Jetty does not fail the exchange (JMeter HttpClient4 parity).
+ */
+ public static final String SERVER_PATH_401_NO_WWW_AUTHENTICATE = "/test/401-no-www-authenticate";
public static final String SERVER_PATH_302 = "/test/302";
public static final String SERVER_PATH_200_WITH_BODY = "/test/body";
public static final String SERVER_PATH_JSON_ONLY = "/test/json-only";
@@ -306,6 +311,12 @@ protected void service(HttpServletRequest req, HttpServletResponse resp)
case SERVER_PATH_400:
resp.setStatus(HttpStatus.BAD_REQUEST_400);
break;
+ case SERVER_PATH_401_NO_WWW_AUTHENTICATE:
+ // App-level unauthorized: no WWW-Authenticate (not an HTTP Auth challenge).
+ resp.setStatus(HttpStatus.UNAUTHORIZED_401);
+ resp.setContentType("text/plain; charset=utf-8");
+ resp.getWriter().write("Unauthorized");
+ break;
case SERVER_PATH_302:
resp.addHeader(HTTPConstants.HEADER_LOCATION,
"https://localhost:" + req.getLocalPort() + SERVER_PATH_200);
diff --git a/src/test/java/com/blazemeter/jmeter/http2/parity/Http401WithoutWwwAuthenticateParityTest.java b/src/test/java/com/blazemeter/jmeter/http2/parity/Http401WithoutWwwAuthenticateParityTest.java
new file mode 100644
index 0000000..e921b71
--- /dev/null
+++ b/src/test/java/com/blazemeter/jmeter/http2/parity/Http401WithoutWwwAuthenticateParityTest.java
@@ -0,0 +1,148 @@
+package com.blazemeter.jmeter.http2.parity;
+
+import static com.blazemeter.jmeter.http2.core.ServerBuilder.HOST_NAME;
+import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_401_NO_WWW_AUTHENTICATE;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.blazemeter.jmeter.http2.HTTP2TestBase;
+import com.blazemeter.jmeter.http2.core.HTTP2ClientProfileConfig;
+import com.blazemeter.jmeter.http2.core.HTTP2JettyClient;
+import com.blazemeter.jmeter.http2.core.ServerBuilder;
+import com.blazemeter.jmeter.http2.core.ServerBuilder.TeardownableServer;
+import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler;
+import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils;
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.net.URL;
+import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult;
+import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase;
+import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory;
+import org.apache.jmeter.protocol.http.util.HTTPConstants;
+import org.apache.jmeter.util.JMeterUtils;
+import org.eclipse.jetty.server.ServerConnector;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * Side-by-side parity for HTTP 401 responses that omit {@code WWW-Authenticate}.
+ *
+ * A 401 does not imply HTTP Auth ({@code WWW-Authenticate}). Servers often use JWT, API keys,
+ * cookies, or other schemes and still return 401 without that header. Jetty's default handler
+ * treats every 401 as an HTTP Auth challenge and fails without the header; JMeter HttpClient4
+ * returns a normal 401 sample. {@code CustomWwwAuthenticationProtocolHandler} aligns BlazeMeter
+ * HTTP with that HttpClient4 behavior.
+ */
+public class Http401WithoutWwwAuthenticateParityTest extends HTTP2TestBase {
+
+ private static final String HTTP_CLIENT4 = "HttpClient4";
+
+ private TeardownableServer server;
+ private int serverPort;
+ private HTTP2JettyClient pluginClient;
+ private HTTP2Sampler sampler;
+ private String originalSharedThreadPoolProperty;
+
+ @BeforeClass
+ public static void setupClass() {
+ JMeterTestUtils.setupJmeterEnv();
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ originalSharedThreadPoolProperty = JMeterUtils.getProperty("httpJettyClient.sharedThreadPool");
+ JMeterUtils.setProperty("httpJettyClient.sharedThreadPool", "false");
+ JMeterUtils.setProperty("httpJettyClient.enableHttp1", "true");
+ JMeterUtils.setProperty("httpJettyClient.enableHttp2", "false");
+ JMeterUtils.setProperty("httpJettyClient.enableHttp3", "false");
+
+ server = new ServerBuilder().withHTTP1().buildServer();
+ server.start();
+ serverPort = ((ServerConnector) server.getConnectors()[0]).getLocalPort();
+
+ sampler = new HTTP2Sampler();
+ sampler.setMethod(HTTPConstants.GET);
+ sampler.setProtocol(HTTPConstants.PROTOCOL_HTTP);
+ sampler.setDomain(HOST_NAME);
+ sampler.setPort(serverPort);
+ sampler.setPath(SERVER_PATH_401_NO_WWW_AUTHENTICATE);
+ sampler.setFollowRedirects(false);
+ sampler.setAutoRedirects(false);
+
+ pluginClient = new HTTP2JettyClient(false, "401-parity",
+ HTTP2ClientProfileConfig.builder()
+ .enableHttp1(true)
+ .enableHttp2(false)
+ .enableHttp3(false)
+ .build());
+ pluginClient.start();
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (pluginClient != null) {
+ pluginClient.stop();
+ }
+ if (sampler != null) {
+ sampler.threadFinished();
+ }
+ if (server != null) {
+ server.stop();
+ }
+ if (originalSharedThreadPoolProperty == null) {
+ JMeterUtils.getJMeterProperties().remove("httpJettyClient.sharedThreadPool");
+ } else {
+ JMeterUtils.setProperty("httpJettyClient.sharedThreadPool",
+ originalSharedThreadPoolProperty);
+ }
+ }
+
+ @Test
+ public void pluginShouldMatchHttpClient4For401WithoutWwwAuthenticate() throws Exception {
+ URL url = targetUrl();
+ HTTPSampleResult reference = sampleHttpClient4(url);
+ HTTPSampleResult plugin = samplePlugin(url);
+
+ assertThat(reference.getResponseCode()).isEqualTo("401");
+ assertThat(plugin.getResponseCode())
+ .as("response code")
+ .isEqualTo(reference.getResponseCode());
+ assertThat(plugin.isSuccessful())
+ .as("success flag")
+ .isEqualTo(reference.isSuccessful());
+ assertThat(plugin.getResponseDataAsString())
+ .as("response body")
+ .isEqualTo(reference.getResponseDataAsString());
+ }
+
+ private URL targetUrl() throws Exception {
+ return URI.create("http://" + HOST_NAME + ":" + serverPort
+ + SERVER_PATH_401_NO_WWW_AUTHENTICATE).toURL();
+ }
+
+ private HTTPSampleResult sampleHttpClient4(URL url) throws Exception {
+ HTTPSamplerBase hc4 = HTTPSamplerFactory.newInstance(HTTP_CLIENT4);
+ hc4.setMethod(sampler.getMethod());
+ hc4.setProtocol(sampler.getProtocol());
+ hc4.setDomain(sampler.getDomain());
+ hc4.setPort(sampler.getPort());
+ hc4.setPath(sampler.getPath());
+ hc4.setFollowRedirects(sampler.getFollowRedirects());
+ hc4.setAutoRedirects(sampler.getAutoRedirects());
+ hc4.setUseKeepAlive(true);
+ Method sample = HTTPSamplerBase.class.getDeclaredMethod(
+ "sample", URL.class, String.class, boolean.class, int.class);
+ sample.setAccessible(true);
+ return (HTTPSampleResult) sample.invoke(hc4, url, sampler.getMethod(), false, 1);
+ }
+
+ private HTTPSampleResult samplePlugin(URL url) throws Exception {
+ HTTPSampleResult shell = new HTTPSampleResult();
+ shell.setURL(url);
+ shell.setHTTPMethod(sampler.getMethod());
+ shell.setSampleLabel(sampler.getName());
+ pluginClient.loadProperties();
+ return pluginClient.sample(sampler, shell, false, 0);
+ }
+}