Skip to content

Commit 4ceefa0

Browse files
authored
Add HTTP authentication mechanism with automatic retry (#3813)
Introduce a flexible mechanism for handling HTTP authentication in HttpClient with automatic retry support when authentication is required: - Automatic retry on authentication challenges (e.g., 401 Unauthorized) - Support for sync (httpAuthentication) and async (httpAuthenticationWhen) flows - Configurable maximum retry attempts per request - Authentication retry count tracking via HttpClientRequest/Response Fixes #3079 Signed-off-by: raccoonback <[email protected]>
1 parent 24e7a4d commit 4ceefa0

File tree

14 files changed

+1293
-44
lines changed

14 files changed

+1293
-44
lines changed

docs/modules/ROOT/pages/http-client.adoc

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,3 +780,166 @@ To customize the default settings, you can configure `HttpClient` as follows:
780780
include::{examples-dir}/resolver/Application.java[lines=18..39]
781781
----
782782
<1> The timeout of each DNS query performed by this resolver will be 500ms.
783+
784+
[[http-authentication]]
785+
== HTTP Authentication
786+
Reactor Netty `HttpClient` provides a flexible HTTP authentication framework that allows you to implement
787+
custom authentication mechanisms such as SPNEGO/Negotiate, OAuth, Bearer tokens, or any other HTTP-based authentication scheme.
788+
789+
The framework provides two APIs for HTTP authentication:
790+
791+
* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiPredicate-java.util.function.BiConsumer-[`httpAuthentication(BiPredicate, BiConsumer)`] -
792+
For authentication where credentials can be computed immediately without delay. The predicate determines when to retry with authentication. Defaults to 1 maximum retry attempt.
793+
* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiPredicate-java.util.function.BiConsumer-int-[`httpAuthentication(BiPredicate, BiConsumer, int maxRetries)`] -
794+
Same as above but allows configuring the maximum count of retry attempts.
795+
* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthenticationWhen-java.util.function.BiPredicate-java.util.function.BiFunction-[`httpAuthenticationWhen(BiPredicate, BiFunction)`] -
796+
For authentication where credentials need to be fetched from external sources or require delayed computation. Returns a `Mono<Void>` to support deferred credential retrieval. Defaults to 1 maximum retry attempt.
797+
* {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthenticationWhen-java.util.function.BiPredicate-java.util.function.BiFunction-int-[`httpAuthenticationWhen(BiPredicate, BiFunction, int maxRetries)`] -
798+
Same as above but allows configuring the maximum count of retry attempts.
799+
800+
This approach gives you complete control over the authentication flow while Reactor Netty handles the retry mechanism.
801+
802+
=== How It Works
803+
804+
The typical HTTP authentication flow works as follows:
805+
806+
. The client sends an HTTP request to a protected resource.
807+
. The server responds with an authentication challenge (e.g., `401 Unauthorized` with a `WWW-Authenticate` header).
808+
. The authenticator function is invoked to add authentication credentials to the request.
809+
. The request is retried with the authentication credentials.
810+
. If authentication is successful, the server returns the requested resource.
811+
812+
=== Immediate Authentication with httpAuthentication
813+
814+
For authentication scenarios where credentials can be computed immediately without needing to fetch them from external sources,
815+
use {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthentication-java.util.function.BiPredicate-java.util.function.BiConsumer-[`httpAuthentication(BiPredicate, BiConsumer)`].
816+
This method requires both a predicate to determine when to retry and a consumer to add authentication headers.
817+
818+
==== Token-Based Authentication Example
819+
820+
The following example demonstrates how to implement Bearer token authentication with configurable retry count:
821+
822+
{examples-link}/authentication/token/Application.java
823+
[%unbreakable]
824+
----
825+
include::{examples-dir}/authentication/token/Application.java[lines=18..51]
826+
----
827+
<1> The predicate checks for `401 Unauthorized` responses to trigger authentication retry.
828+
<2> The authenticator adds the `Authorization` header with a Bearer token.
829+
<3> Configures the maximum count of authentication retry attempts to 3.
830+
831+
==== Basic Authentication Example
832+
833+
{examples-link}/authentication/basic/Application.java
834+
[%unbreakable]
835+
----
836+
include::{examples-dir}/authentication/basic/Application.java[lines=18..44]
837+
----
838+
<1> The predicate checks for `401 Unauthorized` responses to trigger authentication retry.
839+
<2> The authenticator adds Basic authentication credentials to the `Authorization` header.
840+
<3> Uses the default maximum retry count of 1.
841+
842+
=== Custom Authentication with httpAuthenticationWhen
843+
844+
When you need custom retry conditions (e.g., checking specific headers or status codes other than 401),
845+
use the {javadoc}/reactor/netty/http/client/HttpClient.html#httpAuthenticationWhen-java.util.function.BiPredicate-java.util.function.BiFunction-[`httpAuthenticationWhen(BiPredicate, BiFunction)`] method.
846+
847+
==== SPNEGO/Negotiate Authentication Example
848+
849+
For SPNEGO (Kerberos) authentication, you can implement a custom authenticator using Java's GSS-API:
850+
851+
{examples-link}/authentication/spnego/Application.java
852+
[%unbreakable]
853+
----
854+
include::{examples-dir}/authentication/spnego/Application.java[lines=18..70]
855+
----
856+
<1> Custom predicate checks for `401 Unauthorized` with `WWW-Authenticate: Negotiate` header.
857+
<2> The authenticator generates a SPNEGO token using GSS-API and adds it to the `Authorization` header.
858+
<3> Configures the maximum count of authentication retry attempts to 2.
859+
860+
NOTE: For SPNEGO authentication, you need to configure Kerberos settings (e.g., `krb5.conf`) and JAAS configuration
861+
(e.g., `jaas.conf`) appropriately. Set the system properties `java.security.krb5.conf` and `java.security.auth.login.config`
862+
to point to your configuration files.
863+
864+
=== Custom Authentication Scenarios
865+
866+
The authentication framework is flexible enough to support various authentication scenarios:
867+
868+
==== OAuth 2.0 Authentication
869+
[source,java]
870+
----
871+
HttpClient client = HttpClient.create()
872+
.httpAuthenticationWhen(
873+
(req, res) -> res.status().code() == 401, // <1>
874+
(req, addr) -> {
875+
return fetchOAuthToken() // <2>
876+
.doOnNext(token ->
877+
req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token))
878+
.then();
879+
}
880+
);
881+
----
882+
<1> Retry authentication on `401 Unauthorized` responses.
883+
<2> Asynchronously fetch an OAuth token and add it to the request.
884+
885+
==== Proxy Authentication
886+
[source,java]
887+
----
888+
HttpClient client = HttpClient.create()
889+
.httpAuthenticationWhen(
890+
(req, res) -> res.status().code() == 407, // <1>
891+
(req, addr) -> {
892+
String proxyCredentials = generateProxyCredentials();
893+
req.header("Proxy-Authorization", "Bearer " + proxyCredentials);
894+
return Mono.empty();
895+
}
896+
);
897+
----
898+
<1> Custom predicate checks for `407 Proxy Authentication Required` status code.
899+
900+
=== Accessing Authentication Retry Count
901+
902+
During the authentication process, you may need to track how many times authentication has been retried for a given request.
903+
The {javadoc}/reactor/netty/http/client/HttpClientInfos.html#authenticationRetryCount--[`authenticationRetryCount()`] method
904+
on {javadoc}/reactor/netty/http/client/HttpClientInfos.html[`HttpClientInfos`] provides this information.
905+
906+
This is useful for:
907+
908+
* Implementing custom retry logic or backoff strategies
909+
* Logging and debugging authentication flows
910+
* Applying different authentication strategies based on retry count
911+
* Preventing infinite retry loops in custom authentication implementations
912+
913+
==== Example: Logging Authentication Retries
914+
915+
[source,java]
916+
----
917+
HttpClient client = HttpClient.create()
918+
.httpAuthenticationWhen(
919+
(req, res) -> res.status().code() == 401,
920+
(req, addr) -> {
921+
int retryCount = req.authenticationRetryCount(); // <1>
922+
log.info("Authentication retry attempt: {}", retryCount);
923+
924+
String token = generateToken(retryCount); // <2>
925+
req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);
926+
return Mono.empty();
927+
},
928+
3 // <3>
929+
);
930+
----
931+
<1> Get the current authentication retry count.
932+
<2> Use the retry count to implement custom logic (e.g., different token generation).
933+
<3> Configure maximum authentication retry attempts to 3.
934+
935+
NOTE: The retry count starts at `0` for the first authentication attempt and increments by 1 for each subsequent retry.
936+
When the retry count reaches the configured `maxRetries`, authentication will fail if still unsuccessful.
937+
938+
=== Important Notes
939+
940+
* The authenticator function is invoked only when the predicate returns `true` (indicating authentication is needed).
941+
* For `httpAuthentication`, use a `BiConsumer` when credentials can be computed immediately without delay. No `Mono` is needed as headers are added directly.
942+
* For `httpAuthenticationWhen`, use a `BiFunction` that returns `Mono<Void>` when credentials need to be fetched from external sources or require delayed computation.
943+
* The authenticator receives the request and remote address, allowing you to customize authentication based on the target server.
944+
* 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.
945+
* For security reasons, ensure that sensitive credentials are not logged or exposed in error messages.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package reactor.netty.examples.documentation.http.client.authentication.basic;
17+
18+
import io.netty.handler.codec.http.HttpHeaderNames;
19+
import reactor.netty.http.client.HttpClient;
20+
21+
import java.nio.charset.StandardCharsets;
22+
import java.util.Base64;
23+
24+
public class Application {
25+
26+
public static void main(String[] args) {
27+
HttpClient client =
28+
HttpClient.create()
29+
.httpAuthentication(
30+
(req, res) -> res.status().code() == 401, // <1>
31+
(req, addr) -> { // <2>
32+
String credentials = "username:password";
33+
String encodedCredentials = Base64.getEncoder()
34+
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
35+
req.header(HttpHeaderNames.AUTHORIZATION, "Basic " + encodedCredentials);
36+
} // <3>
37+
);
38+
39+
client.get()
40+
.uri("https://example.com/")
41+
.response()
42+
.block();
43+
}
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package reactor.netty.examples.documentation.http.client.authentication.spnego;
17+
18+
import io.netty.handler.codec.http.HttpHeaderNames;
19+
import org.ietf.jgss.GSSContext;
20+
import org.ietf.jgss.GSSException;
21+
import org.ietf.jgss.GSSManager;
22+
import org.ietf.jgss.GSSName;
23+
import org.ietf.jgss.Oid;
24+
import reactor.core.publisher.Mono;
25+
import reactor.netty.http.client.HttpClient;
26+
27+
import java.net.InetSocketAddress;
28+
import java.util.Base64;
29+
30+
public class Application {
31+
32+
public static void main(String[] args) {
33+
HttpClient client =
34+
HttpClient.create()
35+
.httpAuthenticationWhen(
36+
(req, res) -> res.status().code() == 401 && // <1>
37+
res.responseHeaders().contains("WWW-Authenticate", "Negotiate", true),
38+
(req, addr) -> { // <2>
39+
try {
40+
GSSManager manager = GSSManager.getInstance();
41+
String hostName = ((InetSocketAddress) addr).getHostString();
42+
String serviceName = "HTTP@" + hostName;
43+
GSSName serverName = manager.createName(serviceName, GSSName.NT_HOSTBASED_SERVICE);
44+
45+
Oid krb5Mechanism = new Oid("1.2.840.113554.1.2.2");
46+
GSSContext context = manager.createContext(
47+
serverName, krb5Mechanism, null, GSSContext.DEFAULT_LIFETIME);
48+
49+
byte[] token = context.initSecContext(new byte[0], 0, 0);
50+
String encodedToken = Base64.getEncoder().encodeToString(token);
51+
52+
req.header(HttpHeaderNames.AUTHORIZATION, "Negotiate " + encodedToken);
53+
54+
context.dispose();
55+
}
56+
catch (GSSException e) {
57+
return Mono.error(new RuntimeException(
58+
"Failed to generate SPNEGO token", e));
59+
}
60+
return Mono.empty();
61+
},
62+
2 // <3>
63+
);
64+
65+
client.get()
66+
.uri("https://example.com/")
67+
.response()
68+
.block();
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package reactor.netty.examples.documentation.http.client.authentication.token;
17+
18+
import io.netty.handler.codec.http.HttpHeaderNames;
19+
import reactor.netty.http.client.HttpClient;
20+
21+
import java.net.SocketAddress;
22+
23+
public class Application {
24+
25+
public static void main(String[] args) {
26+
HttpClient client =
27+
HttpClient.create()
28+
.httpAuthentication(
29+
(req, res) -> res.status().code() == 401, // <1>
30+
(req, addr) -> { // <2>
31+
String token = generateAuthToken(addr);
32+
req.header(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);
33+
},
34+
3 // <3>
35+
);
36+
37+
client.get()
38+
.uri("https://example.com/")
39+
.response()
40+
.block();
41+
}
42+
43+
/**
44+
* Generates an authentication token for the given remote address.
45+
* In a real application, this would retrieve or generate a valid token.
46+
*/
47+
static String generateAuthToken(SocketAddress remoteAddress) {
48+
// In a real application, implement token generation/retrieval logic
49+
return "sample-token-123";
50+
}
51+
}

reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ else if (msg instanceof HttpResponse) {
111111

112112
setNettyResponse(response);
113113

114-
if (notRedirected(response)) {
114+
if (notRedirected(response) && authenticationNotRequired()) {
115115
try {
116116
HttpResponseStatus status = response.status();
117117
if (!HttpResponseStatus.OK.equals(status)) {
@@ -131,9 +131,12 @@ else if (msg instanceof HttpResponse) {
131131
}
132132
}
133133
else {
134-
// Deliberately suppress "NullAway"
135-
// redirecting != null in this case
136-
listener().onUncaughtException(this, redirecting);
134+
if (redirecting != null) {
135+
listener().onUncaughtException(this, redirecting);
136+
}
137+
else if (authenticating != null) {
138+
listener().onUncaughtException(this, authenticating);
139+
}
137140
}
138141
}
139142
else {

0 commit comments

Comments
 (0)