You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tinkerpop.apache.org by xi...@apache.org on 2026/03/17 00:54:20 UTC

(tinkerpop) branch dotnet-http-interceptors created (now 783772d49a)

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

xiazcy pushed a change to branch dotnet-http-interceptors
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git


      at 783772d49a Add interceptor to .net with new reference auth class

This branch includes the following new commits:

     new 783772d49a Add interceptor to .net with new reference auth class

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(tinkerpop) 01/01: Add interceptor to .net with new reference auth class

Posted by xi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

xiazcy pushed a commit to branch dotnet-http-interceptors
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git

commit 783772d49a19aedce3dcff956bd9b682ff7bac7c
Author: Yang Xia <xi...@gmail.com>
AuthorDate: Mon Mar 16 17:54:13 2026 -0700

    Add interceptor to .net with new reference auth class
---
 gremlin-dotnet/Examples/Connections/Connections.cs |   2 +-
 gremlin-dotnet/docker-compose.yml                  |   1 +
 gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs      | 187 +++++++++++++++++
 .../src/Gremlin.Net/Driver/Connection.cs           |  59 ++++--
 .../src/Gremlin.Net/Driver/ConnectionSettings.cs   |   6 +
 .../src/Gremlin.Net/Driver/GremlinClient.cs        |  13 +-
 .../src/Gremlin.Net/Driver/GremlinServer.cs        |  16 +-
 .../src/Gremlin.Net/Driver/HttpRequestContext.cs   |  80 +++++++
 .../Driver/Remote/DriverRemoteConnection.cs        |  10 +-
 gremlin-dotnet/src/Gremlin.Net/Gremlin.Net.csproj  |   1 +
 .../Docs/Reference/GremlinVariantsTests.cs         |   4 +-
 .../Driver/AuthIntegrationTests.cs                 | 104 ++++++++++
 .../test/Gremlin.Net.UnitTest/Driver/AuthTests.cs  | 230 +++++++++++++++++++++
 .../Gremlin.Net.UnitTest/Driver/ConnectionTests.cs | 104 ++++++++++
 .../Driver/HttpRequestContextTests.cs              | 103 +++++++++
 15 files changed, 883 insertions(+), 37 deletions(-)

diff --git a/gremlin-dotnet/Examples/Connections/Connections.cs b/gremlin-dotnet/Examples/Connections/Connections.cs
index c09eea3f7b..519eb70363 100644
--- a/gremlin-dotnet/Examples/Connections/Connections.cs
+++ b/gremlin-dotnet/Examples/Connections/Connections.cs
@@ -52,7 +52,7 @@ public class ConnectionExample
     static void WithConf()
     {
         using var remoteConnection = new DriverRemoteConnection(new GremlinClient(
-        new GremlinServer(hostname: ServerHost, port: ServerPort, enableSsl: false, username: "", password: "")), "g");
+        new GremlinServer(hostname: ServerHost, port: ServerPort, enableSsl: false)), "g");
         var g = Traversal().With(remoteConnection);
 
         var v = g.AddV(VertexLabel).Iterate();
diff --git a/gremlin-dotnet/docker-compose.yml b/gremlin-dotnet/docker-compose.yml
index 697eab2184..b29e9036f6 100644
--- a/gremlin-dotnet/docker-compose.yml
+++ b/gremlin-dotnet/docker-compose.yml
@@ -56,6 +56,7 @@ services:
       - GREMLIN_SERVER_PORT=45940
       - VERTEX_LABEL=dotnet-example
     working_dir: /gremlin-dotnet
