-
Notifications
You must be signed in to change notification settings - Fork 673
Support SPNEGO Authentication in HttpClient #3813
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 1.2.x
Are you sure you want to change the base?
Conversation
I tested Kerberos authentication using the krb5 available at https://formulae.brew.sh/formula/krb5. |
19ccf13
to
090e1c2
Compare
a6efd89
to
96aa2ba
Compare
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(); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It logs in using the specified JAAS login context name and returns the authenticated Subject
object.
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(); | ||
} | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a SPNEGO authentication expiration response is received via the HTTP response, the authentication header value is cleared.
if (spnegoAuthProvider != null) { | ||
return spnegoAuthProvider.apply(ch, ch.address()) | ||
.then( | ||
Mono.defer( | ||
() -> Mono.from(requestWithBodyInternal(ch)) | ||
) | ||
); | ||
} | ||
|
||
return requestWithBodyInternal(ch); | ||
} | ||
|
||
private Publisher<Void> requestWithBodyInternal(HttpClientOperations ch) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If SPNEGO authentication is configured through the HttpClient, authentication is attempted before sending the request.
if (verifiedAuthHeader != null) { | ||
request.header(HttpHeaderNames.AUTHORIZATION, verifiedAuthHeader); | ||
return Mono.empty(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a valid authentication token already exists, it is reused.
return Mono.fromCallable(() -> { | ||
try { | ||
return Subject.doAs( | ||
authenticator.login(), | ||
(PrivilegedAction<byte[]>) () -> { | ||
try { | ||
byte[] token = generateSpnegoToken(address.getHostName()); | ||
String authHeader = SPNEGO_HEADER + " " + Base64.getEncoder().encodeToString(token); | ||
|
||
verifiedAuthHeader = authHeader; | ||
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(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After obtaining Kerberos credentials via JAAS login, a SPNEGO token for the target server is generated using the GSS-API, and the Authorization: Negotiate <token>
header is added to the request.
8fcac3f
to
a77c0a5
Compare
@@ -446,6 +447,16 @@ 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) { | |||
if (shouldRetryWithSpnego(operations)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Re-authenticates with SPNEGO if necessary.
if (throwable instanceof SpnegoRetryException) { | ||
return true; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Configures it to retry even if a SpnegoRetryException
occurs.
@violetagg |
This is so great! Looking forward to get this in :) |
I can provide some guidance around APIs and configuration. Not every kerberos-enabled client uses JAAS, therefore the direct Subject/SPNEGO token support should be provided |
@wendigo I was thinking of allowing users to implement the If I understood you correctly, you're suggesting that we should provide a way for users to directly supply a Subject, as in the example below: public class DirectSubjectAuthenticator implements SpnegoAuthenticator {
// ...
private Subject subject;
@Override
public Subject login() throws LoginException {
return subject;
}
// ...
} Would you be able to share a more concrete example or use case? |
Sure @raccoonback. I'd like to use reactor-netty in the trino CLI/JDBC/client libraries. We support delegated/constrained/unconstrained kerberos authentication. Relevant code is here: This is how we add it to the okhttp: https://github.com/trinodb/trino/blob/master/client/trino-client/src/main/java/io/trino/client/auth/kerberos/SpnegoHandler.java Configurability is important as we expose configuration that allows the user to pass remote service name, service principal name, whether to canonicalize hostname: https://github.com/trinodb/trino/blob/master/client/trino-client/src/main/java/io/trino/client/auth/kerberos/SpnegoHandler.java#L50C5-L54C48 |
@violetagg cc. @wendigo |
I'm currently on vacation. When I return I'll check it.
На нд, 27.07.2025 г. в 18:21 KOSEUNGBIN ***@***.***> написа:
… *raccoonback* left a comment (reactor/reactor-netty#3813)
<#3813 (comment)>
@violetagg <https://github.com/violetagg>
I think supporting not only JAAS-based authentication but also allowing
the user to provide a GSSCredential directly could improve configurability
and flexibility.
This would be especially useful in environments where JAAS is not
preferred or where credentials need to be managed programmatically.
What do you think about this direction?
cc. @wendigo <https://github.com/wendigo>
—
Reply to this email directly, view it on GitHub
<#3813 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAFKCVJR2CIDYFH4XV65ZID3KTU6LAVCNFSM6AAAAAB75EX2K6VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZTCMRUGQ4DSNBVGA>
.
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
@wendigo |
Signed-off-by: raccoonback <[email protected]>
Signed-off-by: raccoonback <[email protected]>
Signed-off-by: raccoonback <[email protected]>
… authentication Signed-off-by: raccoonback <[email protected]>
Signed-off-by: raccoonback <[email protected]>
Signed-off-by: raccoonback <[email protected]>
private boolean shouldRetryWithSpnego(HttpClientOperations operations) { | ||
int statusCode = operations.status().code(); | ||
HttpHeaders headers = operations.responseHeaders(); | ||
|
||
return handler.spnegoAuthProvider.isUnauthorized(statusCode, headers) | ||
&& handler.spnegoAuthProvider.canRetry(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the resource server responds with a requirement for SPNEGO authentication, the client will retry the authentication.
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()); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The previously issued SPNEGO Negotiate Token
is cleared, and a reissue attempt is made.
@Override | ||
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<GSSContext>) () -> { | ||
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; | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Authentication is performed based on JAAS.
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); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The HTTP client receives the options required for SPNEGO authentication:
- HTTP status to catch the authentication required error response from the resource server
- Service principal name
- Whether to handle canonical hostname
private String resolveHostName(InetSocketAddress address) { | ||
String hostName = address.getHostName(); | ||
if (resolveCanonicalHostname) { | ||
hostName = address.getAddress().getCanonicalHostName(); | ||
} | ||
return hostName; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is handled as the canonical hostname.
private byte[] generateSpnegoToken(String hostName) throws Exception { | ||
if (hostName == null || hostName.trim().isEmpty()) { | ||
throw new IllegalArgumentException("Host name cannot be null or empty"); | ||
} | ||
|
||
GSSContext context = null; | ||
try { | ||
context = authenticator.createContext(serviceName, hostName.trim()); | ||
return context.initSecContext(new byte[0], 0, 0); | ||
} | ||
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); | ||
} | ||
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generates a SPNEGO Negotiate token for the given host name.
Motivation
This PR adds support for SPNEGO (Kerberos) authentication to HttpClient, addressing #3079.
SPNEGO is widely used for HTTP authentication in enterprise environments, particularly those based on Kerberos.
Changes
SpnegoAuthProvider
Provides SPNEGO authentication by generating a Kerberos-based token and attaching it to the
Authorization
header of outgoing HTTP requests.JaasAuthenticator
Provides a pluggable way to perform JAAS-based Kerberos login, making it easy to integrate with various authentication backends.
HttpClient.spnego(...) API
Adds a new API to configure SPNEGO authentication for HttpClient instances.
jaas.conf
A JAAS(Java Authentication and Authorization Service) configuration file in Java for integrating with authentication backends such as Kerberos.
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.
How It Works
401 Unauthorized
and aWWW-Authenticate: Negotiate
header,the client automatically generates a SPNEGO token using the Kerberos ticket and resends the request with the appropriate
Authorization
header.Environment Configuration
Requires proper JAAS (jaas.conf) and Kerberos (krb5.conf) configuration.
See the updated documentation for example configuration files and JVM options.
Additional Notes
SpnegoAuthProvider
allows for easy extension and testing by supporting custom authenticators and GSSManager injection.