You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hugegraph.apache.org by ji...@apache.org on 2026/10/17 06:09:56 UTC

(hugegraph) branch master updated: fix(api): allow anonymous cypher queries (#3366)

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

imbajin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hugegraph.git


The following commit(s) were added to refs/heads/master by this push:
     new 8fe10180d fix(api): allow anonymous cypher queries (#3366)
8fe10180d is described below

commit 8fe10180d39b3acaf5973de735c22f85cfe29c97
Author: imbajin <ji...@apache.org>
AuthorDate: Sat Oct 17 14:09:38 2026 +0800

    fix(api): allow anonymous cypher queries (#3366)
    
    With Server authentication disabled, both Cypher GET and POST requests still returned 401 unless an Authorization header was supplied. This also prevented Hubble from executing Cypher in anonymous mode. Cypher now follows the Server authentication switch and connects to Gremlin without credentials when authentication is disabled.
---
 docs/cypher-api.md                                 | 20 +++++
 .../org/apache/hugegraph/api/cypher/CypherAPI.java | 21 +++--
 .../apache/hugegraph/api/cypher/CypherClient.java  | 10 +++
 .../apache/hugegraph/api/cypher/CypherManager.java |  4 +
 .../src/assembly/travis/run-api-test.sh            | 40 +++++++++-
 .../hugegraph/api/CypherAnonymousApiTest.java      | 66 ++++++++++++++++
 .../org/apache/hugegraph/api/CypherApiTest.java    | 81 ++++++++++++++++++++
 .../hugegraph/api/cypher/CypherClientTest.java     | 89 ++++++++++++++++++++++
 8 files changed, 323 insertions(+), 8 deletions(-)

diff --git a/docs/cypher-api.md b/docs/cypher-api.md
index e32304b5e..66202a224 100644
--- a/docs/cypher-api.md
+++ b/docs/cypher-api.md
@@ -33,6 +33,26 @@ send `?parameters=...` to bind a GET query. Invalid binding values, including th
 reserved translator null marker, also fail before traversal execution; explicit
 JSON null values are accepted and normalized by the Server.
 
+## Authentication
+
+Cypher follows the Server authentication mode. When authentication is disabled,
+GET and POST requests do not require an `Authorization` header, and the Server
+uses an anonymous Gremlin connection. Both the request's `Authorization` header
+and credentials in `conf/remote-objects.yaml` are ignored. Mixed modes with REST
+authentication disabled and Gremlin authentication enabled are unsupported.
+When authentication is enabled, requests still need
+valid Basic or Bearer credentials and retain the caller's graph permissions.
+Use `bin/enable-auth.sh` to configure authentication consistently for both REST
+and Gremlin; enabling only the REST authenticator does not protect the Gremlin
+endpoint used to execute Cypher.
+
+The Gremlin host and port in `conf/remote-objects.yaml` must point to the same
+Server's Gremlin endpoint, including when using nondefault ports.
+
+The API test runner exercises anonymous GET and POST before enabling
+authentication, then runs the authenticated suite, including missing-header
+rejection and a read-only user's write and cross-graph rejection cases.
+
 ## Response envelope
 
 Responses retain the Gremlin-compatible top-level fields `requestId`, `status`
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherAPI.java
index 216fff97e..7bac7e18c 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherAPI.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherAPI.java
@@ -28,6 +28,7 @@ import org.apache.commons.lang3.tuple.ImmutablePair;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.hugegraph.api.API;
 import org.apache.hugegraph.api.filter.CompressInterceptor;
+import org.apache.hugegraph.core.GraphManager;
 import org.apache.hugegraph.util.E;
 import org.apache.hugegraph.util.Log;
 import org.apache.tinkerpop.shaded.jackson.core.JsonProcessingException;
@@ -84,7 +85,8 @@ public class CypherAPI extends API {
     @Timed
     @CompressInterceptor.Compress(buffer = (1024 * 40))
     @Produces(APPLICATION_JSON_WITH_CHARSET)
-    public CypherModel query(@Context HttpHeaders headers,
+    public CypherModel query(@Context GraphManager manager,
+                             @Context HttpHeaders headers,
                              @Parameter(description = "The graph space name")
                              @PathParam("graphspace") String graphspace,
                              @Parameter(description = "The graph name")
@@ -92,7 +94,7 @@ public class CypherAPI extends API {
                              @Parameter(description = "The cypher query string")
                              @QueryParam("cypher") String cypher) {
 
-        return this.queryByCypher(headers, graphspace, graph, cypher,
+        return this.queryByCypher(manager, headers, graphspace, graph, cypher,
                                   Collections.emptyMap());
     }
 
@@ -117,7 +119,8 @@ public class CypherAPI extends API {
                                                                 "n.name = $name RETURN n.name\"," +
                                                                 "\"parameters\":{\"name\":\"marko\"}}"))
                  })
-    public CypherModel post(@Context HttpHeaders headers,
+    public CypherModel post(@Context GraphManager manager,
+                            @Context HttpHeaders headers,
                             @Parameter(description = "The graph space name")
                             @PathParam("graphspace") String graphspace,
                             @Parameter(description = "The graph name")
@@ -147,7 +150,7 @@ public class CypherAPI extends API {
                 parameters = supplied;
             }
         }
-        return this.queryByCypher(headers, graphspace, graph, cypher, parameters);
+        return this.queryByCypher(manager, headers, graphspace, graph, cypher, parameters);
     }
 
     private static boolean looksLikeJson(String body) {
@@ -161,7 +164,7 @@ public class CypherAPI extends API {
                value.equals("true") || value.equals("false");
     }
 
-    private CypherModel queryByCypher(HttpHeaders headers, String graphspace,
+    private CypherModel queryByCypher(GraphManager manager, HttpHeaders headers, String graphspace,
                                       String graph, String cypher,
                                       Map<String, Object> parameters) {
         E.checkArgument(graphspace != null && !graphspace.isEmpty(),
@@ -176,10 +179,14 @@ public class CypherAPI extends API {
         aliases.put("graph", graphInfo);
         aliases.put("g", "__g_" + graphInfo);
 
-        return this.client(headers).submitQuery(cypher, aliases, parameters);
+        return this.client(manager, headers).submitQuery(cypher, aliases, parameters);
     }
 
-    private CypherClient client(HttpHeaders headers) {
+    private CypherClient client(GraphManager manager, HttpHeaders headers) {
+        if (!manager.requireAuthentication()) {
+            return this.cypherManager().getAnonymousClient();
+        }
+
         String auth = headers.getHeaderString(HttpHeaders.AUTHORIZATION);
 
         if (auth != null && !auth.isEmpty()) {
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java
index b8f7d43b4..c9b19c71a 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java
@@ -55,6 +55,10 @@ public final class CypherClient {
     private String password;
     private String token;
 
+    CypherClient(Supplier<Configuration> configurationSupplier) {
+        this.configurationSupplier = configurationSupplier;
+    }
+
     CypherClient(String userName, String password,
                  Supplier<Configuration> configurationSupplier) {
         this.userName = userName;
@@ -229,6 +233,12 @@ public final class CypherClient {
      */
     private Configuration getConfig() {
         Configuration conf = this.configurationSupplier.get();
+        if (this.userName == null && this.token == null) {
+            // Anonymous queries must not inherit a service identity from the YAML file.
+            conf.clearProperty("username");
+            conf.clearProperty("password");
+            return conf;
+        }
         conf.addProperty("username", this.token == null ? this.userName : this.token);
         conf.addProperty("password", this.token == null ? this.password : "");
 
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherManager.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherManager.java
index 95f72d2e4..ee23b6aa4 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherManager.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherManager.java
@@ -44,6 +44,10 @@ public final class CypherManager {
         this.configurationFile = configurationFile;
     }
 
+    public CypherClient getAnonymousClient() {
+        return new CypherClient(this::cloneConfig);
+    }
+
     public CypherClient getClient(String userName, String password) {
         E.checkArgument(userName != null && !userName.isEmpty(),
                         "The userName parameter can't be null or empty");
diff --git a/hugegraph-server/hugegraph-dist/src/assembly/travis/run-api-test.sh b/hugegraph-server/hugegraph-dist/src/assembly/travis/run-api-test.sh
index 30520e2cc..0cd3d5a00 100755
--- a/hugegraph-server/hugegraph-dist/src/assembly/travis/run-api-test.sh
+++ b/hugegraph-server/hugegraph-dist/src/assembly/travis/run-api-test.sh
@@ -64,6 +64,25 @@ CONF=$SERVER_DIR/conf/graphs/hugegraph.properties
 REST_SERVER_CONF=$SERVER_DIR/conf/rest-server.properties
 GREMLIN_SERVER_CONF=$SERVER_DIR/conf/gremlin-server.yaml
 JACOCO_PORT=36320
+SERVER_STARTED=false
+
+function stop_server() {
+    "$TRAVIS_DIR/stop-server.sh" "$SERVER_DIR" || return $?
+    SERVER_STARTED=false
+}
+
+function cleanup() {
+    local status=$?
+    trap - EXIT
+    if [ "$SERVER_STARTED" == "true" ]; then
+        if ! stop_server; then
+            status=1
+        fi
+    fi
+    exit "$status"
+}
+
+trap cleanup EXIT
 
 mvn package -pl hugegraph-server/hugegraph-dist -am -Dmaven.test.skip=true -ntp
 
@@ -72,6 +91,24 @@ if [[ ! -e "$SERVER_DIR/lib/ikanalyzer-2012_u6.jar" ]]; then
                   "https://raw.githubusercontent.com/apache/hugegraph-doc/ik_binary/dist/server/ikanalyzer-2012_u6.jar"
 fi
 
+# Exercise the anonymous HTTP-to-Gremlin path before enabling authentication.
+if grep -Eq '^[[:space:]]*auth\.authenticator=.' "$REST_SERVER_CONF" || \
+   grep -Eq '^[[:space:]]*authentication[[:space:]]*:' "$GREMLIN_SERVER_CONF" || \
+   grep -q '^gremlin.graph=.*HugeFactoryAuthProxy' "$CONF"; then
+    echo "Anonymous API tests require the packaged unauthenticated configuration" >&2
+    exit 1
+fi
+SERVER_STARTED=true
+"$TRAVIS_DIR/start-server.sh" "$SERVER_DIR" "$BACKEND" "$JACOCO_PORT" || \
+    (cat "$SERVER_DIR/logs/hugegraph-server.log" && exit 1)
+mvn test -pl hugegraph-server/hugegraph-test -am -P api-test,"$BACKEND" \
+    -Dtest=CypherAnonymousApiTest || \
+    (cat "$SERVER_DIR/logs/hugegraph-server.log" && exit 1)
+# Preserve this process's probes before the authenticated Server replaces it.
+mvn -f hugegraph-server/hugegraph-test/pom.xml jacoco:dump@pull-test-data \
+    -Dapp.host=localhost -Dapp.port="$JACOCO_PORT" -Dskip.dump=false -Djacoco.append=true
+stop_server
+
 # config rest-server
 sed_in_place '/^#*auth\.authenticator=/d' "$REST_SERVER_CONF"
 sed_in_place '/^#*auth\.admin_token=/d' "$REST_SERVER_CONF"
@@ -90,6 +127,7 @@ authentication: {
 }" >> $GREMLIN_SERVER_CONF
 
 # start server
+SERVER_STARTED=true
 $TRAVIS_DIR/start-server.sh $SERVER_DIR $BACKEND $JACOCO_PORT || (cat $SERVER_DIR/logs/hugegraph-server.log && exit 1)
 
 # run api-test
@@ -103,7 +141,7 @@ fi
 $TRAVIS_DIR/build-report.sh $BACKEND $JACOCO_PORT $REPORT_FILE
 
 # stop server
-$TRAVIS_DIR/stop-server.sh $SERVER_DIR
+stop_server
 
 # Inspect service output after shutdown so background failures cannot pass the suite.
 bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$SERVER_DIR/logs"
diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherAnonymousApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherAnonymousApiTest.java
new file mode 100644
index 000000000..c8f518878
--- /dev/null
+++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherAnonymousApiTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.hugegraph.api;
+
+import java.io.IOException;
+import java.util.Map;
+
+import org.apache.hugegraph.api.BaseApiTest.RestClient;
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import jakarta.ws.rs.core.Response;
+
+public class CypherAnonymousApiTest {
+
+    private static final String PATH = BaseApiTest.URL_PREFIX + "/cypher";
+    private static final String QUERY = "RETURN 1 AS value";
+    private static final ObjectMapper JSON = new ObjectMapper();
+
+    @Test
+    public void testAnonymousGet() throws IOException {
+        RestClient client = new RestClient(BaseApiTest.baseUrl(), false);
+        try {
+            assertQueryResult(client.get(PATH, Map.of("cypher", QUERY)));
+        } finally {
+            client.close();
+        }
+    }
+
+    @Test
+    public void testAnonymousPost() throws IOException {
+        RestClient client = new RestClient(BaseApiTest.baseUrl(), false);
+        try {
+            assertQueryResult(client.post(PATH, QUERY));
+        } finally {
+            client.close();
+        }
+    }
+
+    private static void assertQueryResult(Response response) throws IOException {
+        JsonNode result = JSON.readTree(BaseApiTest.assertResponseStatus(200, response));
+        Assert.assertEquals(200, result.path("status").path("code").asInt());
+        JsonNode data = result.path("result").path("data");
+        Assert.assertTrue(data.isArray());
+        Assert.assertEquals(1, data.size());
+        Assert.assertEquals(1, data.get(0).path("value").asInt());
+    }
+}
diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java
index 3c1f84e8c..cf05c7318 100644
--- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java
+++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java
@@ -402,6 +402,76 @@ public class CypherApiTest extends BaseApiTest {
                      "name", "alice", "dave", "marko", "peter");
     }
 
+    @Test
+    public void testRejectMissingAuthorization() {
+        RestClient anonymous = new RestClient(baseUrl(), false);
+        try {
+            assertResponseStatus(401, anonymous.get(PATH, ImmutableMap.of("cypher", "RETURN 1")));
+            assertResponseStatus(401, anonymous.post(PATH, "RETURN 1"));
+        } finally {
+            anonymous.close();
+        }
+    }
+
+    @Test
+    public void testReadOnlyUserCannotWriteOrReadAnotherGraph() {
+        assumeStandaloneMode();
+        String graph = "cypher_reader_other";
+        String graphPath = "graphspaces/DEFAULT/graphs/" + graph;
+        Deque<String[]> created = new ArrayDeque<>();
+        RestClient reader = null;
+        boolean graphCreated = false;
+        try {
+            String config = JsonUtil.toJson(ImmutableMap.of(
+                    "gremlin.graph", "org.apache.hugegraph.auth.HugeFactoryAuthProxy",
+                    "backend", "memory", "serializer", "text", "store", graph));
+            Response response = client().post(graphPath, Entity.json(config));
+            graphCreated = response.getStatus() == 201;
+            assertResponseStatus(201, response);
+            reader = new RestClient(baseUrl(), "cypher_reader", "cypher-reader-password");
+            String group = this.createAuth(created, "auth/groups",
+                                           "{\"group_name\":\"cypher_read_only_group\"}");
+            String target = this.createAuth(created, "graphspaces/DEFAULT/auth/targets",
+                                            "{\"target_name\":\"cypher_read_only_target\"," +
+                                            "\"target_graph\":\"hugegraph\"," +
+                                            "\"target_resources\":[{\"type\":\"ALL\"}]}");
+            for (String permission : ImmutableList.of("READ", "EXECUTE")) {
+                this.createAuth(created, "graphspaces/DEFAULT/auth/accesses", String.format(
+                        "{\"group\":\"%s\",\"target\":\"%s\",\"access_permission\":\"%s\"}",
+                        group, target, permission));
+            }
+            String user = this.createAuth(created, "graphspaces/DEFAULT/auth/users",
+                                          "{\"user_name\":\"cypher_reader\"," +
+                                          "\"user_password\":\"cypher-reader-password\"}");
+            this.createAuth(created, "graphspaces/DEFAULT/auth/belongs", String.format(
+                    "{\"user\":\"%s\",\"group\":\"%s\"}", user, group));
+
+            assertColumn(assertCypherSuccessData(assertResponseStatus(200, reader.post(PATH, QUERY))),
+                         "name", "marko", "peter");
+            assertPermissionDenied(reader.post(PATH,
+                    "CREATE (n:cypher_person {name:'cypher_denied',age:1,city:'Beijing'}) RETURN n"),
+                    "write", "hugegraph");
+            Assert.assertNull(this.nativeVertex("cypher_denied"));
+            // This existing graph is accessible to the administrator but not to the reader.
+            assertCypherSuccessData(assertResponseStatus(200, client().post(
+                    graphPath + "/cypher", "MATCH (n) RETURN n")));
+            assertPermissionDenied(reader.post(graphPath + "/cypher", "MATCH (n) RETURN n"),
+                                   "execute", graph);
+        } finally {
+            if (reader != null) {
+                reader.close();
+            }
+            try {
+                this.deleteAuth(created);
+            } finally {
+                if (graphCreated) {
+                    assertResponseStatus(204, client().delete(graphPath, ImmutableMap.of(
+                            "confirm_message", "I'm sure to drop the graph")));
+                }
+            }
+        }
+    }
+
     @Test
     public void testBasicAuthWithColonPassword() {
         // The group, target and access setup below uses the standalone auth API; in PD mode
@@ -536,6 +606,17 @@ public class CypherApiTest extends BaseApiTest {
         Assert.assertNull(assertMapValue(body, "result").get("data"));
     }
 
+    private static void assertPermissionDenied(Response response, String operation, String graph) {
+        String content = assertResponseStatus(200, response);
+        Map<?, ?> body = JsonUtil.fromJson(content, Map.class);
+        Map<?, ?> status = assertMapValue(body, "status");
+        Assert.assertEquals(400, ((Number) status.get("code")).intValue());
+        String message = String.valueOf(status.get("message"));
+        assertContains("Permission denied: " + operation, message);
+        assertContains("graph=" + graph + ",", message);
+        Assert.assertNull(assertMapValue(body, "result").get("data"));
+    }
+
     private void addVertex(String name, int age, String city, boolean active) {
         createAndAssert(URL_PREFIX + "/graph/vertices", JsonUtil.toJson(ImmutableMap.of(
                 "label", "cypher_person", "properties", ImmutableMap.of(
diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/cypher/CypherClientTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/cypher/CypherClientTest.java
index 8303d7903..d95b754e7 100644
--- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/cypher/CypherClientTest.java
+++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/cypher/CypherClientTest.java
@@ -23,17 +23,88 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import org.apache.commons.configuration2.Configuration;
+import org.apache.commons.configuration2.YAMLConfiguration;
+import org.apache.hugegraph.core.GraphManager;
 import org.apache.hugegraph.id.IdGenerator;
 import org.apache.hugegraph.testutil.Assert;
+import org.apache.hugegraph.testutil.Whitebox;
 import org.apache.hugegraph.unit.BaseUnitTest;
 import org.apache.tinkerpop.gremlin.process.traversal.Path;
 import org.apache.tinkerpop.gremlin.process.traversal.step.util.MutablePath;
 import org.apache.tinkerpop.gremlin.util.Tokens;
 import org.apache.tinkerpop.gremlin.util.message.RequestMessage;
 import org.junit.Test;
+import org.mockito.Mockito;
+
+import jakarta.ws.rs.NotAuthorizedException;
+import jakarta.ws.rs.core.HttpHeaders;
 
 public class CypherClientTest extends BaseUnitTest {
 
+    @Test
+    public void testAnonymousClientDoesNotInheritCredentials() {
+        YAMLConfiguration source = new YAMLConfiguration();
+        source.addProperty("hosts", "localhost");
+        source.addProperty("port", 8182);
+        source.addProperty("username", "service-user");
+        source.addProperty("password", "service-secret");
+        CypherManager manager = CypherManager.configOf("unused.yaml");
+        Whitebox.setInternalState(manager, "configuration", source);
+
+        Configuration config = clientConfig(manager.getAnonymousClient());
+
+        Assert.assertFalse(config.containsKey("username"));
+        Assert.assertFalse(config.containsKey("password"));
+        Assert.assertEquals(8182, config.getInt("port"));
+        Assert.assertEquals("service-user", source.getString("username"));
+        Assert.assertEquals("service-secret", source.getString("password"));
+    }
+
+    @Test
+    public void testAnonymousServerDoesNotRequireAuthorization() {
+        Configuration config = clientConfig(apiClient(false, null));
+
+        Assert.assertFalse(config.containsKey("username"));
+        Assert.assertFalse(config.containsKey("password"));
+    }
+
+    @Test
+    public void testAuthenticatedServerRejectsMissingAuthorization() {
+        Assert.assertThrows(NotAuthorizedException.class,
+                            () -> apiClient(true, null));
+    }
+
+    @Test
+    public void testAuthenticatedServerRejectsUnsupportedAuthorization() {
+        Assert.assertThrows(NotAuthorizedException.class,
+                            () -> apiClient(true, "Unsupported credential"));
+    }
+
+    @Test
+    public void testAuthenticatedServerPreservesBasicCredentials() {
+        Configuration config = clientConfig(apiClient(true, "Basic dXNlcjpwYXNz"));
+
+        Assert.assertEquals("user", config.getString("username"));
+        Assert.assertEquals("pass", config.getString("password"));
+    }
+
+    @Test
+    public void testAuthenticatedServerPreservesBearerCredentials() {
+        Configuration config = clientConfig(apiClient(true, "Bearer test-token"));
+
+        Assert.assertEquals("test-token", config.getString("username"));
+        Assert.assertEquals("", config.getString("password"));
+    }
+
+    @Test
+    public void testAuthenticatedServerRejectsMalformedCredentials() {
+        Assert.assertThrows(RuntimeException.class,
+                            () -> apiClient(true, "Basic invalid-base64"));
+        Assert.assertThrows(IllegalArgumentException.class,
+                            () -> apiClient(true, "Bearer "));
+    }
+
     @Test
     public void testRequestPreservesQueryAndParameterValues() {
         String query = "MATCH (n:person) WHERE n.name = $name RETURN n.name";
@@ -152,4 +223,22 @@ public class CypherClientTest extends BaseUnitTest {
         Assert.assertTrue(secondLabels.contains("b"));
         Assert.assertTrue(secondLabels.contains("software"));
     }
+
+    private static CypherClient apiClient(boolean authentication, String authorization) {
+        GraphManager graphManager = Mockito.mock(GraphManager.class);
+        Mockito.when(graphManager.requireAuthentication()).thenReturn(authentication);
+        HttpHeaders headers = Mockito.mock(HttpHeaders.class);
+        Mockito.when(headers.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn(authorization);
+        CypherManager manager = CypherManager.configOf("unused.yaml");
+        Whitebox.setInternalState(manager, "configuration", new YAMLConfiguration());
+        CypherAPI api = new CypherAPI();
+        Whitebox.setInternalState(api, "cypherManager", manager);
+        return Whitebox.invoke(CypherAPI.class,
+                              new Class<?>[]{GraphManager.class, HttpHeaders.class},
+                              "client", api, graphManager, headers);
+    }
+
+    private static Configuration clientConfig(CypherClient client) {
+        return Whitebox.invoke(CypherClient.class, "getConfig", client);
+    }
 }