+    # TODO: fix examples issue after feature-completion
     command: >
       bash -c "find . -path '*/TestResults/*.trx' -delete 2>/dev/null || true;
       dotnet tool update -g dotnet-trx; dotnet test ./Gremlin.Net.sln -c Release --logger trx;
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
new file mode 100644
index 0000000000..d7d0770c1b
--- /dev/null
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Auth.cs
@@ -0,0 +1,187 @@
+#region License
+
+/*
+ * 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.
+ */
+
+#endregion
+
+using System;
+using System.Text;
+using System.Threading.Tasks;
+using Amazon.Runtime;
+using Amazon.Runtime.Internal;
+using Amazon.Runtime.Internal.Auth;
+using Amazon.Runtime.Internal.Util;
+
+namespace Gremlin.Net.Driver
+{
+    /// <summary>
+    ///     Provides factory methods for built-in request interceptors.
+    /// </summary>
+    public static class Auth
+    {
+        /// <summary>
+        ///     Returns a request interceptor that adds an HTTP Basic Authentication header.
+        ///     The credentials are pre-computed once and set on every request.
+        /// </summary>
+        /// <param name="username">The username.</param>
+        /// <param name="password">The password.</param>
+        /// <returns>A request interceptor delegate.</returns>
+        public static Func<HttpRequestContext, Task> BasicAuth(string username, string password)
+        {
+            var encoded = Convert.ToBase64String(
+                Encoding.UTF8.GetBytes(username + ":" + password));
+            var headerValue = "Basic " + encoded;
+
+            return context =>
+            {
+                context.Headers["Authorization"] = headerValue;
+                return Task.CompletedTask;
+            };
+        }
+
+        /// <summary>
+        ///     Returns a request interceptor that signs requests using AWS Signature Version 4.
+        ///     If <paramref name="credentials"/> is null, the default AWS credential chain is
+        ///     used and the resolved provider is cached on first use (the provider itself handles
+        ///     credential refresh for expiring credentials like STS).
+        /// </summary>
+        /// <param name="region">The AWS region (e.g. "us-east-1").</param>
+        /// <param name="service">The AWS service name (e.g. "neptune-db").</param>
+        /// <param name="credentials">
+        ///     Optional AWS credentials. When null, the default credential chain is used.
+        /// </param>
+        /// <returns>A request interceptor delegate.</returns>
+        public static Func<HttpRequestContext, Task> SigV4Auth(
+            string region, string service, AWSCredentials? credentials = null)
+        {
+            // Cache the credential provider once when using the default chain.
+            AWSCredentials? cachedProvider = credentials;
+            var cacheLock = new object();
+
+            // Create signer and config once — both are stateless and thread-safe
+            var signer = new AWS4Signer();
+            var clientConfig = new SigningClientConfig
+            {
+                AuthenticationRegion = region,
+                AuthenticationServiceName = service,
+            };
+
+            return context =>
+            {
+                if (cachedProvider == null)
+                {
+                    lock (cacheLock)
+                    {
+                        cachedProvider ??= FallbackCredentialsFactory.GetCredentials();
+                    }
+                }
+                var immutableCreds = cachedProvider.GetCredentials();
+                SignRequest(context, region, service, immutableCreds, signer, clientConfig);
+                return Task.CompletedTask;
+            };
+        }
+
+        private static void SignRequest(HttpRequestContext context, string region, string service, 
+            ImmutableCredentials credentials, AWS4Signer signer, SigningClientConfig clientConfig)
+        {
+            // Build a DefaultRequest from the HttpRequestContext for the AWS SDK signer.
+            var endpointUri = new Uri(context.Uri.GetLeftPart(UriPartial.Authority));
+            var awsRequest = new DefaultRequest(new NullRequest(), service)
+            {
+                HttpMethod = context.Method,
+                Endpoint = endpointUri,
+                ResourcePath = context.Uri.AbsolutePath,
+                Content = context.Body ?? Array.Empty<byte>(),
+                AuthenticationRegion = region,
+                OverrideSigningServiceName = service,
+            };
+
+            // Copy headers (skip Host — signer adds it)
+            foreach (var header in context.Headers)
+            {
+                if (!string.Equals(header.Key, "Host", StringComparison.OrdinalIgnoreCase))
+                {
+                    awsRequest.Headers[header.Key] = header.Value;
+                }
+            }
+
+            // Copy query parameters
+            var query = context.Uri.Query;
+            if (!string.IsNullOrEmpty(query))
+            {
+                // Remove leading '?'
+                var queryString = query.StartsWith("?") ? query.Substring(1) : query;
+                foreach (var param in queryString.Split('&'))
+                {
+                    if (string.IsNullOrEmpty(param)) continue;
+                    var parts = param.Split(new[] { '=' }, 2);
+                    var key = Uri.UnescapeDataString(parts[0]);
+                    var value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : "";
+                    awsRequest.Parameters[key] = value;
+                }
+            }
+
+            // Set content hash header before signing
+            var payloadHash = context.GetPayloadHash();
+            awsRequest.Headers["x-amz-content-sha256"] = payloadHash;
+
+            // Sign the request
+            signer.Sign(awsRequest, clientConfig, new RequestMetrics(),
+                credentials.AccessKey, credentials.SecretKey);
+
+            // Copy signed headers back to context. Cherry-pick the known SigV4 headers
+            // because the .NET Dictionary is case-sensitive and the AWS SDK may use
+            // different casing than what interceptors expect.
+            context.Headers["Host"] = endpointUri.Host;
+            if (awsRequest.Headers.ContainsKey("Authorization"))
+            {
+                context.Headers["Authorization"] = awsRequest.Headers["Authorization"];
+            }
+            if (awsRequest.Headers.ContainsKey("X-Amz-Date"))
+            {
+                context.Headers["X-Amz-Date"] = awsRequest.Headers["X-Amz-Date"];
+            }
+            context.Headers["x-amz-content-sha256"] = payloadHash;
+
+            // Add session token if temporary credentials
+            if (!string.IsNullOrEmpty(credentials.Token))
+            {
+                context.Headers["X-Amz-Security-Token"] = credentials.Token;
+            }
+        }
+
+        /// <summary>
+        ///     A minimal AmazonWebServiceRequest implementation required by DefaultRequest.
+        /// </summary>
+        private class NullRequest : AmazonWebServiceRequest
+        {
+        }
+
+        /// <summary>
+        ///     A minimal ClientConfig implementation for SigV4 signing.
+        /// </summary>
+        private class SigningClientConfig : ClientConfig
+        {
+            public override string RegionEndpointServiceName => "execute-api";
+            public override string ServiceVersion => "2024-01-01";
+            public override string UserAgent => "gremlin-dotnet";
+        }
+    }
+}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
index 54d53d7815..9f8a644e12 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Connection.cs
@@ -49,17 +49,18 @@ namespace Gremlin.Net.Driver
         private readonly bool _enableCompression;
         private readonly bool _enableUserAgentOnConnect;
         private readonly bool _bulkResults;
