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