blob: 0f28ce22407add5f9b7ec3acfc252b88b5a3c16a [file]
/*
* 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>
/// Gets the token exchange endpoint URL.
/// </summary>
string TokenExchangeEndpoint { get; }
/// <summary>
/// Refreshes the provided token to extend the lifetime.
/// </summary>
/// <param name="token">The token to refresh.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The response from the token exchange API.</returns>
Task<TokenExchangeResponse> RefreshTokenAsync(string token, CancellationToken cancellationToken);
/// <summary>
/// Exchanges the provided token for a Databricks OAuth token.
/// </summary>
/// <param name="token">The token to exchange.</param>
/// <param name="identityFederationClientId">Optional identity federation client ID.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The response from the token exchange API.</returns>
Task<TokenExchangeResponse> ExchangeTokenAsync(string token, string? identityFederationClientId, 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;
public string TokenExchangeEndpoint => _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>
/// Refreshes the provided token to extend the lifetime.
/// </summary>
/// <param name="token">The token to refresh.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The response from the token exchange API.</returns>
public async Task<TokenExchangeResponse> RefreshTokenAsync(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>
/// Exchanges the provided token for a Databricks OAuth token.
/// </summary>
/// <param name="token">The token to exchange.</param>
/// <param name="identityFederationClientId">Optional identity federation client ID.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The response from the token exchange API.</returns>
public async Task<TokenExchangeResponse> ExchangeTokenAsync(
string token,
string? identityFederationClientId,
CancellationToken cancellationToken)
{
var formData = new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
new KeyValuePair<string, string>("assertion", token),
new KeyValuePair<string, string>("scope", "sql")
};
if (!string.IsNullOrEmpty(identityFederationClientId))
{
formData.Add(new KeyValuePair<string, string>("identity_federation_client_id", identityFederationClientId!));
}
else
{
formData.Add(new KeyValuePair<string, string>("return_original_token_if_authenticated", "true"));
}
var content = new FormUrlEncodedContent(formData);
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
};
}
}
}