This is an automated email from the ASF dual-hosted git repository.
curth pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-adbc.git
The following commit(s) were added to refs/heads/main by this push:
new e5c25e129 feat(csharp/test/Drivers/Databricks): Support token refresh
to extend connection lifetime (#3177)
e5c25e129 is described below
commit e5c25e129f99a578b83aa77e9b50a6cdcf4f6765
Author: Alex Guo <[email protected]>
AuthorDate: Mon Jul 21 20:44:00 2025 -0700
feat(csharp/test/Drivers/Databricks): Support token refresh to extend
connection lifetime (#3177)
## Motivation
In scenarios like PowerBI dataset refresh, if a query runs longer than
the OAuth token's expiration time (typically 1 hour for AAD tokens), the
connection fails. PowerBI only refreshes access tokens if they have less
than 20 minutes of expiration time and never updates tokens after a
connection is opened.
This PR implements token refresh functionality in the Databricks ADBC
driver using the Databricks token exchange API. When an OAuth token is
about to expire within a configurable time limit, the driver
automatically exchanges it for a new token with a longer expiration
time.
## Key Components
1. **JWT Token Decoder**: Parses JWT tokens to extract expiration time
2. **Token Exchange Client**: Handles API calls to the Databricks token
exchange endpoint
3. **Token Exchange Handler**: HTTP handler that intercepts requests and
refreshes tokens when needed
## Changes
- Added new connection string parameter
`adbc.databricks.token_renew_limit` to control when token renewal
happens
- Implemented JWT token decoding to extract token expiration time
- Created token exchange client to handle API calls to Databricks token
exchange endpoint
- Added HTTP handler to intercept requests and refresh tokens when
needed
- Updated connection handling to create and configure the token exchange
components
## Testing
- Unit tests for JWT token decoding, token exchange client, and token
exchange handler
- End-to-end tests that verify token refresh functionality with real
tokens
```
dotnet test --filter "FullyQualifiedName~JwtTokenDecoderTests"
dotnet test --filter "FullyQualifiedName~TokenExchangeClientTests"
dotnet test --filter
"FullyQualifiedName~TokenExchangeDelegatingHandlerTests"
```
---
.../src/Drivers/Databricks/Auth/JwtTokenDecoder.cs | 96 +++++
.../Auth/OAuthClientCredentialsProvider.cs | 1 -
.../Drivers/Databricks/Auth/TokenExchangeClient.cs | 177 ++++++++
.../Auth/TokenExchangeDelegatingHandler.cs | 144 +++++++
.../src/Drivers/Databricks/DatabricksConnection.cs | 91 ++++-
.../src/Drivers/Databricks/DatabricksParameters.cs | 6 +
.../Databricks/E2E/Auth/TokenExchangeTests.cs | 157 ++++++++
.../Databricks/E2E/DatabricksTestConfiguration.cs | 3 +
.../Databricks/Unit/Auth/JwtTokenDecoderTests.cs | 124 ++++++
.../Unit/Auth/TokenExchangeClientTests.cs | 411 +++++++++++++++++++
.../Auth/TokenExchangeDelegatingHandlerTests.cs | 447 +++++++++++++++++++++
11 files changed, 1638 insertions(+), 19 deletions(-)
diff --git a/csharp/src/Drivers/Databricks/Auth/JwtTokenDecoder.cs
b/csharp/src/Drivers/Databricks/Auth/JwtTokenDecoder.cs
new file mode 100644
index 000000000..ed68aeeb8
--- /dev/null
+++ b/csharp/src/Drivers/Databricks/Auth/JwtTokenDecoder.cs
@@ -0,0 +1,96 @@
+/*
+* 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.
+*/
+
+using System;
+using System.Text;
+using System.Text.Json;
+
+namespace Apache.Arrow.Adbc.Drivers.Databricks.Auth
+{
+ /// <summary>
+ /// Utility class for decoding JWT tokens and extracting claims.
+ /// </summary>
+ internal static class JwtTokenDecoder
+ {
+ /// <summary>
+ /// Tries to parse a JWT token and extract its expiration time.
+ /// </summary>
+ /// <param name="token">The JWT token to parse.</param>
+ /// <param name="expiryTime">The extracted expiration time, if
successful.</param>
+ /// <returns>True if the expiration time was successfully extracted,
false otherwise.</returns>
+ public static bool TryGetExpirationTime(string token, out DateTime
expiryTime)
+ {
+ expiryTime = DateTime.MinValue;
+
+ try
+ {
+ // JWT tokens have three parts separated by dots:
header.payload.signature
+ string[] parts = token.Split('.');
+ if (parts.Length != 3)
+ {
+ return false;
+ }
+
+ string payload = DecodeBase64Url(parts[1]);
+
+ using JsonDocument jsonDoc = JsonDocument.Parse(payload);
+
+ if (!jsonDoc.RootElement.TryGetProperty("exp", out JsonElement
expElement))
+ {
+ return false;
+ }
+
+ // The exp claim is a Unix timestamp (seconds since epoch)
+ if (!expElement.TryGetInt64(out long expSeconds))
+ {
+ return false;
+ }
+
+ expiryTime =
DateTimeOffset.FromUnixTimeSeconds(expSeconds).UtcDateTime;
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ /// <summary>
+ /// Decodes a base64url encoded string to a regular string.
+ /// </summary>
+ /// <param name="base64Url">The base64url encoded string.</param>
+ /// <returns>The decoded string.</returns>
+ private static string DecodeBase64Url(string base64Url)
+ {
+ // Convert base64url to base64
+ string base64 = base64Url
+ .Replace('-', '+')
+ .Replace('_', '/');
+
+ // Add padding if needed
+ switch (base64.Length % 4)
+ {
+ case 2: base64 += "=="; break;
+ case 3: base64 += "="; break;
+ }
+
+ byte[] bytes = Convert.FromBase64String(base64);
+
+ return Encoding.UTF8.GetString(bytes);
+ }
+ }
+}
diff --git
a/csharp/src/Drivers/Databricks/Auth/OAuthClientCredentialsProvider.cs
b/csharp/src/Drivers/Databricks/Auth/OAuthClientCredentialsProvider.cs
index a54d6588c..df4fc6d24 100644
--- a/csharp/src/Drivers/Databricks/Auth/OAuthClientCredentialsProvider.cs
+++ b/csharp/src/Drivers/Databricks/Auth/OAuthClientCredentialsProvider.cs
@@ -228,7 +228,6 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks.Auth
public void Dispose()
{
_tokenLock.Dispose();
- _httpClient.Dispose();
}
public string? GetCachedTokenScope()
diff --git a/csharp/src/Drivers/Databricks/Auth/TokenExchangeClient.cs
b/csharp/src/Drivers/Databricks/Auth/TokenExchangeClient.cs
new file mode 100644
index 000000000..5246c16fa
--- /dev/null
+++ b/csharp/src/Drivers/Databricks/Auth/TokenExchangeClient.cs
@@ -0,0 +1,177 @@
+/*
+* 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.
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Apache.Arrow.Adbc.Drivers.Databricks.Auth
+{
+ /// <summary>
+ /// Response from the token exchange API.
+ /// </summary>
+ internal class TokenExchangeResponse
+ {
+ /// <summary>
+ /// The new access token.
+ /// </summary>
+ public string AccessToken { get; set; } = string.Empty;
+
+ /// <summary>
+ /// The token type (e.g., "Bearer").
+ /// </summary>
+ public string TokenType { get; set; } = string.Empty;
+
+ /// <summary>
+ /// The number of seconds until the token expires.
+ /// </summary>
+ public int ExpiresIn { get; set; }
+
+ /// <summary>
+ /// The calculated expiration time based on ExpiresIn.
+ /// </summary>
+ public DateTime ExpiryTime { get; set; }
+ }
+
+ /// <summary>
+ /// Interface for token exchange operations.
+ /// </summary>
+ internal interface ITokenExchangeClient
+ {
+ /// <summary>
+ /// Exchanges the provided token for a new token.
+ /// </summary>
+ /// <param name="token">The token to exchange.</param>
+ /// <param name="cancellationToken">A cancellation token.</param>
+ /// <returns>The response from the token exchange API.</returns>
+ Task<TokenExchangeResponse> ExchangeTokenAsync(string token,
CancellationToken cancellationToken);
+ }
+
+ /// <summary>
+ /// Client for exchanging tokens using the Databricks token exchange API.
+ /// </summary>
+ internal class TokenExchangeClient : ITokenExchangeClient
+ {
+ private readonly HttpClient _httpClient;
+ private readonly string _tokenExchangeEndpoint;
+
+ /// <summary>
+ /// Initializes a new instance of the <see
cref="TokenExchangeClient"/> class.
+ /// </summary>
+ /// <param name="httpClient">The HTTP client to use for
requests.</param>
+ /// <param name="host">The host of the Databricks workspace.</param>
+ public TokenExchangeClient(HttpClient httpClient, string host)
+ {
+ _httpClient = httpClient ?? throw new
ArgumentNullException(nameof(httpClient));
+
+ if (string.IsNullOrEmpty(host))
+ {
+ throw new ArgumentNullException(nameof(host));
+ }
+
+ // Ensure the host doesn't have a trailing slash
+ host = host.TrimEnd('/');
+
+ _tokenExchangeEndpoint = $"https://{host}/oidc/v1/token";
+ }
+
+ /// <summary>
+ /// Exchanges the provided token for a new token.
+ /// </summary>
+ /// <param name="token">The token to exchange.</param>
+ /// <param name="cancellationToken">A cancellation token.</param>
+ /// <returns>The response from the token exchange API.</returns>
+ public async Task<TokenExchangeResponse> ExchangeTokenAsync(string
token, CancellationToken cancellationToken)
+ {
+ var content = new FormUrlEncodedContent(new[]
+ {
+ new KeyValuePair<string, string>("grant_type",
"urn:ietf:params:oauth:grant-type:jwt-bearer"),
+ new KeyValuePair<string, string>("assertion", token)
+ });
+
+ var request = new HttpRequestMessage(HttpMethod.Post,
_tokenExchangeEndpoint)
+ {
+ Content = content
+ };
+ request.Headers.Accept.Add(new
System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("*/*"));
+
+ HttpResponseMessage response = await
_httpClient.SendAsync(request, cancellationToken);
+
+ response.EnsureSuccessStatusCode();
+
+ string responseContent = await
response.Content.ReadAsStringAsync();
+ return ParseTokenResponse(responseContent);
+ }
+
+ /// <summary>
+ /// Parses the token exchange API response.
+ /// </summary>
+ /// <param name="responseContent">The response content to
parse.</param>
+ /// <returns>The parsed token exchange response.</returns>
+ private TokenExchangeResponse ParseTokenResponse(string
responseContent)
+ {
+ using JsonDocument jsonDoc = JsonDocument.Parse(responseContent);
+ var root = jsonDoc.RootElement;
+
+ if (!root.TryGetProperty("access_token", out JsonElement
accessTokenElement))
+ {
+ throw new DatabricksException("Token exchange response did not
contain an access_token");
+ }
+
+ string? accessToken = accessTokenElement.GetString();
+ if (string.IsNullOrEmpty(accessToken))
+ {
+ throw new DatabricksException("Token exchange access_token was
null or empty");
+ }
+
+ if (!root.TryGetProperty("token_type", out JsonElement
tokenTypeElement))
+ {
+ throw new DatabricksException("Token exchange response did not
contain token_type");
+ }
+
+ string? tokenType = tokenTypeElement.GetString();
+ if (string.IsNullOrEmpty(tokenType))
+ {
+ throw new DatabricksException("Token exchange token_type was
null or empty");
+ }
+
+ if (!root.TryGetProperty("expires_in", out JsonElement
expiresInElement))
+ {
+ throw new DatabricksException("Token exchange response did not
contain expires_in");
+ }
+
+ int expiresIn = expiresInElement.GetInt32();
+ if (expiresIn <= 0)
+ {
+ throw new DatabricksException("Token exchange expires_in value
must be positive");
+ }
+
+ DateTime expiryTime = DateTime.UtcNow.AddSeconds(expiresIn);
+
+ return new TokenExchangeResponse
+ {
+ AccessToken = accessToken!,
+ TokenType = tokenType!,
+ ExpiresIn = expiresIn,
+ ExpiryTime = expiryTime
+ };
+ }
+ }
+}
diff --git
a/csharp/src/Drivers/Databricks/Auth/TokenExchangeDelegatingHandler.cs
b/csharp/src/Drivers/Databricks/Auth/TokenExchangeDelegatingHandler.cs
new file mode 100644
index 000000000..f24a9a90e
--- /dev/null
+++ b/csharp/src/Drivers/Databricks/Auth/TokenExchangeDelegatingHandler.cs
@@ -0,0 +1,144 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+* contributor license agreements. See the NOTICE file distributed with
+* this work for additional information regarding copyright ownership.
+* The ASF licenses this file to You under the Apache License, Version 2.0
+* (the "License"); you may not use this file except in compliance with
+* the License. You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+using System;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Apache.Arrow.Adbc.Drivers.Databricks.Auth
+{
+ /// <summary>
+ /// HTTP message handler that automatically refreshes OAuth tokens before
they expire.
+ /// </summary>
+ internal class TokenExchangeDelegatingHandler : DelegatingHandler
+ {
+ private readonly string _initialToken;
+ private readonly int _tokenRenewLimitMinutes;
+ private readonly SemaphoreSlim _tokenLock = new SemaphoreSlim(1, 1);
+ private readonly ITokenExchangeClient _tokenExchangeClient;
+
+ private string _currentToken;
+ private DateTime _tokenExpiryTime;
+ private bool _tokenExchangeAttempted = false;
+
+ /// <summary>
+ /// Initializes a new instance of the <see
cref="TokenExchangeDelegatingHandler"/> class.
+ /// </summary>
+ /// <param name="innerHandler">The inner handler to delegate
to.</param>
+ /// <param name="tokenExchangeClient">The client for token exchange
operations.</param>
+ /// <param name="initialToken">The initial token from the connection
string.</param>
+ /// <param name="tokenExpiryTime">The expiry time of the initial
token.</param>
+ /// <param name="tokenRenewLimitMinutes">The minutes before token
expiration when we should start renewing the token.</param>
+ public TokenExchangeDelegatingHandler(
+ HttpMessageHandler innerHandler,
+ ITokenExchangeClient tokenExchangeClient,
+ string initialToken,
+ DateTime tokenExpiryTime,
+ int tokenRenewLimitMinutes)
+ : base(innerHandler)
+ {
+ _tokenExchangeClient = tokenExchangeClient ?? throw new
ArgumentNullException(nameof(tokenExchangeClient));
+ _initialToken = initialToken ?? throw new
ArgumentNullException(nameof(initialToken));
+ _tokenExpiryTime = tokenExpiryTime;
+ _tokenRenewLimitMinutes = tokenRenewLimitMinutes;
+ _currentToken = initialToken;
+ }
+
+ /// <summary>
+ /// Checks if the token needs to be renewed.
+ /// </summary>
+ /// <returns>True if the token needs to be renewed, false
otherwise.</returns>
+ private bool NeedsTokenRenewal()
+ {
+ // Only renew if:
+ // 1. We haven't already attempted token exchange (a token can
only be renewed once)
+ // 2. The token will expire within the renewal limit
+ return !_tokenExchangeAttempted &&
+ DateTime.UtcNow.AddMinutes(_tokenRenewLimitMinutes) >=
_tokenExpiryTime;
+ }
+
+ /// <summary>
+ /// Renews the token if needed.
+ /// </summary>
+ /// <param name="cancellationToken">A cancellation token.</param>
+ /// <returns>A task representing the asynchronous operation.</returns>
+ private async Task RenewTokenIfNeededAsync(CancellationToken
cancellationToken)
+ {
+ if (!NeedsTokenRenewal())
+ {
+ return;
+ }
+
+ // Acquire the lock to ensure only one thread attempts renewal
+ await _tokenLock.WaitAsync(cancellationToken);
+
+ try
+ {
+ // Double-check pattern in case another thread renewed while
we were waiting
+ if (!NeedsTokenRenewal())
+ {
+ return;
+ }
+
+ try
+ {
+ _tokenExchangeAttempted = true;
+
+ TokenExchangeResponse response = await
_tokenExchangeClient.ExchangeTokenAsync(_initialToken, cancellationToken);
+
+ _currentToken = response.AccessToken;
+ _tokenExpiryTime = response.ExpiryTime;
+ }
+ catch (Exception ex)
+ {
+ // Log the error but continue with the current token
+ // This is to avoid interrupting the operation if token
exchange fails
+ System.Diagnostics.Debug.WriteLine($"Token exchange
failed: {ex.Message}");
+ }
+ }
+ finally
+ {
+ _tokenLock.Release();
+ }
+ }
+
+ /// <summary>
+ /// Sends an HTTP request with the current token.
+ /// </summary>
+ /// <param name="request">The HTTP request message to send.</param>
+ /// <param name="cancellationToken">A cancellation token.</param>
+ /// <returns>The HTTP response message.</returns>
+ protected override async Task<HttpResponseMessage>
SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ await RenewTokenIfNeededAsync(cancellationToken);
+ request.Headers.Authorization = new
AuthenticationHeaderValue("Bearer", _currentToken);
+ return await base.SendAsync(request, cancellationToken);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _tokenLock.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/csharp/src/Drivers/Databricks/DatabricksConnection.cs
b/csharp/src/Drivers/Databricks/DatabricksConnection.cs
index 7dee4e612..37263183c 100644
--- a/csharp/src/Drivers/Databricks/DatabricksConnection.cs
+++ b/csharp/src/Drivers/Databricks/DatabricksConnection.cs
@@ -69,6 +69,8 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
// Default namespace
private TNamespace? _defaultNamespace;
+ private HttpClient? _authHttpClient;
+
public DatabricksConnection(IReadOnlyDictionary<string, string>
properties) : base(properties)
{
ValidateProperties();
@@ -327,20 +329,26 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
protected override HttpMessageHandler CreateHttpHandler()
{
HttpMessageHandler baseHandler = base.CreateHttpHandler();
+ HttpMessageHandler baseAuthHandler =
HiveServer2TlsImpl.NewHttpClientHandler(TlsOptions, _proxyConfigurator);
// Add tracing handler to propagate W3C trace context if enabled
if (_tracePropagationEnabled)
{
baseHandler = new TracingDelegatingHandler(baseHandler, this,
_traceParentHeaderName, _traceStateEnabled);
+ baseAuthHandler = new
TracingDelegatingHandler(baseAuthHandler, this, _traceParentHeaderName,
_traceStateEnabled);
}
if (TemporarilyUnavailableRetry)
{
// Add retry handler for 503 responses
baseHandler = new RetryHttpHandler(baseHandler,
TemporarilyUnavailableRetryTimeout);
+ baseAuthHandler = new RetryHttpHandler(baseAuthHandler,
TemporarilyUnavailableRetryTimeout);
}
- // Add OAuth handler if OAuth authentication is being used
+ Debug.Assert(_authHttpClient == null, "Auth HttpClient should not
be initialized yet.");
+ _authHttpClient = new HttpClient(baseAuthHandler);
+
+ // Add OAuth client credentials handler if OAuth M2M
authentication is being used
if (Properties.TryGetValue(SparkParameters.AuthType, out string?
authType) &&
SparkAuthTypeParser.TryParse(authType, out SparkAuthType
authTypeValue) &&
authTypeValue == SparkAuthType.OAuth &&
@@ -348,28 +356,14 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
DatabricksOAuthGrantTypeParser.TryParse(grantTypeStr, out
DatabricksOAuthGrantType grantType) &&
grantType == DatabricksOAuthGrantType.ClientCredentials)
{
- // Note: We assume that properties have already been validated
- if (Properties.TryGetValue(SparkParameters.HostName, out
string? host) && !string.IsNullOrEmpty(host))
- {
- // Use hostname directly if provided
- }
- else if (Properties.TryGetValue(AdbcOptions.Uri, out string?
uri) && !string.IsNullOrEmpty(uri))
- {
- // Extract hostname from URI if URI is provided
- if (Uri.TryCreate(uri, UriKind.Absolute, out Uri?
parsedUri))
- {
- host = parsedUri.Host;
- }
- }
+ string host = GetHost();
Properties.TryGetValue(DatabricksParameters.OAuthClientId, out
string? clientId);
Properties.TryGetValue(DatabricksParameters.OAuthClientSecret,
out string? clientSecret);
Properties.TryGetValue(DatabricksParameters.OAuthScope, out
string? scope);
- HttpClient OauthHttpClient = new
HttpClient(HiveServer2TlsImpl.NewHttpClientHandler(TlsOptions,
_proxyConfigurator));
-
var tokenProvider = new OAuthClientCredentialsProvider(
- OauthHttpClient,
+ _authHttpClient,
clientId!,
clientSecret!,
host!,
@@ -377,7 +371,36 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
timeoutMinutes: 1
);
- return new OAuthDelegatingHandler(baseHandler, tokenProvider);
+ baseHandler = new OAuthDelegatingHandler(baseHandler,
tokenProvider);
+ }
+ // Add token exchange handler if token renewal is enabled and the
auth type is OAuth access token
+ else if
(Properties.TryGetValue(DatabricksParameters.TokenRenewLimit, out string?
tokenRenewLimitStr) &&
+ int.TryParse(tokenRenewLimitStr, out int tokenRenewLimit) &&
+ tokenRenewLimit > 0 &&
+ Properties.TryGetValue(SparkParameters.AuthType, out string?
authTypeForToken) &&
+ SparkAuthTypeParser.TryParse(authTypeForToken, out
SparkAuthType authTypeValueForToken) &&
+ authTypeValueForToken == SparkAuthType.OAuth &&
+ Properties.TryGetValue(SparkParameters.AccessToken, out
string? accessToken))
+ {
+ if (string.IsNullOrEmpty(accessToken))
+ {
+ throw new ArgumentException("Access token is required for
OAuth authentication with token renewal.");
+ }
+
+ // Check if token is a JWT token by trying to decode it
+ if (JwtTokenDecoder.TryGetExpirationTime(accessToken, out
DateTime expiryTime))
+ {
+ string host = GetHost();
+
+ var tokenExchangeClient = new
TokenExchangeClient(_authHttpClient, host);
+
+ baseHandler = new TokenExchangeDelegatingHandler(
+ baseHandler,
+ tokenExchangeClient,
+ accessToken,
+ expiryTime,
+ tokenRenewLimit);
+ }
}
return baseHandler;
@@ -673,6 +696,29 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
}
}
+ /// <summary>
+ /// Gets the host from the connection properties.
+ /// </summary>
+ /// <returns>The host, or empty string if not found.</returns>
+ private string GetHost()
+ {
+ if (Properties.TryGetValue(SparkParameters.HostName, out string?
host) && !string.IsNullOrEmpty(host))
+ {
+ return host;
+ }
+
+ if (Properties.TryGetValue(AdbcOptions.Uri, out string? uri) &&
!string.IsNullOrEmpty(uri))
+ {
+ // Parse the URI to extract the host
+ if (Uri.TryCreate(uri, UriKind.Absolute, out Uri? parsedUri))
+ {
+ return parsedUri.Host;
+ }
+ }
+
+ throw new ArgumentException("Host not found in connection
properties. Please provide a valid host using either 'HostName' or 'Uri'
property.");
+ }
+
public override string AssemblyName => s_assemblyName;
public override string AssemblyVersion => s_assemblyVersion;
@@ -685,5 +731,14 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
}
return CatalogName;
}
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _authHttpClient?.Dispose();
+ }
+ base.Dispose(disposing);
+ }
}
}
diff --git a/csharp/src/Drivers/Databricks/DatabricksParameters.cs
b/csharp/src/Drivers/Databricks/DatabricksParameters.cs
index 2166fa43f..56030b4e3 100644
--- a/csharp/src/Drivers/Databricks/DatabricksParameters.cs
+++ b/csharp/src/Drivers/Databricks/DatabricksParameters.cs
@@ -206,6 +206,12 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks
/// When enabled, the driver will also propagate the tracestate header
if available.
/// </summary>
public const string TraceStateEnabled =
"adbc.databricks.trace_propagation.state_enabled";
+
+ /// <summary>
+ /// The minutes before token expiration when we should start renewing
the token.
+ /// Default value is 0 (disabled) if not specified.
+ /// </summary>
+ public const string TokenRenewLimit =
"adbc.databricks.token_renew_limit";
}
/// <summary>
diff --git a/csharp/test/Drivers/Databricks/E2E/Auth/TokenExchangeTests.cs
b/csharp/test/Drivers/Databricks/E2E/Auth/TokenExchangeTests.cs
new file mode 100644
index 000000000..b2e988be1
--- /dev/null
+++ b/csharp/test/Drivers/Databricks/E2E/Auth/TokenExchangeTests.cs
@@ -0,0 +1,157 @@
+/*
+* 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.
+*/
+
+using System;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Apache.Arrow.Adbc.Drivers.Databricks.Auth;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Apache.Arrow.Adbc.Tests.Drivers.Databricks.Auth
+{
+ public class TokenExchangeTests : TestBase<DatabricksTestConfiguration,
DatabricksTestEnvironment>, IDisposable
+ {
+ private readonly HttpClient _httpClient;
+
+ public TokenExchangeTests(ITestOutputHelper? outputHelper)
+ : base(outputHelper, new DatabricksTestEnvironment.Factory())
+ {
+ _httpClient = new HttpClient();
+ }
+
+ private string GetHost()
+ {
+ string host;
+ if (!string.IsNullOrEmpty(TestConfiguration.HostName))
+ {
+ host = TestConfiguration.HostName;
+ }
+ else if (!string.IsNullOrEmpty(TestConfiguration.Uri))
+ {
+ if (Uri.TryCreate(TestConfiguration.Uri, UriKind.Absolute, out
Uri? parsedUri))
+ {
+ host = parsedUri.Host;
+ }
+ else
+ {
+ throw new ArgumentException($"Invalid URI format:
{TestConfiguration.Uri}");
+ }
+ }
+ else
+ {
+ throw new ArgumentException("Either HostName or Uri must be
provided in the test configuration");
+ }
+
+ return host;
+ }
+
+ [SkippableFact]
+ public async Task ExchangeToken_WithValidToken_ReturnsNewToken()
+ {
+ Skip.IfNot(!string.IsNullOrEmpty(TestConfiguration.AccessToken),
"OAuth access token not configured");
+
+ string host = GetHost();
+ var tokenExchangeClient = new TokenExchangeClient(_httpClient,
host);
+
+ var response = await
tokenExchangeClient.ExchangeTokenAsync(TestConfiguration.AccessToken,
CancellationToken.None);
+
+ Assert.NotNull(response);
+ Assert.NotEmpty(response.AccessToken);
+ Assert.NotEqual(TestConfiguration.AccessToken,
response.AccessToken);
+ Assert.Equal("Bearer", response.TokenType);
+ Assert.True(response.ExpiresIn > 0);
+ Assert.True(response.ExpiryTime > DateTime.UtcNow);
+ }
+
+ [SkippableFact]
+ public async Task TokenExchangeHandler_WithValidToken_RefreshesToken()
+ {
+ Skip.IfNot(!string.IsNullOrEmpty(TestConfiguration.AccessToken),
"OAuth access token not configured");
+
+ bool isValidJwt =
JwtTokenDecoder.TryGetExpirationTime(TestConfiguration.AccessToken, out
DateTime expiryTime);
+ Skip.IfNot(isValidJwt, "Access token is not a valid JWT token with
expiration claim");
+
+ // Create a token that's about to expire (by setting expiry time
to near future)
+ DateTime nearFutureExpiry = DateTime.UtcNow.AddMinutes(5);
+
+ string host = GetHost();
+ var tokenExchangeClient = new TokenExchangeClient(_httpClient,
host);
+
+ var handler = new TokenExchangeDelegatingHandler(
+ new HttpClientHandler(),
+ tokenExchangeClient,
+ TestConfiguration.AccessToken,
+ nearFutureExpiry,
+ 10);
+
+ var httpClient = new HttpClient(handler);
+
+ var request = new HttpRequestMessage(HttpMethod.Get,
$"https://{host}/api/2.0/sql/config/warehouses");
+ var response = await httpClient.SendAsync(request,
CancellationToken.None);
+
+ // The request should succeed with the refreshed token
+ response.EnsureSuccessStatusCode();
+
+ string content = await response.Content.ReadAsStringAsync();
+ Assert.Contains("sql_configuration_parameters", content);
+ }
+
+ [SkippableFact]
+ public async Task
TokenExchangeHandler_WithValidTokenNotNearExpiry_UsesOriginalToken()
+ {
+ Skip.IfNot(!string.IsNullOrEmpty(TestConfiguration.AccessToken),
"OAuth access token not configured");
+
+ bool isValidJwt =
JwtTokenDecoder.TryGetExpirationTime(TestConfiguration.AccessToken, out
DateTime expiryTime);
+ Skip.IfNot(isValidJwt, "Access token is not a valid JWT token with
expiration claim");
+ Skip.If(DateTime.UtcNow.AddMinutes(20) >= expiryTime, "Access
token is too close to expiration for this test");
+
+ string host = GetHost();
+ var tokenExchangeClient = new TokenExchangeClient(_httpClient,
host);
+
+ // Create a handler that should not refresh the token (token not
near expiry)
+ var handler = new TokenExchangeDelegatingHandler(
+ new HttpClientHandler(),
+ tokenExchangeClient,
+ TestConfiguration.AccessToken,
+ expiryTime,
+ 10);
+
+ var httpClient = new HttpClient(handler);
+
+ var request = new HttpRequestMessage(HttpMethod.Get,
$"https://{host}/api/2.0/sql/config/warehouses");
+ var response = await httpClient.SendAsync(request,
CancellationToken.None);
+
+ // The request should succeed with the original token
+ response.EnsureSuccessStatusCode();
+
+ string content = await response.Content.ReadAsStringAsync();
+ Assert.Contains("sql_configuration_parameters", content);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _httpClient?.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/csharp/test/Drivers/Databricks/E2E/DatabricksTestConfiguration.cs
b/csharp/test/Drivers/Databricks/E2E/DatabricksTestConfiguration.cs
index e4554b113..2b17c6498 100644
--- a/csharp/test/Drivers/Databricks/E2E/DatabricksTestConfiguration.cs
+++ b/csharp/test/Drivers/Databricks/E2E/DatabricksTestConfiguration.cs
@@ -46,6 +46,9 @@ namespace Apache.Arrow.Adbc.Tests.Drivers.Databricks
[JsonPropertyName("traceStateEnabled"), JsonIgnore(Condition =
JsonIgnoreCondition.WhenWritingDefault)]
public string TraceStateEnabled { get; set; } = string.Empty;
+ [JsonPropertyName("tokenRenewLimit"), JsonIgnore(Condition =
JsonIgnoreCondition.WhenWritingDefault)]
+ public string TokenRenewLimit { get; set; } = string.Empty;
+
[JsonPropertyName("isCITesting"), JsonIgnore(Condition =
JsonIgnoreCondition.WhenWritingDefault)]
public bool IsCITesting { get; set; } = false;
}
diff --git a/csharp/test/Drivers/Databricks/Unit/Auth/JwtTokenDecoderTests.cs
b/csharp/test/Drivers/Databricks/Unit/Auth/JwtTokenDecoderTests.cs
new file mode 100644
index 000000000..bd630ce49
--- /dev/null
+++ b/csharp/test/Drivers/Databricks/Unit/Auth/JwtTokenDecoderTests.cs
@@ -0,0 +1,124 @@
+/*
+* 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.
+*/
+
+using System;
+using System.Text;
+using System.Text.Json;
+using Apache.Arrow.Adbc.Drivers.Databricks.Auth;
+using Xunit;
+
+namespace Apache.Arrow.Adbc.Tests.Drivers.Databricks.Unit.Auth
+{
+ public class JwtTokenDecoderTests
+ {
+ [Fact]
+ public void TryGetExpirationTime_ValidToken_ReturnsTrue()
+ {
+ string token = CreateTestToken(DateTime.UtcNow.AddMinutes(30));
+
+ bool result = JwtTokenDecoder.TryGetExpirationTime(token, out
DateTime expiryTime);
+
+ Assert.True(result);
+ Assert.True(expiryTime > DateTime.UtcNow);
+ Assert.True(expiryTime < DateTime.UtcNow.AddMinutes(31));
+ }
+
+ [Fact]
+ public void TryGetExpirationTime_ExpiredToken_ReturnsTrue()
+ {
+ string token = CreateTestToken(DateTime.UtcNow.AddMinutes(-30));
+
+ bool result = JwtTokenDecoder.TryGetExpirationTime(token, out
DateTime expiryTime);
+
+ Assert.True(result);
+ Assert.True(expiryTime < DateTime.UtcNow);
+ Assert.True(expiryTime > DateTime.UtcNow.AddMinutes(-31));
+ }
+
+ [Fact]
+ public void TryGetExpirationTime_InvalidToken_ReturnsFalse()
+ {
+ string token = "invalid.token.format";
+
+ bool result = JwtTokenDecoder.TryGetExpirationTime(token, out
DateTime expiryTime);
+
+ Assert.False(result);
+ Assert.Equal(DateTime.MinValue, expiryTime);
+ }
+
+ [Fact]
+ public void TryGetExpirationTime_MissingExpClaim_ReturnsFalse()
+ {
+ string token = CreateTestTokenWithoutExpClaim();
+
+ bool result = JwtTokenDecoder.TryGetExpirationTime(token, out
DateTime expiryTime);
+
+ Assert.False(result);
+ Assert.Equal(DateTime.MinValue, expiryTime);
+ }
+
+ private string CreateTestToken(DateTime expiryTime)
+ {
+ // Create a simple JWT token with expiration claim
+ var header = new { alg = "HS256", typ = "JWT" };
+ var payload = new { exp =
((DateTimeOffset)expiryTime).ToUnixTimeSeconds() };
+
+ string headerJson = JsonSerializer.Serialize(header);
+ string payloadJson = JsonSerializer.Serialize(payload);
+
+ string headerBase64 =
Convert.ToBase64String(Encoding.UTF8.GetBytes(headerJson))
+ .Replace('+', '-')
+ .Replace('/', '_')
+ .TrimEnd('=');
+
+ string payloadBase64 =
Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson))
+ .Replace('+', '-')
+ .Replace('/', '_')
+ .TrimEnd('=');
+
+ // For testing purposes, we don't need a valid signature
+ string signature = "signature";
+
+ return $"{headerBase64}.{payloadBase64}.{signature}";
+ }
+
+ private string CreateTestTokenWithoutExpClaim()
+ {
+ // Create a simple JWT token without expiration claim
+ var header = new { alg = "HS256", typ = "JWT" };
+ var payload = new { sub = "test" };
+
+ string headerJson = JsonSerializer.Serialize(header);
+ string payloadJson = JsonSerializer.Serialize(payload);
+
+ string headerBase64 =
Convert.ToBase64String(Encoding.UTF8.GetBytes(headerJson))
+ .Replace('+', '-')
+ .Replace('/', '_')
+ .TrimEnd('=');
+
+ string payloadBase64 =
Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson))
+ .Replace('+', '-')
+ .Replace('/', '_')
+ .TrimEnd('=');
+
+ // For testing purposes, we don't need a valid signature
+ string signature = "signature";
+
+ return $"{headerBase64}.{payloadBase64}.{signature}";
+ }
+ }
+}
diff --git
a/csharp/test/Drivers/Databricks/Unit/Auth/TokenExchangeClientTests.cs
b/csharp/test/Drivers/Databricks/Unit/Auth/TokenExchangeClientTests.cs
new file mode 100644
index 000000000..59d99bead
--- /dev/null
+++ b/csharp/test/Drivers/Databricks/Unit/Auth/TokenExchangeClientTests.cs
@@ -0,0 +1,411 @@
+/*
+* 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.
+*/
+
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Apache.Arrow.Adbc.Drivers.Databricks.Auth;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace Apache.Arrow.Adbc.Drivers.Databricks.Tests.Auth
+{
+ public class TokenExchangeClientTests : IDisposable
+ {
+ private readonly Mock<HttpMessageHandler> _mockHttpMessageHandler;
+ private readonly HttpClient _httpClient;
+ private readonly string _testHost = "test.databricks.com";
+
+ public TokenExchangeClientTests()
+ {
+ _mockHttpMessageHandler = new Mock<HttpMessageHandler>();
+ _httpClient = new HttpClient(_mockHttpMessageHandler.Object);
+ }
+
+ [Fact]
+ public void Constructor_WithValidParameters_SetsEndpointCorrectly()
+ {
+ var client = new TokenExchangeClient(_httpClient, _testHost);
+ Assert.NotNull(client);
+ }
+
+ [Fact]
+ public void Constructor_WithEmptyHost_ThrowsArgumentNullException()
+ {
+ Assert.Throws<ArgumentNullException>(() => new
TokenExchangeClient(_httpClient, string.Empty));
+ }
+
+ [Fact]
+ public async Task
ExchangeTokenAsync_WithValidResponse_ReturnsTokenExchangeResponse()
+ {
+ var testToken = "test-jwt-token";
+ var expectedAccessToken = "new-access-token";
+ var expectedTokenType = "Bearer";
+ var expectedExpiresIn = 3600;
+
+ var responseJson = JsonSerializer.Serialize(new
+ {
+ access_token = expectedAccessToken,
+ token_type = expectedTokenType,
+ expires_in = expectedExpiresIn
+ });
+
+ var httpResponseMessage = new
HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseJson)
+ };
+
+ _mockHttpMessageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.Is<HttpRequestMessage>(req =>
+ req.Method == HttpMethod.Post &&
+ req.RequestUri != null &&
+ req.RequestUri.ToString() ==
$"https://{_testHost}/oidc/v1/token"),
+ ItExpr.IsAny<CancellationToken>())
+ .ReturnsAsync(httpResponseMessage);
+
+ var client = new TokenExchangeClient(_httpClient, _testHost);
+
+ var result = await client.ExchangeTokenAsync(testToken,
CancellationToken.None);
+
+ Assert.NotNull(result);
+ Assert.Equal(expectedAccessToken, result.AccessToken);
+ Assert.Equal(expectedTokenType, result.TokenType);
+ Assert.Equal(expectedExpiresIn, result.ExpiresIn);
+ Assert.True(result.ExpiryTime > DateTime.UtcNow);
+ Assert.True(result.ExpiryTime <=
DateTime.UtcNow.AddSeconds(expectedExpiresIn + 1));
+ }
+
+ [Fact]
+ public async Task ExchangeTokenAsync_SendsCorrectRequestFormat()
+ {
+ var testToken = "test-jwt-token";
+ var responseJson = JsonSerializer.Serialize(new
+ {
+ access_token = "token",
+ token_type = "Bearer",
+ expires_in = 3600
+ });
+
+ var httpResponseMessage = new
HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseJson)
+ };
+
+ HttpRequestMessage? capturedRequest = null;
+
+ _mockHttpMessageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .Callback<HttpRequestMessage, CancellationToken>((req, ct) =>
capturedRequest = req)
+ .ReturnsAsync(httpResponseMessage);
+
+ var client = new TokenExchangeClient(_httpClient, _testHost);
+
+ await client.ExchangeTokenAsync(testToken, CancellationToken.None);
+
+ Assert.NotNull(capturedRequest);
+ Assert.Equal(HttpMethod.Post, capturedRequest.Method);
+ Assert.Equal($"https://{_testHost}/oidc/v1/token",
capturedRequest?.RequestUri?.ToString());
+ Assert.True(capturedRequest?.Headers.Accept.Contains(new
System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("*/*")));
+
+ var content = capturedRequest?.Content as FormUrlEncodedContent;
+ Assert.NotNull(content);
+
+ var formContent = await content.ReadAsStringAsync();
+
Assert.Contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer",
formContent);
+ Assert.Contains($"assertion={testToken}", formContent);
+ }
+
+ [Fact]
+ public async Task
ExchangeTokenAsync_WithHttpError_ThrowsHttpRequestException()
+ {
+ var testToken = "test-jwt-token";
+ var httpResponseMessage = new
HttpResponseMessage(HttpStatusCode.Unauthorized)
+ {
+ Content = new StringContent("Unauthorized")
+ };
+
+ _mockHttpMessageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .ReturnsAsync(httpResponseMessage);
+
+ var client = new TokenExchangeClient(_httpClient, _testHost);
+
+ await Assert.ThrowsAsync<HttpRequestException>(() =>
+ client.ExchangeTokenAsync(testToken, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task
ExchangeTokenAsync_WithMissingAccessToken_ThrowsDatabricksException()
+ {
+ var testToken = "test-jwt-token";
+ var responseJson = JsonSerializer.Serialize(new
+ {
+ token_type = "Bearer",
+ expires_in = 3600
+ });
+
+ var httpResponseMessage = new
HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseJson)
+ };
+
+ _mockHttpMessageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .ReturnsAsync(httpResponseMessage);
+
+ var client = new TokenExchangeClient(_httpClient, _testHost);
+
+ var exception = await Assert.ThrowsAsync<DatabricksException>(() =>
+ client.ExchangeTokenAsync(testToken, CancellationToken.None));
+
+ Assert.Contains("access_token", exception.Message);
+ }
+
+ [Fact]
+ public async Task
ExchangeTokenAsync_WithEmptyAccessToken_ThrowsDatabricksException()
+ {
+ var testToken = "test-jwt-token";
+ var responseJson = JsonSerializer.Serialize(new
+ {
+ access_token = "",
+ token_type = "Bearer",
+ expires_in = 3600
+ });
+
+ var httpResponseMessage = new
HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseJson)
+ };
+
+ _mockHttpMessageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .ReturnsAsync(httpResponseMessage);
+
+ var client = new TokenExchangeClient(_httpClient, _testHost);
+
+ var exception = await Assert.ThrowsAsync<DatabricksException>(() =>
+ client.ExchangeTokenAsync(testToken, CancellationToken.None));
+
+ Assert.Contains("access_token was null or empty",
exception.Message);
+ }
+
+ [Fact]
+ public async Task
ExchangeTokenAsync_WithMissingTokenType_ThrowsDatabricksException()
+ {
+ var testToken = "test-jwt-token";
+ var responseJson = JsonSerializer.Serialize(new
+ {
+ access_token = "token",
+ expires_in = 3600
+ });
+
+ var httpResponseMessage = new
HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseJson)
+ };
+
+ _mockHttpMessageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .ReturnsAsync(httpResponseMessage);
+
+ var client = new TokenExchangeClient(_httpClient, _testHost);
+
+ var exception = await Assert.ThrowsAsync<DatabricksException>(() =>
+ client.ExchangeTokenAsync(testToken, CancellationToken.None));
+
+ Assert.Contains("token_type", exception.Message);
+ }
+
+ [Fact]
+ public async Task
ExchangeTokenAsync_WithMissingExpiresIn_ThrowsDatabricksException()
+ {
+ var testToken = "test-jwt-token";
+ var responseJson = JsonSerializer.Serialize(new
+ {
+ access_token = "token",
+ token_type = "Bearer"
+ });
+
+ var httpResponseMessage = new
HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseJson)
+ };
+
+ _mockHttpMessageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .ReturnsAsync(httpResponseMessage);
+
+ var client = new TokenExchangeClient(_httpClient, _testHost);
+
+ var exception = await Assert.ThrowsAsync<DatabricksException>(() =>
+ client.ExchangeTokenAsync(testToken, CancellationToken.None));
+
+ Assert.Contains("expires_in", exception.Message);
+ }
+
+ [Fact]
+ public async Task
ExchangeTokenAsync_WithNegativeExpiresIn_ThrowsDatabricksException()
+ {
+ var testToken = "test-jwt-token";
+ var responseJson = JsonSerializer.Serialize(new
+ {
+ access_token = "token",
+ token_type = "Bearer",
+ expires_in = -1
+ });
+
+ var httpResponseMessage = new
HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseJson)
+ };
+
+ _mockHttpMessageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .ReturnsAsync(httpResponseMessage);
+
+ var client = new TokenExchangeClient(_httpClient, _testHost);
+
+ var exception = await Assert.ThrowsAsync<DatabricksException>(() =>
+ client.ExchangeTokenAsync(testToken, CancellationToken.None));
+
+ Assert.Contains("expires_in value must be positive",
exception.Message);
+ }
+
+ [Fact]
+ public async Task
ExchangeTokenAsync_WithInvalidJson_ThrowsJsonException()
+ {
+ var testToken = "test-jwt-token";
+ var invalidJson = "{ invalid json }";
+
+ var httpResponseMessage = new
HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(invalidJson)
+ };
+
+ _mockHttpMessageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .ReturnsAsync(httpResponseMessage);
+
+ var client = new TokenExchangeClient(_httpClient, _testHost);
+
+ await Assert.ThrowsAnyAsync<JsonException>(() =>
+ client.ExchangeTokenAsync(testToken, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task
ExchangeTokenAsync_WithCancellationToken_PropagatesCancellation()
+ {
+ var testToken = "test-jwt-token";
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ _mockHttpMessageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .ThrowsAsync(new TaskCanceledException());
+
+ var client = new TokenExchangeClient(_httpClient, _testHost);
+
+ await Assert.ThrowsAsync<TaskCanceledException>(() =>
+ client.ExchangeTokenAsync(testToken, cts.Token));
+ }
+
+ [Fact]
+ public async Task ExchangeTokenAsync_CalculatesExpiryTimeCorrectly()
+ {
+ var testToken = "test-jwt-token";
+ var expiresIn = 1800; // 30 minutes
+ var beforeCall = DateTime.UtcNow;
+
+ var responseJson = JsonSerializer.Serialize(new
+ {
+ access_token = "token",
+ token_type = "Bearer",
+ expires_in = expiresIn
+ });
+
+ var httpResponseMessage = new
HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(responseJson)
+ };
+
+ _mockHttpMessageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .ReturnsAsync(httpResponseMessage);
+
+ var client = new TokenExchangeClient(_httpClient, _testHost);
+
+ var result = await client.ExchangeTokenAsync(testToken,
CancellationToken.None);
+ var afterCall = DateTime.UtcNow;
+
+ var expectedMinExpiry = beforeCall.AddSeconds(expiresIn);
+ var expectedMaxExpiry = afterCall.AddSeconds(expiresIn);
+
+ Assert.True(result.ExpiryTime >= expectedMinExpiry);
+ Assert.True(result.ExpiryTime <= expectedMaxExpiry);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _httpClient?.Dispose();
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+ }
+}
diff --git
a/csharp/test/Drivers/Databricks/Unit/Auth/TokenExchangeDelegatingHandlerTests.cs
b/csharp/test/Drivers/Databricks/Unit/Auth/TokenExchangeDelegatingHandlerTests.cs
new file mode 100644
index 000000000..1abf6dd7d
--- /dev/null
+++
b/csharp/test/Drivers/Databricks/Unit/Auth/TokenExchangeDelegatingHandlerTests.cs
@@ -0,0 +1,447 @@
+/*
+* 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.
+*/
+
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Apache.Arrow.Adbc.Drivers.Databricks.Auth;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace Apache.Arrow.Adbc.Drivers.Databricks.Tests.Auth
+{
+ public class TokenExchangeDelegatingHandlerTests : IDisposable
+ {
+ private readonly Mock<HttpMessageHandler> _mockInnerHandler;
+ private readonly Mock<ITokenExchangeClient> _mockTokenExchangeClient;
+ private readonly string _initialToken = "initial-token";
+ private readonly int _tokenRenewLimitMinutes = 10;
+ private readonly DateTime _initialTokenExpiry =
DateTime.UtcNow.AddHours(1);
+
+ public TokenExchangeDelegatingHandlerTests()
+ {
+ _mockInnerHandler = new Mock<HttpMessageHandler>();
+ _mockTokenExchangeClient = new Mock<ITokenExchangeClient>();
+ }
+
+ [Fact]
+ public void Constructor_WithValidParameters_InitializesCorrectly()
+ {
+ var handler = new TokenExchangeDelegatingHandler(
+ _mockInnerHandler.Object,
+ _mockTokenExchangeClient.Object,
+ _initialToken,
+ _initialTokenExpiry,
+ _tokenRenewLimitMinutes);
+
+ Assert.NotNull(handler);
+ }
+
+ [Fact]
+ public void
Constructor_WithNullTokenExchangeClient_ThrowsArgumentNullException()
+ {
+ Assert.Throws<ArgumentNullException>(() => new
TokenExchangeDelegatingHandler(
+ _mockInnerHandler.Object,
+ null!,
+ _initialToken,
+ _initialTokenExpiry,
+ _tokenRenewLimitMinutes));
+ }
+
+ [Fact]
+ public void
Constructor_WithNullInitialToken_ThrowsArgumentNullException()
+ {
+ Assert.Throws<ArgumentNullException>(() => new
TokenExchangeDelegatingHandler(
+ _mockInnerHandler.Object,
+ _mockTokenExchangeClient.Object,
+ null!,
+ _initialTokenExpiry,
+ _tokenRenewLimitMinutes));
+ }
+
+ [Fact]
+ public async Task
SendAsync_WithValidTokenNotNearExpiry_UsesInitialTokenWithoutRenewal()
+ {
+ var futureExpiry = DateTime.UtcNow.AddHours(2); // Well beyond
renewal limit
+ var handler = new TokenExchangeDelegatingHandler(
+ _mockInnerHandler.Object,
+ _mockTokenExchangeClient.Object,
+ _initialToken,
+ futureExpiry,
+ _tokenRenewLimitMinutes);
+
+ var request = new HttpRequestMessage(HttpMethod.Get,
"https://example.com");
+ var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK);
+
+ HttpRequestMessage? capturedRequest = null;
+
+ _mockInnerHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .Callback<HttpRequestMessage, CancellationToken>((req, ct) =>
capturedRequest = req)
+ .ReturnsAsync(expectedResponse);
+
+ var httpClient = new HttpClient(handler);
+ var response = await httpClient.SendAsync(request);
+
+ Assert.Equal(expectedResponse, response);
+ Assert.NotNull(capturedRequest);
+ Assert.Equal("Bearer",
capturedRequest.Headers.Authorization?.Scheme);
+ Assert.Equal(_initialToken,
capturedRequest.Headers.Authorization?.Parameter);
+
+ _mockTokenExchangeClient.Verify(
+ x => x.ExchangeTokenAsync(It.IsAny<string>(),
It.IsAny<CancellationToken>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task
SendAsync_WithTokenNearExpiry_RenewsTokenBeforeRequest()
+ {
+ // Arrange
+ var nearExpiryTime = DateTime.UtcNow.AddMinutes(5); // Within
renewal limit
+ var newToken = "new-renewed-token";
+ var newExpiry = DateTime.UtcNow.AddHours(1);
+
+ var handler = new TokenExchangeDelegatingHandler(
+ _mockInnerHandler.Object,
+ _mockTokenExchangeClient.Object,
+ _initialToken,
+ nearExpiryTime,
+ _tokenRenewLimitMinutes);
+
+ var request = new HttpRequestMessage(HttpMethod.Get,
"https://example.com");
+ var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK);
+
+ var tokenExchangeResponse = new TokenExchangeResponse
+ {
+ AccessToken = newToken,
+ TokenType = "Bearer",
+ ExpiresIn = 3600,
+ ExpiryTime = newExpiry
+ };
+
+ _mockTokenExchangeClient
+ .Setup(x => x.ExchangeTokenAsync(_initialToken,
It.IsAny<CancellationToken>()))
+ .ReturnsAsync(tokenExchangeResponse);
+
+ HttpRequestMessage? capturedRequest = null;
+
+ _mockInnerHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .Callback<HttpRequestMessage, CancellationToken>((req, ct) =>
capturedRequest = req)
+ .ReturnsAsync(expectedResponse);
+
+ var httpClient = new HttpClient(handler);
+ var response = await httpClient.SendAsync(request);
+
+ Assert.Equal(expectedResponse, response);
+ Assert.NotNull(capturedRequest);
+ Assert.Equal("Bearer",
capturedRequest.Headers.Authorization?.Scheme);
+ Assert.Equal(newToken,
capturedRequest.Headers.Authorization?.Parameter);
+
+ _mockTokenExchangeClient.Verify(
+ x => x.ExchangeTokenAsync(_initialToken,
It.IsAny<CancellationToken>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task
SendAsync_WithTokenExchangeFailure_ContinuesWithOriginalToken()
+ {
+ var nearExpiryTime = DateTime.UtcNow.AddMinutes(5); // Within
renewal limit
+
+ var handler = new TokenExchangeDelegatingHandler(
+ _mockInnerHandler.Object,
+ _mockTokenExchangeClient.Object,
+ _initialToken,
+ nearExpiryTime,
+ _tokenRenewLimitMinutes);
+
+ var request = new HttpRequestMessage(HttpMethod.Get,
"https://example.com");
+ var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK);
+
+ // Setup token exchange to fail
+ _mockTokenExchangeClient
+ .Setup(x => x.ExchangeTokenAsync(_initialToken,
It.IsAny<CancellationToken>()))
+ .ThrowsAsync(new Exception("Token exchange failed"));
+
+ HttpRequestMessage? capturedRequest = null;
+
+ _mockInnerHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .Callback<HttpRequestMessage, CancellationToken>((req, ct) =>
capturedRequest = req)
+ .ReturnsAsync(expectedResponse);
+
+ var httpClient = new HttpClient(handler);
+ var response = await httpClient.SendAsync(request);
+
+ Assert.Equal(expectedResponse, response);
+ Assert.NotNull(capturedRequest);
+ Assert.Equal("Bearer",
capturedRequest.Headers.Authorization?.Scheme);
+ Assert.Equal(_initialToken,
capturedRequest.Headers.Authorization?.Parameter); // Should still use original
token
+
+ // Verify token exchange was attempted
+ _mockTokenExchangeClient.Verify(
+ x => x.ExchangeTokenAsync(_initialToken,
It.IsAny<CancellationToken>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task SendAsync_WithRenewedToken_DoesNotRenewAgain()
+ {
+ var nearExpiryTime = DateTime.UtcNow.AddMinutes(5); // Within
renewal limit
+ var newToken = "new-renewed-token";
+ var newExpiry = DateTime.UtcNow.AddMinutes(3); // New token also
near expiry
+
+ var handler = new TokenExchangeDelegatingHandler(
+ _mockInnerHandler.Object,
+ _mockTokenExchangeClient.Object,
+ _initialToken,
+ nearExpiryTime,
+ _tokenRenewLimitMinutes);
+
+ var tokenExchangeResponse = new TokenExchangeResponse
+ {
+ AccessToken = newToken,
+ TokenType = "Bearer",
+ ExpiresIn = 180,
+ ExpiryTime = newExpiry
+ };
+
+ _mockTokenExchangeClient
+ .Setup(x => x.ExchangeTokenAsync(_initialToken,
It.IsAny<CancellationToken>()))
+ .ReturnsAsync(tokenExchangeResponse);
+
+ _mockInnerHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
+
+ var httpClient = new HttpClient(handler);
+
+ // Make two requests
+ await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get,
"https://example.com/1"));
+ await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get,
"https://example.com/2"));
+
+ // Token exchange should only be called once (renewed tokens
cannot be renewed again)
+ _mockTokenExchangeClient.Verify(
+ x => x.ExchangeTokenAsync(_initialToken,
It.IsAny<CancellationToken>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task
SendAsync_WithConcurrentRequests_OnlyRenewsTokenOnce()
+ {
+ var nearExpiryTime = DateTime.UtcNow.AddMinutes(5); // Within
renewal limit
+ var newToken = "new-renewed-token";
+ var newExpiry = DateTime.UtcNow.AddHours(1);
+
+ var handler = new TokenExchangeDelegatingHandler(
+ _mockInnerHandler.Object,
+ _mockTokenExchangeClient.Object,
+ _initialToken,
+ nearExpiryTime,
+ _tokenRenewLimitMinutes);
+
+ var tokenExchangeResponse = new TokenExchangeResponse
+ {
+ AccessToken = newToken,
+ TokenType = "Bearer",
+ ExpiresIn = 3600,
+ ExpiryTime = newExpiry
+ };
+
+ // Add a small delay to token exchange to simulate concurrent
access
+ _mockTokenExchangeClient
+ .Setup(x => x.ExchangeTokenAsync(_initialToken,
It.IsAny<CancellationToken>()))
+ .Returns(async () =>
+ {
+ await Task.Delay(100);
+ return tokenExchangeResponse;
+ });
+
+ _mockInnerHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
+
+ var httpClient = new HttpClient(handler);
+
+ // Make concurrent requests
+ var tasks = new[]
+ {
+ httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get,
"https://example.com/1")),
+ httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get,
"https://example.com/2")),
+ httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get,
"https://example.com/3"))
+ };
+
+ await Task.WhenAll(tasks);
+
+ // Token exchange should only be called once despite concurrent
requests
+ _mockTokenExchangeClient.Verify(
+ x => x.ExchangeTokenAsync(_initialToken,
It.IsAny<CancellationToken>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task
SendAsync_WithCancellationToken_PropagatesCancellation()
+ {
+ var handler = new TokenExchangeDelegatingHandler(
+ _mockInnerHandler.Object,
+ _mockTokenExchangeClient.Object,
+ _initialToken,
+ _initialTokenExpiry,
+ _tokenRenewLimitMinutes);
+
+ var request = new HttpRequestMessage(HttpMethod.Get,
"https://example.com");
+ var cts = new CancellationTokenSource();
+
+ _mockInnerHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .Returns<HttpRequestMessage, CancellationToken>((req, ct) =>
+ {
+ ct.ThrowIfCancellationRequested();
+ return Task.FromResult(new
HttpResponseMessage(HttpStatusCode.OK));
+ });
+
+ cts.Cancel();
+ var httpClient = new HttpClient(handler);
+ await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
+ httpClient.SendAsync(request, cts.Token));
+ }
+
+ [Fact]
+ public async Task
SendAsync_WithTokenRenewalAndCancellation_PropagatesCancellation()
+ {
+ var nearExpiryTime = DateTime.UtcNow.AddMinutes(5); // Within
renewal limit
+ var handler = new TokenExchangeDelegatingHandler(
+ _mockInnerHandler.Object,
+ _mockTokenExchangeClient.Object,
+ _initialToken,
+ nearExpiryTime,
+ _tokenRenewLimitMinutes);
+
+ var request = new HttpRequestMessage(HttpMethod.Get,
"https://example.com");
+ var cts = new CancellationTokenSource();
+
+ _mockTokenExchangeClient
+ .Setup(x => x.ExchangeTokenAsync(_initialToken,
It.IsAny<CancellationToken>()))
+ .Returns<string, CancellationToken>((token, ct) =>
+ {
+ ct.ThrowIfCancellationRequested();
+ return Task.FromResult(new TokenExchangeResponse
+ {
+ AccessToken = "new-token",
+ TokenType = "Bearer",
+ ExpiresIn = 3600,
+ ExpiryTime = DateTime.UtcNow.AddHours(1)
+ });
+ });
+
+ cts.Cancel();
+ var httpClient = new HttpClient(handler);
+ await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
+ httpClient.SendAsync(request, cts.Token));
+ }
+
+ [Fact]
+ public void Dispose_ReleasesResources()
+ {
+ var handler = new TokenExchangeDelegatingHandler(
+ _mockInnerHandler.Object,
+ _mockTokenExchangeClient.Object,
+ _initialToken,
+ _initialTokenExpiry,
+ _tokenRenewLimitMinutes);
+
+ handler.Dispose();
+ handler.Dispose(); // Should be safe to call multiple times
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(5)]
+ [InlineData(15)]
+ public async Task
SendAsync_WithDifferentRenewalLimits_RenewsTokenAppropriately(int
renewalLimitMinutes)
+ {
+ var tokenExpiryTime =
DateTime.UtcNow.AddMinutes(renewalLimitMinutes / 2); // Half the renewal limit
+ var handler = new TokenExchangeDelegatingHandler(
+ _mockInnerHandler.Object,
+ _mockTokenExchangeClient.Object,
+ _initialToken,
+ tokenExpiryTime,
+ renewalLimitMinutes);
+
+ var request = new HttpRequestMessage(HttpMethod.Get,
"https://example.com");
+
+ _mockTokenExchangeClient
+ .Setup(x => x.ExchangeTokenAsync(_initialToken,
It.IsAny<CancellationToken>()))
+ .ReturnsAsync(new TokenExchangeResponse
+ {
+ AccessToken = "new-token",
+ TokenType = "Bearer",
+ ExpiresIn = 3600,
+ ExpiryTime = DateTime.UtcNow.AddHours(1)
+ });
+
+ _mockInnerHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>(
+ "SendAsync",
+ ItExpr.IsAny<HttpRequestMessage>(),
+ ItExpr.IsAny<CancellationToken>())
+ .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
+
+ var httpClient = new HttpClient(handler);
+ await httpClient.SendAsync(request);
+
+ _mockTokenExchangeClient.Verify(
+ x => x.ExchangeTokenAsync(_initialToken,
It.IsAny<CancellationToken>()),
+ Times.Once);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _mockInnerHandler?.Object?.Dispose();
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+ }
+}