diff --git a/hbase-client/src/main/java/org/apache/hadoop/hbase/security/AccessDeniedException.java b/hbase-client/src/main/java/org/apache/hadoop/hbase/security/AccessDeniedException.java index 259a0a4d651d..730e71af8ddc 100644 --- a/hbase-client/src/main/java/org/apache/hadoop/hbase/security/AccessDeniedException.java +++ b/hbase-client/src/main/java/org/apache/hadoop/hbase/security/AccessDeniedException.java @@ -33,7 +33,7 @@ public AccessDeniedException() { } public AccessDeniedException(Class clazz, String s) { - super( "AccessDenied [" + clazz.getName() + "]: " + s); + super("AccessDenied [" + clazz.getName() + "]: " + s); } public AccessDeniedException(String s) { diff --git a/hbase-client/src/main/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslAuthenticationProvider.java b/hbase-client/src/main/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslAuthenticationProvider.java new file mode 100644 index 000000000000..8b4dcfe5c75b --- /dev/null +++ b/hbase-client/src/main/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslAuthenticationProvider.java @@ -0,0 +1,42 @@ +/* + * 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.hadoop.hbase.security.provider; + +import static org.apache.hadoop.hbase.security.token.OAuthBearerTokenUtil.TOKEN_KIND; +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.yetus.audience.InterfaceAudience; + +/** + * Base client for client/server implementations for the OAuth Bearer (JWT) token auth'n method. + */ +@InterfaceAudience.Private +public class OAuthBearerSaslAuthenticationProvider extends BuiltInSaslAuthenticationProvider { + + public static final SaslAuthMethod SASL_AUTH_METHOD = new SaslAuthMethod( + "OAUTHBEARER", (byte)83, "OAUTHBEARER", UserGroupInformation.AuthenticationMethod.TOKEN); + + @Override + public SaslAuthMethod getSaslAuthMethod() { + return SASL_AUTH_METHOD; + } + + @Override + public String getTokenKind() { + return TOKEN_KIND; + } +} diff --git a/hbase-client/src/main/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslClientAuthenticationProvider.java b/hbase-client/src/main/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslClientAuthenticationProvider.java new file mode 100644 index 000000000000..b1a8d5b262ed --- /dev/null +++ b/hbase-client/src/main/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslClientAuthenticationProvider.java @@ -0,0 +1,161 @@ +/* + * 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.hadoop.hbase.security.provider; + +import static org.apache.hadoop.hbase.security.token.OAuthBearerTokenUtil.OAUTHBEARER_MECHANISM; +import java.io.IOException; +import java.net.InetAddress; +import java.security.AccessController; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.Map; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.sasl.Sasl; +import javax.security.sasl.SaslClient; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hbase.security.SaslUtil; +import org.apache.hadoop.hbase.security.SecurityInfo; +import org.apache.hadoop.hbase.security.User; +import org.apache.hadoop.hbase.security.auth.AuthenticateCallbackHandler; +import org.apache.hadoop.hbase.security.auth.SaslExtensions; +import org.apache.hadoop.hbase.security.auth.SaslExtensionsCallback; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerToken; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerTokenCallback; +import org.apache.hadoop.security.token.Token; +import org.apache.hadoop.security.token.TokenIdentifier; +import org.apache.yetus.audience.InterfaceAudience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.apache.hadoop.hbase.shaded.protobuf.generated.RPCProtos; + +@InterfaceAudience.Private +public class OAuthBearerSaslClientAuthenticationProvider + extends OAuthBearerSaslAuthenticationProvider + implements SaslClientAuthenticationProvider { + + @Override + public SaslClient createClient(Configuration conf, InetAddress serverAddr, + SecurityInfo securityInfo, Token token, + boolean fallbackAllowed, + Map saslProps) throws IOException { + AuthenticateCallbackHandler callbackHandler = new OAuthBearerSaslClientCallbackHandler(); + callbackHandler.configure(conf, getSaslAuthMethod().getSaslMechanism(), saslProps); + return Sasl.createSaslClient(new String[] { getSaslAuthMethod().getSaslMechanism() }, null, + null, SaslUtil.SASL_DEFAULT_REALM, saslProps, callbackHandler); + } + + public static class OAuthBearerSaslClientCallbackHandler implements AuthenticateCallbackHandler { + private static final Logger LOG = + LoggerFactory.getLogger(OAuthBearerSaslClientCallbackHandler.class); + private boolean configured = false; + + @Override public void configure(Configuration configs, String saslMechanism, + Map saslProps) { + if (!OAUTHBEARER_MECHANISM.equals(saslMechanism)) { + throw new IllegalArgumentException( + String.format("Unexpected SASL mechanism: %s", saslMechanism)); + } + this.configured = true; + } + + @Override + public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException { + if (!configured) { + throw new IllegalStateException( + "OAuthBearerSaslClientCallbackHandler handler must be configured first."); + } + + for (Callback callback : callbacks) { + if (callback instanceof OAuthBearerTokenCallback) { + handleCallback((OAuthBearerTokenCallback) callback); + } else if (callback instanceof SaslExtensionsCallback) { + handleCallback((SaslExtensionsCallback) callback, + Subject.getSubject(AccessController.getContext())); + } else { + throw new UnsupportedCallbackException(callback); + } + } + } + + private void handleCallback(OAuthBearerTokenCallback callback) throws IOException { + if (callback.token() != null) { + throw new IllegalArgumentException("Callback had a token already"); + } + Subject subject = Subject.getSubject(AccessController.getContext()); + Set privateCredentials = subject != null + ? subject.getPrivateCredentials(OAuthBearerToken.class) + : Collections.emptySet(); + if (privateCredentials.size() == 0) { + throw new IOException("No OAuth Bearer tokens in Subject's private credentials"); + } + if (privateCredentials.size() == 1) { + LOG.debug("Found 1 OAuthBearer token"); + callback.token(privateCredentials.iterator().next()); + } else { + /* + * There a very small window of time upon token refresh (on the order of milliseconds) + * where both an old and a new token appear on the Subject's private credentials. + * Rather than implement a lock to eliminate this window, we will deal with it by + * checking for the existence of multiple tokens and choosing the one that has the + * longest lifetime. It is also possible that a bug could cause multiple tokens to + * exist (e.g. KAFKA-7902), so dealing with the unlikely possibility that occurs + * during normal operation also allows us to deal more robustly with potential bugs. + */ + SortedSet sortedByLifetime = + new TreeSet<>( + new Comparator() { + @Override + public int compare(OAuthBearerToken o1, OAuthBearerToken o2) { + return Long.compare(o1.lifetimeMs(), o2.lifetimeMs()); + } + }); + sortedByLifetime.addAll(privateCredentials); + if (LOG.isWarnEnabled()) { + LOG.warn("Found {} OAuth Bearer tokens in Subject's private credentials; " + + "the oldest expires at {}, will use the newest, which expires at {}", + sortedByLifetime.size(), new Date(sortedByLifetime.first().lifetimeMs()), + new Date(sortedByLifetime.last().lifetimeMs())); + } + callback.token(sortedByLifetime.last()); + } + } + + /** + * Attaches the first {@link SaslExtensions} found in the public credentials of the Subject + */ + private static void handleCallback(SaslExtensionsCallback extensionsCallback, Subject subject) { + if (subject != null && !subject.getPublicCredentials(SaslExtensions.class).isEmpty()) { + SaslExtensions extensions = + subject.getPublicCredentials(SaslExtensions.class).iterator().next(); + extensionsCallback.extensions(extensions); + } + } + } + + @Override + public RPCProtos.UserInformation getUserInfo(User user) { + // Don't send user for token auth. Copied from RpcConnection. + return null; + } +} diff --git a/hbase-client/src/main/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslProviderSelector.java b/hbase-client/src/main/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslProviderSelector.java new file mode 100644 index 000000000000..88c2eed0c953 --- /dev/null +++ b/hbase-client/src/main/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslProviderSelector.java @@ -0,0 +1,69 @@ +/* + * 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.hadoop.hbase.security.provider; + +import static org.apache.hadoop.hbase.security.token.OAuthBearerTokenUtil.TOKEN_KIND; +import java.util.Collection; +import java.util.Optional; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hbase.security.User; +import org.apache.hadoop.hbase.util.Pair; +import org.apache.hadoop.io.Text; +import org.apache.hadoop.security.token.Token; +import org.apache.hadoop.security.token.TokenIdentifier; +import org.apache.yetus.audience.InterfaceAudience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@InterfaceAudience.Private +public class OAuthBearerSaslProviderSelector extends BuiltInProviderSelector { + + private static final Logger LOG = LoggerFactory.getLogger(OAuthBearerSaslProviderSelector.class); + + private final Text OAUTHBEARER_TOKEN_KIND_TEXT = + new Text(TOKEN_KIND); + private OAuthBearerSaslClientAuthenticationProvider oauthbearer; + + @Override public void configure(Configuration conf, + Collection providers) { + super.configure(conf, providers); + + this.oauthbearer = (OAuthBearerSaslClientAuthenticationProvider) providers.stream() + .filter((p) -> p instanceof OAuthBearerSaslClientAuthenticationProvider) + .findFirst() + .orElseThrow(() -> new RuntimeException( + "OAuthBearerSaslClientAuthenticationProvider not loaded")); + } + + @Override + public Pair> selectProvider( + String clusterId, User user) { + Pair> pair = + super.selectProvider(clusterId, user); + + Optional> optional = user.getTokens().stream() + .filter((t) -> OAUTHBEARER_TOKEN_KIND_TEXT.equals(t.getKind())) + .findFirst(); + if (optional.isPresent()) { + LOG.info("OAuthBearer token found in user tokens"); + return new Pair<>(oauthbearer, optional.get()); + } + + return pair; + } +} diff --git a/hbase-client/src/test/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslClientCallbackHandlerTest.java b/hbase-client/src/test/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslClientCallbackHandlerTest.java new file mode 100644 index 000000000000..2d5d225ef5db --- /dev/null +++ b/hbase-client/src/test/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslClientCallbackHandlerTest.java @@ -0,0 +1,109 @@ +/* + * 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.hadoop.hbase.security.provider; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import java.io.IOException; +import java.security.AccessController; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.Collections; +import java.util.Set; +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hbase.HBaseClassTestRule; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerToken; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerTokenCallback; +import org.apache.hadoop.hbase.security.token.OAuthBearerTokenUtil; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +@Category({ MiscTests.class, SmallTests.class}) +public class OAuthBearerSaslClientCallbackHandlerTest { + @ClassRule + public static final HBaseClassTestRule CLASS_RULE = + HBaseClassTestRule.forClass(OAuthBearerSaslClientCallbackHandlerTest.class); + + private static OAuthBearerToken createTokenWithLifetimeMillis(final long lifetimeMillis) { + return new OAuthBearerToken() { + @Override + public String value() { + return null; + } + + @Override + public String principalName() { + return null; + } + + @Override + public long lifetimeMs() { + return lifetimeMillis; + } + }; + } + + @Test + public void testWithZeroTokens() { + OAuthBearerSaslClientAuthenticationProvider.OAuthBearerSaslClientCallbackHandler handler = + createCallbackHandler(); + PrivilegedActionException e = + assertThrows(PrivilegedActionException.class, () -> Subject.doAs(new Subject(), + (PrivilegedExceptionAction) () -> { + OAuthBearerTokenCallback callback = new OAuthBearerTokenCallback(); + handler.handle(new Callback[] {callback}); + return null; + } + )); + assertEquals(IOException.class, e.getCause().getClass()); + } + + @Test + public void testWithPotentiallyMultipleTokens() throws Exception { + OAuthBearerSaslClientAuthenticationProvider.OAuthBearerSaslClientCallbackHandler handler = + createCallbackHandler(); + Subject.doAs(new Subject(), (PrivilegedExceptionAction) () -> { + final int maxTokens = 4; + final Set privateCredentials = Subject.getSubject(AccessController.getContext()) + .getPrivateCredentials(); + privateCredentials.clear(); + for (int num = 1; num <= maxTokens; ++num) { + privateCredentials.add(createTokenWithLifetimeMillis(num)); + privateCredentials.add(createTokenWithLifetimeMillis(-num)); + OAuthBearerTokenCallback callback = new OAuthBearerTokenCallback(); + handler.handle(new Callback[] {callback}); + assertEquals(num, callback.token().lifetimeMs()); + } + return null; + }); + } + + private static OAuthBearerSaslClientAuthenticationProvider.OAuthBearerSaslClientCallbackHandler + createCallbackHandler() { + OAuthBearerSaslClientAuthenticationProvider.OAuthBearerSaslClientCallbackHandler handler = + new OAuthBearerSaslClientAuthenticationProvider.OAuthBearerSaslClientCallbackHandler(); + handler.configure(new Configuration(), OAuthBearerTokenUtil.OAUTHBEARER_MECHANISM, + Collections.emptyMap()); + return handler; + } +} diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/exceptions/IllegalSaslStateException.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/exceptions/IllegalSaslStateException.java new file mode 100644 index 000000000000..ce7d1f7c3ddb --- /dev/null +++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/exceptions/IllegalSaslStateException.java @@ -0,0 +1,35 @@ +/* + * 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.hadoop.hbase.exceptions; + +import org.apache.yetus.audience.InterfaceAudience; + +/** + * This exception indicates unexpected requests prior to SASL authentication. + * This could be due to misconfigured security. + */ +@InterfaceAudience.Public +public class IllegalSaslStateException extends IllegalStateException { + + private static final long serialVersionUID = 1L; + + public IllegalSaslStateException(String message) { + super(message); + } + +} diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/exceptions/SaslAuthenticationException.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/exceptions/SaslAuthenticationException.java new file mode 100644 index 000000000000..3f4866e0f557 --- /dev/null +++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/exceptions/SaslAuthenticationException.java @@ -0,0 +1,47 @@ +/* + * 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.hadoop.hbase.exceptions; + +import javax.security.sasl.SaslServer; +import org.apache.yetus.audience.InterfaceAudience; + +/** + * This exception indicates that SASL authentication has failed. The error message + * in the exception indicates the actual cause of failure. + *

+ * SASL authentication failures typically indicate invalid credentials, but + * could also include other failures specific to the SASL mechanism used + * for authentication. + *

+ *

Note:If {@link SaslServer#evaluateResponse(byte[])} throws this exception during + * authentication, the message from the exception will be sent to clients in the SaslAuthenticate + * response. Custom {@link SaslServer} implementations may throw this exception in order to + * provide custom error messages to clients, but should take care not to include any + * security-critical information in the message that should not be leaked to unauthenticated + * clients. + *

+ */ +@InterfaceAudience.Public +public class SaslAuthenticationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public SaslAuthenticationException(String message) { + super(message); + } +} diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/security/auth/AuthenticateCallbackHandler.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/auth/AuthenticateCallbackHandler.java new file mode 100644 index 000000000000..1329e9ac67f7 --- /dev/null +++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/auth/AuthenticateCallbackHandler.java @@ -0,0 +1,48 @@ +/* + * 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.hadoop.hbase.security.auth; + +import java.util.Map; +import javax.security.auth.callback.CallbackHandler; +import org.apache.hadoop.conf.Configuration; +import org.apache.yetus.audience.InterfaceAudience; + +/* + * Callback handler for SASL-based authentication + */ +@InterfaceAudience.Private +public interface AuthenticateCallbackHandler extends CallbackHandler { + + /** + * Configures this callback handler for the specified SASL mechanism. + * + * @param configs Key-value pairs containing the parsed configuration options of + * the client or server. Note that these are the HBase configuration options + * and not the JAAS configuration options. JAAS config options may be obtained + * from `jaasConfigEntries` for callbacks which obtain some configs from the + * JAAS configuration. For configs that may be specified as both HBase config + * as well as JAAS config (e.g. sasl.kerberos.service.name), the configuration + * is treated as invalid if conflicting values are provided. + * @param saslMechanism Negotiated SASL mechanism. For clients, this is the SASL + * mechanism configured for the client. For brokers, this is the mechanism + * negotiated with the client and is one of the mechanisms enabled on the broker. + * @param saslProps SASL properties provided by the SASL library. + */ + default void configure( + Configuration configs, String saslMechanism, Map saslProps) {} +} diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/security/auth/SaslExtensions.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/auth/SaslExtensions.java new file mode 100644 index 000000000000..0f7ef6413659 --- /dev/null +++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/auth/SaslExtensions.java @@ -0,0 +1,65 @@ +/* + * 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.hadoop.hbase.security.auth; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.apache.yetus.audience.InterfaceAudience; + +@InterfaceAudience.Private +public class SaslExtensions { + /** + * An "empty" instance indicating no SASL extensions + */ + public static final SaslExtensions NO_SASL_EXTENSIONS = + new SaslExtensions(Collections.emptyMap()); + private final Map extensionsMap; + + public SaslExtensions(Map extensionsMap) { + this.extensionsMap = Collections.unmodifiableMap(new HashMap<>(extensionsMap)); + } + + /** + * Returns an immutable map of the extension names and their values + */ + public Map getExtensions() { + return extensionsMap; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return extensionsMap.equals(((SaslExtensions) o).extensionsMap); + } + + @Override + public String toString() { + return extensionsMap.toString(); + } + + @Override + public int hashCode() { + return extensionsMap.hashCode(); + } +} diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/security/auth/SaslExtensionsCallback.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/auth/SaslExtensionsCallback.java new file mode 100644 index 000000000000..68cf0c00e515 --- /dev/null +++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/auth/SaslExtensionsCallback.java @@ -0,0 +1,52 @@ +/* + * 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.hadoop.hbase.security.auth; + +import java.util.Objects; +import javax.security.auth.callback.Callback; +import org.apache.yetus.audience.InterfaceAudience; + +/** + * Optional callback used for SASL mechanisms if any extensions need to be set + * in the SASL exchange. + */ +@InterfaceAudience.Private +public class SaslExtensionsCallback implements Callback { + private SaslExtensions extensions = SaslExtensions.NO_SASL_EXTENSIONS; + + /** + * Returns always non-null {@link SaslExtensions} consisting of the extension + * names and values that are sent by the client to the server in the initial + * client SASL authentication message. The default value is + * {@link SaslExtensions#NO_SASL_EXTENSIONS} so that if this callback is + * unhandled the client will see a non-null value. + */ + public SaslExtensions extensions() { + return extensions; + } + + /** + * Sets the SASL extensions on this callback. + * + * @param extensions + * the mandatory extensions to set + */ + public void extensions(SaslExtensions extensions) { + this.extensions = Objects.requireNonNull(extensions, "extensions must not be null"); + } +} diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerStringUtils.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerStringUtils.java new file mode 100644 index 000000000000..0b3c10a8b0e9 --- /dev/null +++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerStringUtils.java @@ -0,0 +1,82 @@ +/* + * 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.hadoop.hbase.security.oauthbearer; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.yetus.audience.InterfaceAudience; + +@InterfaceAudience.Public +public final class OAuthBearerStringUtils { + /** + * Converts a {@code Map} class into a string, concatenating keys and values + * Example: + * {@code mkString({ key: "hello", keyTwo: "hi" }, "|START|", "|END|", "=", ",") + * => "|START|key=hello,keyTwo=hi|END|"} + */ + public static String mkString(Map map, String begin, String end, + String keyValueSeparator, String elementSeparator) { + StringBuilder bld = new StringBuilder(); + bld.append(begin); + String prefix = ""; + for (Map.Entry entry : map.entrySet()) { + bld.append(prefix).append(entry.getKey()). + append(keyValueSeparator).append(entry.getValue()); + prefix = elementSeparator; + } + bld.append(end); + return bld.toString(); + } + + /** + * Converts an extensions string into a {@code Map}. + * + * Example: + * {@code parseMap("key=hey,keyTwo=hi,keyThree=hello", "=", ",") => + * { key: "hey", keyTwo: "hi", keyThree: "hello" }} + * + */ + public static Map parseMap(String mapStr, + String keyValueSeparator, String elementSeparator) { + Map map = new HashMap<>(); + + if (!mapStr.isEmpty()) { + String[] attrvals = mapStr.split(elementSeparator); + for (String attrval : attrvals) { + String[] array = attrval.split(keyValueSeparator, 2); + map.put(array[0], array[1]); + } + } + return map; + } + + /** + * Given two maps (A, B), returns all the key-value pairs in A whose keys are not contained in B + */ + public static Map subtractMap(Map minuend, + Map subtrahend) { + return minuend.entrySet().stream() + .filter(entry -> !subtrahend.containsKey(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private OAuthBearerStringUtils() { + // empty + } +} diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerToken.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerToken.java new file mode 100644 index 000000000000..769bceea6181 --- /dev/null +++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerToken.java @@ -0,0 +1,75 @@ +/* + * 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.hadoop.hbase.security.oauthbearer; + +import org.apache.yetus.audience.InterfaceAudience; +import org.apache.yetus.audience.InterfaceStability; + +/** + * The b64token value as defined in + * RFC 6750 Section + * 2.1 along with the token's specific scope and lifetime and principal + * name. + *

+ * A network request would be required to re-hydrate an opaque token, and that + * could result in (for example) an {@code IOException}, but retrievers for + * various attributes ({@link #lifetimeMs()}, etc.) declare no + * exceptions. Therefore, if a network request is required for any of these + * retriever methods, that request could be performed at construction time so + * that the various attributes can be reliably provided thereafter. For example, + * a constructor might declare {@code throws IOException} in such a case. + * Alternatively, the retrievers could throw unchecked exceptions. + * + * @see RFC 6749 + * Section 1.4 and + * RFC 6750 + * Section 2.1 + */ +@InterfaceAudience.Public +@InterfaceStability.Evolving +public interface OAuthBearerToken { + /** + * The b64token value as defined in + * RFC 6750 Section + * 2.1 + * + * @return b64token value as defined in + * RFC 6750 + * Section 2.1 + */ + String value(); + + /** + * The token's lifetime, expressed as the number of milliseconds since the + * epoch, as per RFC + * 6749 Section 1.4 + * + * @return the token'slifetime, expressed as the number of milliseconds since + * the epoch, as per + * RFC 6749 + * Section 1.4. + */ + long lifetimeMs(); + + /** + * The name of the principal to which this credential applies + * + * @return the always non-null/non-empty principal name + */ + String principalName(); +} diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerTokenCallback.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerTokenCallback.java new file mode 100644 index 000000000000..a9e28efbc2a8 --- /dev/null +++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerTokenCallback.java @@ -0,0 +1,122 @@ +/* + * 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.hadoop.hbase.security.oauthbearer; + +import javax.security.auth.callback.Callback; +import org.apache.commons.lang3.StringUtils; +import org.apache.yetus.audience.InterfaceAudience; + +/** + * A {@code Callback} for use by the {@code SaslClient} and {@code Login} + * implementations when they require an OAuth 2 bearer token. Callback handlers + * should use the {@link #error(String, String, String)} method to communicate + * errors returned by the authorization server as per + * RFC 6749: The OAuth + * 2.0 Authorization Framework. Callback handlers should communicate other + * problems by raising an {@code IOException}. + *

+ * This class was introduced in 3.0.0 and, while it feels stable, it could + * evolve. We will try to evolve the API in a compatible manner, but we reserve + * the right to make breaking changes in minor releases, if necessary. We will + * update the {@code InterfaceStability} annotation and this notice once the API + * is considered stable. + */ +@InterfaceAudience.Private +public class OAuthBearerTokenCallback implements Callback { + private OAuthBearerToken token = null; + private String errorCode = null; + private String errorDescription = null; + private String errorUri = null; + + /** + * Return the (potentially null) token + * + * @return the (potentially null) token + */ + public OAuthBearerToken token() { + return token; + } + + /** + * Return the optional (but always non-empty if not null) error code as per + * RFC 6749: The OAuth + * 2.0 Authorization Framework. + * + * @return the optional (but always non-empty if not null) error code + */ + public String errorCode() { + return errorCode; + } + + /** + * Return the (potentially null) error description as per + * RFC 6749: The OAuth + * 2.0 Authorization Framework. + * + * @return the (potentially null) error description + */ + public String errorDescription() { + return errorDescription; + } + + /** + * Return the (potentially null) error URI as per + * RFC 6749: The OAuth + * 2.0 Authorization Framework. + * + * @return the (potentially null) error URI + */ + public String errorUri() { + return errorUri; + } + + /** + * Set the token. All error-related values are cleared. + * + * @param token + * the optional token to set + */ + public void token(OAuthBearerToken token) { + this.token = token; + this.errorCode = null; + this.errorDescription = null; + this.errorUri = null; + } + + /** + * Set the error values as per + * RFC 6749: The OAuth + * 2.0 Authorization Framework. Any token is cleared. + * + * @param errorCode + * the mandatory error code to set + * @param errorDescription + * the optional error description to set + * @param errorUri + * the optional error URI to set + */ + public void error(String errorCode, String errorDescription, String errorUri) { + if (StringUtils.isEmpty(errorCode)) { + throw new IllegalArgumentException("error code must not be empty"); + } + this.errorCode = errorCode; + this.errorDescription = errorDescription; + this.errorUri = errorUri; + this.token = null; + } +} diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerClientInitialResponse.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerClientInitialResponse.java new file mode 100644 index 000000000000..8971c2ba3ea3 --- /dev/null +++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerClientInitialResponse.java @@ -0,0 +1,222 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.security.sasl.SaslException; +import org.apache.hadoop.hbase.security.auth.SaslExtensions; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerStringUtils; +import org.apache.hadoop.hbase.util.Bytes; +import org.apache.yetus.audience.InterfaceAudience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * OAuthBearer SASL client's initial message to the server. + * + * This class has been copy-and-pasted from Kafka codebase. + */ +@InterfaceAudience.Public +public class OAuthBearerClientInitialResponse { + private static final Logger LOG = LoggerFactory.getLogger(OAuthBearerClientInitialResponse.class); + static final String SEPARATOR = "\u0001"; + + private static final String SASLNAME = "(?:[\\x01-\\x7F&&[^=,]]|=2C|=3D)+"; + private static final String KEY = "[A-Za-z]+"; + private static final String VALUE = "[\\x21-\\x7E \t\r\n]+"; + + private static final String KVPAIRS = String.format("(%s=%s%s)*", KEY, VALUE, SEPARATOR); + private static final Pattern AUTH_PATTERN = + Pattern.compile("(?[\\w]+)[ ]+(?[-_\\.a-zA-Z0-9]+)"); + private static final Pattern CLIENT_INITIAL_RESPONSE_PATTERN = Pattern.compile( + String.format("n,(a=(?%s))?,%s(?%s)%s", + SASLNAME, SEPARATOR, KVPAIRS, SEPARATOR)); + public static final String AUTH_KEY = "auth"; + + private final String tokenValue; + private final String authorizationId; + private final SaslExtensions saslExtensions; + + public static final Pattern EXTENSION_KEY_PATTERN = Pattern.compile(KEY); + public static final Pattern EXTENSION_VALUE_PATTERN = Pattern.compile(VALUE); + + public OAuthBearerClientInitialResponse(byte[] response) throws SaslException { + LOG.trace("Client initial response parsing started"); + String responseMsg = new String(response, StandardCharsets.UTF_8); + Matcher matcher = CLIENT_INITIAL_RESPONSE_PATTERN.matcher(responseMsg); + if (!matcher.matches()) { + throw new SaslException("Invalid OAUTHBEARER client first message"); + } + LOG.trace("Client initial response matches pattern"); + String authzid = matcher.group("authzid"); + this.authorizationId = authzid == null ? "" : authzid; + String kvPairs = matcher.group("kvpairs"); + Map properties = OAuthBearerStringUtils.parseMap(kvPairs, "=", SEPARATOR); + String auth = properties.get(AUTH_KEY); + if (auth == null) { + throw new SaslException("Invalid OAUTHBEARER client first message: 'auth' not specified"); + } + LOG.trace("Auth key found in client initial response"); + properties.remove(AUTH_KEY); + SaslExtensions extensions = new SaslExtensions(properties); + validateExtensions(extensions); + this.saslExtensions = extensions; + LOG.trace("Sasl extensions have been validated successfully"); + + Matcher authMatcher = AUTH_PATTERN.matcher(auth); + if (!authMatcher.matches()) { + throw new SaslException("Invalid OAUTHBEARER client first message: invalid 'auth' format"); + } + LOG.trace("Client initial response auth matches pattern"); + if (!"bearer".equalsIgnoreCase(authMatcher.group("scheme"))) { + String msg = String.format("Invalid scheme in OAUTHBEARER client first message: %s", + matcher.group("scheme")); + throw new SaslException(msg); + } + this.tokenValue = authMatcher.group("token"); + LOG.trace("Client initial response parsing finished"); + } + + /** + * Constructor + * + * @param tokenValue + * the mandatory token value + * @param extensions + * the optional extensions + * @throws SaslException + * if any extension name or value fails to conform to the required + * regular expression as defined by the specification, or if the + * reserved {@code auth} appears as a key + */ + public OAuthBearerClientInitialResponse(String tokenValue, SaslExtensions extensions) + throws SaslException { + this(tokenValue, "", extensions); + } + + /** + * Constructor + * + * @param tokenValue + * the mandatory token value + * @param authorizationId + * the optional authorization ID + * @param extensions + * the optional extensions + * @throws SaslException + * if any extension name or value fails to conform to the required + * regular expression as defined by the specification, or if the + * reserved {@code auth} appears as a key + */ + public OAuthBearerClientInitialResponse(String tokenValue, String authorizationId, + SaslExtensions extensions) throws SaslException { + this.tokenValue = Objects.requireNonNull(tokenValue, "token value must not be null"); + this.authorizationId = authorizationId == null ? "" : authorizationId; + validateExtensions(extensions); + this.saslExtensions = extensions != null ? extensions : SaslExtensions.NO_SASL_EXTENSIONS; + } + + /** + * Return the always non-null extensions + * + * @return the always non-null extensions + */ + public SaslExtensions extensions() { + return saslExtensions; + } + + public byte[] toBytes() { + String authzid = authorizationId.isEmpty() ? "" : "a=" + authorizationId; + String extensions = extensionsMessage(); + if (extensions.length() > 0) { + extensions = SEPARATOR + extensions; + } + + String message = String.format("n,%s,%sauth=Bearer %s%s%s%s", authzid, + SEPARATOR, tokenValue, extensions, SEPARATOR, SEPARATOR); + + return Bytes.toBytes(message); + } + + /** + * Return the always non-null token value + * + * @return the always non-null toklen value + */ + public String tokenValue() { + return tokenValue; + } + + /** + * Return the always non-null authorization ID + * + * @return the always non-null authorization ID + */ + public String authorizationId() { + return authorizationId; + } + + /** + * Validates that the given extensions conform to the standard. + * They should also not contain the reserve key name + * {@link OAuthBearerClientInitialResponse#AUTH_KEY} + * + * @param extensions + * optional extensions to validate + * @throws SaslException + * if any extension name or value fails to conform to the required + * regular expression as defined by the specification, or if the + * reserved {@code auth} appears as a key + * + * @see RFC 7628, + * Section 3.1 + */ + public static void validateExtensions(SaslExtensions extensions) throws SaslException { + if (extensions == null) { + return; + } + if (extensions.getExtensions().containsKey(OAuthBearerClientInitialResponse.AUTH_KEY)) { + throw new SaslException("Extension name " + + OAuthBearerClientInitialResponse.AUTH_KEY + " is invalid"); + } + + for (Map.Entry entry : extensions.getExtensions().entrySet()) { + String extensionName = entry.getKey(); + String extensionValue = entry.getValue(); + + if (!EXTENSION_KEY_PATTERN.matcher(extensionName).matches()) { + throw new SaslException("Extension name " + extensionName + " is invalid"); + } + if (!EXTENSION_VALUE_PATTERN.matcher(extensionValue).matches()) { + throw new SaslException("Extension value (" + extensionValue + ") for extension " + + extensionName + " is invalid"); + } + } + } + + /** + * Converts the SASLExtensions to an OAuth protocol-friendly string + */ + private String extensionsMessage() { + return OAuthBearerStringUtils.mkString(saslExtensions.getExtensions(), "", "", "=", SEPARATOR); + } +} diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslClient.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslClient.java new file mode 100644 index 000000000000..24f82f716a87 --- /dev/null +++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslClient.java @@ -0,0 +1,216 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals; + +import static org.apache.hadoop.hbase.security.token.OAuthBearerTokenUtil.OAUTHBEARER_MECHANISM; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.sasl.Sasl; +import javax.security.sasl.SaslClient; +import javax.security.sasl.SaslClientFactory; +import javax.security.sasl.SaslException; +import org.apache.hadoop.hbase.exceptions.IllegalSaslStateException; +import org.apache.hadoop.hbase.security.auth.AuthenticateCallbackHandler; +import org.apache.hadoop.hbase.security.auth.SaslExtensions; +import org.apache.hadoop.hbase.security.auth.SaslExtensionsCallback; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerToken; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerTokenCallback; +import org.apache.yetus.audience.InterfaceAudience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@code SaslClient} implementation for SASL/OAUTHBEARER in Kafka. This + * implementation requires an instance of {@code AuthenticateCallbackHandler} + * that can handle an instance of {@link OAuthBearerTokenCallback} and return + * the {@link OAuthBearerToken} generated by the {@code login()} event on the + * {@code LoginContext}. Said handler can also optionally handle an instance of + * {@link SaslExtensionsCallback} to return any extensions generated by the + * {@code login()} event on the {@code LoginContext}. + * + * @see RFC 6750 Section 2.1 + * + * This class has been copy-and-pasted from Kafka codebase. + */ +@InterfaceAudience.Public +public class OAuthBearerSaslClient implements SaslClient { + static final byte BYTE_CONTROL_A = (byte) 0x01; + private static final Logger LOG = LoggerFactory.getLogger(OAuthBearerSaslClient.class); + private final CallbackHandler callbackHandler; + + enum State { + SEND_CLIENT_FIRST_MESSAGE, RECEIVE_SERVER_FIRST_MESSAGE, RECEIVE_SERVER_MESSAGE_AFTER_FAILURE, + COMPLETE, FAILED + } + + private State state; + + public OAuthBearerSaslClient(AuthenticateCallbackHandler callbackHandler) { + this.callbackHandler = Objects.requireNonNull(callbackHandler); + setState(State.SEND_CLIENT_FIRST_MESSAGE); + } + + public CallbackHandler callbackHandler() { + return callbackHandler; + } + + @Override + public String getMechanismName() { + return OAUTHBEARER_MECHANISM; + } + + @Override + public boolean hasInitialResponse() { + return true; + } + + @Override + public byte[] evaluateChallenge(byte[] challenge) throws SaslException { + try { + OAuthBearerTokenCallback callback = new OAuthBearerTokenCallback(); + switch (state) { + case SEND_CLIENT_FIRST_MESSAGE: + if (challenge != null && challenge.length != 0) { + throw new SaslException("Expected empty challenge"); + } + callbackHandler().handle(new Callback[] {callback}); + SaslExtensions extensions = retrieveCustomExtensions(); + + setState(State.RECEIVE_SERVER_FIRST_MESSAGE); + + return new OAuthBearerClientInitialResponse(callback.token().value(), extensions) + .toBytes(); + case RECEIVE_SERVER_FIRST_MESSAGE: + if (challenge != null && challenge.length != 0) { + String jsonErrorResponse = new String(challenge, StandardCharsets.UTF_8); + if (LOG.isDebugEnabled()) { + LOG.debug("Sending %%x01 response to server after receiving an error: {}", + jsonErrorResponse); + } + setState(State.RECEIVE_SERVER_MESSAGE_AFTER_FAILURE); + return new byte[] {BYTE_CONTROL_A}; + } + callbackHandler().handle(new Callback[] {callback}); + if (LOG.isDebugEnabled()) { + LOG.debug("Successfully authenticated as {}", callback.token().principalName()); + } + setState(State.COMPLETE); + return null; + default: + throw new IllegalSaslStateException("Unexpected challenge in Sasl client state " + state); + } + } catch (SaslException e) { + setState(State.FAILED); + throw e; + } catch (IOException | UnsupportedCallbackException e) { + setState(State.FAILED); + throw new SaslException(e.getMessage(), e); + } + } + + @Override + public boolean isComplete() { + return state == State.COMPLETE; + } + + @Override + public byte[] unwrap(byte[] incoming, int offset, int len) { + if (!isComplete()) { + throw new IllegalStateException("Authentication exchange has not completed"); + } + return Arrays.copyOfRange(incoming, offset, offset + len); + } + + @Override + public byte[] wrap(byte[] outgoing, int offset, int len) { + if (!isComplete()) { + throw new IllegalStateException("Authentication exchange has not completed"); + } + return Arrays.copyOfRange(outgoing, offset, offset + len); + } + + @Override + public Object getNegotiatedProperty(String propName) { + if (!isComplete()) { + throw new IllegalStateException("Authentication exchange has not completed"); + } + return null; + } + + @Override + public void dispose() { + } + + private void setState(State state) { + LOG.debug("Setting SASL/{} client state to {}", OAUTHBEARER_MECHANISM, state); + this.state = state; + } + + private SaslExtensions retrieveCustomExtensions() throws SaslException { + SaslExtensionsCallback extensionsCallback = new SaslExtensionsCallback(); + try { + callbackHandler().handle(new Callback[] {extensionsCallback}); + } catch (UnsupportedCallbackException e) { + LOG.debug("Extensions callback is not supported by client callback handler {}, " + + "no extensions will be added", callbackHandler()); + } catch (Exception e) { + throw new SaslException("SASL extensions could not be obtained", e); + } + + return extensionsCallback.extensions(); + } + + public static String[] mechanismNamesCompatibleWithPolicy(Map props) { + return props != null && "true".equals(String.valueOf(props.get(Sasl.POLICY_NOPLAINTEXT))) + ? new String[] {} + : new String[] { OAUTHBEARER_MECHANISM}; + } + + public static class OAuthBearerSaslClientFactory implements SaslClientFactory { + @Override + public SaslClient createSaslClient(String[] mechanisms, String authorizationId, String protocol, + String serverName, Map props, CallbackHandler callbackHandler) { + String[] mechanismNamesCompatibleWithPolicy = getMechanismNames(props); + for (String mechanism : mechanisms) { + for (String s : mechanismNamesCompatibleWithPolicy) { + if (s.equals(mechanism)) { + 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())); + } + return new OAuthBearerSaslClient((AuthenticateCallbackHandler) callbackHandler); + } + } + } + return null; + } + + @Override + public String[] getMechanismNames(Map props) { + return OAuthBearerSaslClient.mechanismNamesCompatibleWithPolicy(props); + } + } +} diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslClientProvider.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslClientProvider.java new file mode 100644 index 000000000000..1b941e6941e7 --- /dev/null +++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslClientProvider.java @@ -0,0 +1,38 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals; + +import static org.apache.hadoop.hbase.security.token.OAuthBearerTokenUtil.OAUTHBEARER_MECHANISM; +import java.security.Provider; +import java.security.Security; +import org.apache.yetus.audience.InterfaceAudience; + +@InterfaceAudience.Public +public class OAuthBearerSaslClientProvider extends Provider { + private static final long serialVersionUID = 1L; + + protected OAuthBearerSaslClientProvider() { + super("SASL/OAUTHBEARER Client Provider", 1.0, "SASL/OAUTHBEARER Client Provider for HBase"); + put("SaslClientFactory." + OAUTHBEARER_MECHANISM, + OAuthBearerSaslClient.OAuthBearerSaslClientFactory.class.getName()); + } + + public static void initialize() { + Security.addProvider(new OAuthBearerSaslClientProvider()); + } +} diff --git a/hbase-common/src/main/java/org/apache/hadoop/hbase/security/token/OAuthBearerTokenUtil.java b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/token/OAuthBearerTokenUtil.java new file mode 100644 index 000000000000..e4e8e0b3441e --- /dev/null +++ b/hbase-common/src/main/java/org/apache/hadoop/hbase/security/token/OAuthBearerTokenUtil.java @@ -0,0 +1,76 @@ +/** + * + * 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.hadoop.hbase.security.token; + +import java.security.AccessController; +import java.security.PrivilegedAction; +import javax.security.auth.Subject; +import org.apache.hadoop.hbase.security.User; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerToken; +import org.apache.hadoop.hbase.security.oauthbearer.internals.OAuthBearerSaslClientProvider; +import org.apache.hadoop.io.Text; +import org.apache.hadoop.security.token.Token; +import org.apache.yetus.audience.InterfaceAudience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility methods for obtaining OAuthBearer / JWT authentication tokens. + */ +@InterfaceAudience.Public +public final class OAuthBearerTokenUtil { + private static final Logger LOG = LoggerFactory.getLogger(OAuthBearerTokenUtil.class); + public static final String OAUTHBEARER_MECHANISM = "OAUTHBEARER"; + public static final String TOKEN_KIND = "JWT_AUTH_TOKEN"; + + static { + OAuthBearerSaslClientProvider.initialize(); // not part of public API + LOG.info("OAuthBearer SASL client provider has been initialized"); + } + + private OAuthBearerTokenUtil() { } + + /** + * Add token to user's subject private credentials and a hint to provider selector + * to correctly select OAuthBearer SASL provider. + */ + public static void addTokenForUser(User user, String encodedToken) { + user.addToken(new Token<>(null, null, new Text(TOKEN_KIND), null)); + user.runAs(new PrivilegedAction() { + @Override public Object run() { + Subject subject = Subject.getSubject(AccessController.getContext()); + OAuthBearerToken jwt = new OAuthBearerToken() { + @Override public String value() { + return encodedToken; + } + + @Override public long lifetimeMs() { + return 0; + } + + @Override public String principalName() { + return null; + } + }; + subject.getPrivateCredentials().add(jwt); + return null; + } + }); + } +} diff --git a/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/JwtTestUtils.java b/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/JwtTestUtils.java new file mode 100644 index 000000000000..39b4330425d9 --- /dev/null +++ b/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/JwtTestUtils.java @@ -0,0 +1,117 @@ +/* + * 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.hadoop.hbase.security.oauthbearer; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import org.apache.yetus.audience.InterfaceAudience; + +@InterfaceAudience.Public +public final class JwtTestUtils { + private final static ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles"); + public static final String USER = "user"; + + public static RSAKey generateRSAKey() throws JOSEException { + RSAKeyGenerator rsaKeyGenerator = new RSAKeyGenerator(2048); + return rsaKeyGenerator.keyID("1").generate(); + } + + public static String createSignedJwt(RSAKey rsaKey, String issuer, String subject, + LocalDate expirationTime, LocalDate issueTime, String audience) + throws JOSEException { + JWSHeader jwsHeader = + new JWSHeader.Builder(JWSAlgorithm.RS256) + .type(JOSEObjectType.JWT) + .keyID(rsaKey.getKeyID()) + .build(); + JWTClaimsSet payload = new JWTClaimsSet.Builder() + .issuer(issuer) + .subject(subject) + .issueTime(java.sql.Date.valueOf(issueTime)) + .expirationTime(java.sql.Date.valueOf(expirationTime)) + .audience(audience) + .build(); + SignedJWT signedJwt = new SignedJWT(jwsHeader, payload); + signedJwt.sign(new RSASSASigner(rsaKey)); + return signedJwt.serialize(); + } + + public static String createSignedJwt(RSAKey rsaKey) throws JOSEException { + LocalDateTime now = LocalDateTime.now(ZONE_ID); + JWSHeader jwsHeader = + new JWSHeader.Builder(JWSAlgorithm.RS256) + .type(JOSEObjectType.JWT) + .keyID(rsaKey.getKeyID()) + .build(); + JWTClaimsSet payload = new JWTClaimsSet.Builder() + .subject(USER) + .expirationTime(java.sql.Timestamp.valueOf(now.plusDays(1))) + .build(); + SignedJWT signedJwt = new SignedJWT(jwsHeader, payload); + signedJwt.sign(new RSASSASigner(rsaKey)); + return signedJwt.serialize(); + } + + public static String createSignedJwtWithAudience(RSAKey rsaKey, String aud) throws JOSEException { + LocalDateTime now = LocalDateTime.now(ZONE_ID); + JWSHeader jwsHeader = + new JWSHeader.Builder(JWSAlgorithm.RS256) + .type(JOSEObjectType.JWT) + .keyID(rsaKey.getKeyID()) + .build(); + JWTClaimsSet payload = new JWTClaimsSet.Builder() + .subject(USER) + .expirationTime(java.sql.Timestamp.valueOf(now.plusDays(1))) + .audience(aud) + .build(); + SignedJWT signedJwt = new SignedJWT(jwsHeader, payload); + signedJwt.sign(new RSASSASigner(rsaKey)); + return signedJwt.serialize(); + } + + public static String createSignedJwtWithIssuer(RSAKey rsaKey, String iss) throws JOSEException { + LocalDateTime now = LocalDateTime.now(ZONE_ID); + JWSHeader jwsHeader = + new JWSHeader.Builder(JWSAlgorithm.RS256) + .type(JOSEObjectType.JWT) + .keyID(rsaKey.getKeyID()) + .build(); + JWTClaimsSet payload = new JWTClaimsSet.Builder() + .subject(USER) + .expirationTime(java.sql.Timestamp.valueOf(now.plusDays(1))) + .issuer(iss) + .build(); + SignedJWT signedJwt = new SignedJWT(jwsHeader, payload); + signedJwt.sign(new RSASSASigner(rsaKey)); + return signedJwt.serialize(); + } + + private JwtTestUtils() { + // empty + } +} diff --git a/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerTokenCallbackTest.java b/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerTokenCallbackTest.java new file mode 100644 index 000000000000..06ce6577ce5d --- /dev/null +++ b/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerTokenCallbackTest.java @@ -0,0 +1,76 @@ +/* + * 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.hadoop.hbase.security.oauthbearer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import org.apache.hadoop.hbase.HBaseClassTestRule; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +@Category({ MiscTests.class, SmallTests.class}) +public class OAuthBearerTokenCallbackTest { + + @ClassRule + public static final HBaseClassTestRule CLASS_RULE = + HBaseClassTestRule.forClass(OAuthBearerTokenCallbackTest.class); + + private static final OAuthBearerToken TOKEN = new OAuthBearerToken() { + @Override + public String value() { + return "value"; + } + + @Override + public String principalName() { + return "principalName"; + } + + @Override + public long lifetimeMs() { + return 0; + } + }; + + @Test + public void testError() { + String errorCode = "errorCode"; + String errorDescription = "errorDescription"; + String errorUri = "errorUri"; + OAuthBearerTokenCallback callback = new OAuthBearerTokenCallback(); + callback.error(errorCode, errorDescription, errorUri); + assertEquals(errorCode, callback.errorCode()); + assertEquals(errorDescription, callback.errorDescription()); + assertEquals(errorUri, callback.errorUri()); + assertNull(callback.token()); + } + + @Test + public void testToken() { + OAuthBearerTokenCallback callback = new OAuthBearerTokenCallback(); + callback.token(TOKEN); + assertSame(TOKEN, callback.token()); + assertNull(callback.errorCode()); + assertNull(callback.errorDescription()); + assertNull(callback.errorUri()); + } +} diff --git a/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerTokenMock.java b/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerTokenMock.java new file mode 100644 index 000000000000..9e6670b19d30 --- /dev/null +++ b/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerTokenMock.java @@ -0,0 +1,34 @@ +/* + * 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.hadoop.hbase.security.oauthbearer; + +public class OAuthBearerTokenMock implements OAuthBearerToken { + @Override + public String value() { + return null; + } + + @Override + public long lifetimeMs() { + return 0; + } + + @Override + public String principalName() { + return null; + } +} diff --git a/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerClientInitialResponseTest.java b/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerClientInitialResponseTest.java new file mode 100644 index 000000000000..927ce281c41f --- /dev/null +++ b/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerClientInitialResponseTest.java @@ -0,0 +1,144 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import javax.security.sasl.SaslException; +import org.apache.hadoop.hbase.HBaseClassTestRule; +import org.apache.hadoop.hbase.security.auth.SaslExtensions; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +@Category({ MiscTests.class, SmallTests.class}) +public class OAuthBearerClientInitialResponseTest { + @ClassRule + public static final HBaseClassTestRule CLASS_RULE = + HBaseClassTestRule.forClass(OAuthBearerClientInitialResponseTest.class); + + /* + Test how a client would build a response + */ + @Test + public void testBuildClientResponseToBytes() throws Exception { + String expectedMesssage = "n,,\u0001auth=Bearer 123.345.567\u0001nineteen=42\u0001\u0001"; + + Map extensions = new HashMap<>(); + extensions.put("nineteen", "42"); + OAuthBearerClientInitialResponse response = + new OAuthBearerClientInitialResponse("123.345.567", new SaslExtensions(extensions)); + + String message = new String(response.toBytes(), StandardCharsets.UTF_8); + + assertEquals(expectedMesssage, message); + } + + @Test + public void testBuildServerResponseToBytes() throws Exception { + String serverMessage = "n,,\u0001auth=Bearer 123.345.567\u0001nineteen=42\u0001\u0001"; + OAuthBearerClientInitialResponse response = + new OAuthBearerClientInitialResponse(serverMessage.getBytes(StandardCharsets.UTF_8)); + + String message = new String(response.toBytes(), StandardCharsets.UTF_8); + + assertEquals(serverMessage, message); + } + + @Test + public void testThrowsSaslExceptionOnInvalidExtensionKey() throws Exception { + Map extensions = new HashMap<>(); + extensions.put("19", "42"); // keys can only be a-z + assertThrows( + SaslException.class, () -> new OAuthBearerClientInitialResponse("123.345.567", + new SaslExtensions(extensions))); + } + + @Test + public void testToken() throws Exception { + String message = "n,,\u0001auth=Bearer 123.345.567\u0001\u0001"; + OAuthBearerClientInitialResponse response = + new OAuthBearerClientInitialResponse(message.getBytes(StandardCharsets.UTF_8)); + assertEquals("123.345.567", response.tokenValue()); + assertEquals("", response.authorizationId()); + } + + @Test + public void testAuthorizationId() throws Exception { + String message = "n,a=myuser,\u0001auth=Bearer 345\u0001\u0001"; + OAuthBearerClientInitialResponse response = + new OAuthBearerClientInitialResponse(message.getBytes(StandardCharsets.UTF_8)); + assertEquals("345", response.tokenValue()); + assertEquals("myuser", response.authorizationId()); + } + + @Test + public void testExtensions() throws Exception { + String message = + "n,,\u0001propA=valueA1, valueA2\u0001auth=Bearer 567\u0001propB=valueB\u0001\u0001"; + OAuthBearerClientInitialResponse response = + new OAuthBearerClientInitialResponse(message.getBytes(StandardCharsets.UTF_8)); + assertEquals("567", response.tokenValue()); + assertEquals("", response.authorizationId()); + assertEquals("valueA1, valueA2", response.extensions().getExtensions().get("propA")); + assertEquals("valueB", response.extensions().getExtensions().get("propB")); + } + + // The example in the RFC uses `vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==` as the token + // But since we use Base64Url encoding, padding is omitted. Hence this test verifies without '='. + @Test + public void testRfc7688Example() throws Exception { + String message = "n,a=user@example.com,\u0001host=server.example.com\u0001port=143\u0001" + + "auth=Bearer vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg\u0001\u0001"; + OAuthBearerClientInitialResponse response = + new OAuthBearerClientInitialResponse(message.getBytes(StandardCharsets.UTF_8)); + assertEquals("vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg", response.tokenValue()); + assertEquals("user@example.com", response.authorizationId()); + assertEquals("server.example.com", response.extensions().getExtensions().get("host")); + assertEquals("143", response.extensions().getExtensions().get("port")); + } + + @Test + public void testNoExtensionsFromByteArray() throws Exception { + String message = "n,a=user@example.com,\u0001" + + "auth=Bearer vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg\u0001\u0001"; + OAuthBearerClientInitialResponse response = + new OAuthBearerClientInitialResponse(message.getBytes(StandardCharsets.UTF_8)); + assertEquals("vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg", response.tokenValue()); + assertEquals("user@example.com", response.authorizationId()); + assertTrue(response.extensions().getExtensions().isEmpty()); + } + + @Test + public void testNoExtensionsFromTokenAndNullExtensions() throws Exception { + OAuthBearerClientInitialResponse response = + new OAuthBearerClientInitialResponse("token", null); + assertTrue(response.extensions().getExtensions().isEmpty()); + } + + @Test + public void testValidateNullExtensions() throws Exception { + OAuthBearerClientInitialResponse.validateExtensions(null); + } +} diff --git a/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslClientTest.java b/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslClientTest.java new file mode 100644 index 000000000000..7bcf55cbe167 --- /dev/null +++ b/hbase-common/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslClientTest.java @@ -0,0 +1,134 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.sasl.SaslException; +import org.apache.hadoop.hbase.HBaseClassTestRule; +import org.apache.hadoop.hbase.security.auth.AuthenticateCallbackHandler; +import org.apache.hadoop.hbase.security.auth.SaslExtensions; +import org.apache.hadoop.hbase.security.auth.SaslExtensionsCallback; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerToken; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerTokenCallback; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +@Category({ MiscTests.class, SmallTests.class}) +public class OAuthBearerSaslClientTest { + @ClassRule + public static final HBaseClassTestRule CLASS_RULE = + HBaseClassTestRule.forClass(OAuthBearerSaslClientTest.class); + + private static final Map TEST_PROPERTIES = new LinkedHashMap() { + { + put("One", "1"); + put("Two", "2"); + put("Three", "3"); + } + }; + private SaslExtensions testExtensions = new SaslExtensions(TEST_PROPERTIES); + private final String errorMessage = "Error as expected!"; + + public class ExtensionsCallbackHandler implements AuthenticateCallbackHandler { + private boolean configured = false; + private boolean toThrow; + + ExtensionsCallbackHandler(boolean toThrow) { + this.toThrow = toThrow; + } + + public boolean configured() { + return configured; + } + + @Override + public void handle(Callback[] callbacks) throws UnsupportedCallbackException { + for (Callback callback : callbacks) { + if (callback instanceof OAuthBearerTokenCallback) { + ((OAuthBearerTokenCallback) callback).token(new OAuthBearerToken() { + @Override public String value() { + return ""; + } + + @Override public long lifetimeMs() { + return 100; + } + + @Override public String principalName() { + return "principalName"; + } + }); + } else if (callback instanceof SaslExtensionsCallback) { + if (toThrow) { + throw new RuntimeException(errorMessage); + } else { + ((SaslExtensionsCallback) callback).extensions(testExtensions); + } + } else { + throw new UnsupportedCallbackException(callback); + } + } + } + } + + @Test + public void testAttachesExtensionsToFirstClientMessage() throws Exception { + String expectedToken = new String( + new OAuthBearerClientInitialResponse("", testExtensions).toBytes(), StandardCharsets.UTF_8); + OAuthBearerSaslClient client = new OAuthBearerSaslClient(new ExtensionsCallbackHandler(false)); + String message = new String(client.evaluateChallenge("".getBytes(StandardCharsets.UTF_8)), + StandardCharsets.UTF_8); + assertEquals(expectedToken, message); + } + + @Test + public void testNoExtensionsDoesNotAttachAnythingToFirstClientMessage() throws Exception { + TEST_PROPERTIES.clear(); + testExtensions = new SaslExtensions(TEST_PROPERTIES); + String expectedToken = new String(new OAuthBearerClientInitialResponse("", + new SaslExtensions(TEST_PROPERTIES)).toBytes(), StandardCharsets.UTF_8); + OAuthBearerSaslClient client = new OAuthBearerSaslClient(new ExtensionsCallbackHandler(false)); + + String message = new String(client.evaluateChallenge("".getBytes(StandardCharsets.UTF_8)), + StandardCharsets.UTF_8); + + assertEquals(expectedToken, message); + } + + @Test + public void testWrapsExtensionsCallbackHandlingErrorInSaslExceptionInFirstClientMessage() { + OAuthBearerSaslClient client = new OAuthBearerSaslClient(new ExtensionsCallbackHandler(true)); + try { + client.evaluateChallenge("".getBytes(StandardCharsets.UTF_8)); + fail("Should have failed with " + SaslException.class.getName()); + } catch (SaslException e) { + // assert it has caught our expected exception + assertEquals(RuntimeException.class, e.getCause().getClass()); + assertEquals(errorMessage, e.getCause().getMessage()); + } + } +} diff --git a/hbase-common/src/test/java/org/apache/hadoop/hbase/util/ClassLoaderTestHelper.java b/hbase-common/src/test/java/org/apache/hadoop/hbase/util/ClassLoaderTestHelper.java index fd771c722b88..977040eb538b 100644 --- a/hbase-common/src/test/java/org/apache/hadoop/hbase/util/ClassLoaderTestHelper.java +++ b/hbase-common/src/test/java/org/apache/hadoop/hbase/util/ClassLoaderTestHelper.java @@ -19,7 +19,6 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; - import java.io.BufferedWriter; import java.io.File; import java.io.FileInputStream; @@ -35,7 +34,6 @@ import javax.tools.JavaFileObject; import javax.tools.StandardJavaFileManager; import javax.tools.ToolProvider; - import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.slf4j.Logger; diff --git a/hbase-examples/src/main/java/org/apache/hadoop/hbase/jwt/client/example/JwtClientExample.java b/hbase-examples/src/main/java/org/apache/hadoop/hbase/jwt/client/example/JwtClientExample.java new file mode 100644 index 000000000000..78817e0347cb --- /dev/null +++ b/hbase-examples/src/main/java/org/apache/hadoop/hbase/jwt/client/example/JwtClientExample.java @@ -0,0 +1,111 @@ +/** + * 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.hadoop.hbase.jwt.client.example; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ThreadLocalRandom; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.conf.Configured; +import org.apache.hadoop.hbase.Cell; +import org.apache.hadoop.hbase.CellBuilderFactory; +import org.apache.hadoop.hbase.CellBuilderType; +import org.apache.hadoop.hbase.HBaseConfiguration; +import org.apache.hadoop.hbase.TableName; +import org.apache.hadoop.hbase.client.Admin; +import org.apache.hadoop.hbase.client.ColumnFamilyDescriptorBuilder; +import org.apache.hadoop.hbase.client.Connection; +import org.apache.hadoop.hbase.client.ConnectionFactory; +import org.apache.hadoop.hbase.client.Put; +import org.apache.hadoop.hbase.client.Table; +import org.apache.hadoop.hbase.client.TableDescriptor; +import org.apache.hadoop.hbase.client.TableDescriptorBuilder; +import org.apache.hadoop.hbase.security.User; +import org.apache.hadoop.hbase.security.UserProvider; +import org.apache.hadoop.hbase.security.token.OAuthBearerTokenUtil; +import org.apache.hadoop.hbase.util.Bytes; +import org.apache.hadoop.util.Tool; +import org.apache.hadoop.util.ToolRunner; +import org.apache.yetus.audience.InterfaceAudience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An example of using OAuthBearer (JWT) authentication with HBase RPC client. + */ +@InterfaceAudience.Private +public class JwtClientExample extends Configured implements Tool { + private static final Logger LOG = LoggerFactory.getLogger(JwtClientExample.class); + private static final String JWT_TOKEN = ""; + + private static final byte[] FAMILY = Bytes.toBytes("d"); + + public JwtClientExample() { + Configuration conf = HBaseConfiguration.create(); + conf.set("hbase.client.sasl.provider.class", + "org.apache.hadoop.hbase.security.provider.OAuthBearerSaslProviderSelector"); + conf.set("hbase.client.sasl.provider.extras", + "org.apache.hadoop.hbase.security.provider.OAuthBearerSaslClientAuthenticationProvider"); + setConf(conf); + } + + @Override public int run(String[] args) throws Exception { + LOG.info("JWT client example has been started"); + + Configuration conf = getConf(); + LOG.info("Config = " + conf.get("hbase.client.sasl.provider.class")); + UserProvider provider = UserProvider.instantiate(conf); + User user = provider.getCurrent(); + + OAuthBearerTokenUtil.addTokenForUser(user, JWT_TOKEN); + LOG.info("JWT token added"); + + try (final Connection conn = ConnectionFactory.createConnection(conf, user)) { + LOG.info("Connected to HBase"); + Admin admin = conn.getAdmin(); + + TableName tn = TableName.valueOf("jwt-test-table"); + if (!admin.isTableAvailable(tn)) { + TableDescriptor tableDescriptor = TableDescriptorBuilder.newBuilder(tn) + .setColumnFamily(ColumnFamilyDescriptorBuilder.newBuilder(FAMILY).build()) + .build(); + admin.createTable(tableDescriptor); + } + + Table table = conn.getTable(tn); + byte[] rk = Bytes.toBytes(ThreadLocalRandom.current().nextLong()); + Put p = new Put(rk); + p.add(CellBuilderFactory.create(CellBuilderType.SHALLOW_COPY) + .setRow(rk) + .setFamily(FAMILY) + .setType(Cell.Type.Put) + .setValue("test".getBytes(StandardCharsets.UTF_8)) + .build()); + table.put(p); + + admin.disableTable(tn); + admin.deleteTable(tn); + } + + LOG.info("JWT client example is done"); + return 0; + } + + public static void main(String[] args) throws Exception { + ToolRunner.run(new JwtClientExample(), args); + } +} diff --git a/hbase-resource-bundle/src/main/resources/META-INF/LICENSE.vm b/hbase-resource-bundle/src/main/resources/META-INF/LICENSE.vm index ac7c4a75e436..fa48fe049b90 100644 --- a/hbase-resource-bundle/src/main/resources/META-INF/LICENSE.vm +++ b/hbase-resource-bundle/src/main/resources/META-INF/LICENSE.vm @@ -1343,7 +1343,7 @@ You can redistribute it and/or modify it under either the terms of the ## See this FAQ link for justifications: https://www.apache.org/legal/resolved.html ## ## NB: This list is later compared as lower-case. New entries must also be all lower-case -#set($non_aggregate_fine = [ 'public domain', 'new bsd license', 'bsd license', 'bsd', 'bsd 2-clause license', 'mozilla public license version 1.1', 'mozilla public license version 2.0', 'creative commons attribution license, version 2.5', 'apache-2.0' ]) +#set($non_aggregate_fine = [ 'public domain', 'new bsd license', 'bsd license', 'bsd', 'bsd 2-clause license', 'bsd-3-clause', 'mozilla public license version 1.1', 'mozilla public license version 2.0', 'creative commons attribution license, version 2.5', 'apache-2.0' ]) ## include LICENSE sections for anything not under ASL2.0 #foreach( ${dep} in ${projects} ) ## if there are no licenses we'll fail the build later, so diff --git a/hbase-server/pom.xml b/hbase-server/pom.xml index a40fb964ac79..9d45dfd932c1 100644 --- a/hbase-server/pom.xml +++ b/hbase-server/pom.xml @@ -528,6 +528,10 @@ log4j-1.2-api test + + com.nimbusds + nimbus-jose-jwt + diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/ipc/ServerRpcConnection.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/ipc/ServerRpcConnection.java index 4ebc9fa5325a..bddd3c55edee 100644 --- a/hbase-server/src/main/java/org/apache/hadoop/hbase/ipc/ServerRpcConnection.java +++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/ipc/ServerRpcConnection.java @@ -365,7 +365,7 @@ public void saslReadAndProcess(ByteBuff saslToken) throws IOException, throw e; } RpcServer.LOG.debug("Created SASL server with mechanism={}", - provider.getSaslAuthMethod().getAuthMethod()); + provider.getSaslAuthMethod().getSaslMechanism()); } RpcServer.LOG.debug("Read input token of size={} for processing by saslServer." + "evaluateResponse()", saslToken.limit()); diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerExtensionsValidatorCallback.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerExtensionsValidatorCallback.java new file mode 100644 index 000000000000..7519f5e76e17 --- /dev/null +++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerExtensionsValidatorCallback.java @@ -0,0 +1,126 @@ +/* + * 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.hadoop.hbase.security.oauthbearer; + +import static org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerStringUtils.subtractMap; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import javax.security.auth.callback.Callback; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.hbase.security.auth.SaslExtensions; +import org.apache.yetus.audience.InterfaceAudience; + +/** + * A {@code Callback} for use by the {@code SaslServer} implementation when it + * needs to validate the SASL extensions for the OAUTHBEARER mechanism + * Callback handlers should use the {@link #storeAsValid(String)} + * method to communicate valid extensions back to the SASL server. + * Callback handlers should use the + * {@link #storeAsError(String, String)} method to communicate validation errors back to + * the SASL Server. + * As per RFC-7628 (https://tools.ietf.org/html/rfc7628#section-3.1), unknown extensions must be ignored by the server. + * The callback handler implementation should simply ignore unknown extensions, + * not calling {@link #storeAsError(String, String)} nor {@link #storeAsValid(String)}. + * Callback handlers should communicate other problems by raising an {@code IOException}. + *

+ * The OAuth bearer token is provided in the callback for better context in extension validation. + * It is very important that token validation is done in its own + * {@link OAuthBearerValidatorCallback} irregardless of provided extensions, as they are inherently + * insecure. + */ +@InterfaceAudience.Public +public class OAuthBearerExtensionsValidatorCallback implements Callback { + private final OAuthBearerToken token; + private final SaslExtensions inputExtensions; + private final Map validatedExtensions = new HashMap<>(); + private final Map invalidExtensions = new HashMap<>(); + + public OAuthBearerExtensionsValidatorCallback(OAuthBearerToken token, SaslExtensions extensions) { + this.token = Objects.requireNonNull(token); + this.inputExtensions = Objects.requireNonNull(extensions); + } + + /** + * @return {@link OAuthBearerToken} the OAuth bearer token of the client + */ + public OAuthBearerToken getToken() { + return token; + } + + /** + * @return {@link SaslExtensions} consisting of the unvalidated extension names and values that + * were sent by the client + */ + public SaslExtensions getInputExtensions() { + return inputExtensions; + } + + /** + * @return an unmodifiable {@link Map} consisting of the validated and recognized by the server + * extension names and values. + */ + public Map getValidatedExtensions() { + return Collections.unmodifiableMap(validatedExtensions); + } + + /** + * @return An immutable {@link Map} consisting of the name->error messages of extensions + * which failed validation + */ + public Map getInvalidExtensions() { + return Collections.unmodifiableMap(invalidExtensions); + } + + /** + * @return An immutable {@link Map} consisting of the extensions that have neither been + * validated nor invalidated + */ + public Map getIgnoredExtensions() { + return Collections.unmodifiableMap( + subtractMap(subtractMap(inputExtensions.getExtensions(), invalidExtensions), + validatedExtensions)); + } + + /** + * Validates a specific extension in the original {@code inputExtensions} map + * @param extensionName - the name of the extension which was validated + */ + public void storeAsValid(String extensionName) { + if (!inputExtensions.getExtensions().containsKey(extensionName)) { + throw new IllegalArgumentException( + String.format("Extension %s was not found in the original extensions", extensionName)); + } + validatedExtensions.put(extensionName, inputExtensions.getExtensions().get(extensionName)); + } + /** + * Set the error value for a specific extension key-value pair if validation has failed + * + * @param invalidExtensionName + * the mandatory extension name which caused the validation failure + * @param errorMessage + * error message describing why the validation failed + */ + public void storeAsError(String invalidExtensionName, String errorMessage) { + if (StringUtils.isEmpty(invalidExtensionName)) { + throw new IllegalArgumentException("extension name must not be empty"); + } + this.invalidExtensions.put(invalidExtensionName, errorMessage); + } +} diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerValidatorCallback.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerValidatorCallback.java new file mode 100644 index 000000000000..ec4b3c2a329e --- /dev/null +++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerValidatorCallback.java @@ -0,0 +1,156 @@ +/* + * 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.hadoop.hbase.security.oauthbearer; + +import java.util.Objects; +import javax.security.auth.callback.Callback; +import org.apache.commons.lang3.StringUtils; +import org.apache.yetus.audience.InterfaceAudience; + +/** + * A {@code Callback} for use by the {@code SaslServer} implementation when it + * needs to provide an OAuth 2 bearer token compact serialization for + * validation. Callback handlers should use the + * {@link #error(String, String, String)} method to communicate errors back to + * the SASL Client as per + * RFC 6749: The OAuth + * 2.0 Authorization Framework and the IANA + * OAuth Extensions Error Registry. Callback handlers should communicate + * other problems by raising an {@code IOException}. + *

+ * This class was introduced in 2.0.0 and, while it feels stable, it could + * evolve. We will try to evolve the API in a compatible manner, but we reserve + * the right to make breaking changes in minor releases, if necessary. We will + * update the {@code InterfaceStability} annotation and this notice once the API + * is considered stable. + */ +@InterfaceAudience.Public +public class OAuthBearerValidatorCallback implements Callback { + private final String tokenValue; + private OAuthBearerToken token = null; + private String errorStatus = null; + private String errorScope = null; + private String errorOpenIDConfiguration = null; + + /** + * Constructor + * + * @param tokenValue + * the mandatory/non-blank token value + */ + public OAuthBearerValidatorCallback(String tokenValue) { + if (StringUtils.isEmpty(tokenValue)) { + throw new IllegalArgumentException("token value must not be empty"); + } + this.tokenValue = tokenValue; + } + + /** + * Return the (always non-null) token value + * + * @return the (always non-null) token value + */ + public String tokenValue() { + return tokenValue; + } + + /** + * Return the (potentially null) token + * + * @return the (potentially null) token + */ + public OAuthBearerToken token() { + return token; + } + + /** + * Return the (potentially null) error status value as per + * RFC 7628: A Set + * of Simple Authentication and Security Layer (SASL) Mechanisms for OAuth + * and the IANA + * OAuth Extensions Error Registry. + * + * @return the (potentially null) error status value + */ + public String errorStatus() { + return errorStatus; + } + + /** + * Return the (potentially null) error scope value as per + * RFC 7628: A Set + * of Simple Authentication and Security Layer (SASL) Mechanisms for OAuth. + * + * @return the (potentially null) error scope value + */ + public String errorScope() { + return errorScope; + } + + /** + * Return the (potentially null) error openid-configuration value as per + * RFC 7628: A Set + * of Simple Authentication and Security Layer (SASL) Mechanisms for OAuth. + * + * @return the (potentially null) error openid-configuration value + */ + public String errorOpenIDConfiguration() { + return errorOpenIDConfiguration; + } + + /** + * Set the token. The token value is unchanged and is expected to match the + * provided token's value. All error values are cleared. + * + * @param token + * the mandatory token to set + */ + public void token(OAuthBearerToken token) { + this.token = Objects.requireNonNull(token); + this.errorStatus = null; + this.errorScope = null; + this.errorOpenIDConfiguration = null; + } + + /** + * Set the error values as per + * RFC 7628: A Set + * of Simple Authentication and Security Layer (SASL) Mechanisms for OAuth. + * Any token is cleared. + * + * @param errorStatus + * the mandatory error status value from the IANA + * OAuth Extensions Error Registry to set + * @param errorScope + * the optional error scope value to set + * @param errorOpenIDConfiguration + * the optional error openid-configuration value to set + */ + public void error(String errorStatus, String errorScope, String errorOpenIDConfiguration) { + if (StringUtils.isEmpty(errorStatus)) { + throw new IllegalArgumentException("error status must not be empty"); + } + this.errorStatus = errorStatus; + this.errorScope = errorScope; + this.errorOpenIDConfiguration = errorOpenIDConfiguration; + this.token = null; + } +} diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslServer.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslServer.java new file mode 100644 index 000000000000..8adb365f67c1 --- /dev/null +++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslServer.java @@ -0,0 +1,271 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals; + +import static org.apache.hadoop.hbase.security.token.OAuthBearerTokenUtil.OAUTHBEARER_MECHANISM; +import com.nimbusds.jose.shaded.json.JSONObject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.sasl.Sasl; +import javax.security.sasl.SaslException; +import javax.security.sasl.SaslServer; +import javax.security.sasl.SaslServerFactory; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.hbase.exceptions.SaslAuthenticationException; +import org.apache.hadoop.hbase.security.auth.AuthenticateCallbackHandler; +import org.apache.hadoop.hbase.security.auth.SaslExtensions; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerExtensionsValidatorCallback; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerStringUtils; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerToken; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerValidatorCallback; +import org.apache.yetus.audience.InterfaceAudience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@code SaslServer} implementation for SASL/OAUTHBEARER in Kafka. An instance + * of {@link OAuthBearerToken} is 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). + */ +@InterfaceAudience.Public +public class OAuthBearerSaslServer implements SaslServer { + public static final Logger LOG = LoggerFactory.getLogger(OAuthBearerSaslServer.class); + private static final String NEGOTIATED_PROPERTY_KEY_TOKEN = OAUTHBEARER_MECHANISM + ".token"; + private static final String INTERNAL_ERROR_ON_SERVER = + "Authentication could not be performed due to an internal error on the server"; + static final String CREDENTIAL_LIFETIME_MS_SASL_NEGOTIATED_PROPERTY_KEY = + "CREDENTIAL.LIFETIME.MS"; + + private final AuthenticateCallbackHandler callbackHandler; + + private boolean complete; + private OAuthBearerToken tokenForNegotiatedProperty = null; + private String errorMessage = null; + private SaslExtensions extensions; + + public OAuthBearerSaslServer(CallbackHandler callbackHandler) { + if (!(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; + } + + /** + * @throws SaslAuthenticationException + * if access token cannot be validated + *

+ * Note: This method may throw + * {@link SaslAuthenticationException} to provide custom error + * messages to clients. But care should be taken to avoid including + * any information in the exception message that should not be + * leaked to unauthenticated clients. It may be safer to throw + * {@link SaslException} in some cases so that a standard error + * message is returned to clients. + *

+ */ + @Override + public byte[] evaluateResponse(byte[] response) + throws SaslException, SaslAuthenticationException { + try { + if (response.length == 1 && response[0] == OAuthBearerSaslClient.BYTE_CONTROL_A && + errorMessage != null) { + LOG.error("Received %x01 response from client after it received our error"); + throw new SaslAuthenticationException(errorMessage); + } + errorMessage = null; + + OAuthBearerClientInitialResponse clientResponse; + clientResponse = new OAuthBearerClientInitialResponse(response); + + return process(clientResponse.tokenValue(), clientResponse.authorizationId(), + clientResponse.extensions()); + } catch (SaslAuthenticationException e) { + LOG.error("SASL authentication error", e); + throw e; + } catch (Exception e) { + LOG.error("SASL server problem", e); + throw e; + } + } + + @Override + public String getAuthorizationID() { + if (!complete) { + throw new IllegalStateException("Authentication exchange has not completed"); + } + return tokenForNegotiatedProperty.principalName(); + } + + @Override + public String getMechanismName() { + return OAUTHBEARER_MECHANISM; + } + + @Override + public Object getNegotiatedProperty(String propName) { + if (!complete) { + throw new IllegalStateException("Authentication exchange has not completed"); + } + if (NEGOTIATED_PROPERTY_KEY_TOKEN.equals(propName)) { + return tokenForNegotiatedProperty; + } + if (CREDENTIAL_LIFETIME_MS_SASL_NEGOTIATED_PROPERTY_KEY.equals(propName)) { + return tokenForNegotiatedProperty.lifetimeMs(); + } + return extensions.getExtensions().get(propName); + } + + @Override + public boolean isComplete() { + return complete; + } + + @Override + public byte[] unwrap(byte[] incoming, int offset, int len) { + if (!complete) { + throw new IllegalStateException("Authentication exchange has not completed"); + } + return Arrays.copyOfRange(incoming, offset, offset + len); + } + + @Override + public byte[] wrap(byte[] outgoing, int offset, int len) { + if (!complete) { + throw new IllegalStateException("Authentication exchange has not completed"); + } + return Arrays.copyOfRange(outgoing, offset, offset + len); + } + + @Override + public void dispose() { + complete = false; + tokenForNegotiatedProperty = null; + extensions = null; + } + + private byte[] process(String tokenValue, String authorizationId, SaslExtensions extensions) + throws SaslException { + OAuthBearerValidatorCallback callback = new OAuthBearerValidatorCallback(tokenValue); + try { + callbackHandler.handle(new Callback[] {callback}); + } catch (IOException | UnsupportedCallbackException e) { + handleCallbackError(e); + } + OAuthBearerToken token = callback.token(); + if (token == null) { + errorMessage = jsonErrorResponse(callback.errorStatus(), callback.errorScope(), + callback.errorOpenIDConfiguration()); + LOG.error("JWT token validation error: {}", errorMessage); + return errorMessage.getBytes(StandardCharsets.UTF_8); + } + /* + * We support the client specifying an authorization ID as per the SASL + * specification, but it must match the principal name if it is specified. + */ + if (!authorizationId.isEmpty() && !authorizationId.equals(token.principalName())) { + throw new SaslAuthenticationException(String.format( + "Authentication failed: Client requested an authorization id (%s) that is different from " + + "the token's principal name (%s)", + authorizationId, token.principalName())); + } + + Map validExtensions = processExtensions(token, extensions); + + tokenForNegotiatedProperty = token; + this.extensions = new SaslExtensions(validExtensions); + complete = true; + LOG.debug("Successfully authenticate User={}", token.principalName()); + return new byte[0]; + } + + private Map processExtensions(OAuthBearerToken token, SaslExtensions extensions) + throws SaslException { + OAuthBearerExtensionsValidatorCallback + extensionsCallback = new OAuthBearerExtensionsValidatorCallback(token, extensions); + try { + callbackHandler.handle(new Callback[] {extensionsCallback}); + } catch (UnsupportedCallbackException e) { + // backwards compatibility - no extensions will be added + } catch (IOException e) { + handleCallbackError(e); + } + if (!extensionsCallback.getInvalidExtensions().isEmpty()) { + String errorMessage = String.format("Authentication failed: %d extensions are invalid! " + + "They are: %s", extensionsCallback.getInvalidExtensions().size(), + OAuthBearerStringUtils.mkString(extensionsCallback.getInvalidExtensions(), + "", "", ": ", "; ")); + LOG.debug(errorMessage); + throw new SaslAuthenticationException(errorMessage); + } + + return extensionsCallback.getValidatedExtensions(); + } + + private static String jsonErrorResponse(String errorStatus, String errorScope, + String errorOpenIDConfiguration) { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("status", errorStatus); + if (!StringUtils.isBlank(errorScope)) { + jsonObject.put("scope", errorScope); + } + if (!StringUtils.isBlank(errorOpenIDConfiguration)) { + jsonObject.put("openid-configuration", errorOpenIDConfiguration); + } + return jsonObject.toJSONString(); + } + + private void handleCallbackError(Exception e) throws SaslException { + String msg = String.format("%s: %s", INTERNAL_ERROR_ON_SERVER, e.getMessage()); + LOG.debug(msg, e); + throw new SaslException(msg); + } + + public static String[] mechanismNamesCompatibleWithPolicy(Map props) { + return props != null && "true".equals(String.valueOf(props.get(Sasl.POLICY_NOPLAINTEXT))) + ? new String[] {} + : new String[] { OAUTHBEARER_MECHANISM}; + } + + public static class OAuthBearerSaslServerFactory implements SaslServerFactory { + @Override + public SaslServer createSaslServer(String mechanism, String protocol, String serverName, + Map props, CallbackHandler callbackHandler) { + String[] mechanismNamesCompatibleWithPolicy = getMechanismNames(props); + for (String s : mechanismNamesCompatibleWithPolicy) { + if (s.equals(mechanism)) { + return new OAuthBearerSaslServer(callbackHandler); + } + } + return null; + } + + @Override + public String[] getMechanismNames(Map props) { + return OAuthBearerSaslServer.mechanismNamesCompatibleWithPolicy(props); + } + } +} diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslServerProvider.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslServerProvider.java new file mode 100644 index 000000000000..2d7aeed149a2 --- /dev/null +++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslServerProvider.java @@ -0,0 +1,38 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals; + +import static org.apache.hadoop.hbase.security.token.OAuthBearerTokenUtil.OAUTHBEARER_MECHANISM; +import java.security.Provider; +import java.security.Security; +import org.apache.yetus.audience.InterfaceAudience; + +@InterfaceAudience.Public +public class OAuthBearerSaslServerProvider extends Provider { + private static final long serialVersionUID = 1L; + + protected OAuthBearerSaslServerProvider() { + super("SASL/OAUTHBEARER Server Provider", 1.0, "SASL/OAUTHBEARER Server Provider for HBase"); + put("SaslServerFactory." + OAUTHBEARER_MECHANISM, + OAuthBearerSaslServer.OAuthBearerSaslServerFactory.class.getName()); + } + + public static void initialize() { + Security.addProvider(new OAuthBearerSaslServerProvider()); + } +} diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerConfigException.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerConfigException.java new file mode 100644 index 000000000000..acd5b047e1c8 --- /dev/null +++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerConfigException.java @@ -0,0 +1,37 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals.knox; + +import org.apache.yetus.audience.InterfaceAudience; + +/** + * Exception thrown when there is a problem with the configuration (an invalid + * option in a JAAS config, for example). + */ +@InterfaceAudience.Public +public class OAuthBearerConfigException extends RuntimeException { + private static final long serialVersionUID = -8056105648062343518L; + + public OAuthBearerConfigException(String s) { + super(s); + } + + public OAuthBearerConfigException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerIllegalTokenException.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerIllegalTokenException.java new file mode 100644 index 000000000000..09aa28c57ee4 --- /dev/null +++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerIllegalTokenException.java @@ -0,0 +1,65 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals.knox; + +import java.util.Objects; +import org.apache.yetus.audience.InterfaceAudience; + +/** + * Exception thrown when token validation fails due to a problem with the token + * itself (as opposed to a missing remote resource or a configuration problem) + */ +@InterfaceAudience.Public +public class OAuthBearerIllegalTokenException extends RuntimeException { + private static final long serialVersionUID = -5275276640051316350L; + private final OAuthBearerValidationResult reason; + + /** + * Constructor + * + * @param reason + * the mandatory reason for the validation failure; it must indicate + * failure + */ + public OAuthBearerIllegalTokenException(OAuthBearerValidationResult reason) { + super(Objects.requireNonNull(reason, "Reason cannot be null").failureDescription()); + if (reason.success()) { + throw new IllegalArgumentException( + "The reason indicates success; it must instead indicate failure"); + } + this.reason = reason; + } + + public OAuthBearerIllegalTokenException(OAuthBearerValidationResult reason, Throwable t) { + super(Objects.requireNonNull(reason, "Reason cannot be null").failureDescription(), t); + if (reason.success()) { + throw new IllegalArgumentException( + "The reason indicates success; it must instead indicate failure"); + } + this.reason = reason; + } + + /** + * Return the (always non-null) reason for the validation failure + * + * @return the reason for the validation failure + */ + public OAuthBearerValidationResult reason() { + return reason; + } +} diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerSignedJwt.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerSignedJwt.java new file mode 100644 index 000000000000..f17457d66edc --- /dev/null +++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerSignedJwt.java @@ -0,0 +1,193 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals.knox; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTParser; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import java.text.ParseException; +import java.util.Date; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerToken; +import org.apache.yetus.audience.InterfaceAudience; + +/** + * Signed JWT implementation for OAuth Bearer authentication mech of SASL. + * + * This class is based on Kafka's Unsecured JWS token implementation. + */ +@InterfaceAudience.Public +public class OAuthBearerSignedJwt implements OAuthBearerToken { + private final String compactSerialization; + private final JWKSet jwkSet; + + private JWTClaimsSet claims; + private long lifetime; + private int maxClockSkewSeconds = 0; + private String requiredAudience; + private String requiredIssuer; + + /** + * Constructor base64 encoded JWT token and JWK Set. + * + * @param compactSerialization + * the compact serialization to parse as a signed JWT + * @param jwkSet + * the key set which the signature of this JWT should be verified with + */ + public OAuthBearerSignedJwt(String compactSerialization, JWKSet jwkSet) { + this.jwkSet = jwkSet; + this.compactSerialization = Objects.requireNonNull(compactSerialization); + } + + @Override + public String value() { + return compactSerialization; + } + + @Override + public String principalName() { + return claims.getSubject(); + } + + @Override + public long lifetimeMs() { + return lifetime; + } + + /** + * Return the JWT Claim Set as a {@code Map} + * + * @return the (always non-null but possibly empty) claims + */ + public Map claims() { + return claims.getClaims(); + } + + /** + * Set required audience, as per + * + * RFC7519 Section 4.1.3 + */ + public OAuthBearerSignedJwt audience(String aud) { + this.requiredAudience = aud; + return this; + } + + /** + * Set required issuer, as per + * + * RFC7519 Section 4.1.1 + */ + public OAuthBearerSignedJwt issuer(String iss) { + this.requiredIssuer = iss; + return this; + } + + /** + * Set maximum clock skew in seconds. + * @param value New value + */ + public OAuthBearerSignedJwt maxClockSkewSeconds(int value) { + this.maxClockSkewSeconds = value; + return this; + } + + /** + * This method provides a single method for validating the JWT for use in + * request processing. + * + * @throws OAuthBearerIllegalTokenException + * if the compact serialization is not a valid JWT + * (meaning it did not have 3 dot-separated Base64URL sections + * with a digital signature; or the header or claims + * either are not valid Base 64 URL encoded values or are not JSON + * after decoding; or the mandatory '{@code alg}' header value is + * missing) + */ + public OAuthBearerSignedJwt validate(){ + try { + this.claims = validateToken(compactSerialization); + Date expirationTimeSeconds = claims.getExpirationTime(); + if (expirationTimeSeconds == null) { + throw new OAuthBearerIllegalTokenException( + OAuthBearerValidationResult.newFailure("No expiration time in JWT")); + } + lifetime = expirationTimeSeconds.toInstant().toEpochMilli(); + String principalName = claims.getSubject(); + if (StringUtils.isBlank(principalName)) { + throw new OAuthBearerIllegalTokenException(OAuthBearerValidationResult + .newFailure("No principal name in JWT claim")); + } + return this; + } catch (ParseException | BadJOSEException | JOSEException e) { + throw new OAuthBearerIllegalTokenException( + OAuthBearerValidationResult.newFailure("Token validation failed: " + e.getMessage()), e); + } + } + + private JWTClaimsSet validateToken(String jwtToken) + throws BadJOSEException, JOSEException, ParseException { + JWT jwt = JWTParser.parse(jwtToken); + ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); + + Set requiredClaims = new HashSet<>(); + JWTClaimsSet.Builder jwtClaimsSetBuilder = new JWTClaimsSet.Builder(); + + // Audience + if (!StringUtils.isBlank(requiredAudience)) { + requiredClaims.add("aud"); + jwtClaimsSetBuilder.audience(requiredAudience); + } + + // Issuer + if (!StringUtils.isBlank(requiredIssuer)) { + requiredClaims.add("iss"); + jwtClaimsSetBuilder.issuer(requiredIssuer); + } + + // Subject / Principal is always required + requiredClaims.add("sub"); + + DefaultJWTClaimsVerifier jwtClaimsSetVerifier = + new DefaultJWTClaimsVerifier<>(jwtClaimsSetBuilder.build(), requiredClaims); + jwtClaimsSetVerifier.setMaxClockSkew(maxClockSkewSeconds); + jwtProcessor.setJWTClaimsSetVerifier(jwtClaimsSetVerifier); + + JWSKeySelector keySelector = + new JWSVerificationKeySelector<>((JWSAlgorithm)jwt.getHeader().getAlgorithm(), + new ImmutableJWKSet<>(jwkSet)); + jwtProcessor.setJWSKeySelector(keySelector); + return jwtProcessor.process(jwtToken, null); + } +} diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerSignedJwtValidatorCallbackHandler.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerSignedJwtValidatorCallbackHandler.java new file mode 100644 index 000000000000..832571fa0a57 --- /dev/null +++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerSignedJwtValidatorCallbackHandler.java @@ -0,0 +1,205 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals.knox; + +import static org.apache.hadoop.hbase.security.token.OAuthBearerTokenUtil.OAUTHBEARER_MECHANISM; +import com.nimbusds.jose.jwk.JWKSet; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.text.ParseException; +import java.util.Map; +import java.util.Objects; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.UnsupportedCallbackException; +import org.apache.commons.lang3.StringUtils; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hbase.security.auth.AuthenticateCallbackHandler; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerExtensionsValidatorCallback; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerValidatorCallback; +import org.apache.yetus.audience.InterfaceAudience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@code CallbackHandler} that recognizes + * {@link OAuthBearerValidatorCallback} and validates a secure (signed) OAuth 2 + * bearer token (JWT). + * + * It requires a valid JWK Set to be initialized at startup which holds the available + * RSA public keys that JWT signature can be validated with. The Set can be initialized + * via an URL or a local file. + * + * It requires there to be an "exp" (Expiration Time) + * claim of type Number. If "iat" (Issued At) or + * "nbf" (Not Before) claims are present each must be a number that + * precedes the Expiration Time claim, and if both are present the Not Before + * claim must not precede the Issued At claim. It also accepts the following + * options, none of which are required: + *
    + *
  • {@code hbase.security.oauth.jwt.jwks.url} set to a non-empty value if you + * wish to initialize the JWK Set via an URL. HTTPS URLs must have valid certificates. + *
  • + *
  • {@code hbase.security.oauth.jwt.jwks.file} set to a non-empty value if you + * wish to initialize the JWK Set from a local JSON file. + *
  • + *
  • {@code hbase.security.oauth.jwt.audience} set to a String value which + * you want the desired audience ("aud") the JWT to have.
  • + *
  • {@code hbase.security.oauth.jwt.issuer} set to a String value which + * you want the issuer ("iss") of the JWT has to be.
  • + *
  • {@code hbase.security.oauth.jwt.allowableclockskewseconds} set to a positive integer + * value if you wish to allow up to some number of positive seconds of + * clock skew (the default is 0)
  • + *
+ * + * It also recognizes {@link OAuthBearerExtensionsValidatorCallback} and validates + * every extension passed to it. + * + * This class is based on Kafka's OAuthBearerUnsecuredValidatorCallbackHandler. + */ +@InterfaceAudience.Public +public class OAuthBearerSignedJwtValidatorCallbackHandler implements AuthenticateCallbackHandler { + private static final Logger LOG = + LoggerFactory.getLogger(OAuthBearerSignedJwtValidatorCallbackHandler.class); + private static final String OPTION_PREFIX = "hbase.security.oauth.jwt."; + private static final String JWKS_URL = OPTION_PREFIX + "jwks.url"; + private static final String JWKS_FILE = OPTION_PREFIX + "jwks.file"; + private static final String ALLOWABLE_CLOCK_SKEW_SECONDS_OPTION = + OPTION_PREFIX + "allowableclockskewseconds"; + static final String REQUIRED_AUDIENCE_OPTION = OPTION_PREFIX + "audience"; + static final String REQUIRED_ISSUER_OPTION = OPTION_PREFIX + "issuer"; + private Configuration hBaseConfiguration; + private JWKSet jwkSet; + private boolean configured = false; + + @Override + public void handle(Callback[] callbacks) throws UnsupportedCallbackException { + if (!configured) { + throw new RuntimeException( + "OAuthBearerSignedJwtValidatorCallbackHandler must be configured first."); + } + + for (Callback callback : callbacks) { + if (callback instanceof OAuthBearerValidatorCallback) { + OAuthBearerValidatorCallback validationCallback = (OAuthBearerValidatorCallback) callback; + try { + handleCallback(validationCallback); + } catch (OAuthBearerIllegalTokenException e) { + LOG.error("Signed JWT token validation error: {}", e.getMessage()); + OAuthBearerValidationResult failureReason = e.reason(); + String failureScope = failureReason.failureScope(); + validationCallback.error(failureScope != null ? "insufficient_scope" : "invalid_token", + failureScope, failureReason.failureOpenIdConfig()); + } + } else if (callback instanceof OAuthBearerExtensionsValidatorCallback) { + OAuthBearerExtensionsValidatorCallback extensionsCallback = + (OAuthBearerExtensionsValidatorCallback) callback; + extensionsCallback.getInputExtensions().getExtensions().forEach((extensionName, v) -> + extensionsCallback.storeAsValid(extensionName)); + } else { + throw new UnsupportedCallbackException(callback); + } + } + } + + @Override public void configure(Configuration configs, String saslMechanism, + Map saslProps) { + if (!OAUTHBEARER_MECHANISM.equals(saslMechanism)) { + throw new IllegalArgumentException( + String.format("Unexpected SASL mechanism: %s", saslMechanism)); + } + + this.hBaseConfiguration = configs; + + try { + loadJwkSet(); + } catch (IOException | ParseException e) { + throw new RuntimeException("Unable to initialize JWK Set", e); + } + + configured = true; + } + + @InterfaceAudience.Private + public void configure(Configuration configs, JWKSet jwkSet) { + this.hBaseConfiguration = Objects.requireNonNull(configs); + this.jwkSet = Objects.requireNonNull(jwkSet); + this.configured = true; + } + + private void handleCallback(OAuthBearerValidatorCallback callback) { + String tokenValue = callback.tokenValue(); + if (tokenValue == null) { + throw new IllegalArgumentException("Callback missing required token value"); + } + OAuthBearerSignedJwt signedJwt = new OAuthBearerSignedJwt(tokenValue, jwkSet) + .audience(requiredAudience()) + .issuer(requiredIssuer()) + .maxClockSkewSeconds(allowableClockSkewSeconds()) + .validate(); + + LOG.info("Successfully validated token with principal {}: {}", signedJwt.principalName(), + signedJwt.claims()); + callback.token(signedJwt); + } + + private String requiredAudience() { + return hBaseConfiguration.get(REQUIRED_AUDIENCE_OPTION); + } + + private String requiredIssuer() { + return hBaseConfiguration.get(REQUIRED_ISSUER_OPTION); + } + + private int allowableClockSkewSeconds() { + String allowableClockSkewSecondsValue = hBaseConfiguration.get( + ALLOWABLE_CLOCK_SKEW_SECONDS_OPTION); + int allowableClockSkewSeconds = 0; + try { + allowableClockSkewSeconds = StringUtils.isBlank(allowableClockSkewSecondsValue) + ? 0 : Integer.parseInt(allowableClockSkewSecondsValue.trim()); + } catch (NumberFormatException e) { + throw new OAuthBearerConfigException(e.getMessage(), e); + } + if (allowableClockSkewSeconds < 0) { + throw new OAuthBearerConfigException( + String.format("Allowable clock skew seconds must not be negative: %s", + allowableClockSkewSecondsValue)); + } + return allowableClockSkewSeconds; + } + + private void loadJwkSet() throws IOException, ParseException { + String jwksFile = hBaseConfiguration.get(JWKS_FILE); + String jwksUrl = hBaseConfiguration.get(JWKS_URL); + + if (StringUtils.isBlank(jwksFile) && StringUtils.isBlank(jwksUrl)) { + throw new RuntimeException("Failed to initialize JWKS db. " + + JWKS_FILE + " or " + JWKS_URL + " must be specified in the config."); + } + + if (!StringUtils.isBlank(jwksFile)) { + this.jwkSet = JWKSet.load(new File(jwksFile)); + LOG.debug("JWKS db initialized from file: {}", jwksFile); + return; + } + + this.jwkSet = JWKSet.load(new URL(jwksUrl)); + LOG.debug("JWKS db initialized from URL: {}", jwksUrl); + } +} diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerValidationResult.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerValidationResult.java new file mode 100644 index 000000000000..d41962d5e67b --- /dev/null +++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerValidationResult.java @@ -0,0 +1,135 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals.knox; + +import java.io.Serializable; +import org.apache.yetus.audience.InterfaceAudience; + +/** + * The result of some kind of token validation + * + * This class has been copy-and-pasted from Kafka codebase. + */ +@InterfaceAudience.Public +public final class OAuthBearerValidationResult implements Serializable { + private static final long serialVersionUID = 5774669940899777373L; + private final boolean success; + private final String failureDescription; + private final String failureScope; + private final String failureOpenIdConfig; + + /** + * Return an instance indicating success + * + * @return an instance indicating success + */ + public static OAuthBearerValidationResult newSuccess() { + return new OAuthBearerValidationResult(true, null, null, null); + } + + /** + * Return a new validation failure instance + * + * @param failureDescription + * optional description of the failure + * @return a new validation failure instance + */ + public static OAuthBearerValidationResult newFailure(String failureDescription) { + return newFailure(failureDescription, null, null); + } + + /** + * Return a new validation failure instance + * + * @param failureDescription + * optional description of the failure + * @param failureScope + * optional scope to be reported with the failure + * @param failureOpenIdConfig + * optional OpenID Connect configuration to be reported with the + * failure + * @return a new validation failure instance + */ + public static OAuthBearerValidationResult newFailure(String failureDescription, + String failureScope, String failureOpenIdConfig) { + return new OAuthBearerValidationResult(false, failureDescription, failureScope, + failureOpenIdConfig); + } + + private OAuthBearerValidationResult(boolean success, String failureDescription, + String failureScope, String failureOpenIdConfig) { + if (success && (failureScope != null || failureOpenIdConfig != null)) { + throw new IllegalArgumentException( + "success was indicated but failure scope/OpenIdConfig were provided"); + } + this.success = success; + this.failureDescription = failureDescription; + this.failureScope = failureScope; + this.failureOpenIdConfig = failureOpenIdConfig; + } + + /** + * Return true if this instance indicates success, otherwise false + * + * @return true if this instance indicates success, otherwise false + */ + public boolean success() { + return success; + } + + /** + * Return the (potentially null) descriptive message for the failure + * + * @return the (potentially null) descriptive message for the failure + */ + public String failureDescription() { + return failureDescription; + } + + /** + * Return the (potentially null) scope to be reported with the failure + * + * @return the (potentially null) scope to be reported with the failure + */ + public String failureScope() { + return failureScope; + } + + /** + * Return the (potentially null) OpenID Connect configuration to be reported + * with the failure + * + * @return the (potentially null) OpenID Connect configuration to be reported + * with the failure + */ + public String failureOpenIdConfig() { + return failureOpenIdConfig; + } + + /** + * Raise an exception if this instance indicates failure, otherwise do nothing + * + * @throws OAuthBearerIllegalTokenException + * if this instance indicates failure + */ + public void throwExceptionIfFailed() throws OAuthBearerIllegalTokenException { + if (!success()) { + throw new OAuthBearerIllegalTokenException(this); + } + } +} diff --git a/hbase-server/src/main/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslServerAuthenticationProvider.java b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslServerAuthenticationProvider.java new file mode 100644 index 000000000000..b6f8078ccbe8 --- /dev/null +++ b/hbase-server/src/main/java/org/apache/hadoop/hbase/security/provider/OAuthBearerSaslServerAuthenticationProvider.java @@ -0,0 +1,100 @@ +/* + * 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.hadoop.hbase.security.provider; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.security.PrivilegedExceptionAction; +import java.util.Map; +import javax.security.sasl.Sasl; +import javax.security.sasl.SaslException; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hbase.security.auth.AuthenticateCallbackHandler; +import org.apache.hadoop.hbase.security.oauthbearer.internals.OAuthBearerSaslServerProvider; +import org.apache.hadoop.hbase.security.oauthbearer.internals.knox.OAuthBearerSignedJwtValidatorCallbackHandler; +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.hadoop.security.token.SecretManager; +import org.apache.hadoop.security.token.TokenIdentifier; +import org.apache.yetus.audience.InterfaceAudience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@InterfaceAudience.Private +public class OAuthBearerSaslServerAuthenticationProvider + extends OAuthBearerSaslAuthenticationProvider + implements SaslServerAuthenticationProvider { + + private static final Logger LOG = LoggerFactory.getLogger( + OAuthBearerSaslServerAuthenticationProvider.class); + private Configuration hbaseConfiguration; + private boolean initialized = false; + + static { + OAuthBearerSaslServerProvider.initialize(); // not part of public API + LOG.info("OAuthBearer SASL server provider has been initialized"); + } + + @Override public void init(Configuration conf) throws IOException { + this.hbaseConfiguration = conf; + this.initialized = true; + } + + @Override public AttemptingUserProvidingSaslServer createServer( + SecretManager secretManager, Map saslProps) + throws IOException { + + if (!initialized) { + throw new IllegalStateException( + "OAuthBearerSaslServerAuthenticationProvider must be initialized first."); + } + + UserGroupInformation current = UserGroupInformation.getCurrentUser(); + String fullName = current.getUserName(); + LOG.debug("Server's OAuthBearer user name is {}", fullName); + LOG.debug("OAuthBearer saslProps = {}", saslProps); + + try { + return current.doAs(new PrivilegedExceptionAction() { + @Override + public AttemptingUserProvidingSaslServer run() throws SaslException { + AuthenticateCallbackHandler callbackHandler = + new OAuthBearerSignedJwtValidatorCallbackHandler(); + callbackHandler.configure(hbaseConfiguration, getSaslAuthMethod().getSaslMechanism(), + saslProps); + return new AttemptingUserProvidingSaslServer(Sasl.createSaslServer( + getSaslAuthMethod().getSaslMechanism(), null, null, saslProps, + callbackHandler), () -> null); + } + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException("Failed to construct OAUTHBEARER SASL server"); + } + } + + @Override public boolean supportsProtocolAuthentication() { + return true; + } + + @Override public UserGroupInformation getAuthorizedUgi(String authzId, + SecretManager secretManager) { + UserGroupInformation ugi = UserGroupInformation.createRemoteUser(authzId); + ugi.setAuthenticationMethod(getSaslAuthMethod().getAuthMethod()); + return ugi; + } +} diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerExtensionsValidatorCallbackTest.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerExtensionsValidatorCallbackTest.java new file mode 100644 index 000000000000..f5cccf825d37 --- /dev/null +++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerExtensionsValidatorCallbackTest.java @@ -0,0 +1,104 @@ +/* + * 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.hadoop.hbase.security.oauthbearer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import java.util.HashMap; +import java.util.Map; +import org.apache.hadoop.hbase.HBaseClassTestRule; +import org.apache.hadoop.hbase.security.auth.SaslExtensions; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +@Category({ MiscTests.class, SmallTests.class}) +public class OAuthBearerExtensionsValidatorCallbackTest { + @ClassRule + public static final HBaseClassTestRule CLASS_RULE = + HBaseClassTestRule.forClass(OAuthBearerExtensionsValidatorCallbackTest.class); + + private static final OAuthBearerToken TOKEN = new OAuthBearerTokenMock(); + + @Test + public void testValidatedExtensionsAreReturned() { + Map extensions = new HashMap<>(); + extensions.put("hello", "bye"); + + OAuthBearerExtensionsValidatorCallback callback = + new OAuthBearerExtensionsValidatorCallback(TOKEN, new SaslExtensions(extensions)); + + assertTrue(callback.getValidatedExtensions().isEmpty()); + assertTrue(callback.getInvalidExtensions().isEmpty()); + callback.storeAsValid("hello"); + assertFalse(callback.getValidatedExtensions().isEmpty()); + assertEquals("bye", callback.getValidatedExtensions().get("hello")); + assertTrue(callback.getInvalidExtensions().isEmpty()); + } + + @Test + public void testInvalidExtensionsAndErrorMessagesAreReturned() { + Map extensions = new HashMap<>(); + extensions.put("hello", "bye"); + + OAuthBearerExtensionsValidatorCallback callback = + new OAuthBearerExtensionsValidatorCallback(TOKEN, new SaslExtensions(extensions)); + + assertTrue(callback.getValidatedExtensions().isEmpty()); + assertTrue(callback.getInvalidExtensions().isEmpty()); + callback.storeAsError("hello", "error"); + assertFalse(callback.getInvalidExtensions().isEmpty()); + assertEquals("error", callback.getInvalidExtensions().get("hello")); + assertTrue(callback.getValidatedExtensions().isEmpty()); + } + + /** + * Extensions that are neither validated or invalidated must not be present in either maps + */ + @Test + public void testUnvalidatedExtensionsAreIgnored() { + Map extensions = new HashMap<>(); + extensions.put("valid", "valid"); + extensions.put("error", "error"); + extensions.put("nothing", "nothing"); + + OAuthBearerExtensionsValidatorCallback callback = + new OAuthBearerExtensionsValidatorCallback(TOKEN, new SaslExtensions(extensions)); + callback.storeAsError("error", "error"); + callback.storeAsValid("valid"); + + assertFalse(callback.getValidatedExtensions().containsKey("nothing")); + assertFalse(callback.getInvalidExtensions().containsKey("nothing")); + assertEquals("nothing", callback.getIgnoredExtensions().get("nothing")); + } + + @Test + public void testCannotValidateExtensionWhichWasNotGiven() { + Map extensions = new HashMap<>(); + extensions.put("hello", "bye"); + + OAuthBearerExtensionsValidatorCallback callback = + new OAuthBearerExtensionsValidatorCallback(TOKEN, new SaslExtensions(extensions)); + + assertThrows(IllegalArgumentException.class, () -> callback.storeAsValid("???")); + } +} diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerValidatorCallbackTest.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerValidatorCallbackTest.java new file mode 100644 index 000000000000..8be3cfcaf4a4 --- /dev/null +++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/OAuthBearerValidatorCallbackTest.java @@ -0,0 +1,75 @@ +/* + * 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.hadoop.hbase.security.oauthbearer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import org.apache.hadoop.hbase.HBaseClassTestRule; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +@Category({ MiscTests.class, SmallTests.class}) +public class OAuthBearerValidatorCallbackTest { + @ClassRule + public static final HBaseClassTestRule CLASS_RULE = + HBaseClassTestRule.forClass(OAuthBearerValidatorCallbackTest.class); + + private static final OAuthBearerToken TOKEN = new OAuthBearerToken() { + @Override + public String value() { + return "value"; + } + + @Override + public String principalName() { + return "principalName"; + } + + @Override + public long lifetimeMs() { + return 0; + } + }; + + @Test + public void testError() { + String errorStatus = "errorStatus"; + String errorScope = "errorScope"; + String errorOpenIDConfiguration = "errorOpenIDConfiguration"; + OAuthBearerValidatorCallback callback = new OAuthBearerValidatorCallback(TOKEN.value()); + callback.error(errorStatus, errorScope, errorOpenIDConfiguration); + assertEquals(errorStatus, callback.errorStatus()); + assertEquals(errorScope, callback.errorScope()); + assertEquals(errorOpenIDConfiguration, callback.errorOpenIDConfiguration()); + assertNull(callback.token()); + } + + @Test + public void testToken() { + OAuthBearerValidatorCallback callback = new OAuthBearerValidatorCallback(TOKEN.value()); + callback.token(TOKEN); + assertSame(TOKEN, callback.token()); + assertNull(callback.errorStatus()); + assertNull(callback.errorScope()); + assertNull(callback.errorOpenIDConfiguration()); + } +} diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslServerTest.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslServerTest.java new file mode 100644 index 000000000000..a16a6d6a969c --- /dev/null +++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/OAuthBearerSaslServerTest.java @@ -0,0 +1,228 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals; + +import static org.apache.hadoop.hbase.security.oauthbearer.JwtTestUtils.USER; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.UnsupportedCallbackException; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hbase.HBaseClassTestRule; +import org.apache.hadoop.hbase.exceptions.SaslAuthenticationException; +import org.apache.hadoop.hbase.security.auth.AuthenticateCallbackHandler; +import org.apache.hadoop.hbase.security.auth.SaslExtensions; +import org.apache.hadoop.hbase.security.oauthbearer.JwtTestUtils; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerExtensionsValidatorCallback; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerToken; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerTokenMock; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerValidatorCallback; +import org.apache.hadoop.hbase.security.oauthbearer.internals.knox.OAuthBearerConfigException; +import org.apache.hadoop.hbase.security.oauthbearer.internals.knox.OAuthBearerSignedJwtValidatorCallbackHandler; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +@Category({ MiscTests.class, SmallTests.class}) +public class OAuthBearerSaslServerTest { + @ClassRule + public static final HBaseClassTestRule CLASS_RULE = + HBaseClassTestRule.forClass(OAuthBearerSaslServerTest.class); + + private static final Configuration CONFIGS; + private static final AuthenticateCallbackHandler EXTENSIONS_VALIDATOR_CALLBACK_HANDLER; + static { + CONFIGS = new Configuration(); + EXTENSIONS_VALIDATOR_CALLBACK_HANDLER = new OAuthBearerSignedJwtValidatorCallbackHandler() { + @Override + public void handle(Callback[] callbacks) throws UnsupportedCallbackException { + for (Callback callback : callbacks) { + if (callback instanceof OAuthBearerValidatorCallback) { + OAuthBearerValidatorCallback validationCallback = + (OAuthBearerValidatorCallback) callback; + validationCallback.token(new OAuthBearerTokenMock()); + } else if (callback instanceof OAuthBearerExtensionsValidatorCallback) { + OAuthBearerExtensionsValidatorCallback extensionsCallback = + (OAuthBearerExtensionsValidatorCallback) callback; + extensionsCallback.storeAsValid("firstKey"); + extensionsCallback.storeAsValid("secondKey"); + } else { + throw new UnsupportedCallbackException(callback); + } + } + } + }; + } + + private String JWT; + private OAuthBearerSaslServer saslServer; + + @Before + public void setUp() throws JOSEException { + RSAKey rsaKey = JwtTestUtils.generateRSAKey(); + JWT = JwtTestUtils.createSignedJwt(rsaKey); + OAuthBearerSignedJwtValidatorCallbackHandler validatorCallbackHandler = + new OAuthBearerSignedJwtValidatorCallbackHandler(); + validatorCallbackHandler.configure(CONFIGS, new JWKSet(rsaKey)); + // only validate extensions "firstKey" and "secondKey" + saslServer = new OAuthBearerSaslServer(validatorCallbackHandler); + } + + @Test + public void noAuthorizationIdSpecified() throws Exception { + byte[] nextChallenge = saslServer + .evaluateResponse(clientInitialResponse(null)); + // also asserts that no authentication error is thrown + // if OAuthBearerExtensionsValidatorCallback is not supported + assertTrue("Next challenge is not empty",nextChallenge.length == 0); + } + + @Test + public void negotiatedProperty() throws Exception { + saslServer.evaluateResponse(clientInitialResponse(USER)); + OAuthBearerToken token = + (OAuthBearerToken) saslServer.getNegotiatedProperty("OAUTHBEARER.token"); + assertNotNull(token); + assertEquals(token.lifetimeMs(), + saslServer.getNegotiatedProperty( + OAuthBearerSaslServer.CREDENTIAL_LIFETIME_MS_SASL_NEGOTIATED_PROPERTY_KEY)); + } + + /** + * SASL Extensions that are validated by the callback handler should be accessible through + * the {@code #getNegotiatedProperty()} method + */ + @Test + public void savesCustomExtensionAsNegotiatedProperty() throws Exception { + Map customExtensions = new HashMap<>(); + customExtensions.put("firstKey", "value1"); + customExtensions.put("secondKey", "value2"); + + byte[] nextChallenge = saslServer + .evaluateResponse(clientInitialResponse(null, false, customExtensions)); + + assertTrue("Next challenge is not empty", nextChallenge.length == 0); + assertEquals("value1", saslServer.getNegotiatedProperty("firstKey")); + assertEquals("value2", saslServer.getNegotiatedProperty("secondKey")); + } + + /** + * SASL Extensions that were not recognized (neither validated nor invalidated) + * by the callback handler must not be accessible through the {@code #getNegotiatedProperty()} + * method + */ + @Test + public void unrecognizedExtensionsAreNotSaved() throws Exception { + saslServer = new OAuthBearerSaslServer(EXTENSIONS_VALIDATOR_CALLBACK_HANDLER); + Map customExtensions = new HashMap<>(); + customExtensions.put("firstKey", "value1"); + customExtensions.put("secondKey", "value1"); + customExtensions.put("thirdKey", "value1"); + + byte[] nextChallenge = saslServer + .evaluateResponse(clientInitialResponse(null, false, customExtensions)); + + assertTrue("Next challenge is not empty", nextChallenge.length == 0); + assertNull("Extensions not recognized by the server must be ignored", + saslServer.getNegotiatedProperty("thirdKey")); + } + + /** + * If the callback handler handles the `OAuthBearerExtensionsValidatorCallback` + * and finds an invalid extension, SaslServer should throw an authentication exception + */ + @Test + public void throwsAuthenticationExceptionOnInvalidExtensions() { + OAuthBearerSignedJwtValidatorCallbackHandler invalidHandler = + new OAuthBearerSignedJwtValidatorCallbackHandler() { + @Override + public void handle(Callback[] callbacks) throws UnsupportedCallbackException { + for (Callback callback : callbacks) { + if (callback instanceof OAuthBearerValidatorCallback) { + OAuthBearerValidatorCallback validationCallback = + (OAuthBearerValidatorCallback) callback; + validationCallback.token(new OAuthBearerTokenMock()); + } else if (callback instanceof OAuthBearerExtensionsValidatorCallback) { + OAuthBearerExtensionsValidatorCallback extensionsCallback = + (OAuthBearerExtensionsValidatorCallback) callback; + extensionsCallback.storeAsError("firstKey", "is not valid"); + extensionsCallback.storeAsError("secondKey", "is not valid either"); + } else { + throw new UnsupportedCallbackException(callback); + } + } + } + }; + saslServer = new OAuthBearerSaslServer(invalidHandler); + Map customExtensions = new HashMap<>(); + customExtensions.put("firstKey", "value"); + customExtensions.put("secondKey", "value"); + + assertThrows(SaslAuthenticationException.class, + () -> saslServer.evaluateResponse(clientInitialResponse(null, false, customExtensions))); + } + + @Test + public void authorizatonIdEqualsAuthenticationId() throws Exception { + byte[] nextChallenge = saslServer + .evaluateResponse(clientInitialResponse(USER)); + assertTrue("Next challenge is not empty", nextChallenge.length == 0); + } + + @Test + public void authorizatonIdNotEqualsAuthenticationId() { + assertThrows(SaslAuthenticationException.class, + () -> saslServer.evaluateResponse(clientInitialResponse(USER + "x"))); + } + + @Test + public void illegalToken() throws Exception { + byte[] bytes = saslServer.evaluateResponse(clientInitialResponse(null, true, + Collections.emptyMap())); + String challenge = new String(bytes, StandardCharsets.UTF_8); + assertEquals("{\"status\":\"invalid_token\"}", challenge); + } + + private byte[] clientInitialResponse(String authorizationId) + throws OAuthBearerConfigException, IOException { + return clientInitialResponse(authorizationId, false, Collections.emptyMap()); + } + + private byte[] clientInitialResponse(String authorizationId, boolean illegalToken, + Map customExtensions) + throws OAuthBearerConfigException, IOException { + String compactSerialization = JWT; + String tokenValue = compactSerialization + (illegalToken ? "AB" : ""); + return new OAuthBearerClientInitialResponse(tokenValue, authorizationId, + new SaslExtensions(customExtensions)).toBytes(); + } +} diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerSignedJwtTest.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerSignedJwtTest.java new file mode 100644 index 000000000000..f4e9bfac7692 --- /dev/null +++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerSignedJwtTest.java @@ -0,0 +1,120 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals.knox; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Date; +import org.apache.hadoop.hbase.HBaseClassTestRule; +import org.apache.hadoop.hbase.security.oauthbearer.JwtTestUtils; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +@Category({ MiscTests.class, SmallTests.class}) +public class OAuthBearerSignedJwtTest { + @ClassRule + public static final HBaseClassTestRule CLASS_RULE = + HBaseClassTestRule.forClass(OAuthBearerSignedJwtTest.class); + private final static ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles"); + + private JWKSet JWK_SET; + private RSAKey RSA_KEY; + + @Before + public void before() throws JOSEException { + RSA_KEY = JwtTestUtils.generateRSAKey(); + JWK_SET = new JWKSet(RSA_KEY); + } + + @Test + public void validCompactSerialization() throws JOSEException { + String subject = "foo"; + + LocalDate issuedAt = LocalDate.now(ZONE_ID); + LocalDate expirationTime = issuedAt.plusDays(1); + String validCompactSerialization = + compactSerialization(subject, issuedAt, expirationTime); + OAuthBearerSignedJwt jws = new OAuthBearerSignedJwt(validCompactSerialization, JWK_SET) + .validate(); + assertEquals(5, jws.claims().size()); + assertEquals(subject, jws.claims().get("sub")); + assertEquals(issuedAt, Date.class.cast(jws.claims().get("iat")).toInstant() + .atZone(ZoneId.systemDefault()).toLocalDate()); + assertEquals(expirationTime, Date.class.cast(jws.claims().get("exp")).toInstant() + .atZone(ZoneId.systemDefault()).toLocalDate()); + assertEquals(expirationTime.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(), + jws.lifetimeMs()); + } + + @Test + public void missingPrincipal() throws JOSEException { + String subject = null; + LocalDate issuedAt = LocalDate.now(ZONE_ID); + LocalDate expirationTime = issuedAt.plusDays(1); + String validCompactSerialization = + compactSerialization(subject, issuedAt, expirationTime); + assertThrows(OAuthBearerIllegalTokenException.class, + () -> new OAuthBearerSignedJwt(validCompactSerialization, JWK_SET).validate()); + } + + @Test + public void blankPrincipalName() throws JOSEException { + String subject = " "; + LocalDate issuedAt = LocalDate.now(ZONE_ID); + LocalDate expirationTime = issuedAt.plusDays(1); + String validCompactSerialization = + compactSerialization(subject, issuedAt, expirationTime); + assertThrows(OAuthBearerIllegalTokenException.class, + () -> new OAuthBearerSignedJwt(validCompactSerialization, JWK_SET).validate()); + } + + @Test + public void missingIssuer() throws JOSEException { + String validCompactSerialization = + JwtTestUtils.createSignedJwtWithIssuer(RSA_KEY, ""); + assertThrows(OAuthBearerIllegalTokenException.class, + () -> new OAuthBearerSignedJwt(validCompactSerialization, JWK_SET) + .issuer("test-issuer") + .validate()); + } + + @Test + public void badIssuer() throws JOSEException { + String validCompactSerialization = + JwtTestUtils.createSignedJwtWithIssuer(RSA_KEY, "bad-issuer"); + assertThrows(OAuthBearerIllegalTokenException.class, + () -> new OAuthBearerSignedJwt(validCompactSerialization, JWK_SET) + .issuer("test-issuer") + .validate()); + } + + private String compactSerialization(String subject, LocalDate issuedAt, LocalDate expirationTime) + throws JOSEException { + return JwtTestUtils.createSignedJwt(RSA_KEY, "me", subject, + expirationTime, issuedAt, "test-audience"); + } +} diff --git a/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerSignedJwtValidatorCallbackHandlerTest.java b/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerSignedJwtValidatorCallbackHandlerTest.java new file mode 100644 index 000000000000..aa6c8e63156e --- /dev/null +++ b/hbase-server/src/test/java/org/apache/hadoop/hbase/security/oauthbearer/internals/knox/OAuthBearerSignedJwtValidatorCallbackHandlerTest.java @@ -0,0 +1,166 @@ +/* + * 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.hadoop.hbase.security.oauthbearer.internals.knox; + +import static org.apache.hadoop.hbase.security.oauthbearer.internals.knox.OAuthBearerSignedJwtValidatorCallbackHandler.REQUIRED_AUDIENCE_OPTION; +import static org.apache.hadoop.hbase.security.oauthbearer.internals.knox.OAuthBearerSignedJwtValidatorCallbackHandler.REQUIRED_ISSUER_OPTION; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import java.time.LocalDate; +import java.time.ZoneId; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.UnsupportedCallbackException; +import org.apache.hadoop.hbase.HBaseClassTestRule; +import org.apache.hadoop.hbase.HBaseConfiguration; +import org.apache.hadoop.hbase.security.oauthbearer.JwtTestUtils; +import org.apache.hadoop.hbase.security.oauthbearer.OAuthBearerValidatorCallback; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +@Category({ MiscTests.class, SmallTests.class}) +public class OAuthBearerSignedJwtValidatorCallbackHandlerTest { + @ClassRule + public static final HBaseClassTestRule CLASS_RULE = + HBaseClassTestRule.forClass(OAuthBearerSignedJwtValidatorCallbackHandlerTest.class); + + private final static ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles"); + private static final HBaseConfiguration EMPTY_CONFIG = new HBaseConfiguration(); + private static final HBaseConfiguration REQUIRED_AUDIENCE_CONFIG; + static { + REQUIRED_AUDIENCE_CONFIG = new HBaseConfiguration(); + REQUIRED_AUDIENCE_CONFIG.set(REQUIRED_AUDIENCE_OPTION, "test-audience"); + } + private static final HBaseConfiguration REQUIRED_ISSUER_CONFIG; + static { + REQUIRED_ISSUER_CONFIG = new HBaseConfiguration(); + REQUIRED_ISSUER_CONFIG.set(REQUIRED_ISSUER_OPTION, "test-issuer"); + } + + private RSAKey RSA_KEY; + + @Before + public void before() throws JOSEException { + RSA_KEY = JwtTestUtils.generateRSAKey(); + } + + @Test + public void validToken() throws JOSEException, UnsupportedCallbackException { + Object validationResult = validationResult(EMPTY_CONFIG, JwtTestUtils.createSignedJwt(RSA_KEY)); + assertTrue(validationResult instanceof OAuthBearerValidatorCallback); + assertTrue(((OAuthBearerValidatorCallback) validationResult).token() + instanceof OAuthBearerSignedJwt); + } + + @Test + public void missingPrincipal() + throws UnsupportedCallbackException, JOSEException { + LocalDate now = LocalDate.now(ZONE_ID); + String token = JwtTestUtils.createSignedJwt(RSA_KEY, "me", "", + now.plusDays(1), now, "test-aud"); + confirmFailsValidation(EMPTY_CONFIG, token); + } + + @Test + public void tooEarlyExpirationTime() throws JOSEException, UnsupportedCallbackException { + LocalDate now = LocalDate.now(ZONE_ID); + String token = JwtTestUtils.createSignedJwt(RSA_KEY, "me", "", + now.minusDays(1), + now.minusDays(1), + "test-aud"); + confirmFailsValidation(EMPTY_CONFIG, token); + } + + @Test + public void requiredAudience() throws JOSEException, UnsupportedCallbackException { + String token = JwtTestUtils.createSignedJwtWithAudience(RSA_KEY, "test-audience"); + Object validationResult = validationResult(REQUIRED_AUDIENCE_CONFIG, token); + assertTrue(validationResult instanceof OAuthBearerValidatorCallback); + assertTrue(((OAuthBearerValidatorCallback) validationResult).token() + instanceof OAuthBearerSignedJwt); + } + + @Test + public void missingAudience() throws JOSEException, UnsupportedCallbackException { + String token = JwtTestUtils.createSignedJwt(RSA_KEY); + confirmFailsValidation(REQUIRED_AUDIENCE_CONFIG, token); + } + + @Test + public void badAudience() throws JOSEException, UnsupportedCallbackException { + String token = JwtTestUtils.createSignedJwtWithAudience(RSA_KEY, "bad-audience"); + confirmFailsValidation(REQUIRED_AUDIENCE_CONFIG, token); + } + + @Test + public void requiredIssuer() throws UnsupportedCallbackException, JOSEException { + String token = JwtTestUtils.createSignedJwtWithIssuer(RSA_KEY, "test-issuer"); + Object validationResult = validationResult(REQUIRED_ISSUER_CONFIG, token); + assertTrue(validationResult instanceof OAuthBearerValidatorCallback); + assertTrue(((OAuthBearerValidatorCallback) validationResult).token() + instanceof OAuthBearerSignedJwt); + } + + @Test + public void missingIssuer() throws JOSEException, UnsupportedCallbackException { + String token = JwtTestUtils.createSignedJwt(RSA_KEY); + confirmFailsValidation(REQUIRED_ISSUER_CONFIG, token); + } + + @Test + public void badIssuer() throws JOSEException, UnsupportedCallbackException { + String token = JwtTestUtils.createSignedJwtWithIssuer(RSA_KEY, "bad-issuer"); + confirmFailsValidation(REQUIRED_ISSUER_CONFIG, token); + } + + private void confirmFailsValidation(HBaseConfiguration config, String tokenValue) + throws OAuthBearerConfigException, OAuthBearerIllegalTokenException, + UnsupportedCallbackException { + Object validationResultObj = validationResult(config, tokenValue); + assertTrue(validationResultObj instanceof OAuthBearerValidatorCallback); + OAuthBearerValidatorCallback callback = (OAuthBearerValidatorCallback) validationResultObj; + assertNull(callback.token()); + assertNull(callback.errorOpenIDConfiguration()); + assertEquals("invalid_token", callback.errorStatus()); + assertNull(callback.errorScope()); + } + + private OAuthBearerValidatorCallback validationResult(HBaseConfiguration config, + String tokenValue) + throws UnsupportedCallbackException { + OAuthBearerValidatorCallback callback = new OAuthBearerValidatorCallback(tokenValue); + createCallbackHandler(config).handle(new Callback[] {callback}); + return callback; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private OAuthBearerSignedJwtValidatorCallbackHandler + createCallbackHandler(HBaseConfiguration config) { + OAuthBearerSignedJwtValidatorCallbackHandler callbackHandler = + new OAuthBearerSignedJwtValidatorCallbackHandler(); + callbackHandler.configure(config, new JWKSet(RSA_KEY)); + return callbackHandler; + } +} diff --git a/pom.xml b/pom.xml index e2a0b621bd5a..bd2c16271dfc 100755 --- a/pom.xml +++ b/pom.xml @@ -1821,6 +1821,7 @@ 1.9 1.5.0-4 4.0.1 + 9.15.2 @@ -3414,12 +3415,16 @@ hadoop-distcp ${hadoop-three.version} - org.apache.hadoop hadoop-hdfs-client ${hadoop-three.version} + + com.nimbusds + nimbus-jose-jwt + ${nimbusds.version} +