From 4ddffb0b9c5320c98f1dcaa3320c837b226edc77 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Mon, 23 Jun 2025 11:40:42 +0900 Subject: [PATCH 01/29] Support SPNEGO (Kerberos) authentication Signed-off-by: raccoonback --- docs/modules/ROOT/pages/http-client.adoc | 70 +++++++++ .../http/client/spnego/Application.java | 39 +++++ .../reactor/netty/http/client/HttpClient.java | 14 ++ .../netty/http/client/HttpClientConfig.java | 2 + .../netty/http/client/HttpClientConnect.java | 16 ++ .../netty/http/client/JaasAuthenticator.java | 56 +++++++ .../netty/http/client/SpnegoAuthProvider.java | 146 ++++++++++++++++++ .../http/client/SpnegoAuthenticator.java | 39 +++++ .../http/client/SpnegoAuthProviderTest.java | 110 +++++++++++++ 9 files changed, 492 insertions(+) create mode 100644 reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java create mode 100644 reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc index 56e8a9f53..6a0144128 100644 --- a/docs/modules/ROOT/pages/http-client.adoc +++ b/docs/modules/ROOT/pages/http-client.adoc @@ -777,3 +777,73 @@ 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-client-spnego]] +=== SPNEGO (Kerberos) Authentication +Reactor Netty HttpClient supports SPNEGO (Kerberos) authentication, which is widely used in enterprise environments. +SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) provides secure authentication over HTTP using Kerberos. + +==== How It Works +SPNEGO authentication follows this HTTP authentication flow: +1. The client sends an HTTP request to a protected resource. +2. The server responds with `401 Unauthorized` and a `WWW-Authenticate: Negotiate` header. +3. The client generates a SPNEGO token based on its Kerberos ticket, and resends the request with an `Authorization: Negotiate ` header. +4. The server validates the token and, if authentication is successful, returns 200 OK. + +If further negotiation is required, the server may return another 401 with additional data in the WWW-Authenticate header. + +{examples-link}/spnego/Application.java +---- +include::{examples-dir}/spnego/Application.java[lines=18..39] +---- +<1> Configures the `jaas.conf`. A JAAS configuration file in Java for integrating with authentication backends such as Kerberos. +<2> Configures the `krb5.conf`. krb5.conf is a Kerberos client configuration file used to define how the client locates and communicates with the Kerberos Key Distribution Center (KDC) for authentication. +<3> Configures the SPNEGO jaas.conf. A JVM system property that enables detailed debug logging for Kerberos operations in Java. +<4> `JaasAuthenticator` performs Kerberos authentication using a JAAS configuration (jaas.conf). +<5> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket and automatically adds the `Authorization: Negotiate ...` header to HTTP requests. + +==== Environment Configuration +===== Example JAAS Configuration +Specify the path to your JAAS configuration file using the `java.security.auth.login.config` system property. + +.`jaas.conf` +[jaas,conf] +---- +KerberosLogin { + com.sun.security.auth.module.Krb5LoginModule required + client=true + useKeyTab=true + keyTab="/path/to/test.keytab" + principal="test@EXAMPLE.COM" + doNotPrompt=true + debug=true; +}; +---- + +===== Example Kerberos Configuration +Specify Kerberos realm and KDC information using the `java.security.krb5.conf` system property. + +.`krb5.conf` +[krb5,conf] +---- +[libdefaults] + default_realm = EXAMPLE.COM +[realms] + EXAMPLE.COM = { + kdc = kdc.example.com + } +[domain_realms] + .example.com = EXAMPLE.COM + example.com = EXAMPLE.COM +---- + +===== Configuration Example +[jvm option] +---- +-Djava.security.auth.login.config=/path/to/login.conf +-Djava.security.krb5.conf=/path/to/krb5.conf +---- + +==== Notes +- SPNEGO authentication is fully supported on Java 1.6 and above. +- If authentication fails, check the server logs and client exception messages, and verify your Kerberos environment settings (realm, KDC, ticket, etc.). \ No newline at end of file diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java new file mode 100644 index 000000000..334dfeafb --- /dev/null +++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java @@ -0,0 +1,39 @@ +/* + * 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.spnego; + +import reactor.netty.http.client.HttpClient; +import reactor.netty.http.client.JaasAuthenticator; +import reactor.netty.http.client.SpnegoAuthProvider; +import reactor.netty.http.client.SpnegoAuthenticator; + +public class Application { + + public static void main(String[] args) { + System.setProperty("java.security.auth.login.config", "/path/to/jaas.conf"); // <1> + System.setProperty("java.security.krb5.conf", "/path/to/krb5.conf"); // <2> + System.setProperty("sun.security.krb5.debug", "true"); // <3> + + SpnegoAuthenticator authenticator = new JaasAuthenticator("KerberosLogin"); // <4> + HttpClient client = HttpClient.create() + .spnego(SpnegoAuthProvider.create(authenticator)); // <5> + + client.get() + .uri("http://protected.example.com/") + .responseSingle((res, content) -> content.asString()) + .block(); + } +} 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..94984ecc3 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 @@ -1696,6 +1696,20 @@ public final HttpClient wiretap(boolean enable) { return super.wiretap(enable); } + /** + * Configure SPNEGO authentication for the HTTP client. + * + * @param spnegoAuthProvider the SPNEGO authentication provider + * @return a new {@link HttpClient} + * @since 1.3.0 + */ + public final HttpClient spnego(SpnegoAuthProvider spnegoAuthProvider) { + Objects.requireNonNull(spnegoAuthProvider, "spnegoAuthProvider"); + HttpClient dup = duplicate(); + dup.configuration().spnegoAuthProvider = spnegoAuthProvider; + 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/HttpClientConfig.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java index df3446b6b..b8e219b72 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 @@ -367,6 +367,7 @@ public HttpProtocol[] protocols() { @Nullable String uriStr; @Nullable Function uriTagValue; @Nullable WebsocketClientSpec websocketClientSpec; + @Nullable SpnegoAuthProvider spnegoAuthProvider; HttpClientConfig(HttpConnectionProvider connectionProvider, Map, ?> options, Supplier remoteAddress) { @@ -419,6 +420,7 @@ public HttpProtocol[] protocols() { this.uriStr = parent.uriStr; this.uriTagValue = parent.uriTagValue; this.websocketClientSpec = parent.websocketClientSpec; + this.spnegoAuthProvider = parent.spnegoAuthProvider; } @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..cd0f479d1 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 @@ -499,6 +499,8 @@ static final class HttpClientHandler extends SocketAddress volatile boolean shouldRetry; volatile @Nullable HttpHeaders previousRequestHeaders; + SpnegoAuthProvider spnegoAuthProvider; + HttpClientHandler(HttpClientConfig configuration) { this.method = configuration.method; this.followRedirectPredicate = configuration.followRedirectPredicate; @@ -536,6 +538,7 @@ static final class HttpClientHandler extends SocketAddress this.fromURI = this.toURI = uriEndpointFactory.createUriEndpoint(configuration.uri, configuration.websocketClientSpec != null); } this.resourceUrl = toURI.toExternalForm(); + this.spnegoAuthProvider = configuration.spnegoAuthProvider; } @Override @@ -550,6 +553,19 @@ public SocketAddress get() { @SuppressWarnings("ReferenceEquality") Publisher requestWithBody(HttpClientOperations ch) { + if (spnegoAuthProvider != null) { + return spnegoAuthProvider.apply(ch, ch.address()) + .then( + Mono.defer( + () -> Mono.from(requestWithBodyInternal(ch)) + ) + ); + } + + return requestWithBodyInternal(ch); + } + + private Publisher requestWithBodyInternal(HttpClientOperations ch) { try { ch.resourceUrl = this.resourceUrl; ch.responseTimeout = responseTimeout; diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java new file mode 100644 index 000000000..6fd7ae749 --- /dev/null +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java @@ -0,0 +1,56 @@ +/* + * 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; + +import javax.security.auth.Subject; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; + +/** + * A JAAS-based Authenticator implementation for use with SPNEGO providers. + *

+ * This authenticator performs a JAAS login using the specified context name and returns the authenticated Subject. + *

+ * + * @author raccoonback + * @since 1.3.0 + */ +public class JaasAuthenticator implements SpnegoAuthenticator { + + private final String contextName; + + /** + * Creates a new JaasAuthenticator with the given context name. + * + * @param contextName the JAAS login context name + */ + public JaasAuthenticator(String contextName) { + this.contextName = contextName; + } + + /** + * Performs a JAAS login using the configured context name and returns the authenticated Subject. + * + * @return the authenticated JAAS Subject + * @throws LoginException if login fails + */ + @Override + public Subject login() throws LoginException { + LoginContext context = new LoginContext(contextName); + context.login(); + return context.getSubject(); + } +} diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java new file mode 100644 index 000000000..04cfb1b07 --- /dev/null +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java @@ -0,0 +1,146 @@ +/* + * 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; + +import static reactor.core.scheduler.Schedulers.boundedElastic; + +import io.netty.handler.codec.http.HttpHeaderNames; +import java.net.InetSocketAddress; +import java.security.PrivilegedAction; +import java.util.Base64; +import javax.security.auth.Subject; +import javax.security.auth.login.LoginException; +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; + +/** + * Provides SPNEGO authentication for Reactor Netty HttpClient. + *

+ * This provider is responsible for generating and attaching a SPNEGO (Kerberos) token + * to the HTTP Authorization header for outgoing requests, enabling single sign-on and + * secure authentication in enterprise environments. + *

+ * + *

Typical usage:

+ *
+ *     HttpClient client = HttpClient.create()
+ *         .spnego(SpnegoAuthProvider.create(new JaasAuthenticator("KerberosLogin")));
+ * 
+ * + * @author raccoonback + * @since 1.3.0 + */ +public final class SpnegoAuthProvider { + + private final SpnegoAuthenticator authenticator; + private final GSSManager gssManager; + + /** + * Constructs a new SpnegoAuthProvider with the given authenticator and GSSManager. + * + * @param authenticator the authenticator to use for JAAS login + * @param gssManager the GSSManager to use for SPNEGO token generation + */ + private SpnegoAuthProvider(SpnegoAuthenticator authenticator, GSSManager gssManager) { + this.authenticator = authenticator; + this.gssManager = gssManager; + } + + /** + * Creates a new SPNEGO authentication provider using the default GSSManager instance. + * + * @param authenticator the authenticator to use for JAAS login + * @return a new SPNEGO authentication provider + */ + public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator) { + return create(authenticator, GSSManager.getInstance()); + } + + /** + * Creates a new SPNEGO authentication provider with a custom GSSManager instance. + *

+ * This overload is intended for testing or advanced scenarios where a custom GSSManager is needed. + *

+ * + * @param authenticator the authenticator to use for JAAS login + * @param gssManager the GSSManager to use for SPNEGO token generation + * @return a new SPNEGO authentication provider + */ + public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSManager gssManager) { + return new SpnegoAuthProvider(authenticator, gssManager); + } + + /** + * Applies SPNEGO authentication to the given HTTP client request. + *

+ * This method generates a SPNEGO token for the specified address and attaches it + * as an Authorization header to the outgoing HTTP request. + *

+ * + * @param request the HTTP client request to authenticate + * @param address the target server address (used for service principal) + * @return a Mono that completes when the authentication is applied + * @throws RuntimeException if login or token generation fails + */ + public Mono apply(HttpClientRequest request, InetSocketAddress address) { + return Mono.fromCallable(() -> { + try { + return Subject.doAs( + authenticator.login(), + (PrivilegedAction) () -> { + try { + byte[] token = generateSpnegoToken(address.getHostName()); + String authHeader = "Negotiate " + Base64.getEncoder().encodeToString(token); + request.header(HttpHeaderNames.AUTHORIZATION, authHeader); + return token; + } + catch (GSSException e) { + throw new RuntimeException("Failed to generate SPNEGO token", e); + } + } + ); + } + catch (LoginException e) { + throw new RuntimeException("Failed to login with SPNEGO", e); + } + }) + .subscribeOn(boundedElastic()) + .then(); + } + + /** + * Generates a SPNEGO token for the given host name. + *

+ * This method uses the GSSManager to create a GSSContext and generate a SPNEGO token + * for the specified service principal (HTTP/hostName). + *

+ * + * @param hostName the target server host name + * @return the raw SPNEGO token bytes + * @throws GSSException if token generation fails + */ + private byte[] generateSpnegoToken(String hostName) throws GSSException { + GSSName serverName = gssManager.createName("HTTP/" + hostName, GSSName.NT_HOSTBASED_SERVICE); + Oid spnegoOid = new Oid("1.3.6.1.5.5.2"); // SPNEGO OID + + GSSContext context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME); + return context.initSecContext(new byte[0], 0, 0); + } +} diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java new file mode 100644 index 000000000..505f3366c --- /dev/null +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java @@ -0,0 +1,39 @@ +/* + * 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; + +import javax.security.auth.Subject; +import javax.security.auth.login.LoginException; + +/** + * An abstraction for authentication logic used by SPNEGO providers. + *

+ * Implementations are responsible for performing a JAAS login and returning a logged-in Subject. + *

+ * + * @author raccoonback + * @since 1.3.0 + */ +public interface SpnegoAuthenticator { + + /** + * Performs a JAAS login and returns the authenticated Subject. + * + * @return the authenticated JAAS Subject + * @throws LoginException if login fails + */ + Subject login() throws LoginException; +} diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java new file mode 100644 index 000000000..c4ccd72e7 --- /dev/null +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java @@ -0,0 +1,110 @@ +/* + * 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; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import io.netty.handler.codec.http.HttpHeaderNames; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.util.HashSet; +import java.util.Set; +import javax.security.auth.Subject; +import javax.security.auth.kerberos.KerberosPrincipal; +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 org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; +import reactor.test.StepVerifier; + +class SpnegoAuthProviderTest { + + private static final int TEST_PORT = 8080; + + private DisposableServer server; + + @BeforeEach + void setUp() { + server = HttpServer.create() + .port(TEST_PORT) + .route(routes -> routes + .get("/", (request, response) -> { + String authHeader = request.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); + if (authHeader != null && authHeader.startsWith("Negotiate ")) { + return response.status(200).sendString(Mono.just("Authenticated")); + } + return response.status(401).sendString(Mono.just("Unauthorized")); + })) + .bindNow(); + } + + @AfterEach + void tearDown() { + server.disposeNow(); + } + + @Test + void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException { + GSSManager gssManager = mock(GSSManager.class); + GSSContext gssContext = mock(GSSContext.class); + GSSName gssName = mock(GSSName.class); + Oid oid = new Oid("1.3.6.1.5.5.2"); + + given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) + .willReturn("spnego-negotiate-token".getBytes(StandardCharsets.UTF_8)); + given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE))) + .willReturn(gssName); + given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt())) + .willReturn(gssContext); + + HttpClient client = HttpClient.create() + .port(TEST_PORT) + .spnego( + SpnegoAuthProvider.create( + () -> { + Set principals = new HashSet<>(); + principals.add(new KerberosPrincipal("test@LOCALHOST")); + return new Subject(true, principals, new HashSet<>(), new HashSet<>()); + }, + gssManager + ) + ) + .wiretap(true) + .disableRetry(true); + + StepVerifier.create( + client.get() + .uri("/") + .responseContent() + .aggregate() + .asString() + ) + .expectNext("Authenticated") + .verifyComplete(); + } +} From 6b09c793e15690bc2f16f28ddeb19162c9f757f1 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Thu, 26 Jun 2025 12:48:53 +0900 Subject: [PATCH 02/29] Reuse SPNEGO token until expiry and reset on expiration Signed-off-by: raccoonback --- .../http/client/spnego/Application.java | 2 +- .../netty/http/client/HttpClientConnect.java | 9 +++ .../netty/http/client/SpnegoAuthProvider.java | 67 ++++++++++++++++--- .../http/client/SpnegoAuthenticator.java | 6 +- .../http/client/SpnegoAuthProviderTest.java | 3 +- 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java index 334dfeafb..b79bf9955 100644 --- a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java +++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java @@ -29,7 +29,7 @@ public static void main(String[] args) { SpnegoAuthenticator authenticator = new JaasAuthenticator("KerberosLogin"); // <4> HttpClient client = HttpClient.create() - .spnego(SpnegoAuthProvider.create(authenticator)); // <5> + .spnego(SpnegoAuthProvider.create(authenticator, 401)); // <5> client.get() .uri("http://protected.example.com/") 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 cd0f479d1..46aabb5cb 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 @@ -456,6 +456,15 @@ public Context currentContext() { @Override public void onStateChange(Connection connection, State newState) { if (newState == HttpClientState.RESPONSE_RECEIVED) { + HttpClientOperations operations = connection.as(HttpClientOperations.class); + if (operations != null && handler.spnegoAuthProvider != null) { + int statusCode = operations.status().code(); + HttpHeaders headers = operations.responseHeaders(); + if (handler.spnegoAuthProvider.isUnauthorized(statusCode, headers)) { + handler.spnegoAuthProvider.invalidateCache(); + } + } + sink.success(connection); return; } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java index 04cfb1b07..478e35f5d 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java @@ -18,6 +18,7 @@ import static reactor.core.scheduler.Schedulers.boundedElastic; import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaders; import java.net.InetSocketAddress; import java.security.PrivilegedAction; import java.util.Base64; @@ -49,8 +50,14 @@ */ public final class SpnegoAuthProvider { + private static final String SPNEGO_HEADER = "Negotiate"; + private static final String STR_OID = "1.3.6.1.5.5.2"; + private final SpnegoAuthenticator authenticator; private final GSSManager gssManager; + private final int unauthorizedStatusCode; + + private volatile String verifiedAuthHeader; /** * Constructs a new SpnegoAuthProvider with the given authenticator and GSSManager. @@ -58,19 +65,21 @@ public final class SpnegoAuthProvider { * @param authenticator the authenticator to use for JAAS login * @param gssManager the GSSManager to use for SPNEGO token generation */ - private SpnegoAuthProvider(SpnegoAuthenticator authenticator, GSSManager gssManager) { + private SpnegoAuthProvider(SpnegoAuthenticator authenticator, GSSManager gssManager, int unauthorizedStatusCode) { this.authenticator = authenticator; this.gssManager = gssManager; + this.unauthorizedStatusCode = unauthorizedStatusCode; } /** * Creates a new SPNEGO authentication provider using the default GSSManager instance. * * @param authenticator the authenticator to use for JAAS login + * @param unauthorizedStatusCode the HTTP status code that indicates authentication failure * @return a new SPNEGO authentication provider */ - public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator) { - return create(authenticator, GSSManager.getInstance()); + public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, int unauthorizedStatusCode) { + return create(authenticator, GSSManager.getInstance(), unauthorizedStatusCode); } /** @@ -81,10 +90,11 @@ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator) { * * @param authenticator the authenticator to use for JAAS login * @param gssManager the GSSManager to use for SPNEGO token generation + * @param unauthorizedStatusCode the HTTP status code that indicates authentication failure * @return a new SPNEGO authentication provider */ - public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSManager gssManager) { - return new SpnegoAuthProvider(authenticator, gssManager); + public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSManager gssManager, int unauthorizedStatusCode) { + return new SpnegoAuthProvider(authenticator, gssManager, unauthorizedStatusCode); } /** @@ -100,6 +110,11 @@ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSMa * @throws RuntimeException if login or token generation fails */ public Mono apply(HttpClientRequest request, InetSocketAddress address) { + if (verifiedAuthHeader != null) { + request.header(HttpHeaderNames.AUTHORIZATION, verifiedAuthHeader); + return Mono.empty(); + } + return Mono.fromCallable(() -> { try { return Subject.doAs( @@ -107,17 +122,19 @@ public Mono apply(HttpClientRequest request, InetSocketAddress address) { (PrivilegedAction) () -> { try { byte[] token = generateSpnegoToken(address.getHostName()); - String authHeader = "Negotiate " + Base64.getEncoder().encodeToString(token); + String authHeader = SPNEGO_HEADER + " " + Base64.getEncoder().encodeToString(token); + + verifiedAuthHeader = authHeader; request.header(HttpHeaderNames.AUTHORIZATION, authHeader); return token; } - catch (GSSException e) { + catch (GSSException e) { throw new RuntimeException("Failed to generate SPNEGO token", e); } } ); } - catch (LoginException e) { + catch (LoginException e) { throw new RuntimeException("Failed to login with SPNEGO", e); } }) @@ -138,9 +155,41 @@ public Mono apply(HttpClientRequest request, InetSocketAddress address) { */ private byte[] generateSpnegoToken(String hostName) throws GSSException { GSSName serverName = gssManager.createName("HTTP/" + hostName, GSSName.NT_HOSTBASED_SERVICE); - Oid spnegoOid = new Oid("1.3.6.1.5.5.2"); // SPNEGO OID + Oid spnegoOid = new Oid(STR_OID); // SPNEGO OID GSSContext context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME); return context.initSecContext(new byte[0], 0, 0); } + + /** + * Invalidates the cached authentication token. + *

+ * This method should be called when a response indicates that the current token + * is no longer valid (typically after receiving an unauthorized status code). + * The next request will generate a new authentication token. + *

+ */ + public void invalidateCache() { + this.verifiedAuthHeader = null; + } + + /** + * Checks if the response indicates an authentication failure that requires a new token. + *

+ * This method checks both the status code and the WWW-Authenticate header to determine + * if a new SPNEGO token needs to be generated. + *

+ * + * @param status the HTTP status code + * @param headers the HTTP response headers + * @return true if the response indicates an authentication failure + */ + public boolean isUnauthorized(int status, HttpHeaders headers) { + if (status != unauthorizedStatusCode) { + return false; + } + + String header = headers.get(HttpHeaderNames.WWW_AUTHENTICATE); + return header != null && header.startsWith(SPNEGO_HEADER); + } } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java index 505f3366c..44d665311 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java @@ -21,7 +21,7 @@ /** * An abstraction for authentication logic used by SPNEGO providers. *

- * Implementations are responsible for performing a JAAS login and returning a logged-in Subject. + * Implementations are responsible for performing a login and returning a logged-in Subject. *

* * @author raccoonback @@ -30,9 +30,9 @@ public interface SpnegoAuthenticator { /** - * Performs a JAAS login and returns the authenticated Subject. + * Performs a login and returns the authenticated Subject. * - * @return the authenticated JAAS Subject + * @return the authenticated Subject * @throws LoginException if login fails */ Subject login() throws LoginException; diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java index c4ccd72e7..62d2a2a4d 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java @@ -91,7 +91,8 @@ void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException { principals.add(new KerberosPrincipal("test@LOCALHOST")); return new Subject(true, principals, new HashSet<>(), new HashSet<>()); }, - gssManager + gssManager, + 401 ) ) .wiretap(true) From 5a8ff0292e74a67bb8045b5b035549cfe258fa28 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Fri, 4 Jul 2025 12:04:17 +0900 Subject: [PATCH 03/29] Ensure GSSContext is disposed after initializing security context Signed-off-by: raccoonback --- .../java/reactor/netty/http/client/SpnegoAuthProvider.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java index 478e35f5d..34621ec8b 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java @@ -158,7 +158,11 @@ private byte[] generateSpnegoToken(String hostName) throws GSSException { Oid spnegoOid = new Oid(STR_OID); // SPNEGO OID GSSContext context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME); - return context.initSecContext(new byte[0], 0, 0); + try { + return context.initSecContext(new byte[0], 0, 0); + } finally { + context.dispose(); + } } /** From 956b1f80e11d2f9c57aabbd792e7686713dba902 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Fri, 4 Jul 2025 12:09:56 +0900 Subject: [PATCH 04/29] Add SpnegoAuthenticationException for better error handling in SPNEGO authentication Signed-off-by: raccoonback --- .../netty/http/client/SpnegoAuthProvider.java | 6 ++-- .../client/SpnegoAuthenticationException.java | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java index 34621ec8b..564bc8190 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java @@ -107,7 +107,7 @@ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSMa * @param request the HTTP client request to authenticate * @param address the target server address (used for service principal) * @return a Mono that completes when the authentication is applied - * @throws RuntimeException if login or token generation fails + * @throws SpnegoAuthenticationException if login or token generation fails */ public Mono apply(HttpClientRequest request, InetSocketAddress address) { if (verifiedAuthHeader != null) { @@ -129,13 +129,13 @@ public Mono apply(HttpClientRequest request, InetSocketAddress address) { return token; } catch (GSSException e) { - throw new RuntimeException("Failed to generate SPNEGO token", e); + throw new SpnegoAuthenticationException("Failed to generate SPNEGO token", e); } } ); } catch (LoginException e) { - throw new RuntimeException("Failed to login with SPNEGO", e); + throw new SpnegoAuthenticationException("Failed to login with SPNEGO", e); } }) .subscribeOn(boundedElastic()) diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java new file mode 100644 index 000000000..d9879bf59 --- /dev/null +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java @@ -0,0 +1,29 @@ +/* + * 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; + +/** + * Exception thrown when SPNEGO (Kerberos) authentication fails. + * + * @author raccoonback + * @since 1.3.0 + */ +public class SpnegoAuthenticationException extends RuntimeException { + + public SpnegoAuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} From 82dd4f6bc50f0da1e08f5e8fa438b0b45c11ef7c Mon Sep 17 00:00:00 2001 From: raccoonback Date: Fri, 4 Jul 2025 12:14:23 +0900 Subject: [PATCH 05/29] Improve SPNEGO authentication retry mechanism Signed-off-by: raccoonback --- docs/modules/ROOT/pages/http-client.adoc | 4 +- .../netty/http/client/HttpClientConnect.java | 49 ++- .../netty/http/client/SpnegoAuthProvider.java | 82 +++- .../client/SpnegoAuthenticationException.java | 2 +- .../http/client/SpnegoRetryException.java | 29 ++ .../http/client/SpnegoAuthProviderTest.java | 357 +++++++++++++++--- 6 files changed, 452 insertions(+), 71 deletions(-) create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoRetryException.java diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc index 6a0144128..e132277b0 100644 --- a/docs/modules/ROOT/pages/http-client.adoc +++ b/docs/modules/ROOT/pages/http-client.adoc @@ -779,7 +779,7 @@ include::{examples-dir}/resolver/Application.java[lines=18..39] <1> The timeout of each DNS query performed by this resolver will be 500ms. [[http-client-spnego]] -=== SPNEGO (Kerberos) Authentication +== SPNEGO Authentication Reactor Netty HttpClient supports SPNEGO (Kerberos) authentication, which is widely used in enterprise environments. SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) provides secure authentication over HTTP using Kerberos. @@ -800,7 +800,7 @@ include::{examples-dir}/spnego/Application.java[lines=18..39] <2> Configures the `krb5.conf`. krb5.conf is a Kerberos client configuration file used to define how the client locates and communicates with the Kerberos Key Distribution Center (KDC) for authentication. <3> Configures the SPNEGO jaas.conf. A JVM system property that enables detailed debug logging for Kerberos operations in Java. <4> `JaasAuthenticator` performs Kerberos authentication using a JAAS configuration (jaas.conf). -<5> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket and automatically adds the `Authorization: Negotiate ...` header to HTTP requests. +<5> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket and automatically adds the `Authorization: Negotiate ...` header to HTTP requests. If the server responds with `401 Unauthorized` and includes `WWW-Authenticate: Negotiate`, the client will automatically reauthenticate and retry the request once. ==== Environment Configuration ===== Example JAAS Configuration 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 46aabb5cb..95df365d2 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 { @@ -458,11 +459,12 @@ public void onStateChange(Connection connection, State newState) { if (newState == HttpClientState.RESPONSE_RECEIVED) { HttpClientOperations operations = connection.as(HttpClientOperations.class); if (operations != null && handler.spnegoAuthProvider != null) { - int statusCode = operations.status().code(); - HttpHeaders headers = operations.responseHeaders(); - if (handler.spnegoAuthProvider.isUnauthorized(statusCode, headers)) { - handler.spnegoAuthProvider.invalidateCache(); + if (shouldRetryWithSpnego(operations)) { + retryWithSpnego(operations); + return; } + + handler.spnegoAuthProvider.resetRetryCount(); } sink.success(connection); @@ -478,6 +480,42 @@ public void onStateChange(Connection connection, State newState) { .subscribe(connection.disposeSubscriber()); } } + + /** + * Determines if the current HTTP response requires a SPNEGO authentication retry. + * + * @param operations the HTTP client operations containing the response status and headers + * @return {@code true} if SPNEGO re-authentication should be attempted, {@code false} otherwise + */ + private boolean shouldRetryWithSpnego(HttpClientOperations operations) { + int statusCode = operations.status().code(); + HttpHeaders headers = operations.responseHeaders(); + + return handler.spnegoAuthProvider.isUnauthorized(statusCode, headers) + && handler.spnegoAuthProvider.canRetry(); + } + + /** + * Triggers a SPNEGO authentication retry by throwing a {@link SpnegoRetryException}. + *

+ * The exception-based approach ensures that a completely new {@link HttpClientOperations} + * instance is created, avoiding the "Status and headers already sent" error that would + * occur if trying to reuse the existing connection. + *

+ * + * @param operations the current HTTP client operations that received the 401 response + * @throws SpnegoRetryException always thrown to trigger the retry mechanism + */ + private void retryWithSpnego(HttpClientOperations operations) { + handler.spnegoAuthProvider.invalidateTokenHeader(); + handler.spnegoAuthProvider.incrementRetryCount(); + + if (log.isDebugEnabled()) { + log.debug(format(operations.channel(), "Triggering SPNEGO re-authentication")); + } + + sink.error(new SpnegoRetryException()); + } } static final class HttpClientHandler extends SocketAddress @@ -753,6 +791,9 @@ public boolean test(Throwable throwable) { redirect(re.location); return true; } + if (throwable instanceof SpnegoRetryException) { + 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/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java index 564bc8190..5cf8abef7 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java @@ -21,7 +21,10 @@ import io.netty.handler.codec.http.HttpHeaders; import java.net.InetSocketAddress; import java.security.PrivilegedAction; +import java.util.Arrays; import java.util.Base64; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import javax.security.auth.Subject; import javax.security.auth.login.LoginException; import org.ietf.jgss.GSSContext; @@ -30,6 +33,8 @@ import org.ietf.jgss.GSSName; import org.ietf.jgss.Oid; import reactor.core.publisher.Mono; +import reactor.util.Logger; +import reactor.util.Loggers; /** * Provides SPNEGO authentication for Reactor Netty HttpClient. @@ -50,6 +55,7 @@ */ public final class SpnegoAuthProvider { + private static final Logger log = Loggers.getLogger(SpnegoAuthProvider.class); private static final String SPNEGO_HEADER = "Negotiate"; private static final String STR_OID = "1.3.6.1.5.5.2"; @@ -57,7 +63,9 @@ public final class SpnegoAuthProvider { private final GSSManager gssManager; private final int unauthorizedStatusCode; - private volatile String verifiedAuthHeader; + private final AtomicReference verifiedAuthHeader = new AtomicReference<>(); + private final AtomicInteger retryCount = new AtomicInteger(0); + private static final int MAX_RETRY_COUNT = 1; /** * Constructs a new SpnegoAuthProvider with the given authenticator and GSSManager. @@ -110,8 +118,9 @@ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSMa * @throws SpnegoAuthenticationException if login or token generation fails */ public Mono apply(HttpClientRequest request, InetSocketAddress address) { - if (verifiedAuthHeader != null) { - request.header(HttpHeaderNames.AUTHORIZATION, verifiedAuthHeader); + String cachedToken = verifiedAuthHeader.get(); + if (cachedToken != null) { + request.header(HttpHeaderNames.AUTHORIZATION, cachedToken); return Mono.empty(); } @@ -124,7 +133,7 @@ public Mono apply(HttpClientRequest request, InetSocketAddress address) { byte[] token = generateSpnegoToken(address.getHostName()); String authHeader = SPNEGO_HEADER + " " + Base64.getEncoder().encodeToString(token); - verifiedAuthHeader = authHeader; + verifiedAuthHeader.set(authHeader); request.header(HttpHeaderNames.AUTHORIZATION, authHeader); return token; } @@ -154,27 +163,61 @@ public Mono apply(HttpClientRequest request, InetSocketAddress address) { * @throws GSSException if token generation fails */ private byte[] generateSpnegoToken(String hostName) throws GSSException { - GSSName serverName = gssManager.createName("HTTP/" + hostName, GSSName.NT_HOSTBASED_SERVICE); + if (hostName == null || hostName.trim().isEmpty()) { + throw new IllegalArgumentException("Host name cannot be null or empty"); + } + + GSSName serverName = gssManager.createName("HTTP/" + hostName.trim(), GSSName.NT_HOSTBASED_SERVICE); Oid spnegoOid = new Oid(STR_OID); // SPNEGO OID - GSSContext context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME); + GSSContext context = null; try { + context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME); return context.initSecContext(new byte[0], 0, 0); - } finally { - context.dispose(); + } + finally { + if (context != null) { + try { + context.dispose(); + } + catch (GSSException e) { + // Log but don't propagate disposal errors + if (log.isDebugEnabled()) { + log.debug("Failed to dispose GSSContext", e); + } + } + } } } /** * Invalidates the cached authentication token. - *

- * This method should be called when a response indicates that the current token - * is no longer valid (typically after receiving an unauthorized status code). - * The next request will generate a new authentication token. - *

*/ - public void invalidateCache() { - this.verifiedAuthHeader = null; + public void invalidateTokenHeader() { + this.verifiedAuthHeader.set(null); + } + + /** + * Checks if SPNEGO authentication retry is allowed. + * + * @return true if retry is allowed, false otherwise + */ + public boolean canRetry() { + return retryCount.get() < MAX_RETRY_COUNT; + } + + /** + * Increments the retry count for SPNEGO authentication attempts. + */ + public void incrementRetryCount() { + retryCount.incrementAndGet(); + } + + /** + * Resets the retry count for SPNEGO authentication. + */ + public void resetRetryCount() { + retryCount.set(0); } /** @@ -194,6 +237,13 @@ public boolean isUnauthorized(int status, HttpHeaders headers) { } String header = headers.get(HttpHeaderNames.WWW_AUTHENTICATE); - return header != null && header.startsWith(SPNEGO_HEADER); + if (header == null) { + return false; + } + + // More robust parsing - handle multiple comma-separated authentication schemes + return Arrays.stream(header.split(",")) + .map(String::trim) + .anyMatch(auth -> auth.toLowerCase().startsWith(SPNEGO_HEADER.toLowerCase())); } } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java index d9879bf59..27e492334 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java @@ -16,7 +16,7 @@ package reactor.netty.http.client; /** - * Exception thrown when SPNEGO (Kerberos) authentication fails. + * Exception thrown when SPNEGO authentication fails. * * @author raccoonback * @since 1.3.0 diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoRetryException.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoRetryException.java new file mode 100644 index 000000000..48182abff --- /dev/null +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoRetryException.java @@ -0,0 +1,29 @@ +/* + * 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; + +/** + * Exception thrown to trigger a retry when SPNEGO authentication fails with a 401 Unauthorized response. + * + * @author raccoonback + * @since 1.3.0 + */ +final class SpnegoRetryException extends RuntimeException { + + SpnegoRetryException() { + super("SPNEGO authentication requires retry"); + } +} diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java index 62d2a2a4d..714abeb93 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java @@ -22,11 +22,15 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + import io.netty.handler.codec.http.HttpHeaderNames; import java.nio.charset.StandardCharsets; import java.security.Principal; import java.util.HashSet; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import javax.security.auth.Subject; import javax.security.auth.kerberos.KerberosPrincipal; import org.ietf.jgss.GSSContext; @@ -34,8 +38,6 @@ import org.ietf.jgss.GSSManager; import org.ietf.jgss.GSSName; import org.ietf.jgss.Oid; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import reactor.netty.DisposableServer; @@ -46,11 +48,9 @@ class SpnegoAuthProviderTest { private static final int TEST_PORT = 8080; - private DisposableServer server; - - @BeforeEach - void setUp() { - server = HttpServer.create() + @Test + void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException { + DisposableServer server = HttpServer.create() .port(TEST_PORT) .route(routes -> routes .get("/", (request, response) -> { @@ -61,51 +61,312 @@ void setUp() { return response.status(401).sendString(Mono.just("Unauthorized")); })) .bindNow(); + + try { + GSSManager gssManager = mock(GSSManager.class); + GSSContext gssContext = mock(GSSContext.class); + GSSName gssName = mock(GSSName.class); + Oid oid = new Oid("1.3.6.1.5.5.2"); + + given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) + .willReturn("spnego-negotiate-token".getBytes(StandardCharsets.UTF_8)); + given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE))) + .willReturn(gssName); + given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt())) + .willReturn(gssContext); + + HttpClient client = HttpClient.create() + .port(TEST_PORT) + .spnego( + SpnegoAuthProvider.create( + () -> { + Set principals = new HashSet<>(); + principals.add(new KerberosPrincipal("test@LOCALHOST")); + return new Subject(true, principals, new HashSet<>(), new HashSet<>()); + }, + gssManager, + 401 + ) + ) + .wiretap(true) + .disableRetry(true); + + StepVerifier.create( + client.get() + .uri("/") + .responseContent() + .aggregate() + .asString() + ) + .expectNext("Authenticated") + .verifyComplete(); + } + finally { + server.disposeNow(); + } } - @AfterEach - void tearDown() { - server.disposeNow(); + @Test + void automaticReauthenticateOn401Response() throws GSSException { + AtomicInteger requestCount = new AtomicInteger(0); + + DisposableServer server = HttpServer.create() + .port(0) + .route(routes -> routes + .get("/reauth", (request, response) -> { + String authHeader = request.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); + int count = requestCount.incrementAndGet(); + + if (count == 1) { + return response.status(401) + .header("WWW-Authenticate", "Negotiate") + .sendString(Mono.just("Unauthorized")); + } + else if (authHeader != null && authHeader.startsWith("Negotiate ")) { + return response.status(200).sendString(Mono.just("Reauthenticated")); + } + return response.status(401).sendString(Mono.just("Failed")); + })) + .bindNow(); + + try { + GSSManager gssManager = mock(GSSManager.class); + GSSContext gssContext = mock(GSSContext.class); + GSSName gssName = mock(GSSName.class); + Oid oid = new Oid("1.3.6.1.5.5.2"); + + given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) + .willReturn("spnego-reauth-token".getBytes(StandardCharsets.UTF_8)); + given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE))) + .willReturn(gssName); + given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt())) + .willReturn(gssContext); + + HttpClient client = HttpClient.create() + .port(server.port()) + .spnego( + SpnegoAuthProvider.create( + () -> { + Set principals = new HashSet<>(); + principals.add(new KerberosPrincipal("test@LOCALHOST")); + return new Subject(true, principals, new HashSet<>(), new HashSet<>()); + }, + gssManager, + 401 + ) + ) + .wiretap(true) + .disableRetry(true); + + StepVerifier.create( + client.get() + .uri("/reauth") + .responseContent() + .aggregate() + .asString() + ) + .expectNext("Reauthenticated") + .verifyComplete(); + + verify(gssContext, times(2)).initSecContext(any(byte[].class), anyInt(), anyInt()); + } + finally { + server.disposeNow(); + } } @Test - void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException { - GSSManager gssManager = mock(GSSManager.class); - GSSContext gssContext = mock(GSSContext.class); - GSSName gssName = mock(GSSName.class); - Oid oid = new Oid("1.3.6.1.5.5.2"); - - given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) - .willReturn("spnego-negotiate-token".getBytes(StandardCharsets.UTF_8)); - given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE))) - .willReturn(gssName); - given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt())) - .willReturn(gssContext); - - HttpClient client = HttpClient.create() - .port(TEST_PORT) - .spnego( - SpnegoAuthProvider.create( - () -> { - Set principals = new HashSet<>(); - principals.add(new KerberosPrincipal("test@LOCALHOST")); - return new Subject(true, principals, new HashSet<>(), new HashSet<>()); - }, - gssManager, - 401 - ) - ) - .wiretap(true) - .disableRetry(true); - - StepVerifier.create( - client.get() - .uri("/") - .responseContent() - .aggregate() - .asString() - ) - .expectNext("Authenticated") - .verifyComplete(); + void doesNotReauthenticateWhenMaxRetryReached() throws GSSException { + AtomicInteger requestCount = new AtomicInteger(0); + + DisposableServer server = HttpServer.create() + .port(0) + .route(routes -> routes + .get("/fail", (request, response) -> { + requestCount.incrementAndGet(); + return response.status(401) + .header("WWW-Authenticate", "Negotiate") + .sendString(Mono.just("Always Unauthorized")); + })) + .bindNow(); + + try { + GSSManager gssManager = mock(GSSManager.class); + GSSContext gssContext = mock(GSSContext.class); + GSSName gssName = mock(GSSName.class); + Oid oid = new Oid("1.3.6.1.5.5.2"); + + given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) + .willReturn("spnego-fail-token".getBytes(StandardCharsets.UTF_8)); + given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE))) + .willReturn(gssName); + given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt())) + .willReturn(gssContext); + + HttpClient client = HttpClient.create() + .port(server.port()) + .spnego( + SpnegoAuthProvider.create( + () -> { + Set principals = new HashSet<>(); + principals.add(new KerberosPrincipal("test@LOCALHOST")); + return new Subject(true, principals, new HashSet<>(), new HashSet<>()); + }, + gssManager, + 401 + ) + ) + .wiretap(true) + .disableRetry(true); + + StepVerifier.create( + client.get() + .uri("/fail") + .response() + .map(response -> response.status().code()) + ) + .expectNext(401) + .verifyComplete(); + + verify(gssContext, times(2)).initSecContext(any(byte[].class), anyInt(), anyInt()); + } + finally { + server.disposeNow(); + } + } + + @Test + void doesNotReauthenticateWithoutWwwAuthenticateHeader() throws GSSException { + DisposableServer server = HttpServer.create() + .port(0) + .route(routes -> routes + .get("/noheader", (request, response) -> + response.status(401).sendString(Mono.just("No WWW-Authenticate header")))) + .bindNow(); + + try { + GSSManager gssManager = mock(GSSManager.class); + GSSContext gssContext = mock(GSSContext.class); + GSSName gssName = mock(GSSName.class); + Oid oid = new Oid("1.3.6.1.5.5.2"); + + given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) + .willReturn("spnego-token".getBytes(StandardCharsets.UTF_8)); + given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE))) + .willReturn(gssName); + given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt())) + .willReturn(gssContext); + + HttpClient client = HttpClient.create() + .port(server.port()) + .spnego( + SpnegoAuthProvider.create( + () -> { + Set principals = new HashSet<>(); + principals.add(new KerberosPrincipal("test@LOCALHOST")); + return new Subject(true, principals, new HashSet<>(), new HashSet<>()); + }, + gssManager, + 401 + ) + ) + .wiretap(true) + .disableRetry(true); + + StepVerifier.create( + client.get() + .uri("/noheader") + .response() + .map(response -> response.status().code()) + ) + .expectNext(401) + .verifyComplete(); + + verify(gssContext, times(1)).initSecContext(any(byte[].class), anyInt(), anyInt()); + } + finally { + server.disposeNow(); + } + } + + @Test + void successfulAuthenticationResetsRetryCount() throws GSSException { + AtomicInteger requestCount = new AtomicInteger(0); + + DisposableServer server = HttpServer.create() + .port(0) + .route(routes -> routes + .get("/reset", (request, response) -> { + String authHeader = request.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); + int count = requestCount.incrementAndGet(); + + if (count == 1) { + return response.status(401) + .header("WWW-Authenticate", "Negotiate") + .sendString(Mono.just("First 401")); + } + else if (authHeader != null && authHeader.startsWith("Negotiate ")) { + return response.status(200).sendString(Mono.just("Success")); + } + return response.status(401).sendString(Mono.just("Unexpected")); + })) + .bindNow(); + + try { + GSSManager gssManager = mock(GSSManager.class); + GSSContext gssContext = mock(GSSContext.class); + GSSName gssName = mock(GSSName.class); + Oid oid = new Oid("1.3.6.1.5.5.2"); + + given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) + .willReturn("spnego-reset-token".getBytes(StandardCharsets.UTF_8)); + given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE))) + .willReturn(gssName); + given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt())) + .willReturn(gssContext); + + SpnegoAuthProvider provider = SpnegoAuthProvider.create( + () -> { + Set principals = new HashSet<>(); + principals.add(new KerberosPrincipal("test@LOCALHOST")); + return new Subject(true, principals, new HashSet<>(), new HashSet<>()); + }, + gssManager, + 401 + ); + + HttpClient client = HttpClient.create() + .port(server.port()) + .spnego(provider) + .wiretap(true) + .disableRetry(true); + + StepVerifier.create( + client.get() + .uri("/reset") + .responseContent() + .aggregate() + .asString() + ) + .expectNext("Success") + .verifyComplete(); + + requestCount.set(0); + + StepVerifier.create( + client.get() + .uri("/reset") + .responseContent() + .aggregate() + .asString() + ) + .expectNext("Success") + .verifyComplete(); + + verify(gssContext, times(3)).initSecContext(any(byte[].class), anyInt(), anyInt()); + } + finally { + server.disposeNow(); + } } } From d1ef964f2b4789b5d4667a217529690dc6bc110d Mon Sep 17 00:00:00 2001 From: raccoonback Date: Wed, 30 Jul 2025 22:58:16 +0900 Subject: [PATCH 06/29] Support GSSCredential-based SPNEGO authentication Signed-off-by: raccoonback --- docs/modules/ROOT/pages/http-client.adoc | 91 ++++++++- .../spnego/gsscredential/Application.java | 46 +++++ .../client/spnego/{ => jaas}/Application.java | 11 +- .../client/GssCredentialAuthenticator.java | 88 +++++++++ .../netty/http/client/JaasAuthenticator.java | 62 ++++-- .../netty/http/client/SpnegoAuthProvider.java | 182 ++++++++++++------ .../http/client/SpnegoAuthenticator.java | 15 +- .../http/client/SpnegoAuthProviderTest.java | 118 +++--------- 8 files changed, 431 insertions(+), 182 deletions(-) create mode 100644 reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/gsscredential/Application.java rename reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/{ => jaas}/Application.java (82%) create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/GssCredentialAuthenticator.java diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc index e132277b0..912cdaf4e 100644 --- a/docs/modules/ROOT/pages/http-client.adoc +++ b/docs/modules/ROOT/pages/http-client.adoc @@ -785,24 +785,26 @@ SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) provides secure authe ==== How It Works SPNEGO authentication follows this HTTP authentication flow: -1. The client sends an HTTP request to a protected resource. -2. The server responds with `401 Unauthorized` and a `WWW-Authenticate: Negotiate` header. -3. The client generates a SPNEGO token based on its Kerberos ticket, and resends the request with an `Authorization: Negotiate ` header. -4. The server validates the token and, if authentication is successful, returns 200 OK. -If further negotiation is required, the server may return another 401 with additional data in the WWW-Authenticate header. +. The client sends an HTTP request to a protected resource. +. The server responds with `401 Unauthorized` and a `WWW-Authenticate: Negotiate` header. +. The client generates a SPNEGO token based on its Kerberos ticket, and resends the request with an `Authorization: Negotiate ` header. +. The server validates the token and, if authentication is successful, returns 200 OK. -{examples-link}/spnego/Application.java +If further negotiation is required, the server may return another 401 with additional data in the `WWW-Authenticate` header. + +==== JAAS-based Authenticator +{examples-link}/spnego/jaas/Application.java ---- -include::{examples-dir}/spnego/Application.java[lines=18..39] +include::{examples-dir}/spnego/jaas/Application.java[lines=18..45] ---- <1> Configures the `jaas.conf`. A JAAS configuration file in Java for integrating with authentication backends such as Kerberos. <2> Configures the `krb5.conf`. krb5.conf is a Kerberos client configuration file used to define how the client locates and communicates with the Kerberos Key Distribution Center (KDC) for authentication. <3> Configures the SPNEGO jaas.conf. A JVM system property that enables detailed debug logging for Kerberos operations in Java. <4> `JaasAuthenticator` performs Kerberos authentication using a JAAS configuration (jaas.conf). -<5> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket and automatically adds the `Authorization: Negotiate ...` header to HTTP requests. If the server responds with `401 Unauthorized` and includes `WWW-Authenticate: Negotiate`, the client will automatically reauthenticate and retry the request once. +<5> `SpnegoAuthProvider.Builder` supports the following configuration methods. Please refer to <>. +<6> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket. It automatically adds the `Authorization: Negotiate ...` header to HTTP requests. If the server responds with `401 Unauthorized` and includes `WWW-Authenticate: Negotiate`, the client will automatically reauthenticate and retry the request once. -==== Environment Configuration ===== Example JAAS Configuration Specify the path to your JAAS configuration file using the `java.security.auth.login.config` system property. @@ -844,6 +846,75 @@ Specify Kerberos realm and KDC information using the `java.security.krb5.conf` s -Djava.security.krb5.conf=/path/to/krb5.conf ---- +==== GSSCredential-based Authenticator +For scenarios where you already have a `GSSCredential` available or want to avoid JAAS configuration, you can use `GssCredentialAuthenticator`: + +{examples-link}/spnego/gsscredential/Application.java +---- +include::{examples-dir}/spnego/gsscredential/Application.java[lines=18..46] +---- +<1> Obtain the `GSSCredential` through other means. +<2> Configure the GSSCredential-based authenticator for SPNEGO authentication. + +This approach is useful when: +- You want to reuse existing credentials +- You need more control over credential management +- JAAS configuration is not available or preferred + +==== Custom Authenticator Implementation +For advanced scenarios where the provided authenticators don't meet your specific requirements, you can implement the `SpnegoAuthenticator` interface directly: + +---- +import org.ietf.jgss.GSSContext; +import reactor.netty.http.client.SpnegoAuthenticator; +import reactor.netty.http.client.SpnegoAuthProvider; + +public class CustomSpnegoAuthenticator implements SpnegoAuthenticator { + + @Override + public GSSContext createContext(String serviceName, String remoteHost) throws Exception { + // Your custom authentication logic here + // This method should return a configured GSSContext + // for the specified service and remote host + // serviceName: e.g., "HTTP", "LDAP" + // remoteHost: target server hostname + + return null; // Replace with actual GSSContext creation logic + } +} + +// Usage with advanced configuration +HttpClient client = HttpClient.create() + .spnego( + SpnegoAuthProvider.builder(new CustomSpnegoAuthenticator()) + .serviceName("HTTP") // Custom service name + .unauthorizedStatusCode(401) // Custom status code + .resolveCanonicalHostname(true) // Use canonical hostname + .build() + ); +---- + +This approach is useful when you need: +- Custom credential acquisition logic +- Integration with third-party authentication systems +- Special handling for token caching or refresh +- Environment-specific authentication flows + +[[spnegoauthprovider-config]] +==== SpnegoAuthProvider Configuration Options +The `SpnegoAuthProvider.Builder` supports the following configuration Options: + +[width="100%",options="header"] +|======= +| Method | Default | Description | Example +| `serviceName(String)` | "HTTP" | Service name for constructing service principal names (serviceName/hostname) | "HTTP", "LDAP" +| `unauthorizedStatusCode(int)` | 401 | HTTP status code that triggers authentication retry | 401, 407 +| `resolveCanonicalHostname(boolean)` | false | Whether to use canonical hostname resolution via reverse DNS lookup | true for FQDN requirements +|======= + ==== Notes - SPNEGO authentication is fully supported on Java 1.6 and above. -- If authentication fails, check the server logs and client exception messages, and verify your Kerberos environment settings (realm, KDC, ticket, etc.). \ No newline at end of file +- If authentication fails, check the server logs and client exception messages, and verify your Kerberos environment settings (realm, KDC, ticket, etc.). +- `JaasAuthenticator` performs authentication through JAAS login configuration. +- `GssCredentialAuthenticator` uses pre-existing `GSSCredential` objects, bypassing JAAS configuration. +- For custom scenarios, implement the `SpnegoAuthenticator` interface to provide your own authentication logic. diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/gsscredential/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/gsscredential/Application.java new file mode 100644 index 000000000..e67ab5af0 --- /dev/null +++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/gsscredential/Application.java @@ -0,0 +1,46 @@ +/* + * 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.spnego.gsscredential; + +import org.ietf.jgss.GSSCredential; +import reactor.netty.http.client.GssCredentialAuthenticator; +import reactor.netty.http.client.HttpClient; +import reactor.netty.http.client.SpnegoAuthProvider; + +public class Application { + + public static void main(String[] args) { + // Assuming you have obtained a GSSCredential from elsewhere + GSSCredential credential = obtainGSSCredential(); // <1> + + HttpClient client = HttpClient.create() + .spnego( + SpnegoAuthProvider.builder(new GssCredentialAuthenticator(credential)) // <2> + .build() + ); + + client.get() + .uri("http://protected.example.com/") + .responseSingle((res, content) -> content.asString()) + .block(); + } + + private static GSSCredential obtainGSSCredential() { + // Implement your logic to obtain a GSSCredential + // This could involve using a Kerberos library or other means + return null; + } +} diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/jaas/Application.java similarity index 82% rename from reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java rename to reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/jaas/Application.java index b79bf9955..d011faa1d 100644 --- a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java +++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/jaas/Application.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package reactor.netty.examples.documentation.http.client.spnego; +package reactor.netty.examples.documentation.http.client.spnego.jaas; import reactor.netty.http.client.HttpClient; import reactor.netty.http.client.JaasAuthenticator; @@ -28,8 +28,15 @@ public static void main(String[] args) { System.setProperty("sun.security.krb5.debug", "true"); // <3> SpnegoAuthenticator authenticator = new JaasAuthenticator("KerberosLogin"); // <4> + SpnegoAuthProvider provider = SpnegoAuthProvider.builder(authenticator) + .serviceName("HTTP") + .unauthorizedStatusCode(401) + .resolveCanonicalHostname(false) + .build(); HttpClient client = HttpClient.create() - .spnego(SpnegoAuthProvider.create(authenticator, 401)); // <5> + .spnego( + provider // <5> + ); // <6> client.get() .uri("http://protected.example.com/") diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/GssCredentialAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/GssCredentialAuthenticator.java new file mode 100644 index 000000000..721ad6b5f --- /dev/null +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/GssCredentialAuthenticator.java @@ -0,0 +1,88 @@ +/* + * 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; + +import java.util.Objects; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSCredential; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.ietf.jgss.Oid; + +/** + * A GSSCredential-based Authenticator implementation for use with SPNEGO providers. + *