-        // Interceptor slot reserved for future spec
-        // private readonly IReadOnlyList<Func<HttpRequestMessage, Task>> _interceptors;
+        private readonly IReadOnlyList<Func<HttpRequestContext, Task>> _interceptors;
 
         public Connection(Uri uri, IMessageSerializer serializer,
-            ConnectionSettings settings)
+            ConnectionSettings settings,
+            IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null)
         {
             _uri = uri;
             _serializer = serializer;
             _enableCompression = settings.EnableCompression;
             _enableUserAgentOnConnect = settings.EnableUserAgentOnConnect;
             _bulkResults = settings.BulkResults;
+            _interceptors = interceptors ?? Array.Empty<Func<HttpRequestContext, Task>>();
 
 #if NET6_0_OR_GREATER
             var handler = new SocketsHttpHandler
@@ -69,6 +70,13 @@ namespace Gremlin.Net.Driver
                 ConnectTimeout = settings.ConnectionTimeout,
                 KeepAlivePingTimeout = settings.KeepAliveInterval,
             };
+            if (settings.SkipCertificateValidation)
+            {
+                handler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions
+                {
+                    RemoteCertificateValidationCallback = (_, _, _, _) => true,
+                };
+            }
             _httpClient = new HttpClient(handler);
 #else
             _httpClient = new HttpClient();
@@ -80,7 +88,8 @@ namespace Gremlin.Net.Driver
         ///     Constructor that accepts a pre-configured HttpClient (for testing).
         /// </summary>
         internal Connection(Uri uri, IMessageSerializer serializer,
-            ConnectionSettings settings, HttpClient httpClient)
+            ConnectionSettings settings, HttpClient httpClient,
+            IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null)
         {
             _uri = uri;
             _serializer = serializer;
@@ -88,6 +97,7 @@ namespace Gremlin.Net.Driver
             _enableUserAgentOnConnect = settings.EnableUserAgentOnConnect;
             _bulkResults = settings.BulkResults;
             _httpClient = httpClient;
+            _interceptors = interceptors ?? Array.Empty<Func<HttpRequestContext, Task>>();
         }
 
         public async Task<ResultSet<T>> SubmitAsync<T>(RequestMessage requestMessage,
@@ -96,29 +106,50 @@ namespace Gremlin.Net.Driver
             var requestBytes = await _serializer.SerializeMessageAsync(requestMessage, cancellationToken)
                 .ConfigureAwait(false);
 
-            using var content = new ByteArrayContent(requestBytes);
-            content.Headers.ContentType = new MediaTypeHeaderValue(GraphBinaryMimeType);
-
-            using var httpRequest = new HttpRequestMessage(HttpMethod.Post, _uri);
-            httpRequest.Content = content;
-            httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(GraphBinaryMimeType));
+            // Build HttpRequestContext with default headers
+            var headers = new Dictionary<string, string>();
+            headers["Content-Type"] = GraphBinaryMimeType;
+            headers["Accept"] = GraphBinaryMimeType;
 
             if (_enableCompression)
             {
-                httpRequest.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate"));
+                headers["Accept-Encoding"] = "deflate";
             }
 
             if (_enableUserAgentOnConnect)
             {
-                httpRequest.Headers.TryAddWithoutValidation("User-Agent", Utils.UserAgent);
+                headers["User-Agent"] = Utils.UserAgent;
             }
 
             if (_bulkResults)
             {
-                httpRequest.Headers.Add("bulkResults", "true");
+                headers["bulkResults"] = "true";
+            }
+
+            var context = new HttpRequestContext("POST", _uri, headers, requestBytes);
+
+            // Apply interceptors in order
+            foreach (var interceptor in _interceptors)
+            {
+                await interceptor(context).ConfigureAwait(false);
             }
 
-            // Future: apply interceptors here
+            // Convert HttpRequestContext to HttpRequestMessage
+            using var httpRequest = new HttpRequestMessage(new HttpMethod(context.Method), context.Uri);
+            httpRequest.Content = new ByteArrayContent(context.Body);
+
+            foreach (var header in context.Headers)
+            {
+                // Content-Type must be set on the content headers, not the request headers
+                if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
+                {
+                    httpRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(header.Value);
+                }
+                else
+                {
+                    httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
+                }
+            }
 
             using var response = await _httpClient.SendAsync(httpRequest, cancellationToken)
                 .ConfigureAwait(false);
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
index 0d8df4a6a7..83faebf7ed 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/ConnectionSettings.cs
@@ -84,5 +84,11 @@ namespace Gremlin.Net.Driver
         ///     Gets or sets whether to send the bulkResults: true header on all requests.
         /// </summary>
         public bool BulkResults { get; set; } = false;
+
+        /// <summary>
+        ///     Gets or sets whether to skip SSL certificate validation.
+        ///     Only use for testing with self-signed certificates.
+        /// </summary>
+        public bool SkipCertificateValidation { get; set; } = false;
     }
 }
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs
index 6445ac6621..62ad5b5dca 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs
@@ -22,6 +22,7 @@
 #endregion
 
 using System;
+using System.Collections.Generic;
 using System.Threading;
 using System.Threading.Tasks;
 using Gremlin.Net.Driver.Messages;
