From 4ddffb0b9c5320c98f1dcaa3320c837b226edc77 Mon Sep 17 00:00:00 2001
From: raccoonback
+ * This authenticator performs a JAAS login using the specified context name and returns the authenticated Subject.
+ *
+ * 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:
+ * This overload is intended for testing or advanced scenarios where a custom GSSManager is needed.
+ *
+ * This method generates a SPNEGO token for the specified address and attaches it
+ * as an Authorization header to the outgoing HTTP request.
+ *
+ * This method uses the GSSManager to create a GSSContext and generate a SPNEGO token
+ * for the specified service principal (HTTP/hostName).
+ *
+ * Implementations are responsible for performing a JAAS login and returning a logged-in Subject.
+ *
+ * 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.
+ *
+ * This method checks both the status code and the WWW-Authenticate header to determine
+ * if a new SPNEGO token needs to be generated.
+ *
- * 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.
*
+ * 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.
+ *
- * 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.
- *
+ * 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:
+ * This method uses the pre-existing GSSCredential to create a GSSContext for SPNEGO
+ * authentication. The service principal name is constructed as serviceName/remoteHost.
+ *
- * 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:
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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+ * 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+ * 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- * 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+ * 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- * 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- * 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- * 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- * 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- * 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* 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 super HttpClientRequest, ? super SocketAddress, ? extends Mono+ * 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 super HttpClientRequest, ? super SocketAddress, ? extends Mono> 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 super HttpClientRequest, ? super SocketAddress, ? extends Mono> authenticator) {
- return httpAuthenticationWhen(HttpClientConfig.AUTHENTICATION_PREDICATE, authenticator);
+ BiPredicate predicate,
+ BiConsumer super HttpClientRequest, ? super SocketAddress> 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.
+ *
+ * - The initial request is sent without authentication
+ * - If the response matches the {@code predicate} (e.g., 401 Unauthorized), the request is retried
+ * - Before retry, the {@code authenticator} adds credentials to the request
+ *
+ *
+ *
+ * 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:
+ *
+ *
+ * - The initial request is sent without authentication
+ * - If the response matches the {@code predicate} (e.g., 401 Unauthorized), the request is retried
+ * - Before retry, the {@code authenticator} asynchronously adds credentials to the request
+ *
+ *
*
- * 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 super HttpClientRequest, ? super HttpClientResponse> predicate,
BiConsumer super HttpClientRequest, ? super SocketAddress> 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 super HttpClientRequest, ? super HttpClientResponse> predicate,
BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> 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 super HttpClientRequest, ? super HttpClientResponse> authenticationPredicate;
@Nullable BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> 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 super HttpClientRequest, ? super HttpClientResponse> authenticationPredicate;
@Nullable BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> 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 super HttpClientRequest, ? super HttpClientResponse> authenticationPredicate;
@Nullable Consumer redirectRequestConsumer;
@Nullable HttpHeaders previousRequestHeaders;
@Nullable BiConsumer redirectRequestBiConsumer;
@@ -346,7 +346,7 @@ void followRedirectPredicate(@Nullable BiPredicate predicate) {
+ void authenticationPredicate(@Nullable BiPredicate super HttpClientRequest, ? super HttpClientResponse> 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 super HttpClientRequest, ? super HttpClientResponse> predicate,
BiConsumer super HttpClientRequest, ? super SocketAddress> 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:
+ *
+ *
+ * - The initial request is sent without authentication
+ * - If the response matches the {@code predicate} (e.g., 401 Unauthorized), the request is retried
+ * - Before retry, the {@code authenticator} adds credentials to the request
+ * - Retries continue until authentication succeeds or {@code maxRetries} is reached
+ *
+ *
+ *
+ * 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 super HttpClientRequest, ? super HttpClientResponse> predicate,
+ BiConsumer super HttpClientRequest, ? super SocketAddress> 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 super HttpClientRequest, ? super HttpClientResponse> predicate,
BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> 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:
+ *
+ *
+ * - The initial request is sent without authentication
+ * - If the response matches the {@code predicate} (e.g., 401 Unauthorized), the request is retried
+ * - Before retry, the {@code authenticator} asynchronously adds credentials to the request
+ * - Retries continue until authentication succeeds or {@code maxRetries} is reached
+ *
+ *
+ *
+ * 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 super HttpClientRequest, ? super HttpClientResponse> predicate,
+ BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> 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 super HttpClientRequest, ? super HttpClientResponse> authenticationPredicate;
@Nullable BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> authenticator;
+ int maxAuthenticationRetries;
HttpClientConfig(HttpConnectionProvider connectionProvider, Map, ?> options,
Supplier extends SocketAddress> 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 super HttpClientRequest, ? super HttpClientResponse> authenticationPredicate;
@Nullable BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> 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 super HttpClientRequest, ?
this.authenticationPredicate = predicate;
}
+ void configureAuthenticationRetries(int authenticationRetries, int maxAuthenticationRetries) {
+ this.authenticationRetries = authenticationRetries;
+ this.maxAuthenticationRetries = maxAuthenticationRetries;
+ }
+
void redirectRequestConsumer(@Nullable Consumer 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 super HttpClientRequest, ? super HttpClientResponse> authenticationPredicate;
@Nullable BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> 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 super HttpClientRequest, ? super HttpClientResponse> authenticationPredicate;
@Nullable BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> 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 super HttpClientRequest, ? super HttpClientResponse> authenticationPredicate;
@Nullable BiFunction super HttpClientRequest, ? super SocketAddress, ? extends Mono> 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).