1use std::sync::Arc;
2use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3
4use async_trait::async_trait;
5use base64::Engine as _;
6use reqwest::{Client, Method};
7use serde::Deserialize;
8
9use crate::gcp::GcpCredentialProvider;
10use crate::retry::RetryExt;
11use crate::service::HttpService;
12use crate::token::TemporaryToken;
13use crate::{RetryConfig, TokenProvider};
14
15#[derive(Eq, PartialEq)]
21pub struct DatabricksCredential {
22 pub bearer: String,
24}
25
26impl std::fmt::Debug for DatabricksCredential {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 f.debug_struct("DatabricksCredential")
32 .field("bearer", &"<redacted>")
33 .finish()
34 }
35}
36
37#[derive(Deserialize, Debug)]
39pub(crate) struct DatabricksTokenResponse {
40 pub access_token: String,
41 pub expires_in: u64,
42}
43
44#[derive(Debug, thiserror::Error)]
45pub(crate) enum Error {
46 #[error("Error performing Databricks token request: {}", source)]
47 TokenRequest { source: crate::retry::Error },
48
49 #[error("Error reading Databricks token response body: {}", source)]
50 TokenResponseBody { source: reqwest::Error },
51}
52
53impl From<Error> for crate::Error {
54 fn from(value: Error) -> Self {
55 Self::Generic {
56 source: Box::new(value),
57 }
58 }
59}
60
61#[derive(Debug)]
71pub(crate) struct DatabricksM2MProvider {
72 pub token_url: String,
73 pub client_id: String,
74 pub client_secret: String,
75 pub scope: String,
76}
77
78#[async_trait]
79impl TokenProvider for DatabricksM2MProvider {
80 type Credential = DatabricksCredential;
81
82 async fn fetch_token(
83 &self,
84 client: &Client,
85 service: &Arc<dyn HttpService>,
86 retry: &RetryConfig,
87 ) -> crate::Result<TemporaryToken<Arc<DatabricksCredential>>> {
88 let response: DatabricksTokenResponse = client
89 .request(Method::POST, &self.token_url)
90 .form(&[
91 ("grant_type", "client_credentials"),
92 ("client_id", self.client_id.as_str()),
93 ("client_secret", self.client_secret.as_str()),
94 ("scope", self.scope.as_str()),
95 ])
96 .retryable(retry, service.clone())
97 .idempotent(true)
98 .send()
99 .await
100 .map_err(|source| Error::TokenRequest { source })?
101 .json()
102 .await
103 .map_err(|source| Error::TokenResponseBody { source })?;
104
105 Ok(TemporaryToken {
106 token: Arc::new(DatabricksCredential {
107 bearer: response.access_token,
108 }),
109 expiry: Some(Instant::now() + Duration::from_secs(response.expires_in)),
110 })
111 }
112}
113
114#[derive(Debug)]
119pub(crate) struct DatabricksGcpTokenExchangeProvider {
120 pub token_url: String,
121 pub gcp_provider: GcpCredentialProvider,
122}
123
124#[async_trait]
125impl TokenProvider for DatabricksGcpTokenExchangeProvider {
126 type Credential = DatabricksCredential;
127
128 async fn fetch_token(
129 &self,
130 client: &Client,
131 service: &Arc<dyn HttpService>,
132 retry: &RetryConfig,
133 ) -> crate::Result<TemporaryToken<Arc<DatabricksCredential>>> {
134 let gcp_cred = self.gcp_provider.get_credential().await?;
135
136 let response: DatabricksTokenResponse = client
137 .request(Method::POST, &self.token_url)
138 .form(&[
139 ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
140 ("assertion", gcp_cred.bearer.as_str()),
141 ])
142 .retryable(retry, service.clone())
143 .idempotent(true)
144 .send()
145 .await
146 .map_err(|source| Error::TokenRequest { source })?
147 .json()
148 .await
149 .map_err(|source| Error::TokenResponseBody { source })?;
150
151 Ok(TemporaryToken {
152 token: Arc::new(DatabricksCredential {
153 bearer: response.access_token,
154 }),
155 expiry: Some(Instant::now() + Duration::from_secs(response.expires_in)),
156 })
157 }
158}
159
160pub(crate) fn jwt_exp(token: &str) -> Option<Instant> {
164 let payload = token.split('.').nth(1)?;
165 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
166 .decode(payload)
167 .ok()?;
168 let v: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
169 let exp = v["exp"].as_u64()?;
170 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
171 let secs_remaining = exp.checked_sub(now_secs)?;
172 Some(Instant::now() + Duration::from_secs(secs_remaining))
173}
174
175#[derive(Debug)]
184pub(crate) struct OidcEnvTokenProvider {
185 pub env_var: String,
187}
188
189#[async_trait]
190impl TokenProvider for OidcEnvTokenProvider {
191 type Credential = DatabricksCredential;
192
193 async fn fetch_token(
194 &self,
195 _client: &Client,
196 _service: &Arc<dyn HttpService>,
197 _retry: &RetryConfig,
198 ) -> crate::Result<TemporaryToken<Arc<DatabricksCredential>>> {
199 let token = std::env::var(&self.env_var).map_err(|_| crate::Error::Generic {
200 source: format!(
201 "env-oidc: environment variable {:?} is not set",
202 self.env_var
203 )
204 .into(),
205 })?;
206 let expiry = jwt_exp(&token);
207 Ok(TemporaryToken {
208 token: Arc::new(DatabricksCredential { bearer: token }),
209 expiry,
210 })
211 }
212}
213
214#[derive(Debug)]
224pub(crate) struct OidcFileTokenProvider {
225 pub filepath: String,
227}
228
229#[async_trait]
230impl TokenProvider for OidcFileTokenProvider {
231 type Credential = DatabricksCredential;
232
233 async fn fetch_token(
234 &self,
235 _client: &Client,
236 _service: &Arc<dyn HttpService>,
237 _retry: &RetryConfig,
238 ) -> crate::Result<TemporaryToken<Arc<DatabricksCredential>>> {
239 let token = std::fs::read_to_string(&self.filepath).map_err(|e| crate::Error::Generic {
240 source: format!(
241 "file-oidc: failed to read token from {:?}: {e}",
242 self.filepath
243 )
244 .into(),
245 })?;
246 let token = token.trim().to_owned();
247 let expiry = jwt_exp(&token);
248 Ok(TemporaryToken {
249 token: Arc::new(DatabricksCredential { bearer: token }),
250 expiry,
251 })
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::service::ReqwestService;
259
260 #[test]
261 fn test_debug_does_not_leak_secrets() {
262 let credential = DatabricksCredential {
266 bearer: "fake-token-do-not-print".to_string(),
267 };
268 let rendered = format!("{credential:?}");
269 assert!(
270 !rendered.contains("fake-token-do-not-print"),
271 "bearer token leaked: {rendered}"
272 );
273 assert!(rendered.contains("<redacted>"));
274 }
275
276 #[tokio::test]
277 async fn test_m2m_fetch_token() {
278 let mut server = mockito::Server::new_async().await;
279
280 let _mock = server
281 .mock("POST", "/oidc/v1/token")
282 .match_body(mockito::Matcher::AllOf(vec![
283 mockito::Matcher::Regex("grant_type=client_credentials".into()),
284 mockito::Matcher::Regex("client_id=myid".into()),
285 mockito::Matcher::Regex("scope=unity-catalog".into()),
286 ]))
287 .with_status(200)
288 .with_body(r#"{"access_token":"DBTOKEN","expires_in":3600,"token_type":"Bearer"}"#)
289 .create_async()
290 .await;
291
292 let client = Client::new();
293 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
294 let retry = RetryConfig::default();
295
296 let provider = DatabricksM2MProvider {
297 token_url: format!("{}/oidc/v1/token", server.url()),
298 client_id: "myid".into(),
299 client_secret: "mysecret".into(),
300 scope: "unity-catalog".into(),
301 };
302
303 let token = provider
304 .fetch_token(&client, &service, &retry)
305 .await
306 .unwrap();
307 assert_eq!(token.token.bearer, "DBTOKEN");
308 assert!(token.expiry.is_some());
309 }
310
311 fn make_jwt(exp: u64) -> String {
313 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
314 .encode(r#"{"alg":"none","typ":"JWT"}"#);
315 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
316 .encode(format!(r#"{{"sub":"test","exp":{exp}}}"#));
317 format!("{header}.{payload}.sig")
318 }
319
320 #[test]
321 fn test_jwt_exp_future() {
322 let future_exp = SystemTime::now()
323 .duration_since(UNIX_EPOCH)
324 .unwrap()
325 .as_secs()
326 + 3600;
327 let token = make_jwt(future_exp);
328 let expiry = jwt_exp(&token);
329 assert!(expiry.is_some());
330 let remaining = expiry.unwrap().duration_since(Instant::now());
332 assert!(remaining.as_secs() > 3590 && remaining.as_secs() <= 3600);
333 }
334
335 #[test]
336 fn test_jwt_exp_already_expired() {
337 let past_exp = SystemTime::now()
339 .duration_since(UNIX_EPOCH)
340 .unwrap()
341 .as_secs()
342 .saturating_sub(60);
343 let token = make_jwt(past_exp);
344 assert!(jwt_exp(&token).is_none());
345 }
346
347 #[tokio::test]
348 async fn test_env_oidc_provider() {
349 let future_exp = SystemTime::now()
350 .duration_since(UNIX_EPOCH)
351 .unwrap()
352 .as_secs()
353 + 3600;
354 let jwt = make_jwt(future_exp);
355 let env_var = "TEST_OIDC_TOKEN_UNIQUE_KEY_12345";
356 unsafe { std::env::set_var(env_var, &jwt) };
358
359 let client = Client::new();
360 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
361 let retry = RetryConfig::default();
362
363 let provider = OidcEnvTokenProvider {
364 env_var: env_var.to_owned(),
365 };
366 let token = provider
367 .fetch_token(&client, &service, &retry)
368 .await
369 .unwrap();
370 assert_eq!(token.token.bearer, jwt);
371 assert!(token.expiry.is_some());
372
373 unsafe { std::env::remove_var(env_var) };
375 }
376
377 #[tokio::test]
378 async fn test_file_oidc_provider() {
379 use std::io::Write as _;
380 let future_exp = SystemTime::now()
381 .duration_since(UNIX_EPOCH)
382 .unwrap()
383 .as_secs()
384 + 3600;
385 let jwt = make_jwt(future_exp);
386
387 let mut tmp = tempfile::NamedTempFile::new().unwrap();
388 write!(tmp, "{jwt}").unwrap();
389
390 let client = Client::new();
391 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
392 let retry = RetryConfig::default();
393
394 let provider = OidcFileTokenProvider {
395 filepath: tmp.path().to_str().unwrap().to_owned(),
396 };
397 let token = provider
398 .fetch_token(&client, &service, &retry)
399 .await
400 .unwrap();
401 assert_eq!(token.token.bearer, jwt);
402 assert!(token.expiry.is_some());
403 }
404
405 #[tokio::test]
406 async fn test_m2m_token_refresh() {
407 let mut server = mockito::Server::new_async().await;
408
409 let _mock1 = server
411 .mock("POST", "/oidc/v1/token")
412 .match_body(mockito::Matcher::AllOf(vec![
413 mockito::Matcher::Regex("grant_type=client_credentials".into()),
414 mockito::Matcher::Regex("client_id=refresh-client".into()),
415 ]))
416 .with_status(200)
417 .with_body(r#"{"access_token":"TOKEN_FIRST","expires_in":1,"token_type":"Bearer"}"#)
418 .create_async()
419 .await;
420
421 let client = Client::new();
422 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
423 let retry = RetryConfig::default();
424
425 let provider = DatabricksM2MProvider {
426 token_url: format!("{}/oidc/v1/token", server.url()),
427 client_id: "refresh-client".into(),
428 client_secret: "refresh-secret".into(),
429 scope: "unity-catalog".into(),
430 };
431
432 let token1 = provider
433 .fetch_token(&client, &service, &retry)
434 .await
435 .unwrap();
436 assert_eq!(token1.token.bearer, "TOKEN_FIRST");
437 assert!(token1.expiry.is_some());
439
440 let _mock2 = server
442 .mock("POST", "/oidc/v1/token")
443 .with_status(200)
444 .with_body(r#"{"access_token":"TOKEN_SECOND","expires_in":3600,"token_type":"Bearer"}"#)
445 .create_async()
446 .await;
447
448 let token2 = provider
449 .fetch_token(&client, &service, &retry)
450 .await
451 .unwrap();
452 assert_eq!(token2.token.bearer, "TOKEN_SECOND");
453
454 _mock1.assert_async().await;
455 _mock2.assert_async().await;
456 }
457
458 #[tokio::test]
459 async fn test_uc_credential_vending_aws() {
460 let mut server = mockito::Server::new_async().await;
463
464 let _token_mock = server
465 .mock("POST", "/oidc/v1/token")
466 .with_status(200)
467 .with_body(
468 r#"{"access_token":"UC_BEARER_TOKEN","expires_in":3600,"token_type":"Bearer"}"#,
469 )
470 .create_async()
471 .await;
472
473 let _creds_mock = server
474 .mock("POST", "/api/2.1/unity-catalog/temporary-table-credentials")
475 .match_header("Authorization", "Bearer UC_BEARER_TOKEN")
476 .with_status(200)
477 .with_body(
478 r#"{
479 "aws_temp_credentials": {
480 "access_key_id": "ASIATESTVENDING",
481 "secret_access_key": "vendingsecret",
482 "session_token": "vendingtoken"
483 },
484 "expiration_time": "2099-01-01T00:00:00Z"
485 }"#,
486 )
487 .create_async()
488 .await;
489
490 let client = Client::new();
491 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
492 let retry = RetryConfig::default();
493
494 let provider = DatabricksM2MProvider {
495 token_url: format!("{}/oidc/v1/token", server.url()),
496 client_id: "uc-client".into(),
497 client_secret: "uc-secret".into(),
498 scope: "unity-catalog".into(),
499 };
500
501 let token = provider
502 .fetch_token(&client, &service, &retry)
503 .await
504 .unwrap();
505
506 let vend_resp = client
508 .request(
509 Method::POST,
510 format!(
511 "{}/api/2.1/unity-catalog/temporary-table-credentials",
512 server.url()
513 ),
514 )
515 .bearer_auth(&token.token.bearer)
516 .send()
517 .await
518 .unwrap();
519
520 assert!(vend_resp.status().is_success());
521
522 let body: serde_json::Value = vend_resp.json().await.unwrap();
523 assert_eq!(
524 body["aws_temp_credentials"]["access_key_id"],
525 "ASIATESTVENDING"
526 );
527
528 _token_mock.assert_async().await;
529 _creds_mock.assert_async().await;
530 }
531}