@@ -50,11 +51,14 @@ namespace Gremlin.Net.Driver
         /// </param>
         /// <param name="connectionSettings">The <see cref="ConnectionSettings" /> for the HTTP connection.</param>
         /// <param name="loggerFactory">A factory to create loggers. If not provided, then nothing will be logged.</param>
-        // Interceptor slot reserved for future spec:
-        // IReadOnlyList<Func<HttpRequestMessage, Task>>? interceptors = null,
+        /// <param name="interceptors">
+        ///     An optional list of request interceptors. Each interceptor receives a mutable
+        ///     <see cref="HttpRequestContext" /> and can modify headers, body, URI, and method before the request is sent.
+        /// </param>
         public GremlinClient(GremlinServer gremlinServer, IMessageSerializer? messageSerializer = null,
             ConnectionSettings? connectionSettings = null,
-            ILoggerFactory? loggerFactory = null)
+            ILoggerFactory? loggerFactory = null,
+            IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null)
         {
             messageSerializer ??= new GraphBinaryMessageSerializer();
             connectionSettings ??= new ConnectionSettings();
@@ -63,7 +67,8 @@ namespace Gremlin.Net.Driver
             _connection = new Connection(
                 gremlinServer.Uri,
                 messageSerializer,
-                connectionSettings);
+                connectionSettings,
+                interceptors);
 
             var logger = LoggerFactory.CreateLogger<GremlinClient>();
             logger.InitializedHttpConnection(gremlinServer.Uri);
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs
index 94c46936ad..19ed2fbb18 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinServer.cs
@@ -36,15 +36,11 @@ namespace Gremlin.Net.Driver
         /// <param name="hostname">The hostname of the server.</param>
         /// <param name="port">The port on which Gremlin Server can be reached.</param>
         /// <param name="enableSsl">Specifies whether SSL should be enabled.</param>
-        /// <param name="username">The username to submit on requests that require authentication.</param>
-        /// <param name="password">The password to submit on requests that require authentication.</param>
         /// <param name="path">The path to the Gremlin endpoint on the server.</param>
         public GremlinServer(string hostname = "localhost", int port = 8182, bool enableSsl = false,
-            string? username = null, string? password = null, string path = "/gremlin")
+            string path = "/gremlin")
         {
             Uri = CreateUri(hostname, port, enableSsl, path);
-            Username = username;
-            Password = password;
         }
 
         /// <summary>
@@ -52,16 +48,6 @@ namespace Gremlin.Net.Driver
         /// </summary>
         public Uri Uri { get; }
 