+ * This authenticator uses a pre-existing GSSCredential to create a GSSContext, + * bypassing the need for JAAS login configuration. This is useful when you already + * have obtained Kerberos credentials through other means or want more direct control + * over the authentication process. + *

+ *

+ * The GSSCredential should contain valid Kerberos credentials that can be used + * for SPNEGO authentication. The credential's lifetime and validity are managed + * externally to this authenticator. + *

+ * + *

Example usage:

+ *
+ *     GSSCredential credential = // ... obtain credential
+ *     GssCredentialAuthenticator authenticator = new GssCredentialAuthenticator(credential);
+ *     SpnegoAuthProvider provider = SpnegoAuthProvider.builder(authenticator).build();
+ * 
+ * + * @author raccoonback + * @since 1.3.0 + */ +public class GssCredentialAuthenticator implements SpnegoAuthenticator { + + private final GSSCredential credential; + + /** + * Creates a new GssCredentialAuthenticator with the given GSSCredential. + * + * @param credential the GSSCredential to use for authentication + */ + public GssCredentialAuthenticator(GSSCredential credential) { + Objects.requireNonNull(credential, "GSSCredential cannot be null"); + this.credential = credential; + } + + /** + * Creates a GSSContext for the specified service and remote host using the provided GSSCredential. + *

+ * This method uses the pre-existing GSSCredential to create a GSSContext for SPNEGO + * authentication. The service principal name is constructed as serviceName/remoteHost. + *

+ * + * @param serviceName the service name (e.g., "HTTP", "FTP") + * @param remoteHost the remote host to authenticate with + * @return the created GSSContext configured for SPNEGO authentication + * @throws Exception if context creation fails + */ + @Override + public GSSContext createContext(String serviceName, String remoteHost) throws Exception { + GSSManager manager = GSSManager.getInstance(); + GSSName serverName = manager.createName(serviceName + "/" + remoteHost, GSSName.NT_HOSTBASED_SERVICE); + GSSContext context = manager.createContext( + serverName, + new Oid("1.3.6.1.5.5.2"), + credential, + GSSContext.DEFAULT_LIFETIME + ); + context.requestMutualAuth(true); + return context; + } +} \ No newline at end of file diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java index 6fd7ae749..c5f23e2fb 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java @@ -15,42 +15,78 @@ */ package reactor.netty.http.client; +import java.security.PrivilegedExceptionAction; import javax.security.auth.Subject; import javax.security.auth.login.LoginContext; -import javax.security.auth.login.LoginException; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.ietf.jgss.Oid; /** * A JAAS-based Authenticator implementation for use with SPNEGO providers. *

- * This authenticator performs a JAAS login using the specified context name and returns the authenticated Subject. + * This authenticator performs a JAAS login using the specified context name and creates a GSSContext + * for SPNEGO authentication. It relies on JAAS configuration to obtain Kerberos credentials. + *

+ *

+ * The JAAS configuration should define a login context that acquires Kerberos credentials, + * typically using the Krb5LoginModule. The login context name provided to this authenticator + * must match the entry name in the JAAS configuration file. *

* + *

Example usage:

+ *
+ *     JaasAuthenticator authenticator = new JaasAuthenticator("KerberosLogin");
+ *     SpnegoAuthProvider provider = SpnegoAuthProvider.builder(authenticator).build();
+ * 
+ * * @author raccoonback * @since 1.3.0 */ public class JaasAuthenticator implements SpnegoAuthenticator { - private final String contextName; + private final String loginContext; /** * Creates a new JaasAuthenticator with the given context name. * - * @param contextName the JAAS login context name + * @param loginContext the JAAS login context name */ - public JaasAuthenticator(String contextName) { - this.contextName = contextName; + public JaasAuthenticator(String loginContext) { + this.loginContext = loginContext; } /** - * Performs a JAAS login using the configured context name and returns the authenticated Subject. + * Creates a GSSContext for the specified service and remote host using JAAS authentication. + *

+ * This method performs a JAAS login, obtains the authenticated Subject, and creates + * a GSSContext within the Subject's security context. The service principal name + * is constructed as serviceName/remoteHost. + *

* - * @return the authenticated JAAS Subject - * @throws LoginException if login fails + * @param serviceName the service name (e.g., "HTTP", "CIFS") + * @param remoteHost the remote host to authenticate with + * @return the created GSSContext configured for SPNEGO authentication + * @throws Exception if JAAS login or context creation fails */ @Override - public Subject login() throws LoginException { - LoginContext context = new LoginContext(contextName); - context.login(); - return context.getSubject(); + public GSSContext createContext(String serviceName, String remoteHost) throws Exception { + LoginContext lc = new LoginContext(loginContext); + lc.login(); + Subject subject = lc.getSubject(); + + return Subject.doAs(subject, (PrivilegedExceptionAction) () -> { + GSSManager manager = GSSManager.getInstance(); + GSSName serverName = manager.createName(serviceName + "/" + remoteHost, GSSName.NT_HOSTBASED_SERVICE); + GSSContext context = manager.createContext( + serverName, + new Oid("1.3.6.1.5.5.2"), // SPNEGO + null, + GSSContext.DEFAULT_LIFETIME + ); + context.requestMutualAuth(true); + return context; + }); } } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java index 5cf8abef7..c5c169b50 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java @@ -20,18 +20,12 @@ import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaders; import java.net.InetSocketAddress; -import java.security.PrivilegedAction; import java.util.Arrays; import java.util.Base64; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; -import javax.security.auth.Subject; -import javax.security.auth.login.LoginException; 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.util.Logger; import reactor.util.Loggers; @@ -43,11 +37,30 @@ * to the HTTP Authorization header for outgoing requests, enabling single sign-on and * secure authentication in enterprise environments. *

+ *

+ * The provider supports authentication caching and retry mechanisms to handle token + * expiration and authentication failures gracefully. It can be configured with different + * service names, unauthorized status codes, and hostname resolution strategies. + *

* - *

Typical usage:

+ *

Basic usage with JAAS:

*
+ *     SpnegoAuthProvider provider = SpnegoAuthProvider
+ *         .builder(new JaasAuthenticator("KerberosLogin"))
+ *         .build();
+ *
  *     HttpClient client = HttpClient.create()
- *         .spnego(SpnegoAuthProvider.create(new JaasAuthenticator("KerberosLogin")));
+ *         .spnego(provider);
+ * 
+ * + *

Advanced configuration:

+ *
+ *     SpnegoAuthProvider provider = SpnegoAuthProvider
+ *         .builder(new GssCredentialAuthenticator(credential))
+ *         .serviceName("CIFS")
+ *         .unauthorizedStatusCode(401)
+ *         .resolveCanonicalHostname(true)
+ *         .build();
  * 
* * @author raccoonback @@ -57,52 +70,99 @@ public final class SpnegoAuthProvider { private static final Logger log = Loggers.getLogger(SpnegoAuthProvider.class); private static final String SPNEGO_HEADER = "Negotiate"; - private static final String STR_OID = "1.3.6.1.5.5.2"; private final SpnegoAuthenticator authenticator; - private final GSSManager gssManager; private final int unauthorizedStatusCode; + private final String serviceName; + private final boolean resolveCanonicalHostname; private final AtomicReference verifiedAuthHeader = new AtomicReference<>(); private final AtomicInteger retryCount = new AtomicInteger(0); private static final int MAX_RETRY_COUNT = 1; /** - * Constructs a new SpnegoAuthProvider with the given authenticator and GSSManager. + * Constructs a new SpnegoAuthProvider with the specified configuration. + *

+ * This constructor is private and should only be used by the {@link Builder}. + * Use {@link #builder(SpnegoAuthenticator)} to create instances. + *

* - * @param authenticator the authenticator to use for JAAS login - * @param gssManager the GSSManager to use for SPNEGO token generation + * @param authenticator the authenticator to use for SPNEGO authentication (must not be null) + * @param unauthorizedStatusCode the HTTP status code that indicates authentication failure + * @param serviceName the service name for constructing service principal names + * @param resolveCanonicalHostname whether to resolve canonical hostnames for service principals */ - private SpnegoAuthProvider(SpnegoAuthenticator authenticator, GSSManager gssManager, int unauthorizedStatusCode) { + private SpnegoAuthProvider(SpnegoAuthenticator authenticator, int unauthorizedStatusCode, String serviceName, boolean resolveCanonicalHostname) { this.authenticator = authenticator; - this.gssManager = gssManager; this.unauthorizedStatusCode = unauthorizedStatusCode; + this.serviceName = serviceName; + this.resolveCanonicalHostname = resolveCanonicalHostname; } /** - * Creates a new SPNEGO authentication provider using the default GSSManager instance. + * Creates a new builder for configuring SPNEGO authentication provider. * - * @param authenticator the authenticator to use for JAAS login - * @param unauthorizedStatusCode the HTTP status code that indicates authentication failure - * @return a new SPNEGO authentication provider + * @param authenticator the authenticator to use for SPNEGO authentication + * @return a new builder instance */ - public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, int unauthorizedStatusCode) { - return create(authenticator, GSSManager.getInstance(), unauthorizedStatusCode); + public static Builder builder(SpnegoAuthenticator authenticator) { + return new Builder(authenticator); } /** - * Creates a new SPNEGO authentication provider with a custom GSSManager instance. - *

- * This overload is intended for testing or advanced scenarios where a custom GSSManager is needed. - *

- * - * @param authenticator the authenticator to use for JAAS login - * @param gssManager the GSSManager to use for SPNEGO token generation - * @param unauthorizedStatusCode the HTTP status code that indicates authentication failure - * @return a new SPNEGO authentication provider + * Builder for creating SpnegoAuthProvider instances. */ - public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSManager gssManager, int unauthorizedStatusCode) { - return new SpnegoAuthProvider(authenticator, gssManager, unauthorizedStatusCode); + public static final class Builder { + private final SpnegoAuthenticator authenticator; + private int unauthorizedStatusCode = 401; + private String serviceName = "HTTP"; + private boolean resolveCanonicalHostname; + + private Builder(SpnegoAuthenticator authenticator) { + this.authenticator = authenticator; + } + + /** + * Sets the HTTP status code that indicates authentication failure. + * + * @param statusCode the status code (default: 401) + * @return this builder + */ + public Builder unauthorizedStatusCode(int statusCode) { + this.unauthorizedStatusCode = statusCode; + return this; + } + + /** + * Sets the service name for the service principal. + * + * @param serviceName the service name (default: "HTTP") + * @return this builder + */ + public Builder serviceName(String serviceName) { + this.serviceName = serviceName; + return this; + } + + /** + * Sets whether to resolve canonical hostname. + * + * @param resolveCanonicalHostname true to resolve canonical hostname (default: false) + * @return this builder + */ + public Builder resolveCanonicalHostname(boolean resolveCanonicalHostname) { + this.resolveCanonicalHostname = resolveCanonicalHostname; + return this; + } + + /** + * Builds the SpnegoAuthProvider instance. + * + * @return a new SpnegoAuthProvider + */ + public SpnegoAuthProvider build() { + return new SpnegoAuthProvider(authenticator, unauthorizedStatusCode, serviceName, resolveCanonicalHostname); + } } /** @@ -126,53 +186,60 @@ public Mono apply(HttpClientRequest request, InetSocketAddress address) { return Mono.fromCallable(() -> { try { - return Subject.doAs( - authenticator.login(), - (PrivilegedAction) () -> { - try { - byte[] token = generateSpnegoToken(address.getHostName()); - String authHeader = SPNEGO_HEADER + " " + Base64.getEncoder().encodeToString(token); - - verifiedAuthHeader.set(authHeader); - request.header(HttpHeaderNames.AUTHORIZATION, authHeader); - return token; - } - catch (GSSException e) { - throw new SpnegoAuthenticationException("Failed to generate SPNEGO token", e); - } - } - ); + String hostName = resolveHostName(address); + byte[] token = generateSpnegoToken(hostName); + String authHeader = SPNEGO_HEADER + " " + Base64.getEncoder().encodeToString(token); + + verifiedAuthHeader.set(authHeader); + request.header(HttpHeaderNames.AUTHORIZATION, authHeader); + return token; } - catch (LoginException e) { - throw new SpnegoAuthenticationException("Failed to login with SPNEGO", e); + catch (Exception e) { + throw new SpnegoAuthenticationException("Failed to generate SPNEGO token", e); } }) .subscribeOn(boundedElastic()) .then(); } + /** + * Resolves the hostname from the given socket address. + *

+ * This method returns either the hostname or canonical hostname based on the + * {@code resolveCanonicalHostname} configuration. When canonical hostname resolution + * is enabled, it performs a reverse DNS lookup to get the fully qualified domain name. + *

+ * + * @param address the socket address to resolve hostname from + * @return the resolved hostname (canonical if configured, otherwise standard hostname) + */ + private String resolveHostName(InetSocketAddress address) { + String hostName = address.getHostName(); + if (resolveCanonicalHostname) { + hostName = address.getAddress().getCanonicalHostName(); + } + return hostName; + } + /** * Generates a SPNEGO token for the given host name. *

- * This method uses the GSSManager to create a GSSContext and generate a SPNEGO token + * This method uses the authenticator to create a GSSContext and generate a SPNEGO token * for the specified service principal (HTTP/hostName). *

* * @param hostName the target server host name * @return the raw SPNEGO token bytes - * @throws GSSException if token generation fails + * @throws Exception if token generation fails */ - private byte[] generateSpnegoToken(String hostName) throws GSSException { + private byte[] generateSpnegoToken(String hostName) throws Exception { if (hostName == null || hostName.trim().isEmpty()) { throw new IllegalArgumentException("Host name cannot be null or empty"); } - GSSName serverName = gssManager.createName("HTTP/" + hostName.trim(), GSSName.NT_HOSTBASED_SERVICE); - Oid spnegoOid = new Oid(STR_OID); // SPNEGO OID - GSSContext context = null; try { - context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME); + context = authenticator.createContext(serviceName, hostName.trim()); return context.initSecContext(new byte[0], 0, 0); } finally { @@ -241,7 +308,6 @@ public boolean isUnauthorized(int status, HttpHeaders headers) { return false; } - // More robust parsing - handle multiple comma-separated authentication schemes return Arrays.stream(header.split(",")) .map(String::trim) .anyMatch(auth -> auth.toLowerCase().startsWith(SPNEGO_HEADER.toLowerCase())); diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java index 44d665311..392470359 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java @@ -15,13 +15,12 @@ */ package reactor.netty.http.client; -import javax.security.auth.Subject; -import javax.security.auth.login.LoginException; +import org.ietf.jgss.GSSContext; /** * An abstraction for authentication logic used by SPNEGO providers. *

- * Implementations are responsible for performing a login and returning a logged-in Subject. + * Implementations are responsible for creating a GSSContext for the specified remote host. *

* * @author raccoonback @@ -30,10 +29,12 @@ public interface SpnegoAuthenticator { /** - * Performs a login and returns the authenticated Subject. + * Creates a GSSContext for the specified remote host. * - * @return the authenticated Subject - * @throws LoginException if login fails + * @param serviceName the service name (e.g., "HTTP", "FTP") + * @param remoteHost the remote host to authenticate with + * @return the created GSSContext + * @throws Exception if context creation fails */ - Subject login() throws LoginException; + GSSContext createContext(String serviceName, String remoteHost) throws Exception; } diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java index 714abeb93..d5453dcdd 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java @@ -17,27 +17,16 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; - import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import io.netty.handler.codec.http.HttpHeaderNames; import java.nio.charset.StandardCharsets; -import java.security.Principal; -import java.util.HashSet; -import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; -import javax.security.auth.Subject; -import javax.security.auth.kerberos.KerberosPrincipal; 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 org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import reactor.netty.DisposableServer; @@ -49,7 +38,7 @@ class SpnegoAuthProviderTest { private static final int TEST_PORT = 8080; @Test - void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException { + void negotiateSpnegoAuthenticationWithHttpClient() throws Exception { DisposableServer server = HttpServer.create() .port(TEST_PORT) .route(routes -> routes @@ -63,30 +52,19 @@ void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException { .bindNow(); try { - GSSManager gssManager = mock(GSSManager.class); GSSContext gssContext = mock(GSSContext.class); - GSSName gssName = mock(GSSName.class); - Oid oid = new Oid("1.3.6.1.5.5.2"); + SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class); given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) .willReturn("spnego-negotiate-token".getBytes(StandardCharsets.UTF_8)); - given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE))) - .willReturn(gssName); - given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt())) + given(authenticator.createContext(anyString(), anyString())) .willReturn(gssContext); HttpClient client = HttpClient.create() .port(TEST_PORT) .spnego( - SpnegoAuthProvider.create( - () -> { - Set principals = new HashSet<>(); - principals.add(new KerberosPrincipal("test@LOCALHOST")); - return new Subject(true, principals, new HashSet<>(), new HashSet<>()); - }, - gssManager, - 401 - ) + SpnegoAuthProvider.builder(authenticator) + .build() ) .wiretap(true) .disableRetry(true); @@ -107,7 +85,7 @@ void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException { } @Test - void automaticReauthenticateOn401Response() throws GSSException { + void automaticReauthenticateOn401Response() throws Exception { AtomicInteger requestCount = new AtomicInteger(0); DisposableServer server = HttpServer.create() @@ -130,30 +108,19 @@ else if (authHeader != null && authHeader.startsWith("Negotiate ")) { .bindNow(); try { - GSSManager gssManager = mock(GSSManager.class); GSSContext gssContext = mock(GSSContext.class); - GSSName gssName = mock(GSSName.class); - Oid oid = new Oid("1.3.6.1.5.5.2"); + SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class); given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) .willReturn("spnego-reauth-token".getBytes(StandardCharsets.UTF_8)); - given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE))) - .willReturn(gssName); - given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt())) + given(authenticator.createContext(anyString(), anyString())) .willReturn(gssContext); HttpClient client = HttpClient.create() .port(server.port()) .spnego( - SpnegoAuthProvider.create( - () -> { - Set principals = new HashSet<>(); - principals.add(new KerberosPrincipal("test@LOCALHOST")); - return new Subject(true, principals, new HashSet<>(), new HashSet<>()); - }, - gssManager, - 401 - ) + SpnegoAuthProvider.builder(authenticator) + .build() ) .wiretap(true) .disableRetry(true); @@ -176,7 +143,7 @@ else if (authHeader != null && authHeader.startsWith("Negotiate ")) { } @Test - void doesNotReauthenticateWhenMaxRetryReached() throws GSSException { + void doesNotReauthenticateWhenMaxRetryReached() throws Exception { AtomicInteger requestCount = new AtomicInteger(0); DisposableServer server = HttpServer.create() @@ -191,30 +158,19 @@ void doesNotReauthenticateWhenMaxRetryReached() throws GSSException { .bindNow(); try { - GSSManager gssManager = mock(GSSManager.class); GSSContext gssContext = mock(GSSContext.class); - GSSName gssName = mock(GSSName.class); - Oid oid = new Oid("1.3.6.1.5.5.2"); + SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class); given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) .willReturn("spnego-fail-token".getBytes(StandardCharsets.UTF_8)); - given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE))) - .willReturn(gssName); - given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt())) + given(authenticator.createContext(anyString(), anyString())) .willReturn(gssContext); HttpClient client = HttpClient.create() .port(server.port()) .spnego( - SpnegoAuthProvider.create( - () -> { - Set principals = new HashSet<>(); - principals.add(new KerberosPrincipal("test@LOCALHOST")); - return new Subject(true, principals, new HashSet<>(), new HashSet<>()); - }, - gssManager, - 401 - ) + SpnegoAuthProvider.builder(authenticator) + .build() ) .wiretap(true) .disableRetry(true); @@ -236,7 +192,7 @@ void doesNotReauthenticateWhenMaxRetryReached() throws GSSException { } @Test - void doesNotReauthenticateWithoutWwwAuthenticateHeader() throws GSSException { + void doesNotReauthenticateWithoutWwwAuthenticateHeader() throws Exception { DisposableServer server = HttpServer.create() .port(0) .route(routes -> routes @@ -245,30 +201,19 @@ void doesNotReauthenticateWithoutWwwAuthenticateHeader() throws GSSException { .bindNow(); try { - GSSManager gssManager = mock(GSSManager.class); GSSContext gssContext = mock(GSSContext.class); - GSSName gssName = mock(GSSName.class); - Oid oid = new Oid("1.3.6.1.5.5.2"); + SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class); given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) .willReturn("spnego-token".getBytes(StandardCharsets.UTF_8)); - given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE))) - .willReturn(gssName); - given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt())) + given(authenticator.createContext(anyString(), anyString())) .willReturn(gssContext); HttpClient client = HttpClient.create() .port(server.port()) .spnego( - SpnegoAuthProvider.create( - () -> { - Set principals = new HashSet<>(); - principals.add(new KerberosPrincipal("test@LOCALHOST")); - return new Subject(true, principals, new HashSet<>(), new HashSet<>()); - }, - gssManager, - 401 - ) + SpnegoAuthProvider.builder(authenticator) + .build() ) .wiretap(true) .disableRetry(true); @@ -290,7 +235,7 @@ void doesNotReauthenticateWithoutWwwAuthenticateHeader() throws GSSException { } @Test - void successfulAuthenticationResetsRetryCount() throws GSSException { + void successfulAuthenticationResetsRetryCount() throws Exception { AtomicInteger requestCount = new AtomicInteger(0); DisposableServer server = HttpServer.create() @@ -313,27 +258,16 @@ else if (authHeader != null && authHeader.startsWith("Negotiate ")) { .bindNow(); try { - GSSManager gssManager = mock(GSSManager.class); GSSContext gssContext = mock(GSSContext.class); - GSSName gssName = mock(GSSName.class); - Oid oid = new Oid("1.3.6.1.5.5.2"); + SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class); given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) .willReturn("spnego-reset-token".getBytes(StandardCharsets.UTF_8)); - given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE))) - .willReturn(gssName); - given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt())) + given(authenticator.createContext(anyString(), anyString())) .willReturn(gssContext); - SpnegoAuthProvider provider = SpnegoAuthProvider.create( - () -> { - Set principals = new HashSet<>(); - principals.add(new KerberosPrincipal("test@LOCALHOST")); - return new Subject(true, principals, new HashSet<>(), new HashSet<>()); - }, - gssManager, - 401 - ); + SpnegoAuthProvider provider = SpnegoAuthProvider.builder(authenticator) + .build(); HttpClient client = HttpClient.create() .port(server.port()) From 5e664faa5b62e68cb4efa91f6fa1a08078975868 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Mon, 17 Nov 2025 13:17:34 +0900 Subject: [PATCH 07/29] Generalize the httpAuthentication infrastructure and remove SPNEGO-specific design Following the review feedback, this change updates the proposed SPNEGO-specific implementation into a more generic authentication flow that can support various HTTP authentication mechanisms. Signed-off-by: raccoonback --- docs/modules/ROOT/pages/http-client.adoc | 198 +++++------ .../authentication/basic/Application.java | 46 +++ .../authentication/spnego/Application.java | 69 ++++ .../authentication/token/Application.java | 52 +++ .../spnego/gsscredential/Application.java | 46 --- .../http/client/spnego/jaas/Application.java | 46 --- .../client/GssCredentialAuthenticator.java | 88 ----- .../reactor/netty/http/client/HttpClient.java | 40 ++- ...=> HttpClientAuthenticationException.java} | 19 +- .../netty/http/client/HttpClientConfig.java | 6 +- .../netty/http/client/HttpClientConnect.java | 62 +--- .../netty/http/client/JaasAuthenticator.java | 92 ----- .../netty/http/client/SpnegoAuthProvider.java | 315 ------------------ .../client/SpnegoAuthenticationException.java | 29 -- .../http/client/SpnegoAuthenticator.java | 40 --- .../netty/http/client/HttpClientTest.java | 226 +++++++++++++ .../http/client/SpnegoAuthProviderTest.java | 306 ----------------- 17 files changed, 541 insertions(+), 1139 deletions(-) create mode 100644 reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/basic/Application.java create mode 100644 reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/spnego/Application.java create mode 100644 reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/token/Application.java delete mode 100644 reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/gsscredential/Application.java delete mode 100644 reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/jaas/Application.java delete mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/GssCredentialAuthenticator.java rename reactor-netty-http/src/main/java/reactor/netty/http/client/{SpnegoRetryException.java => HttpClientAuthenticationException.java} (55%) delete mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java delete mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java delete mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java delete mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java delete mode 100644 reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc index 912cdaf4e..85a5d804c 100644 --- a/docs/modules/ROOT/pages/http-client.adoc +++ b/docs/modules/ROOT/pages/http-client.adoc @@ -778,143 +778,105 @@ include::{examples-dir}/resolver/Application.java[lines=18..39] ---- <1> The timeout of each DNS query performed by this resolver will be 500ms. -[[http-client-spnego]] -== SPNEGO Authentication -Reactor Netty HttpClient supports SPNEGO (Kerberos) authentication, which is widely used in enterprise environments. -SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) provides secure authentication over HTTP using Kerberos. +[[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. -==== How It Works -SPNEGO authentication follows this HTTP authentication flow: +The {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiPredicate-java.util.function.BiFunction-[`httpAuthentication`] +method accepts two parameters: -. The client sends an HTTP request to a protected resource. -. The server responds with `401 Unauthorized` and a `WWW-Authenticate: Negotiate` header. -. The client generates a SPNEGO token based on its Kerberos ticket, and resends the request with an `Authorization: Negotiate ` header. -. The server validates the token and, if authentication is successful, returns 200 OK. - -If further negotiation is required, the server may return another 401 with additional data in the `WWW-Authenticate` header. +* A predicate that determines when authentication should be applied (typically by checking the HTTP status code and headers) +* An authenticator function that applies authentication credentials to the request -==== JAAS-based Authenticator -{examples-link}/spnego/jaas/Application.java ----- -include::{examples-dir}/spnego/jaas/Application.java[lines=18..45] ----- -<1> Configures the `jaas.conf`. A JAAS configuration file in Java for integrating with authentication backends such as Kerberos. -<2> Configures the `krb5.conf`. krb5.conf is a Kerberos client configuration file used to define how the client locates and communicates with the Kerberos Key Distribution Center (KDC) for authentication. -<3> Configures the SPNEGO jaas.conf. A JVM system property that enables detailed debug logging for Kerberos operations in Java. -<4> `JaasAuthenticator` performs Kerberos authentication using a JAAS configuration (jaas.conf). -<5> `SpnegoAuthProvider.Builder` supports the following configuration methods. Please refer to <>. -<6> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket. It automatically adds the `Authorization: Negotiate ...` header to HTTP requests. If the server responds with `401 Unauthorized` and includes `WWW-Authenticate: Negotiate`, the client will automatically reauthenticate and retry the request once. +This approach gives you complete control over the authentication flow while Reactor Netty handles the retry mechanism. -===== Example JAAS Configuration -Specify the path to your JAAS configuration file using the `java.security.auth.login.config` system property. - -.`jaas.conf` -[jaas,conf] ----- -KerberosLogin { - com.sun.security.auth.module.Krb5LoginModule required - client=true - useKeyTab=true - keyTab="/path/to/test.keytab" - principal="test@EXAMPLE.COM" - doNotPrompt=true - debug=true; -}; ----- +=== How It Works -===== Example Kerberos Configuration -Specify Kerberos realm and KDC information using the `java.security.krb5.conf` system property. +The typical HTTP authentication flow works as follows: -.`krb5.conf` -[krb5,conf] ----- -[libdefaults] - default_realm = EXAMPLE.COM -[realms] - EXAMPLE.COM = { - kdc = kdc.example.com - } -[domain_realms] - .example.com = EXAMPLE.COM - example.com = EXAMPLE.COM ----- +. 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. -===== Configuration Example -[jvm option] ----- --Djava.security.auth.login.config=/path/to/login.conf --Djava.security.krb5.conf=/path/to/krb5.conf ----- +=== Token-Based Authentication Example -==== GSSCredential-based Authenticator -For scenarios where you already have a `GSSCredential` available or want to avoid JAAS configuration, you can use `GssCredentialAuthenticator`: +The following example demonstrates how to implement Bearer token authentication: -{examples-link}/spnego/gsscredential/Application.java +{examples-link}/authentication/token/Application.java +[%unbreakable] ---- -include::{examples-dir}/spnego/gsscredential/Application.java[lines=18..46] +include::{examples-dir}/authentication/token/Application.java[lines=18..52] ---- -<1> Obtain the `GSSCredential` through other means. -<2> Configure the GSSCredential-based authenticator for SPNEGO authentication. +<1> The predicate checks if the response status is `401 Unauthorized`. +<2> The authenticator adds the `Authorization` header with a Bearer token. -This approach is useful when: -- You want to reuse existing credentials -- You need more control over credential management -- JAAS configuration is not available or preferred +=== SPNEGO/Negotiate Authentication Example -==== Custom Authenticator Implementation -For advanced scenarios where the provided authenticators don't meet your specific requirements, you can implement the `SpnegoAuthenticator` interface directly: +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..69] ---- -import org.ietf.jgss.GSSContext; -import reactor.netty.http.client.SpnegoAuthenticator; -import reactor.netty.http.client.SpnegoAuthProvider; +<1> The 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. -public class CustomSpnegoAuthenticator implements SpnegoAuthenticator { +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. - @Override - public GSSContext createContext(String serviceName, String remoteHost) throws Exception { - // Your custom authentication logic here - // This method should return a configured GSSContext - // for the specified service and remote host - // serviceName: e.g., "HTTP", "LDAP" - // remoteHost: target server hostname +=== Custom Authentication Scenarios - return null; // Replace with actual GSSContext creation logic - } -} +The `httpAuthentication` method is flexible enough to support various authentication scenarios: -// Usage with advanced configuration +==== OAuth 2.0 Authentication +[source,java] +---- HttpClient client = HttpClient.create() - .spnego( - SpnegoAuthProvider.builder(new CustomSpnegoAuthenticator()) - .serviceName("HTTP") // Custom service name - .unauthorizedStatusCode(401) // Custom status code - .resolveCanonicalHostname(true) // Use canonical hostname - .build() - ); + .httpAuthentication( + (req, res) -> res.status().code() == 401, + (req, addr) -> { + return fetchOAuthToken() // <1> + .doOnNext(token -> + req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token)) + .then(); + } + ); +---- +<1> Asynchronously fetch an OAuth token and add it to the request. + +==== Basic Authentication +{examples-link}/authentication/basic/Application.java +[%unbreakable] ---- +include::{examples-dir}/authentication/basic/Application.java[lines=18..46] +---- +<1> The predicate checks if the response status is `401 Unauthorized`. +<2> The authenticator adds Basic authentication credentials to the `Authorization` header. -This approach is useful when you need: -- Custom credential acquisition logic -- Integration with third-party authentication systems -- Special handling for token caching or refresh -- Environment-specific authentication flows - -[[spnegoauthprovider-config]] -==== SpnegoAuthProvider Configuration Options -The `SpnegoAuthProvider.Builder` supports the following configuration Options: - -[width="100%",options="header"] -|======= -| Method | Default | Description | Example -| `serviceName(String)` | "HTTP" | Service name for constructing service principal names (serviceName/hostname) | "HTTP", "LDAP" -| `unauthorizedStatusCode(int)` | 401 | HTTP status code that triggers authentication retry | 401, 407 -| `resolveCanonicalHostname(boolean)` | false | Whether to use canonical hostname resolution via reverse DNS lookup | true for FQDN requirements -|======= - -==== Notes -- SPNEGO authentication is fully supported on Java 1.6 and above. -- If authentication fails, check the server logs and client exception messages, and verify your Kerberos environment settings (realm, KDC, ticket, etc.). -- `JaasAuthenticator` performs authentication through JAAS login configuration. -- `GssCredentialAuthenticator` uses pre-existing `GSSCredential` objects, bypassing JAAS configuration. -- For custom scenarios, implement the `SpnegoAuthenticator` interface to provide your own authentication logic. +==== Proxy Authentication +[source,java] +---- +HttpClient client = HttpClient.create() + .httpAuthentication( + (req, res) -> res.status().code() == 407, // <1> + (req, addr) -> { + String proxyCredentials = generateProxyCredentials(); + req.header("Proxy-Authorization", "Bearer " + proxyCredentials); + return Mono.empty(); + } + ); +---- +<1> Check for `407 Proxy Authentication Required` status code. + +=== Important Notes + +* The authenticator function is invoked only when the predicate returns `true`. +* The authenticator receives the request and remote address, allowing you to customize authentication based on the target server. +* The authenticator returns a `Mono` which allows for asynchronous credential retrieval. +* Authentication is retried only once per request. If authentication fails after retry, 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..f8f1bd801 --- /dev/null +++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/basic/Application.java @@ -0,0 +1,46 @@ +/* + * 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.core.publisher.Mono; +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); + return Mono.empty(); + } + ); + + 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..f8ab67cba --- /dev/null +++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/spnego/Application.java @@ -0,0 +1,69 @@ +/* + * 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() + .httpAuthentication( + (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(); + } + ); + + 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..952c3374c --- /dev/null +++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/authentication/token/Application.java @@ -0,0 +1,52 @@ +/* + * 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.core.publisher.Mono; +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); + return Mono.empty(); + } + ); + + 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-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/gsscredential/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/gsscredential/Application.java deleted file mode 100644 index e67ab5af0..000000000 --- a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/gsscredential/Application.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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.spnego.gsscredential; - -import org.ietf.jgss.GSSCredential; -import reactor.netty.http.client.GssCredentialAuthenticator; -import reactor.netty.http.client.HttpClient; -import reactor.netty.http.client.SpnegoAuthProvider; - -public class Application { - - public static void main(String[] args) { - // Assuming you have obtained a GSSCredential from elsewhere - GSSCredential credential = obtainGSSCredential(); // <1> - - HttpClient client = HttpClient.create() - .spnego( - SpnegoAuthProvider.builder(new GssCredentialAuthenticator(credential)) // <2> - .build() - ); - - client.get() - .uri("http://protected.example.com/") - .responseSingle((res, content) -> content.asString()) - .block(); - } - - private static GSSCredential obtainGSSCredential() { - // Implement your logic to obtain a GSSCredential - // This could involve using a Kerberos library or other means - return null; - } -} diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/jaas/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/jaas/Application.java deleted file mode 100644 index d011faa1d..000000000 --- a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/jaas/Application.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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.spnego.jaas; - -import reactor.netty.http.client.HttpClient; -import reactor.netty.http.client.JaasAuthenticator; -import reactor.netty.http.client.SpnegoAuthProvider; -import reactor.netty.http.client.SpnegoAuthenticator; - -public class Application { - - public static void main(String[] args) { - System.setProperty("java.security.auth.login.config", "/path/to/jaas.conf"); // <1> - System.setProperty("java.security.krb5.conf", "/path/to/krb5.conf"); // <2> - System.setProperty("sun.security.krb5.debug", "true"); // <3> - - SpnegoAuthenticator authenticator = new JaasAuthenticator("KerberosLogin"); // <4> - SpnegoAuthProvider provider = SpnegoAuthProvider.builder(authenticator) - .serviceName("HTTP") - .unauthorizedStatusCode(401) - .resolveCanonicalHostname(false) - .build(); - HttpClient client = HttpClient.create() - .spnego( - provider // <5> - ); // <6> - - client.get() - .uri("http://protected.example.com/") - .responseSingle((res, content) -> content.asString()) - .block(); - } -} diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/GssCredentialAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/GssCredentialAuthenticator.java deleted file mode 100644 index 721ad6b5f..000000000 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/GssCredentialAuthenticator.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * 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; - -import java.util.Objects; -import org.ietf.jgss.GSSContext; -import org.ietf.jgss.GSSCredential; -import org.ietf.jgss.GSSManager; -import org.ietf.jgss.GSSName; -import org.ietf.jgss.Oid; - -/** - * A GSSCredential-based Authenticator implementation for use with SPNEGO providers. - *

- * This authenticator uses a pre-existing GSSCredential to create a GSSContext, - * bypassing the need for JAAS login configuration. This is useful when you already - * have obtained Kerberos credentials through other means or want more direct control - * over the authentication process. - *

- *

- * The GSSCredential should contain valid Kerberos credentials that can be used - * for SPNEGO authentication. The credential's lifetime and validity are managed - * externally to this authenticator. - *

- * - *

Example usage:

- *
- *     GSSCredential credential = // ... obtain credential
- *     GssCredentialAuthenticator authenticator = new GssCredentialAuthenticator(credential);
- *     SpnegoAuthProvider provider = SpnegoAuthProvider.builder(authenticator).build();
- * 
- * - * @author raccoonback - * @since 1.3.0 - */ -public class GssCredentialAuthenticator implements SpnegoAuthenticator { - - private final GSSCredential credential; - - /** - * Creates a new GssCredentialAuthenticator with the given GSSCredential. - * - * @param credential the GSSCredential to use for authentication - */ - public GssCredentialAuthenticator(GSSCredential credential) { - Objects.requireNonNull(credential, "GSSCredential cannot be null"); - this.credential = credential; - } - - /** - * Creates a GSSContext for the specified service and remote host using the provided GSSCredential. - *

- * This method uses the pre-existing GSSCredential to create a GSSContext for SPNEGO - * authentication. The service principal name is constructed as serviceName/remoteHost. - *

- * - * @param serviceName the service name (e.g., "HTTP", "FTP") - * @param remoteHost the remote host to authenticate with - * @return the created GSSContext configured for SPNEGO authentication - * @throws Exception if context creation fails - */ - @Override - public GSSContext createContext(String serviceName, String remoteHost) throws Exception { - GSSManager manager = GSSManager.getInstance(); - GSSName serverName = manager.createName(serviceName + "/" + remoteHost, GSSName.NT_HOSTBASED_SERVICE); - GSSContext context = manager.createContext( - serverName, - new Oid("1.3.6.1.5.5.2"), - credential, - GSSContext.DEFAULT_LIFETIME - ); - context.requestMutualAuth(true); - return context; - } -} \ No newline at end of file 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 94984ecc3..fc9ed0ca6 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 @@ -1697,16 +1697,46 @@ public final HttpClient wiretap(boolean enable) { } /** - * Configure SPNEGO authentication for the HTTP client. + * Configure HTTP authentication for the client with custom authentication logic. + *

+ * This method provides a generic authentication framework that allows users to implement + * their own authentication mechanisms (e.g., Negotiate/SPNEGO, OAuth, Bearer tokens, custom schemes). + * The framework handles when to apply authentication, while users control how to generate + * and attach authentication credentials. + *

+ * + *

Example - Token-based Authentication:

+ *
+	 * {@code
+	 * HttpClient client = HttpClient.create()
+	 *     .httpAuthentication(
+	 *         // Retry on 401 Unauthorized responses
+	 *         (req, res) -> res.status().code() == 401,
+	 *         // Add authentication header before request
+	 *         (req, addr) -> {
+	 *             String token = generateAuthToken(addr);
+	 *             req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);
+	 *             return Mono.empty();
+	 *         }
+	 *     );
+	 * }
+	 * 
* - * @param spnegoAuthProvider the SPNEGO authentication provider + * @param predicate determines when authentication should be applied, receives the request + * and response to decide if authentication is needed (e.g., check for 401 status) + * @param authenticator applies authentication to the request, receives the request and remote address, + * returns a Mono that completes when authentication is applied * @return a new {@link HttpClient} * @since 1.3.0 */ - public final HttpClient spnego(SpnegoAuthProvider spnegoAuthProvider) { - Objects.requireNonNull(spnegoAuthProvider, "spnegoAuthProvider"); + public final HttpClient httpAuthentication( + BiPredicate predicate, + BiFunction> authenticator) { + Objects.requireNonNull(predicate, "predicate"); + Objects.requireNonNull(authenticator, "authenticator"); HttpClient dup = duplicate(); - dup.configuration().spnegoAuthProvider = spnegoAuthProvider; + dup.configuration().authenticationPredicate = predicate; + dup.configuration().authenticator = authenticator; return dup; } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoRetryException.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientAuthenticationException.java similarity index 55% rename from reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoRetryException.java rename to reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientAuthenticationException.java index 48182abff..7d79717c1 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoRetryException.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientAuthenticationException.java @@ -16,14 +16,21 @@ package reactor.netty.http.client; /** - * Exception thrown to trigger a retry when SPNEGO authentication fails with a 401 Unauthorized response. + * Exception thrown to trigger HTTP authentication retry. + *

+ * This exception is used internally by the generic HTTP authentication framework + * to signal that the current request requires authentication and should be retried. + * The framework will invoke the configured authenticator before retrying the request. + *

* - * @author raccoonback + * @author Oliver Ko * @since 1.3.0 */ -final class SpnegoRetryException extends RuntimeException { +final class HttpClientAuthenticationException extends RuntimeException { - SpnegoRetryException() { - super("SPNEGO authentication requires retry"); + private static final long serialVersionUID = 1L; + + 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 b8e219b72..f78d5a54d 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 @@ -367,7 +367,8 @@ public HttpProtocol[] protocols() { @Nullable String uriStr; @Nullable Function uriTagValue; @Nullable WebsocketClientSpec websocketClientSpec; - @Nullable SpnegoAuthProvider spnegoAuthProvider; + @Nullable BiPredicate authenticationPredicate; + @Nullable BiFunction> authenticator; HttpClientConfig(HttpConnectionProvider connectionProvider, Map, ?> options, Supplier remoteAddress) { @@ -420,7 +421,8 @@ public HttpProtocol[] protocols() { this.uriStr = parent.uriStr; this.uriTagValue = parent.uriTagValue; this.websocketClientSpec = parent.websocketClientSpec; - this.spnegoAuthProvider = parent.spnegoAuthProvider; + this.authenticationPredicate = parent.authenticationPredicate; + this.authenticator = parent.authenticator; } @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 95df365d2..78cbc0199 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 @@ -458,13 +458,14 @@ public Context currentContext() { public void onStateChange(Connection connection, State newState) { if (newState == HttpClientState.RESPONSE_RECEIVED) { HttpClientOperations operations = connection.as(HttpClientOperations.class); - if (operations != null && handler.spnegoAuthProvider != null) { - if (shouldRetryWithSpnego(operations)) { - retryWithSpnego(operations); + if (operations != null && handler.authenticationPredicate != null) { + if (handler.authenticationPredicate.test(operations, operations)) { + if (log.isDebugEnabled()) { + log.debug(format(operations.channel(), "Authentication predicate matched, triggering retry")); + } + sink.error(new HttpClientAuthenticationException()); return; } - - handler.spnegoAuthProvider.resetRetryCount(); } sink.success(connection); @@ -480,42 +481,6 @@ public void onStateChange(Connection connection, State newState) { .subscribe(connection.disposeSubscriber()); } } - - /** - * Determines if the current HTTP response requires a SPNEGO authentication retry. - * - * @param operations the HTTP client operations containing the response status and headers - * @return {@code true} if SPNEGO re-authentication should be attempted, {@code false} otherwise - */ - private boolean shouldRetryWithSpnego(HttpClientOperations operations) { - int statusCode = operations.status().code(); - HttpHeaders headers = operations.responseHeaders(); - - return handler.spnegoAuthProvider.isUnauthorized(statusCode, headers) - && handler.spnegoAuthProvider.canRetry(); - } - - /** - * Triggers a SPNEGO authentication retry by throwing a {@link SpnegoRetryException}. - *

- * The exception-based approach ensures that a completely new {@link HttpClientOperations} - * instance is created, avoiding the "Status and headers already sent" error that would - * occur if trying to reuse the existing connection. - *

- * - * @param operations the current HTTP client operations that received the 401 response - * @throws SpnegoRetryException always thrown to trigger the retry mechanism - */ - private void retryWithSpnego(HttpClientOperations operations) { - handler.spnegoAuthProvider.invalidateTokenHeader(); - handler.spnegoAuthProvider.incrementRetryCount(); - - if (log.isDebugEnabled()) { - log.debug(format(operations.channel(), "Triggering SPNEGO re-authentication")); - } - - sink.error(new SpnegoRetryException()); - } } static final class HttpClientHandler extends SocketAddress @@ -545,8 +510,10 @@ static final class HttpClientHandler extends SocketAddress volatile Supplier @Nullable [] redirectedFrom; volatile boolean shouldRetry; volatile @Nullable HttpHeaders previousRequestHeaders; + volatile boolean authenticationAttempted; - SpnegoAuthProvider spnegoAuthProvider; + @Nullable BiPredicate authenticationPredicate; + @Nullable BiFunction> authenticator; HttpClientHandler(HttpClientConfig configuration) { this.method = configuration.method; @@ -585,7 +552,8 @@ static final class HttpClientHandler extends SocketAddress this.fromURI = this.toURI = uriEndpointFactory.createUriEndpoint(configuration.uri, configuration.websocketClientSpec != null); } this.resourceUrl = toURI.toExternalForm(); - this.spnegoAuthProvider = configuration.spnegoAuthProvider; + this.authenticationPredicate = configuration.authenticationPredicate; + this.authenticator = configuration.authenticator; } @Override @@ -600,8 +568,8 @@ public SocketAddress get() { @SuppressWarnings("ReferenceEquality") Publisher requestWithBody(HttpClientOperations ch) { - if (spnegoAuthProvider != null) { - return spnegoAuthProvider.apply(ch, ch.address()) + if (authenticator != null && authenticationAttempted) { + return authenticator.apply(ch, ch.address()) .then( Mono.defer( () -> Mono.from(requestWithBodyInternal(ch)) @@ -791,7 +759,9 @@ public boolean test(Throwable throwable) { redirect(re.location); return true; } - if (throwable instanceof SpnegoRetryException) { + if (throwable instanceof HttpClientAuthenticationException) { + // Set flag to trigger authenticator on retry + authenticationAttempted = true; return true; } if (shouldRetry && AbortedException.isConnectionReset(throwable)) { diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java deleted file mode 100644 index c5f23e2fb..000000000 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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; - -import java.security.PrivilegedExceptionAction; -import javax.security.auth.Subject; -import javax.security.auth.login.LoginContext; -import org.ietf.jgss.GSSContext; -import org.ietf.jgss.GSSManager; -import org.ietf.jgss.GSSName; -import org.ietf.jgss.Oid; - -/** - * A JAAS-based Authenticator implementation for use with SPNEGO providers. - *

- * This authenticator performs a JAAS login using the specified context name and creates a GSSContext - * for SPNEGO authentication. It relies on JAAS configuration to obtain Kerberos credentials. - *

- *

- * The JAAS configuration should define a login context that acquires Kerberos credentials, - * typically using the Krb5LoginModule. The login context name provided to this authenticator - * must match the entry name in the JAAS configuration file. - *

- * - *

Example usage:

- *
- *     JaasAuthenticator authenticator = new JaasAuthenticator("KerberosLogin");
- *     SpnegoAuthProvider provider = SpnegoAuthProvider.builder(authenticator).build();
- * 
- * - * @author raccoonback - * @since 1.3.0 - */ -public class JaasAuthenticator implements SpnegoAuthenticator { - - private final String loginContext; - - /** - * Creates a new JaasAuthenticator with the given context name. - * - * @param loginContext the JAAS login context name - */ - public JaasAuthenticator(String loginContext) { - this.loginContext = loginContext; - } - - /** - * Creates a GSSContext for the specified service and remote host using JAAS authentication. - *

- * This method performs a JAAS login, obtains the authenticated Subject, and creates - * a GSSContext within the Subject's security context. The service principal name - * is constructed as serviceName/remoteHost. - *

- * - * @param serviceName the service name (e.g., "HTTP", "CIFS") - * @param remoteHost the remote host to authenticate with - * @return the created GSSContext configured for SPNEGO authentication - * @throws Exception if JAAS login or context creation fails - */ - @Override - public GSSContext createContext(String serviceName, String remoteHost) throws Exception { - LoginContext lc = new LoginContext(loginContext); - lc.login(); - Subject subject = lc.getSubject(); - - return Subject.doAs(subject, (PrivilegedExceptionAction) () -> { - GSSManager manager = GSSManager.getInstance(); - GSSName serverName = manager.createName(serviceName + "/" + remoteHost, GSSName.NT_HOSTBASED_SERVICE); - GSSContext context = manager.createContext( - serverName, - new Oid("1.3.6.1.5.5.2"), // SPNEGO - null, - GSSContext.DEFAULT_LIFETIME - ); - context.requestMutualAuth(true); - return context; - }); - } -} diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java deleted file mode 100644 index c5c169b50..000000000 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java +++ /dev/null @@ -1,315 +0,0 @@ -/* - * 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; - -import static reactor.core.scheduler.Schedulers.boundedElastic; - -import io.netty.handler.codec.http.HttpHeaderNames; -import io.netty.handler.codec.http.HttpHeaders; -import java.net.InetSocketAddress; -import java.util.Arrays; -import java.util.Base64; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import org.ietf.jgss.GSSContext; -import org.ietf.jgss.GSSException; -import reactor.core.publisher.Mono; -import reactor.util.Logger; -import reactor.util.Loggers; - -/** - * Provides SPNEGO authentication for Reactor Netty HttpClient. - *

- * This provider is responsible for generating and attaching a SPNEGO (Kerberos) token - * to the HTTP Authorization header for outgoing requests, enabling single sign-on and - * secure authentication in enterprise environments. - *

- *

- * The provider supports authentication caching and retry mechanisms to handle token - * expiration and authentication failures gracefully. It can be configured with different - * service names, unauthorized status codes, and hostname resolution strategies. - *

- * - *

Basic usage with JAAS:

- *
- *     SpnegoAuthProvider provider = SpnegoAuthProvider
- *         .builder(new JaasAuthenticator("KerberosLogin"))
- *         .build();
- *
- *     HttpClient client = HttpClient.create()
- *         .spnego(provider);
- * 
- * - *

Advanced configuration:

- *
- *     SpnegoAuthProvider provider = SpnegoAuthProvider
- *         .builder(new GssCredentialAuthenticator(credential))
- *         .serviceName("CIFS")
- *         .unauthorizedStatusCode(401)
- *         .resolveCanonicalHostname(true)
- *         .build();
- * 
- * - * @author raccoonback - * @since 1.3.0 - */ -public final class SpnegoAuthProvider { - - private static final Logger log = Loggers.getLogger(SpnegoAuthProvider.class); - private static final String SPNEGO_HEADER = "Negotiate"; - - private final SpnegoAuthenticator authenticator; - private final int unauthorizedStatusCode; - private final String serviceName; - private final boolean resolveCanonicalHostname; - - private final AtomicReference verifiedAuthHeader = new AtomicReference<>(); - private final AtomicInteger retryCount = new AtomicInteger(0); - private static final int MAX_RETRY_COUNT = 1; - - /** - * Constructs a new SpnegoAuthProvider with the specified configuration. - *

- * This constructor is private and should only be used by the {@link Builder}. - * Use {@link #builder(SpnegoAuthenticator)} to create instances. - *

- * - * @param authenticator the authenticator to use for SPNEGO authentication (must not be null) - * @param unauthorizedStatusCode the HTTP status code that indicates authentication failure - * @param serviceName the service name for constructing service principal names - * @param resolveCanonicalHostname whether to resolve canonical hostnames for service principals - */ - private SpnegoAuthProvider(SpnegoAuthenticator authenticator, int unauthorizedStatusCode, String serviceName, boolean resolveCanonicalHostname) { - this.authenticator = authenticator; - this.unauthorizedStatusCode = unauthorizedStatusCode; - this.serviceName = serviceName; - this.resolveCanonicalHostname = resolveCanonicalHostname; - } - - /** - * Creates a new builder for configuring SPNEGO authentication provider. - * - * @param authenticator the authenticator to use for SPNEGO authentication - * @return a new builder instance - */ - public static Builder builder(SpnegoAuthenticator authenticator) { - return new Builder(authenticator); - } - - /** - * Builder for creating SpnegoAuthProvider instances. - */ - public static final class Builder { - private final SpnegoAuthenticator authenticator; - private int unauthorizedStatusCode = 401; - private String serviceName = "HTTP"; - private boolean resolveCanonicalHostname; - - private Builder(SpnegoAuthenticator authenticator) { - this.authenticator = authenticator; - } - - /** - * Sets the HTTP status code that indicates authentication failure. - * - * @param statusCode the status code (default: 401) - * @return this builder - */ - public Builder unauthorizedStatusCode(int statusCode) { - this.unauthorizedStatusCode = statusCode; - return this; - } - - /** - * Sets the service name for the service principal. - * - * @param serviceName the service name (default: "HTTP") - * @return this builder - */ - public Builder serviceName(String serviceName) { - this.serviceName = serviceName; - return this; - } - - /** - * Sets whether to resolve canonical hostname. - * - * @param resolveCanonicalHostname true to resolve canonical hostname (default: false) - * @return this builder - */ - public Builder resolveCanonicalHostname(boolean resolveCanonicalHostname) { - this.resolveCanonicalHostname = resolveCanonicalHostname; - return this; - } - - /** - * Builds the SpnegoAuthProvider instance. - * - * @return a new SpnegoAuthProvider - */ - public SpnegoAuthProvider build() { - return new SpnegoAuthProvider(authenticator, unauthorizedStatusCode, serviceName, resolveCanonicalHostname); - } - } - - /** - * Applies SPNEGO authentication to the given HTTP client request. - *

- * This method generates a SPNEGO token for the specified address and attaches it - * as an Authorization header to the outgoing HTTP request. - *

- * - * @param request the HTTP client request to authenticate - * @param address the target server address (used for service principal) - * @return a Mono that completes when the authentication is applied - * @throws SpnegoAuthenticationException if login or token generation fails - */ - public Mono apply(HttpClientRequest request, InetSocketAddress address) { - String cachedToken = verifiedAuthHeader.get(); - if (cachedToken != null) { - request.header(HttpHeaderNames.AUTHORIZATION, cachedToken); - return Mono.empty(); - } - - return Mono.fromCallable(() -> { - try { - String hostName = resolveHostName(address); - byte[] token = generateSpnegoToken(hostName); - String authHeader = SPNEGO_HEADER + " " + Base64.getEncoder().encodeToString(token); - - verifiedAuthHeader.set(authHeader); - request.header(HttpHeaderNames.AUTHORIZATION, authHeader); - return token; - } - catch (Exception e) { - throw new SpnegoAuthenticationException("Failed to generate SPNEGO token", e); - } - }) - .subscribeOn(boundedElastic()) - .then(); - } - - /** - * Resolves the hostname from the given socket address. - *

- * This method returns either the hostname or canonical hostname based on the - * {@code resolveCanonicalHostname} configuration. When canonical hostname resolution - * is enabled, it performs a reverse DNS lookup to get the fully qualified domain name. - *

- * - * @param address the socket address to resolve hostname from - * @return the resolved hostname (canonical if configured, otherwise standard hostname) - */ - private String resolveHostName(InetSocketAddress address) { - String hostName = address.getHostName(); - if (resolveCanonicalHostname) { - hostName = address.getAddress().getCanonicalHostName(); - } - return hostName; - } - - /** - * Generates a SPNEGO token for the given host name. - *

- * This method uses the authenticator to create a GSSContext and generate a SPNEGO token - * for the specified service principal (HTTP/hostName). - *

- * - * @param hostName the target server host name - * @return the raw SPNEGO token bytes - * @throws Exception if token generation fails - */ - private byte[] generateSpnegoToken(String hostName) throws Exception { - if (hostName == null || hostName.trim().isEmpty()) { - throw new IllegalArgumentException("Host name cannot be null or empty"); - } - - GSSContext context = null; - try { - context = authenticator.createContext(serviceName, hostName.trim()); - return context.initSecContext(new byte[0], 0, 0); - } - finally { - if (context != null) { - try { - context.dispose(); - } - catch (GSSException e) { - // Log but don't propagate disposal errors - if (log.isDebugEnabled()) { - log.debug("Failed to dispose GSSContext", e); - } - } - } - } - } - - /** - * Invalidates the cached authentication token. - */ - public void invalidateTokenHeader() { - this.verifiedAuthHeader.set(null); - } - - /** - * Checks if SPNEGO authentication retry is allowed. - * - * @return true if retry is allowed, false otherwise - */ - public boolean canRetry() { - return retryCount.get() < MAX_RETRY_COUNT; - } - - /** - * Increments the retry count for SPNEGO authentication attempts. - */ - public void incrementRetryCount() { - retryCount.incrementAndGet(); - } - - /** - * Resets the retry count for SPNEGO authentication. - */ - public void resetRetryCount() { - retryCount.set(0); - } - - /** - * Checks if the response indicates an authentication failure that requires a new token. - *

- * This method checks both the status code and the WWW-Authenticate header to determine - * if a new SPNEGO token needs to be generated. - *

- * - * @param status the HTTP status code - * @param headers the HTTP response headers - * @return true if the response indicates an authentication failure - */ - public boolean isUnauthorized(int status, HttpHeaders headers) { - if (status != unauthorizedStatusCode) { - return false; - } - - String header = headers.get(HttpHeaderNames.WWW_AUTHENTICATE); - if (header == null) { - return false; - } - - return Arrays.stream(header.split(",")) - .map(String::trim) - .anyMatch(auth -> auth.toLowerCase().startsWith(SPNEGO_HEADER.toLowerCase())); - } -} diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java deleted file mode 100644 index 27e492334..000000000 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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; - -/** - * Exception thrown when SPNEGO authentication fails. - * - * @author raccoonback - * @since 1.3.0 - */ -public class SpnegoAuthenticationException extends RuntimeException { - - public SpnegoAuthenticationException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java deleted file mode 100644 index 392470359..000000000 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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; - -import org.ietf.jgss.GSSContext; - -/** - * An abstraction for authentication logic used by SPNEGO providers. - *

- * Implementations are responsible for creating a GSSContext for the specified remote host. - *

- * - * @author raccoonback - * @since 1.3.0 - */ -public interface SpnegoAuthenticator { - - /** - * Creates a GSSContext for the specified remote host. - * - * @param serviceName the service name (e.g., "HTTP", "FTP") - * @param remoteHost the remote host to authenticate with - * @return the created GSSContext - * @throws Exception if context creation fails - */ - GSSContext createContext(String serviceName, String remoteHost) throws Exception; -} 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..8629deb99 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,232 @@ 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) -> { + requestCount.incrementAndGet(); + String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); + + // First request should already 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()) + .doOnRequest((req, conn) -> { + authHeaderAdded.set(true); + req.requestHeaders().set(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(1); + 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()) + .httpAuthentication( + // Only retry on 401, not 403 + (req, res) -> res.status() == 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()) + .httpAuthentication( + (req, res) -> res.status() == 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()) + .httpAuthentication( + (req, res) -> res.status() == 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()) + .httpAuthentication( + // Retry on 407 instead of 401 + (req, res) -> res.status() == 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); + } + private static final class EchoAction implements Publisher, Consumer { private final Publisher sender; private volatile FluxSink emitter; diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java deleted file mode 100644 index d5453dcdd..000000000 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java +++ /dev/null @@ -1,306 +0,0 @@ -/* - * 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; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import io.netty.handler.codec.http.HttpHeaderNames; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.atomic.AtomicInteger; -import org.ietf.jgss.GSSContext; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; -import reactor.netty.DisposableServer; -import reactor.netty.http.server.HttpServer; -import reactor.test.StepVerifier; - -class SpnegoAuthProviderTest { - - private static final int TEST_PORT = 8080; - - @Test - void negotiateSpnegoAuthenticationWithHttpClient() throws Exception { - DisposableServer server = HttpServer.create() - .port(TEST_PORT) - .route(routes -> routes - .get("/", (request, response) -> { - String authHeader = request.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); - if (authHeader != null && authHeader.startsWith("Negotiate ")) { - return response.status(200).sendString(Mono.just("Authenticated")); - } - return response.status(401).sendString(Mono.just("Unauthorized")); - })) - .bindNow(); - - try { - GSSContext gssContext = mock(GSSContext.class); - SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class); - - given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) - .willReturn("spnego-negotiate-token".getBytes(StandardCharsets.UTF_8)); - given(authenticator.createContext(anyString(), anyString())) - .willReturn(gssContext); - - HttpClient client = HttpClient.create() - .port(TEST_PORT) - .spnego( - SpnegoAuthProvider.builder(authenticator) - .build() - ) - .wiretap(true) - .disableRetry(true); - - StepVerifier.create( - client.get() - .uri("/") - .responseContent() - .aggregate() - .asString() - ) - .expectNext("Authenticated") - .verifyComplete(); - } - finally { - server.disposeNow(); - } - } - - @Test - void automaticReauthenticateOn401Response() throws Exception { - AtomicInteger requestCount = new AtomicInteger(0); - - DisposableServer server = HttpServer.create() - .port(0) - .route(routes -> routes - .get("/reauth", (request, response) -> { - String authHeader = request.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); - int count = requestCount.incrementAndGet(); - - if (count == 1) { - return response.status(401) - .header("WWW-Authenticate", "Negotiate") - .sendString(Mono.just("Unauthorized")); - } - else if (authHeader != null && authHeader.startsWith("Negotiate ")) { - return response.status(200).sendString(Mono.just("Reauthenticated")); - } - return response.status(401).sendString(Mono.just("Failed")); - })) - .bindNow(); - - try { - GSSContext gssContext = mock(GSSContext.class); - SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class); - - given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) - .willReturn("spnego-reauth-token".getBytes(StandardCharsets.UTF_8)); - given(authenticator.createContext(anyString(), anyString())) - .willReturn(gssContext); - - HttpClient client = HttpClient.create() - .port(server.port()) - .spnego( - SpnegoAuthProvider.builder(authenticator) - .build() - ) - .wiretap(true) - .disableRetry(true); - - StepVerifier.create( - client.get() - .uri("/reauth") - .responseContent() - .aggregate() - .asString() - ) - .expectNext("Reauthenticated") - .verifyComplete(); - - verify(gssContext, times(2)).initSecContext(any(byte[].class), anyInt(), anyInt()); - } - finally { - server.disposeNow(); - } - } - - @Test - void doesNotReauthenticateWhenMaxRetryReached() throws Exception { - AtomicInteger requestCount = new AtomicInteger(0); - - DisposableServer server = HttpServer.create() - .port(0) - .route(routes -> routes - .get("/fail", (request, response) -> { - requestCount.incrementAndGet(); - return response.status(401) - .header("WWW-Authenticate", "Negotiate") - .sendString(Mono.just("Always Unauthorized")); - })) - .bindNow(); - - try { - GSSContext gssContext = mock(GSSContext.class); - SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class); - - given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) - .willReturn("spnego-fail-token".getBytes(StandardCharsets.UTF_8)); - given(authenticator.createContext(anyString(), anyString())) - .willReturn(gssContext); - - HttpClient client = HttpClient.create() - .port(server.port()) - .spnego( - SpnegoAuthProvider.builder(authenticator) - .build() - ) - .wiretap(true) - .disableRetry(true); - - StepVerifier.create( - client.get() - .uri("/fail") - .response() - .map(response -> response.status().code()) - ) - .expectNext(401) - .verifyComplete(); - - verify(gssContext, times(2)).initSecContext(any(byte[].class), anyInt(), anyInt()); - } - finally { - server.disposeNow(); - } - } - - @Test - void doesNotReauthenticateWithoutWwwAuthenticateHeader() throws Exception { - DisposableServer server = HttpServer.create() - .port(0) - .route(routes -> routes - .get("/noheader", (request, response) -> - response.status(401).sendString(Mono.just("No WWW-Authenticate header")))) - .bindNow(); - - try { - GSSContext gssContext = mock(GSSContext.class); - SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class); - - given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) - .willReturn("spnego-token".getBytes(StandardCharsets.UTF_8)); - given(authenticator.createContext(anyString(), anyString())) - .willReturn(gssContext); - - HttpClient client = HttpClient.create() - .port(server.port()) - .spnego( - SpnegoAuthProvider.builder(authenticator) - .build() - ) - .wiretap(true) - .disableRetry(true); - - StepVerifier.create( - client.get() - .uri("/noheader") - .response() - .map(response -> response.status().code()) - ) - .expectNext(401) - .verifyComplete(); - - verify(gssContext, times(1)).initSecContext(any(byte[].class), anyInt(), anyInt()); - } - finally { - server.disposeNow(); - } - } - - @Test - void successfulAuthenticationResetsRetryCount() throws Exception { - AtomicInteger requestCount = new AtomicInteger(0); - - DisposableServer server = HttpServer.create() - .port(0) - .route(routes -> routes - .get("/reset", (request, response) -> { - String authHeader = request.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); - int count = requestCount.incrementAndGet(); - - if (count == 1) { - return response.status(401) - .header("WWW-Authenticate", "Negotiate") - .sendString(Mono.just("First 401")); - } - else if (authHeader != null && authHeader.startsWith("Negotiate ")) { - return response.status(200).sendString(Mono.just("Success")); - } - return response.status(401).sendString(Mono.just("Unexpected")); - })) - .bindNow(); - - try { - GSSContext gssContext = mock(GSSContext.class); - SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class); - - given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt())) - .willReturn("spnego-reset-token".getBytes(StandardCharsets.UTF_8)); - given(authenticator.createContext(anyString(), anyString())) - .willReturn(gssContext); - - SpnegoAuthProvider provider = SpnegoAuthProvider.builder(authenticator) - .build(); - - HttpClient client = HttpClient.create() - .port(server.port()) - .spnego(provider) - .wiretap(true) - .disableRetry(true); - - StepVerifier.create( - client.get() - .uri("/reset") - .responseContent() - .aggregate() - .asString() - ) - .expectNext("Success") - .verifyComplete(); - - requestCount.set(0); - - StepVerifier.create( - client.get() - .uri("/reset") - .responseContent() - .aggregate() - .asString() - ) - .expectNext("Success") - .verifyComplete(); - - verify(gssContext, times(3)).initSecContext(any(byte[].class), anyInt(), anyInt()); - } - finally { - server.disposeNow(); - } - } -} From ee921a0c5a0f1e8d3a15fbdb22b8b3e67f3d95a2 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Wed, 19 Nov 2025 23:20:46 +0900 Subject: [PATCH 08/29] Update omitting the stack trace Signed-off-by: raccoonback --- .../http/client/HttpClientAuthenticationException.java | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 7d79717c1..0fa7a19c7 100644 --- 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 @@ -30,6 +30,12 @@ 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"); } From e35a0288abf99939be30d5358796814d69918d1e Mon Sep 17 00:00:00 2001 From: raccoonback Date: Thu, 20 Nov 2025 13:01:37 +0900 Subject: [PATCH 09/29] Apply authenticator after HTTP request preparation Moved authenticator to occur after the HTTP request is prepared, ensuring configuration is completed before authentication logic runs. Signed-off-by: raccoonback --- .../netty/http/client/HttpClientConnect.java | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) 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 78cbc0199..5dabc5660 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 @@ -568,19 +568,6 @@ public SocketAddress get() { @SuppressWarnings("ReferenceEquality") Publisher requestWithBody(HttpClientOperations ch) { - if (authenticator != null && authenticationAttempted) { - return authenticator.apply(ch, ch.address()) - .then( - Mono.defer( - () -> Mono.from(requestWithBodyInternal(ch)) - ) - ); - } - - return requestWithBodyInternal(ch); - } - - private Publisher requestWithBodyInternal(HttpClientOperations ch) { try { ch.resourceUrl = this.resourceUrl; ch.responseTimeout = responseTimeout; @@ -613,6 +600,7 @@ private Publisher requestWithBodyInternal(HttpClientOperations ch) { } ch.followRedirectPredicate(followRedirectPredicate); + ch.authenticationPredicate(authenticationPredicate); if (!Objects.equals(method, HttpMethod.GET) && !Objects.equals(method, HttpMethod.HEAD) && @@ -622,6 +610,9 @@ private Publisher requestWithBodyInternal(HttpClientOperations ch) { } ch.listener().onStateChange(ch, HttpClientState.REQUEST_PREPARED); + + Publisher result; + if (websocketClientSpec != null) { // ReferenceEquality is deliberate if (ch.version == H2) { @@ -635,48 +626,57 @@ private Publisher requestWithBodyInternal(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 && authenticationAttempted) { + 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); From 0e0522291039b7eee4eee8f958b4670c59a9dd69 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Thu, 20 Nov 2025 13:09:46 +0900 Subject: [PATCH 10/29] Ensure response messages are drained before authentication retry Move authentication predicate evaluation from RESPONSE_RECEIVED state to response processing, ensuring all response messages are consumed before retrying with authentication. Signed-off-by: raccoonback --- .../netty/http/client/HttpClientConnect.java | 11 ------ .../http/client/HttpClientOperations.java | 39 ++++++++++++++++--- 2 files changed, 33 insertions(+), 17 deletions(-) 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 5dabc5660..b3ac81420 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 @@ -457,17 +457,6 @@ public Context currentContext() { @Override public void onStateChange(Connection connection, State newState) { if (newState == HttpClientState.RESPONSE_RECEIVED) { - HttpClientOperations operations = connection.as(HttpClientOperations.class); - if (operations != null && handler.authenticationPredicate != null) { - if (handler.authenticationPredicate.test(operations, operations)) { - if (log.isDebugEnabled()) { - log.debug(format(operations.channel(), "Authentication predicate matched, triggering retry")); - } - sink.error(new HttpClientAuthenticationException()); - return; - } - } - sink.success(connection); return; } 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..22c923647 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 @@ -128,8 +128,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,6 +144,7 @@ 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.redirectRequestConsumer = replaced.redirectRequestConsumer; this.previousRequestHeaders = replaced.previousRequestHeaders; @@ -150,6 +153,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,6 +175,7 @@ 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.redirectRequestConsumer = replaced.redirectRequestConsumer; this.previousRequestHeaders = replaced.previousRequestHeaders; @@ -179,6 +184,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 +345,10 @@ void followRedirectPredicate(@Nullable BiPredicate predicate) { + this.authenticationPredicate = predicate; + } + void redirectRequestConsumer(@Nullable Consumer redirectRequestConsumer) { this.redirectRequestConsumer = redirectRequestConsumer; } @@ -401,6 +411,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 +850,7 @@ protected void onInboundNext(ChannelHandlerContext ctx, Object msg) { httpMessageLogFactory().debug(HttpMessageArgProviderFactory.create(response))); } - if (notRedirected(response)) { + if (notRedirected(response) && notAuthenticated()) { try { listener().onStateChange(this, HttpClientState.RESPONSE_RECEIVED); } @@ -848,7 +861,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 +916,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 +924,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 +954,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 +990,20 @@ final boolean notRedirected(HttpResponse response) { return true; } + @SuppressWarnings("NullAway") + final boolean notAuthenticated() { + // Deliberately suppress "NullAway" + // authenticationPredicate is checked for null before calling this method + if (authenticationPredicate != null && authenticationPredicate.test(this, this)) { + authenticating = new HttpClientAuthenticationException(); + if (log.isDebugEnabled()) { + log.debug(format(channel(), "Authentication predicate matched, triggering retry")); + } + return false; + } + return true; + } + @Override protected HttpMessage newFullBodyMessage(ByteBuf body) { HttpRequest request = new DefaultFullHttpRequest(version(), method(), uri(), body); From 1865e7c50735107019e9f09b7b7d80ccb9e72058 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Thu, 20 Nov 2025 13:12:49 +0900 Subject: [PATCH 11/29] Extend authentication handling to WebSocket operations Signed-off-by: raccoonback --- .../client/Http2WebsocketClientOperations.java | 15 +++++++++++---- .../http/client/WebsocketClientOperations.java | 15 +++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) 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..237dccf75 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) && notAuthenticated()) { try { HttpResponseStatus status = response.status(); if (!HttpResponseStatus.OK.equals(status)) { @@ -131,9 +131,16 @@ else if (msg instanceof HttpResponse) { } } else { - // Deliberately suppress "NullAway" - // redirecting != null in this case - listener().onUncaughtException(this, redirecting); + if (redirecting != null) { + // Deliberately suppress "NullAway" + // redirecting != null in this case + listener().onUncaughtException(this, redirecting); + } + else if (authenticating != null) { + // Deliberately suppress "NullAway" + // authenticating != null in this case + listener().onUncaughtException(this, authenticating); + } } } else { 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..c58d6807a 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 @@ -144,7 +144,7 @@ public void onInboundNext(ChannelHandlerContext ctx, Object msg) { setNettyResponse(response); - if (notRedirected(response)) { + if (notRedirected(response) && notAuthenticated()) { try { handshakerHttp11.finishHandshake(channel(), response); // This change is needed after the Netty change https://github.com/netty/netty/pull/11966 @@ -165,9 +165,16 @@ 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) { + // Deliberately suppress "NullAway" + // redirecting is initialized in notRedirected(response) + listener().onUncaughtException(this, redirecting); + } + else if (authenticating != null) { + // Deliberately suppress "NullAway" + // authenticating is initialized in notAuthenticated() + listener().onUncaughtException(this, authenticating); + } } return; } From 138d7d25fe58356be544a93fc712d7befb764c9b Mon Sep 17 00:00:00 2001 From: raccoonback Date: Thu, 20 Nov 2025 23:51:40 +0900 Subject: [PATCH 12/29] Add httpAuthentication API and rename existing to httpAuthenticationWhen Introduce httpAuthentication(BiFunction) for automatic 401 retry, and rename the predicate-based method to httpAuthenticationWhen for clarity when custom retry conditions are needed. Signed-off-by: raccoonback --- .../reactor/netty/http/client/HttpClient.java | 46 ++++++++++++++++--- .../netty/http/client/HttpClientConfig.java | 3 ++ .../netty/http/client/HttpClientTest.java | 8 ++-- 3 files changed, 47 insertions(+), 10 deletions(-) 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 fc9ed0ca6..e8ba3300e 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 @@ -1697,12 +1697,11 @@ public final HttpClient wiretap(boolean enable) { } /** - * Configure HTTP authentication for the client with custom authentication logic. + * Configure HTTP authentication that retries on 401 Unauthorized responses. *

* This method provides a generic authentication framework that allows users to implement * their own authentication mechanisms (e.g., Negotiate/SPNEGO, OAuth, Bearer tokens, custom schemes). - * The framework handles when to apply authentication, while users control how to generate - * and attach authentication credentials. + * The framework automatically retries requests when a 401 Unauthorized response is received. *

* *

Example - Token-based Authentication:

@@ -1710,8 +1709,43 @@ public final HttpClient wiretap(boolean enable) { * {@code * HttpClient client = HttpClient.create() * .httpAuthentication( - * // Retry on 401 Unauthorized responses - * (req, res) -> res.status().code() == 401, + * // Add authentication header before request + * (req, addr) -> { + * String token = generateAuthToken(addr); + * req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token); + * return Mono.empty(); + * } + * ); + * } + * + * + * @param authenticator applies authentication to the request, receives the request and remote address, + * returns a Mono that completes when authentication is applied + * @return a new {@link HttpClient} + * @since 1.3.0 + * @see #httpAuthenticationWhen(BiPredicate, BiFunction) + */ + public final HttpClient httpAuthentication( + BiFunction> authenticator) { + return httpAuthenticationWhen(HttpClientConfig.AUTHENTICATION_PREDICATE, authenticator); + } + + /** + * Configure HTTP authentication for the client with custom authentication logic and retry predicate. + *

+ * This method provides a generic authentication framework that allows users to implement + * their own authentication mechanisms (e.g., Negotiate/SPNEGO, OAuth, Bearer tokens, custom schemes). + * The framework handles when to apply authentication based on the provided predicate, while users + * control how to generate and attach authentication credentials. + *

+ * + *

Example - Token-based Authentication with custom retry logic:

+ *
+	 * {@code
+	 * HttpClient client = HttpClient.create()
+	 *     .httpAuthenticationWhen(
+	 *         // Custom retry predicate (e.g., retry on 401 or 403)
+	 *         (req, res) -> res.status().code() == 401 || res.status().code() == 403,
 	 *         // Add authentication header before request
 	 *         (req, addr) -> {
 	 *             String token = generateAuthToken(addr);
@@ -1729,7 +1763,7 @@ public final HttpClient wiretap(boolean enable) {
 	 * @return a new {@link HttpClient}
 	 * @since 1.3.0
 	 */
-	public final HttpClient httpAuthentication(
+	public final HttpClient httpAuthenticationWhen(
 			BiPredicate predicate,
 			BiFunction> authenticator) {
 		Objects.requireNonNull(predicate, "predicate");
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 f78d5a54d..3472a7ecb 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
@@ -845,6 +845,9 @@ else if (metricsRecorder instanceof ContextAwareHttpClientMetricsRecorder) {
 			                                               .codeAsText())
 			                                   .matches();
 
+	static final BiPredicate AUTHENTICATION_PREDICATE =
+			(req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED);
+
 	static final int h3 = 0b1000;
 
 	static final int h2 = 0b010;
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 8629deb99..bbc028e42 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
@@ -3866,7 +3866,7 @@ void testHttpAuthenticationNoRetryWhenPredicateDoesNotMatch() {
 		HttpClient client =
 				HttpClient.create()
 				          .port(disposableServer.port())
-				          .httpAuthentication(
+				          .httpAuthenticationWhen(
 				                  // Only retry on 401, not 403
 				                  (req, res) -> res.status() == HttpResponseStatus.UNAUTHORIZED,
 				                  (req, addr) -> {
@@ -3916,7 +3916,7 @@ void testHttpAuthenticationWithMonoAuthenticator() {
 		HttpClient client =
 				HttpClient.create()
 				          .port(disposableServer.port())
-				          .httpAuthentication(
+				          .httpAuthenticationWhen(
 				                  (req, res) -> res.status() == HttpResponseStatus.UNAUTHORIZED,
 				                  (req, addr) -> {
 				                      int callNum = authCallCount.incrementAndGet();
@@ -3964,7 +3964,7 @@ void testHttpAuthenticationMultipleRequests() {
 		HttpClient client =
 				HttpClient.create()
 				          .port(disposableServer.port())
-				          .httpAuthentication(
+				          .httpAuthenticationWhen(
 				                  (req, res) -> res.status() == HttpResponseStatus.UNAUTHORIZED,
 				                  (req, addr) -> {
 				                      req.header(HttpHeaderNames.AUTHORIZATION, "Bearer valid-token");
@@ -4015,7 +4015,7 @@ void testHttpAuthenticationWithCustomStatusCode() {
 		HttpClient client =
 				HttpClient.create()
 				          .port(disposableServer.port())
-				          .httpAuthentication(
+				          .httpAuthenticationWhen(
 				                  // Retry on 407 instead of 401
 				                  (req, res) -> res.status() == HttpResponseStatus.PROXY_AUTHENTICATION_REQUIRED,
 				                  (req, addr) -> {

From 721be5c934adaa878701af8e37223eea8bd94519 Mon Sep 17 00:00:00 2001
From: raccoonback 
Date: Thu, 20 Nov 2025 23:54:01 +0900
Subject: [PATCH 13/29] Add test for HTTP authentication state propagation

Signed-off-by: raccoonback 
---
 .../http/client/HttpClientOperationsTest.java | 75 +++++++++++++++++++
 1 file changed, 75 insertions(+)

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..91949255b 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,82 @@ 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) throws Exception {
+		ConnectionProvider provider = ConnectionProvider.create("testConstructorWithProvidedReplacement_4", 1);
+		try {
+			HttpServer server = serverCtx == null ?
+					createServer().protocol(serverProtocols) :
+					createServer().protocol(serverProtocols).secure(spec -> spec.sslContext(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("testConstructorWithProvidedReplacement_4"));
+					                  }))
+					      .bindNow();
+
+			HttpClient client = clientCtx == null ?
+					createClient(disposableServer.port()).protocol(clientProtocols) :
+					createClient(disposableServer.port()).protocol(clientProtocols).secure(spec -> spec.sslContext(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("testConstructorWithProvidedReplacement_4");
+			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 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, addr) -> {
+			req.header(HttpHeaderNames.AUTHORIZATION, "Bearer test-token");
+			return Mono.empty();
+		});
+		return client.doAfterRequest((req, conn) -> {
+		                 if (request.get() == null) {
+		                     requestChannel.set(conn.channel());
+		                     requestListener.set(((HttpClientOperations) req).listener());
+		                     request.set(req);
+		                 }
+		             })
+		             .doAfterResponseSuccess((res, conn) -> {
+		                 if (response.get() == null) {
+		                     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),

From dead58f91498741fe6fbfbd343063756664f5d77 Mon Sep 17 00:00:00 2001
From: raccoonback 
Date: Thu, 20 Nov 2025 23:55:26 +0900
Subject: [PATCH 14/29] Update documentation and examples for
 httpAuthentication API changes

Signed-off-by: raccoonback 
---
 docs/modules/ROOT/pages/http-client.adoc      | 57 +++++++++++--------
 .../authentication/basic/Application.java     |  3 +-
 .../authentication/spnego/Application.java    |  2 +-
 .../authentication/token/Application.java     |  3 +-
 4 files changed, 37 insertions(+), 28 deletions(-)

diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc
index 85a5d804c..563bb1212 100644
--- a/docs/modules/ROOT/pages/http-client.adoc
+++ b/docs/modules/ROOT/pages/http-client.adoc
@@ -783,11 +783,12 @@ include::{examples-dir}/resolver/Application.java[lines=18..39]
 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 {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiPredicate-java.util.function.BiFunction-[`httpAuthentication`]
-method accepts two parameters:
+The framework provides two APIs for HTTP authentication:
 
-* A predicate that determines when authentication should be applied (typically by checking the HTTP status code and headers)
-* An authenticator function that applies authentication credentials to the request
+* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiFunction-[`httpAuthentication(BiFunction)`] -
+Automatically retries requests when the server returns `401 Unauthorized`.
+* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthenticationWhen-java.util.function.BiPredicate-java.util.function.BiFunction-[`httpAuthenticationWhen(BiPredicate, BiFunction)`] -
+Allows custom retry conditions based on request and response.
 
 This approach gives you complete control over the authentication flow while Reactor Netty handles the retry mechanism.
 
@@ -801,19 +802,39 @@ The typical HTTP authentication flow works as follows:
 . The request is retried with the authentication credentials.
 . If authentication is successful, the server returns the requested resource.
 
-=== Token-Based Authentication Example
+=== Simple Authentication with httpAuthentication
+
+For most authentication scenarios where you want to retry on `401 Unauthorized` responses, use the simpler
+{javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiFunction-[`httpAuthentication(BiFunction)`] method.
+
+==== Token-Based Authentication Example
 
 The following example demonstrates how to implement Bearer token authentication:
 
 {examples-link}/authentication/token/Application.java
 [%unbreakable]
 ----
-include::{examples-dir}/authentication/token/Application.java[lines=18..52]
+include::{examples-dir}/authentication/token/Application.java[lines=18..51]
 ----
-<1> The predicate checks if the response status is `401 Unauthorized`.
+<1> Automatically retries on `401 Unauthorized` responses.
 <2> The authenticator adds the `Authorization` header with a Bearer token.
 
-=== SPNEGO/Negotiate Authentication Example
+==== Basic Authentication Example
+
+{examples-link}/authentication/basic/Application.java
+[%unbreakable]
+----
+include::{examples-dir}/authentication/basic/Application.java[lines=18..45]
+----
+<1> Automatically retries on `401 Unauthorized` responses.
+<2> The authenticator adds Basic authentication credentials to the `Authorization` header.
+
+=== 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:
 
@@ -822,7 +843,7 @@ For SPNEGO (Kerberos) authentication, you can implement a custom authenticator u
 ----
 include::{examples-dir}/authentication/spnego/Application.java[lines=18..69]
 ----
-<1> The predicate checks for `401 Unauthorized` with `WWW-Authenticate: Negotiate` header.
+<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.
 
 NOTE: For SPNEGO authentication, you need to configure Kerberos settings (e.g., `krb5.conf`) and JAAS configuration
@@ -831,14 +852,13 @@ to point to your configuration files.
 
 === Custom Authentication Scenarios
 
-The `httpAuthentication` method is flexible enough to support various authentication scenarios:
+The authentication framework is flexible enough to support various authentication scenarios:
 
 ==== OAuth 2.0 Authentication
 [source,java]
 ----
 HttpClient client = HttpClient.create()
     .httpAuthentication(
-        (req, res) -> res.status().code() == 401,
         (req, addr) -> {
             return fetchOAuthToken() // <1>
                 .doOnNext(token ->
@@ -849,20 +869,11 @@ HttpClient client = HttpClient.create()
 ----
 <1> Asynchronously fetch an OAuth token and add it to the request.
 
-==== Basic Authentication
-{examples-link}/authentication/basic/Application.java
-[%unbreakable]
-----
-include::{examples-dir}/authentication/basic/Application.java[lines=18..46]
-----
-<1> The predicate checks if the response status is `401 Unauthorized`.
-<2> The authenticator adds Basic authentication credentials to the `Authorization` header.
-
 ==== Proxy Authentication
 [source,java]
 ----
 HttpClient client = HttpClient.create()
-    .httpAuthentication(
+    .httpAuthenticationWhen(
         (req, res) -> res.status().code() == 407, // <1>
         (req, addr) -> {
             String proxyCredentials = generateProxyCredentials();
@@ -871,11 +882,11 @@ HttpClient client = HttpClient.create()
         }
     );
 ----
-<1> Check for `407 Proxy Authentication Required` status code.
+<1> Custom predicate checks for `407 Proxy Authentication Required` status code.
 
 === Important Notes
 
-* The authenticator function is invoked only when the predicate returns `true`.
+* The authenticator function is invoked only when authentication is needed (on `401` for `httpAuthentication`, or when the predicate returns `true` for `httpAuthenticationWhen`).
 * The authenticator receives the request and remote address, allowing you to customize authentication based on the target server.
 * The authenticator returns a `Mono` which allows for asynchronous credential retrieval.
 * Authentication is retried only once per request. If authentication fails after retry, the error is propagated to the caller.
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
index f8f1bd801..c1c67807a 100644
--- 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
@@ -27,8 +27,7 @@ public class Application {
 	public static void main(String[] args) {
 		HttpClient client =
 				HttpClient.create()
-				          .httpAuthentication(
-				              (req, res) -> res.status().code() == 401, // <1>
+				          .httpAuthentication( // <1>
 				              (req, addr) -> { // <2>
 				                  String credentials = "username:password";
 				                  String encodedCredentials = Base64.getEncoder()
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
index f8ab67cba..ac191df10 100644
--- 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
@@ -32,7 +32,7 @@ public class Application {
 	public static void main(String[] args) {
 		HttpClient client =
 				HttpClient.create()
-				          .httpAuthentication(
+				          .httpAuthenticationWhen(
 				              (req, res) -> res.status().code() == 401 && // <1>
 				                            res.responseHeaders().contains("WWW-Authenticate", "Negotiate", true),
 				              (req, addr) -> { // <2>
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
index 952c3374c..07e314cf3 100644
--- 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
@@ -26,8 +26,7 @@ public class Application {
 	public static void main(String[] args) {
 		HttpClient client =
 				HttpClient.create()
-				          .httpAuthentication(
-				              (req, res) -> res.status().code() == 401, // <1>
+				          .httpAuthentication( // <1>
 				              (req, addr) -> { // <2>
 				                  String token = generateAuthToken(addr);
 				                  req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);

From 723ac4bde259e7c9f3f3943a0877f718d1bbfd2a Mon Sep 17 00:00:00 2001
From: raccoonback 
Date: Fri, 21 Nov 2025 00:26:19 +0900
Subject: [PATCH 15/29] Fix broken test

Signed-off-by: raccoonback 
---
 .../http/client/HttpClientOperationsTest.java | 26 ++++++++-----------
 1 file changed, 11 insertions(+), 15 deletions(-)

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 91949255b..8dc11f992 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
@@ -576,8 +576,8 @@ private static void checkRequest(HttpClientRequest request, HttpClientResponse r
 	@ParameterizedTest
 	@MethodSource("httpCompatibleProtocols")
 	void testConstructorWithProvidedAuthentication(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols,
-			SslProvider.@Nullable ProtocolSslContextSpec serverCtx, SslProvider.@Nullable ProtocolSslContextSpec clientCtx) throws Exception {
-		ConnectionProvider provider = ConnectionProvider.create("testConstructorWithProvidedReplacement_4", 1);
+			SslProvider.@Nullable ProtocolSslContextSpec serverCtx, SslProvider.@Nullable ProtocolSslContextSpec clientCtx) {
+		ConnectionProvider provider = ConnectionProvider.create("testConstructorWithProvidedAuthentication", 1);
 		try {
 			HttpServer server = serverCtx == null ?
 					createServer().protocol(serverProtocols) :
@@ -589,7 +589,7 @@ void testConstructorWithProvidedAuthentication(HttpProtocol[] serverProtocols, H
 					                      if (authHeader == null || !authHeader.equals("Bearer test-token")) {
 					                          return res.status(HttpResponseStatus.UNAUTHORIZED).send();
 					                      }
-					                      return res.sendString(Mono.just("testConstructorWithProvidedReplacement_4"));
+					                      return res.sendString(Mono.just("testConstructorWithProvidedAuthentication"));
 					                  }))
 					      .bindNow();
 
@@ -605,7 +605,7 @@ void testConstructorWithProvidedAuthentication(HttpProtocol[] serverProtocols, H
 			AtomicReference<@Nullable ConnectionObserver> responseListener = new AtomicReference<>();
 			String result = httpAuthentication(client, request, response, requestChannel, responseChannel,
 					requestListener, responseListener);
-			assertThat(result).isNotNull().isEqualTo("testConstructorWithProvidedReplacement_4");
+			assertThat(result).isNotNull().isEqualTo("testConstructorWithProvidedAuthentication");
 			assertThat(requestListener.get()).isSameAs(responseListener.get());
 			checkRequest(request.get(), response.get(), requestChannel.get(), responseChannel.get(), false, false);
 		}
@@ -624,18 +624,14 @@ private static String httpAuthentication(HttpClient originalClient, AtomicRefere
 			return Mono.empty();
 		});
 		return client.doAfterRequest((req, conn) -> {
-		                 if (request.get() == null) {
-		                     requestChannel.set(conn.channel());
-		                     requestListener.set(((HttpClientOperations) req).listener());
-		                     request.set(req);
-		                 }
+		                 requestChannel.set(conn.channel());
+		                 requestListener.set(((HttpClientOperations) req).listener());
+		                 request.set(req);
 		             })
-		             .doAfterResponseSuccess((res, conn) -> {
-		                 if (response.get() == null) {
-		                     responseChannel.set(conn.channel());
-		                     responseListener.set(((HttpClientOperations) res).listener());
-		                     response.set(res);
-		                 }
+		             .doOnResponse((res, conn) -> {
+		                 responseChannel.set(conn.channel());
+		                 responseListener.set(((HttpClientOperations) res).listener());
+		                 response.set(res);
 		             })
 		             .get()
 		             .uri("/protected")

From d2c310c538023c508a370fd92000c1d13748b4f9 Mon Sep 17 00:00:00 2001
From: raccoonback 
Date: Fri, 21 Nov 2025 00:57:31 +0900
Subject: [PATCH 16/29] Fix checkstyle

Signed-off-by: raccoonback 
---
 .../http/client/authentication/basic/Application.java           | 2 +-
 .../http/client/authentication/token/Application.java           | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

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
index c1c67807a..8048e06a7 100644
--- 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
@@ -27,7 +27,7 @@ public class Application {
 	public static void main(String[] args) {
 		HttpClient client =
 				HttpClient.create()
-				          .httpAuthentication( // <1>
+				          .httpAuthentication(// <1>
 				              (req, addr) -> { // <2>
 				                  String credentials = "username:password";
 				                  String encodedCredentials = Base64.getEncoder()
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
index 07e314cf3..9b78ec960 100644
--- 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
@@ -26,7 +26,7 @@ public class Application {
 	public static void main(String[] args) {
 		HttpClient client =
 				HttpClient.create()
-				          .httpAuthentication( // <1>
+				          .httpAuthentication(// <1>
 				              (req, addr) -> { // <2>
 				                  String token = generateAuthToken(addr);
 				                  req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);

From b65d57d6b1ffa235baf34600ae219033f46a3d9f Mon Sep 17 00:00:00 2001
From: raccoonback 
Date: Fri, 21 Nov 2025 11:52:04 +0900
Subject: [PATCH 17/29] Fix test warning log

Signed-off-by: raccoonback 
---
 .../netty/http/client/HttpClientOperationsTest.java       | 4 ++--
 .../java/reactor/netty/http/client/HttpClientTest.java    | 8 ++++----
 2 files changed, 6 insertions(+), 6 deletions(-)

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 8dc11f992..76dabfb55 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
@@ -581,7 +581,7 @@ void testConstructorWithProvidedAuthentication(HttpProtocol[] serverProtocols, H
 		try {
 			HttpServer server = serverCtx == null ?
 					createServer().protocol(serverProtocols) :
-					createServer().protocol(serverProtocols).secure(spec -> spec.sslContext(serverCtx));
+					createServer().protocol(serverProtocols).secure(spec -> spec.sslContext((SslProvider.GenericSslContextSpec) serverCtx));
 
 			disposableServer =
 					server.route(r -> r.get("/protected", (req, res) -> {
@@ -595,7 +595,7 @@ void testConstructorWithProvidedAuthentication(HttpProtocol[] serverProtocols, H
 
 			HttpClient client = clientCtx == null ?
 					createClient(disposableServer.port()).protocol(clientProtocols) :
-					createClient(disposableServer.port()).protocol(clientProtocols).secure(spec -> spec.sslContext(clientCtx));
+					createClient(disposableServer.port()).protocol(clientProtocols).secure(spec -> spec.sslContext((SslProvider.GenericSslContextSpec) clientCtx));
 
 			AtomicReference<@Nullable HttpClientRequest> request = new AtomicReference<>();
 			AtomicReference<@Nullable HttpClientResponse> response = new AtomicReference<>();
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 bbc028e42..3a837a189 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
@@ -3868,7 +3868,7 @@ void testHttpAuthenticationNoRetryWhenPredicateDoesNotMatch() {
 				          .port(disposableServer.port())
 				          .httpAuthenticationWhen(
 				                  // Only retry on 401, not 403
-				                  (req, res) -> res.status() == HttpResponseStatus.UNAUTHORIZED,
+				                  (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED),
 				                  (req, addr) -> {
 				                      authenticatorCalled.set(true);
 				                      req.header(HttpHeaderNames.AUTHORIZATION, "Bearer test-token");
@@ -3917,7 +3917,7 @@ void testHttpAuthenticationWithMonoAuthenticator() {
 				HttpClient.create()
 				          .port(disposableServer.port())
 				          .httpAuthenticationWhen(
-				                  (req, res) -> res.status() == HttpResponseStatus.UNAUTHORIZED,
+				                  (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED),
 				                  (req, addr) -> {
 				                      int callNum = authCallCount.incrementAndGet();
 				                      // Simulate async token generation
@@ -3965,7 +3965,7 @@ void testHttpAuthenticationMultipleRequests() {
 				HttpClient.create()
 				          .port(disposableServer.port())
 				          .httpAuthenticationWhen(
-				                  (req, res) -> res.status() == HttpResponseStatus.UNAUTHORIZED,
+				                  (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED),
 				                  (req, addr) -> {
 				                      req.header(HttpHeaderNames.AUTHORIZATION, "Bearer valid-token");
 				                      return Mono.empty();
@@ -4017,7 +4017,7 @@ void testHttpAuthenticationWithCustomStatusCode() {
 				          .port(disposableServer.port())
 				          .httpAuthenticationWhen(
 				                  // Retry on 407 instead of 401
-				                  (req, res) -> res.status() == HttpResponseStatus.PROXY_AUTHENTICATION_REQUIRED,
+				                  (req, res) -> res.status().equals(HttpResponseStatus.PROXY_AUTHENTICATION_REQUIRED),
 				                  (req, addr) -> {
 									  req.header("WWW-Authenticate", "Negotiate custom-token");
 				                      return Mono.empty();

From 18a038ce95f204bfe42bebc0115dacd26eaa6fe4 Mon Sep 17 00:00:00 2001
From: raccoonback 
Date: Sat, 22 Nov 2025 16:46:14 +0900
Subject: [PATCH 18/29] Update httpAuthentication API to accept custom retry
 predicate

This change updates the httpAuthentication() method to require both a retry predicate and an authenticator, allowing users to customize when authentication retry should occur.

Signed-off-by: raccoonback 
---
 docs/modules/ROOT/pages/http-client.adoc      | 30 ++++++-----
 .../authentication/basic/Application.java     |  5 +-
 .../authentication/token/Application.java     |  5 +-
 .../reactor/netty/http/client/HttpClient.java | 50 +++++++++++--------
 .../netty/http/client/HttpClientConfig.java   |  3 --
 .../http/client/HttpClientOperationsTest.java | 11 ++--
 .../netty/http/client/HttpClientTest.java     | 29 +++++++----
 7 files changed, 75 insertions(+), 58 deletions(-)

diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc
index 563bb1212..5e89e095c 100644
--- a/docs/modules/ROOT/pages/http-client.adoc
+++ b/docs/modules/ROOT/pages/http-client.adoc
@@ -785,10 +785,10 @@ custom authentication mechanisms such as SPNEGO/Negotiate, OAuth, Bearer tokens,
 
 The framework provides two APIs for HTTP authentication:
 
-* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiFunction-[`httpAuthentication(BiFunction)`] -
-Automatically retries requests when the server returns `401 Unauthorized`.
+* {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.
 * {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthenticationWhen-java.util.function.BiPredicate-java.util.function.BiFunction-[`httpAuthenticationWhen(BiPredicate, BiFunction)`] -
-Allows custom retry conditions based on request and response.
+For authentication where credentials need to be fetched from external sources or require delayed computation. Returns a `Mono` to support deferred credential retrieval.
 
 This approach gives you complete control over the authentication flow while Reactor Netty handles the retry mechanism.
 
@@ -802,10 +802,11 @@ The typical HTTP authentication flow works as follows:
 . The request is retried with the authentication credentials.
 . If authentication is successful, the server returns the requested resource.
 
-=== Simple Authentication with httpAuthentication
+=== Immediate Authentication with httpAuthentication
 
-For most authentication scenarios where you want to retry on `401 Unauthorized` responses, use the simpler
-{javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiFunction-[`httpAuthentication(BiFunction)`] method.
+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
 
@@ -816,7 +817,7 @@ The following example demonstrates how to implement Bearer token authentication:
 ----
 include::{examples-dir}/authentication/token/Application.java[lines=18..51]
 ----
-<1> Automatically retries on `401 Unauthorized` responses.
+<1> The predicate checks for `401 Unauthorized` responses to trigger authentication retry.
 <2> The authenticator adds the `Authorization` header with a Bearer token.
 
 ==== Basic Authentication Example
@@ -826,7 +827,7 @@ include::{examples-dir}/authentication/token/Application.java[lines=18..51]
 ----
 include::{examples-dir}/authentication/basic/Application.java[lines=18..45]
 ----
-<1> Automatically retries on `401 Unauthorized` responses.
+<1> The predicate checks for `401 Unauthorized` responses to trigger authentication retry.
 <2> The authenticator adds Basic authentication credentials to the `Authorization` header.
 
 === Custom Authentication with httpAuthenticationWhen
@@ -858,16 +859,18 @@ The authentication framework is flexible enough to support various authenticatio
 [source,java]
 ----
 HttpClient client = HttpClient.create()
-    .httpAuthentication(
+    .httpAuthenticationWhen(
+        (req, res) -> res.status().code() == 401, // <1>
         (req, addr) -> {
-            return fetchOAuthToken() // <1>
+            return fetchOAuthToken() // <2>
                 .doOnNext(token ->
                     req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token))
                 .then();
         }
     );
 ----
-<1> Asynchronously fetch an OAuth token and add it to the request.
+<1> Retry authentication on `401 Unauthorized` responses.
+<2> Asynchronously fetch an OAuth token and add it to the request.
 
 ==== Proxy Authentication
 [source,java]
@@ -886,8 +889,9 @@ HttpClient client = HttpClient.create()
 
 === Important Notes
 
-* The authenticator function is invoked only when authentication is needed (on `401` for `httpAuthentication`, or when the predicate returns `true` for `httpAuthenticationWhen`).
+* 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.
-* The authenticator returns a `Mono` which allows for asynchronous credential retrieval.
 * Authentication is retried only once per request. If authentication fails after retry, 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
index 8048e06a7..411301553 100644
--- 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
@@ -16,7 +16,6 @@
 package reactor.netty.examples.documentation.http.client.authentication.basic;
 
 import io.netty.handler.codec.http.HttpHeaderNames;
-import reactor.core.publisher.Mono;
 import reactor.netty.http.client.HttpClient;
 
 import java.nio.charset.StandardCharsets;
@@ -27,13 +26,13 @@ public class Application {
 	public static void main(String[] args) {
 		HttpClient client =
 				HttpClient.create()
-				          .httpAuthentication(// <1>
+				          .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);
-				                  return Mono.empty();
 				              }
 				          );
 
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
index 9b78ec960..50351935b 100644
--- 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
@@ -16,7 +16,6 @@
 package reactor.netty.examples.documentation.http.client.authentication.token;
 
 import io.netty.handler.codec.http.HttpHeaderNames;
-import reactor.core.publisher.Mono;
 import reactor.netty.http.client.HttpClient;
 
 import java.net.SocketAddress;
@@ -26,11 +25,11 @@ public class Application {
 	public static void main(String[] args) {
 		HttpClient client =
 				HttpClient.create()
-				          .httpAuthentication(// <1>
+				          .httpAuthentication(
+				              (req, res) -> res.status().code() == 401, // <1>
 				              (req, addr) -> { // <2>
 				                  String token = generateAuthToken(addr);
 				                  req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);
-				                  return Mono.empty();
 				              }
 				          );
 
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 e8ba3300e..b1de283c8 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
@@ -1697,61 +1697,69 @@ public final HttpClient wiretap(boolean enable) {
 	}
 
 	/**
-	 * Configure HTTP authentication that retries on 401 Unauthorized responses.
+	 * Configure HTTP authentication with custom retry predicate.
 	 * 

- * This method provides a generic authentication framework that allows users to implement - * their own authentication mechanisms (e.g., Negotiate/SPNEGO, OAuth, Bearer tokens, custom schemes). - * The framework automatically retries requests when a 401 Unauthorized response is received. + * This method is for authentication where credentials can be computed immediately + * without needing to fetch them from external sources. Use this when the token can be + * calculated and added to headers without delay, with a custom condition for when + * to retry with authentication. *

* - *

Example - Token-based Authentication:

+ *

Example - Token-based Authentication with custom retry logic:

*
 	 * {@code
 	 * HttpClient client = HttpClient.create()
 	 *     .httpAuthentication(
+	 *         // Custom retry predicate (e.g., retry on 401 or 403)
+	 *         (req, res) -> res.status().code() == 401 || res.status().code() == 403,
 	 *         // Add authentication header before request
 	 *         (req, addr) -> {
 	 *             String token = generateAuthToken(addr);
 	 *             req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);
-	 *             return Mono.empty();
 	 *         }
 	 *     );
 	 * }
 	 * 
* - * @param authenticator applies authentication to the request, receives the request and remote address, - * returns a Mono that completes when authentication is applied + * @param predicate determines when authentication should be applied, receives the request + * and response to decide if authentication is needed (e.g., check for 401 status) + * @param authenticator applies authentication to the request, receives the request and remote address * @return a new {@link HttpClient} * @since 1.3.0 * @see #httpAuthenticationWhen(BiPredicate, BiFunction) */ public final HttpClient httpAuthentication( - BiFunction> authenticator) { - return httpAuthenticationWhen(HttpClientConfig.AUTHENTICATION_PREDICATE, authenticator); + BiPredicate predicate, + BiConsumer authenticator) { + Objects.requireNonNull(predicate, "predicate"); + Objects.requireNonNull(authenticator, "authenticator"); + return httpAuthenticationWhen( + predicate, + (req, addr) -> { + authenticator.accept(req, addr); + return Mono.empty(); + }); } /** * Configure HTTP authentication for the client with custom authentication logic and retry predicate. *

- * This method provides a generic authentication framework that allows users to implement - * their own authentication mechanisms (e.g., Negotiate/SPNEGO, OAuth, Bearer tokens, custom schemes). - * The framework handles when to apply authentication based on the provided predicate, while users - * control how to generate and attach authentication credentials. + * This method is for authentication where credentials need to be fetched from external sources + * or require delayed computation (e.g., asking another service for a token). *

* - *

Example - Token-based Authentication with custom retry logic:

+ *

Example - Deferred Token-based Authentication with custom retry logic:

*
 	 * {@code
 	 * HttpClient client = HttpClient.create()
 	 *     .httpAuthenticationWhen(
 	 *         // Custom retry predicate (e.g., retry on 401 or 403)
 	 *         (req, res) -> res.status().code() == 401 || res.status().code() == 403,
-	 *         // Add authentication header before request
-	 *         (req, addr) -> {
-	 *             String token = generateAuthToken(addr);
-	 *             req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);
-	 *             return Mono.empty();
-	 *         }
+	 *         // Fetch token and add authentication header
+	 *         (req, addr) -> fetchTokenAsync(addr)
+	 *             .doOnNext(token ->
+	 *                 req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token))
+	 *             .then()
 	 *     );
 	 * }
 	 * 
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 3472a7ecb..f78d5a54d 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 @@ -845,9 +845,6 @@ else if (metricsRecorder instanceof ContextAwareHttpClientMetricsRecorder) { .codeAsText()) .matches(); - static final BiPredicate AUTHENTICATION_PREDICATE = - (req, res) -> res.status().equals(HttpResponseStatus.UNAUTHORIZED); - static final int h3 = 0b1000; static final int h2 = 0b010; 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 76dabfb55..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 @@ -615,14 +615,15 @@ void testConstructorWithProvidedAuthentication(HttpProtocol[] serverProtocols, H } } - private static String httpAuthentication(HttpClient originalClient, AtomicReference<@Nullable HttpClientRequest> request, + 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, addr) -> { - req.header(HttpHeaderNames.AUTHORIZATION, "Bearer test-token"); - return Mono.empty(); - }); + 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()); 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 3a837a189..e5f430b03 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 @@ -3818,23 +3818,32 @@ void testHttpAuthentication() { HttpServer.create() .port(0) .handle((req, res) -> { - requestCount.incrementAndGet(); + int count = requestCount.incrementAndGet(); String authHeader = req.requestHeaders().get(HttpHeaderNames.AUTHORIZATION); - // First request should already have auth header - assertThat(authHeader).isEqualTo("Bearer test-token"); - return res.status(HttpResponseStatus.OK) - .sendString(Mono.just("Authenticated!")); + 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()) - .doOnRequest((req, conn) -> { - authHeaderAdded.set(true); - req.requestHeaders().set(HttpHeaderNames.AUTHORIZATION, "Bearer test-token"); - }); + .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") @@ -3844,7 +3853,7 @@ void testHttpAuthentication() { .block(Duration.ofSeconds(5)); assertThat(response).isEqualTo("Authenticated!"); - assertThat(requestCount.get()).isEqualTo(1); + assertThat(requestCount.get()).isEqualTo(2); assertThat(authHeaderAdded.get()).isTrue(); } From 9c3d6b962c58009baa13816560f5d06ae36dfe56 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Mon, 24 Nov 2025 23:13:23 +0900 Subject: [PATCH 19/29] notAuthenticated -> authenticationNotRequired Signed-off-by: raccoonback --- .../netty/http/client/Http2WebsocketClientOperations.java | 6 +----- .../reactor/netty/http/client/HttpClientOperations.java | 7 ++----- .../netty/http/client/WebsocketClientOperations.java | 6 +----- 3 files changed, 4 insertions(+), 15 deletions(-) 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 237dccf75..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) && notAuthenticated()) { + if (notRedirected(response) && authenticationNotRequired()) { try { HttpResponseStatus status = response.status(); if (!HttpResponseStatus.OK.equals(status)) { @@ -132,13 +132,9 @@ else if (msg instanceof HttpResponse) { } else { if (redirecting != null) { - // Deliberately suppress "NullAway" - // redirecting != null in this case listener().onUncaughtException(this, redirecting); } else if (authenticating != null) { - // Deliberately suppress "NullAway" - // authenticating != null in this case listener().onUncaughtException(this, authenticating); } } 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 22c923647..b2cd2127a 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 @@ -850,7 +850,7 @@ protected void onInboundNext(ChannelHandlerContext ctx, Object msg) { httpMessageLogFactory().debug(HttpMessageArgProviderFactory.create(response))); } - if (notRedirected(response) && notAuthenticated()) { + if (notRedirected(response) && authenticationNotRequired()) { try { listener().onStateChange(this, HttpClientState.RESPONSE_RECEIVED); } @@ -990,10 +990,7 @@ final boolean notRedirected(HttpResponse response) { return true; } - @SuppressWarnings("NullAway") - final boolean notAuthenticated() { - // Deliberately suppress "NullAway" - // authenticationPredicate is checked for null before calling this method + final boolean authenticationNotRequired() { if (authenticationPredicate != null && authenticationPredicate.test(this, this)) { authenticating = new HttpClientAuthenticationException(); if (log.isDebugEnabled()) { 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 c58d6807a..840fce413 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 @@ -144,7 +144,7 @@ public void onInboundNext(ChannelHandlerContext ctx, Object msg) { setNettyResponse(response); - if (notRedirected(response) && notAuthenticated()) { + if (notRedirected(response) && authenticationNotRequired()) { try { handshakerHttp11.finishHandshake(channel(), response); // This change is needed after the Netty change https://github.com/netty/netty/pull/11966 @@ -166,13 +166,9 @@ public void onInboundNext(ChannelHandlerContext ctx, Object msg) { response.content() .release(); if (redirecting != null) { - // Deliberately suppress "NullAway" - // redirecting is initialized in notRedirected(response) listener().onUncaughtException(this, redirecting); } else if (authenticating != null) { - // Deliberately suppress "NullAway" - // authenticating is initialized in notAuthenticated() listener().onUncaughtException(this, authenticating); } } From 0a9ac823e1111d302a63270a2b34793fa045a770 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Mon, 24 Nov 2025 23:23:36 +0900 Subject: [PATCH 20/29] Update javadoc about http authentication Signed-off-by: raccoonback --- .../reactor/netty/http/client/HttpClient.java | 71 ++++++++++++------- .../HttpClientAuthenticationException.java | 16 ++--- .../netty/http/client/HttpClientConfig.java | 1 + .../http/client/HttpClientOperations.java | 1 + .../client/WebsocketClientOperations.java | 1 + 5 files changed, 56 insertions(+), 34 deletions(-) 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 b1de283c8..91b2841e2 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 { @@ -1697,22 +1698,28 @@ public final HttpClient wiretap(boolean enable) { } /** - * Configure HTTP authentication with custom retry predicate. + * Enables a mechanism for an automatic retry of the requests when HTTP authentication is expected: *

- * This method is for authentication where credentials can be computed immediately - * without needing to fetch them from external sources. Use this when the token can be - * calculated and added to headers without delay, with a custom condition for when - * to retry with authentication. + *

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

+ *

+ * 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 - Token-based Authentication with custom retry logic:

+ *

Example - Bearer token authentication with custom retry logic:

*
 	 * {@code
 	 * HttpClient client = HttpClient.create()
 	 *     .httpAuthentication(
-	 *         // Custom retry predicate (e.g., retry on 401 or 403)
-	 *         (req, res) -> res.status().code() == 401 || res.status().code() == 403,
-	 *         // Add authentication header before request
+	 *         // 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);
@@ -1721,11 +1728,12 @@ public final HttpClient wiretap(boolean enable) {
 	 * }
 	 * 
* - * @param predicate determines when authentication should be applied, receives the request - * and response to decide if authentication is needed (e.g., check for 401 status) - * @param authenticator applies authentication to the request, receives the request and remote address + * @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.0 + * @since 1.3.1 * @see #httpAuthenticationWhen(BiPredicate, BiFunction) */ public final HttpClient httpAuthentication( @@ -1742,21 +1750,30 @@ public final HttpClient httpAuthentication( } /** - * Configure HTTP authentication for the client with custom authentication logic and retry predicate. + * 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 is for authentication where credentials need to be fetched from external sources - * or require delayed computation (e.g., asking another service for a token). + * 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 - Deferred Token-based Authentication with custom retry logic:

+ *

Example - Async token retrieval with custom retry logic:

*
 	 * {@code
 	 * HttpClient client = HttpClient.create()
 	 *     .httpAuthenticationWhen(
-	 *         // Custom retry predicate (e.g., retry on 401 or 403)
-	 *         (req, res) -> res.status().code() == 401 || res.status().code() == 403,
-	 *         // Fetch token and add authentication header
-	 *         (req, addr) -> fetchTokenAsync(addr)
+	 *         // 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()
@@ -1764,12 +1781,14 @@ public final HttpClient httpAuthentication(
 	 * }
 	 * 
* - * @param predicate determines when authentication should be applied, receives the request - * and response to decide if authentication is needed (e.g., check for 401 status) - * @param authenticator applies authentication to the request, receives the request and remote address, - * returns a Mono that completes when authentication is applied + * @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.0 + * @since 1.3.1 + * @see #httpAuthentication(BiPredicate, BiConsumer) */ public final HttpClient httpAuthenticationWhen( BiPredicate predicate, 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 index 0fa7a19c7..581eb3e11 100644 --- 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 @@ -16,15 +16,15 @@ package reactor.netty.http.client; /** - * Exception thrown to trigger HTTP authentication retry. - *

- * This exception is used internally by the generic HTTP authentication framework - * to signal that the current request requires authentication and should be retried. - * The framework will invoke the configured authenticator before retrying the request. - *

+ * 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 Oliver Ko - * @since 1.3.0 + * @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 { 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 f78d5a54d..dcba711aa 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 { 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 b2cd2127a..39d479af1 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 { 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 840fce413..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 { From efb21afa2a4fabfa3264cfce88cf2831d5efee07 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Mon, 24 Nov 2025 23:37:40 +0900 Subject: [PATCH 21/29] Apply generic in http authentication predication Signed-off-by: raccoonback --- .../src/main/java/reactor/netty/http/client/HttpClient.java | 4 ++-- .../main/java/reactor/netty/http/client/HttpClientConfig.java | 2 +- .../java/reactor/netty/http/client/HttpClientConnect.java | 2 +- .../java/reactor/netty/http/client/HttpClientOperations.java | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) 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 91b2841e2..9ec305408 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 @@ -1737,7 +1737,7 @@ public final HttpClient wiretap(boolean enable) { * @see #httpAuthenticationWhen(BiPredicate, BiFunction) */ public final HttpClient httpAuthentication( - BiPredicate predicate, + BiPredicate predicate, BiConsumer authenticator) { Objects.requireNonNull(predicate, "predicate"); Objects.requireNonNull(authenticator, "authenticator"); @@ -1791,7 +1791,7 @@ public final HttpClient httpAuthentication( * @see #httpAuthentication(BiPredicate, BiConsumer) */ public final HttpClient httpAuthenticationWhen( - BiPredicate predicate, + BiPredicate predicate, BiFunction> authenticator) { Objects.requireNonNull(predicate, "predicate"); Objects.requireNonNull(authenticator, "authenticator"); 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 dcba711aa..cc3621cdf 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 @@ -368,7 +368,7 @@ public HttpProtocol[] protocols() { @Nullable String uriStr; @Nullable Function uriTagValue; @Nullable WebsocketClientSpec websocketClientSpec; - @Nullable BiPredicate authenticationPredicate; + @Nullable BiPredicate authenticationPredicate; @Nullable BiFunction> authenticator; HttpClientConfig(HttpConnectionProvider connectionProvider, Map, ?> options, 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 b3ac81420..4e3f8811f 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 @@ -501,7 +501,7 @@ static final class HttpClientHandler extends SocketAddress volatile @Nullable HttpHeaders previousRequestHeaders; volatile boolean authenticationAttempted; - @Nullable BiPredicate authenticationPredicate; + @Nullable BiPredicate authenticationPredicate; @Nullable BiFunction> authenticator; HttpClientHandler(HttpClientConfig configuration) { 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 39d479af1..f89130156 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 @@ -132,7 +132,7 @@ class HttpClientOperations extends HttpOperations @Nullable HttpClientAuthenticationException authenticating; @Nullable BiPredicate followRedirectPredicate; - @Nullable BiPredicate authenticationPredicate; + @Nullable BiPredicate authenticationPredicate; @Nullable Consumer redirectRequestConsumer; @Nullable HttpHeaders previousRequestHeaders; @Nullable BiConsumer redirectRequestBiConsumer; @@ -346,7 +346,7 @@ void followRedirectPredicate(@Nullable BiPredicate predicate) { + void authenticationPredicate(@Nullable BiPredicate predicate) { this.authenticationPredicate = predicate; } From 7eb0cb5809f03b6d64d5c8f031eabc184020cb0e Mon Sep 17 00:00:00 2001 From: raccoonback Date: Wed, 26 Nov 2025 04:07:54 +0900 Subject: [PATCH 22/29] Add configurable maximum retry attempts for HTTP authentication Signed-off-by: raccoonback --- docs/modules/ROOT/pages/http-client.adoc | 19 +- .../authentication/basic/Application.java | 2 +- .../authentication/spnego/Application.java | 3 +- .../authentication/token/Application.java | 3 +- .../reactor/netty/http/client/HttpClient.java | 126 +++++++++- .../netty/http/client/HttpClientConfig.java | 2 + .../netty/http/client/HttpClientConnect.java | 16 +- .../http/client/HttpClientOperations.java | 12 +- .../netty/http/client/HttpClientTest.java | 226 ++++++++++++++++++ 9 files changed, 391 insertions(+), 18 deletions(-) diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc index 5e89e095c..8d6b5b9c0 100644 --- a/docs/modules/ROOT/pages/http-client.adoc +++ b/docs/modules/ROOT/pages/http-client.adoc @@ -786,9 +786,13 @@ custom authentication mechanisms such as SPNEGO/Negotiate, OAuth, Bearer tokens, 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. +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. +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. @@ -810,7 +814,7 @@ This method requires both a predicate to determine when to retry and a consumer ==== Token-Based Authentication Example -The following example demonstrates how to implement Bearer token authentication: +The following example demonstrates how to implement Bearer token authentication with configurable retry count: {examples-link}/authentication/token/Application.java [%unbreakable] @@ -819,16 +823,18 @@ 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..45] +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 @@ -842,10 +848,11 @@ For SPNEGO (Kerberos) authentication, you can implement a custom authenticator u {examples-link}/authentication/spnego/Application.java [%unbreakable] ---- -include::{examples-dir}/authentication/spnego/Application.java[lines=18..69] +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` @@ -893,5 +900,5 @@ HttpClient client = HttpClient.create() * 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. -* Authentication is retried only once per request. If authentication fails after retry, the error is propagated to the caller. +* 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 index 411301553..c7e114f39 100644 --- 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 @@ -33,7 +33,7 @@ public static void main(String[] args) { String encodedCredentials = Base64.getEncoder() .encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); req.header(HttpHeaderNames.AUTHORIZATION, "Basic " + encodedCredentials); - } + } // <3> ); client.get() 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 index ac191df10..83709b5eb 100644 --- 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 @@ -58,7 +58,8 @@ public static void main(String[] args) { "Failed to generate SPNEGO token", e)); } return Mono.empty(); - } + }, + 2 // <3> ); client.get() 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 index 50351935b..578091e22 100644 --- 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 @@ -30,7 +30,8 @@ public static void main(String[] args) { (req, addr) -> { // <2> String token = generateAuthToken(addr); req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token); - } + }, + 3 // <3> ); client.get() 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 9ec305408..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 @@ -1707,6 +1707,10 @@ public final HttpClient wiretap(boolean enable) { * *

*

+ * 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. @@ -1734,19 +1738,72 @@ public final HttpClient wiretap(boolean enable) { * 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) { - Objects.requireNonNull(predicate, "predicate"); - Objects.requireNonNull(authenticator, "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 + ); } /** @@ -1760,6 +1817,10 @@ public final HttpClient httpAuthentication( * *

*

+ * 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. @@ -1788,16 +1849,75 @@ public final HttpClient httpAuthentication( * 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; } 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 cc3621cdf..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 @@ -370,6 +370,7 @@ public HttpProtocol[] protocols() { @Nullable WebsocketClientSpec websocketClientSpec; @Nullable BiPredicate authenticationPredicate; @Nullable BiFunction> authenticator; + int maxAuthenticationRetries; HttpClientConfig(HttpConnectionProvider connectionProvider, Map, ?> options, Supplier remoteAddress) { @@ -424,6 +425,7 @@ public HttpProtocol[] protocols() { 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 4e3f8811f..3f197f65f 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 @@ -499,10 +499,11 @@ static final class HttpClientHandler extends SocketAddress volatile Supplier @Nullable [] redirectedFrom; volatile boolean shouldRetry; volatile @Nullable HttpHeaders previousRequestHeaders; - volatile boolean authenticationAttempted; + volatile int authenticationRetries; @Nullable BiPredicate authenticationPredicate; @Nullable BiFunction> authenticator; + int maxAuthenticationRetries; HttpClientHandler(HttpClientConfig configuration) { this.method = configuration.method; @@ -543,6 +544,7 @@ static final class HttpClientHandler extends SocketAddress this.resourceUrl = toURI.toExternalForm(); this.authenticationPredicate = configuration.authenticationPredicate; this.authenticator = configuration.authenticator; + this.maxAuthenticationRetries = configuration.maxAuthenticationRetries; } @Override @@ -660,7 +662,7 @@ Publisher requestWithBody(HttpClientOperations ch) { } // Apply authenticator if needed (after REQUEST_PREPARED) - if (authenticator != null && authenticationAttempted) { + if (authenticator != null && authenticationRetries > 0) { return authenticator.apply(ch, ch.address()) .then(Mono.defer(() -> Mono.from(result))); } @@ -736,6 +738,8 @@ void channel(HttpClientOperations ops) { if (redirectedFrom != null) { ops.redirectedFrom = redirectedFrom; } + ops.authenticationRetries = this.authenticationRetries; + ops.maxAuthenticationRetries = this.maxAuthenticationRetries; } @Override @@ -749,8 +753,12 @@ public boolean test(Throwable throwable) { return true; } if (throwable instanceof HttpClientAuthenticationException) { - // Set flag to trigger authenticator on retry - authenticationAttempted = true; + // 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)) { 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 f89130156..2743796cd 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 @@ -119,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; @@ -147,6 +149,8 @@ class HttpClientOperations extends HttpOperations 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; @@ -178,6 +182,8 @@ class HttpClientOperations extends HttpOperations 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; @@ -992,10 +998,12 @@ final boolean notRedirected(HttpResponse response) { } final boolean authenticationNotRequired() { - if (authenticationPredicate != null && authenticationPredicate.test(this, this)) { + if (authenticationPredicate != null && authenticationRetries < maxAuthenticationRetries && + authenticationPredicate.test(this, this)) { authenticating = new HttpClientAuthenticationException(); if (log.isDebugEnabled()) { - log.debug(format(channel(), "Authentication predicate matched, triggering retry")); + log.debug(format(channel(), "Authentication predicate matched, triggering retry (attempt {} of {})"), + authenticationRetries + 1, maxAuthenticationRetries); } return false; } 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 e5f430b03..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 @@ -4044,6 +4044,232 @@ void testHttpAuthenticationWithCustomStatusCode() { 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; From 5febca9c4dad65570a6762d7bf487841b7fef9af Mon Sep 17 00:00:00 2001 From: raccoonback Date: Wed, 26 Nov 2025 09:22:50 +0900 Subject: [PATCH 23/29] Extract authentication retry configuration to method Signed-off-by: raccoonback --- .../java/reactor/netty/http/client/HttpClientConnect.java | 3 +-- .../java/reactor/netty/http/client/HttpClientOperations.java | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) 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 3f197f65f..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 @@ -738,8 +738,7 @@ void channel(HttpClientOperations ops) { if (redirectedFrom != null) { ops.redirectedFrom = redirectedFrom; } - ops.authenticationRetries = this.authenticationRetries; - ops.maxAuthenticationRetries = this.maxAuthenticationRetries; + ops.configureAuthenticationRetries(this.authenticationRetries, this.maxAuthenticationRetries); } @Override 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 2743796cd..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 @@ -356,6 +356,11 @@ void authenticationPredicate(@Nullable BiPredicate redirectRequestConsumer) { this.redirectRequestConsumer = redirectRequestConsumer; } From 3a475348918c6f7f9ad937f714413d5ced78c3ef Mon Sep 17 00:00:00 2001 From: raccoonback Date: Mon, 1 Dec 2025 18:51:12 +0900 Subject: [PATCH 24/29] Check authentication retry limit in HttpClientOperations only Move retry limit check from HttpClientHandler to HttpClientOperations to ensure the response is delivered to the user when authentication retries are exhausted, rather than failing the entire request. Signed-off-by: raccoonback --- .../java/reactor/netty/http/client/HttpClientConnect.java | 6 +----- .../reactor/netty/http/client/HttpClientOperationsTest.java | 1 + 2 files changed, 2 insertions(+), 5 deletions(-) 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 367594d99..42186af00 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 @@ -499,10 +499,10 @@ 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; + volatile int authenticationRetries; int maxAuthenticationRetries; HttpClientHandler(HttpClientConfig configuration) { @@ -752,10 +752,6 @@ public boolean test(Throwable throwable) { 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; 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 ae082fb45..a8eae84bf 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 @@ -72,6 +72,7 @@ * This test class verifies basic {@link HttpClient} functionality. * * @author Simon Baslé + * @author raccoonback */ class HttpClientOperationsTest extends BaseHttpTest { static X509Bundle ssc; From 23d17f9407ba470af9911e54015df5b60fba1576 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Mon, 1 Dec 2025 22:26:21 +0900 Subject: [PATCH 25/29] Provide authentication retry attempts during HttpClient auth Signed-off-by: raccoonback --- .../http/client/FailedHttpClientRequest.java | 6 +++ .../netty/http/client/HttpClientInfos.java | 9 ++++ .../http/client/HttpClientOperations.java | 5 +++ .../http/client/HttpClientOperationsTest.java | 2 + .../netty/http/client/HttpClientTest.java | 43 ++++++++++++++----- 5 files changed, 54 insertions(+), 11 deletions(-) diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/FailedHttpClientRequest.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/FailedHttpClientRequest.java index db5654631..dc7cc671c 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/FailedHttpClientRequest.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/FailedHttpClientRequest.java @@ -38,6 +38,7 @@ * cannot be created. * * @author Violeta Georgieva + * @author raccoonback */ final class FailedHttpClientRequest implements HttpClientRequest { @@ -135,6 +136,11 @@ public String[] redirectedFrom() { return EMPTY; } + @Override + public int authenticationRetryCount() { + return 0; + } + @Override public HttpHeaders requestHeaders() { return headers; diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientInfos.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientInfos.java index 8c4774ad8..ba955278a 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientInfos.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientInfos.java @@ -25,6 +25,7 @@ * An Http Reactive Channel with several accessors related to HTTP flow: resource URL, * information for redirections etc... * + * @author raccoonback * @since 0.9.3 */ public interface HttpClientInfos extends HttpInfos { @@ -56,6 +57,14 @@ public interface HttpClientInfos extends HttpInfos { */ String[] redirectedFrom(); + /** + * Return the number of authentication retry attempts for this request. + * + * @return the number of authentication retries + * @since 1.3.1 + */ + int authenticationRetryCount(); + /** * Return outbound headers to be sent. * 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 72e7388e2..8f67e0eac 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 @@ -514,6 +514,11 @@ public String[] redirectedFrom() { return dest; } + @Override + public int authenticationRetryCount() { + return authenticationRetries; + } + @Override public HttpHeaders requestHeaders() { return nettyRequest.headers(); 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 a8eae84bf..346ec6c19 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 @@ -608,6 +608,8 @@ void testConstructorWithProvidedAuthentication(HttpProtocol[] serverProtocols, H requestListener, responseListener); assertThat(result).isNotNull().isEqualTo("testConstructorWithProvidedAuthentication"); assertThat(requestListener.get()).isSameAs(responseListener.get()); + assertThat(request.get()).isNotNull(); + assertThat(request.get().authenticationRetryCount()).isEqualTo(1); checkRequest(request.get(), response.get(), requestChannel.get(), responseChannel.get(), false, false); } finally { 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 de0d14bf6..573f16bbc 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 @@ -3813,6 +3813,7 @@ void testSelectedIpsDelayedAddressResolution() { void testHttpAuthentication() { AtomicInteger requestCount = new AtomicInteger(0); AtomicBoolean authHeaderAdded = new AtomicBoolean(false); + AtomicReference capturedRequest = new AtomicReference<>(); disposableServer = HttpServer.create() @@ -3845,7 +3846,8 @@ void testHttpAuthentication() { req.header(HttpHeaderNames.AUTHORIZATION, "Bearer test-token"); }); - String response = client.get() + String response = client.doAfterRequest((req, conn) -> capturedRequest.set(req)) + .get() .uri("/protected") .responseContent() .aggregate() @@ -3855,12 +3857,15 @@ void testHttpAuthentication() { assertThat(response).isEqualTo("Authenticated!"); assertThat(requestCount.get()).isEqualTo(2); assertThat(authHeaderAdded.get()).isTrue(); + assertThat(capturedRequest.get()).isNotNull(); + assertThat(capturedRequest.get().authenticationRetryCount()).isEqualTo(1); } @Test void testHttpAuthenticationNoRetryWhenPredicateDoesNotMatch() { AtomicInteger requestCount = new AtomicInteger(0); AtomicBoolean authenticatorCalled = new AtomicBoolean(false); + AtomicReference capturedRequest = new AtomicReference<>(); disposableServer = HttpServer.create() @@ -3885,23 +3890,27 @@ void testHttpAuthenticationNoRetryWhenPredicateDoesNotMatch() { } ); - client.get() - .uri("/protected") - .responseSingle((res, content) -> Mono.just(res.status())) - .as(StepVerifier::create) - .expectNext(HttpResponseStatus.FORBIDDEN) - .expectComplete() - .verify(Duration.ofSeconds(5)); + client.doAfterRequest((req, conn) -> capturedRequest.set(req)) + .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(); + assertThat(capturedRequest.get()).isNotNull(); + assertThat(capturedRequest.get().authenticationRetryCount()).isEqualTo(0); } @Test void testHttpAuthenticationWithMonoAuthenticator() { AtomicInteger requestCount = new AtomicInteger(0); AtomicInteger authCallCount = new AtomicInteger(0); + AtomicReference capturedRequest = new AtomicReference<>(); disposableServer = HttpServer.create() @@ -3937,7 +3946,8 @@ void testHttpAuthenticationWithMonoAuthenticator() { } ); - String response = client.get() + String response = client.doAfterRequest((req, conn) -> capturedRequest.set(req)) + .get() .uri("/api/resource") .responseContent() .aggregate() @@ -3947,6 +3957,8 @@ void testHttpAuthenticationWithMonoAuthenticator() { assertThat(response).isEqualTo("Success"); assertThat(requestCount.get()).isEqualTo(2); assertThat(authCallCount.get()).isEqualTo(1); + assertThat(capturedRequest.get()).isNotNull(); + assertThat(capturedRequest.get().authenticationRetryCount()).isEqualTo(1); } @Test @@ -3999,6 +4011,7 @@ void testHttpAuthenticationMultipleRequests() { @Test void testHttpAuthenticationWithCustomStatusCode() { AtomicInteger requestCount = new AtomicInteger(0); + AtomicReference capturedRequest = new AtomicReference<>(); disposableServer = HttpServer.create() @@ -4033,7 +4046,8 @@ void testHttpAuthenticationWithCustomStatusCode() { } ); - String response = client.get() + String response = client.doAfterRequest((req, conn) -> capturedRequest.set(req)) + .get() .uri("/proxy-protected") .responseContent() .aggregate() @@ -4042,12 +4056,15 @@ void testHttpAuthenticationWithCustomStatusCode() { assertThat(response).isEqualTo("Authorized"); assertThat(requestCount.get()).isEqualTo(2); + assertThat(capturedRequest.get()).isNotNull(); + assertThat(capturedRequest.get().authenticationRetryCount()).isEqualTo(1); } @Test void testHttpAuthenticationMaxRetries() { AtomicInteger requestCount = new AtomicInteger(0); AtomicInteger authenticatorCallCount = new AtomicInteger(0); + AtomicReference capturedRequest = new AtomicReference<>(); disposableServer = HttpServer.create() @@ -4073,7 +4090,8 @@ void testHttpAuthenticationMaxRetries() { ); // After 3 retry attempts, request should complete with 401 status - client.get() + client.doAfterRequest((req, conn) -> capturedRequest.set(req)) + .get() .uri("/protected") .responseSingle((res, bytes) -> bytes.then(Mono.just(res.status()))) .as(StepVerifier::create) @@ -4085,6 +4103,9 @@ void testHttpAuthenticationMaxRetries() { assertThat(requestCount.get()).isEqualTo(4); // Authenticator should be called 3 times (once per retry) assertThat(authenticatorCallCount.get()).isEqualTo(3); + // Authentication retry count should be 3 (reached max limit) + assertThat(capturedRequest.get()).isNotNull(); + assertThat(capturedRequest.get().authenticationRetryCount()).isEqualTo(3); } @Test From bdc344c6a1f782e94adfe5f4e596fdc458373b71 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Mon, 1 Dec 2025 23:00:14 +0900 Subject: [PATCH 26/29] Use AtomicInteger for authentication retry count to fix non-atomic volatile update Signed-off-by: raccoonback --- .../reactor/netty/http/client/HttpClientConnect.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 42186af00..30f3db173 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 @@ -26,6 +26,7 @@ import java.util.Map; import java.time.Duration; import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.BiPredicate; @@ -502,7 +503,7 @@ static final class HttpClientHandler extends SocketAddress @Nullable BiPredicate authenticationPredicate; @Nullable BiFunction> authenticator; - volatile int authenticationRetries; + AtomicInteger authenticationRetries; int maxAuthenticationRetries; HttpClientHandler(HttpClientConfig configuration) { @@ -544,6 +545,7 @@ static final class HttpClientHandler extends SocketAddress this.resourceUrl = toURI.toExternalForm(); this.authenticationPredicate = configuration.authenticationPredicate; this.authenticator = configuration.authenticator; + this.authenticationRetries = new AtomicInteger(0); this.maxAuthenticationRetries = configuration.maxAuthenticationRetries; } @@ -662,7 +664,7 @@ Publisher requestWithBody(HttpClientOperations ch) { } // Apply authenticator if needed (after REQUEST_PREPARED) - if (authenticator != null && authenticationRetries > 0) { + if (authenticator != null && authenticationRetries.get() > 0) { return authenticator.apply(ch, ch.address()) .then(Mono.defer(() -> Mono.from(result))); } @@ -738,7 +740,7 @@ void channel(HttpClientOperations ops) { if (redirectedFrom != null) { ops.redirectedFrom = redirectedFrom; } - ops.configureAuthenticationRetries(this.authenticationRetries, this.maxAuthenticationRetries); + ops.configureAuthenticationRetries(this.authenticationRetries.get(), this.maxAuthenticationRetries); } @Override @@ -753,7 +755,7 @@ public boolean test(Throwable throwable) { } if (throwable instanceof HttpClientAuthenticationException) { // Increment retry counter to trigger authenticator on retry - authenticationRetries++; + authenticationRetries.incrementAndGet(); return true; } if (shouldRetry && AbortedException.isConnectionReset(throwable)) { From 3f49b967c638f633157edfbcc51c5d2234a6d73a Mon Sep 17 00:00:00 2001 From: raccoonback Date: Mon, 1 Dec 2025 23:02:57 +0900 Subject: [PATCH 27/29] Add default authenticationRetryCount() to keep HttpClientInfos backward compatible Signed-off-by: raccoonback --- .../reactor/netty/http/client/FailedHttpClientRequest.java | 6 ------ .../java/reactor/netty/http/client/HttpClientInfos.java | 4 +++- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/FailedHttpClientRequest.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/FailedHttpClientRequest.java index dc7cc671c..db5654631 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/FailedHttpClientRequest.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/FailedHttpClientRequest.java @@ -38,7 +38,6 @@ * cannot be created. * * @author Violeta Georgieva - * @author raccoonback */ final class FailedHttpClientRequest implements HttpClientRequest { @@ -136,11 +135,6 @@ public String[] redirectedFrom() { return EMPTY; } - @Override - public int authenticationRetryCount() { - return 0; - } - @Override public HttpHeaders requestHeaders() { return headers; diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientInfos.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientInfos.java index ba955278a..6b253d751 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientInfos.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientInfos.java @@ -63,7 +63,9 @@ public interface HttpClientInfos extends HttpInfos { * @return the number of authentication retries * @since 1.3.1 */ - int authenticationRetryCount(); + default int authenticationRetryCount() { + return 0; + } /** * Return outbound headers to be sent. From 499d5d98f41ffaf3527876246b5e56932f00ca71 Mon Sep 17 00:00:00 2001 From: raccoonback Date: Tue, 2 Dec 2025 09:02:16 +0900 Subject: [PATCH 28/29] Revert to volatile int for authenticationRetries and suppress warning The AtomicInteger overhead is unnecessary for this use case. Added @SuppressWarnings annotation to suppress the non-atomic operation warning. Signed-off-by: raccoonback --- .../reactor/netty/http/client/HttpClientConnect.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 30f3db173..b60d7e6ba 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 @@ -26,7 +26,6 @@ import java.util.Map; import java.time.Duration; import java.util.Objects; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.BiPredicate; @@ -503,7 +502,7 @@ static final class HttpClientHandler extends SocketAddress @Nullable BiPredicate authenticationPredicate; @Nullable BiFunction> authenticator; - AtomicInteger authenticationRetries; + volatile int authenticationRetries; int maxAuthenticationRetries; HttpClientHandler(HttpClientConfig configuration) { @@ -545,7 +544,6 @@ static final class HttpClientHandler extends SocketAddress this.resourceUrl = toURI.toExternalForm(); this.authenticationPredicate = configuration.authenticationPredicate; this.authenticator = configuration.authenticator; - this.authenticationRetries = new AtomicInteger(0); this.maxAuthenticationRetries = configuration.maxAuthenticationRetries; } @@ -664,7 +662,7 @@ Publisher requestWithBody(HttpClientOperations ch) { } // Apply authenticator if needed (after REQUEST_PREPARED) - if (authenticator != null && authenticationRetries.get() > 0) { + if (authenticator != null && authenticationRetries > 0) { return authenticator.apply(ch, ch.address()) .then(Mono.defer(() -> Mono.from(result))); } @@ -740,10 +738,11 @@ void channel(HttpClientOperations ops) { if (redirectedFrom != null) { ops.redirectedFrom = redirectedFrom; } - ops.configureAuthenticationRetries(this.authenticationRetries.get(), this.maxAuthenticationRetries); + ops.configureAuthenticationRetries(this.authenticationRetries, this.maxAuthenticationRetries); } @Override + @SuppressWarnings("NonAtomicOperationOnVolatileField") public boolean test(Throwable throwable) { if (throwable instanceof RedirectClientException) { RedirectClientException re = (RedirectClientException) throwable; @@ -755,7 +754,7 @@ public boolean test(Throwable throwable) { } if (throwable instanceof HttpClientAuthenticationException) { // Increment retry counter to trigger authenticator on retry - authenticationRetries.incrementAndGet(); + authenticationRetries++; return true; } if (shouldRetry && AbortedException.isConnectionReset(throwable)) { From 9183702c191ee0a6c391d15fcbab6d19a8c76e3b Mon Sep 17 00:00:00 2001 From: raccoonback Date: Tue, 2 Dec 2025 22:54:47 +0900 Subject: [PATCH 29/29] Add documentation for authenticationRetryCount() API Document the authenticationRetryCount() method in the HTTP Authentication section, including usage examples and best practices for tracking authentication retry attempts. Signed-off-by: raccoonback --- docs/modules/ROOT/pages/http-client.adoc | 38 ++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc index 8d6b5b9c0..a64efcba7 100644 --- a/docs/modules/ROOT/pages/http-client.adoc +++ b/docs/modules/ROOT/pages/http-client.adoc @@ -894,6 +894,44 @@ HttpClient client = HttpClient.create() ---- <1> Custom predicate checks for `407 Proxy Authentication Required` status code. +=== Accessing Authentication Retry Count + +During the authentication process, you may need to track how many times authentication has been retried for a given request. +The {javadoc}/reactor/netty/http/client/HttpClientInfos.html#authenticationRetryCount--[`authenticationRetryCount()`] method +on {javadoc}/reactor/netty/http/client/HttpClientInfos.html[`HttpClientInfos`] provides this information. + +This is useful for: + +* Implementing custom retry logic or backoff strategies +* Logging and debugging authentication flows +* Applying different authentication strategies based on retry count +* Preventing infinite retry loops in custom authentication implementations + +==== Example: Logging Authentication Retries + +[source,java] +---- +HttpClient client = HttpClient.create() + .httpAuthenticationWhen( + (req, res) -> res.status().code() == 401, + (req, addr) -> { + int retryCount = req.authenticationRetryCount(); // <1> + log.info("Authentication retry attempt: {}", retryCount); + + String token = generateToken(retryCount); // <2> + req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token); + return Mono.empty(); + }, + 3 // <3> + ); +---- +<1> Get the current authentication retry count. +<2> Use the retry count to implement custom logic (e.g., different token generation). +<3> Configure maximum authentication retry attempts to 3. + +NOTE: The retry count starts at `0` for the first authentication attempt and increments by 1 for each subsequent retry. +When the retry count reaches the configured `maxRetries`, authentication will fail if still unsuccessful. + === Important Notes * The authenticator function is invoked only when the predicate returns `true` (indicating authentication is needed).