From cf051f80526746e5621ef04f30993ecc48a4c914 Mon Sep 17 00:00:00 2001
From: raccoonback
Date: Mon, 23 Jun 2025 11:40:42 +0900
Subject: [PATCH 1/6] Support SPNEGO (Kerberos) authentication
Signed-off-by: raccoonback
---
docs/modules/ROOT/pages/http-client.adoc | 70 +++++++++
.../http/client/spnego/Application.java | 39 +++++
.../reactor/netty/http/client/HttpClient.java | 14 ++
.../netty/http/client/HttpClientConfig.java | 2 +
.../netty/http/client/HttpClientConnect.java | 16 ++
.../netty/http/client/JaasAuthenticator.java | 56 +++++++
.../netty/http/client/SpnegoAuthProvider.java | 146 ++++++++++++++++++
.../http/client/SpnegoAuthenticator.java | 39 +++++
.../http/client/SpnegoAuthProviderTest.java | 110 +++++++++++++
9 files changed, 492 insertions(+)
create mode 100644 reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java
create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java
create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java
create mode 100644 reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java
diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc
index 9eb4125778..ac96c3e6f9 100644
--- a/docs/modules/ROOT/pages/http-client.adoc
+++ b/docs/modules/ROOT/pages/http-client.adoc
@@ -742,3 +742,73 @@ To customize the default settings, you can configure `HttpClient` as follows:
include::{examples-dir}/resolver/Application.java[lines=18..39]
----
<1> The timeout of each DNS query performed by this resolver will be 500ms.
+
+[[http-client-spnego]]
+=== SPNEGO (Kerberos) Authentication
+Reactor Netty HttpClient supports SPNEGO (Kerberos) authentication, which is widely used in enterprise environments.
+SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) provides secure authentication over HTTP using Kerberos.
+
+==== How It Works
+SPNEGO authentication follows this HTTP authentication flow:
+1. The client sends an HTTP request to a protected resource.
+2. The server responds with `401 Unauthorized` and a `WWW-Authenticate: Negotiate` header.
+3. The client generates a SPNEGO token based on its Kerberos ticket, and resends the request with an `Authorization: Negotiate ` header.
+4. The server validates the token and, if authentication is successful, returns 200 OK.
+
+If further negotiation is required, the server may return another 401 with additional data in the WWW-Authenticate header.
+
+{examples-link}/spnego/Application.java
+----
+include::{examples-dir}/spnego/Application.java[lines=18..39]
+----
+<1> Configures the `jaas.conf`. A JAAS configuration file in Java for integrating with authentication backends such as Kerberos.
+<2> Configures the `krb5.conf`. krb5.conf is a Kerberos client configuration file used to define how the client locates and communicates with the Kerberos Key Distribution Center (KDC) for authentication.
+<3> Configures the SPNEGO jaas.conf. A JVM system property that enables detailed debug logging for Kerberos operations in Java.
+<4> `JaasAuthenticator` performs Kerberos authentication using a JAAS configuration (jaas.conf).
+<5> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket and automatically adds the `Authorization: Negotiate ...` header to HTTP requests.
+
+==== Environment Configuration
+===== Example JAAS Configuration
+Specify the path to your JAAS configuration file using the `java.security.auth.login.config` system property.
+
+.`jaas.conf`
+[jaas,conf]
+----
+KerberosLogin {
+ com.sun.security.auth.module.Krb5LoginModule required
+ client=true
+ useKeyTab=true
+ keyTab="/path/to/test.keytab"
+ principal="test@EXAMPLE.COM"
+ doNotPrompt=true
+ debug=true;
+};
+----
+
+===== Example Kerberos Configuration
+Specify Kerberos realm and KDC information using the `java.security.krb5.conf` system property.
+
+.`krb5.conf`
+[krb5,conf]
+----
+[libdefaults]
+ default_realm = EXAMPLE.COM
+[realms]
+ EXAMPLE.COM = {
+ kdc = kdc.example.com
+ }
+[domain_realms]
+ .example.com = EXAMPLE.COM
+ example.com = EXAMPLE.COM
+----
+
+===== Configuration Example
+[jvm option]
+----
+-Djava.security.auth.login.config=/path/to/login.conf
+-Djava.security.krb5.conf=/path/to/krb5.conf
+----
+
+==== Notes
+- SPNEGO authentication is fully supported on Java 1.6 and above.
+- If authentication fails, check the server logs and client exception messages, and verify your Kerberos environment settings (realm, KDC, ticket, etc.).
\ No newline at end of file
diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java
new file mode 100644
index 0000000000..334dfeafbc
--- /dev/null
+++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package reactor.netty.examples.documentation.http.client.spnego;
+
+import reactor.netty.http.client.HttpClient;
+import reactor.netty.http.client.JaasAuthenticator;
+import reactor.netty.http.client.SpnegoAuthProvider;
+import reactor.netty.http.client.SpnegoAuthenticator;
+
+public class Application {
+
+ public static void main(String[] args) {
+ System.setProperty("java.security.auth.login.config", "/path/to/jaas.conf"); // <1>
+ System.setProperty("java.security.krb5.conf", "/path/to/krb5.conf"); // <2>
+ System.setProperty("sun.security.krb5.debug", "true"); // <3>
+
+ SpnegoAuthenticator authenticator = new JaasAuthenticator("KerberosLogin"); // <4>
+ HttpClient client = HttpClient.create()
+ .spnego(SpnegoAuthProvider.create(authenticator)); // <5>
+
+ client.get()
+ .uri("http://protected.example.com/")
+ .responseSingle((res, content) -> content.asString())
+ .block();
+ }
+}
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClient.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClient.java
index e5b5f7527a..ea1e4dbfec 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
@@ -1698,6 +1698,20 @@ public final HttpClient wiretap(boolean enable) {
return super.wiretap(enable);
}
+ /**
+ * Configure SPNEGO authentication for the HTTP client.
+ *
+ * @param spnegoAuthProvider the SPNEGO authentication provider
+ * @return a new {@link HttpClient}
+ * @since 1.3.0
+ */
+ public final HttpClient spnego(SpnegoAuthProvider spnegoAuthProvider) {
+ Objects.requireNonNull(spnegoAuthProvider, "spnegoAuthProvider");
+ HttpClient dup = duplicate();
+ dup.configuration().spnegoAuthProvider = spnegoAuthProvider;
+ return dup;
+ }
+
static boolean isCompressing(HttpHeaders h) {
return h.contains(HttpHeaderNames.ACCEPT_ENCODING, HttpHeaderValues.GZIP, true)
|| h.contains(HttpHeaderNames.ACCEPT_ENCODING, HttpHeaderValues.BR, true);
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java
index e92e7da29b..05ac90e1c0 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
@@ -378,6 +378,7 @@ public WebsocketClientSpec websocketClientSpec() {
String uriStr;
Function uriTagValue;
WebsocketClientSpec websocketClientSpec;
+ SpnegoAuthProvider spnegoAuthProvider;
HttpClientConfig(HttpConnectionProvider connectionProvider, Map, ?> options,
Supplier extends SocketAddress> remoteAddress) {
@@ -430,6 +431,7 @@ public WebsocketClientSpec websocketClientSpec() {
this.uriStr = parent.uriStr;
this.uriTagValue = parent.uriTagValue;
this.websocketClientSpec = parent.websocketClientSpec;
+ this.spnegoAuthProvider = parent.spnegoAuthProvider;
}
@Override
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java
index b0563c3e1d..34a4fec8e3 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
@@ -489,6 +489,8 @@ static final class HttpClientHandler extends SocketAddress
volatile boolean shouldRetry;
volatile HttpHeaders previousRequestHeaders;
+ SpnegoAuthProvider spnegoAuthProvider;
+
HttpClientHandler(HttpClientConfig configuration) {
this.method = configuration.method;
this.followRedirectPredicate = configuration.followRedirectPredicate;
@@ -526,6 +528,7 @@ static final class HttpClientHandler extends SocketAddress
this.fromURI = this.toURI = uriEndpointFactory.createUriEndpoint(configuration.uri, configuration.websocketClientSpec != null);
}
this.resourceUrl = toURI.toExternalForm();
+ this.spnegoAuthProvider = configuration.spnegoAuthProvider;
}
@Override
@@ -540,6 +543,19 @@ public SocketAddress get() {
@SuppressWarnings("ReferenceEquality")
Publisher requestWithBody(HttpClientOperations ch) {
+ if (spnegoAuthProvider != null) {
+ return spnegoAuthProvider.apply(ch, ch.address())
+ .then(
+ Mono.defer(
+ () -> Mono.from(requestWithBodyInternal(ch))
+ )
+ );
+ }
+
+ return requestWithBodyInternal(ch);
+ }
+
+ private Publisher requestWithBodyInternal(HttpClientOperations ch) {
try {
ch.resourceUrl = this.resourceUrl;
ch.responseTimeout = responseTimeout;
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java
new file mode 100644
index 0000000000..6fd7ae7491
--- /dev/null
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package reactor.netty.http.client;
+
+import javax.security.auth.Subject;
+import javax.security.auth.login.LoginContext;
+import javax.security.auth.login.LoginException;
+
+/**
+ * A JAAS-based Authenticator implementation for use with SPNEGO providers.
+ *
+ * This authenticator performs a JAAS login using the specified context name and returns the authenticated Subject.
+ *
+ *
+ * @author raccoonback
+ * @since 1.3.0
+ */
+public class JaasAuthenticator implements SpnegoAuthenticator {
+
+ private final String contextName;
+
+ /**
+ * Creates a new JaasAuthenticator with the given context name.
+ *
+ * @param contextName the JAAS login context name
+ */
+ public JaasAuthenticator(String contextName) {
+ this.contextName = contextName;
+ }
+
+ /**
+ * Performs a JAAS login using the configured context name and returns the authenticated Subject.
+ *
+ * @return the authenticated JAAS Subject
+ * @throws LoginException if login fails
+ */
+ @Override
+ public Subject login() throws LoginException {
+ LoginContext context = new LoginContext(contextName);
+ context.login();
+ return context.getSubject();
+ }
+}
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
new file mode 100644
index 0000000000..04cfb1b07b
--- /dev/null
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package reactor.netty.http.client;
+
+import static reactor.core.scheduler.Schedulers.boundedElastic;
+
+import io.netty.handler.codec.http.HttpHeaderNames;
+import java.net.InetSocketAddress;
+import java.security.PrivilegedAction;
+import java.util.Base64;
+import javax.security.auth.Subject;
+import javax.security.auth.login.LoginException;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSException;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.GSSName;
+import org.ietf.jgss.Oid;
+import reactor.core.publisher.Mono;
+
+/**
+ * Provides SPNEGO authentication for Reactor Netty HttpClient.
+ *
+ * This provider is responsible for generating and attaching a SPNEGO (Kerberos) token
+ * to the HTTP Authorization header for outgoing requests, enabling single sign-on and
+ * secure authentication in enterprise environments.
+ *
+ *
+ * Typical usage:
+ *
+ * HttpClient client = HttpClient.create()
+ * .spnego(SpnegoAuthProvider.create(new JaasAuthenticator("KerberosLogin")));
+ *
+ *
+ * @author raccoonback
+ * @since 1.3.0
+ */
+public final class SpnegoAuthProvider {
+
+ private final SpnegoAuthenticator authenticator;
+ private final GSSManager gssManager;
+
+ /**
+ * Constructs a new SpnegoAuthProvider with the given authenticator and GSSManager.
+ *
+ * @param authenticator the authenticator to use for JAAS login
+ * @param gssManager the GSSManager to use for SPNEGO token generation
+ */
+ private SpnegoAuthProvider(SpnegoAuthenticator authenticator, GSSManager gssManager) {
+ this.authenticator = authenticator;
+ this.gssManager = gssManager;
+ }
+
+ /**
+ * Creates a new SPNEGO authentication provider using the default GSSManager instance.
+ *
+ * @param authenticator the authenticator to use for JAAS login
+ * @return a new SPNEGO authentication provider
+ */
+ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator) {
+ return create(authenticator, GSSManager.getInstance());
+ }
+
+ /**
+ * Creates a new SPNEGO authentication provider with a custom GSSManager instance.
+ *
+ * This overload is intended for testing or advanced scenarios where a custom GSSManager is needed.
+ *
+ *
+ * @param authenticator the authenticator to use for JAAS login
+ * @param gssManager the GSSManager to use for SPNEGO token generation
+ * @return a new SPNEGO authentication provider
+ */
+ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSManager gssManager) {
+ return new SpnegoAuthProvider(authenticator, gssManager);
+ }
+
+ /**
+ * Applies SPNEGO authentication to the given HTTP client request.
+ *
+ * This method generates a SPNEGO token for the specified address and attaches it
+ * as an Authorization header to the outgoing HTTP request.
+ *
+ *
+ * @param request the HTTP client request to authenticate
+ * @param address the target server address (used for service principal)
+ * @return a Mono that completes when the authentication is applied
+ * @throws RuntimeException if login or token generation fails
+ */
+ public Mono apply(HttpClientRequest request, InetSocketAddress address) {
+ return Mono.fromCallable(() -> {
+ try {
+ return Subject.doAs(
+ authenticator.login(),
+ (PrivilegedAction) () -> {
+ try {
+ byte[] token = generateSpnegoToken(address.getHostName());
+ String authHeader = "Negotiate " + Base64.getEncoder().encodeToString(token);
+ request.header(HttpHeaderNames.AUTHORIZATION, authHeader);
+ return token;
+ }
+ catch (GSSException e) {
+ throw new RuntimeException("Failed to generate SPNEGO token", e);
+ }
+ }
+ );
+ }
+ catch (LoginException e) {
+ throw new RuntimeException("Failed to login with SPNEGO", e);
+ }
+ })
+ .subscribeOn(boundedElastic())
+ .then();
+ }
+
+ /**
+ * Generates a SPNEGO token for the given host name.
+ *
+ * This method uses the GSSManager to create a GSSContext and generate a SPNEGO token
+ * for the specified service principal (HTTP/hostName).
+ *
+ *
+ * @param hostName the target server host name
+ * @return the raw SPNEGO token bytes
+ * @throws GSSException if token generation fails
+ */
+ private byte[] generateSpnegoToken(String hostName) throws GSSException {
+ GSSName serverName = gssManager.createName("HTTP/" + hostName, GSSName.NT_HOSTBASED_SERVICE);
+ Oid spnegoOid = new Oid("1.3.6.1.5.5.2"); // SPNEGO OID
+
+ GSSContext context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME);
+ return context.initSecContext(new byte[0], 0, 0);
+ }
+}
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java
new file mode 100644
index 0000000000..505f3366c9
--- /dev/null
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package reactor.netty.http.client;
+
+import javax.security.auth.Subject;
+import javax.security.auth.login.LoginException;
+
+/**
+ * An abstraction for authentication logic used by SPNEGO providers.
+ *
+ * Implementations are responsible for performing a JAAS login and returning a logged-in Subject.
+ *
+ *
+ * @author raccoonback
+ * @since 1.3.0
+ */
+public interface SpnegoAuthenticator {
+
+ /**
+ * Performs a JAAS login and returns the authenticated Subject.
+ *
+ * @return the authenticated JAAS Subject
+ * @throws LoginException if login fails
+ */
+ Subject login() throws LoginException;
+}
diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java
new file mode 100644
index 0000000000..c4ccd72e70
--- /dev/null
+++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package reactor.netty.http.client;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+import io.netty.handler.codec.http.HttpHeaderNames;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.util.HashSet;
+import java.util.Set;
+import javax.security.auth.Subject;
+import javax.security.auth.kerberos.KerberosPrincipal;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSException;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.GSSName;
+import org.ietf.jgss.Oid;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+import reactor.netty.DisposableServer;
+import reactor.netty.http.server.HttpServer;
+import reactor.test.StepVerifier;
+
+class SpnegoAuthProviderTest {
+
+ private static final int TEST_PORT = 8080;
+
+ private DisposableServer server;
+
+ @BeforeEach
+ void setUp() {
+ server = HttpServer.create()
+ .port(TEST_PORT)
+ .route(routes -> routes
+ .get("/", (request, response) -> {
+ String authHeader = request.requestHeaders().get(HttpHeaderNames.AUTHORIZATION);
+ if (authHeader != null && authHeader.startsWith("Negotiate ")) {
+ return response.status(200).sendString(Mono.just("Authenticated"));
+ }
+ return response.status(401).sendString(Mono.just("Unauthorized"));
+ }))
+ .bindNow();
+ }
+
+ @AfterEach
+ void tearDown() {
+ server.disposeNow();
+ }
+
+ @Test
+ void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException {
+ GSSManager gssManager = mock(GSSManager.class);
+ GSSContext gssContext = mock(GSSContext.class);
+ GSSName gssName = mock(GSSName.class);
+ Oid oid = new Oid("1.3.6.1.5.5.2");
+
+ given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt()))
+ .willReturn("spnego-negotiate-token".getBytes(StandardCharsets.UTF_8));
+ given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE)))
+ .willReturn(gssName);
+ given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt()))
+ .willReturn(gssContext);
+
+ HttpClient client = HttpClient.create()
+ .port(TEST_PORT)
+ .spnego(
+ SpnegoAuthProvider.create(
+ () -> {
+ Set principals = new HashSet<>();
+ principals.add(new KerberosPrincipal("test@LOCALHOST"));
+ return new Subject(true, principals, new HashSet<>(), new HashSet<>());
+ },
+ gssManager
+ )
+ )
+ .wiretap(true)
+ .disableRetry(true);
+
+ StepVerifier.create(
+ client.get()
+ .uri("/")
+ .responseContent()
+ .aggregate()
+ .asString()
+ )
+ .expectNext("Authenticated")
+ .verifyComplete();
+ }
+}
From 84e592f2d0122c190197aa3bb15e5ff7dc157a18 Mon Sep 17 00:00:00 2001
From: raccoonback
Date: Thu, 26 Jun 2025 12:48:53 +0900
Subject: [PATCH 2/6] Reuse SPNEGO token until expiry and reset on expiration
Signed-off-by: raccoonback
---
.../http/client/spnego/Application.java | 2 +-
.../netty/http/client/HttpClientConnect.java | 9 +++
.../netty/http/client/SpnegoAuthProvider.java | 67 ++++++++++++++++---
.../http/client/SpnegoAuthenticator.java | 6 +-
.../http/client/SpnegoAuthProviderTest.java | 3 +-
5 files changed, 73 insertions(+), 14 deletions(-)
diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java
index 334dfeafbc..b79bf99559 100644
--- a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java
+++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java
@@ -29,7 +29,7 @@ public static void main(String[] args) {
SpnegoAuthenticator authenticator = new JaasAuthenticator("KerberosLogin"); // <4>
HttpClient client = HttpClient.create()
- .spnego(SpnegoAuthProvider.create(authenticator)); // <5>
+ .spnego(SpnegoAuthProvider.create(authenticator, 401)); // <5>
client.get()
.uri("http://protected.example.com/")
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java
index 34a4fec8e3..37764d25d6 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
@@ -446,6 +446,15 @@ public Context currentContext() {
@Override
public void onStateChange(Connection connection, State newState) {
if (newState == HttpClientState.RESPONSE_RECEIVED) {
+ HttpClientOperations operations = connection.as(HttpClientOperations.class);
+ if (operations != null && handler.spnegoAuthProvider != null) {
+ int statusCode = operations.status().code();
+ HttpHeaders headers = operations.responseHeaders();
+ if (handler.spnegoAuthProvider.isUnauthorized(statusCode, headers)) {
+ handler.spnegoAuthProvider.invalidateCache();
+ }
+ }
+
sink.success(connection);
return;
}
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
index 04cfb1b07b..478e35f5d0 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
@@ -18,6 +18,7 @@
import static reactor.core.scheduler.Schedulers.boundedElastic;
import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpHeaders;
import java.net.InetSocketAddress;
import java.security.PrivilegedAction;
import java.util.Base64;
@@ -49,8 +50,14 @@
*/
public final class SpnegoAuthProvider {
+ private static final String SPNEGO_HEADER = "Negotiate";
+ private static final String STR_OID = "1.3.6.1.5.5.2";
+
private final SpnegoAuthenticator authenticator;
private final GSSManager gssManager;
+ private final int unauthorizedStatusCode;
+
+ private volatile String verifiedAuthHeader;
/**
* Constructs a new SpnegoAuthProvider with the given authenticator and GSSManager.
@@ -58,19 +65,21 @@ public final class SpnegoAuthProvider {
* @param authenticator the authenticator to use for JAAS login
* @param gssManager the GSSManager to use for SPNEGO token generation
*/
- private SpnegoAuthProvider(SpnegoAuthenticator authenticator, GSSManager gssManager) {
+ private SpnegoAuthProvider(SpnegoAuthenticator authenticator, GSSManager gssManager, int unauthorizedStatusCode) {
this.authenticator = authenticator;
this.gssManager = gssManager;
+ this.unauthorizedStatusCode = unauthorizedStatusCode;
}
/**
* Creates a new SPNEGO authentication provider using the default GSSManager instance.
*
* @param authenticator the authenticator to use for JAAS login
+ * @param unauthorizedStatusCode the HTTP status code that indicates authentication failure
* @return a new SPNEGO authentication provider
*/
- public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator) {
- return create(authenticator, GSSManager.getInstance());
+ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, int unauthorizedStatusCode) {
+ return create(authenticator, GSSManager.getInstance(), unauthorizedStatusCode);
}
/**
@@ -81,10 +90,11 @@ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator) {
*
* @param authenticator the authenticator to use for JAAS login
* @param gssManager the GSSManager to use for SPNEGO token generation
+ * @param unauthorizedStatusCode the HTTP status code that indicates authentication failure
* @return a new SPNEGO authentication provider
*/
- public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSManager gssManager) {
- return new SpnegoAuthProvider(authenticator, gssManager);
+ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSManager gssManager, int unauthorizedStatusCode) {
+ return new SpnegoAuthProvider(authenticator, gssManager, unauthorizedStatusCode);
}
/**
@@ -100,6 +110,11 @@ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSMa
* @throws RuntimeException if login or token generation fails
*/
public Mono apply(HttpClientRequest request, InetSocketAddress address) {
+ if (verifiedAuthHeader != null) {
+ request.header(HttpHeaderNames.AUTHORIZATION, verifiedAuthHeader);
+ return Mono.empty();
+ }
+
return Mono.fromCallable(() -> {
try {
return Subject.doAs(
@@ -107,17 +122,19 @@ public Mono apply(HttpClientRequest request, InetSocketAddress address) {
(PrivilegedAction) () -> {
try {
byte[] token = generateSpnegoToken(address.getHostName());
- String authHeader = "Negotiate " + Base64.getEncoder().encodeToString(token);
+ String authHeader = SPNEGO_HEADER + " " + Base64.getEncoder().encodeToString(token);
+
+ verifiedAuthHeader = authHeader;
request.header(HttpHeaderNames.AUTHORIZATION, authHeader);
return token;
}
- catch (GSSException e) {
+ catch (GSSException e) {
throw new RuntimeException("Failed to generate SPNEGO token", e);
}
}
);
}
- catch (LoginException e) {
+ catch (LoginException e) {
throw new RuntimeException("Failed to login with SPNEGO", e);
}
})
@@ -138,9 +155,41 @@ public Mono apply(HttpClientRequest request, InetSocketAddress address) {
*/
private byte[] generateSpnegoToken(String hostName) throws GSSException {
GSSName serverName = gssManager.createName("HTTP/" + hostName, GSSName.NT_HOSTBASED_SERVICE);
- Oid spnegoOid = new Oid("1.3.6.1.5.5.2"); // SPNEGO OID
+ Oid spnegoOid = new Oid(STR_OID); // SPNEGO OID
GSSContext context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME);
return context.initSecContext(new byte[0], 0, 0);
}
+
+ /**
+ * Invalidates the cached authentication token.
+ *
+ * This method should be called when a response indicates that the current token
+ * is no longer valid (typically after receiving an unauthorized status code).
+ * The next request will generate a new authentication token.
+ *
+ */
+ public void invalidateCache() {
+ this.verifiedAuthHeader = null;
+ }
+
+ /**
+ * Checks if the response indicates an authentication failure that requires a new token.
+ *
+ * This method checks both the status code and the WWW-Authenticate header to determine
+ * if a new SPNEGO token needs to be generated.
+ *
+ *
+ * @param status the HTTP status code
+ * @param headers the HTTP response headers
+ * @return true if the response indicates an authentication failure
+ */
+ public boolean isUnauthorized(int status, HttpHeaders headers) {
+ if (status != unauthorizedStatusCode) {
+ return false;
+ }
+
+ String header = headers.get(HttpHeaderNames.WWW_AUTHENTICATE);
+ return header != null && header.startsWith(SPNEGO_HEADER);
+ }
}
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java
index 505f3366c9..44d6653110 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java
@@ -21,7 +21,7 @@
/**
* An abstraction for authentication logic used by SPNEGO providers.
*
- * Implementations are responsible for performing a JAAS login and returning a logged-in Subject.
+ * Implementations are responsible for performing a login and returning a logged-in Subject.
*
*
* @author raccoonback
@@ -30,9 +30,9 @@
public interface SpnegoAuthenticator {
/**
- * Performs a JAAS login and returns the authenticated Subject.
+ * Performs a login and returns the authenticated Subject.
*
- * @return the authenticated JAAS Subject
+ * @return the authenticated Subject
* @throws LoginException if login fails
*/
Subject login() throws LoginException;
diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java
index c4ccd72e70..62d2a2a4d4 100644
--- a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java
+++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java
@@ -91,7 +91,8 @@ void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException {
principals.add(new KerberosPrincipal("test@LOCALHOST"));
return new Subject(true, principals, new HashSet<>(), new HashSet<>());
},
- gssManager
+ gssManager,
+ 401
)
)
.wiretap(true)
From 1d769d24aac958a8cce68a71092a18424ca2ad46 Mon Sep 17 00:00:00 2001
From: raccoonback
Date: Fri, 4 Jul 2025 12:04:17 +0900
Subject: [PATCH 3/6] Ensure GSSContext is disposed after initializing security
context
Signed-off-by: raccoonback
---
.../java/reactor/netty/http/client/SpnegoAuthProvider.java | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
index 478e35f5d0..34621ec8bf 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
@@ -158,7 +158,11 @@ private byte[] generateSpnegoToken(String hostName) throws GSSException {
Oid spnegoOid = new Oid(STR_OID); // SPNEGO OID
GSSContext context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME);
- return context.initSecContext(new byte[0], 0, 0);
+ try {
+ return context.initSecContext(new byte[0], 0, 0);
+ } finally {
+ context.dispose();
+ }
}
/**
From 776de4924aba12f5c072b19548cf564832cfe3a7 Mon Sep 17 00:00:00 2001
From: raccoonback
Date: Fri, 4 Jul 2025 12:09:56 +0900
Subject: [PATCH 4/6] Add SpnegoAuthenticationException for better error
handling in SPNEGO authentication
Signed-off-by: raccoonback
---
.../netty/http/client/SpnegoAuthProvider.java | 6 ++--
.../client/SpnegoAuthenticationException.java | 29 +++++++++++++++++++
2 files changed, 32 insertions(+), 3 deletions(-)
create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
index 34621ec8bf..564bc8190e 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
@@ -107,7 +107,7 @@ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSMa
* @param request the HTTP client request to authenticate
* @param address the target server address (used for service principal)
* @return a Mono that completes when the authentication is applied
- * @throws RuntimeException if login or token generation fails
+ * @throws SpnegoAuthenticationException if login or token generation fails
*/
public Mono apply(HttpClientRequest request, InetSocketAddress address) {
if (verifiedAuthHeader != null) {
@@ -129,13 +129,13 @@ public Mono apply(HttpClientRequest request, InetSocketAddress address) {
return token;
}
catch (GSSException e) {
- throw new RuntimeException("Failed to generate SPNEGO token", e);
+ throw new SpnegoAuthenticationException("Failed to generate SPNEGO token", e);
}
}
);
}
catch (LoginException e) {
- throw new RuntimeException("Failed to login with SPNEGO", e);
+ throw new SpnegoAuthenticationException("Failed to login with SPNEGO", e);
}
})
.subscribeOn(boundedElastic())
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java
new file mode 100644
index 0000000000..d9879bf59e
--- /dev/null
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package reactor.netty.http.client;
+
+/**
+ * Exception thrown when SPNEGO (Kerberos) authentication fails.
+ *
+ * @author raccoonback
+ * @since 1.3.0
+ */
+public class SpnegoAuthenticationException extends RuntimeException {
+
+ public SpnegoAuthenticationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
From f3c5542a3ebc3a98ca4f46b40e4bde466dedf76a Mon Sep 17 00:00:00 2001
From: raccoonback
Date: Fri, 4 Jul 2025 12:14:23 +0900
Subject: [PATCH 5/6] Improve SPNEGO authentication retry mechanism
Signed-off-by: raccoonback
---
docs/modules/ROOT/pages/http-client.adoc | 4 +-
.../netty/http/client/HttpClientConnect.java | 49 ++-
.../netty/http/client/SpnegoAuthProvider.java | 82 +++-
.../client/SpnegoAuthenticationException.java | 2 +-
.../http/client/SpnegoRetryException.java | 29 ++
.../http/client/SpnegoAuthProviderTest.java | 357 +++++++++++++++---
6 files changed, 452 insertions(+), 71 deletions(-)
create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoRetryException.java
diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc
index ac96c3e6f9..2a8b08cfcc 100644
--- a/docs/modules/ROOT/pages/http-client.adoc
+++ b/docs/modules/ROOT/pages/http-client.adoc
@@ -744,7 +744,7 @@ include::{examples-dir}/resolver/Application.java[lines=18..39]
<1> The timeout of each DNS query performed by this resolver will be 500ms.
[[http-client-spnego]]
-=== SPNEGO (Kerberos) Authentication
+== SPNEGO Authentication
Reactor Netty HttpClient supports SPNEGO (Kerberos) authentication, which is widely used in enterprise environments.
SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) provides secure authentication over HTTP using Kerberos.
@@ -765,7 +765,7 @@ include::{examples-dir}/spnego/Application.java[lines=18..39]
<2> Configures the `krb5.conf`. krb5.conf is a Kerberos client configuration file used to define how the client locates and communicates with the Kerberos Key Distribution Center (KDC) for authentication.
<3> Configures the SPNEGO jaas.conf. A JVM system property that enables detailed debug logging for Kerberos operations in Java.
<4> `JaasAuthenticator` performs Kerberos authentication using a JAAS configuration (jaas.conf).
-<5> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket and automatically adds the `Authorization: Negotiate ...` header to HTTP requests.
+<5> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket and automatically adds the `Authorization: Negotiate ...` header to HTTP requests. If the server responds with `401 Unauthorized` and includes `WWW-Authenticate: Negotiate`, the client will automatically reauthenticate and retry the request once.
==== Environment Configuration
===== Example JAAS Configuration
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java
index 37764d25d6..47c7436861 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java
@@ -75,6 +75,7 @@
*
* @author Stephane Maldini
* @author Violeta Georgieva
+ * @author raccoonback
*/
class HttpClientConnect extends HttpClient {
@@ -448,11 +449,12 @@ public void onStateChange(Connection connection, State newState) {
if (newState == HttpClientState.RESPONSE_RECEIVED) {
HttpClientOperations operations = connection.as(HttpClientOperations.class);
if (operations != null && handler.spnegoAuthProvider != null) {
- int statusCode = operations.status().code();
- HttpHeaders headers = operations.responseHeaders();
- if (handler.spnegoAuthProvider.isUnauthorized(statusCode, headers)) {
- handler.spnegoAuthProvider.invalidateCache();
+ if (shouldRetryWithSpnego(operations)) {
+ retryWithSpnego(operations);
+ return;
}
+
+ handler.spnegoAuthProvider.resetRetryCount();
}
sink.success(connection);
@@ -468,6 +470,42 @@ public void onStateChange(Connection connection, State newState) {
.subscribe(connection.disposeSubscriber());
}
}
+
+ /**
+ * Determines if the current HTTP response requires a SPNEGO authentication retry.
+ *
+ * @param operations the HTTP client operations containing the response status and headers
+ * @return {@code true} if SPNEGO re-authentication should be attempted, {@code false} otherwise
+ */
+ private boolean shouldRetryWithSpnego(HttpClientOperations operations) {
+ int statusCode = operations.status().code();
+ HttpHeaders headers = operations.responseHeaders();
+
+ return handler.spnegoAuthProvider.isUnauthorized(statusCode, headers)
+ && handler.spnegoAuthProvider.canRetry();
+ }
+
+ /**
+ * Triggers a SPNEGO authentication retry by throwing a {@link SpnegoRetryException}.
+ *
+ * The exception-based approach ensures that a completely new {@link HttpClientOperations}
+ * instance is created, avoiding the "Status and headers already sent" error that would
+ * occur if trying to reuse the existing connection.
+ *
+ *
+ * @param operations the current HTTP client operations that received the 401 response
+ * @throws SpnegoRetryException always thrown to trigger the retry mechanism
+ */
+ private void retryWithSpnego(HttpClientOperations operations) {
+ handler.spnegoAuthProvider.invalidateTokenHeader();
+ handler.spnegoAuthProvider.incrementRetryCount();
+
+ if (log.isDebugEnabled()) {
+ log.debug(format(operations.channel(), "Triggering SPNEGO re-authentication"));
+ }
+
+ sink.error(new SpnegoRetryException());
+ }
}
static final class HttpClientHandler extends SocketAddress
@@ -743,6 +781,9 @@ public boolean test(Throwable throwable) {
redirect(re.location);
return true;
}
+ if (throwable instanceof SpnegoRetryException) {
+ return true;
+ }
if (shouldRetry && AbortedException.isConnectionReset(throwable)) {
shouldRetry = false;
redirect(toURI.toString());
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
index 564bc8190e..5cf8abef73 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
@@ -21,7 +21,10 @@
import io.netty.handler.codec.http.HttpHeaders;
import java.net.InetSocketAddress;
import java.security.PrivilegedAction;
+import java.util.Arrays;
import java.util.Base64;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
import javax.security.auth.Subject;
import javax.security.auth.login.LoginException;
import org.ietf.jgss.GSSContext;
@@ -30,6 +33,8 @@
import org.ietf.jgss.GSSName;
import org.ietf.jgss.Oid;
import reactor.core.publisher.Mono;
+import reactor.util.Logger;
+import reactor.util.Loggers;
/**
* Provides SPNEGO authentication for Reactor Netty HttpClient.
@@ -50,6 +55,7 @@
*/
public final class SpnegoAuthProvider {
+ private static final Logger log = Loggers.getLogger(SpnegoAuthProvider.class);
private static final String SPNEGO_HEADER = "Negotiate";
private static final String STR_OID = "1.3.6.1.5.5.2";
@@ -57,7 +63,9 @@ public final class SpnegoAuthProvider {
private final GSSManager gssManager;
private final int unauthorizedStatusCode;
- private volatile String verifiedAuthHeader;
+ private final AtomicReference verifiedAuthHeader = new AtomicReference<>();
+ private final AtomicInteger retryCount = new AtomicInteger(0);
+ private static final int MAX_RETRY_COUNT = 1;
/**
* Constructs a new SpnegoAuthProvider with the given authenticator and GSSManager.
@@ -110,8 +118,9 @@ public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSMa
* @throws SpnegoAuthenticationException if login or token generation fails
*/
public Mono apply(HttpClientRequest request, InetSocketAddress address) {
- if (verifiedAuthHeader != null) {
- request.header(HttpHeaderNames.AUTHORIZATION, verifiedAuthHeader);
+ String cachedToken = verifiedAuthHeader.get();
+ if (cachedToken != null) {
+ request.header(HttpHeaderNames.AUTHORIZATION, cachedToken);
return Mono.empty();
}
@@ -124,7 +133,7 @@ public Mono apply(HttpClientRequest request, InetSocketAddress address) {
byte[] token = generateSpnegoToken(address.getHostName());
String authHeader = SPNEGO_HEADER + " " + Base64.getEncoder().encodeToString(token);
- verifiedAuthHeader = authHeader;
+ verifiedAuthHeader.set(authHeader);
request.header(HttpHeaderNames.AUTHORIZATION, authHeader);
return token;
}
@@ -154,27 +163,61 @@ public Mono apply(HttpClientRequest request, InetSocketAddress address) {
* @throws GSSException if token generation fails
*/
private byte[] generateSpnegoToken(String hostName) throws GSSException {
- GSSName serverName = gssManager.createName("HTTP/" + hostName, GSSName.NT_HOSTBASED_SERVICE);
+ if (hostName == null || hostName.trim().isEmpty()) {
+ throw new IllegalArgumentException("Host name cannot be null or empty");
+ }
+
+ GSSName serverName = gssManager.createName("HTTP/" + hostName.trim(), GSSName.NT_HOSTBASED_SERVICE);
Oid spnegoOid = new Oid(STR_OID); // SPNEGO OID
- GSSContext context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME);
+ GSSContext context = null;
try {
+ context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME);
return context.initSecContext(new byte[0], 0, 0);
- } finally {
- context.dispose();
+ }
+ finally {
+ if (context != null) {
+ try {
+ context.dispose();
+ }
+ catch (GSSException e) {
+ // Log but don't propagate disposal errors
+ if (log.isDebugEnabled()) {
+ log.debug("Failed to dispose GSSContext", e);
+ }
+ }
+ }
}
}
/**
* Invalidates the cached authentication token.
- *
- * This method should be called when a response indicates that the current token
- * is no longer valid (typically after receiving an unauthorized status code).
- * The next request will generate a new authentication token.
- *
*/
- public void invalidateCache() {
- this.verifiedAuthHeader = null;
+ public void invalidateTokenHeader() {
+ this.verifiedAuthHeader.set(null);
+ }
+
+ /**
+ * Checks if SPNEGO authentication retry is allowed.
+ *
+ * @return true if retry is allowed, false otherwise
+ */
+ public boolean canRetry() {
+ return retryCount.get() < MAX_RETRY_COUNT;
+ }
+
+ /**
+ * Increments the retry count for SPNEGO authentication attempts.
+ */
+ public void incrementRetryCount() {
+ retryCount.incrementAndGet();
+ }
+
+ /**
+ * Resets the retry count for SPNEGO authentication.
+ */
+ public void resetRetryCount() {
+ retryCount.set(0);
}
/**
@@ -194,6 +237,13 @@ public boolean isUnauthorized(int status, HttpHeaders headers) {
}
String header = headers.get(HttpHeaderNames.WWW_AUTHENTICATE);
- return header != null && header.startsWith(SPNEGO_HEADER);
+ if (header == null) {
+ return false;
+ }
+
+ // More robust parsing - handle multiple comma-separated authentication schemes
+ return Arrays.stream(header.split(","))
+ .map(String::trim)
+ .anyMatch(auth -> auth.toLowerCase().startsWith(SPNEGO_HEADER.toLowerCase()));
}
}
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java
index d9879bf59e..27e4923344 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticationException.java
@@ -16,7 +16,7 @@
package reactor.netty.http.client;
/**
- * Exception thrown when SPNEGO (Kerberos) authentication fails.
+ * Exception thrown when SPNEGO authentication fails.
*
* @author raccoonback
* @since 1.3.0
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoRetryException.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoRetryException.java
new file mode 100644
index 0000000000..48182abff4
--- /dev/null
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoRetryException.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package reactor.netty.http.client;
+
+/**
+ * Exception thrown to trigger a retry when SPNEGO authentication fails with a 401 Unauthorized response.
+ *
+ * @author raccoonback
+ * @since 1.3.0
+ */
+final class SpnegoRetryException extends RuntimeException {
+
+ SpnegoRetryException() {
+ super("SPNEGO authentication requires retry");
+ }
+}
diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java
index 62d2a2a4d4..714abeb935 100644
--- a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java
+++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java
@@ -22,11 +22,15 @@
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
import io.netty.handler.codec.http.HttpHeaderNames;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.HashSet;
import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
import javax.security.auth.Subject;
import javax.security.auth.kerberos.KerberosPrincipal;
import org.ietf.jgss.GSSContext;
@@ -34,8 +38,6 @@
import org.ietf.jgss.GSSManager;
import org.ietf.jgss.GSSName;
import org.ietf.jgss.Oid;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.netty.DisposableServer;
@@ -46,11 +48,9 @@ class SpnegoAuthProviderTest {
private static final int TEST_PORT = 8080;
- private DisposableServer server;
-
- @BeforeEach
- void setUp() {
- server = HttpServer.create()
+ @Test
+ void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException {
+ DisposableServer server = HttpServer.create()
.port(TEST_PORT)
.route(routes -> routes
.get("/", (request, response) -> {
@@ -61,51 +61,312 @@ void setUp() {
return response.status(401).sendString(Mono.just("Unauthorized"));
}))
.bindNow();
+
+ try {
+ GSSManager gssManager = mock(GSSManager.class);
+ GSSContext gssContext = mock(GSSContext.class);
+ GSSName gssName = mock(GSSName.class);
+ Oid oid = new Oid("1.3.6.1.5.5.2");
+
+ given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt()))
+ .willReturn("spnego-negotiate-token".getBytes(StandardCharsets.UTF_8));
+ given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE)))
+ .willReturn(gssName);
+ given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt()))
+ .willReturn(gssContext);
+
+ HttpClient client = HttpClient.create()
+ .port(TEST_PORT)
+ .spnego(
+ SpnegoAuthProvider.create(
+ () -> {
+ Set principals = new HashSet<>();
+ principals.add(new KerberosPrincipal("test@LOCALHOST"));
+ return new Subject(true, principals, new HashSet<>(), new HashSet<>());
+ },
+ gssManager,
+ 401
+ )
+ )
+ .wiretap(true)
+ .disableRetry(true);
+
+ StepVerifier.create(
+ client.get()
+ .uri("/")
+ .responseContent()
+ .aggregate()
+ .asString()
+ )
+ .expectNext("Authenticated")
+ .verifyComplete();
+ }
+ finally {
+ server.disposeNow();
+ }
}
- @AfterEach
- void tearDown() {
- server.disposeNow();
+ @Test
+ void automaticReauthenticateOn401Response() throws GSSException {
+ AtomicInteger requestCount = new AtomicInteger(0);
+
+ DisposableServer server = HttpServer.create()
+ .port(0)
+ .route(routes -> routes
+ .get("/reauth", (request, response) -> {
+ String authHeader = request.requestHeaders().get(HttpHeaderNames.AUTHORIZATION);
+ int count = requestCount.incrementAndGet();
+
+ if (count == 1) {
+ return response.status(401)
+ .header("WWW-Authenticate", "Negotiate")
+ .sendString(Mono.just("Unauthorized"));
+ }
+ else if (authHeader != null && authHeader.startsWith("Negotiate ")) {
+ return response.status(200).sendString(Mono.just("Reauthenticated"));
+ }
+ return response.status(401).sendString(Mono.just("Failed"));
+ }))
+ .bindNow();
+
+ try {
+ GSSManager gssManager = mock(GSSManager.class);
+ GSSContext gssContext = mock(GSSContext.class);
+ GSSName gssName = mock(GSSName.class);
+ Oid oid = new Oid("1.3.6.1.5.5.2");
+
+ given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt()))
+ .willReturn("spnego-reauth-token".getBytes(StandardCharsets.UTF_8));
+ given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE)))
+ .willReturn(gssName);
+ given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt()))
+ .willReturn(gssContext);
+
+ HttpClient client = HttpClient.create()
+ .port(server.port())
+ .spnego(
+ SpnegoAuthProvider.create(
+ () -> {
+ Set principals = new HashSet<>();
+ principals.add(new KerberosPrincipal("test@LOCALHOST"));
+ return new Subject(true, principals, new HashSet<>(), new HashSet<>());
+ },
+ gssManager,
+ 401
+ )
+ )
+ .wiretap(true)
+ .disableRetry(true);
+
+ StepVerifier.create(
+ client.get()
+ .uri("/reauth")
+ .responseContent()
+ .aggregate()
+ .asString()
+ )
+ .expectNext("Reauthenticated")
+ .verifyComplete();
+
+ verify(gssContext, times(2)).initSecContext(any(byte[].class), anyInt(), anyInt());
+ }
+ finally {
+ server.disposeNow();
+ }
}
@Test
- void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException {
- GSSManager gssManager = mock(GSSManager.class);
- GSSContext gssContext = mock(GSSContext.class);
- GSSName gssName = mock(GSSName.class);
- Oid oid = new Oid("1.3.6.1.5.5.2");
-
- given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt()))
- .willReturn("spnego-negotiate-token".getBytes(StandardCharsets.UTF_8));
- given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE)))
- .willReturn(gssName);
- given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt()))
- .willReturn(gssContext);
-
- HttpClient client = HttpClient.create()
- .port(TEST_PORT)
- .spnego(
- SpnegoAuthProvider.create(
- () -> {
- Set principals = new HashSet<>();
- principals.add(new KerberosPrincipal("test@LOCALHOST"));
- return new Subject(true, principals, new HashSet<>(), new HashSet<>());
- },
- gssManager,
- 401
- )
- )
- .wiretap(true)
- .disableRetry(true);
-
- StepVerifier.create(
- client.get()
- .uri("/")
- .responseContent()
- .aggregate()
- .asString()
- )
- .expectNext("Authenticated")
- .verifyComplete();
+ void doesNotReauthenticateWhenMaxRetryReached() throws GSSException {
+ AtomicInteger requestCount = new AtomicInteger(0);
+
+ DisposableServer server = HttpServer.create()
+ .port(0)
+ .route(routes -> routes
+ .get("/fail", (request, response) -> {
+ requestCount.incrementAndGet();
+ return response.status(401)
+ .header("WWW-Authenticate", "Negotiate")
+ .sendString(Mono.just("Always Unauthorized"));
+ }))
+ .bindNow();
+
+ try {
+ GSSManager gssManager = mock(GSSManager.class);
+ GSSContext gssContext = mock(GSSContext.class);
+ GSSName gssName = mock(GSSName.class);
+ Oid oid = new Oid("1.3.6.1.5.5.2");
+
+ given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt()))
+ .willReturn("spnego-fail-token".getBytes(StandardCharsets.UTF_8));
+ given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE)))
+ .willReturn(gssName);
+ given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt()))
+ .willReturn(gssContext);
+
+ HttpClient client = HttpClient.create()
+ .port(server.port())
+ .spnego(
+ SpnegoAuthProvider.create(
+ () -> {
+ Set principals = new HashSet<>();
+ principals.add(new KerberosPrincipal("test@LOCALHOST"));
+ return new Subject(true, principals, new HashSet<>(), new HashSet<>());
+ },
+ gssManager,
+ 401
+ )
+ )
+ .wiretap(true)
+ .disableRetry(true);
+
+ StepVerifier.create(
+ client.get()
+ .uri("/fail")
+ .response()
+ .map(response -> response.status().code())
+ )
+ .expectNext(401)
+ .verifyComplete();
+
+ verify(gssContext, times(2)).initSecContext(any(byte[].class), anyInt(), anyInt());
+ }
+ finally {
+ server.disposeNow();
+ }
+ }
+
+ @Test
+ void doesNotReauthenticateWithoutWwwAuthenticateHeader() throws GSSException {
+ DisposableServer server = HttpServer.create()
+ .port(0)
+ .route(routes -> routes
+ .get("/noheader", (request, response) ->
+ response.status(401).sendString(Mono.just("No WWW-Authenticate header"))))
+ .bindNow();
+
+ try {
+ GSSManager gssManager = mock(GSSManager.class);
+ GSSContext gssContext = mock(GSSContext.class);
+ GSSName gssName = mock(GSSName.class);
+ Oid oid = new Oid("1.3.6.1.5.5.2");
+
+ given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt()))
+ .willReturn("spnego-token".getBytes(StandardCharsets.UTF_8));
+ given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE)))
+ .willReturn(gssName);
+ given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt()))
+ .willReturn(gssContext);
+
+ HttpClient client = HttpClient.create()
+ .port(server.port())
+ .spnego(
+ SpnegoAuthProvider.create(
+ () -> {
+ Set principals = new HashSet<>();
+ principals.add(new KerberosPrincipal("test@LOCALHOST"));
+ return new Subject(true, principals, new HashSet<>(), new HashSet<>());
+ },
+ gssManager,
+ 401
+ )
+ )
+ .wiretap(true)
+ .disableRetry(true);
+
+ StepVerifier.create(
+ client.get()
+ .uri("/noheader")
+ .response()
+ .map(response -> response.status().code())
+ )
+ .expectNext(401)
+ .verifyComplete();
+
+ verify(gssContext, times(1)).initSecContext(any(byte[].class), anyInt(), anyInt());
+ }
+ finally {
+ server.disposeNow();
+ }
+ }
+
+ @Test
+ void successfulAuthenticationResetsRetryCount() throws GSSException {
+ AtomicInteger requestCount = new AtomicInteger(0);
+
+ DisposableServer server = HttpServer.create()
+ .port(0)
+ .route(routes -> routes
+ .get("/reset", (request, response) -> {
+ String authHeader = request.requestHeaders().get(HttpHeaderNames.AUTHORIZATION);
+ int count = requestCount.incrementAndGet();
+
+ if (count == 1) {
+ return response.status(401)
+ .header("WWW-Authenticate", "Negotiate")
+ .sendString(Mono.just("First 401"));
+ }
+ else if (authHeader != null && authHeader.startsWith("Negotiate ")) {
+ return response.status(200).sendString(Mono.just("Success"));
+ }
+ return response.status(401).sendString(Mono.just("Unexpected"));
+ }))
+ .bindNow();
+
+ try {
+ GSSManager gssManager = mock(GSSManager.class);
+ GSSContext gssContext = mock(GSSContext.class);
+ GSSName gssName = mock(GSSName.class);
+ Oid oid = new Oid("1.3.6.1.5.5.2");
+
+ given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt()))
+ .willReturn("spnego-reset-token".getBytes(StandardCharsets.UTF_8));
+ given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE)))
+ .willReturn(gssName);
+ given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt()))
+ .willReturn(gssContext);
+
+ SpnegoAuthProvider provider = SpnegoAuthProvider.create(
+ () -> {
+ Set principals = new HashSet<>();
+ principals.add(new KerberosPrincipal("test@LOCALHOST"));
+ return new Subject(true, principals, new HashSet<>(), new HashSet<>());
+ },
+ gssManager,
+ 401
+ );
+
+ HttpClient client = HttpClient.create()
+ .port(server.port())
+ .spnego(provider)
+ .wiretap(true)
+ .disableRetry(true);
+
+ StepVerifier.create(
+ client.get()
+ .uri("/reset")
+ .responseContent()
+ .aggregate()
+ .asString()
+ )
+ .expectNext("Success")
+ .verifyComplete();
+
+ requestCount.set(0);
+
+ StepVerifier.create(
+ client.get()
+ .uri("/reset")
+ .responseContent()
+ .aggregate()
+ .asString()
+ )
+ .expectNext("Success")
+ .verifyComplete();
+
+ verify(gssContext, times(3)).initSecContext(any(byte[].class), anyInt(), anyInt());
+ }
+ finally {
+ server.disposeNow();
+ }
}
}
From b08266122eab704237add4ac1d8198282fa12f07 Mon Sep 17 00:00:00 2001
From: raccoonback
Date: Wed, 30 Jul 2025 22:58:16 +0900
Subject: [PATCH 6/6] Support GSSCredential-based SPNEGO authentication
Signed-off-by: raccoonback
---
docs/modules/ROOT/pages/http-client.adoc | 91 ++++++++-
.../spnego/gsscredential/Application.java | 46 +++++
.../client/spnego/{ => jaas}/Application.java | 11 +-
.../client/GssCredentialAuthenticator.java | 88 +++++++++
.../netty/http/client/JaasAuthenticator.java | 62 ++++--
.../netty/http/client/SpnegoAuthProvider.java | 182 ++++++++++++------
.../http/client/SpnegoAuthenticator.java | 15 +-
.../http/client/SpnegoAuthProviderTest.java | 118 +++---------
8 files changed, 431 insertions(+), 182 deletions(-)
create mode 100644 reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/gsscredential/Application.java
rename reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/{ => jaas}/Application.java (82%)
create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/GssCredentialAuthenticator.java
diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc
index 2a8b08cfcc..44a176cd22 100644
--- a/docs/modules/ROOT/pages/http-client.adoc
+++ b/docs/modules/ROOT/pages/http-client.adoc
@@ -750,24 +750,26 @@ SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) provides secure authe
==== How It Works
SPNEGO authentication follows this HTTP authentication flow:
-1. The client sends an HTTP request to a protected resource.
-2. The server responds with `401 Unauthorized` and a `WWW-Authenticate: Negotiate` header.
-3. The client generates a SPNEGO token based on its Kerberos ticket, and resends the request with an `Authorization: Negotiate ` header.
-4. The server validates the token and, if authentication is successful, returns 200 OK.
-If further negotiation is required, the server may return another 401 with additional data in the WWW-Authenticate header.
+. The client sends an HTTP request to a protected resource.
+. The server responds with `401 Unauthorized` and a `WWW-Authenticate: Negotiate` header.
+. The client generates a SPNEGO token based on its Kerberos ticket, and resends the request with an `Authorization: Negotiate ` header.
+. The server validates the token and, if authentication is successful, returns 200 OK.
-{examples-link}/spnego/Application.java
+If further negotiation is required, the server may return another 401 with additional data in the `WWW-Authenticate` header.
+
+==== JAAS-based Authenticator
+{examples-link}/spnego/jaas/Application.java
----
-include::{examples-dir}/spnego/Application.java[lines=18..39]
+include::{examples-dir}/spnego/jaas/Application.java[lines=18..45]
----
<1> Configures the `jaas.conf`. A JAAS configuration file in Java for integrating with authentication backends such as Kerberos.
<2> Configures the `krb5.conf`. krb5.conf is a Kerberos client configuration file used to define how the client locates and communicates with the Kerberos Key Distribution Center (KDC) for authentication.
<3> Configures the SPNEGO jaas.conf. A JVM system property that enables detailed debug logging for Kerberos operations in Java.
<4> `JaasAuthenticator` performs Kerberos authentication using a JAAS configuration (jaas.conf).
-<5> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket and automatically adds the `Authorization: Negotiate ...` header to HTTP requests. If the server responds with `401 Unauthorized` and includes `WWW-Authenticate: Negotiate`, the client will automatically reauthenticate and retry the request once.
+<5> `SpnegoAuthProvider.Builder` supports the following configuration methods. Please refer to <>.
+<6> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket. It automatically adds the `Authorization: Negotiate ...` header to HTTP requests. If the server responds with `401 Unauthorized` and includes `WWW-Authenticate: Negotiate`, the client will automatically reauthenticate and retry the request once.
-==== Environment Configuration
===== Example JAAS Configuration
Specify the path to your JAAS configuration file using the `java.security.auth.login.config` system property.
@@ -809,6 +811,75 @@ Specify Kerberos realm and KDC information using the `java.security.krb5.conf` s
-Djava.security.krb5.conf=/path/to/krb5.conf
----
+==== GSSCredential-based Authenticator
+For scenarios where you already have a `GSSCredential` available or want to avoid JAAS configuration, you can use `GssCredentialAuthenticator`:
+
+{examples-link}/spnego/gsscredential/Application.java
+----
+include::{examples-dir}/spnego/gsscredential/Application.java[lines=18..46]
+----
+<1> Obtain the `GSSCredential` through other means.
+<2> Configure the GSSCredential-based authenticator for SPNEGO authentication.
+
+This approach is useful when:
+- You want to reuse existing credentials
+- You need more control over credential management
+- JAAS configuration is not available or preferred
+
+==== Custom Authenticator Implementation
+For advanced scenarios where the provided authenticators don't meet your specific requirements, you can implement the `SpnegoAuthenticator` interface directly:
+
+----
+import org.ietf.jgss.GSSContext;
+import reactor.netty.http.client.SpnegoAuthenticator;
+import reactor.netty.http.client.SpnegoAuthProvider;
+
+public class CustomSpnegoAuthenticator implements SpnegoAuthenticator {
+
+ @Override
+ public GSSContext createContext(String serviceName, String remoteHost) throws Exception {
+ // Your custom authentication logic here
+ // This method should return a configured GSSContext
+ // for the specified service and remote host
+ // serviceName: e.g., "HTTP", "LDAP"
+ // remoteHost: target server hostname
+
+ return null; // Replace with actual GSSContext creation logic
+ }
+}
+
+// Usage with advanced configuration
+HttpClient client = HttpClient.create()
+ .spnego(
+ SpnegoAuthProvider.builder(new CustomSpnegoAuthenticator())
+ .serviceName("HTTP") // Custom service name
+ .unauthorizedStatusCode(401) // Custom status code
+ .resolveCanonicalHostname(true) // Use canonical hostname
+ .build()
+ );
+----
+
+This approach is useful when you need:
+- Custom credential acquisition logic
+- Integration with third-party authentication systems
+- Special handling for token caching or refresh
+- Environment-specific authentication flows
+
+[[spnegoauthprovider-config]]
+==== SpnegoAuthProvider Configuration Options
+The `SpnegoAuthProvider.Builder` supports the following configuration Options:
+
+[width="100%",options="header"]
+|=======
+| Method | Default | Description | Example
+| `serviceName(String)` | "HTTP" | Service name for constructing service principal names (serviceName/hostname) | "HTTP", "LDAP"
+| `unauthorizedStatusCode(int)` | 401 | HTTP status code that triggers authentication retry | 401, 407
+| `resolveCanonicalHostname(boolean)` | false | Whether to use canonical hostname resolution via reverse DNS lookup | true for FQDN requirements
+|=======
+
==== Notes
- SPNEGO authentication is fully supported on Java 1.6 and above.
-- If authentication fails, check the server logs and client exception messages, and verify your Kerberos environment settings (realm, KDC, ticket, etc.).
\ No newline at end of file
+- If authentication fails, check the server logs and client exception messages, and verify your Kerberos environment settings (realm, KDC, ticket, etc.).
+- `JaasAuthenticator` performs authentication through JAAS login configuration.
+- `GssCredentialAuthenticator` uses pre-existing `GSSCredential` objects, bypassing JAAS configuration.
+- For custom scenarios, implement the `SpnegoAuthenticator` interface to provide your own authentication logic.
diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/gsscredential/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/gsscredential/Application.java
new file mode 100644
index 0000000000..e67ab5af09
--- /dev/null
+++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/gsscredential/Application.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package reactor.netty.examples.documentation.http.client.spnego.gsscredential;
+
+import org.ietf.jgss.GSSCredential;
+import reactor.netty.http.client.GssCredentialAuthenticator;
+import reactor.netty.http.client.HttpClient;
+import reactor.netty.http.client.SpnegoAuthProvider;
+
+public class Application {
+
+ public static void main(String[] args) {
+ // Assuming you have obtained a GSSCredential from elsewhere
+ GSSCredential credential = obtainGSSCredential(); // <1>
+
+ HttpClient client = HttpClient.create()
+ .spnego(
+ SpnegoAuthProvider.builder(new GssCredentialAuthenticator(credential)) // <2>
+ .build()
+ );
+
+ client.get()
+ .uri("http://protected.example.com/")
+ .responseSingle((res, content) -> content.asString())
+ .block();
+ }
+
+ private static GSSCredential obtainGSSCredential() {
+ // Implement your logic to obtain a GSSCredential
+ // This could involve using a Kerberos library or other means
+ return null;
+ }
+}
diff --git a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/jaas/Application.java
similarity index 82%
rename from reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java
rename to reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/jaas/Application.java
index b79bf99559..d011faa1d7 100644
--- a/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/Application.java
+++ b/reactor-netty-examples/src/main/java/reactor/netty/examples/documentation/http/client/spnego/jaas/Application.java
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package reactor.netty.examples.documentation.http.client.spnego;
+package reactor.netty.examples.documentation.http.client.spnego.jaas;
import reactor.netty.http.client.HttpClient;
import reactor.netty.http.client.JaasAuthenticator;
@@ -28,8 +28,15 @@ public static void main(String[] args) {
System.setProperty("sun.security.krb5.debug", "true"); // <3>
SpnegoAuthenticator authenticator = new JaasAuthenticator("KerberosLogin"); // <4>
+ SpnegoAuthProvider provider = SpnegoAuthProvider.builder(authenticator)
+ .serviceName("HTTP")
+ .unauthorizedStatusCode(401)
+ .resolveCanonicalHostname(false)
+ .build();
HttpClient client = HttpClient.create()
- .spnego(SpnegoAuthProvider.create(authenticator, 401)); // <5>
+ .spnego(
+ provider // <5>
+ ); // <6>
client.get()
.uri("http://protected.example.com/")
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/GssCredentialAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/GssCredentialAuthenticator.java
new file mode 100644
index 0000000000..721ad6b5fd
--- /dev/null
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/GssCredentialAuthenticator.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package reactor.netty.http.client;
+
+import java.util.Objects;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSCredential;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.GSSName;
+import org.ietf.jgss.Oid;
+
+/**
+ * A GSSCredential-based Authenticator implementation for use with SPNEGO providers.
+ *
+ * This authenticator uses a pre-existing GSSCredential to create a GSSContext,
+ * bypassing the need for JAAS login configuration. This is useful when you already
+ * have obtained Kerberos credentials through other means or want more direct control
+ * over the authentication process.
+ *
+ *
+ * The GSSCredential should contain valid Kerberos credentials that can be used
+ * for SPNEGO authentication. The credential's lifetime and validity are managed
+ * externally to this authenticator.
+ *
+ *
+ * Example usage:
+ *
+ * GSSCredential credential = // ... obtain credential
+ * GssCredentialAuthenticator authenticator = new GssCredentialAuthenticator(credential);
+ * SpnegoAuthProvider provider = SpnegoAuthProvider.builder(authenticator).build();
+ *
+ *
+ * @author raccoonback
+ * @since 1.3.0
+ */
+public class GssCredentialAuthenticator implements SpnegoAuthenticator {
+
+ private final GSSCredential credential;
+
+ /**
+ * Creates a new GssCredentialAuthenticator with the given GSSCredential.
+ *
+ * @param credential the GSSCredential to use for authentication
+ */
+ public GssCredentialAuthenticator(GSSCredential credential) {
+ Objects.requireNonNull(credential, "GSSCredential cannot be null");
+ this.credential = credential;
+ }
+
+ /**
+ * Creates a GSSContext for the specified service and remote host using the provided GSSCredential.
+ *
+ * This method uses the pre-existing GSSCredential to create a GSSContext for SPNEGO
+ * authentication. The service principal name is constructed as serviceName/remoteHost.
+ *
+ *
+ * @param serviceName the service name (e.g., "HTTP", "FTP")
+ * @param remoteHost the remote host to authenticate with
+ * @return the created GSSContext configured for SPNEGO authentication
+ * @throws Exception if context creation fails
+ */
+ @Override
+ public GSSContext createContext(String serviceName, String remoteHost) throws Exception {
+ GSSManager manager = GSSManager.getInstance();
+ GSSName serverName = manager.createName(serviceName + "/" + remoteHost, GSSName.NT_HOSTBASED_SERVICE);
+ GSSContext context = manager.createContext(
+ serverName,
+ new Oid("1.3.6.1.5.5.2"),
+ credential,
+ GSSContext.DEFAULT_LIFETIME
+ );
+ context.requestMutualAuth(true);
+ return context;
+ }
+}
\ No newline at end of file
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java
index 6fd7ae7491..c5f23e2fb1 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/JaasAuthenticator.java
@@ -15,42 +15,78 @@
*/
package reactor.netty.http.client;
+import java.security.PrivilegedExceptionAction;
import javax.security.auth.Subject;
import javax.security.auth.login.LoginContext;
-import javax.security.auth.login.LoginException;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.GSSName;
+import org.ietf.jgss.Oid;
/**
* A JAAS-based Authenticator implementation for use with SPNEGO providers.
*
- * This authenticator performs a JAAS login using the specified context name and returns the authenticated Subject.
+ * This authenticator performs a JAAS login using the specified context name and creates a GSSContext
+ * for SPNEGO authentication. It relies on JAAS configuration to obtain Kerberos credentials.
+ *
+ *
+ * The JAAS configuration should define a login context that acquires Kerberos credentials,
+ * typically using the Krb5LoginModule. The login context name provided to this authenticator
+ * must match the entry name in the JAAS configuration file.
*
*
+ * Example usage:
+ *
+ * JaasAuthenticator authenticator = new JaasAuthenticator("KerberosLogin");
+ * SpnegoAuthProvider provider = SpnegoAuthProvider.builder(authenticator).build();
+ *
+ *
* @author raccoonback
* @since 1.3.0
*/
public class JaasAuthenticator implements SpnegoAuthenticator {
- private final String contextName;
+ private final String loginContext;
/**
* Creates a new JaasAuthenticator with the given context name.
*
- * @param contextName the JAAS login context name
+ * @param loginContext the JAAS login context name
*/
- public JaasAuthenticator(String contextName) {
- this.contextName = contextName;
+ public JaasAuthenticator(String loginContext) {
+ this.loginContext = loginContext;
}
/**
- * Performs a JAAS login using the configured context name and returns the authenticated Subject.
+ * Creates a GSSContext for the specified service and remote host using JAAS authentication.
+ *
+ * This method performs a JAAS login, obtains the authenticated Subject, and creates
+ * a GSSContext within the Subject's security context. The service principal name
+ * is constructed as serviceName/remoteHost.
+ *
*
- * @return the authenticated JAAS Subject
- * @throws LoginException if login fails
+ * @param serviceName the service name (e.g., "HTTP", "CIFS")
+ * @param remoteHost the remote host to authenticate with
+ * @return the created GSSContext configured for SPNEGO authentication
+ * @throws Exception if JAAS login or context creation fails
*/
@Override
- public Subject login() throws LoginException {
- LoginContext context = new LoginContext(contextName);
- context.login();
- return context.getSubject();
+ public GSSContext createContext(String serviceName, String remoteHost) throws Exception {
+ LoginContext lc = new LoginContext(loginContext);
+ lc.login();
+ Subject subject = lc.getSubject();
+
+ return Subject.doAs(subject, (PrivilegedExceptionAction) () -> {
+ GSSManager manager = GSSManager.getInstance();
+ GSSName serverName = manager.createName(serviceName + "/" + remoteHost, GSSName.NT_HOSTBASED_SERVICE);
+ GSSContext context = manager.createContext(
+ serverName,
+ new Oid("1.3.6.1.5.5.2"), // SPNEGO
+ null,
+ GSSContext.DEFAULT_LIFETIME
+ );
+ context.requestMutualAuth(true);
+ return context;
+ });
}
}
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
index 5cf8abef73..c5c169b504 100644
--- a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
+++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthProvider.java
@@ -20,18 +20,12 @@
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaders;
import java.net.InetSocketAddress;
-import java.security.PrivilegedAction;
import java.util.Arrays;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
-import javax.security.auth.Subject;
-import javax.security.auth.login.LoginException;
import org.ietf.jgss.GSSContext;
import org.ietf.jgss.GSSException;
-import org.ietf.jgss.GSSManager;
-import org.ietf.jgss.GSSName;
-import org.ietf.jgss.Oid;
import reactor.core.publisher.Mono;
import reactor.util.Logger;
import reactor.util.Loggers;
@@ -43,11 +37,30 @@
* to the HTTP Authorization header for outgoing requests, enabling single sign-on and
* secure authentication in enterprise environments.
*
+ *
+ * The provider supports authentication caching and retry mechanisms to handle token
+ * expiration and authentication failures gracefully. It can be configured with different
+ * service names, unauthorized status codes, and hostname resolution strategies.
+ *
*
- * Typical usage:
+ * Basic usage with JAAS:
*
+ * SpnegoAuthProvider provider = SpnegoAuthProvider
+ * .builder(new JaasAuthenticator("KerberosLogin"))
+ * .build();
+ *
* HttpClient client = HttpClient.create()
- * .spnego(SpnegoAuthProvider.create(new JaasAuthenticator("KerberosLogin")));
+ * .spnego(provider);
+ *
+ *
+ * Advanced configuration:
+ *
+ * SpnegoAuthProvider provider = SpnegoAuthProvider
+ * .builder(new GssCredentialAuthenticator(credential))
+ * .serviceName("CIFS")
+ * .unauthorizedStatusCode(401)
+ * .resolveCanonicalHostname(true)
+ * .build();
*
*
* @author raccoonback
@@ -57,52 +70,99 @@ public final class SpnegoAuthProvider {
private static final Logger log = Loggers.getLogger(SpnegoAuthProvider.class);
private static final String SPNEGO_HEADER = "Negotiate";
- private static final String STR_OID = "1.3.6.1.5.5.2";
private final SpnegoAuthenticator authenticator;
- private final GSSManager gssManager;
private final int unauthorizedStatusCode;
+ private final String serviceName;
+ private final boolean resolveCanonicalHostname;
private final AtomicReference verifiedAuthHeader = new AtomicReference<>();
private final AtomicInteger retryCount = new AtomicInteger(0);
private static final int MAX_RETRY_COUNT = 1;
/**
- * Constructs a new SpnegoAuthProvider with the given authenticator and GSSManager.
+ * Constructs a new SpnegoAuthProvider with the specified configuration.
+ *
+ * This constructor is private and should only be used by the {@link Builder}.
+ * Use {@link #builder(SpnegoAuthenticator)} to create instances.
+ *
*
- * @param authenticator the authenticator to use for JAAS login
- * @param gssManager the GSSManager to use for SPNEGO token generation
+ * @param authenticator the authenticator to use for SPNEGO authentication (must not be null)
+ * @param unauthorizedStatusCode the HTTP status code that indicates authentication failure
+ * @param serviceName the service name for constructing service principal names
+ * @param resolveCanonicalHostname whether to resolve canonical hostnames for service principals
*/
- private SpnegoAuthProvider(SpnegoAuthenticator authenticator, GSSManager gssManager, int unauthorizedStatusCode) {
+ private SpnegoAuthProvider(SpnegoAuthenticator authenticator, int unauthorizedStatusCode, String serviceName, boolean resolveCanonicalHostname) {
this.authenticator = authenticator;
- this.gssManager = gssManager;
this.unauthorizedStatusCode = unauthorizedStatusCode;
+ this.serviceName = serviceName;
+ this.resolveCanonicalHostname = resolveCanonicalHostname;
}
/**
- * Creates a new SPNEGO authentication provider using the default GSSManager instance.
+ * Creates a new builder for configuring SPNEGO authentication provider.
*
- * @param authenticator the authenticator to use for JAAS login
- * @param unauthorizedStatusCode the HTTP status code that indicates authentication failure
- * @return a new SPNEGO authentication provider
+ * @param authenticator the authenticator to use for SPNEGO authentication
+ * @return a new builder instance
*/
- public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, int unauthorizedStatusCode) {
- return create(authenticator, GSSManager.getInstance(), unauthorizedStatusCode);
+ public static Builder builder(SpnegoAuthenticator authenticator) {
+ return new Builder(authenticator);
}
/**
- * Creates a new SPNEGO authentication provider with a custom GSSManager instance.
- *
- * This overload is intended for testing or advanced scenarios where a custom GSSManager is needed.
- *
- *
- * @param authenticator the authenticator to use for JAAS login
- * @param gssManager the GSSManager to use for SPNEGO token generation
- * @param unauthorizedStatusCode the HTTP status code that indicates authentication failure
- * @return a new SPNEGO authentication provider
+ * Builder for creating SpnegoAuthProvider instances.
*/
- public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSManager gssManager, int unauthorizedStatusCode) {
- return new SpnegoAuthProvider(authenticator, gssManager, unauthorizedStatusCode);
+ public static final class Builder {
+ private final SpnegoAuthenticator authenticator;
+ private int unauthorizedStatusCode = 401;
+ private String serviceName = "HTTP";
+ private boolean resolveCanonicalHostname;
+
+ private Builder(SpnegoAuthenticator authenticator) {
+ this.authenticator = authenticator;
+ }
+
+ /**
+ * Sets the HTTP status code that indicates authentication failure.
+ *
+ * @param statusCode the status code (default: 401)
+ * @return this builder
+ */
+ public Builder unauthorizedStatusCode(int statusCode) {
+ this.unauthorizedStatusCode = statusCode;
+ return this;
+ }
+
+ /**
+ * Sets the service name for the service principal.
+ *
+ * @param serviceName the service name (default: "HTTP")
+ * @return this builder
+ */
+ public Builder serviceName(String serviceName) {
+ this.serviceName = serviceName;
+ return this;
+ }
+
+ /**
+ * Sets whether to resolve canonical hostname.
+ *
+ * @param resolveCanonicalHostname true to resolve canonical hostname (default: false)
+ * @return this builder
+ */
+ public Builder resolveCanonicalHostname(boolean resolveCanonicalHostname) {
+ this.resolveCanonicalHostname = resolveCanonicalHostname;
+ return this;
+ }
+
+ /**
+ * Builds the SpnegoAuthProvider instance.
+ *
+ * @return a new SpnegoAuthProvider
+ */
+ public SpnegoAuthProvider build() {
+ return new SpnegoAuthProvider(authenticator, unauthorizedStatusCode, serviceName, resolveCanonicalHostname);
+ }
}
/**
@@ -126,53 +186,60 @@ public Mono apply(HttpClientRequest request, InetSocketAddress address) {
return Mono.fromCallable(() -> {
try {
- return Subject.doAs(
- authenticator.login(),
- (PrivilegedAction) () -> {
- try {
- byte[] token = generateSpnegoToken(address.getHostName());
- String authHeader = SPNEGO_HEADER + " " + Base64.getEncoder().encodeToString(token);
-
- verifiedAuthHeader.set(authHeader);
- request.header(HttpHeaderNames.AUTHORIZATION, authHeader);
- return token;
- }
- catch (GSSException e) {
- throw new SpnegoAuthenticationException("Failed to generate SPNEGO token", e);
- }
- }
- );
+ String hostName = resolveHostName(address);
+ byte[] token = generateSpnegoToken(hostName);
+ String authHeader = SPNEGO_HEADER + " " + Base64.getEncoder().encodeToString(token);
+
+ verifiedAuthHeader.set(authHeader);
+ request.header(HttpHeaderNames.AUTHORIZATION, authHeader);
+ return token;
}
- catch (LoginException e) {
- throw new SpnegoAuthenticationException("Failed to login with SPNEGO", e);
+ catch (Exception e) {
+ throw new SpnegoAuthenticationException("Failed to generate SPNEGO token", e);
}
})
.subscribeOn(boundedElastic())
.then();
}
+ /**
+ * Resolves the hostname from the given socket address.
+ *
+ * This method returns either the hostname or canonical hostname based on the
+ * {@code resolveCanonicalHostname} configuration. When canonical hostname resolution
+ * is enabled, it performs a reverse DNS lookup to get the fully qualified domain name.
+ *
+ *
+ * @param address the socket address to resolve hostname from
+ * @return the resolved hostname (canonical if configured, otherwise standard hostname)
+ */
+ private String resolveHostName(InetSocketAddress address) {
+ String hostName = address.getHostName();
+ if (resolveCanonicalHostname) {
+ hostName = address.getAddress().getCanonicalHostName();
+ }
+ return hostName;
+ }
+
/**
* Generates a SPNEGO token for the given host name.
*
- * This method uses the GSSManager to create a GSSContext and generate a SPNEGO token
+ * This method uses the authenticator to create a GSSContext and generate a SPNEGO token
* for the specified service principal (HTTP/hostName).
*
*
* @param hostName the target server host name
* @return the raw SPNEGO token bytes
- * @throws GSSException if token generation fails
+ * @throws Exception if token generation fails
*/
- private byte[] generateSpnegoToken(String hostName) throws GSSException {
+ private byte[] generateSpnegoToken(String hostName) throws Exception {
if (hostName == null || hostName.trim().isEmpty()) {
throw new IllegalArgumentException("Host name cannot be null or empty");
}
- GSSName serverName = gssManager.createName("HTTP/" + hostName.trim(), GSSName.NT_HOSTBASED_SERVICE);
- Oid spnegoOid = new Oid(STR_OID); // SPNEGO OID
-
GSSContext context = null;
try {
- context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME);
+ context = authenticator.createContext(serviceName, hostName.trim());
return context.initSecContext(new byte[0], 0, 0);
}
finally {
@@ -241,7 +308,6 @@ public boolean isUnauthorized(int status, HttpHeaders headers) {
return false;
}
- // More robust parsing - handle multiple comma-separated authentication schemes
return Arrays.stream(header.split(","))
.map(String::trim)
.anyMatch(auth -> auth.toLowerCase().startsWith(SPNEGO_HEADER.toLowerCase()));
diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/SpnegoAuthenticator.java
index 44d6653110..3924703599 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 714abeb935..d5453dcdde 100644
--- a/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java
+++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/SpnegoAuthProviderTest.java
@@ -17,27 +17,16 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
-
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import io.netty.handler.codec.http.HttpHeaderNames;
import java.nio.charset.StandardCharsets;
-import java.security.Principal;
-import java.util.HashSet;
-import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
-import javax.security.auth.Subject;
-import javax.security.auth.kerberos.KerberosPrincipal;
import org.ietf.jgss.GSSContext;
-import org.ietf.jgss.GSSException;
-import org.ietf.jgss.GSSManager;
-import org.ietf.jgss.GSSName;
-import org.ietf.jgss.Oid;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.netty.DisposableServer;
@@ -49,7 +38,7 @@ class SpnegoAuthProviderTest {
private static final int TEST_PORT = 8080;
@Test
- void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException {
+ void negotiateSpnegoAuthenticationWithHttpClient() throws Exception {
DisposableServer server = HttpServer.create()
.port(TEST_PORT)
.route(routes -> routes
@@ -63,30 +52,19 @@ void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException {
.bindNow();
try {
- GSSManager gssManager = mock(GSSManager.class);
GSSContext gssContext = mock(GSSContext.class);
- GSSName gssName = mock(GSSName.class);
- Oid oid = new Oid("1.3.6.1.5.5.2");
+ SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class);
given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt()))
.willReturn("spnego-negotiate-token".getBytes(StandardCharsets.UTF_8));
- given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE)))
- .willReturn(gssName);
- given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt()))
+ given(authenticator.createContext(anyString(), anyString()))
.willReturn(gssContext);
HttpClient client = HttpClient.create()
.port(TEST_PORT)
.spnego(
- SpnegoAuthProvider.create(
- () -> {
- Set principals = new HashSet<>();
- principals.add(new KerberosPrincipal("test@LOCALHOST"));
- return new Subject(true, principals, new HashSet<>(), new HashSet<>());
- },
- gssManager,
- 401
- )
+ SpnegoAuthProvider.builder(authenticator)
+ .build()
)
.wiretap(true)
.disableRetry(true);
@@ -107,7 +85,7 @@ void negotiateSpnegoAuthenticationWithHttpClient() throws GSSException {
}
@Test
- void automaticReauthenticateOn401Response() throws GSSException {
+ void automaticReauthenticateOn401Response() throws Exception {
AtomicInteger requestCount = new AtomicInteger(0);
DisposableServer server = HttpServer.create()
@@ -130,30 +108,19 @@ else if (authHeader != null && authHeader.startsWith("Negotiate ")) {
.bindNow();
try {
- GSSManager gssManager = mock(GSSManager.class);
GSSContext gssContext = mock(GSSContext.class);
- GSSName gssName = mock(GSSName.class);
- Oid oid = new Oid("1.3.6.1.5.5.2");
+ SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class);
given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt()))
.willReturn("spnego-reauth-token".getBytes(StandardCharsets.UTF_8));
- given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE)))
- .willReturn(gssName);
- given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt()))
+ given(authenticator.createContext(anyString(), anyString()))
.willReturn(gssContext);
HttpClient client = HttpClient.create()
.port(server.port())
.spnego(
- SpnegoAuthProvider.create(
- () -> {
- Set principals = new HashSet<>();
- principals.add(new KerberosPrincipal("test@LOCALHOST"));
- return new Subject(true, principals, new HashSet<>(), new HashSet<>());
- },
- gssManager,
- 401
- )
+ SpnegoAuthProvider.builder(authenticator)
+ .build()
)
.wiretap(true)
.disableRetry(true);
@@ -176,7 +143,7 @@ else if (authHeader != null && authHeader.startsWith("Negotiate ")) {
}
@Test
- void doesNotReauthenticateWhenMaxRetryReached() throws GSSException {
+ void doesNotReauthenticateWhenMaxRetryReached() throws Exception {
AtomicInteger requestCount = new AtomicInteger(0);
DisposableServer server = HttpServer.create()
@@ -191,30 +158,19 @@ void doesNotReauthenticateWhenMaxRetryReached() throws GSSException {
.bindNow();
try {
- GSSManager gssManager = mock(GSSManager.class);
GSSContext gssContext = mock(GSSContext.class);
- GSSName gssName = mock(GSSName.class);
- Oid oid = new Oid("1.3.6.1.5.5.2");
+ SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class);
given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt()))
.willReturn("spnego-fail-token".getBytes(StandardCharsets.UTF_8));
- given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE)))
- .willReturn(gssName);
- given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt()))
+ given(authenticator.createContext(anyString(), anyString()))
.willReturn(gssContext);
HttpClient client = HttpClient.create()
.port(server.port())
.spnego(
- SpnegoAuthProvider.create(
- () -> {
- Set principals = new HashSet<>();
- principals.add(new KerberosPrincipal("test@LOCALHOST"));
- return new Subject(true, principals, new HashSet<>(), new HashSet<>());
- },
- gssManager,
- 401
- )
+ SpnegoAuthProvider.builder(authenticator)
+ .build()
)
.wiretap(true)
.disableRetry(true);
@@ -236,7 +192,7 @@ void doesNotReauthenticateWhenMaxRetryReached() throws GSSException {
}
@Test
- void doesNotReauthenticateWithoutWwwAuthenticateHeader() throws GSSException {
+ void doesNotReauthenticateWithoutWwwAuthenticateHeader() throws Exception {
DisposableServer server = HttpServer.create()
.port(0)
.route(routes -> routes
@@ -245,30 +201,19 @@ void doesNotReauthenticateWithoutWwwAuthenticateHeader() throws GSSException {
.bindNow();
try {
- GSSManager gssManager = mock(GSSManager.class);
GSSContext gssContext = mock(GSSContext.class);
- GSSName gssName = mock(GSSName.class);
- Oid oid = new Oid("1.3.6.1.5.5.2");
+ SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class);
given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt()))
.willReturn("spnego-token".getBytes(StandardCharsets.UTF_8));
- given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE)))
- .willReturn(gssName);
- given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt()))
+ given(authenticator.createContext(anyString(), anyString()))
.willReturn(gssContext);
HttpClient client = HttpClient.create()
.port(server.port())
.spnego(
- SpnegoAuthProvider.create(
- () -> {
- Set principals = new HashSet<>();
- principals.add(new KerberosPrincipal("test@LOCALHOST"));
- return new Subject(true, principals, new HashSet<>(), new HashSet<>());
- },
- gssManager,
- 401
- )
+ SpnegoAuthProvider.builder(authenticator)
+ .build()
)
.wiretap(true)
.disableRetry(true);
@@ -290,7 +235,7 @@ void doesNotReauthenticateWithoutWwwAuthenticateHeader() throws GSSException {
}
@Test
- void successfulAuthenticationResetsRetryCount() throws GSSException {
+ void successfulAuthenticationResetsRetryCount() throws Exception {
AtomicInteger requestCount = new AtomicInteger(0);
DisposableServer server = HttpServer.create()
@@ -313,27 +258,16 @@ else if (authHeader != null && authHeader.startsWith("Negotiate ")) {
.bindNow();
try {
- GSSManager gssManager = mock(GSSManager.class);
GSSContext gssContext = mock(GSSContext.class);
- GSSName gssName = mock(GSSName.class);
- Oid oid = new Oid("1.3.6.1.5.5.2");
+ SpnegoAuthenticator authenticator = mock(SpnegoAuthenticator.class);
given(gssContext.initSecContext(any(byte[].class), anyInt(), anyInt()))
.willReturn("spnego-reset-token".getBytes(StandardCharsets.UTF_8));
- given(gssManager.createName(any(String.class), eq(GSSName.NT_HOSTBASED_SERVICE)))
- .willReturn(gssName);
- given(gssManager.createContext(eq(gssName), eq(oid), isNull(), anyInt()))
+ given(authenticator.createContext(anyString(), anyString()))
.willReturn(gssContext);
- SpnegoAuthProvider provider = SpnegoAuthProvider.create(
- () -> {
- Set principals = new HashSet<>();
- principals.add(new KerberosPrincipal("test@LOCALHOST"));
- return new Subject(true, principals, new HashSet<>(), new HashSet<>());
- },
- gssManager,
- 401
- );
+ SpnegoAuthProvider provider = SpnegoAuthProvider.builder(authenticator)
+ .build();
HttpClient client = HttpClient.create()
.port(server.port())