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 reqwest::{Client, Method};
6use serde::Deserialize;
7
8use crate::gcp::GcpCredentialProvider;
9use crate::retry::RetryExt;
10use crate::service::HttpService;
11use crate::token::{TemporaryToken, jwt_exp_secs};
12use crate::{RetryConfig, TokenProvider};
13
14/// Bearer token credential used by all Databricks auth paths.
15///
16/// Wraps an OAuth/OIDC bearer token used to authorize requests against
17/// Databricks REST APIs. The [`bearer`](Self::bearer) field is secret material;
18/// treat it as confidential and avoid logging it.
19#[derive(Eq, PartialEq)]
20pub struct DatabricksCredential {
21    /// The bearer token. This is secret material.
22    pub bearer: String,
23}
24
25// Manual `Debug` impl: a `DatabricksCredential` holds a long-lived bearer token,
26// so we never expose its value. See the credential-redaction convention in the
27// README.
28impl std::fmt::Debug for DatabricksCredential {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.debug_struct("DatabricksCredential")
31            .field("bearer", &"<redacted>")
32            .finish()
33    }
34}
35
36/// Response shape shared by all Databricks OIDC token endpoints.
37#[derive(Deserialize, Debug)]
38pub(crate) struct DatabricksTokenResponse {
39    pub access_token: String,
40    pub expires_in: u64,
41}
42
43#[derive(Debug, thiserror::Error)]
44pub(crate) enum Error {
45    #[error("Error performing Databricks token request: {}", source)]
46    TokenRequest { source: crate::retry::Error },
47
48    #[error("Error reading Databricks token response body: {}", source)]
49    TokenResponseBody { source: reqwest::Error },
50}
51
52impl From<Error> for crate::Error {
53    fn from(value: Error) -> Self {
54        Self::Generic {
55            source: Box::new(value),
56        }
57    }
58}
59
60/// OAuth M2M token provider for AWS/GCP Databricks-hosted services.
61///
62/// POSTs to `{host}/oidc/v1/token` with `grant_type=client_credentials` using
63/// a `client_id` / `client_secret` pair.  The returned bearer token is cached
64/// until it approaches expiry.  This is the recommended auth method for
65/// service-to-service calls against Databricks REST APIs.
66///
67/// # References
68/// - <https://docs.databricks.com/en/dev-tools/auth/oauth-m2m.html>
69#[derive(Debug)]
70pub(crate) struct DatabricksM2MProvider {
71    pub token_url: String,
72    pub client_id: String,
73    pub client_secret: String,
74    pub scope: String,
75}
76
77#[async_trait]
78impl TokenProvider for DatabricksM2MProvider {
79    type Credential = DatabricksCredential;
80
81    async fn fetch_token(
82        &self,
83        client: &Client,
84        service: &Arc<dyn HttpService>,
85        retry: &RetryConfig,
86    ) -> crate::Result<TemporaryToken<Arc<DatabricksCredential>>> {
87        let response: DatabricksTokenResponse = client
88            .request(Method::POST, &self.token_url)
89            .form(&[
90                ("grant_type", "client_credentials"),
91                ("client_id", self.client_id.as_str()),
92                ("client_secret", self.client_secret.as_str()),
93                ("scope", self.scope.as_str()),
94            ])
95            .retryable(retry, service.clone())
96            .idempotent(true)
97            .send()
98            .await
99            .map_err(|source| Error::TokenRequest { source })?
100            .json()
101            .await
102            .map_err(|source| Error::TokenResponseBody { source })?;
103
104        Ok(TemporaryToken {
105            token: Arc::new(DatabricksCredential {
106                bearer: response.access_token,
107            }),
108            expiry: Some(Instant::now() + Duration::from_secs(response.expires_in)),
109        })
110    }
111}
112
113/// GCP service-account bearer → Databricks OIDC token exchange provider.
114///
115/// Uses `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` to exchange
116/// a GCP service account JWT for a Databricks OIDC access token.
117#[derive(Debug)]
118pub(crate) struct DatabricksGcpTokenExchangeProvider {
119    pub token_url: String,
120    pub gcp_provider: GcpCredentialProvider,
121}
122
123#[async_trait]
124impl TokenProvider for DatabricksGcpTokenExchangeProvider {
125    type Credential = DatabricksCredential;
126
127    async fn fetch_token(
128        &self,
129        client: &Client,
130        service: &Arc<dyn HttpService>,
131        retry: &RetryConfig,
132    ) -> crate::Result<TemporaryToken<Arc<DatabricksCredential>>> {
133        let gcp_cred = self.gcp_provider.get_credential().await?;
134
135        let response: DatabricksTokenResponse = client
136            .request(Method::POST, &self.token_url)
137            .form(&[
138                ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
139                ("assertion", gcp_cred.bearer.as_str()),
140            ])
141            .retryable(retry, service.clone())
142            .idempotent(true)
143            .send()
144            .await
145            .map_err(|source| Error::TokenRequest { source })?
146            .json()
147            .await
148            .map_err(|source| Error::TokenResponseBody { source })?;
149
150        Ok(TemporaryToken {
151            token: Arc::new(DatabricksCredential {
152                bearer: response.access_token,
153            }),
154            expiry: Some(Instant::now() + Duration::from_secs(response.expires_in)),
155        })
156    }
157}
158
159/// Decode the `exp` claim from a JWT payload and return an `Instant` representing the expiry.
160///
161/// Does not verify the signature — we are the intended recipient.
162pub(crate) fn jwt_exp(token: &str) -> Option<Instant> {
163    let exp = jwt_exp_secs(token)?;
164    let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
165    let secs_remaining = exp.checked_sub(now_secs)?;
166    Some(Instant::now() + Duration::from_secs(secs_remaining))
167}
168
169/// OIDC token provider that reads a token from an environment variable on each fetch.
170///
171/// The env var value is re-read on every call so rotated tokens are picked up
172/// automatically without restarting the process.  The JWT `exp` claim is
173/// parsed (without verifying the signature) to set the [`TemporaryToken`]
174/// expiry so the surrounding [`TokenCache`] knows when to refresh.  Useful in
175/// CI/CD pipelines or Kubernetes environments where a sidecar injects a fresh
176/// OIDC token into an environment variable.
177#[derive(Debug)]
178pub(crate) struct OidcEnvTokenProvider {
179    /// Name of the environment variable holding the OIDC JWT.
180    pub env_var: String,
181}
182
183#[async_trait]
184impl TokenProvider for OidcEnvTokenProvider {
185    type Credential = DatabricksCredential;
186
187    async fn fetch_token(
188        &self,
189        _client: &Client,
190        _service: &Arc<dyn HttpService>,
191        _retry: &RetryConfig,
192    ) -> crate::Result<TemporaryToken<Arc<DatabricksCredential>>> {
193        let token = std::env::var(&self.env_var).map_err(|_| crate::Error::Generic {
194            source: format!(
195                "env-oidc: environment variable {:?} is not set",
196                self.env_var
197            )
198            .into(),
199        })?;
200        let expiry = jwt_exp(&token);
201        Ok(TemporaryToken {
202            token: Arc::new(DatabricksCredential { bearer: token }),
203            expiry,
204        })
205    }
206}
207
208/// OIDC token provider that reads a token from a file on each fetch.
209///
210/// The file is re-read on every call so kubelet-rotated tokens are picked up
211/// automatically without restarting the process.  The JWT `exp` claim is
212/// parsed (without verifying the signature) to set the [`TemporaryToken`]
213/// expiry so the surrounding [`TokenCache`] knows when to refresh.  The
214/// canonical use-case is Kubernetes workload-identity federation where the
215/// kubelet writes a fresh projected service-account token to a well-known
216/// path (e.g. `/var/run/secrets/azure/tokens/azure-identity-token`).
217#[derive(Debug)]
218pub(crate) struct OidcFileTokenProvider {
219    /// Path to the file containing the OIDC JWT.
220    pub filepath: String,
221}
222
223#[async_trait]
224impl TokenProvider for OidcFileTokenProvider {
225    type Credential = DatabricksCredential;
226
227    async fn fetch_token(
228        &self,
229        _client: &Client,
230        _service: &Arc<dyn HttpService>,
231        _retry: &RetryConfig,
232    ) -> crate::Result<TemporaryToken<Arc<DatabricksCredential>>> {
233        let token = std::fs::read_to_string(&self.filepath).map_err(|e| crate::Error::Generic {
234            source: format!(
235                "file-oidc: failed to read token from {:?}: {e}",
236                self.filepath
237            )
238            .into(),
239        })?;
240        let token = token.trim().to_owned();
241        let expiry = jwt_exp(&token);
242        Ok(TemporaryToken {
243            token: Arc::new(DatabricksCredential { bearer: token }),
244            expiry,
245        })
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use base64::Engine as _;
252
253    use super::*;
254    use crate::service::ReqwestService;
255
256    #[test]
257    fn test_debug_does_not_leak_secrets() {
258        // Deliberately non-secret-looking sentinel value so the secret scanner
259        // doesn't flag this test; we only care that the Debug output does not
260        // echo it back.
261        let credential = DatabricksCredential {
262            bearer: "fake-token-do-not-print".to_string(),
263        };
264        let rendered = format!("{credential:?}");
265        assert!(
266            !rendered.contains("fake-token-do-not-print"),
267            "bearer token leaked: {rendered}"
268        );
269        assert!(rendered.contains("<redacted>"));
270    }
271
272    #[tokio::test]
273    async fn test_m2m_fetch_token() {
274        let mut server = mockito::Server::new_async().await;
275
276        let _mock = server
277            .mock("POST", "/oidc/v1/token")
278            .match_body(mockito::Matcher::AllOf(vec![
279                mockito::Matcher::Regex("grant_type=client_credentials".into()),
280                mockito::Matcher::Regex("client_id=myid".into()),
281                mockito::Matcher::Regex("scope=unity-catalog".into()),
282            ]))
283            .with_status(200)
284            .with_body(r#"{"access_token":"DBTOKEN","expires_in":3600,"token_type":"Bearer"}"#)
285            .create_async()
286            .await;
287
288        let client = Client::new();
289        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
290        let retry = RetryConfig::default();
291
292        let provider = DatabricksM2MProvider {
293            token_url: format!("{}/oidc/v1/token", server.url()),
294            client_id: "myid".into(),
295            client_secret: "mysecret".into(),
296            scope: "unity-catalog".into(),
297        };
298
299        let token = provider
300            .fetch_token(&client, &service, &retry)
301            .await
302            .unwrap();
303        assert_eq!(token.token.bearer, "DBTOKEN");
304        assert!(token.expiry.is_some());
305    }
306
307    /// Build a minimal JWT with a given `exp` Unix timestamp (no signature).
308    fn make_jwt(exp: u64) -> String {
309        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
310            .encode(r#"{"alg":"none","typ":"JWT"}"#);
311        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
312            .encode(format!(r#"{{"sub":"test","exp":{exp}}}"#));
313        format!("{header}.{payload}.sig")
314    }
315
316    #[test]
317    fn test_jwt_exp_future() {
318        let future_exp = SystemTime::now()
319            .duration_since(UNIX_EPOCH)
320            .unwrap()
321            .as_secs()
322            + 3600;
323        let token = make_jwt(future_exp);
324        let expiry = jwt_exp(&token);
325        assert!(expiry.is_some());
326        // Should expire roughly 1 hour from now (allow ±5s).
327        let remaining = expiry.unwrap().duration_since(Instant::now());
328        assert!(remaining.as_secs() > 3590 && remaining.as_secs() <= 3600);
329    }
330
331    #[test]
332    fn test_jwt_exp_already_expired() {
333        // exp in the past → checked_sub underflows → None
334        let past_exp = SystemTime::now()
335            .duration_since(UNIX_EPOCH)
336            .unwrap()
337            .as_secs()
338            .saturating_sub(60);
339        let token = make_jwt(past_exp);
340        assert!(jwt_exp(&token).is_none());
341    }
342
343    #[tokio::test]
344    async fn test_env_oidc_provider() {
345        let future_exp = SystemTime::now()
346            .duration_since(UNIX_EPOCH)
347            .unwrap()
348            .as_secs()
349            + 3600;
350        let jwt = make_jwt(future_exp);
351        let env_var = "TEST_OIDC_TOKEN_UNIQUE_KEY_12345";
352        // SAFETY: single-threaded test, no concurrent env access.
353        unsafe { std::env::set_var(env_var, &jwt) };
354
355        let client = Client::new();
356        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
357        let retry = RetryConfig::default();
358
359        let provider = OidcEnvTokenProvider {
360            env_var: env_var.to_owned(),
361        };
362        let token = provider
363            .fetch_token(&client, &service, &retry)
364            .await
365            .unwrap();
366        assert_eq!(token.token.bearer, jwt);
367        assert!(token.expiry.is_some());
368
369        // SAFETY: single-threaded test, no concurrent env access.
370        unsafe { std::env::remove_var(env_var) };
371    }
372
373    #[tokio::test]
374    async fn test_file_oidc_provider() {
375        use std::io::Write as _;
376        let future_exp = SystemTime::now()
377            .duration_since(UNIX_EPOCH)
378            .unwrap()
379            .as_secs()
380            + 3600;
381        let jwt = make_jwt(future_exp);
382
383        let mut tmp = tempfile::NamedTempFile::new().unwrap();
384        write!(tmp, "{jwt}").unwrap();
385
386        let client = Client::new();
387        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
388        let retry = RetryConfig::default();
389
390        let provider = OidcFileTokenProvider {
391            filepath: tmp.path().to_str().unwrap().to_owned(),
392        };
393        let token = provider
394            .fetch_token(&client, &service, &retry)
395            .await
396            .unwrap();
397        assert_eq!(token.token.bearer, jwt);
398        assert!(token.expiry.is_some());
399    }
400
401    #[tokio::test]
402    async fn test_m2m_token_refresh() {
403        let mut server = mockito::Server::new_async().await;
404
405        // First call
406        let _mock1 = server
407            .mock("POST", "/oidc/v1/token")
408            .match_body(mockito::Matcher::AllOf(vec![
409                mockito::Matcher::Regex("grant_type=client_credentials".into()),
410                mockito::Matcher::Regex("client_id=refresh-client".into()),
411            ]))
412            .with_status(200)
413            .with_body(r#"{"access_token":"TOKEN_FIRST","expires_in":1,"token_type":"Bearer"}"#)
414            .create_async()
415            .await;
416
417        let client = Client::new();
418        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
419        let retry = RetryConfig::default();
420
421        let provider = DatabricksM2MProvider {
422            token_url: format!("{}/oidc/v1/token", server.url()),
423            client_id: "refresh-client".into(),
424            client_secret: "refresh-secret".into(),
425            scope: "unity-catalog".into(),
426        };
427
428        let token1 = provider
429            .fetch_token(&client, &service, &retry)
430            .await
431            .unwrap();
432        assert_eq!(token1.token.bearer, "TOKEN_FIRST");
433        // expires_in = 1 second — expiry should be very soon
434        assert!(token1.expiry.is_some());
435
436        // Second call — simulate token refresh after expiry
437        let _mock2 = server
438            .mock("POST", "/oidc/v1/token")
439            .with_status(200)
440            .with_body(r#"{"access_token":"TOKEN_SECOND","expires_in":3600,"token_type":"Bearer"}"#)
441            .create_async()
442            .await;
443
444        let token2 = provider
445            .fetch_token(&client, &service, &retry)
446            .await
447            .unwrap();
448        assert_eq!(token2.token.bearer, "TOKEN_SECOND");
449
450        _mock1.assert_async().await;
451        _mock2.assert_async().await;
452    }
453
454    #[tokio::test]
455    async fn test_uc_credential_vending_aws() {
456        // Test that DatabricksM2MProvider correctly fetches tokens which
457        // can then be used as bearer tokens for UC credential vending calls.
458        let mut server = mockito::Server::new_async().await;
459
460        let _token_mock = server
461            .mock("POST", "/oidc/v1/token")
462            .with_status(200)
463            .with_body(
464                r#"{"access_token":"UC_BEARER_TOKEN","expires_in":3600,"token_type":"Bearer"}"#,
465            )
466            .create_async()
467            .await;
468
469        let _creds_mock = server
470            .mock("POST", "/api/2.1/unity-catalog/temporary-table-credentials")
471            .match_header("Authorization", "Bearer UC_BEARER_TOKEN")
472            .with_status(200)
473            .with_body(
474                r#"{
475                "aws_temp_credentials": {
476                    "access_key_id": "ASIATESTVENDING",
477                    "secret_access_key": "vendingsecret",
478                    "session_token": "vendingtoken"
479                },
480                "expiration_time": "2099-01-01T00:00:00Z"
481            }"#,
482            )
483            .create_async()
484            .await;
485
486        let client = Client::new();
487        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
488        let retry = RetryConfig::default();
489
490        let provider = DatabricksM2MProvider {
491            token_url: format!("{}/oidc/v1/token", server.url()),
492            client_id: "uc-client".into(),
493            client_secret: "uc-secret".into(),
494            scope: "unity-catalog".into(),
495        };
496
497        let token = provider
498            .fetch_token(&client, &service, &retry)
499            .await
500            .unwrap();
501
502        // Simulate using the token to call the credential vending endpoint
503        let vend_resp = client
504            .request(
505                Method::POST,
506                format!(
507                    "{}/api/2.1/unity-catalog/temporary-table-credentials",
508                    server.url()
509                ),
510            )
511            .bearer_auth(&token.token.bearer)
512            .send()
513            .await
514            .unwrap();
515
516        assert!(vend_resp.status().is_success());
517
518        let body: serde_json::Value = vend_resp.json().await.unwrap();
519        assert_eq!(
520            body["aws_temp_credentials"]["access_key_id"],
521            "ASIATESTVENDING"
522        );
523
524        _token_mock.assert_async().await;
525        _creds_mock.assert_async().await;
526    }
527}