Skip to content

Commit cfa42a3

Browse files
committed
Support SPNEGO (Kerberos) authentication
Signed-off-by: raccoonback <[email protected]>
1 parent c3aa6ae commit cfa42a3

File tree

9 files changed

+492
-0
lines changed

9 files changed

+492
-0
lines changed

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,3 +742,73 @@ To customize the default settings, you can configure `HttpClient` as follows:
742742
include::{examples-dir}/resolver/Application.java[lines=18..39]
743743
----
744744
<1> The timeout of each DNS query performed by this resolver will be 500ms.
745+
746+
[[http-client-spnego]]
747+
=== SPNEGO (Kerberos) Authentication
748+
Reactor Netty HttpClient supports SPNEGO (Kerberos) authentication, which is widely used in enterprise environments.
749+
SPNEGO (Simple and Protected GSSAPI Negotiation Mechanism) provides secure authentication over HTTP using Kerberos.
750+
751+
==== How It Works
752+
SPNEGO authentication follows this HTTP authentication flow:
753+
1. The client sends an HTTP request to a protected resource.
754+
2. The server responds with `401 Unauthorized` and a `WWW-Authenticate: Negotiate` header.
755+
3. The client generates a SPNEGO token based on its Kerberos ticket, and resends the request with an `Authorization: Negotiate <base64-encoded-token>` header.
756+
4. The server validates the token and, if authentication is successful, returns 200 OK.
757+
758+
If further negotiation is required, the server may return another 401 with additional data in the WWW-Authenticate header.
759+
760+
{examples-link}/spnego/Application.java
761+
----
762+
include::{examples-dir}/spnego/Application.java[lines=18..39]
763+
----
764+
<1> Configures the `jaas.conf`. A JAAS configuration file in Java for integrating with authentication backends such as Kerberos.
765+
<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.
766+
<3> Configures the SPNEGO jaas.conf. A JVM system property that enables detailed debug logging for Kerberos operations in Java.
767+
<4> `JaasAuthenticator` performs Kerberos authentication using a JAAS configuration (jaas.conf).
768+
<5> `SpnegoAuthProvider` generates a SPNEGO token from the Kerberos ticket and automatically adds the `Authorization: Negotiate ...` header to HTTP requests.
769+
770+
==== Environment Configuration
771+
===== Example JAAS Configuration
772+
Specify the path to your JAAS configuration file using the `java.security.auth.login.config` system property.
773+
774+
.`jaas.conf`
775+
[jaas,conf]
776+
----
777+
KerberosLogin {
778+
com.sun.security.auth.module.Krb5LoginModule required
779+
client=true
780+
useKeyTab=true
781+
keyTab="/path/to/test.keytab"
782+
principal="[email protected]"
783+
doNotPrompt=true
784+
debug=true;
785+
};
786+
----
787+
788+
===== Example Kerberos Configuration
789+
Specify Kerberos realm and KDC information using the `java.security.krb5.conf` system property.
790+
791+
.`krb5.conf`
792+
[krb5,conf]
793+
----
794+
[libdefaults]
795+
default_realm = EXAMPLE.COM
796+
[realms]
797+
EXAMPLE.COM = {
798+
kdc = kdc.example.com
799+
}
800+
[domain_realms]
801+
.example.com = EXAMPLE.COM
802+
example.com = EXAMPLE.COM
803+
----
804+
805+
===== Configuration Example
806+
[jvm option]
807+
----
808+
-Djava.security.auth.login.config=/path/to/login.conf
809+
-Djava.security.krb5.conf=/path/to/krb5.conf
810+
----
811+
812+
==== Notes
813+
- SPNEGO authentication is fully supported on Java 1.6 and above.
814+
- If authentication fails, check the server logs and client exception messages, and verify your Kerberos environment settings (realm, KDC, ticket, etc.).
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package reactor.netty.examples.documentation.http.client.spnego;
17+
18+
import reactor.netty.http.client.HttpClient;
19+
import reactor.netty.http.client.JaasAuthenticator;
20+
import reactor.netty.http.client.SpnegoAuthProvider;
21+
import reactor.netty.http.client.SpnegoAuthenticator;
22+
23+
public class Application {
24+
25+
public static void main(String[] args) {
26+
System.setProperty("java.security.auth.login.config", "/path/to/jaas.conf"); // <1>
27+
System.setProperty("java.security.krb5.conf", "/path/to/krb5.conf"); // <2>
28+
System.setProperty("sun.security.krb5.debug", "true"); // <3>
29+
30+
SpnegoAuthenticator authenticator = new JaasAuthenticator("KerberosLogin"); // <4>
31+
HttpClient client = HttpClient.create()
32+
.spnego(SpnegoAuthProvider.create(authenticator)); // <5>
33+
34+
client.get()
35+
.uri("http://protected.example.com/")
36+
.responseSingle((res, content) -> content.asString())
37+
.block();
38+
}
39+
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1698,6 +1698,20 @@ public final HttpClient wiretap(boolean enable) {
16981698
return super.wiretap(enable);
16991699
}
17001700

1701+
/**
1702+
* Configure SPNEGO authentication for the HTTP client.
1703+
*
1704+
* @param spnegoAuthProvider the SPNEGO authentication provider
1705+
* @return a new {@link HttpClient}
1706+
* @since 1.3.0
1707+
*/
1708+
public final HttpClient spnego(SpnegoAuthProvider spnegoAuthProvider) {
1709+
Objects.requireNonNull(spnegoAuthProvider, "spnegoAuthProvider");
1710+
HttpClient dup = duplicate();
1711+
dup.configuration().spnegoAuthProvider = spnegoAuthProvider;
1712+
return dup;
1713+
}
1714+
17011715
static boolean isCompressing(HttpHeaders h) {
17021716
return h.contains(HttpHeaderNames.ACCEPT_ENCODING, HttpHeaderValues.GZIP, true)
17031717
|| h.contains(HttpHeaderNames.ACCEPT_ENCODING, HttpHeaderValues.BR, true);

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ public WebsocketClientSpec websocketClientSpec() {
376376
String uriStr;
377377
Function<String, String> uriTagValue;
378378
WebsocketClientSpec websocketClientSpec;
379+
SpnegoAuthProvider spnegoAuthProvider;
379380

380381
HttpClientConfig(HttpConnectionProvider connectionProvider, Map<ChannelOption<?>, ?> options,
381382
Supplier<? extends SocketAddress> remoteAddress) {
@@ -428,6 +429,7 @@ public WebsocketClientSpec websocketClientSpec() {
428429
this.uriStr = parent.uriStr;
429430
this.uriTagValue = parent.uriTagValue;
430431
this.websocketClientSpec = parent.websocketClientSpec;
432+
this.spnegoAuthProvider = parent.spnegoAuthProvider;
431433
}
432434

433435
@Override

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,8 @@ static final class HttpClientHandler extends SocketAddress
489489
volatile boolean shouldRetry;
490490
volatile HttpHeaders previousRequestHeaders;
491491

492+
SpnegoAuthProvider spnegoAuthProvider;
493+
492494
HttpClientHandler(HttpClientConfig configuration) {
493495
this.method = configuration.method;
494496
this.followRedirectPredicate = configuration.followRedirectPredicate;
@@ -526,6 +528,7 @@ static final class HttpClientHandler extends SocketAddress
526528
this.toURI = uriEndpointFactory.createUriEndpoint(configuration.uri, configuration.websocketClientSpec != null);
527529
}
528530
this.resourceUrl = toURI.toExternalForm();
531+
this.spnegoAuthProvider = configuration.spnegoAuthProvider;
529532
}
530533

531534
@Override
@@ -540,6 +543,19 @@ public SocketAddress get() {
540543

541544
@SuppressWarnings("ReferenceEquality")
542545
Publisher<Void> requestWithBody(HttpClientOperations ch) {
546+
if (spnegoAuthProvider != null) {
547+
return spnegoAuthProvider.apply(ch, ch.address())
548+
.then(
549+
Mono.defer(
550+
() -> Mono.from(requestWithBodyInternal(ch))
551+
)
552+
);
553+
}
554+
555+
return requestWithBodyInternal(ch);
556+
}
557+
558+
private Publisher<Void> requestWithBodyInternal(HttpClientOperations ch) {
543559
try {
544560
ch.resourceUrl = this.resourceUrl;
545561
ch.responseTimeout = responseTimeout;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package reactor.netty.http.client;
17+
18+
import javax.security.auth.Subject;
19+
import javax.security.auth.login.LoginContext;
20+
import javax.security.auth.login.LoginException;
21+
22+
/**
23+
* A JAAS-based Authenticator implementation for use with SPNEGO providers.
24+
* <p>
25+
* This authenticator performs a JAAS login using the specified context name and returns the authenticated Subject.
26+
* </p>
27+
*
28+
* @author raccoonback
29+
* @since 1.3.0
30+
*/
31+
public class JaasAuthenticator implements SpnegoAuthenticator {
32+
33+
private final String contextName;
34+
35+
/**
36+
* Creates a new JaasAuthenticator with the given context name.
37+
*
38+
* @param contextName the JAAS login context name
39+
*/
40+
public JaasAuthenticator(String contextName) {
41+
this.contextName = contextName;
42+
}
43+
44+
/**
45+
* Performs a JAAS login using the configured context name and returns the authenticated Subject.
46+
*
47+
* @return the authenticated JAAS Subject
48+
* @throws LoginException if login fails
49+
*/
50+
@Override
51+
public Subject login() throws LoginException {
52+
LoginContext context = new LoginContext(contextName);
53+
context.login();
54+
return context.getSubject();
55+
}
56+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package reactor.netty.http.client;
17+
18+
import static reactor.core.scheduler.Schedulers.boundedElastic;
19+
20+
import io.netty.handler.codec.http.HttpHeaderNames;
21+
import java.net.InetSocketAddress;
22+
import java.security.PrivilegedAction;
23+
import java.util.Base64;
24+
import javax.security.auth.Subject;
25+
import javax.security.auth.login.LoginException;
26+
import org.ietf.jgss.GSSContext;
27+
import org.ietf.jgss.GSSException;
28+
import org.ietf.jgss.GSSManager;
29+
import org.ietf.jgss.GSSName;
30+
import org.ietf.jgss.Oid;
31+
import reactor.core.publisher.Mono;
32+
33+
/**
34+
* Provides SPNEGO authentication for Reactor Netty HttpClient.
35+
* <p>
36+
* This provider is responsible for generating and attaching a SPNEGO (Kerberos) token
37+
* to the HTTP Authorization header for outgoing requests, enabling single sign-on and
38+
* secure authentication in enterprise environments.
39+
* </p>
40+
*
41+
* <p>Typical usage:</p>
42+
* <pre>
43+
* HttpClient client = HttpClient.create()
44+
* .spnego(SpnegoAuthProvider.create(new JaasAuthenticator("KerberosLogin")));
45+
* </pre>
46+
*
47+
* @author raccoonback
48+
* @since 1.3.0
49+
*/
50+
public final class SpnegoAuthProvider {
51+
52+
private final SpnegoAuthenticator authenticator;
53+
private final GSSManager gssManager;
54+
55+
/**
56+
* Constructs a new SpnegoAuthProvider with the given authenticator and GSSManager.
57+
*
58+
* @param authenticator the authenticator to use for JAAS login
59+
* @param gssManager the GSSManager to use for SPNEGO token generation
60+
*/
61+
private SpnegoAuthProvider(SpnegoAuthenticator authenticator, GSSManager gssManager) {
62+
this.authenticator = authenticator;
63+
this.gssManager = gssManager;
64+
}
65+
66+
/**
67+
* Creates a new SPNEGO authentication provider using the default GSSManager instance.
68+
*
69+
* @param authenticator the authenticator to use for JAAS login
70+
* @return a new SPNEGO authentication provider
71+
*/
72+
public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator) {
73+
return create(authenticator, GSSManager.getInstance());
74+
}
75+
76+
/**
77+
* Creates a new SPNEGO authentication provider with a custom GSSManager instance.
78+
* <p>
79+
* This overload is intended for testing or advanced scenarios where a custom GSSManager is needed.
80+
* </p>
81+
*
82+
* @param authenticator the authenticator to use for JAAS login
83+
* @param gssManager the GSSManager to use for SPNEGO token generation
84+
* @return a new SPNEGO authentication provider
85+
*/
86+
public static SpnegoAuthProvider create(SpnegoAuthenticator authenticator, GSSManager gssManager) {
87+
return new SpnegoAuthProvider(authenticator, gssManager);
88+
}
89+
90+
/**
91+
* Applies SPNEGO authentication to the given HTTP client request.
92+
* <p>
93+
* This method generates a SPNEGO token for the specified address and attaches it
94+
* as an Authorization header to the outgoing HTTP request.
95+
* </p>
96+
*
97+
* @param request the HTTP client request to authenticate
98+
* @param address the target server address (used for service principal)
99+
* @return a Mono that completes when the authentication is applied
100+
* @throws RuntimeException if login or token generation fails
101+
*/
102+
public Mono<Void> apply(HttpClientRequest request, InetSocketAddress address) {
103+
return Mono.fromCallable(() -> {
104+
try {
105+
return Subject.doAs(
106+
authenticator.login(),
107+
(PrivilegedAction<byte[]>) () -> {
108+
try {
109+
byte[] token = generateSpnegoToken(address.getHostName());
110+
String authHeader = "Negotiate " + Base64.getEncoder().encodeToString(token);
111+
request.header(HttpHeaderNames.AUTHORIZATION, authHeader);
112+
return token;
113+
}
114+
catch (GSSException e) {
115+
throw new RuntimeException("Failed to generate SPNEGO token", e);
116+
}
117+
}
118+
);
119+
}
120+
catch (LoginException e) {
121+
throw new RuntimeException("Failed to login with SPNEGO", e);
122+
}
123+
})
124+
.subscribeOn(boundedElastic())
125+
.then();
126+
}
127+
128+
/**
129+
* Generates a SPNEGO token for the given host name.
130+
* <p>
131+
* This method uses the GSSManager to create a GSSContext and generate a SPNEGO token
132+
* for the specified service principal (HTTP/hostName).
133+
* </p>
134+
*
135+
* @param hostName the target server host name
136+
* @return the raw SPNEGO token bytes
137+
* @throws GSSException if token generation fails
138+
*/
139+
private byte[] generateSpnegoToken(String hostName) throws GSSException {
140+
GSSName serverName = gssManager.createName("HTTP/" + hostName, GSSName.NT_HOSTBASED_SERVICE);
141+
Oid spnegoOid = new Oid("1.3.6.1.5.5.2"); // SPNEGO OID
142+
143+
GSSContext context = gssManager.createContext(serverName, spnegoOid, null, GSSContext.DEFAULT_LIFETIME);
144+
return context.initSecContext(new byte[0], 0, 0);
145+
}
146+
}

0 commit comments

Comments
 (0)