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 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())