All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule Maven / Gradle / Ivy

There is a newer version: 3.3.8
Show newest version
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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
 *
 *    http://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 org.apache.kafka.common.security.oauthbearer;

import java.io.IOException;
import java.util.Collections;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;

import javax.security.auth.Subject;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.auth.login.LoginException;
import javax.security.auth.spi.LoginModule;

import org.apache.kafka.common.config.SaslConfigs;
import org.apache.kafka.common.security.auth.AuthenticateCallbackHandler;
import org.apache.kafka.common.security.auth.Login;
import org.apache.kafka.common.security.auth.SaslExtensionsCallback;
import org.apache.kafka.common.security.auth.SaslExtensions;
import org.apache.kafka.common.security.oauthbearer.internals.OAuthBearerSaslClientProvider;
import org.apache.kafka.common.security.oauthbearer.internals.OAuthBearerSaslServerProvider;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.LogManager;

/**
 * The {@code LoginModule} for the SASL/OAUTHBEARER mechanism. When a client
 * (whether a non-broker client or a broker when SASL/OAUTHBEARER is the
 * inter-broker protocol) connects to Kafka the {@code OAuthBearerLoginModule}
 * instance asks its configured {@link AuthenticateCallbackHandler}
 * implementation to handle an instance of {@link OAuthBearerTokenCallback} and
 * return an instance of {@link OAuthBearerToken}. A default, builtin
 * {@link AuthenticateCallbackHandler} implementation creates an unsecured token
 * as defined by these JAAS module options:
 * 

*

* * * * * * * * * * * * * * * * * * * * * * * * * * * * *
JAAS Module Option for Unsecured Token RetrievalDocumentation
{@code unsecuredLoginStringClaim_="value"}Creates a {@code String} claim with the given name and value. Any valid * claim name can be specified except '{@code iat}' and '{@code exp}' (these are * automatically generated).
{@code unsecuredLoginNumberClaim_="value"}Creates a {@code Number} claim with the given name and value. Any valid * claim name can be specified except '{@code iat}' and '{@code exp}' (these are * automatically generated).
{@code unsecuredLoginListClaim_="value"}Creates a {@code String List} claim with the given name and values parsed * from the given value where the first character is taken as the delimiter. For * example: {@code unsecuredLoginListClaim_fubar="|value1|value2"}. Any valid * claim name can be specified except '{@code iat}' and '{@code exp}' (these are * automatically generated).
{@code unsecuredLoginPrincipalClaimName}Set to a custom claim name if you wish the name of the {@code String} * claim holding the principal name to be something other than * '{@code sub}'.
{@code unsecuredLoginLifetimeSeconds}Set to an integer value if the token expiration is to be set to something * other than the default value of 3600 seconds (which is 1 hour). The * '{@code exp}' claim will be set to reflect the expiration time.
{@code unsecuredLoginScopeClaimName}Set to a custom claim name if you wish the name of the {@code String} or * {@code String List} claim holding any token scope to be something other than * '{@code scope}'.
*

*

* You can also add custom unsecured SASL extensions when using the default, builtin {@link AuthenticateCallbackHandler} * implementation through using the configurable option {@code unsecuredLoginExtension_}. Note that there * are validations for the key/values in order to conform to the SASL/OAUTHBEARER standard * (https://tools.ietf.org/html/rfc7628#section-3.1), including the reserved key at * {@link org.apache.kafka.common.security.oauthbearer.internals.OAuthBearerClientInitialResponse#AUTH_KEY}. * The {@code OAuthBearerLoginModule} instance also asks its configured {@link AuthenticateCallbackHandler} * implementation to handle an instance of {@link SaslExtensionsCallback} and return an instance of {@link SaslExtensions}. * The configured callback handler does not need to handle this callback, though -- any {@code UnsupportedCallbackException} * that is thrown is ignored, and no SASL extensions will be associated with the login. *

* Production use cases will require writing an implementation of * {@link AuthenticateCallbackHandler} that can handle an instance of * {@link OAuthBearerTokenCallback} and declaring it via either the * {@code sasl.login.callback.handler.class} configuration option for a * non-broker client or via the * {@code listener.name.sasl_ssl.oauthbearer.sasl.login.callback.handler.class} * configuration option for brokers (when SASL/OAUTHBEARER is the inter-broker * protocol). *

* This class stores the retrieved {@link OAuthBearerToken} in the * {@code Subject}'s private credentials where the {@code SaslClient} can * retrieve it. An appropriate, builtin {@code SaslClient} implementation is * automatically used and configured such that it can perform that retrieval. *

* Here is a typical, basic JAAS configuration for a client leveraging unsecured * SASL/OAUTHBEARER authentication: * *

 * KafkaClient {
 *      org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule Required
 *      unsecuredLoginStringClaim_sub="thePrincipalName";
 * };
 * 
* * An implementation of the {@link Login} interface specific to the * {@code OAUTHBEARER} mechanism is automatically applied; it periodically * refreshes any token before it expires so that the client can continue to make * connections to brokers. The parameters that impact how the refresh algorithm * operates are specified as part of the producer/consumer/broker configuration * and are as follows. See the documentation for these properties elsewhere for * details. *

*

* * * * * * * * * * * * * * * *
Producer/Consumer/Broker Configuration Property
{@code sasl.login.refresh.window.factor}
{@code sasl.login.refresh.window.jitter}
{@code sasl.login.refresh.min.period.seconds}
{@code sasl.login.refresh.min.buffer.seconds}
*

* When a broker accepts a SASL/OAUTHBEARER connection the instance of the * builtin {@code SaslServer} implementation asks its configured * {@link AuthenticateCallbackHandler} implementation to handle an instance of * {@link OAuthBearerValidatorCallback} constructed with the OAuth 2 Bearer * Token's compact serialization and return an instance of * {@link OAuthBearerToken} if the value validates. A default, builtin * {@link AuthenticateCallbackHandler} implementation validates an unsecured * token as defined by these JAAS module options: *

*

* * * * * * * * * * * * * * * * * * * * *
JAAS Module Option for Unsecured Token ValidationDocumentation
{@code unsecuredValidatorPrincipalClaimName="value"}Set to a non-empty value if you wish a particular {@code String} claim * holding a principal name to be checked for existence; the default is to check * for the existence of the '{@code sub}' claim.
{@code unsecuredValidatorScopeClaimName="value"}Set to a custom claim name if you wish the name of the {@code String} or * {@code String List} claim holding any token scope to be something other than * '{@code scope}'.
{@code unsecuredValidatorRequiredScope="value"}Set to a space-delimited list of scope values if you wish the * {@code String/String List} claim holding the token scope to be checked to * make sure it contains certain values.
{@code unsecuredValidatorAllowableClockSkewMs="value"}Set to a positive integer value if you wish to allow up to some number of * positive milliseconds of clock skew (the default is 0).
*

* Here is a typical, basic JAAS configuration for a broker leveraging unsecured * SASL/OAUTHBEARER validation: * *

 * KafkaServer {
 *      org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule Required
 *      unsecuredLoginStringClaim_sub="thePrincipalName";
 * };
 * 
* * Production use cases will require writing an implementation of * {@link AuthenticateCallbackHandler} that can handle an instance of * {@link OAuthBearerValidatorCallback} and declaring it via the * {@code listener.name.sasl_ssl.oauthbearer.sasl.server.callback.handler.class} * broker configuration option. *

* The builtin {@code SaslServer} implementation for SASL/OAUTHBEARER in Kafka * makes the instance of {@link OAuthBearerToken} available upon successful * authentication via the negotiated property "{@code OAUTHBEARER.token}"; the * token could be used in a custom authorizer (to authorize based on JWT claims * rather than ACLs, for example). *

* This implementation's {@code logout()} method will logout the specific token * that this instance logged in if it's {@code Subject} instance is shared * across multiple {@code LoginContext}s and there happen to be multiple tokens * on the {@code Subject}. This functionality is useful because it means a new * token with a longer lifetime can be created before a soon-to-expire token is * actually logged out. Otherwise, if multiple simultaneous tokens were not * supported like this, the soon-to-be expired token would have to be logged out * first, and then if the new token could not be retrieved (maybe the * authorization server is temporarily unavailable, for example) the client * would be left without a token and would be unable to create new connections. * Better to mitigate this possibility by leaving the existing token (which * still has some lifetime left) in place until a new replacement token is * actually retrieved. This implementation supports this. * * @see SaslConfigs#SASL_LOGIN_REFRESH_WINDOW_FACTOR_DOC * @see SaslConfigs#SASL_LOGIN_REFRESH_WINDOW_JITTER_DOC * @see SaslConfigs#SASL_LOGIN_REFRESH_MIN_PERIOD_SECONDS_DOC * @see SaslConfigs#SASL_LOGIN_REFRESH_BUFFER_SECONDS_DOC */ public class OAuthBearerLoginModule implements LoginModule { /** * Login state transitions: * Initial state: NOT_LOGGED_IN * login() : NOT_LOGGED_IN => LOGGED_IN_NOT_COMMITTED * commit() : LOGGED_IN_NOT_COMMITTED => COMMITTED * abort() : LOGGED_IN_NOT_COMMITTED => NOT_LOGGED_IN * logout() : Any state => NOT_LOGGED_IN */ private enum LoginState { NOT_LOGGED_IN, LOGGED_IN_NOT_COMMITTED, COMMITTED } /** * The SASL Mechanism name for OAuth 2: {@code OAUTHBEARER} */ public static final String OAUTHBEARER_MECHANISM = "OAUTHBEARER"; private static final Logger log = LogManager.getLogger(OAuthBearerLoginModule.class); private static final SaslExtensions EMPTY_EXTENSIONS = new SaslExtensions(Collections.emptyMap()); private Subject subject = null; private AuthenticateCallbackHandler callbackHandler = null; private OAuthBearerToken tokenRequiringCommit = null; private OAuthBearerToken myCommittedToken = null; private SaslExtensions extensionsRequiringCommit = null; private SaslExtensions myCommittedExtensions = null; private LoginState loginState; static { OAuthBearerSaslClientProvider.initialize(); // not part of public API OAuthBearerSaslServerProvider.initialize(); // not part of public API } @Override public void initialize(Subject subject, CallbackHandler callbackHandler, Map sharedState, Map options) { this.subject = Objects.requireNonNull(subject); if (!(Objects.requireNonNull(callbackHandler) instanceof AuthenticateCallbackHandler)) throw new IllegalArgumentException(String.format("Callback handler must be castable to %s: %s", AuthenticateCallbackHandler.class.getName(), callbackHandler.getClass().getName())); this.callbackHandler = (AuthenticateCallbackHandler) callbackHandler; } @Override public boolean login() throws LoginException { if (loginState == LoginState.LOGGED_IN_NOT_COMMITTED) { if (tokenRequiringCommit != null) throw new IllegalStateException(String.format( "Already have an uncommitted token with private credential token count=%d", committedTokenCount())); else throw new IllegalStateException("Already logged in without a token"); } if (loginState == LoginState.COMMITTED) { if (myCommittedToken != null) throw new IllegalStateException(String.format( "Already have a committed token with private credential token count=%d; must login on another login context or logout here first before reusing the same login context", committedTokenCount())); else throw new IllegalStateException("Login has already been committed without a token"); } identifyToken(); if (tokenRequiringCommit != null) identifyExtensions(); else log.debug("Logged in without a token, this login cannot be used to establish client connections"); loginState = LoginState.LOGGED_IN_NOT_COMMITTED; log.debug("Login succeeded; invoke commit() to commit it; current committed token count={}", committedTokenCount()); return true; } private void identifyToken() throws LoginException { OAuthBearerTokenCallback tokenCallback = new OAuthBearerTokenCallback(); try { callbackHandler.handle(new Callback[] {tokenCallback}); } catch (IOException | UnsupportedCallbackException e) { log.error(e.getMessage(), e); throw new LoginException("An internal error occurred while retrieving token from callback handler"); } tokenRequiringCommit = tokenCallback.token(); if (tokenCallback.errorCode() != null) { log.info("Login failed: {} : {} (URI={})", tokenCallback.errorCode(), tokenCallback.errorDescription(), tokenCallback.errorUri()); throw new LoginException(tokenCallback.errorDescription()); } } /** * Attaches SASL extensions to the Subject */ private void identifyExtensions() throws LoginException { SaslExtensionsCallback extensionsCallback = new SaslExtensionsCallback(); try { callbackHandler.handle(new Callback[] {extensionsCallback}); extensionsRequiringCommit = extensionsCallback.extensions(); } catch (IOException e) { log.error(e.getMessage(), e); throw new LoginException("An internal error occurred while retrieving SASL extensions from callback handler"); } catch (UnsupportedCallbackException e) { extensionsRequiringCommit = EMPTY_EXTENSIONS; log.debug("CallbackHandler {} does not support SASL extensions. No extensions will be added", callbackHandler.getClass().getName()); } if (extensionsRequiringCommit == null) { log.error("SASL Extensions cannot be null. Check whether your callback handler is explicitly setting them as null."); throw new LoginException("Extensions cannot be null."); } } @Override public boolean logout() { if (loginState == LoginState.LOGGED_IN_NOT_COMMITTED) throw new IllegalStateException( "Cannot call logout() immediately after login(); need to first invoke commit() or abort()"); if (loginState != LoginState.COMMITTED) { log.debug("Nothing here to log out"); return false; } if (myCommittedToken != null) { log.trace("Logging out my token; current committed token count = {}", committedTokenCount()); for (Iterator iterator = subject.getPrivateCredentials().iterator(); iterator.hasNext(); ) { Object privateCredential = iterator.next(); if (privateCredential == myCommittedToken) { iterator.remove(); myCommittedToken = null; break; } } log.debug("Done logging out my token; committed token count is now {}", committedTokenCount()); } else log.debug("No tokens to logout for this login"); if (myCommittedExtensions != null) { log.trace("Logging out my extensions"); if (subject.getPublicCredentials().removeIf(e -> myCommittedExtensions == e)) myCommittedExtensions = null; log.debug("Done logging out my extensions"); } else log.debug("No extensions to logout for this login"); loginState = LoginState.NOT_LOGGED_IN; return true; } @Override public boolean commit() { if (loginState != LoginState.LOGGED_IN_NOT_COMMITTED) { log.debug("Nothing here to commit"); return false; } if (tokenRequiringCommit != null) { log.trace("Committing my token; current committed token count = {}", committedTokenCount()); subject.getPrivateCredentials().add(tokenRequiringCommit); myCommittedToken = tokenRequiringCommit; tokenRequiringCommit = null; log.debug("Done committing my token; committed token count is now {}", committedTokenCount()); } else log.debug("No tokens to commit, this login cannot be used to establish client connections"); if (extensionsRequiringCommit != null) { subject.getPublicCredentials().add(extensionsRequiringCommit); myCommittedExtensions = extensionsRequiringCommit; extensionsRequiringCommit = null; } loginState = LoginState.COMMITTED; return true; } @Override public boolean abort() { if (loginState == LoginState.LOGGED_IN_NOT_COMMITTED) { log.debug("Login aborted"); tokenRequiringCommit = null; extensionsRequiringCommit = null; loginState = LoginState.NOT_LOGGED_IN; return true; } log.debug("Nothing here to abort"); return false; } private int committedTokenCount() { return subject.getPrivateCredentials(OAuthBearerToken.class).size(); } }