This is an automated email from the ASF dual-hosted git repository.

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new a77aef2fa3 [#12300] feat(client:client-java): TLS support at the 
HTTPClient layer- #12300 (#12449)
a77aef2fa3 is described below

commit a77aef2fa3de5818623a4f56a7083da5f5bc02ab
Author: Octavio Herrera Contreras <[email protected]>
AuthorDate: Thu Aug 13 19:00:21 2026 -0700

    [#12300] feat(client:client-java): TLS support at the HTTPClient layer- 
#12300 (#12449)
    
    [Subtask] M2: TLS support at the HTTPClient layer #12300
    
    ### What changes were proposed in this pull request?
    - Added TLSConfigurer: Default TLS interface. One note to the reviewer:
    the hostname verifier still uses an import
    org.apache.hc.client5.http.ssl.HttpsSupport to provide a default
    hostname verifier. SSLContext follows the requested "no types from
    implementation-scoped dependencies in its public signatures or default
    method bodies". I want to clarify if an hc5 import would meet or fail
    this request.
    - Added TLSConfigurers: contains all builder methods for easier ux.
    -Modified HTTPClient: Added optional TLS configuration
    - Added TestHTTPClientTLS.java: covers 6 requested test + 2 extra early
    fail tests that further prove requested functionality
    - Modified TestHttpsServerAuthentication to avoid redundant code in
    TestHTTPClientTLS by shifting a few functions into TestTlsServerUtils
    - Modified clients/client-java/build.gradle.kts: added test
    implementation to include testArtifacts published in M1
    
    
    Fix: #12300
    
    ### Does this PR introduce _any_ user-facing change?
    Yes, addition of TLSConfigurer + TLSConfigurer as described in M0.
    
    ### How was this patch tested?
    Ran:
    ./gradlew rat
    Passed
    ./gradlew :server-common:test --tests
    "org.apache.gravitino.server.web.TestHttpsServerAuthentication"
    Passed
    ./gradlew :clients:client-java:test --tests
    "org.apache.gravitino.client.TestHTTPClientTLS"
    Passed
---
 clients/client-java/build.gradle.kts               |   1 +
 .../org/apache/gravitino/client/HTTPClient.java    |  81 +++++++-
 .../org/apache/gravitino/client/TLSConfigurer.java |  68 +++++++
 .../apache/gravitino/client/TLSConfigurers.java    | 182 ++++++++++++++++++
 .../apache/gravitino/client/TestHTTPClientTLS.java | 207 +++++++++++++++++++++
 .../server/web/TestHttpsServerAuthentication.java  |  39 +---
 .../gravitino/server/web/TestTlsServerUtils.java   |  51 ++++-
 7 files changed, 585 insertions(+), 44 deletions(-)

diff --git a/clients/client-java/build.gradle.kts 
b/clients/client-java/build.gradle.kts
index b3850d71fe..5ddb7b79ee 100644
--- a/clients/client-java/build.gradle.kts
+++ b/clients/client-java/build.gradle.kts
@@ -46,6 +46,7 @@ dependencies {
   testImplementation(project(":integration-test-common", "testArtifacts"))
   testImplementation(project(":server"))
   testImplementation(project(":server-common"))
+  testImplementation(project(":server-common", "testArtifacts"))
 
   testImplementation(libs.awaitility)
   testImplementation(libs.bundles.jersey)
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/HTTPClient.java 
b/clients/client-java/src/main/java/org/apache/gravitino/client/HTTPClient.java
index 2862ac9e7c..b6a9c03785 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/HTTPClient.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/HTTPClient.java
@@ -35,6 +35,8 @@ import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
 import java.util.stream.Collectors;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Version;
 import org.apache.gravitino.auth.AuthConstants;
@@ -52,6 +54,7 @@ import 
org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
 import org.apache.hc.client5.http.impl.classic.HttpClients;
 import 
org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
 import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
 import org.apache.hc.core5.http.ContentType;
 import org.apache.hc.core5.http.Header;
 import org.apache.hc.core5.http.HttpHeaders;
@@ -64,6 +67,7 @@ import org.apache.hc.core5.http.io.entity.StringEntity;
 import org.apache.hc.core5.http.message.BasicHeader;
 import org.apache.hc.core5.io.CloseMode;
 import org.apache.hc.core5.net.URIBuilder;
+import org.apache.hc.core5.reactor.ssl.SSLBufferMode;
 
 /**
  * An HttpClient for usage with the REST catalog.
@@ -99,7 +103,8 @@ public class HTTPClient implements RESTClient {
   private volatile HandlerStatus handlerStatus = HandlerStatus.Start;
 
   /**
-   * Constructs an instance of HTTPClient with the provided information.
+   * Constructs an instance of HTTPClient with the provided information, with 
a default null TLS
+   * configurer.
    *
    * @param uri The base URI of the REST API.
    * @param baseHeaders A map of base headers to be included in all HTTP 
requests.
@@ -115,13 +120,36 @@ public class HTTPClient implements RESTClient {
       AuthDataProvider authDataProvider,
       Runnable beforeConnectHandler,
       Map<String, String> properties) {
+    this(uri, baseHeaders, objectMapper, authDataProvider, 
beforeConnectHandler, properties, null);
+  }
+
+  /**
+   * Constructs an instance of HTTPClient with the provided information.
+   *
+   * @param uri The base URI of the REST API.
+   * @param baseHeaders A map of base headers to be included in all HTTP 
requests.
+   * @param objectMapper The ObjectMapper used for JSON serialization and 
deserialization.
+   * @param authDataProvider The provider of authentication data.
+   * @param beforeConnectHandler The function to be executed before connecting 
to the server.
+   * @param properties A map of properties (key-value pairs) used to configure 
the HTTP client.
+   * @param tlsConfigurer The TLSConfigurer used to configure TLS settings for 
the HTTP client.
+   */
+  private HTTPClient(
+      String uri,
+      Map<String, String> baseHeaders,
+      ObjectMapper objectMapper,
+      AuthDataProvider authDataProvider,
+      Runnable beforeConnectHandler,
+      Map<String, String> properties,
+      TLSConfigurer tlsConfigurer) {
     this.uri = uri;
     this.mapper = objectMapper;
     GravitinoClientConfiguration clientConfiguration =
         GravitinoClientConfiguration.buildFromProperties(properties);
 
     HttpClientBuilder clientBuilder = HttpClients.custom();
-    
clientBuilder.setConnectionManager(configureConnectionManager(clientConfiguration));
+    clientBuilder.setConnectionManager(
+        configureConnectionManager(clientConfiguration, tlsConfigurer));
 
     if (baseHeaders != null) {
       clientBuilder.setDefaultHeaders(
@@ -746,7 +774,7 @@ public class HTTPClient implements RESTClient {
   }
 
   private static HttpClientConnectionManager configureConnectionManager(
-      GravitinoClientConfiguration clientConfiguration) {
+      GravitinoClientConfiguration clientConfiguration, TLSConfigurer 
tlsConfigurer) {
     PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder =
         PoolingHttpClientConnectionManagerBuilder.create();
 
@@ -757,6 +785,26 @@ public class HTTPClient implements RESTClient {
 
     ConnectionConfig connectionConfig = 
configureConnectionConfig(clientConfiguration);
     connectionManagerBuilder.setDefaultConnectionConfig(connectionConfig);
+
+    // Configure custom TLS settings, if provided
+    if (tlsConfigurer != null) {
+      SSLContext sslContext = requireTlsValue(tlsConfigurer.sslContext(), 
"SSLContext");
+      HostnameVerifier hostnameVerifier =
+          requireTlsValue(tlsConfigurer.hostnameVerifier(), 
"HostnameVerifier");
+      String[] protocols =
+          requireTlsValue(tlsConfigurer.supportedProtocols(), "supported 
protocols");
+      String[] cipherSuites =
+          requireTlsValue(tlsConfigurer.supportedCipherSuites(), "supported 
cipher suites");
+
+      connectionManagerBuilder.setTlsSocketStrategy(
+          new DefaultClientTlsStrategy(
+              sslContext,
+              protocols.length == 0 ? null : protocols,
+              cipherSuites.length == 0 ? null : cipherSuites,
+              SSLBufferMode.STATIC,
+              hostnameVerifier));
+    }
+
     return connectionManagerBuilder.build();
   }
 
@@ -782,6 +830,7 @@ public class HTTPClient implements RESTClient {
     private final Map<String, String> properties;
 
     private final Map<String, String> baseHeaders = Maps.newHashMap();
+    private TLSConfigurer tlsConfigurer;
     private String uri;
     private ObjectMapper mapper = ObjectMapperProvider.objectMapper();
     private AuthDataProvider authDataProvider;
@@ -860,6 +909,17 @@ public class HTTPClient implements RESTClient {
       return this;
     }
 
+    /**
+     * Adds TLS configuration to the HTTP client.
+     *
+     * @param tlsConfigurer The TLS configurer for custom TLS settings.
+     * @return This Builder instance for method chaining.
+     */
+    public Builder withTlsConfigurer(TLSConfigurer tlsConfigurer) {
+      this.tlsConfigurer = tlsConfigurer;
+      return this;
+    }
+
     /**
      * Builds and returns an instance of the HTTPClient with the configured 
options.
      *
@@ -868,7 +928,13 @@ public class HTTPClient implements RESTClient {
     public HTTPClient build() {
 
       return new HTTPClient(
-          uri, baseHeaders, mapper, authDataProvider, beforeConnectHandler, 
properties);
+          uri,
+          baseHeaders,
+          mapper,
+          authDataProvider,
+          beforeConnectHandler,
+          properties,
+          tlsConfigurer);
     }
   }
 
@@ -883,4 +949,11 @@ public class HTTPClient implements RESTClient {
   private StringEntity toFormEncoding(Map<?, ?> formData) {
     return new StringEntity(RESTUtils.encodeFormData(formData));
   }
+
+  private static <T> T requireTlsValue(T value, String name) {
+    if (value == null) {
+      throw new IllegalArgumentException("TLSConfigurer must provide a 
non-null " + name + ".");
+    }
+    return value;
+  }
 }
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/TLSConfigurer.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/TLSConfigurer.java
new file mode 100644
index 0000000000..2a753aa317
--- /dev/null
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/TLSConfigurer.java
@@ -0,0 +1,68 @@
+/*
+ * 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.gravitino.client;
+
+import java.security.NoSuchAlgorithmException;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+import org.apache.hc.client5.http.ssl.HttpsSupport;
+
+/** Configures TLS settings for the HTTP client. */
+public interface TLSConfigurer {
+
+  /**
+   * Returns the SSL context used for TLS connections.
+   *
+   * @return SSL context
+   */
+  default SSLContext sslContext() {
+    try {
+      return SSLContext.getDefault();
+    } catch (NoSuchAlgorithmException e) {
+      throw new IllegalStateException("Default SSLContext is unavailable", e);
+    }
+  }
+
+  /**
+   * Returns the hostname verifier used for TLS connections.
+   *
+   * @return hostname verifier
+   */
+  default HostnameVerifier hostnameVerifier() {
+    return HttpsSupport.getDefaultHostnameVerifier();
+  }
+
+  /**
+   * Returns the supported TLS protocols.
+   *
+   * @return supported TLS protocols
+   */
+  default String[] supportedProtocols() {
+    return new String[] {};
+  }
+
+  /**
+   * Returns the supported cipher suites.
+   *
+   * @return supported cipher suites
+   */
+  default String[] supportedCipherSuites() {
+    return new String[] {};
+  }
+}
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/TLSConfigurers.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/TLSConfigurers.java
new file mode 100644
index 0000000000..c68512e233
--- /dev/null
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/TLSConfigurers.java
@@ -0,0 +1,182 @@
+/*
+ * 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.gravitino.client;
+
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManagerFactory;
+
+/** Provides utility methods for creating {@link TLSConfigurer} instances. */
+public final class TLSConfigurers {
+
+  private static final String DEFAULT_STORE_TYPE = "PKCS12";
+
+  private TLSConfigurers() {}
+
+  /**
+   * Returns a new builder for creating a {@link TLSConfigurer}.
+   *
+   * @return a new builder
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /** Builder for creating a {@link TLSConfigurer} from truststore and 
keystore files. */
+  public static final class Builder {
+    private Path trustStorePath;
+    private String trustStorePassword;
+    private Path keyStorePath;
+    private String keyStorePassword;
+    private String storeType = DEFAULT_STORE_TYPE;
+
+    private Builder() {}
+
+    /**
+     * Configures the truststore used to verify the server certificate.
+     *
+     * @param path path to the truststore
+     * @param password password for the truststore
+     * @return this builder
+     */
+    public Builder trustStore(Path path, String password) {
+      this.trustStorePath = path;
+      this.trustStorePassword = password;
+      return this;
+    }
+
+    /**
+     * Configures the client keystore used for mutual TLS authentication.
+     *
+     * @param path path to the client keystore
+     * @param password password for the client keystore
+     * @return this builder
+     */
+    public Builder keyStore(Path path, String password) {
+      this.keyStorePath = path;
+      this.keyStorePassword = password;
+      return this;
+    }
+
+    /**
+     * Configures the store type used for both the truststore and client 
keystore.
+     *
+     * <p>The default store type is {@code PKCS12}.
+     *
+     * @param storeType keystore and truststore type
+     * @return this builder
+     */
+    public Builder storeType(String storeType) {
+      this.storeType = storeType;
+      return this;
+    }
+
+    /**
+     * Builds a {@link TLSConfigurer} using the configured truststore and 
optional client keystore.
+     *
+     * @return the configured TLS configurer
+     * @throws IllegalArgumentException if required configuration is missing 
or the stores cannot be
+     *     loaded
+     */
+    public TLSConfigurer build() {
+      Preconditions.checkArgument(trustStorePath != null, "Truststore path 
must be provided");
+      Preconditions.checkArgument(
+          trustStorePassword != null, "Truststore password must be provided");
+      Preconditions.checkArgument(storeType != null, "Store type must be 
provided");
+      Preconditions.checkArgument(
+          keyStorePath == null || keyStorePassword != null,
+          "Keystore password must be provided when a keystore is configured");
+
+      SSLContext sslContext = buildSslContext();
+
+      return new TLSConfigurer() {
+        @Override
+        public SSLContext sslContext() {
+          return sslContext;
+        }
+      };
+    }
+
+    /**
+     * Creates an SSL context from the configured truststore and optional 
client keystore.
+     *
+     * @return the configured SSL context
+     * @throws IllegalArgumentException if the SSL context cannot be created
+     */
+    private SSLContext buildSslContext() {
+      try {
+        KeyStore trustStore = loadStore(trustStorePath, trustStorePassword, 
"truststore");
+
+        TrustManagerFactory trustManagerFactory =
+            
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+        trustManagerFactory.init(trustStore);
+
+        SSLContext sslContext = SSLContext.getInstance("TLS");
+
+        if (keyStorePath == null) {
+          sslContext.init(null, trustManagerFactory.getTrustManagers(), null);
+          return sslContext;
+        }
+
+        KeyStore keyStore = loadStore(keyStorePath, keyStorePassword, 
"keystore");
+
+        KeyManagerFactory keyManagerFactory =
+            
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+        keyManagerFactory.init(keyStore, keyStorePassword.toCharArray());
+
+        sslContext.init(
+            keyManagerFactory.getKeyManagers(), 
trustManagerFactory.getTrustManagers(), null);
+
+        return sslContext;
+      } catch (GeneralSecurityException e) {
+        throw new IllegalArgumentException(
+            "Failed to configure TLS from the configured truststore or 
keystore", e);
+      }
+    }
+
+    /**
+     * Loads a keystore from the provided path.
+     *
+     * @param path path to the keystore
+     * @param password password for the keystore
+     * @param storeName name of the store for error messages
+     * @return the loaded keystore
+     */
+    private KeyStore loadStore(Path path, String password, String storeName) {
+      try {
+        KeyStore keyStore = KeyStore.getInstance(storeType);
+
+        try (InputStream inputStream = Files.newInputStream(path)) {
+          keyStore.load(inputStream, password.toCharArray());
+        }
+
+        return keyStore;
+      } catch (IOException | GeneralSecurityException e) {
+        throw new IllegalArgumentException("Failed to load TLS " + storeName + 
" from " + path, e);
+      }
+    }
+  }
+}
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestHTTPClientTLS.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestHTTPClientTLS.java
new file mode 100644
index 0000000000..b77752709d
--- /dev/null
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestHTTPClientTLS.java
@@ -0,0 +1,207 @@
+/*
+ * 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.gravitino.client;
+
+import static 
org.apache.gravitino.server.web.TestTlsServerUtils.TEST_STORE_PASSWORD;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.nio.file.Path;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.gravitino.exceptions.RESTException;
+import org.apache.gravitino.server.web.JettyServer;
+import org.apache.gravitino.server.web.TestTlsServerUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class TestHTTPClientTLS {
+  private static final String TEST_SERVLET_PATH = "/tls-test";
+  private static final String TEST_CLIENT_PATH = "tls-test";
+
+  private JettyServer jettyServer;
+
+  @BeforeEach
+  public void setUp() {
+    jettyServer = new JettyServer();
+  }
+
+  @AfterEach
+  public void tearDown() {
+    if (jettyServer != null) {
+      jettyServer.stop();
+    }
+  }
+
+  @Test
+  public void testMutualTlsAcceptsTrustedClientCertificate() throws Exception {
+    int port =
+        TestTlsServerUtils.startHttpsServer(
+            jettyServer, true, createTestServlet(), TEST_SERVLET_PATH);
+
+    TLSConfigurer tlsConfigurer =
+        TLSConfigurers.builder()
+            .trustStore(
+                TestTlsServerUtils.testResource("test-client-truststore.p12"), 
TEST_STORE_PASSWORD)
+            .keyStore(
+                
TestTlsServerUtils.testResource("test-trusted-client-keystore.p12"),
+                TEST_STORE_PASSWORD)
+            .build();
+
+    try (HTTPClient client = createHttpsClient(port, tlsConfigurer)) {
+      client.head(TEST_CLIENT_PATH, ImmutableMap.of(), response -> {});
+    }
+  }
+
+  @Test
+  public void testMutualTlsRejectsMissingClientCertificate() throws Exception {
+    int port =
+        TestTlsServerUtils.startHttpsServer(
+            jettyServer, true, createTestServlet(), TEST_SERVLET_PATH);
+
+    TLSConfigurer tlsConfigurer =
+        TLSConfigurers.builder()
+            .trustStore(
+                TestTlsServerUtils.testResource("test-client-truststore.p12"), 
TEST_STORE_PASSWORD)
+            .build();
+
+    try (HTTPClient client = createHttpsClient(port, tlsConfigurer)) {
+      RESTException exception =
+          assertThrows(
+              RESTException.class,
+              () -> client.head(TEST_CLIENT_PATH, ImmutableMap.of(), response 
-> {}));
+
+      TestTlsServerUtils.assertHandshakeFailure(exception);
+    }
+  }
+
+  @Test
+  public void testMutualTlsRejectsUntrustedClientCertificate() throws 
Exception {
+    int port =
+        TestTlsServerUtils.startHttpsServer(
+            jettyServer, true, createTestServlet(), TEST_SERVLET_PATH);
+
+    TLSConfigurer tlsConfigurer =
+        TLSConfigurers.builder()
+            .trustStore(
+                TestTlsServerUtils.testResource("test-client-truststore.p12"), 
TEST_STORE_PASSWORD)
+            .keyStore(
+                
TestTlsServerUtils.testResource("test-untrusted-client-keystore.p12"),
+                TEST_STORE_PASSWORD)
+            .build();
+
+    try (HTTPClient client = createHttpsClient(port, tlsConfigurer)) {
+      RESTException exception =
+          assertThrows(
+              RESTException.class,
+              () -> client.head(TEST_CLIENT_PATH, ImmutableMap.of(), response 
-> {}));
+
+      TestTlsServerUtils.assertHandshakeFailure(exception);
+    }
+  }
+
+  @Test
+  public void testHttpsWithoutClientAuthentication() throws Exception {
+    int port =
+        TestTlsServerUtils.startHttpsServer(
+            jettyServer, false, createTestServlet(), TEST_SERVLET_PATH);
+
+    TLSConfigurer tlsConfigurer =
+        TLSConfigurers.builder()
+            .trustStore(
+                TestTlsServerUtils.testResource("test-client-truststore.p12"), 
TEST_STORE_PASSWORD)
+            .build();
+
+    try (HTTPClient client = createHttpsClient(port, tlsConfigurer)) {
+      client.head(TEST_CLIENT_PATH, ImmutableMap.of(), response -> {});
+    }
+  }
+
+  @Test
+  public void testClientRejectsUntrustedServerCertificate() throws Exception {
+    int port =
+        TestTlsServerUtils.startHttpsServer(
+            jettyServer, false, createTestServlet(), TEST_SERVLET_PATH);
+
+    TLSConfigurer tlsConfigurer =
+        TLSConfigurers.builder()
+            .trustStore(
+                
TestTlsServerUtils.testResource("test-untrusted-server-truststore.p12"),
+                TEST_STORE_PASSWORD)
+            .build();
+
+    try (HTTPClient client = createHttpsClient(port, tlsConfigurer)) {
+      RESTException exception =
+          assertThrows(
+              RESTException.class,
+              () -> client.head(TEST_CLIENT_PATH, ImmutableMap.of(), response 
-> {}));
+
+      TestTlsServerUtils.assertHandshakeFailure(exception);
+    }
+  }
+
+  @Test
+  public void testHttpWithoutCustomTlsConfiguration() throws Exception {
+    int port =
+        TestTlsServerUtils.startHttpServer(jettyServer, createTestServlet(), 
TEST_SERVLET_PATH);
+
+    try (HTTPClient client =
+        HTTPClient.builder(ImmutableMap.of()).uri("http://localhost:"; + 
port).build()) {
+      client.head(TEST_CLIENT_PATH, ImmutableMap.of(), response -> {});
+    }
+  }
+
+  @Test
+  public void testInvalidTrustStorePathFailsAtBuild() {
+    Path missingPath = Path.of("does-not-exist.p12");
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> TLSConfigurers.builder().trustStore(missingPath, 
TEST_STORE_PASSWORD).build());
+  }
+
+  @Test
+  public void testWrongTrustStorePasswordFailsAtBuild() throws Exception {
+    Path trustStore = 
TestTlsServerUtils.testResource("test-client-truststore.p12");
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> TLSConfigurers.builder().trustStore(trustStore, 
"wrong-password").build());
+  }
+
+  private static HTTPClient createHttpsClient(int port, TLSConfigurer 
tlsConfigurer) {
+    return HTTPClient.builder(ImmutableMap.of())
+        .uri("https://localhost:"; + port)
+        .withTlsConfigurer(tlsConfigurer)
+        .build();
+  }
+
+  private static HttpServlet createTestServlet() {
+    return new HttpServlet() {
+      @Override
+      protected void doHead(HttpServletRequest request, HttpServletResponse 
response)
+          throws IOException {
+        response.setStatus(HttpServletResponse.SC_OK);
+      }
+    };
+  }
+}
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/web/TestHttpsServerAuthentication.java
 
b/server-common/src/test/java/org/apache/gravitino/server/web/TestHttpsServerAuthentication.java
index 35cfbf1b82..df7c5d1516 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/web/TestHttpsServerAuthentication.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/web/TestHttpsServerAuthentication.java
@@ -22,7 +22,6 @@ package org.apache.gravitino.server.web;
 import static 
org.apache.gravitino.server.web.TestTlsServerUtils.TEST_STORE_PASSWORD;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.fail;
 
 import java.io.IOException;
 import java.io.InputStream;
@@ -36,7 +35,6 @@ import java.security.KeyStore;
 import java.time.Duration;
 import javax.net.ssl.KeyManagerFactory;
 import javax.net.ssl.SSLContext;
-import javax.net.ssl.SSLHandshakeException;
 import javax.net.ssl.TrustManagerFactory;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
@@ -119,7 +117,7 @@ public class TestHttpsServerAuthentication {
         assertThrows(
             Exception.class, () -> client.send(request, 
HttpResponse.BodyHandlers.ofString()));
 
-    assertHandshakeFailure(exception);
+    TestTlsServerUtils.assertHandshakeFailure(exception);
   }
 
   @Test
@@ -138,7 +136,7 @@ public class TestHttpsServerAuthentication {
         assertThrows(
             Exception.class, () -> client.send(request, 
HttpResponse.BodyHandlers.ofString()));
 
-    assertHandshakeFailure(exception);
+    TestTlsServerUtils.assertHandshakeFailure(exception);
   }
 
   @Test
@@ -156,7 +154,7 @@ public class TestHttpsServerAuthentication {
         assertThrows(
             Exception.class, () -> client.send(request, 
HttpResponse.BodyHandlers.ofString()));
 
-    assertHandshakeFailure(exception);
+    TestTlsServerUtils.assertHandshakeFailure(exception);
   }
 
   @Test
@@ -177,7 +175,7 @@ public class TestHttpsServerAuthentication {
 
   @Test
   public void testHttpWithoutCustomTlsConfiguration() throws Exception {
-    int port = startHttpServer(jettyServer, createTestServlet(), TEST_PATH);
+    int port = TestTlsServerUtils.startHttpServer(jettyServer, 
createTestServlet(), TEST_PATH);
 
     HttpRequest request =
         HttpRequest.newBuilder()
@@ -193,21 +191,6 @@ public class TestHttpsServerAuthentication {
     assertEquals(TEST_RESPONSE, response.body());
   }
 
-  public static int startHttpServer(JettyServer server, HttpServlet servlet, 
String servletPath)
-      throws Exception {
-
-    int port = RESTUtils.findAvailablePort(5000, 6000);
-
-    Config config = new Config(false) {};
-    config.set(JettyServerConfig.WEBSERVER_HTTP_PORT, port);
-
-    server.initialize(JettyServerConfig.fromConfig(config), "test", false);
-    server.start();
-    server.addServlet(servlet, servletPath);
-
-    return port;
-  }
-
   private static HttpClient createHttpsClient(Path trustStorePath, Path 
clientKeyStorePath)
       throws Exception {
     KeyStore trustStore = loadStore(trustStorePath);
@@ -266,18 +249,4 @@ public class TestHttpsServerAuthentication {
       }
     };
   }
-
-  private static void assertHandshakeFailure(Throwable throwable) {
-    Throwable current = throwable;
-
-    while (current != null) {
-      if (current instanceof SSLHandshakeException) {
-        return;
-      }
-
-      current = current.getCause();
-    }
-
-    fail("Expected an SSL handshake failure, but received: " + throwable, 
throwable);
-  }
 }
diff --git 
a/server-common/src/test/java/org/apache/gravitino/server/web/TestTlsServerUtils.java
 
b/server-common/src/test/java/org/apache/gravitino/server/web/TestTlsServerUtils.java
index 83e3b48b50..7682442a1e 100644
--- 
a/server-common/src/test/java/org/apache/gravitino/server/web/TestTlsServerUtils.java
+++ 
b/server-common/src/test/java/org/apache/gravitino/server/web/TestTlsServerUtils.java
@@ -19,8 +19,14 @@
 
 package org.apache.gravitino.server.web;
 
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.InputStream;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
 import java.util.Objects;
+import javax.net.ssl.SSLHandshakeException;
 import javax.servlet.http.HttpServlet;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.rest.RESTUtils;
@@ -62,11 +68,46 @@ public final class TestTlsServerUtils {
     return port;
   }
 
+  public static int startHttpServer(JettyServer server, HttpServlet servlet, 
String servletPath)
+      throws Exception {
+
+    int port = RESTUtils.findAvailablePort(6000, 7000);
+
+    Config config = new Config(false) {};
+    config.set(JettyServerConfig.WEBSERVER_HTTP_PORT, port);
+
+    server.initialize(JettyServerConfig.fromConfig(config), "test", false);
+    server.start();
+    server.addServlet(servlet, servletPath);
+
+    return port;
+  }
+
   public static Path testResource(String filename) throws Exception {
-    return Path.of(
-        Objects.requireNonNull(
-                TestTlsServerUtils.class.getResource("/tls/" + filename),
-                "Missing TLS test resource: " + filename)
-            .toURI());
+    try (InputStream inputStream =
+        TestTlsServerUtils.class.getResourceAsStream("/tls/" + filename)) {
+
+      Objects.requireNonNull(inputStream, "Missing TLS test resource: " + 
filename);
+
+      Path tempFile = Files.createTempFile("gravitino-tls-", "-" + filename);
+      Files.copy(inputStream, tempFile, StandardCopyOption.REPLACE_EXISTING);
+      tempFile.toFile().deleteOnExit();
+
+      return tempFile;
+    }
+  }
+
+  public static void assertHandshakeFailure(Throwable throwable) {
+    Throwable current = throwable;
+
+    while (current != null) {
+      if (current instanceof SSLHandshakeException) {
+        return;
+      }
+
+      current = current.getCause();
+    }
+
+    fail("Expected an SSL handshake failure, but received: " + throwable, 
throwable);
   }
 }

Reply via email to