@@ -28,128 +28,204 @@ namespace Apache.Arrow.Adbc.Drivers.Databricks.Auth
2828 /// <summary>
2929 /// Service for obtaining OAuth access tokens using the client credentials grant type.
3030 /// </summary>
31- internal class OAuthClientCredentialsService
31+ internal class OAuthClientCredentialsService : IDisposable
3232 {
3333 private readonly Lazy < HttpClient > _httpClient ;
3434 private readonly string _clientId ;
3535 private readonly string _clientSecret ;
3636 private readonly Uri _baseUri ;
37- private readonly string ? _tenantId ;
38- private readonly string _scope ;
3937 private readonly string _tokenEndpoint ;
4038 private readonly int _timeoutMinutes ;
39+ private readonly SemaphoreSlim _tokenLock = new SemaphoreSlim ( 1 , 1 ) ;
40+ private TokenInfo ? _cachedToken ;
41+
42+ private class TokenInfo
43+ {
44+ public string ? AccessToken { get ; set ; }
45+ public DateTime ExpiresAt { get ; set ; }
46+
47+ public bool IsExpired => DateTime . UtcNow >= ExpiresAt ;
48+
49+ // Add buffer time to refresh token before actual expiration
50+ public bool NeedsRefresh => DateTime . UtcNow >= ExpiresAt . AddMinutes ( - 5 ) ;
51+ }
4152
4253 /// <summary>
4354 /// Initializes a new instance of the <see cref="OAuthClientCredentialsService"/> class.
4455 /// </summary>
4556 /// <param name="clientId">The OAuth client ID.</param>
4657 /// <param name="clientSecret">The OAuth client secret.</param>
4758 /// <param name="baseUri">The base URI of the Databricks workspace.</param>
48- /// <param name="tenantId">The Azure AD tenant ID. Required for Azure Databricks.</param>
49- /// <param name="scope">The OAuth scope to request. Default is "all-apis".</param>
50- /// <param name="timeoutMinutes">The timeout in minutes for HTTP requests. Default is 5 minutes.</param>
5159 public OAuthClientCredentialsService (
5260 string clientId ,
5361 string clientSecret ,
5462 Uri baseUri ,
55- string ? tenantId = null ,
56- string scope = "all-apis" ,
57- int timeoutMinutes = 5 )
63+ int timeoutMinutes = 1 ,
64+ HttpClient ? httpClient = null )
5865 {
5966 _clientId = clientId ?? throw new ArgumentNullException ( nameof ( clientId ) ) ;
6067 _clientSecret = clientSecret ?? throw new ArgumentNullException ( nameof ( clientSecret ) ) ;
6168 _baseUri = baseUri ?? throw new ArgumentNullException ( nameof ( baseUri ) ) ;
62- _tenantId = tenantId ;
63- _scope = scope ?? "all-apis" ;
6469 _timeoutMinutes = timeoutMinutes ;
6570 _tokenEndpoint = DetermineTokenEndpoint ( ) ;
6671
67- _httpClient = new Lazy < HttpClient > ( ( ) =>
68- {
69- var client = new HttpClient ( ) ;
70- client . Timeout = TimeSpan . FromMinutes ( _timeoutMinutes ) ;
71- return client ;
72- } ) ;
72+ _httpClient = httpClient != null
73+ ? new Lazy < HttpClient > ( ( ) => httpClient )
74+ : new Lazy < HttpClient > ( ( ) =>
75+ {
76+ var client = new HttpClient ( ) ;
77+ client . Timeout = TimeSpan . FromMinutes ( _timeoutMinutes ) ;
78+ return client ;
79+ } ) ;
7380 }
7481
7582 private HttpClient HttpClient => _httpClient . Value ;
7683
7784 private string DetermineTokenEndpoint ( )
7885 {
79- string host = _baseUri . Host . ToLowerInvariant ( ) ;
80- if ( host . Contains ( "azuredatabricks.net" ) )
86+ // For workspace URLs, the token endpoint is always /oidc/v1/token
87+ // TODO: Might be different for Azure AAD SPs
88+ return $ "{ _baseUri . Scheme } ://{ _baseUri . Host } /oidc/v1/token";
89+ }
90+
91+ private string ? GetValidCachedToken ( )
92+ {
93+ return _cachedToken != null && ! _cachedToken . NeedsRefresh && _cachedToken . AccessToken != null
94+ ? _cachedToken . AccessToken
95+ : null ;
96+ }
97+
98+
99+ private async Task < string > RefreshTokenInternalAsync ( CancellationToken cancellationToken )
100+ {
101+ var request = CreateTokenRequest ( ) ;
102+
103+ HttpResponseMessage response ;
104+ try
81105 {
82- if ( string . IsNullOrEmpty ( _tenantId ) )
83- {
84- throw new ArgumentException ( "Azure Databricks requires a tenantId to determine the token endpoint." ) ;
85- }
106+ response = await HttpClient . SendAsync ( request , cancellationToken ) ;
107+ response . EnsureSuccessStatusCode ( ) ;
108+ }
109+ catch ( HttpRequestException ex )
110+ {
111+ throw new DatabricksException ( $ "Failed to acquire OAuth access token: { ex . Message } ", ex ) ;
112+ }
86113
87- return $ "https://login.microsoftonline.com/{ _tenantId } /oauth2/v2.0/token";
114+ string content = await response . Content . ReadAsStringAsync ( ) ;
115+
116+ try
117+ {
118+ _cachedToken = ParseTokenResponse ( content ) ;
119+ return _cachedToken . AccessToken ! ;
88120 }
89- else
121+ catch ( JsonException ex )
90122 {
91- // Applies to AWS and GCP (if using Databricks-hosted IdP)
92- return "https://accounts.cloud.databricks.com/oidc/token" ;
123+ throw new DatabricksException ( $ "Failed to parse OAuth response: { ex . Message } ", ex ) ;
93124 }
94125 }
95126
96- /// <summary>
97- /// Gets an OAuth access token using the client credentials grant type.
98- /// </summary>
99- /// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
100- /// <returns>The access token.</returns>
101- /// <exception cref="DatabricksException">Thrown when the token request fails or the response is invalid.</exception>
102- public async Task < string > GetAccessTokenAsync ( CancellationToken cancellationToken = default )
127+ private HttpRequestMessage CreateTokenRequest ( )
103128 {
104129 var requestContent = new FormUrlEncodedContent ( new [ ]
105130 {
106131 new KeyValuePair < string , string > ( "grant_type" , "client_credentials" ) ,
107- new KeyValuePair < string , string > ( "client_id" , _clientId ) ,
108- new KeyValuePair < string , string > ( "client_secret" , _clientSecret ) ,
109- new KeyValuePair < string , string > ( "scope" , _scope )
132+ new KeyValuePair < string , string > ( "scope" , "sql" )
110133 } ) ;
111134
112135 var request = new HttpRequestMessage ( HttpMethod . Post , _tokenEndpoint )
113136 {
114137 Content = requestContent
115138 } ;
116139
140+ // Use Basic Auth with client ID and secret
141+ var authHeader = Convert . ToBase64String (
142+ System . Text . Encoding . ASCII . GetBytes ( $ "{ _clientId } :{ _clientSecret } ") ) ;
143+ request . Headers . Authorization = new AuthenticationHeaderValue ( "Basic" , authHeader ) ;
117144 request . Headers . Accept . Add ( new MediaTypeWithQualityHeaderValue ( "application/json" ) ) ;
118145
119- HttpResponseMessage response ;
120- try
146+ return request ;
147+ }
148+
149+ private TokenInfo ParseTokenResponse ( string content )
150+ {
151+ using var jsonDoc = JsonDocument . Parse ( content ) ;
152+
153+ if ( ! jsonDoc . RootElement . TryGetProperty ( "access_token" , out var accessTokenElement ) )
121154 {
122- response = await HttpClient . SendAsync ( request , cancellationToken ) ;
123- response . EnsureSuccessStatusCode ( ) ;
155+ throw new DatabricksException ( "OAuth response did not contain an access_token" ) ;
124156 }
125- catch ( HttpRequestException ex )
157+
158+ string ? accessToken = accessTokenElement . GetString ( ) ;
159+ if ( string . IsNullOrEmpty ( accessToken ) )
126160 {
127- throw new DatabricksException ( $ "Failed to acquire OAuth access token: { ex . Message } " , ex ) ;
161+ throw new DatabricksException ( "OAuth access_token was null or empty" ) ;
128162 }
129163
130- string content = await response . Content . ReadAsStringAsync ( ) ;
131-
132- try
164+ // Get expiration time from response
165+ if ( ! jsonDoc . RootElement . TryGetProperty ( "expires_in" , out var expiresInElement ) )
133166 {
134- using var jsonDoc = JsonDocument . Parse ( content ) ;
135-
136- if ( ! jsonDoc . RootElement . TryGetProperty ( "access_token" , out var accessTokenElement ) )
137- {
138- throw new DatabricksException ( "OAuth response did not contain an access_token" ) ;
139- }
167+ throw new DatabricksException ( "OAuth response did not contain expires_in" ) ;
168+ }
169+
170+ int expiresIn = expiresInElement . GetInt32 ( ) ;
171+ if ( expiresIn <= 0 )
172+ {
173+ throw new DatabricksException ( "OAuth expires_in value must be positive" ) ;
174+ }
175+
176+ return new TokenInfo
177+ {
178+ AccessToken = accessToken ! ,
179+ ExpiresAt = DateTime . UtcNow . AddSeconds ( expiresIn )
180+ } ;
181+ }
182+
183+ /// <summary>
184+ /// Gets an OAuth access token using the client credentials grant type.
185+ /// </summary>
186+ /// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
187+ /// <returns>The access token.</returns>
188+ /// <exception cref="DatabricksException">Thrown when the token request fails or the response is invalid.</exception>
189+ public async Task < string > GetAccessTokenAsync ( CancellationToken cancellationToken = default )
190+ {
191+ // First try to get cached token without acquiring lock
192+ if ( GetValidCachedToken ( ) is string cachedToken )
193+ {
194+ return cachedToken ;
195+ }
196+
197+ // If token needs refresh, acquire lock with timeout
198+ var lockTimeout = TimeSpan . FromSeconds ( 30 ) ; // Reasonable timeout for lock acquisition
199+ if ( ! await _tokenLock . WaitAsync ( lockTimeout , cancellationToken ) . ConfigureAwait ( false ) )
200+ {
201+ throw new TimeoutException ( "Timeout waiting for token refresh lock" ) ;
202+ }
140203
141- string ? accessToken = accessTokenElement . GetString ( ) ;
142- if ( string . IsNullOrEmpty ( accessToken ) )
204+ try
205+ {
206+ // Double-check pattern in case another thread refreshed while we were waiting
207+ if ( GetValidCachedToken ( ) is string refreshedToken )
143208 {
144- throw new DatabricksException ( "OAuth access_token was null or empty" ) ;
209+ return refreshedToken ;
145210 }
146211
147- return accessToken ! ;
212+ return await RefreshTokenInternalAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
148213 }
149- catch ( JsonException ex )
214+ finally
150215 {
151- throw new DatabricksException ( $ "Failed to parse OAuth response: { ex . Message } " , ex ) ;
216+ _tokenLock . Release ( ) ;
152217 }
153218 }
219+
220+
221+ public void Dispose ( )
222+ {
223+ _tokenLock . Dispose ( ) ;
224+ if ( _httpClient . IsValueCreated )
225+ {
226+ HttpClient . Dispose ( ) ;
227+ }
228+ }
229+
154230 }
155231}
0 commit comments