-        /// <summary>
-        ///     Gets the username to submit on requests that require authentication.
-        /// </summary>
-        public string? Username { get; }
-
-        /// <summary>
-        ///     Gets the password to submit on requests that require authentication.
-        /// </summary>
-        public string? Password { get; }
-
         private static Uri CreateUri(string hostname, int port, bool enableSsl, string path)
         {
             var scheme = enableSsl ? "https" : "http";
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/HttpRequestContext.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/HttpRequestContext.cs
new file mode 100644
index 0000000000..f2ce916885
--- /dev/null
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/HttpRequestContext.cs
@@ -0,0 +1,80 @@
+#region License
+
+/*
+ * 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.
+ */
+
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+
+namespace Gremlin.Net.Driver
+{
+    /// <summary>
+    ///     Mutable HTTP request context passed to request interceptors.
+    /// </summary>
+    public class HttpRequestContext
+    {
+        /// <summary>
+        ///     Gets or sets the HTTP method (e.g. "POST").
+        /// </summary>
+        public string Method { get; set; }
+
+        /// <summary>
+        ///     Gets or sets the request URI.
+        /// </summary>
+        public Uri Uri { get; set; }
+
+        /// <summary>
+        ///     Gets the HTTP headers. Interceptors may add, modify, or remove entries.
+        /// </summary>
+        public Dictionary<string, string> Headers { get; }
+
+        /// <summary>
+        ///     Gets or sets the serialized request body.
+        /// </summary>
+        public byte[] Body { get; set; }
+
+        /// <summary>
+        ///     Initializes a new instance of the <see cref="HttpRequestContext" /> class.
+        /// </summary>
+        /// <param name="method">The HTTP method.</param>
+        /// <param name="uri">The request URI.</param>
+        /// <param name="headers">The HTTP headers.</param>
+        /// <param name="body">The serialized request body.</param>
+        public HttpRequestContext(string method, Uri uri, Dictionary<string, string> headers, byte[] body)
+        {
+            Method = method ?? throw new ArgumentNullException(nameof(method));
+            Uri = uri ?? throw new ArgumentNullException(nameof(uri));
+            Headers = headers ?? throw new ArgumentNullException(nameof(headers));
+            Body = body;
+        }
+
+        /// <summary>
+        ///     Returns the lowercase hex-encoded SHA-256 digest of the body.
+        /// </summary>
+        public string GetPayloadHash()
+        {
+            using var sha256 = SHA256.Create();
+            var hash = sha256.ComputeHash(Body ?? Array.Empty<byte>());
+            return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+        }
+    }
+}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Remote/DriverRemoteConnection.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Remote/DriverRemoteConnection.cs
index c25cffe725..83aeb9ceb4 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/Remote/DriverRemoteConnection.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Remote/DriverRemoteConnection.cs
@@ -65,10 +65,16 @@ namespace Gremlin.Net.Driver.Remote
         /// <param name="port">The port to connect to.</param>
         /// <param name="traversalSource">The name of the traversal source on the server to bind to.</param>
         /// <param name="loggerFactory">A factory to create loggers. If not provided, then nothing will be logged.</param>
+        /// <param name="interceptors">
+        ///     An optional list of request interceptors forwarded to the underlying
+        ///     <see cref="GremlinClient" />.
+        /// </param>
         /// <exception cref="ArgumentNullException">Thrown when client is null.</exception>
         public DriverRemoteConnection(string host, int port, string traversalSource = "g",
-            ILoggerFactory? loggerFactory = null) : this(
-            new GremlinClient(new GremlinServer(host, port), loggerFactory: loggerFactory), traversalSource,
+            ILoggerFactory? loggerFactory = null,
+            IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null) : this(
+            new GremlinClient(new GremlinServer(host, port), loggerFactory: loggerFactory, interceptors: interceptors),
+            traversalSource,
             logger: loggerFactory?.CreateLogger<DriverRemoteConnection>() ?? NullLogger<DriverRemoteConnection>.Instance)
         {
         }
diff --git a/gremlin-dotnet/src/Gremlin.Net/Gremlin.Net.csproj b/gremlin-dotnet/src/Gremlin.Net/Gremlin.Net.csproj
index df9cac5be7..c1c53ae69d 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Gremlin.Net.csproj
+++ b/gremlin-dotnet/src/Gremlin.Net/Gremlin.Net.csproj
@@ -72,6 +72,7 @@ NOTE that versions suffixed with "-rc" are considered release candidates (i.e. p
   </PropertyGroup>
 
   <ItemGroup>
+    <PackageReference Include="AWSSDK.Core" Version="3.7.400.2" />
     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
     <PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
     <PackageReference Include="Polly" Version="8.5.1" />
diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs
index 7f5c833bf6..c7b82d2583 100644
--- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Docs/Reference/GremlinVariantsTests.cs
@@ -158,7 +158,9 @@ var response =
 // tag::submittingScriptsWithAuthentication[]
 var username = "username";
 var password = "password";
-var gremlinServer = new GremlinServer("localhost", 8182, true, username, password);
+var gremlinServer = new GremlinServer("localhost", 8182, enableSsl: true);
+using var gremlinClient = new GremlinClient(gremlinServer,
+    interceptors: new[] { Auth.BasicAuth(username, password) });
 // end::submittingScriptsWithAuthentication[]
         }
         
diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs
new file mode 100644
index 0000000000..802a9fb066
--- /dev/null
+++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/AuthIntegrationTests.cs
@@ -0,0 +1,104 @@
+#region License
+
+/*
+ * 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.
+ */
+
+#endregion
+
+using System;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Gremlin.Net.Driver;
+using Gremlin.Net.Driver.Remote;
+using Gremlin.Net.Process.Traversal;
+using Gremlin.Net.Structure.IO.GraphBinary4;
+using Xunit;
+
+namespace Gremlin.Net.IntegrationTest.Driver
+{
+    /// <summary>
+    ///     Integration tests for authentication interceptors against the secure Gremlin Server
+    ///     (port 45941, SSL + SimpleAuthenticator).
+    /// </summary>
+    public class AuthIntegrationTests
+    {
+        private static readonly string TestHost = ConfigProvider.Configuration["TestServerIpAddress"]!;
+        private static readonly int TestSecurePort = Convert.ToInt32(ConfigProvider.Configuration["TestSecureServerPort"]);
+
+        private GremlinClient CreateSecureClient(Func<HttpRequestContext, Task>[]? interceptors = null)
+        {
+            var gremlinServer = new GremlinServer(TestHost, TestSecurePort, enableSsl: true);
+            return new GremlinClient(gremlinServer,
+                new GraphBinaryMessageSerializer(),
+                new ConnectionSettings { SkipCertificateValidation = true },
+                interceptors: interceptors);
+        }
+
+        [Fact]
+        public async Task ShouldAuthenticateWithBasicAuth()
+        {
+            // The secure server uses SimpleAuthenticator with credentials: stephen/password
+            using var gremlinClient = CreateSecureClient(
+                new[] { Auth.BasicAuth("stephen", "password") });
+
+            var response = await gremlinClient.SubmitAsync<long>("g.inject(1).count()");
+
+            Assert.Single(response);
+        }
+
+        [Fact]
+        public async Task ShouldAuthenticateWithBasicAuthViaDriverRemoteConnection()
+        {
+            // Test through DriverRemoteConnection + traversal
+            using var client = CreateSecureClient(
+                new[] { Auth.BasicAuth("stephen", "password") });
+            using var remote = new DriverRemoteConnection(client, "gmodern");
+            var g = AnonymousTraversalSource.Traversal().With(remote);
+
+            var count = await g.V().Count().Promise(t => t.Next());
+
+            Assert.True(count > 0);
+        }
+
+        [Fact]
+        public async Task ShouldFailWithWrongCredentials()
+        {
+            using var gremlinClient = CreateSecureClient(
+                new[] { Auth.BasicAuth("stephen", "wrongpassword") });
+
+            var ex = await Assert.ThrowsAsync<HttpRequestException>(
+                () => gremlinClient.SubmitAsync<long>("g.inject(1).count()"));
+
+            // Verify this is an HTTP 401 error, not a connection refused error
+            Assert.Contains("401", ex.Message);
+        }
+
+        [Fact]
+        public async Task ShouldFailWithNoCredentials()
+        {
+            using var gremlinClient = CreateSecureClient();
+
+            var ex = await Assert.ThrowsAsync<HttpRequestException>(
+                () => gremlinClient.SubmitAsync<long>("g.inject(1).count()"));
+
+            // Verify this is an HTTP 401 error, not a connection refused error
+            Assert.Contains("401", ex.Message);
+        }
+    }
+}
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs
new file mode 100644
index 0000000000..6090521187
--- /dev/null
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/AuthTests.cs
@@ -0,0 +1,230 @@
+#region License
+
+/*
+ * 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.
+ */
+
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using Amazon.Runtime;
+using Gremlin.Net.Driver;
+using Xunit;
+
+namespace Gremlin.Net.UnitTest.Driver
+{
+    public class AuthTests
+    {
+        private static HttpRequestContext CreateTestContext()
+        {
+            return new HttpRequestContext("POST", new Uri("http://localhost:8182/gremlin"),
+                new Dictionary<string, string>(), new byte[] { 0x01 });
+        }
+
+        [Fact]
+        public async Task BasicAuthShouldSetCorrectAuthorizationHeader()
+        {
+            var interceptor = Auth.BasicAuth("user", "pass");
+            var context = CreateTestContext();
+
+            await interceptor(context);
+
+            var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass"));
+            Assert.Equal(expected, context.Headers["Authorization"]);
+        }
+
+        [Fact]
+        public async Task BasicAuthShouldSetHeaderOnEveryInvocation()
+        {
+            var interceptor = Auth.BasicAuth("user", "pass");
+            var context1 = CreateTestContext();
+            var context2 = CreateTestContext();
+
+            await interceptor(context1);
+            await interceptor(context2);
+
+            var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass"));
+            Assert.Equal(expected, context1.Headers["Authorization"]);
+            Assert.Equal(expected, context2.Headers["Authorization"]);
+        }
+
+        [Fact]
+        public async Task BasicAuthShouldHandleColonsInPassword()
+        {
+            var interceptor = Auth.BasicAuth("user", "pass:with:colons");
+            var context = CreateTestContext();
+
+            await interceptor(context);
+
+            var expected = "Basic " + Convert.ToBase64String(
+                Encoding.UTF8.GetBytes("user:pass:with:colons"));
+            Assert.Equal(expected, context.Headers["Authorization"]);
+        }
+
+        [Fact]
+        public async Task BasicAuthShouldHandleUnicodeCharacters()
+        {
+            var interceptor = Auth.BasicAuth("用户", "密码");
+            var context = CreateTestContext();
+
+            await interceptor(context);
+
+            var expected = "Basic " + Convert.ToBase64String(
+                Encoding.UTF8.GetBytes("用户:密码"));
+            Assert.Equal(expected, context.Headers["Authorization"]);
+        }
+
+        [Fact]
+        public async Task BasicAuthShouldOverwriteExistingAuthorizationHeader()
+        {
+            var interceptor = Auth.BasicAuth("user", "pass");
+            var context = CreateTestContext();
+            context.Headers["Authorization"] = "Bearer old-token";
+
+            await interceptor(context);
+
+            var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass"));
+            Assert.Equal(expected, context.Headers["Authorization"]);
+        }
+
+        [Fact]
+        public async Task BasicAuthShouldHandleEmptyCredentials()
+        {
+            var interceptor = Auth.BasicAuth("", "");
+            var context = CreateTestContext();
+
+            await interceptor(context);
+
+            var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(":"));
+            Assert.Equal(expected, context.Headers["Authorization"]);
+        }
+
+        // --- SigV4 Tests ---
+
+        private static readonly BasicAWSCredentials TestBasicCredentials =
+            new BasicAWSCredentials("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
+
+        private static readonly SessionAWSCredentials TestSessionCredentials =
+            new SessionAWSCredentials("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+                "FwoGZXIvYXdzEBYaDHqa0AP");
+
+        private static HttpRequestContext CreateSigv4TestContext(byte[]? body = null)
+        {
+            return new HttpRequestContext("POST", new Uri("https://example.com:8182/gremlin"),
+                new Dictionary<string, string>
+                {
+                    { "Content-Type", "application/vnd.graphbinary-v4.0" },
+                    { "Accept", "application/vnd.graphbinary-v4.0" },
+                },
+                body ?? new byte[] { 0x84, 0x00 });
+        }
+
+        [Fact]
+        public async Task SigV4AuthShouldAddRequiredHeaders()
+        {
+            var interceptor = Auth.SigV4Auth("us-east-1", "neptune-db", TestBasicCredentials);
+            var context = CreateSigv4TestContext();
+
+            await interceptor(context);
+
+            Assert.True(context.Headers.ContainsKey("Authorization"));
+            Assert.True(context.Headers.ContainsKey("X-Amz-Date"));
+            Assert.True(context.Headers.ContainsKey("x-amz-content-sha256"));
+            Assert.True(context.Headers.ContainsKey("Host"));
+        }
+
+        [Fact]
+        public async Task SigV4AuthShouldHaveCorrectAuthorizationPrefix()
+        {
+            var interceptor = Auth.SigV4Auth("us-west-2", "neptune-db", TestBasicCredentials);
+            var context = CreateSigv4TestContext();
+
+            await interceptor(context);
+
+            Assert.StartsWith("AWS4-HMAC-SHA256 Credential=", context.Headers["Authorization"]);
+            Assert.Contains("us-west-2", context.Headers["Authorization"]);
+            Assert.Contains("neptune-db", context.Headers["Authorization"]);
+        }
+
+        [Fact]
+        public async Task SigV4AuthShouldAddSessionTokenForTemporaryCredentials()
+        {
+            var interceptor = Auth.SigV4Auth("us-east-1", "neptune-db", TestSessionCredentials);
+            var context = CreateSigv4TestContext();
+
+            await interceptor(context);
+
+            Assert.True(context.Headers.ContainsKey("X-Amz-Security-Token"));
+            Assert.Equal("FwoGZXIvYXdzEBYaDHqa0AP", context.Headers["X-Amz-Security-Token"]);
+        }
+
+        [Fact]
+        public async Task SigV4AuthShouldNotAddSessionTokenForPermanentCredentials()
+        {
+            var interceptor = Auth.SigV4Auth("us-east-1", "neptune-db", TestBasicCredentials);
+            var context = CreateSigv4TestContext();
+
+            await interceptor(context);
+
+            Assert.False(context.Headers.ContainsKey("X-Amz-Security-Token"));
+        }
+
+        [Fact]
+        public async Task SigV4AuthContentHashShouldMatchBodySha256()
+        {
+            var body = new byte[] { 0x84, 0x00, 0xFD, 0x01 };
+            var interceptor = Auth.SigV4Auth("us-east-1", "neptune-db", TestBasicCredentials);
+            var context = CreateSigv4TestContext(body);
+
+            await interceptor(context);
+
+            using var sha256 = SHA256.Create();
+            var expectedHash = BitConverter.ToString(sha256.ComputeHash(body))
+                .Replace("-", "").ToLowerInvariant();
+            Assert.Equal(expectedHash, context.Headers["x-amz-content-sha256"]);
+        }
+
+        [Fact]
+        public async Task SigV4AuthShouldHandleEmptyBody()
+        {
+            var interceptor = Auth.SigV4Auth("us-east-1", "neptune-db", TestBasicCredentials);
+            var context = CreateSigv4TestContext(Array.Empty<byte>());
+
+            await interceptor(context);
+
+            Assert.True(context.Headers.ContainsKey("Authorization"));
+            Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+                context.Headers["x-amz-content-sha256"]);
+        }
+
+        [Fact]
+        public async Task SigV4AuthShouldSetCorrectHost()
+        {
+            var interceptor = Auth.SigV4Auth("us-east-1", "neptune-db", TestBasicCredentials);
+            var context = CreateSigv4TestContext();
+
+            await interceptor(context);
+
+            Assert.Equal("example.com", context.Headers["Host"]);
+        }
+    }
+}
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
index a73b5acc8a..bcb4b35d8b 100644
--- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/ConnectionTests.cs
@@ -278,6 +278,110 @@ namespace Gremlin.Net.UnitTest.Driver
             return serializer;
         }
 
