Skip to content

Commit 9ef0537

Browse files
feat: add mTLS client certificate support
1 parent 0326918 commit 9ef0537

4 files changed

Lines changed: 138 additions & 16 deletions

File tree

Spice/src/Flight/SpiceFlightClient.cs

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121
*/
2222

2323
using System.Net.Http.Headers;
24+
using System.Security.Cryptography.X509Certificates;
2425
using Apache.Arrow.Flight;
2526
using Apache.Arrow.Flight.Client;
2627
using Grpc.Core;
@@ -39,7 +40,7 @@ internal class SpiceFlightClient : IDisposable
3940
private readonly HttpClient? _httpClient;
4041
private readonly AsyncRetryPolicy _retryPolicy;
4142

42-
private static GrpcChannelOptions GetGrpcChannelOptions(string? appId, string? apiKey, string? userAgent, bool useTls)
43+
private static GrpcChannelOptions GetGrpcChannelOptions(string? appId, string? apiKey, string? userAgent, bool useTls, string? tlsClientCertFile = null, string? tlsClientKeyFile = null, string? tlsRootCertFile = null)
4344
{
4445
var options = new GrpcChannelOptions();
4546

@@ -57,12 +58,27 @@ private static GrpcChannelOptions GetGrpcChannelOptions(string? appId, string? a
5758
var handler = new SocketsHttpHandler
5859
{
5960
EnableMultipleHttp2Connections = true,
60-
// Force periodic connection recycling to trigger DNS re-resolution.
61-
// Without this, HTTP/2 connections are kept alive indefinitely and
62-
// the client can get stuck on stale IPs when backend targets change
63-
// (e.g. AWS ALB target rotation).
6461
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
6562
};
63+
if (tlsClientCertFile != null && tlsClientKeyFile != null)
64+
{
65+
var clientCert = X509Certificate2.CreateFromPemFile(tlsClientCertFile, tlsClientKeyFile);
66+
handler.SslOptions.ClientCertificates = new X509Certificate2Collection { clientCert };
67+
}
68+
if (tlsRootCertFile != null)
69+
{
70+
#pragma warning disable SYSLIB0057
71+
var caCert = new X509Certificate2(tlsRootCertFile);
72+
#pragma warning restore SYSLIB0057
73+
handler.SslOptions.RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
74+
{
75+
if (errors == System.Net.Security.SslPolicyErrors.None) return true;
76+
if (cert == null || chain == null) return false;
77+
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
78+
chain.ChainPolicy.CustomTrustStore.Add(caCert);
79+
return chain.Build(new X509Certificate2(cert));
80+
};
81+
}
6682
options.HttpHandler = handler;
6783
}
6884
#endif
@@ -77,15 +93,31 @@ private static GrpcChannelOptions GetGrpcChannelOptions(string? appId, string? a
7793
#if NET8_0_OR_GREATER
7894
if (useTls)
7995
{
80-
messageHandler = new SocketsHttpHandler
96+
var handler = new SocketsHttpHandler
8197
{
8298
EnableMultipleHttp2Connections = true,
83-
// Force periodic connection recycling to trigger DNS re-resolution.
84-
// Without this, HTTP/2 connections are kept alive indefinitely and
85-
// the client can get stuck on stale IPs when backend targets change
86-
// (e.g. AWS ALB target rotation).
8799
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
88100
};
101+
if (tlsClientCertFile != null && tlsClientKeyFile != null)
102+
{
103+
var clientCert = X509Certificate2.CreateFromPemFile(tlsClientCertFile, tlsClientKeyFile);
104+
handler.SslOptions.ClientCertificates = new X509Certificate2Collection { clientCert };
105+
}
106+
if (tlsRootCertFile != null)
107+
{
108+
#pragma warning disable SYSLIB0057
109+
var caCert = new X509Certificate2(tlsRootCertFile);
110+
#pragma warning restore SYSLIB0057
111+
handler.SslOptions.RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
112+
{
113+
if (errors == System.Net.Security.SslPolicyErrors.None) return true;
114+
if (cert == null || chain == null) return false;
115+
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
116+
chain.ChainPolicy.CustomTrustStore.Add(caCert);
117+
return chain.Build(new X509Certificate2(cert));
118+
};
119+
}
120+
messageHandler = handler;
89121
}
90122
else
91123
#endif
@@ -112,13 +144,13 @@ private static GrpcChannelOptions GetGrpcChannelOptions(string? appId, string? a
112144
return responseHeaders.Get("authorization") ?? trailers.Get("authorization");
113145
}
114146

115-
internal SpiceFlightClient(string address, int maxRetries, string? appId, string? apiKey, string? userAgent, bool useTls)
147+
internal SpiceFlightClient(string address, int maxRetries, string? appId, string? apiKey, string? userAgent, bool useTls, string? tlsClientCertFile = null, string? tlsClientKeyFile = null, string? tlsRootCertFile = null)
116148
{
117149
_retryPolicy = RetryPolicyFactory.CreateRpcRetryPolicy(
118150
maxRetries,
119151
(ex, ts, attempt) => RetryPolicyFactory.LogRetry("Flight", ex, ts, attempt));
120152

121-
var options = GetGrpcChannelOptions(appId, apiKey, userAgent, useTls);
153+
var options = GetGrpcChannelOptions(appId, apiKey, userAgent, useTls, tlsClientCertFile, tlsClientKeyFile, tlsRootCertFile);
122154
_httpClient = options.HttpClient;
123155

124156
_channel = GrpcChannel.ForAddress(address, options);

Spice/src/Http/SpiceHttpClient.cs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ internal class SpiceHttpClient : ISpiceHttpClient
3535
private readonly string _httpAddress;
3636
private bool _disposed;
3737

38-
internal SpiceHttpClient(string httpAddress, string? appId, string? apiKey, string? userAgent)
38+
internal SpiceHttpClient(string httpAddress, string? appId, string? apiKey, string? userAgent,
39+
string? tlsClientCertFile = null, string? tlsClientKeyFile = null, string? tlsRootCertFile = null)
3940
{
4041
#if NET8_0_OR_GREATER
4142
ArgumentException.ThrowIfNullOrWhiteSpace(httpAddress);
@@ -44,7 +45,42 @@ internal SpiceHttpClient(string httpAddress, string? appId, string? apiKey, stri
4445
#endif
4546

4647
_httpAddress = httpAddress;
47-
_httpClient = new HttpClient();
48+
49+
#if NET8_0_OR_GREATER
50+
if (tlsClientCertFile != null || tlsRootCertFile != null)
51+
{
52+
var handler = new SocketsHttpHandler
53+
{
54+
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
55+
};
56+
if (tlsClientCertFile != null && tlsClientKeyFile != null)
57+
{
58+
var clientCert = System.Security.Cryptography.X509Certificates.X509Certificate2.CreateFromPemFile(
59+
tlsClientCertFile, tlsClientKeyFile);
60+
handler.SslOptions.ClientCertificates =
61+
new System.Security.Cryptography.X509Certificates.X509Certificate2Collection { clientCert };
62+
}
63+
if (tlsRootCertFile != null)
64+
{
65+
#pragma warning disable SYSLIB0057
66+
var caCert = new System.Security.Cryptography.X509Certificates.X509Certificate2(tlsRootCertFile);
67+
#pragma warning restore SYSLIB0057
68+
handler.SslOptions.RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
69+
{
70+
if (errors == System.Net.Security.SslPolicyErrors.None) return true;
71+
if (cert == null || chain == null) return false;
72+
chain.ChainPolicy.TrustMode = System.Security.Cryptography.X509Certificates.X509ChainTrustMode.CustomRootTrust;
73+
chain.ChainPolicy.CustomTrustStore.Add(caCert);
74+
return chain.Build(new System.Security.Cryptography.X509Certificates.X509Certificate2(cert));
75+
};
76+
}
77+
_httpClient = new HttpClient(handler);
78+
}
79+
else
80+
#endif
81+
{
82+
_httpClient = new HttpClient();
83+
}
4884

4985
// Set authorization if credentials provided
5086
if (!string.IsNullOrEmpty(appId) && !string.IsNullOrEmpty(apiKey))

Spice/src/SpiceClient.cs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,44 @@ public class SpiceClient : IDisposable
6666
/// </summary>
6767
public bool UseTls { get; internal set; }
6868

69+
/// <summary>
70+
/// Gets or sets the path to a PEM-encoded client certificate file for mTLS.
71+
/// Must be used together with <see cref="TlsClientKeyFile"/>.
72+
/// </summary>
73+
public string? TlsClientCertFile { get; internal set; }
74+
75+
/// <summary>
76+
/// Gets or sets the path to a PEM-encoded client private key file for mTLS.
77+
/// Must be used together with <see cref="TlsClientCertFile"/>.
78+
/// </summary>
79+
public string? TlsClientKeyFile { get; internal set; }
80+
81+
/// <summary>
82+
/// Gets or sets the path to a PEM-encoded CA certificate file for server verification.
83+
/// When set, this CA is used instead of the system certificate store.
84+
/// </summary>
85+
public string? TlsRootCertFile { get; internal set; }
86+
6987
private SpiceFlightClient? FlightClient { get; set; }
7088
private SpiceAdbcClient? AdbcClient { get; set; }
7189
private SpiceHttpClient? HttpClient { get; set; }
7290

7391

7492
internal void Init()
7593
{
76-
FlightClient = new SpiceFlightClient(FlightAddress, MaxRetries, AppId, ApiKey, UserAgent, UseTls);
94+
// Validate that client cert and key are either both set or both unset
95+
bool hasCert = !string.IsNullOrEmpty(TlsClientCertFile);
96+
bool hasKey = !string.IsNullOrEmpty(TlsClientKeyFile);
97+
if (hasCert != hasKey)
98+
{
99+
var missing = hasCert ? nameof(TlsClientKeyFile) : nameof(TlsClientCertFile);
100+
throw new InvalidOperationException(
101+
$"Both {nameof(TlsClientCertFile)} and {nameof(TlsClientKeyFile)} must be provided together for mTLS. {missing} is missing.");
102+
}
103+
104+
FlightClient = new SpiceFlightClient(FlightAddress, MaxRetries, AppId, ApiKey, UserAgent, UseTls, TlsClientCertFile, TlsClientKeyFile, TlsRootCertFile);
77105
AdbcClient = new SpiceAdbcClient(FlightAddress, MaxRetries, AppId, ApiKey, UserAgent, UseTls);
78-
HttpClient = new SpiceHttpClient(HttpAddress, AppId, ApiKey, UserAgent);
106+
HttpClient = new SpiceHttpClient(HttpAddress, AppId, ApiKey, UserAgent, TlsClientCertFile, TlsClientKeyFile, TlsRootCertFile);
79107
}
80108

81109
/// <summary>

Spice/src/SpiceClientBuilder.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,32 @@ public SpiceClientBuilder WithTls(bool useTls = true)
148148
return this;
149149
}
150150

151+
/// <summary>
152+
/// Sets the paths to PEM-encoded client certificate and key files for mTLS.
153+
/// </summary>
154+
/// <param name="certFile">Path to the client certificate PEM file.</param>
155+
/// <param name="keyFile">Path to the client private key PEM file.</param>
156+
/// <returns>The current instance of <see cref="SpiceClientBuilder"/> for method chaining.</returns>
157+
public SpiceClientBuilder WithTlsClientCertificate(string certFile, string keyFile)
158+
{
159+
_spiceClient.TlsClientCertFile = certFile;
160+
_spiceClient.TlsClientKeyFile = keyFile;
161+
_spiceClient.UseTls = true;
162+
return this;
163+
}
164+
165+
/// <summary>
166+
/// Sets the path to a PEM-encoded CA certificate file for server verification.
167+
/// </summary>
168+
/// <param name="caFile">Path to the CA certificate PEM file.</param>
169+
/// <returns>The current instance of <see cref="SpiceClientBuilder"/> for method chaining.</returns>
170+
public SpiceClientBuilder WithTlsRootCertificate(string caFile)
171+
{
172+
_spiceClient.TlsRootCertFile = caFile;
173+
_spiceClient.UseTls = true;
174+
return this;
175+
}
176+
151177
/// <summary>
152178
/// Initiates <see cref="SpiceClient" /> with provided parameters.
153179
/// </summary>

0 commit comments

Comments
 (0)