From 4c46617bbfce8b3677332f6b3c476d13a1624ea1 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Thu, 9 Jul 2026 12:00:45 -0300 Subject: [PATCH 1/2] Allow 401 without HTTP AUTH --- .../jmeter/http2/core/HTTP2JettyClient.java | 7 + ...ustomWwwAuthenticationProtocolHandler.java | 379 ++++++++++++++++++ .../http2/core/HTTP2JettyClientTest.java | 28 ++ .../jmeter/http2/core/ServerBuilder.java | 11 + ...tp401WithoutWwwAuthenticateParityTest.java | 148 +++++++ 5 files changed, 573 insertions(+) create mode 100644 src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java create mode 100644 src/test/java/com/blazemeter/jmeter/http2/parity/Http401WithoutWwwAuthenticateParityTest.java 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..ee69485 --- /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: + *

+ * 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

+ * + * + * + *

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 x) { + if (LOG.isDebugEnabled()) { + LOG.atDebug().setCause(x).log("Authentication failed"); + } + forwardFailureComplete(request, null, response, x); + } + } + + 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); + } +} From e187e41dcf2895d2d5e1279e07be8c66290def4d Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Thu, 9 Jul 2026 12:26:57 -0300 Subject: [PATCH 2/2] Fix checkstyle --- .../core/jetty/CustomWwwAuthenticationProtocolHandler.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index ee69485..027153c 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java @@ -286,11 +286,11 @@ public void onComplete(Result result) { } else { newRequest.send(listener); } - } catch (Throwable x) { + } catch (Throwable ex) { if (LOG.isDebugEnabled()) { - LOG.atDebug().setCause(x).log("Authentication failed"); + LOG.atDebug().setCause(ex).log("Authentication failed"); } - forwardFailureComplete(request, null, response, x); + forwardFailureComplete(request, null, response, ex); } }