+        [Fact]
+        public async Task ShouldCallInterceptorsInOrder()
+        {
+            var (httpClient, _) = CreateMockHttpClient();
+            var serializer = CreateMockSerializer();
+            var settings = new ConnectionSettings();
+            var callOrder = new List<int>();
+
+            var interceptors = new List<Func<HttpRequestContext, Task>>
+            {
+                ctx => { callOrder.Add(1); return Task.CompletedTask; },
+                ctx => { callOrder.Add(2); return Task.CompletedTask; },
+                ctx => { callOrder.Add(3); return Task.CompletedTask; },
+            };
+
+            using var connection = new Connection(TestUri, serializer, settings, httpClient, interceptors);
+
+            await connection.SubmitAsync<object>(CreateTestRequest());
+
+            Assert.Equal(new List<int> { 1, 2, 3 }, callOrder);
+        }
+
+        [Fact]
+        public async Task ShouldPropagateInterceptorException()
+        {
+            var (httpClient, handler) = CreateMockHttpClient();
+            var serializer = CreateMockSerializer();
+            var settings = new ConnectionSettings();
+            var expectedException = new InvalidOperationException("interceptor failed");
+
+            var interceptors = new List<Func<HttpRequestContext, Task>>
+            {
+                _ => throw expectedException,
+            };
+
+            using var connection = new Connection(TestUri, serializer, settings, httpClient, interceptors);
+
+            var ex = await Assert.ThrowsAsync<InvalidOperationException>(
+                () => connection.SubmitAsync<object>(CreateTestRequest()));
+
+            Assert.Same(expectedException, ex);
+            // HTTP request should not have been sent
+            Assert.Null(handler.CapturedRequest);
+        }
+
+        [Fact]
+        public async Task ShouldAllowInterceptorToModifyHeaders()
+        {
+            var (httpClient, handler) = CreateMockHttpClient();
+            var serializer = CreateMockSerializer();
+            var settings = new ConnectionSettings();
+
+            var interceptors = new List<Func<HttpRequestContext, Task>>
+            {
+                ctx =>
+                {
+                    ctx.Headers["Authorization"] = "Basic dGVzdDp0ZXN0";
+                    return Task.CompletedTask;
+                },
+            };
+
+            using var connection = new Connection(TestUri, serializer, settings, httpClient, interceptors);
+
+            await connection.SubmitAsync<object>(CreateTestRequest());
+
+            Assert.NotNull(handler.CapturedRequest);
+            Assert.True(handler.CapturedRequest!.Headers.Contains("Authorization"));
+            Assert.Equal("Basic dGVzdDp0ZXN0",
+                handler.CapturedRequest.Headers.GetValues("Authorization").First());
+        }
+
+        [Fact]
+        public async Task ShouldSeeEarlierInterceptorModifications()
+        {
+            var (httpClient, handler) = CreateMockHttpClient();
+            var serializer = CreateMockSerializer();
+            var settings = new ConnectionSettings();
+            string? observedHeader = null;
+
+            var interceptors = new List<Func<HttpRequestContext, Task>>
+            {
+                ctx =>
+                {
+                    ctx.Headers["X-Custom"] = "first";
+                    return Task.CompletedTask;
+                },
+                ctx =>
+                {
+                    observedHeader = ctx.Headers.ContainsKey("X-Custom") ? ctx.Headers["X-Custom"] : null;
+                    ctx.Headers["X-Custom"] = "second";
+                    return Task.CompletedTask;
+                },
+            };
+
+            using var connection = new Connection(TestUri, serializer, settings, httpClient, interceptors);
+
+            await connection.SubmitAsync<object>(CreateTestRequest());
+
+            Assert.Equal("first", observedHeader);
+            Assert.NotNull(handler.CapturedRequest);
+            Assert.Equal("second",
+                handler.CapturedRequest!.Headers.GetValues("X-Custom").First());
+        }
+
         /// <summary>
         ///     A test HttpMessageHandler that captures the request and returns a canned response.
         /// </summary>
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/HttpRequestContextTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/HttpRequestContextTests.cs
new file mode 100644
index 0000000000..d5fbb2bc90
--- /dev/null
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Driver/HttpRequestContextTests.cs
@@ -0,0 +1,103 @@
+#region License
+
+/*
+ * 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.
+ */
+
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Gremlin.Net.Driver;
+using Xunit;
+
+namespace Gremlin.Net.UnitTest.Driver
+{
+    public class HttpRequestContextTests
+    {
+        [Fact]
+        public void ShouldConstructWithAllProperties()
+        {
+            var method = "POST";
+            var uri = new Uri("http://localhost:8182/gremlin");
+            var headers = new Dictionary<string, string> { { "Content-Type", "application/vnd.graphbinary-v4.0" } };
+            var body = new byte[] { 0x01, 0x02, 0x03 };
+
+            var context = new HttpRequestContext(method, uri, headers, body);
+
+            Assert.Equal(method, context.Method);
+            Assert.Equal(uri, context.Uri);
+            Assert.Same(headers, context.Headers);
+            Assert.Same(body, context.Body);
+        }
+
+        [Fact]
+        public void ShouldAllowMutatingProperties()
+        {
+            var context = new HttpRequestContext("POST", new Uri("http://localhost:8182/gremlin"),
+                new Dictionary<string, string>(), new byte[] { 0x01 });
+
+            var newUri = new Uri("https://example.com/gremlin");
+            context.Method = "PUT";
+            context.Uri = newUri;
+            context.Body = new byte[] { 0x02, 0x03 };
+            context.Headers["Authorization"] = "Basic dGVzdA==";
+
+            Assert.Equal("PUT", context.Method);
+            Assert.Equal(newUri, context.Uri);
+            Assert.Equal(new byte[] { 0x02, 0x03 }, context.Body);
+            Assert.Equal("Basic dGVzdA==", context.Headers["Authorization"]);
+        }
+
+        [Fact]
+        public void ShouldComputePayloadHashForKnownBody()
+        {
+            // SHA-256 of "hello" = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
+            var body = Encoding.UTF8.GetBytes("hello");
+            var context = new HttpRequestContext("POST", new Uri("http://localhost:8182/gremlin"),
+                new Dictionary<string, string>(), body);
+
+            var hash = context.GetPayloadHash();
+
+            Assert.Equal("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hash);
+        }
+
+        [Fact]
+        public void ShouldComputePayloadHashForEmptyBody()
+        {
+            var context = new HttpRequestContext("POST", new Uri("http://localhost:8182/gremlin"),
+                new Dictionary<string, string>(), Array.Empty<byte>());
+
+            var hash = context.GetPayloadHash();
+
+            Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash);
+        }
+
+        [Fact]
+        public void ShouldComputePayloadHashForNullBody()
+        {
+            var context = new HttpRequestContext("POST", new Uri("http://localhost:8182/gremlin"),
+                new Dictionary<string, string>(), null!);
+
+            var hash = context.GetPayloadHash();
+
+            Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash);
+        }
+    }
+}