Skip to content

Commit 0dc9087

Browse files
committed
Improve SPNEGO authentication retry mechanism
Signed-off-by: raccoonback <[email protected]>
1 parent 9a0b4a7 commit 0dc9087

File tree

6 files changed

+445
-71
lines changed

6 files changed

+445
-71
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ include::{examples-dir}/resolver/Application.java[lines=18..39]
744744
<1> The timeout of each DNS query performed by this resolver will be 500ms.
745745

746746
[[http-client-spnego]]
747-
=== SPNEGO (Kerberos) Authentication
747+
== SPNEGO Authentication
748748
Reactor Netty HttpClient supports SPNEGO (Kerberos) authentication, which is widely used in enterprise environments.
749749
SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) provides secure authentication over HTTP using Kerberos.
750750

@@ -765,7 +765,7 @@ include::{examples-dir}/spnego/Application.java[lines=18..39]
765765
<2> Configures the `krb5.conf`. krb5.conf is a Kerberos client configuration file used to define how the client locates and communicates with the Kerberos Key Distribution Center (KDC) for authentication.
766766
<3> Configures the SPNEGO jaas.conf. A JVM system property that enables detailed debug logging for Kerberos operations in Java.
767767
<4> `JaasAuthenticator` performs Kerberos authentication using a JAAS configuration (jaas.conf).
768-
<5> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket and automatically adds the `Authorization: Negotiate ...` header to HTTP requests.
768+
<5> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket and automatically adds the `Authorization: Negotiate ...` header to HTTP requests. If the server responds with `401 Unauthorized` and includes `WWW-Authenticate: Negotiate`, the client will automatically reauthenticate and retry the request once.
769769

