Skip to main content

olai_http/databricks/
credential.rs

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/// Bearer token credential used by all Databricks auth paths.
16///
17/// Wraps an OAuth/OIDC bearer token used to authorize requests against
18/// Databricks REST APIs. The [`bearer`](Self::bearer) field is secret material;
19/// treat it as confidential and avoid logging it.
20#[derive(Eq, PartialEq)]
21pub struct DatabricksCredential {
22    /// The bearer token. This is secret material.
23    pub bearer: String,
24}
25
26// Manual `Debug` impl: a `DatabricksCredential` holds a long-lived bearer token,
27// so we never expose its value. See the credential-redaction convention in the
28// README.
29impl 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/// Response shape shared by all Databricks OIDC token endpoints.
38#[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/// OAuth M2M token provider for AWS/GCP Databricks-hosted services.
62///
63/// POSTs to `{host}/oidc/v1/token` with `grant_type=client_credentials` using
64/// a `client_id` / `client_secret` pair.  The returned bearer token is cached
65/// until it approaches expiry.  This is the recommended auth method for
66/// service-to-service calls against Databricks REST APIs.
67///
68/// # References
69/// - <https://docs.databricks.com/en/dev-tools/auth/oauth-m2m.html>
70#[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/// GCP service-account bearer → Databricks OIDC token exchange provider.
115///
116/// Uses `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` to exchange
117/// a GCP service account JWT for a Databricks OIDC access token.
118#[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
160/// Decode the `exp` claim from a JWT payload and return an `Instant` representing the expiry.
161///
162/// Does not verify the signature — we are the intended recipient.
163pub(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/// OIDC token provider that reads a token from an environment variable on each fetch.
176///
177/// The env var value is re-read on every call so rotated tokens are picked up
178/// automatically without restarting the process.  The JWT `exp` claim is
179/// parsed (without verifying the signature) to set the [`TemporaryToken`]
180/// expiry so the surrounding [`TokenCache`] knows when to refresh.  Useful in
181/// CI/CD pipelines or Kubernetes environments where a sidecar injects a fresh
182/// OIDC token into an environment variable.
183#[derive(Debug)]
184pub(crate) struct OidcEnvTokenProvider {
185    /// Name of the environment variable holding the OIDC JWT.
186    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/// OIDC token provider that reads a token from a file on each fetch.
215///
216/// The file is re-read on every call so kubelet-rotated tokens are picked up
217/// automatically without restarting the process.  The JWT `exp` claim is
218/// parsed (without verifying the signature) to set the [`TemporaryToken`]
219/// expiry so the surrounding [`TokenCache`] knows when to refresh.  The
220/// canonical use-case is Kubernetes workload-identity federation where the
221/// kubelet writes a fresh projected service-account token to a well-known
222/// path (e.g. `/var/run/secrets/azure/tokens/azure-identity-token`).
223#[derive(Debug)]
224pub(crate) struct OidcFileTokenProvider {
225    /// Path to the file containing the OIDC JWT.
226    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        // Deliberately non-secret-looking sentinel value so the secret scanner
263        // doesn't flag this test; we only care that the Debug output does not
264        // echo it back.
265        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    /// Build a minimal JWT with a given `exp` Unix timestamp (no signature).
312    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        // Should expire roughly 1 hour from now (allow ±5s).
331        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        // exp in the past → checked_sub underflows → None
338        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        // SAFETY: single-threaded test, no concurrent env access.
357        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        // SAFETY: single-threaded test, no concurrent env access.
374        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        // First call
410        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        // expires_in = 1 second — expiry should be very soon
438        assert!(token1.expiry.is_some());
439
440        // Second call — simulate token refresh after expiry
441        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        // Test that DatabricksM2MProvider correctly fetches tokens which
461        // can then be used as bearer tokens for UC credential vending calls.
462        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        // Simulate using the token to call the credential vending endpoint
507        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}