diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc index 56e8a9f53..8d6b5b9c0 100644 --- a/docs/modules/ROOT/pages/http-client.adoc +++ b/docs/modules/ROOT/pages/http-client.adoc @@ -777,3 +777,128 @@ To customize the default settings, you can configure `HttpClient` as follows: include::{examples-dir}/resolver/Application.java[lines=18..39] ---- <1> The timeout of each DNS query performed by this resolver will be 500ms. + +[[http-authentication]] +== HTTP Authentication +Reactor Netty `HttpClient` provides a flexible HTTP authentication framework that allows you to implement +custom authentication mechanisms such as SPNEGO/Negotiate, OAuth, Bearer tokens, or any other HTTP-based authentication scheme. + +The framework provides two APIs for HTTP authentication: + +* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiPredicate-java.util.function.BiConsumer-[`httpAuthentication(BiPredicate, BiConsumer)`] - +For authentication where credentials can be computed immediately without delay. The predicate determines when to retry with authentication. Defaults to 1 maximum retry attempt. +* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiPredicate-java.util.function.BiConsumer-int-[`httpAuthentication(BiPredicate, BiConsumer, int maxRetries)`] - +Same as above but allows configuring the maximum count of retry attempts. +* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthenticationWhen-java.util.function.BiPredicate-java.util.function.BiFunction-[`httpAuthenticationWhen(BiPredicate, BiFunction)`] - +For authentication where credentials need to be fetched from external sources or require delayed computation. Returns a `Mono` to support deferred credential retrieval. Defaults to 1 maximum retry attempt. +* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthenticationWhen-java.util.function.BiPredicate-java.util.function.BiFunction-int-[`httpAuthenticationWhen(BiPredicate, BiFunction, int maxRetries)`] - +Same as above but allows configuring the maximum count of retry attempts. + +This approach gives you complete control over the authentication flow while Reactor Netty handles the retry mechanism. + +=== How It Works + +The typical HTTP authentication flow works as follows: + +. The client sends an HTTP request to a protected resource. +. The server responds with an authentication challenge (e.g., `401 Unauthorized` with a `WWW-Authenticate` header). +. The authenticator function is invoked to add authentication credentials to the request. +. The request is retried with the authentication credentials. +. If authentication is successful, the server returns the requested resource. + +=== Immediate Authentication with httpAuthentication + +For authentication scenarios where credentials can be computed immediately without needing to fetch them from external sources, +use {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiPredicate-java.util.function.BiConsumer-[`httpAuthentication(BiPredicate, BiConsumer)`]. +This method requires both a predicate to determine when to retry and a consumer to add authentication headers. + +==== Token-Based Authentication Example + +The following example demonstrates how to implement Bearer token authentication with configurable retry count: + +{examples-link}/authentication/token/Application.java +[%unbreakable] +---- +include::{examples-dir}/authentication/token/Application.java[lines=18..51] +---- +<1> The predicate checks for `401 Unauthorized` responses to trigger authentication retry. +<2> The authenticator adds the `Authorization` header with a Bearer token. +<3> Configures the maximum count of authentication retry attempts to 3. + +==== Basic Authentication Example + +{examples-link}/authentication/basic/Application.java +[%unbreakable] +---- +include::{examples-dir}/authentication/basic/Application.java[lines=18..44] +---- +<1> The predicate checks for `401 Unauthorized` responses to trigger authentication retry. +<2> The authenticator adds Basic authentication credentials to the `Authorization` header. +<3> Uses the default maximum retry count of 1. + +=== Custom Authentication with httpAuthenticationWhen + +When you need custom retry conditions (e.g., checking specific headers or status codes other than 401), +use the {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthenticationWhen-java.util.function.BiPredicate-java.util.function.BiFunction-[`httpAuthenticationWhen(BiPredicate, BiFunction)`] method. + +==== SPNEGO/Negotiate Authentication Example + +For SPNEGO (Kerberos) authentication, you can implement a custom authenticator using Java's GSS-API: + +{examples-link}/authentication/spnego/Application.java +[%unbreakable] +---- +include::{examples-dir}/authentication/spnego/Application.java[lines=18..70] +---- +<1> Custom predicate checks for `401 Unauthorized` with `WWW-Authenticate: Negotiate` header. +<2> The authenticator generates a SPNEGO token using GSS-API and adds it to the `Authorization` header. +<3> Configures the maximum count of authentication retry attempts to 2. + +NOTE: For SPNEGO authentication, you need to configure Kerberos settings (e.g., `krb5.conf`) and JAAS configuration +(e.g., `jaas.conf`) appropriately. Set the system properties `java.security.krb5.conf` and `java.security.auth.login.config` +to point to your configuration files. + +=== Custom Authentication Scenarios + +The authentication framework is flexible enough to support various authentication scenarios: + +==== OAuth 2.0 Authentication +[source,java] +---- +HttpClient client = HttpClient.create() + .httpAuthenticationWhen( + (req, res) -> res.status().code() == 401, // <1> + (req, addr) -> { + return fetchOAuthToken() // <2> + .doOnNext(token -> + req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token)) + .then(); + } + ); +---- +<1> Retry authentication on `401 Unauthorized` responses. +<2> Asynchronously fetch an OAuth token and add it to the request. + +==== Proxy Authentication +[source,java] +---- +HttpClient client = HttpClient.create() + .httpAuthenticationWhen( + (req, res) -> res.status().code() == 407, // <1> + (req, addr) -> { + String proxyCredentials = generateProxyCredentials(); + req.header("Proxy-Authorization", "Bearer " + proxyCredentials); + return Mono.empty(); + } + ); +---- +<1> Custom predicate checks for `407 Proxy Authentication Required` status code. + +=== Important Notes + +* The authenticator function is invoked only when the predicate returns `true` (indicating authentication is needed). +* For `httpAuthentication`, use a `BiConsumer` when credentials can be computed immediately without delay. No `Mono` is needed as headers are added directly. +* For `httpAuthenticationWhen`, use a `BiFunction` that returns `Mono` when credentials need to be fetched from external sources or require delayed computation. +* The authenticator receives the request and remote address, allowing you to customize authentication based on the target server. +* By default, authentication is retried once per request. You can configure the maximum count of retry attempts using the `maxRetries` parameter. If authentication fails after all retry attempts are exhausted, the error is propagated to the caller. +* For security reasons, ensure that sensitive credentials are not logged or exposed in error messages. diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/basic/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/basic/Application.java new file mode 100644 index 000000000..c7e114f39 --- /dev/null +++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/basic/Application.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved. + * + * Licensed 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 + * + * https://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 reactor.netty.examples.documentation.http.client.authentication.basic; + +import io.netty.handler.codec.http.HttpHeaderNames; +import reactor.netty.http.client.HttpClient; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class Application { + + public static void main(String[] args) { + HttpClient client = + HttpClient.create() + .httpAuthentication( + (req, res) -> res.status().code() == 401, // <1> + (req, addr) -> { // <2> + String credentials = "username:password"; + String encodedCredentials = Base64.getEncoder() + .encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + req.header(HttpHeaderNames.AUTHORIZATION, "Basic " + encodedCredentials); + } // <3> + ); + + client.get() + .uri("https://example.com/") + .response() + .block(); + } +} \ No newline at end of file diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/spnego/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/spnego/Application.java new file mode 100644 index 000000000..83709b5eb --- /dev/null +++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/spnego/Application.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved. + * + * Licensed 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 + * + * https://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 reactor.netty.examples.documentation.http.client.authentication.spnego; + +import io.netty.handler.codec.http.HttpHeaderNames; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.ietf.jgss.Oid; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; + +import java.net.InetSocketAddress; +import java.util.Base64; + +public class Application { + + public static void main(String[] args) { + HttpClient client = + HttpClient.create() + .httpAuthenticationWhen( + (req, res) -> res.status().code() == 401 && // <1> + res.responseHeaders().contains("WWW-Authenticate", "Negotiate", true), + (req, addr) -> { // <2> + try { + GSSManager manager = GSSManager.getInstance(); + String hostName = ((InetSocketAddress) addr).getHostString(); + String serviceName = "HTTP@" + hostName; + GSSName serverName = manager.createName(serviceName, GSSName.NT_HOSTBASED_SERVICE); + + Oid krb5Mechanism = new Oid("1.2.840.113554.1.2.2"); + GSSContext context = manager.createContext( + serverName, krb5Mechanism, null, GSSContext.DEFAULT_LIFETIME); + + byte[] token = context.initSecContext(new byte[0], 0, 0); + String encodedToken = Base64.getEncoder().encodeToString(token); + + req.header(HttpHeaderNames.AUTHORIZATION, "Negotiate " + encodedToken); + + context.dispose(); + } + catch (GSSException e) { + return Mono.error(new RuntimeException( + "Failed to generate SPNEGO token", e)); + } + return Mono.empty(); + }, + 2 // <3> + ); + + client.get() + .uri("https://example.com/") + .response() + .block(); + } +} \ No newline at end of file diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/token/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/token/Application.java new file mode 100644 index 000000000..578091e22 --- /dev/null +++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/token/Application.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved. + * + * Licensed 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 + * + * https://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 reactor.netty.examples.documentation.http.client.authentication.token; + +import io.netty.handler.codec.http.HttpHeaderNames; +import reactor.netty.http.client.HttpClient; + +import java.net.SocketAddress; + +public class Application { + + public static void main(String[] args) { + HttpClient client = + HttpClient.create() + .httpAuthentication( + (req, res) -> res.status().code() == 401, // <1> + (req, addr) -> { // <2> + String token = generateAuthToken(addr); + req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token); + }, + 3 // <3> + ); + + client.get() + .uri("https://example.com/") + .response() + .block(); + } + + /** + * Generates an authentication token for the given remote address. + * In a real application, this would retrieve or generate a valid token. + */ + static String generateAuthToken(SocketAddress remoteAddress) { + // In a real application, implement token generation/retrieval logic + return "sample-token-123"; + } +} \ No newline at end of file diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java index d4c055253..c5e8dddd5 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java @@ -111,7 +111,7 @@ else if (msg instanceof HttpResponse) { setNettyResponse(response); - if (notRedirected(response)) { + if (notRedirected(response) && authenticationNotRequired()) { try { HttpResponseStatus status = response.status(); if (!HttpResponseStatus.OK.equals(status)) { @@ -131,9 +131,12 @@ else if (msg instanceof HttpResponse) { } } else { - // Deliberately suppress "NullAway" - // redirecting != null in this case - listener().onUncaughtException(this, redirecting); + if (redirecting != null) { + listener().onUncaughtException(this, redirecting); + } + else if (authenticating != null) { + listener().onUncaughtException(this, authenticating); + } } } else { diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClient.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClient.java index 4ebc6f6c7..eaab01796 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClient.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClient.java @@ -110,6 +110,7 @@ * * @author Stephane Maldini * @author Violeta Georgieva + * @author raccoonback */ public abstract class HttpClient extends ClientTransport { @@ -1696,6 +1697,230 @@ public final HttpClient wiretap(boolean enable) { return super.wiretap(enable); } + /** + * Enables a mechanism for an automatic retry of the requests when HTTP authentication is expected: + *

+ *

    + *
  1. The initial request is sent without authentication
  2. + *
  3. If the response matches the {@code predicate} (e.g., 401 Unauthorized), the request is retried
  4. + *
  5. Before retry, the {@code authenticator} adds credentials to the request
  6. + *
+ *

+ *

+ * This method defaults to 1 maximum retry attempt. To configure a different maximum retry count, + * use {@link #httpAuthentication(BiPredicate, BiConsumer, int)} instead. + *

+ *

+ * Use this method when authentication credentials can be computed synchronously (without I/O operations). + * For async credential retrieval (e.g., fetching tokens from external services), use + * {@link #httpAuthenticationWhen(BiPredicate, BiFunction)} instead. + *

+ * + *

Example - Bearer token authentication with custom retry logic:

+ *
+	 * {@code
+	 * HttpClient client = HttpClient.create()
+	 *     .httpAuthentication(
+	 *         // Retry on 401 Unauthorized
+	 *         (req, res) -> res.status().code() == 401,
+	 *         // Add authentication header before retry
+	 *         (req, addr) -> {
+	 *             String token = generateAuthToken(addr);
+	 *             req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);
+	 *         }
+	 *     );
+	 * }
+	 * 
+ * + * @param predicate determines whether to retry with authentication; receives the original request + * and the response from the failed attempt (e.g., check for 401 Unauthorized status) + * @param authenticator applies authentication credentials to the request before retry; receives + * the request and remote address + * @return a new {@link HttpClient} + * @since 1.3.1 + * @see #httpAuthentication(BiPredicate, BiConsumer, int) + * @see #httpAuthenticationWhen(BiPredicate, BiFunction) + */ + public final HttpClient httpAuthentication( + BiPredicate predicate, + BiConsumer authenticator) { + return httpAuthentication(predicate, authenticator, 1); + } + + /** + * Enables a mechanism for an automatic retry of the requests when HTTP authentication is expected, + * with configurable maximum retry attempts: + *

+ *

    + *
  1. The initial request is sent without authentication
  2. + *
  3. If the response matches the {@code predicate} (e.g., 401 Unauthorized), the request is retried
  4. + *
  5. Before retry, the {@code authenticator} adds credentials to the request
  6. + *
  7. Retries continue until authentication succeeds or {@code maxRetries} is reached
  8. + *
+ *

+ *

+ * Use this method when authentication credentials can be computed synchronously (without I/O operations). + * For async credential retrieval (e.g., fetching tokens from external services), use + * {@link #httpAuthenticationWhen(BiPredicate, BiFunction, int)} instead. + *

+ * + *

Example - Bearer token authentication with custom retry logic and maximum 2 retries:

+ *
+	 * {@code
+	 * HttpClient client = HttpClient.create()
+	 *     .httpAuthentication(
+	 *         // Retry on 401 Unauthorized
+	 *         (req, res) -> res.status().code() == 401,
+	 *         // Add authentication header before retry
+	 *         (req, addr) -> {
+	 *             String token = generateAuthToken(addr);
+	 *             req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);
+	 *         },
+	 *         2  // Maximum retry attempts
+	 *     );
+	 * }
+	 * 
+ * + * @param predicate determines whether to retry with authentication; receives the original request + * and the response from the failed attempt (e.g., check for 401 Unauthorized status) + * @param authenticator applies authentication credentials to the request before retry; receives + * the request and remote address + * @param maxRetries the maximum number of retry attempts for authentication (must be positive) + * @return a new {@link HttpClient} + * @throws IllegalArgumentException if {@code maxRetries} is less than 1 + * @since 1.3.1 + * @see #httpAuthenticationWhen(BiPredicate, BiFunction, int) + */ + public final HttpClient httpAuthentication( + BiPredicate predicate, + BiConsumer authenticator, + int maxRetries + ) { + return httpAuthenticationWhen( + predicate, + (req, addr) -> { + authenticator.accept(req, addr); + return Mono.empty(); + }, + maxRetries + ); + } + + /** + * Enables a mechanism for an automatic retry of the requests when HTTP authentication is expected, + * with support for async credential retrieval: + *

+ *

    + *
  1. The initial request is sent without authentication
  2. + *
  3. If the response matches the {@code predicate} (e.g., 401 Unauthorized), the request is retried
  4. + *
  5. Before retry, the {@code authenticator} asynchronously adds credentials to the request
  6. + *
+ *

+ *

+ * This method defaults to 1 maximum retry attempt. To configure a different maximum retry count, + * use {@link #httpAuthenticationWhen(BiPredicate, BiFunction, int)} instead. + *

+ *

+ * Use this method when authentication credentials require async operations (e.g., fetching tokens + * from external services, reading from databases, or performing I/O). For synchronous credential + * computation, {@link #httpAuthentication(BiPredicate, BiConsumer)} provides a simpler API. + *

+ * + *

Example - Async token retrieval with custom retry logic:

+ *
+	 * {@code
+	 * HttpClient client = HttpClient.create()
+	 *     .httpAuthenticationWhen(
+	 *         // Retry on 401 Unauthorized
+	 *         (req, res) -> res.status().code() == 401,
+	 *         // Fetch token asynchronously and add authentication header before retry
+	 *         (req, addr) -> tokenService.fetchToken(addr)
+	 *             .doOnNext(token ->
+	 *                 req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token))
+	 *             .then()
+	 *     );
+	 * }
+	 * 
+ * + * @param predicate determines whether to retry with authentication; receives the original request + * and the response from the failed attempt (e.g., check for 401 Unauthorized status) + * @param authenticator applies authentication credentials to the request before retry; receives + * the request and remote address, returns a {@link Mono} that completes when + * authentication credentials have been applied + * @return a new {@link HttpClient} + * @since 1.3.1 + * @see #httpAuthenticationWhen(BiPredicate, BiFunction, int) + * @see #httpAuthentication(BiPredicate, BiConsumer) + */ + public final HttpClient httpAuthenticationWhen( + BiPredicate predicate, + BiFunction> authenticator) { + return httpAuthenticationWhen(predicate, authenticator, 1); + } + + /** + * Enables a mechanism for an automatic retry of the requests when HTTP authentication is expected, + * with support for async credential retrieval and configurable maximum retry attempts: + *

+ *

    + *
  1. The initial request is sent without authentication
  2. + *
  3. If the response matches the {@code predicate} (e.g., 401 Unauthorized), the request is retried
  4. + *
  5. Before retry, the {@code authenticator} asynchronously adds credentials to the request
  6. + *
  7. Retries continue until authentication succeeds or {@code maxRetries} is reached
  8. + *
+ *

+ *

+ * Use this method when authentication credentials require async operations (e.g., fetching tokens + * from external services, reading from databases, or performing I/O). For synchronous credential + * computation, {@link #httpAuthentication(BiPredicate, BiConsumer, int)} provides a simpler API. + *

+ * + *

Example - Async token retrieval with custom retry logic and maximum 2 retries:

+ *
+	 * {@code
+	 * HttpClient client = HttpClient.create()
+	 *     .httpAuthenticationWhen(
+	 *         // Retry on 401 Unauthorized
+	 *         (req, res) -> res.status().code() == 401,
+	 *         // Fetch token asynchronously and add authentication header before retry
+	 *         (req, addr) -> tokenService.fetchToken(addr)
+	 *             .doOnNext(token ->
+	 *                 req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token))
+	 *             .then(),
+	 *         2  // Maximum retry attempts
+	 *     );
+	 * }
+	 * 
+ * + * @param predicate determines whether to retry with authentication; receives the original request + * and the response from the failed attempt (e.g., check for 401 Unauthorized status) + * @param authenticator applies authentication credentials to the request before retry; receives + * the request and remote address, returns a {@link Mono} that completes when + * authentication credentials have been applied + * @param maxRetries the maximum number of retry attempts for authentication (must be positive) + * @return a new {@link HttpClient} + * @throws IllegalArgumentException if {@code maxRetries} is less than 1 + * @since 1.3.1 + * @see #httpAuthentication(BiPredicate, BiConsumer, int) + */ + public final HttpClient httpAuthenticationWhen( + BiPredicate predicate, + BiFunction> authenticator, + int maxRetries + ) { + Objects.requireNonNull(predicate, "predicate"); + Objects.requireNonNull(authenticator, "authenticator"); + if (maxRetries < 1) { + throw new IllegalArgumentException("maxRetries must be at least 1, was: " + maxRetries); + } + + HttpClient dup = duplicate(); + dup.configuration().authenticationPredicate = predicate; + dup.configuration().authenticator = authenticator; + dup.configuration().maxAuthenticationRetries = maxRetries; + return dup; + } + static boolean isCompressing(HttpHeaders h) { return h.contains(HttpHeaderNames.ACCEPT_ENCODING, HttpHeaderValues.GZIP, true) || h.contains(HttpHeaderNames.ACCEPT_ENCODING, HttpHeaderValues.BR, true); diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientAuthenticationException.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientAuthenticationException.java new file mode 100644 index 000000000..581eb3e11 --- /dev/null +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientAuthenticationException.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved. + * + * Licensed 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 + * + * https://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 reactor.netty.http.client; + +/** + * This exception is used internally to signal that the current request requires HTTP authentication and should be retried. + * The {@code authenticator} configured via {@link HttpClient#httpAuthentication(java.util.function.BiPredicate, java.util.function.BiConsumer)} + * or {@link HttpClient#httpAuthenticationWhen(java.util.function.BiPredicate, java.util.function.BiFunction)} + * will be invoked before retrying the request. + * + * @author raccoonback + * @since 1.3.1 + * @see HttpClient#httpAuthentication(java.util.function.BiPredicate, java.util.function.BiConsumer) + * @see HttpClient#httpAuthenticationWhen(java.util.function.BiPredicate, java.util.function.BiFunction) + */ +final class HttpClientAuthenticationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + @Override + public synchronized Throwable fillInStackTrace() { + // omit stacktrace for this exception + return this; + } + + HttpClientAuthenticationException() { + super("HTTP authentication required, triggering retry"); + } +} \ No newline at end of file diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java index df3446b6b..adf151eb1 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java @@ -108,6 +108,7 @@ * * @author Stephane Maldini * @author Violeta Georgieva + * @author raccoonback */ public final class HttpClientConfig extends ClientTransportConfig { @@ -367,6 +368,9 @@ public HttpProtocol[] protocols() { @Nullable String uriStr; @Nullable Function uriTagValue; @Nullable WebsocketClientSpec websocketClientSpec; + @Nullable BiPredicate authenticationPredicate; + @Nullable BiFunction> authenticator; + int maxAuthenticationRetries; HttpClientConfig(HttpConnectionProvider connectionProvider, Map, ?> options, Supplier remoteAddress) { @@ -419,6 +423,9 @@ public HttpProtocol[] protocols() { this.uriStr = parent.uriStr; this.uriTagValue = parent.uriTagValue; this.websocketClientSpec = parent.websocketClientSpec; + this.authenticationPredicate = parent.authenticationPredicate; + this.authenticator = parent.authenticator; + this.maxAuthenticationRetries = parent.maxAuthenticationRetries; } @Override diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java index fb2649575..367594d99 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java @@ -75,6 +75,7 @@ * * @author Stephane Maldini * @author Violeta Georgieva + * @author raccoonback */ class HttpClientConnect extends HttpClient { @@ -498,6 +499,11 @@ static final class HttpClientHandler extends SocketAddress volatile Supplier @Nullable [] redirectedFrom; volatile boolean shouldRetry; volatile @Nullable HttpHeaders previousRequestHeaders; + volatile int authenticationRetries; + + @Nullable BiPredicate authenticationPredicate; + @Nullable BiFunction> authenticator; + int maxAuthenticationRetries; HttpClientHandler(HttpClientConfig configuration) { this.method = configuration.method; @@ -536,6 +542,9 @@ static final class HttpClientHandler extends SocketAddress this.fromURI = this.toURI = uriEndpointFactory.createUriEndpoint(configuration.uri, configuration.websocketClientSpec != null); } this.resourceUrl = toURI.toExternalForm(); + this.authenticationPredicate = configuration.authenticationPredicate; + this.authenticator = configuration.authenticator; + this.maxAuthenticationRetries = configuration.maxAuthenticationRetries; } @Override @@ -582,6 +591,7 @@ Publisher requestWithBody(HttpClientOperations ch) { } ch.followRedirectPredicate(followRedirectPredicate); + ch.authenticationPredicate(authenticationPredicate); if (!Objects.equals(method, HttpMethod.GET) && !Objects.equals(method, HttpMethod.HEAD) && @@ -591,6 +601,9 @@ Publisher requestWithBody(HttpClientOperations ch) { } ch.listener().onStateChange(ch, HttpClientState.REQUEST_PREPARED); + + Publisher result; + if (websocketClientSpec != null) { // ReferenceEquality is deliberate if (ch.version == H2) { @@ -604,48 +617,57 @@ Publisher requestWithBody(HttpClientOperations ch) { "[SETTINGS_ENABLE_CONNECT_PROTOCOL(0x8)=0] was received."); } } - Mono result = + Mono wsResult = Mono.fromRunnable(() -> ch.withWebsocketSupport(websocketClientSpec)); if (handler != null) { - result = result.thenEmpty(Mono.fromRunnable(() -> Flux.concat(handler.apply(ch, ch)))); + wsResult = wsResult.thenEmpty(Mono.fromRunnable(() -> Flux.concat(handler.apply(ch, ch)))); } - return result; + result = wsResult; } + else { + Consumer consumer = null; + if (fromURI != null && !toURI.equals(fromURI)) { + if (handler instanceof RedirectSendHandler) { + headers.remove(HttpHeaderNames.EXPECT) + .remove(HttpHeaderNames.COOKIE) + .remove(HttpHeaderNames.AUTHORIZATION) + .remove(HttpHeaderNames.PROXY_AUTHORIZATION); + } + else { + consumer = request -> + request.requestHeaders() + .remove(HttpHeaderNames.EXPECT) + .remove(HttpHeaderNames.COOKIE) + .remove(HttpHeaderNames.AUTHORIZATION) + .remove(HttpHeaderNames.PROXY_AUTHORIZATION); + } + } + if (this.redirectRequestConsumer != null) { + consumer = consumer != null ? consumer.andThen(this.redirectRequestConsumer) : this.redirectRequestConsumer; + } - Consumer consumer = null; - if (fromURI != null && !toURI.equals(fromURI)) { - if (handler instanceof RedirectSendHandler) { - headers.remove(HttpHeaderNames.EXPECT) - .remove(HttpHeaderNames.COOKIE) - .remove(HttpHeaderNames.AUTHORIZATION) - .remove(HttpHeaderNames.PROXY_AUTHORIZATION); + if (redirectRequestBiConsumer != null) { + ch.previousRequestHeaders = previousRequestHeaders; + ch.redirectRequestBiConsumer = redirectRequestBiConsumer; + } + + ch.redirectRequestConsumer(consumer); + if (handler != null) { + Publisher publisher = handler.apply(ch, ch); + result = ch.equals(publisher) ? ch.send() : publisher; } else { - consumer = request -> - request.requestHeaders() - .remove(HttpHeaderNames.EXPECT) - .remove(HttpHeaderNames.COOKIE) - .remove(HttpHeaderNames.AUTHORIZATION) - .remove(HttpHeaderNames.PROXY_AUTHORIZATION); + result = ch.send(); } } - if (this.redirectRequestConsumer != null) { - consumer = consumer != null ? consumer.andThen(this.redirectRequestConsumer) : this.redirectRequestConsumer; - } - if (redirectRequestBiConsumer != null) { - ch.previousRequestHeaders = previousRequestHeaders; - ch.redirectRequestBiConsumer = redirectRequestBiConsumer; + // Apply authenticator if needed (after REQUEST_PREPARED) + if (authenticator != null && authenticationRetries > 0) { + return authenticator.apply(ch, ch.address()) + .then(Mono.defer(() -> Mono.from(result))); } - ch.redirectRequestConsumer(consumer); - if (handler != null) { - Publisher publisher = handler.apply(ch, ch); - return ch.equals(publisher) ? ch.send() : publisher; - } - else { - return ch.send(); - } + return result; } catch (Throwable t) { return Mono.error(t); @@ -716,6 +738,7 @@ void channel(HttpClientOperations ops) { if (redirectedFrom != null) { ops.redirectedFrom = redirectedFrom; } + ops.configureAuthenticationRetries(this.authenticationRetries, this.maxAuthenticationRetries); } @Override @@ -728,6 +751,15 @@ public boolean test(Throwable throwable) { redirect(re.location); return true; } + if (throwable instanceof HttpClientAuthenticationException) { + // Check if we've exceeded the max retry limit + if (authenticationRetries >= maxAuthenticationRetries) { + return false; + } + // Increment retry counter to trigger authenticator on retry + authenticationRetries++; + return true; + } if (shouldRetry && AbortedException.isConnectionReset(throwable)) { shouldRetry = false; redirect(toURI.toString()); diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java index 2eb1177b6..72e7388e2 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java @@ -104,6 +104,7 @@ * * @author Stephane Maldini * @author Simon Baslé + * @author raccoonback */ class HttpClientOperations extends HttpOperations implements HttpClientResponse, HttpClientRequest { @@ -118,6 +119,8 @@ class HttpClientOperations extends HttpOperations final HttpVersion version; Supplier[] redirectedFrom = EMPTY_REDIRECTIONS; + int authenticationRetries; + int maxAuthenticationRetries; @Nullable String resourceUrl; @Nullable String path; @Nullable Duration responseTimeout; @@ -128,8 +131,10 @@ class HttpClientOperations extends HttpOperations boolean retrying; boolean is100Continue; @Nullable RedirectClientException redirecting; + @Nullable HttpClientAuthenticationException authenticating; @Nullable BiPredicate followRedirectPredicate; + @Nullable BiPredicate authenticationPredicate; @Nullable Consumer redirectRequestConsumer; @Nullable HttpHeaders previousRequestHeaders; @Nullable BiConsumer redirectRequestBiConsumer; @@ -142,7 +147,10 @@ class HttpClientOperations extends HttpOperations this.started = replaced.started; this.retrying = replaced.retrying; this.redirecting = replaced.redirecting; + this.authenticating = replaced.authenticating; this.redirectedFrom = replaced.redirectedFrom; + this.authenticationRetries = replaced.authenticationRetries; + this.maxAuthenticationRetries = replaced.maxAuthenticationRetries; this.redirectRequestConsumer = replaced.redirectRequestConsumer; this.previousRequestHeaders = replaced.previousRequestHeaders; this.redirectRequestBiConsumer = replaced.redirectRequestBiConsumer; @@ -150,6 +158,7 @@ class HttpClientOperations extends HttpOperations this.nettyRequest = replaced.nettyRequest; this.responseState = replaced.responseState; this.followRedirectPredicate = replaced.followRedirectPredicate; + this.authenticationPredicate = replaced.authenticationPredicate; this.requestHeaders = replaced.requestHeaders; this.cookieEncoder = replaced.cookieEncoder; this.cookieDecoder = replaced.cookieDecoder; @@ -171,7 +180,10 @@ class HttpClientOperations extends HttpOperations this.started = replaced.started; this.retrying = replaced.retrying; this.redirecting = replaced.redirecting; + this.authenticating = replaced.authenticating; this.redirectedFrom = replaced.redirectedFrom; + this.authenticationRetries = replaced.authenticationRetries; + this.maxAuthenticationRetries = replaced.maxAuthenticationRetries; this.redirectRequestConsumer = replaced.redirectRequestConsumer; this.previousRequestHeaders = replaced.previousRequestHeaders; this.redirectRequestBiConsumer = replaced.redirectRequestBiConsumer; @@ -179,6 +191,7 @@ class HttpClientOperations extends HttpOperations this.nettyRequest = replaced.nettyRequest; this.responseState = replaced.responseState; this.followRedirectPredicate = replaced.followRedirectPredicate; + this.authenticationPredicate = replaced.authenticationPredicate; this.requestHeaders = replaced.requestHeaders; this.cookieEncoder = replaced.cookieEncoder; this.cookieDecoder = replaced.cookieDecoder; @@ -339,6 +352,15 @@ void followRedirectPredicate(@Nullable BiPredicate predicate) { + this.authenticationPredicate = predicate; + } + + void configureAuthenticationRetries(int authenticationRetries, int maxAuthenticationRetries) { + this.authenticationRetries = authenticationRetries; + this.maxAuthenticationRetries = maxAuthenticationRetries; + } + void redirectRequestConsumer(@Nullable Consumer redirectRequestConsumer) { this.redirectRequestConsumer = redirectRequestConsumer; } @@ -401,6 +423,9 @@ protected void afterInboundComplete() { if (redirecting != null) { listener().onUncaughtException(this, redirecting); } + else if (authenticating != null) { + listener().onUncaughtException(this, authenticating); + } else { listener().onStateChange(this, HttpClientState.RESPONSE_COMPLETED); } @@ -837,7 +862,7 @@ protected void onInboundNext(ChannelHandlerContext ctx, Object msg) { httpMessageLogFactory().debug(HttpMessageArgProviderFactory.create(response))); } - if (notRedirected(response)) { + if (notRedirected(response) && authenticationNotRequired()) { try { listener().onStateChange(this, HttpClientState.RESPONSE_RECEIVED); } @@ -848,7 +873,7 @@ protected void onInboundNext(ChannelHandlerContext ctx, Object msg) { } } else { - // when redirecting no need of manual reading + // when redirecting or authenticating no need of manual reading channel().config().setAutoRead(true); } @@ -903,7 +928,7 @@ protected void onInboundNext(ChannelHandlerContext ctx, Object msg) { if (lastHttpContent != LastHttpContent.EMPTY_LAST_CONTENT) { // When there is HTTP/2 response with INBOUND HEADERS(endStream=false) followed by INBOUND DATA(endStream=true length=0), // Netty sends LastHttpContent with empty buffer instead of EMPTY_LAST_CONTENT - if (redirecting != null || lastHttpContent.content().readableBytes() == 0) { + if (redirecting != null || authenticating != null || lastHttpContent.content().readableBytes() == 0) { lastHttpContent.release(); } else { @@ -911,7 +936,7 @@ protected void onInboundNext(ChannelHandlerContext ctx, Object msg) { } } - if (redirecting == null) { + if (redirecting == null && authenticating == null) { // EmitResult is ignored as it is guaranteed that there will be only one emission of LastHttpContent // Whether there are subscribers or the subscriber cancels is not of interest // Evaluated EmitResult: FAIL_TERMINATED, FAIL_OVERFLOW, FAIL_CANCELLED, FAIL_NON_SERIALIZED @@ -941,9 +966,9 @@ protected void onInboundNext(ChannelHandlerContext ctx, Object msg) { return; } - if (redirecting != null) { + if (redirecting != null || authenticating != null) { ReferenceCountUtil.release(msg); - // when redirecting auto-read is set to true, no need of manual reading + // when redirecting or authenticating auto-read is set to true, no need of manual reading return; } super.onInboundNext(ctx, msg); @@ -977,6 +1002,19 @@ final boolean notRedirected(HttpResponse response) { return true; } + final boolean authenticationNotRequired() { + if (authenticationPredicate != null && authenticationRetries < maxAuthenticationRetries && + authenticationPredicate.test(this, this)) { + authenticating = new HttpClientAuthenticationException(); + if (log.isDebugEnabled()) { + log.debug(format(channel(), "Authentication predicate matched, triggering retry (attempt {} of {})"), + authenticationRetries + 1, maxAuthenticationRetries); + } + return false; + } + return true; + } + @Override protected HttpMessage newFullBodyMessage(ByteBuf body) { HttpRequest request = new DefaultFullHttpRequest(version(), method(), uri(), body); diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/WebsocketClientOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/WebsocketClientOperations.java index 5a5b12d38..92c4f2a58 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/WebsocketClientOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/WebsocketClientOperations.java @@ -59,6 +59,7 @@ * * @author Stephane Maldini * @author Simon Baslé + * @author raccoonback */ class WebsocketClientOperations extends HttpClientOperations implements WebsocketInbound, WebsocketOutbound { @@ -144,7 +145,7 @@ public void onInboundNext(ChannelHandlerContext ctx, Object msg) { setNettyResponse(response); - if (notRedirected(response)) { + if (notRedirected(response) && authenticationNotRequired()) { try { handshakerHttp11.finishHandshake(channel(), response); // This change is needed after the Netty change https://github.com/netty/netty/pull/11966 @@ -165,9 +166,12 @@ public void onInboundNext(ChannelHandlerContext ctx, Object msg) { else { response.content() .release(); - // Deliberately suppress "NullAway" - // redirecting is initialized in notRedirected(response) - listener().onUncaughtException(this, redirecting); + if (redirecting != null) { + listener().onUncaughtException(this, redirecting); + } + else if (authenticating != null) { + listener().onUncaughtException(this, authenticating); + } } return; } diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientOperationsTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientOperationsTest.java index 67d15cecc..ae082fb45 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientOperationsTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientOperationsTest.java @@ -197,6 +197,7 @@ void testConstructorWithProvidedReplacement_1() { ops1.retrying = true; ops1.redirecting = new RedirectClientException(new DefaultHttpHeaders().add(HttpHeaderNames.LOCATION, "/"), HttpResponseStatus.MOVED_PERMANENTLY); + ops1.authenticating = new HttpClientAuthenticationException(); HttpClientOperations ops2 = new HttpClientOperations(ops1); @@ -204,6 +205,7 @@ void testConstructorWithProvidedReplacement_1() { assertThat(ops1.started).isSameAs(ops2.started); assertThat(ops1.retrying).isSameAs(ops2.retrying); assertThat(ops1.redirecting).isSameAs(ops2.redirecting); + assertThat(ops1.authenticating).isSameAs(ops2.authenticating); assertThat(ops1.redirectedFrom).isSameAs(ops2.redirectedFrom); assertThat(ops1.isSecure).isSameAs(ops2.isSecure); assertThat(ops1.nettyRequest).isSameAs(ops2.nettyRequest); @@ -535,6 +537,7 @@ private static void checkRequest(HttpClientRequest request, HttpClientResponse r assertThat(req.isSecure).isSameAs(res.isSecure); assertThat(req.nettyRequest).isSameAs(res.nettyRequest); assertThat(req.followRedirectPredicate).isSameAs(res.followRedirectPredicate); + assertThat(req.authenticationPredicate).isSameAs(res.authenticationPredicate); assertThat(req.requestHeaders).isSameAs(res.requestHeaders); assertThat(req.cookieEncoder).isSameAs(res.cookieEncoder); assertThat(req.cookieDecoder).isSameAs(res.cookieDecoder); @@ -555,6 +558,7 @@ private static void checkRequest(HttpClientRequest request, HttpClientResponse r else { assertThat(req.redirecting).isSameAs(res.redirecting); } + assertThat(req.authenticating).isSameAs(res.authenticating); assertThat(req.responseState).isNotSameAs(res.responseState); assertThat(req.version).isNotSameAs(res.version); } @@ -563,11 +567,79 @@ private static void checkRequest(HttpClientRequest request, HttpClientResponse r assertThat(req.asShortText()).isSameAs(res.asShortText()); assertThat(req.started).isSameAs(res.started); assertThat(req.redirecting).isSameAs(res.redirecting); + assertThat(req.authenticating).isSameAs(res.authenticating); assertThat(req.responseState).isSameAs(res.responseState); assertThat(req.version).isSameAs(res.version); } } + @ParameterizedTest + @MethodSource("httpCompatibleProtocols") + void testConstructorWithProvidedAuthentication(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + SslProvider.@Nullable ProtocolSslContextSpec serverCtx, SslProvider.@Nullable ProtocolSslContextSpec clientCtx) { + ConnectionProvider provider = ConnectionProvider.create("testConstructorWithProvidedAuthentication", 1); + try { + HttpServer server = serverCtx == null ? + createServer().protocol(serverProtocols) : + createServer().protocol(serverProtocols).secure(spec -> spec.sslContext((SslProvider.GenericSslContextSpec) serverCtx)); + + disposableServer = + server.route(r -> r.get("/protected", (req, res) -> { + String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); + if (authHeader == null || !authHeader.equals("Bearer test-token")) { + return res.status(HttpResponseStatus.UNAUTHORIZED).send(); + } + return res.sendString(Mono.just("testConstructorWithProvidedAuthentication")); + })) + .bindNow(); + + HttpClient client = clientCtx == null ? + createClient(disposableServer.port()).protocol(clientProtocols) : + createClient(disposableServer.port()).protocol(clientProtocols).secure(spec -> spec.sslContext((SslProvider.GenericSslContextSpec) clientCtx)); + + AtomicReference<@Nullable HttpClientRequest> request = new AtomicReference<>(); + AtomicReference<@Nullable HttpClientResponse> response = new AtomicReference<>(); + AtomicReference<@Nullable Channel> requestChannel = new AtomicReference<>(); + AtomicReference<@Nullable Channel> responseChannel = new AtomicReference<>(); + AtomicReference<@Nullable ConnectionObserver> requestListener = new AtomicReference<>(); + AtomicReference<@Nullable ConnectionObserver> responseListener = new AtomicReference<>(); + String result = httpAuthentication(client, request, response, requestChannel, responseChannel, + requestListener, responseListener); + assertThat(result).isNotNull().isEqualTo("testConstructorWithProvidedAuthentication"); + assertThat(requestListener.get()).isSameAs(responseListener.get()); + checkRequest(request.get(), response.get(), requestChannel.get(), responseChannel.get(), false, false); + } + finally { + provider.disposeLater() + .block(Duration.ofSeconds(5)); + } + } + + private static @Nullable String httpAuthentication(HttpClient originalClient, AtomicReference<@Nullable HttpClientRequest> request, + AtomicReference<@Nullable HttpClientResponse> response, AtomicReference<@Nullable Channel> requestChannel, + AtomicReference<@Nullable Channel> responseChannel, AtomicReference<@Nullable ConnectionObserver> requestListener, + AtomicReference<@Nullable ConnectionObserver> responseListener) { + HttpClient client = originalClient.httpAuthentication( + (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED), + (req, addr) -> { + req.header(HttpHeaderNames.AUTHORIZATION, "Bearer test-token"); + }); + return client.doAfterRequest((req, conn) -> { + requestChannel.set(conn.channel()); + requestListener.set(((HttpClientOperations) req).listener()); + request.set(req); + }) + .doOnResponse((res, conn) -> { + responseChannel.set(conn.channel()); + responseListener.set(((HttpClientOperations) res).listener()); + response.set(res); + }) + .get() + .uri("/protected") + .responseSingle((res, bytes) -> bytes.asString()) + .block(Duration.ofSeconds(5)); + } + static Stream httpCompatibleProtocols() { return Stream.of( Arguments.of(new HttpProtocol[]{HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.HTTP11}, null, null), diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java index 44db3958a..de0d14bf6 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java @@ -3809,6 +3809,467 @@ void testSelectedIpsDelayedAddressResolution() { .verify(Duration.ofSeconds(5)); } + @Test + void testHttpAuthentication() { + AtomicInteger requestCount = new AtomicInteger(0); + AtomicBoolean authHeaderAdded = new AtomicBoolean(false); + + disposableServer = + HttpServer.create() + .port(0) + .handle((req, res) -> { + int count = requestCount.incrementAndGet(); + String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); + + if (count == 1) { + // First request should not have auth header + assertThat(authHeader).isNull(); + return res.status(HttpResponseStatus.UNAUTHORIZED).send(); + } + else { + // Second request should have auth header + assertThat(authHeader).isEqualTo("Bearer test-token"); + return res.status(HttpResponseStatus.OK) + .sendString(Mono.just("Authenticated!")); + } + }) + .bindNow(); + + HttpClient client = + HttpClient.create() + .port(disposableServer.port()) + .httpAuthentication( + (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED), + (req, addr) -> { + authHeaderAdded.set(true); + req.header(HttpHeaderNames.AUTHORIZATION, "Bearer test-token"); + }); + + String response = client.get() + .uri("/protected") + .responseContent() + .aggregate() + .asString() + .block(Duration.ofSeconds(5)); + + assertThat(response).isEqualTo("Authenticated!"); + assertThat(requestCount.get()).isEqualTo(2); + assertThat(authHeaderAdded.get()).isTrue(); + } + + @Test + void testHttpAuthenticationNoRetryWhenPredicateDoesNotMatch() { + AtomicInteger requestCount = new AtomicInteger(0); + AtomicBoolean authenticatorCalled = new AtomicBoolean(false); + + disposableServer = + HttpServer.create() + .port(0) + .handle((req, res) -> { + requestCount.incrementAndGet(); + // Always return 403 Forbidden + return res.status(HttpResponseStatus.FORBIDDEN).send(); + }) + .bindNow(); + + HttpClient client = + HttpClient.create() + .port(disposableServer.port()) + .httpAuthenticationWhen( + // Only retry on 401, not 403 + (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED), + (req, addr) -> { + authenticatorCalled.set(true); + req.header(HttpHeaderNames.AUTHORIZATION, "Bearer test-token"); + return Mono.empty(); + } + ); + + client.get() + .uri("/protected") + .responseSingle((res, content) -> Mono.just(res.status())) + .as(StepVerifier::create) + .expectNext(HttpResponseStatus.FORBIDDEN) + .expectComplete() + .verify(Duration.ofSeconds(5)); + + // Should only make one request since predicate doesn't match + assertThat(requestCount.get()).isEqualTo(1); + assertThat(authenticatorCalled.get()).isFalse(); + } + + @Test + void testHttpAuthenticationWithMonoAuthenticator() { + AtomicInteger requestCount = new AtomicInteger(0); + AtomicInteger authCallCount = new AtomicInteger(0); + + disposableServer = + HttpServer.create() + .port(0) + .handle((req, res) -> { + int count = requestCount.incrementAndGet(); + String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); + + if (count == 1) { + assertThat(authHeader).isNull(); + return res.status(HttpResponseStatus.UNAUTHORIZED).send(); + } + else { + assertThat(authHeader).startsWith("Bearer async-token-"); + return res.status(HttpResponseStatus.OK) + .sendString(Mono.just("Success")); + } + }) + .bindNow(); + + HttpClient client = + HttpClient.create() + .port(disposableServer.port()) + .httpAuthenticationWhen( + (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED), + (req, addr) -> { + int callNum = authCallCount.incrementAndGet(); + // Simulate async token generation + return Mono.delay(Duration.ofMillis(100)) + .then(Mono.fromRunnable(() -> + req.header(HttpHeaderNames.AUTHORIZATION, + "Bearer async-token-" + callNum))); + } + ); + + String response = client.get() + .uri("/api/resource") + .responseContent() + .aggregate() + .asString() + .block(Duration.ofSeconds(5)); + + assertThat(response).isEqualTo("Success"); + assertThat(requestCount.get()).isEqualTo(2); + assertThat(authCallCount.get()).isEqualTo(1); + } + + @Test + void testHttpAuthenticationMultipleRequests() { + AtomicInteger requestCount = new AtomicInteger(0); + + disposableServer = + HttpServer.create() + .port(0) + .handle((req, res) -> { + requestCount.incrementAndGet(); + String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); + + if (authHeader == null || !authHeader.equals("Bearer valid-token")) { + return res.status(HttpResponseStatus.UNAUTHORIZED).send(); + } + else { + return res.status(HttpResponseStatus.OK) + .sendString(Mono.just("OK")); + } + }) + .bindNow(); + + HttpClient client = + HttpClient.create() + .port(disposableServer.port()) + .httpAuthenticationWhen( + (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED), + (req, addr) -> { + req.header(HttpHeaderNames.AUTHORIZATION, "Bearer valid-token"); + return Mono.empty(); + } + ); + + // Make multiple requests + for (int i = 0; i < 3; i++) { + String response = client.get() + .uri("/resource") + .responseContent() + .aggregate() + .asString() + .block(Duration.ofSeconds(5)); + assertThat(response).isEqualTo("OK"); + } + + // Each request should trigger auth flow (1 initial + 1 retry) = 6 total + assertThat(requestCount.get()).isEqualTo(6); + } + + @Test + void testHttpAuthenticationWithCustomStatusCode() { + AtomicInteger requestCount = new AtomicInteger(0); + + disposableServer = + HttpServer.create() + .port(0) + .handle((req, res) -> { + int count = requestCount.incrementAndGet(); + String authHeader = req.requestHeaders().get("WWW-Authenticate"); + + if (count == 1) { + assertThat(authHeader).isNull(); + // Return custom status code 407 (Proxy Authentication Required) + return res.status(HttpResponseStatus.PROXY_AUTHENTICATION_REQUIRED).send(); + } + else { + assertThat(authHeader).isEqualTo("Negotiate custom-token"); + return res.status(HttpResponseStatus.OK) + .sendString(Mono.just("Authorized")); + } + }) + .bindNow(); + + + HttpClient client = + HttpClient.create() + .port(disposableServer.port()) + .httpAuthenticationWhen( + // Retry on 407 instead of 401 + (req, res) -> res.status().equals(HttpResponseStatus.PROXY_AUTHENTICATION_REQUIRED), + (req, addr) -> { + req.header("WWW-Authenticate", "Negotiate custom-token"); + return Mono.empty(); + } + ); + + String response = client.get() + .uri("/proxy-protected") + .responseContent() + .aggregate() + .asString() + .block(Duration.ofSeconds(5)); + + assertThat(response).isEqualTo("Authorized"); + assertThat(requestCount.get()).isEqualTo(2); + } + + @Test + void testHttpAuthenticationMaxRetries() { + AtomicInteger requestCount = new AtomicInteger(0); + AtomicInteger authenticatorCallCount = new AtomicInteger(0); + + disposableServer = + HttpServer.create() + .port(0) + .handle((req, res) -> { + requestCount.incrementAndGet(); + // Server always returns 401, forcing client to hit retry limit + return res.status(HttpResponseStatus.UNAUTHORIZED).send(); + }) + .bindNow(); + + HttpClient client = + HttpClient.create() + .port(disposableServer.port()) + .httpAuthenticationWhen( + (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED), + (req, addr) -> { + authenticatorCallCount.incrementAndGet(); + req.header(HttpHeaderNames.AUTHORIZATION, "Bearer retry-token"); + return Mono.empty(); + }, + 3 // Set maxRetries to 3 + ); + + // After 3 retry attempts, request should complete with 401 status + client.get() + .uri("/protected") + .responseSingle((res, bytes) -> bytes.then(Mono.just(res.status()))) + .as(StepVerifier::create) + .expectNext(HttpResponseStatus.UNAUTHORIZED) + .expectComplete() + .verify(Duration.ofSeconds(5)); + + // Total requests = 1 initial + 3 retries = 4 + assertThat(requestCount.get()).isEqualTo(4); + // Authenticator should be called 3 times (once per retry) + assertThat(authenticatorCallCount.get()).isEqualTo(3); + } + + @Test + void testHttpAuthenticationRetriesResetPerRequestHttp1() { + AtomicInteger requestCount = new AtomicInteger(0); + AtomicInteger authenticatorCallCount = new AtomicInteger(0); + Set channelIds = ConcurrentHashMap.newKeySet(); + + disposableServer = + HttpServer.create() + .port(0) + .wiretap(true) + .handle((req, res) -> { + int count = requestCount.incrementAndGet(); + String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); + + // Track channel ID to verify connection reuse + req.withConnection(conn -> channelIds.add(conn.channel().id())); + + // Always return 401 on first attempt (no auth header) + if (authHeader == null || !authHeader.equals("Bearer token")) { + return res.status(HttpResponseStatus.UNAUTHORIZED).send(); + } + else { + return res.status(HttpResponseStatus.OK) + .sendString(Mono.just("OK-" + count)); + } + }) + .bindNow(); + + ConnectionProvider provider = ConnectionProvider.create("test", 1); + + try { + HttpClient client = + HttpClient.create(provider) + .port(disposableServer.port()) + .protocol(HttpProtocol.HTTP11) + .httpAuthenticationWhen( + (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED), + (req, addr) -> { + authenticatorCallCount.incrementAndGet(); + req.header(HttpHeaderNames.AUTHORIZATION, "Bearer token"); + return Mono.empty(); + } + ); + + // First request: 401 -> retry with auth -> 200 + String response1 = client.get() + .uri("/api/1") + .responseContent() + .aggregate() + .asString() + .block(Duration.ofSeconds(5)); + assertThat(response1).contains("OK"); + + // Second request using same connection from pool: should reset authenticationRetries + // Should also trigger: 401 -> retry with auth -> 200 + String response2 = client.get() + .uri("/api/2") + .responseContent() + .aggregate() + .asString() + .block(Duration.ofSeconds(5)); + assertThat(response2).contains("OK"); + + // Third request: same pattern + String response3 = client.get() + .uri("/api/3") + .responseContent() + .aggregate() + .asString() + .block(Duration.ofSeconds(5)); + assertThat(response3).contains("OK"); + + // Verify: + // - Each request triggered auth retry (3 requests × 2 attempts = 6 total server requests) + assertThat(requestCount.get()).isEqualTo(6); + // - Authenticator was called 3 times (once per request) + assertThat(authenticatorCallCount.get()).isEqualTo(3); + // - Connection was reused (HTTP/1.1 keep-alive with pool size 1) + // All requests should use the same channel + assertThat(channelIds).hasSize(1); + } + finally { + provider.disposeLater().block(Duration.ofSeconds(5)); + } + } + + @Test + void testHttpAuthenticationRetriesResetPerRequestHttp2() throws Exception { + AtomicInteger requestCount = new AtomicInteger(0); + AtomicInteger authenticatorCallCount = new AtomicInteger(0); + Set parentChannelIds = ConcurrentHashMap.newKeySet(); + + SslContext sslServer = SslContextBuilder.forServer(ssc.toTempCertChainPem(), ssc.toTempPrivateKeyPem()) + .build(); + SslContext sslClient = SslContextBuilder.forClient() + .trustManager(InsecureTrustManagerFactory.INSTANCE) + .build(); + + disposableServer = + HttpServer.create() + .port(0) + .protocol(HttpProtocol.H2) + .secure(spec -> spec.sslContext(sslServer)) + .wiretap(true) + .handle((req, res) -> { + int count = requestCount.incrementAndGet(); + String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); + + // Track parent channel ID (HTTP/2 connection) to verify reuse + req.withConnection(conn -> { + if (conn.channel().parent() != null) { + parentChannelIds.add(conn.channel().parent().id()); + } + }); + + // Always return 401 on first attempt (no auth header) + if (authHeader == null || !authHeader.equals("Bearer h2-token")) { + return res.status(HttpResponseStatus.UNAUTHORIZED).send(); + } + else { + return res.status(HttpResponseStatus.OK) + .sendString(Mono.just("OK-H2-" + count)); + } + }) + .bindNow(); + + ConnectionProvider provider = ConnectionProvider.create("test-h2", 1); + + try { + HttpClient client = + HttpClient.create(provider) + .port(disposableServer.port()) + .protocol(HttpProtocol.H2) + .secure(spec -> spec.sslContext(sslClient)) + .httpAuthenticationWhen( + (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED), + (req, addr) -> { + authenticatorCallCount.incrementAndGet(); + req.header(HttpHeaderNames.AUTHORIZATION, "Bearer h2-token"); + return Mono.empty(); + } + ); + + // First request: 401 -> retry with auth -> 200 + String response1 = client.get() + .uri("/api/1") + .responseContent() + .aggregate() + .asString() + .block(Duration.ofSeconds(5)); + assertThat(response1).contains("OK-H2"); + + // Second request on same HTTP/2 connection (new stream): should reset authenticationRetries + String response2 = client.get() + .uri("/api/2") + .responseContent() + .aggregate() + .asString() + .block(Duration.ofSeconds(5)); + assertThat(response2).contains("OK-H2"); + + // Third request: same pattern + String response3 = client.get() + .uri("/api/3") + .responseContent() + .aggregate() + .asString() + .block(Duration.ofSeconds(5)); + assertThat(response3).contains("OK-H2"); + + // Verify: + // - Each request triggered auth retry (3 requests × 2 attempts = 6 total) + assertThat(requestCount.get()).isEqualTo(6); + // - Authenticator was called 3 times (once per request) + assertThat(authenticatorCallCount.get()).isEqualTo(3); + // - Same HTTP/2 connection was reused for all streams + assertThat(parentChannelIds).hasSize(1); + } + finally { + provider.disposeLater().block(Duration.ofSeconds(5)); + } + } + private static final class EchoAction implements Publisher, Consumer { private final Publisher sender; private volatile FluxSink emitter;