770770
==== Environment Configuration
771771
===== Example JAAS Configuration

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

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -448,11 +448,12 @@ public void onStateChange(Connection connection, State newState) {
448448
if (newState == HttpClientState.RESPONSE_RECEIVED) {
449449
HttpClientOperations operations = connection.as(HttpClientOperations.class);
450450
if (operations != null && handler.spnegoAuthProvider != null) {
451-
int statusCode = operations.status().code();
452-
HttpHeaders headers = operations.responseHeaders();
453-
if (handler.spnegoAuthProvider.isUnauthorized(statusCode, headers)) {
454-
handler.spnegoAuthProvider.invalidateCache();
451+
if (shouldRetryWithSpnego(operations)) {
452+
retryWithSpnego(operations);
453+
return;
455454
}
455+
456+
handler.spnegoAuthProvider.resetRetryCount();
456457
}
457458

458459
sink.success(connection);
@@ -468,6 +469,42 @@ public void onStateChange(Connection connection, State newState) {
468469
.subscribe(connection.disposeSubscriber());
469470
}
470471
}
472+
473+
/**
474+
* Determines if the current HTTP response requires a SPNEGO authentication retry.
475+
*
476+
* @param operations the HTTP client operations containing the response status and headers
477+
* @return {@code true} if SPNEGO re-authentication should be attempted, {@code false} otherwise
478+
*/
479+
private boolean shouldRetryWithSpnego(HttpClientOperations operations) {
480+
int statusCode = operations.status().code();
481+
HttpHeaders headers = operations.responseHeaders();
482+
483+
return handler.spnegoAuthProvider.isUnauthorized(statusCode, headers)
484+
&& handler.spnegoAuthProvider.canRetry();
485+
}
486+
487+
/**
488+
* Triggers a SPNEGO authentication retry by throwing a {@link SpnegoRetryException}.
489+
* <p>
490+
* The exception-based approach ensures that a completely new {@link HttpClientOperations}
491+
* instance is created, avoiding the "Status and headers already sent" error that would
492+
* occur if trying to reuse the existing connection.
493+
* </p>
494+
*
495+
* @param operations the current HTTP client operations that received the 401 response
496+
* @throws SpnegoRetryException always thrown to trigger the retry mechanism
497+
*/
498+
private void retryWithSpnego(HttpClientOperations operations) {
499+
handler.spnegoAuthProvider.invalidateTokenHeader();
500+
handler.spnegoAuthProvider.incrementRetryCount();
501+
502+
if (log.isDebugEnabled()) {
503+
log.debug(format(operations.channel(), "Triggering SPNEGO re-authentication"));
504+
}
505+
506+
sink.error(new SpnegoRetryException());
507+
}
471508
}
472509

473510
static final class HttpClientHandler extends SocketAddress
@@ -744,6 +781,9 @@ public boolean test(Throwable throwable) {
744781
redirect(re.location);
745782
return true;
746783
}
784+
if (throwable instanceof SpnegoRetryException) {
785+
return true;
786+
}
747787
if (shouldRetry && AbortedException.isConnectionReset(throwable)) {
748788
shouldRetry = false;
749789
redirect(toURI.toString());

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

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
import io.netty.handler.codec.http.HttpHeaders;
2222
import java.net.InetSocketAddress;
2323
import java.security.PrivilegedAction;
24+
import java.util.Arrays;
2425
import java.util.Base64;
26+
import java.util.concurrent.atomic.AtomicInteger;
27+
import java.util.concurrent.atomic.AtomicReference;
2528
import javax.security.auth.Subject;
2629
import javax.security.auth.login.LoginException;
2730
import org.ietf.jgss.GSSContext;
@@ -30,6 +33,8 @@
3033
import org.ietf.jgss.GSSName;
3134
import org.ietf.jgss.Oid;
3235
import reactor.core.publisher.Mono;
36+
import reactor.util.Logger;
37+
import reactor.util.Loggers;
3338

3439
/**
3540
* Provides SPNEGO authentication for Reactor Netty HttpClient.
@@ -50,14 +55,17 @@
5055
*/
5156
public final class SpnegoAuthProvider {
5257

58+
private static final Logger log = Loggers.getLogger(SpnegoAuthProvider.class);
5359
private static final String SPNEGO_HEADER = "Negotiate";
5460
private static final String STR_OID = "1.3.6.1.5.5.2";
5561

5662
private final SpnegoAuthenticator authenticator;
5763
private final GSSManager gssManager;
5864
private final int unauthorizedStatusCode;
5965

60-
private volatile String verifiedAuthHeader;
66+
private final AtomicReference<String> verifiedAuthHeader = new AtomicReference<>();
67+
private final AtomicInteger retryCount = new AtomicInteger(0);
68+
private static final int MAX_RETRY_COUNT = 1;
6169

6270
/**
6371
* Constructs a new SpnegoAuthProvider with the given authenticator and GSSManager.
@@ -110,8 +118,9 @@ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSMa
110118
* @throws SpnegoAuthenticationException if login or token generation fails
111119
*/
112120
public Mono<Void> apply(HttpClientRequest request, InetSocketAddress address) {
113-
if (verifiedAuthHeader != null) {
114-
request.header(HttpHeaderNames.AUTHORIZATION, verifiedAuthHeader);
121+
String cachedToken = verifiedAuthHeader.get();
122+
if (cachedToken != null) {
123+
request.header(HttpHeaderNames.AUTHORIZATION, cachedToken);
115124
return Mono.empty();
116125
}
117126

@@ -124,7 +133,7 @@ public Mono<Void> apply(HttpClientRequest request, InetSocketAddress address) {
124133
byte[] token = generateSpnegoToken(address.getHostName());
125134
String authHeader = SPNEGO_HEADER + " " + Base64.getEncoder().encodeToString(token);
126135

127-
verifiedAuthHeader = authHeader;
136+
verifiedAuthHeader.set(authHeader);
128137
request.header(HttpHeaderNames.AUTHORIZATION, authHeader);
129138
return token;
130139
}
@@ -154,27 +163,61 @@ public Mono<Void> apply(HttpClientRequest request, InetSocketAddress address) {
154163
* @throws GSSException if token generation fails
155164
*/
156165
private byte[] generateSpnegoToken(String hostName) throws GSSException {
157-
GSSName serverName = gssManager.createName("HTTP/" + hostName, GSSName.NT_HOSTBASED_SERVICE);
166+
if (hostName == null || hostName.trim().isEmpty()) {
167+
throw new IllegalArgumentException("Host name cannot be null or empty");
168+
}
169+
170+
GSSName serverName = gssManager.createName("HTTP/" + hostName.trim(), GSSName.NT_HOSTBASED_SERVICE);
158171
Oid spnegoOid = new Oid(STR_OID); // SPNEGO OID
159172

160-
GSSContext context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME);
173+
GSSContext context = null;
161174
try {
175+
context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME);
162176
return context.initSecContext(new byte[0], 0, 0);
163-
} finally {
164-
context.dispose();
177+
}
178+
finally {
179+
if (context != null) {
180+
try {
181+
context.dispose();
182+
}
183+
catch (GSSException e) {
184+
// Log but don't propagate disposal errors
185+
if (log.isDebugEnabled()) {
186+
log.debug("Failed to dispose GSSContext", e);
187+
}
188+
}
189+
}
165190
}
166191
}
167192

168193
/**
169194
* Invalidates the cached authentication token.
170-
* <p>
171-
* This method should be called when a response indicates that the current token
172-
* is no longer valid (typically after receiving an unauthorized status code).
173-
* The next request will generate a new authentication token.
174-
* </p>
175195
*/
176-
public void invalidateCache() {
177-
this.verifiedAuthHeader = null;
196+
public void invalidateTokenHeader() {
197+
this.verifiedAuthHeader.set(null);
198+
}
199+
200+
/**
201+
* Checks if SPNEGO authentication retry is allowed.
202+
*
203+
* @return true if retry is allowed, false otherwise
204+
*/
205+
public boolean canRetry() {
206+
return retryCount.get() < MAX_RETRY_COUNT;
207+
}
208+
209+
/**
210+
* Increments the retry count for SPNEGO authentication attempts.
211+
*/
212+
public void incrementRetryCount() {
213+
retryCount.incrementAndGet();
214+
}
215+
216+
/**
217+
* Resets the retry count for SPNEGO authentication.
218+
*/
219+
public void resetRetryCount() {
220+
retryCount.set(0);
178221
}
179222

180223
/**
@@ -194,6 +237,13 @@ public boolean isUnauthorized(int status, HttpHeaders headers) {
194237
}
195238

196239
String header = headers.get(HttpHeaderNames.WWW_AUTHENTICATE);
197-
return header != null && header.startsWith(SPNEGO_HEADER);
240+
if (header == null) {
241+
return false;
242+
}
243+
244+
// More robust parsing - handle multiple comma-separated authentication schemes
245+
return Arrays.stream(header.split(","))
246+
.map(String::trim)
247+
.anyMatch(auth -> auth.toLowerCase().startsWith(SPNEGO_HEADER.toLowerCase()));
198248
}
199249
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
package reactor.netty.http.client;
1717

1818
/**
19-
* Exception thrown when SPNEGO (Kerberos) authentication fails.
19+
* Exception thrown when SPNEGO authentication fails.
2020
*
2121
* @author raccoonback
2222
* @since 1.3.0
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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.http.client;
17+
18+
/**
19+
* Exception thrown to trigger a retry when SPNEGO authentication fails with a 401 Unauthorized response.
20+
*
21+
* @author raccoonback
22+
* @since 1.3.0
23+
*/
24+
final class SpnegoRetryException extends RuntimeException {
25+
26+
SpnegoRetryException() {
27+
super("SPNEGO authentication requires retry");
28+
}
29+
}

0 commit comments

Comments
 (0)