1use std::fmt::Debug;
19use std::ops::Deref;
20use std::process::Command;
21use std::str;
22use std::sync::Arc;
23use std::time::{Duration, Instant, SystemTime};
24
25use async_trait::async_trait;
26use chrono::DateTime;
27use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderValue};
28use reqwest::{Client, Method, Request, RequestBuilder};
29use serde::Deserialize;
30
31use crate::retry::RetryExt;
32use crate::service::HttpService;
33use crate::token::{TemporaryToken, TokenCache};
34use crate::{CredentialProvider, RetryConfig, TokenProvider};
35
36const CONTENT_TYPE_JSON: &str = "application/json";
37const MSI_SECRET_ENV_KEY: &str = "IDENTITY_HEADER";
38const MSI_API_VERSION: &str = "2019-08-01";
39
40pub(crate) const AZURE_STORAGE_SCOPE: &str = "https://storage.azure.com/.default";
42
43pub(crate) const AZURE_STORAGE_RESOURCE: &str = "https://storage.azure.com";
45
46pub(crate) const AZURE_DATABRICKS_RESOURCE: &str = "2ff814a6-3304-4ab8-85cb-cd0e6f879c1d";
48
49#[allow(dead_code)]
52pub(crate) const AZURE_DATABRICKS_SCOPE: &str = "2ff814a6-3304-4ab8-85cb-cd0e6f879c1d/.default";
53
54#[derive(Debug, thiserror::Error)]
55pub enum Error {
56 #[error("Error performing token request: {}", source)]
57 TokenRequest { source: crate::retry::Error },
58
59 #[error("Error getting token response body: {}", source)]
60 TokenResponseBody { source: reqwest::Error },
61
62 #[error("Error reading federated token file ")]
63 FederatedTokenFile,
64
65 #[error("Invalid Access Key: {}", source)]
66 InvalidAccessKey { source: base64::DecodeError },
67
68 #[error("'az account get-access-token' command failed: {message}")]
69 AzureCli { message: String },
70
71 #[error("Failed to parse azure cli response: {source}")]
72 AzureCliResponse { source: serde_json::Error },
73}
74
75pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
76
77impl From<Error> for crate::Error {
78 fn from(value: Error) -> Self {
79 Self::Generic {
80 source: Box::new(value),
81 }
82 }
83}
84
85#[derive(Eq, PartialEq)]
91pub enum AzureCredential {
92 BearerToken(String),
97}
98
99impl std::fmt::Debug for AzureCredential {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 match self {
105 Self::BearerToken(_) => f.debug_tuple("BearerToken").field(&"<redacted>").finish(),
106 }
107 }
108}
109
110#[allow(dead_code)]
112pub mod authority_hosts {
113 pub const AZURE_CHINA: &str = "https://login.chinacloudapi.cn";
115 pub const AZURE_GERMANY: &str = "https://login.microsoftonline.de";
117 pub const AZURE_GOVERNMENT: &str = "https://login.microsoftonline.us";
119 pub const AZURE_PUBLIC_CLOUD: &str = "https://login.microsoftonline.com";
121}
122
123#[derive(Debug)]
125pub struct AzureAuthorizer<'a> {
126 credential: &'a AzureCredential,
127}
128
129impl<'a> AzureAuthorizer<'a> {
130 pub fn new(credential: &'a AzureCredential) -> Self {
133 AzureAuthorizer { credential }
134 }
135
136 pub fn authorize(&self, request: &mut Request) {
142 match self.credential {
143 AzureCredential::BearerToken(token) => {
144 request.headers_mut().append(
145 AUTHORIZATION,
146 HeaderValue::from_str(format!("Bearer {token}").as_str()).unwrap(),
147 );
148 }
149 }
150 }
151}
152
153pub trait AzureCredentialExt {
154 fn with_azure_authorization(
156 self,
157 credential: &Option<impl Deref<Target = AzureCredential>>,
158 ) -> Self;
159}
160
161impl AzureCredentialExt for RequestBuilder {
162 fn with_azure_authorization(
163 self,
164 credential: &Option<impl Deref<Target = AzureCredential>>,
165 ) -> Self {
166 match credential.as_deref() {
167 Some(credential) => {
168 let (client, request) = self.build_split();
169 let mut request = request.expect("request valid");
170 AzureAuthorizer::new(credential).authorize(&mut request);
171 Self::from_parts(client, request)
172 }
173 None => self,
174 }
175 }
176}
177
178#[derive(Deserialize, Debug)]
180struct OAuthTokenResponse {
181 access_token: String,
182 expires_in: u64,
183}
184
185#[derive(Debug)]
196pub(crate) struct ClientSecretOAuthProvider {
197 token_url: String,
198 client_id: String,
199 client_secret: String,
200 scope: String,
201}
202
203impl ClientSecretOAuthProvider {
204 pub(crate) fn new_with_scope(
206 client_id: String,
207 client_secret: String,
208 tenant_id: impl AsRef<str>,
209 authority_host: Option<String>,
210 scope: &str,
211 ) -> Self {
212 let authority_host =
213 authority_host.unwrap_or_else(|| authority_hosts::AZURE_PUBLIC_CLOUD.to_owned());
214
215 Self {
216 token_url: format!(
217 "{}/{}/oauth2/v2.0/token",
218 authority_host,
219 tenant_id.as_ref()
220 ),
221 client_id,
222 client_secret,
223 scope: scope.to_owned(),
224 }
225 }
226}
227
228#[async_trait::async_trait]
229impl TokenProvider for ClientSecretOAuthProvider {
230 type Credential = AzureCredential;
231
232 async fn fetch_token(
241 &self,
242 client: &Client,
243 service: &Arc<dyn HttpService>,
244 retry: &RetryConfig,
245 ) -> crate::Result<TemporaryToken<Arc<AzureCredential>>> {
246 let response: OAuthTokenResponse = client
247 .request(Method::POST, &self.token_url)
248 .header(ACCEPT, HeaderValue::from_static(CONTENT_TYPE_JSON))
249 .form(&[
250 ("client_id", self.client_id.as_str()),
251 ("client_secret", self.client_secret.as_str()),
252 ("scope", self.scope.as_str()),
253 ("grant_type", "client_credentials"),
254 ])
255 .retryable(retry, service.clone())
256 .idempotent(true)
257 .send()
258 .await
259 .map_err(|source| Error::TokenRequest { source })?
260 .json()
261 .await
262 .map_err(|source| Error::TokenResponseBody { source })?;
263
264 Ok(TemporaryToken {
265 token: Arc::new(AzureCredential::BearerToken(response.access_token)),
266 expiry: Some(Instant::now() + Duration::from_secs(response.expires_in)),
267 })
268 }
269}
270
271fn expires_on_string<'de, D>(deserializer: D) -> std::result::Result<Instant, D::Error>
272where
273 D: serde::de::Deserializer<'de>,
274{
275 let v = String::deserialize(deserializer)?;
276 let v = v.parse::<u64>().map_err(serde::de::Error::custom)?;
277 let now = SystemTime::now()
278 .duration_since(SystemTime::UNIX_EPOCH)
279 .map_err(serde::de::Error::custom)?;
280
281 Ok(Instant::now() + Duration::from_secs(v.saturating_sub(now.as_secs())))
282}
283
284#[derive(Debug, Clone, Deserialize)]
285struct ImdsTokenResponse {
286 pub access_token: String,
287 #[serde(deserialize_with = "expires_on_string")]
288 pub expires_on: Instant,
289}
290
291#[derive(Debug)]
303pub(crate) struct ImdsManagedIdentityProvider {
304 msi_endpoint: String,
305 client_id: Option<String>,
306 object_id: Option<String>,
307 msi_res_id: Option<String>,
308 resource: String,
309}
310
311impl ImdsManagedIdentityProvider {
312 pub(crate) fn new(
313 client_id: Option<String>,
314 object_id: Option<String>,
315 msi_res_id: Option<String>,
316 msi_endpoint: Option<String>,
317 ) -> Self {
318 Self::new_with_resource(
319 client_id,
320 object_id,
321 msi_res_id,
322 msi_endpoint,
323 AZURE_STORAGE_RESOURCE,
324 )
325 }
326
327 pub(crate) fn new_with_resource(
329 client_id: Option<String>,
330 object_id: Option<String>,
331 msi_res_id: Option<String>,
332 msi_endpoint: Option<String>,
333 resource: &str,
334 ) -> Self {
335 let msi_endpoint = msi_endpoint
336 .unwrap_or_else(|| "http://169.254.169.254/metadata/identity/oauth2/token".to_owned());
337
338 Self {
339 msi_endpoint,
340 client_id,
341 object_id,
342 msi_res_id,
343 resource: resource.to_owned(),
344 }
345 }
346}
347
348#[async_trait::async_trait]
349impl TokenProvider for ImdsManagedIdentityProvider {
350 type Credential = AzureCredential;
351
352 async fn fetch_token(
361 &self,
362 client: &Client,
363 service: &Arc<dyn HttpService>,
364 retry: &RetryConfig,
365 ) -> crate::Result<TemporaryToken<Arc<AzureCredential>>> {
366 let mut query_items = vec![
367 ("api-version", MSI_API_VERSION),
368 ("resource", self.resource.as_str()),
369 ];
370
371 let mut identity = None;
372 if let Some(client_id) = &self.client_id {
373 identity = Some(("client_id", client_id));
374 }
375 if let Some(object_id) = &self.object_id {
376 identity = Some(("object_id", object_id));
377 }
378 if let Some(msi_res_id) = &self.msi_res_id {
379 identity = Some(("msi_res_id", msi_res_id));
380 }
381 if let Some((key, value)) = identity {
382 query_items.push((key, value));
383 }
384
385 let mut builder = client
386 .request(Method::GET, &self.msi_endpoint)
387 .header("metadata", "true")
388 .query(&query_items);
389
390 if let Ok(val) = std::env::var(MSI_SECRET_ENV_KEY) {
391 builder = builder.header("x-identity-header", val);
392 };
393
394 let response: ImdsTokenResponse = builder
395 .send_retry(retry, service.clone())
396 .await
397 .map_err(|source| Error::TokenRequest { source })?
398 .json()
399 .await
400 .map_err(|source| Error::TokenResponseBody { source })?;
401
402 Ok(TemporaryToken {
403 token: Arc::new(AzureCredential::BearerToken(response.access_token)),
404 expiry: Some(response.expires_on),
405 })
406 }
407}
408
409#[derive(Debug)]
420pub(crate) struct WorkloadIdentityOAuthProvider {
421 token_url: String,
422 client_id: String,
423 federated_token_file: String,
424}
425
426impl WorkloadIdentityOAuthProvider {
427 pub(crate) fn new(
428 client_id: impl Into<String>,
429 federated_token_file: impl Into<String>,
430 tenant_id: impl AsRef<str>,
431 authority_host: Option<String>,
432 ) -> Self {
433 let authority_host =
434 authority_host.unwrap_or_else(|| authority_hosts::AZURE_PUBLIC_CLOUD.to_owned());
435
436 Self {
437 token_url: format!(
438 "{}/{}/oauth2/v2.0/token",
439 authority_host,
440 tenant_id.as_ref()
441 ),
442 client_id: client_id.into(),
443 federated_token_file: federated_token_file.into(),
444 }
445 }
446}
447
448#[async_trait::async_trait]
449impl TokenProvider for WorkloadIdentityOAuthProvider {
450 type Credential = AzureCredential;
451
452 async fn fetch_token(
463 &self,
464 client: &Client,
465 service: &Arc<dyn HttpService>,
466 retry: &RetryConfig,
467 ) -> crate::Result<TemporaryToken<Arc<AzureCredential>>> {
468 let token_str = std::fs::read_to_string(&self.federated_token_file)
469 .map_err(|_| Error::FederatedTokenFile)?;
470
471 let response: OAuthTokenResponse = client
472 .request(Method::POST, &self.token_url)
473 .header(ACCEPT, HeaderValue::from_static(CONTENT_TYPE_JSON))
474 .form(&[
475 ("client_id", self.client_id.as_str()),
476 (
477 "client_assertion_type",
478 "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
479 ),
480 ("client_assertion", token_str.as_str()),
481 ("scope", AZURE_STORAGE_SCOPE),
482 ("grant_type", "client_credentials"),
483 ])
484 .retryable(retry, service.clone())
485 .idempotent(true)
486 .send()
487 .await
488 .map_err(|source| Error::TokenRequest { source })?
489 .json()
490 .await
491 .map_err(|source| Error::TokenResponseBody { source })?;
492
493 Ok(TemporaryToken {
494 token: Arc::new(AzureCredential::BearerToken(response.access_token)),
495 expiry: Some(Instant::now() + Duration::from_secs(response.expires_in)),
496 })
497 }
498}
499
500mod az_cli_date_format {
501 use chrono::{DateTime, TimeZone};
502 use serde::{self, Deserialize, Deserializer};
503
504 pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<chrono::Local>, D::Error>
505 where
506 D: Deserializer<'de>,
507 {
508 let s = String::deserialize(deserializer)?;
509 let date = chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S.%6f")
510 .map_err(serde::de::Error::custom)?;
511 chrono::Local
512 .from_local_datetime(&date)
513 .single()
514 .ok_or(serde::de::Error::custom(
515 "azure cli returned ambiguous expiry date",
516 ))
517 }
518}
519
520#[derive(Debug, Clone, Deserialize)]
521#[serde(rename_all = "camelCase")]
522struct AzureCliTokenResponse {
523 pub access_token: String,
524 #[serde(with = "az_cli_date_format")]
525 pub expires_on: DateTime<chrono::Local>,
526 pub token_type: String,
527}
528
529#[derive(Default, Debug)]
530pub(crate) struct AzureCliCredential {
531 cache: TokenCache<Arc<AzureCredential>>,
532}
533
534impl AzureCliCredential {
535 pub(crate) fn new() -> Self {
536 Self::default()
537 }
538
539 async fn fetch_token(&self) -> Result<TemporaryToken<Arc<AzureCredential>>> {
540 let program = if cfg!(target_os = "windows") {
541 "cmd"
542 } else {
543 "az"
544 };
545 let mut args = Vec::new();
546 if cfg!(target_os = "windows") {
547 args.push("/C");
548 args.push("az");
549 }
550 args.push("account");
551 args.push("get-access-token");
552 args.push("--output");
553 args.push("json");
554 args.push("--scope");
555 args.push(AZURE_STORAGE_SCOPE);
556
557 match Command::new(program).args(args).output() {
558 Ok(az_output) if az_output.status.success() => {
559 let output = str::from_utf8(&az_output.stdout).map_err(|_| Error::AzureCli {
560 message: "az response is not a valid utf-8 string".to_string(),
561 })?;
562
563 let token_response = serde_json::from_str::<AzureCliTokenResponse>(output)
564 .map_err(|source| Error::AzureCliResponse { source })?;
565
566 if !token_response.token_type.eq_ignore_ascii_case("bearer") {
567 return Err(Error::AzureCli {
568 message: format!(
569 "got unexpected token type from azure cli: {0}",
570 token_response.token_type
571 ),
572 });
573 }
574 let duration =
575 token_response.expires_on.naive_local() - chrono::Local::now().naive_local();
576 Ok(TemporaryToken {
577 token: Arc::new(AzureCredential::BearerToken(token_response.access_token)),
578 expiry: Some(
579 Instant::now()
580 + duration.to_std().map_err(|_| Error::AzureCli {
581 message: "az returned invalid lifetime".to_string(),
582 })?,
583 ),
584 })
585 }
586 Ok(az_output) => {
587 let message = String::from_utf8_lossy(&az_output.stderr);
588 Err(Error::AzureCli {
589 message: message.into(),
590 })
591 }
592 Err(e) => match e.kind() {
593 std::io::ErrorKind::NotFound => Err(Error::AzureCli {
594 message: "Azure Cli not installed".into(),
595 }),
596 error_kind => Err(Error::AzureCli {
597 message: format!("io error: {error_kind:?}"),
598 }),
599 },
600 }
601 }
602}
603
604#[async_trait]
605impl CredentialProvider for AzureCliCredential {
606 type Credential = AzureCredential;
607
608 async fn get_credential(&self) -> crate::Result<Arc<Self::Credential>> {
609 Ok(self.cache.get_or_insert_with(|| self.fetch_token()).await?)
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use reqwest::Client;
616 use tempfile::NamedTempFile;
617
618 use super::*;
619 use crate::service::ReqwestService;
620 use mockito;
621
622 #[test]
623 fn test_debug_does_not_leak_secrets() {
624 let credential = AzureCredential::BearerToken("fake-token-do-not-print".to_string());
628 let rendered = format!("{credential:?}");
629 assert!(
630 !rendered.contains("fake-token-do-not-print"),
631 "bearer token leaked: {rendered}"
632 );
633 assert!(rendered.contains("<redacted>"));
634 }
635
636 #[tokio::test]
637 async fn test_managed_identity() {
638 let mut server = mockito::Server::new_async().await;
639
640 unsafe { std::env::set_var(MSI_SECRET_ENV_KEY, "env-secret") };
641
642 let endpoint = server.url();
643 let client = Client::new();
644 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
645 let retry_config = RetryConfig::default();
646
647 let _mock = server
648 .mock("GET", "/metadata/identity/oauth2/token")
649 .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
650 "client_id".into(),
651 "client_id".into(),
652 )]))
653 .match_header("x-identity-header", "env-secret")
654 .match_header("metadata", "true")
655 .with_status(200)
656 .with_body(
657 r#"
658 {
659 "access_token": "TOKEN",
660 "refresh_token": "",
661 "expires_in": "3599",
662 "expires_on": "1506484173",
663 "not_before": "1506480273",
664 "resource": "https://management.azure.com/",
665 "token_type": "Bearer"
666 }
667 "#,
668 )
669 .create_async()
670 .await;
671
672 let credential = ImdsManagedIdentityProvider::new(
673 Some("client_id".into()),
674 None,
675 None,
676 Some(format!("{endpoint}/metadata/identity/oauth2/token")),
677 );
678
679 let token = credential
680 .fetch_token(&client, &service, &retry_config)
681 .await
682 .unwrap();
683
684 assert_eq!(
685 token.token.as_ref(),
686 &AzureCredential::BearerToken("TOKEN".into())
687 );
688 }
689
690 #[tokio::test]
691 async fn test_workload_identity() {
692 let mut server = mockito::Server::new_async().await;
693 let tokenfile = NamedTempFile::new().unwrap();
694 let tenant = "tenant";
695 std::fs::write(tokenfile.path(), "federated-token").unwrap();
696
697 let endpoint = server.url();
698 let client = Client::new();
699 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
700 let retry_config = RetryConfig::default();
701
702 let _mock = server
703 .mock("POST", format!("/{tenant}/oauth2/v2.0/token").as_str())
704 .match_body(mockito::Matcher::Regex("federated-token".into()))
705 .with_status(200)
706 .with_body(
707 r#"
708 {
709 "access_token": "TOKEN",
710 "refresh_token": "",
711 "expires_in": 3599,
712 "expires_on": "1506484173",
713 "not_before": "1506480273",
714 "resource": "https://management.azure.com/",
715 "token_type": "Bearer"
716 }
717 "#,
718 )
719 .create_async()
720 .await;
721
722 let credential = WorkloadIdentityOAuthProvider::new(
723 "client_id",
724 tokenfile.path().to_str().unwrap(),
725 tenant,
726 Some(endpoint.to_string()),
727 );
728
729 let token = credential
730 .fetch_token(&client, &service, &retry_config)
731 .await
732 .unwrap();
733
734 assert_eq!(
735 token.token.as_ref(),
736 &AzureCredential::BearerToken("TOKEN".into())
737 );
738 }
739
740 #[tokio::test]
741 async fn test_client_secret_happy_path() {
742 let mut server = mockito::Server::new_async().await;
743 let tenant = "my-tenant";
744
745 let _mock = server
746 .mock("POST", format!("/{tenant}/oauth2/v2.0/token").as_str())
747 .match_body(mockito::Matcher::AllOf(vec![
748 mockito::Matcher::Regex("client_id=myclientid".into()),
749 mockito::Matcher::Regex("grant_type=client_credentials".into()),
750 ]))
751 .with_status(200)
752 .with_body(r#"{"access_token":"AZURE_TOKEN","expires_in":3599,"token_type":"Bearer"}"#)
753 .create_async()
754 .await;
755
756 let client = Client::new();
757 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
758 let retry_config = RetryConfig::default();
759
760 let provider = ClientSecretOAuthProvider::new_with_scope(
761 "myclientid".into(),
762 "myclientsecret".into(),
763 tenant,
764 Some(server.url()),
765 AZURE_STORAGE_SCOPE,
766 );
767
768 let token = provider
769 .fetch_token(&client, &service, &retry_config)
770 .await
771 .unwrap();
772
773 assert_eq!(
774 token.token.as_ref(),
775 &AzureCredential::BearerToken("AZURE_TOKEN".into())
776 );
777 assert!(token.expiry.is_some());
778
779 _mock.assert_async().await;
780 }
781
782 #[tokio::test]
783 async fn test_client_secret_invalid_client() {
784 let mut server = mockito::Server::new_async().await;
785 let tenant = "bad-tenant";
786
787 let _mock = server
788 .mock("POST", format!("/{tenant}/oauth2/v2.0/token").as_str())
789 .with_status(400)
790 .with_body(
791 r#"{"error":"invalid_client","error_description":"Client authentication failed"}"#,
792 )
793 .create_async()
794 .await;
795
796 let client = Client::new();
797 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
798 let retry_config = RetryConfig::default();
799
800 let provider = ClientSecretOAuthProvider::new_with_scope(
801 "badclientid".into(),
802 "badsecret".into(),
803 tenant,
804 Some(server.url()),
805 AZURE_STORAGE_SCOPE,
806 );
807
808 let result = provider.fetch_token(&client, &service, &retry_config).await;
809
810 assert!(result.is_err(), "Expected error for 400 response");
811
812 _mock.assert_async().await;
813 }
814
815 #[tokio::test]
816 async fn test_client_secret_token_refresh() {
817 let mut server = mockito::Server::new_async().await;
818 let tenant = "refresh-tenant";
819
820 let _mock1 = server
822 .mock("POST", format!("/{tenant}/oauth2/v2.0/token").as_str())
823 .with_status(200)
824 .with_body(r#"{"access_token":"FIRST_TOKEN","expires_in":1,"token_type":"Bearer"}"#)
825 .create_async()
826 .await;
827
828 let client = Client::new();
829 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
830 let retry_config = RetryConfig::default();
831
832 let provider = ClientSecretOAuthProvider::new_with_scope(
833 "myclientid".into(),
834 "myclientsecret".into(),
835 tenant,
836 Some(server.url()),
837 AZURE_STORAGE_SCOPE,
838 );
839
840 let token1 = provider
841 .fetch_token(&client, &service, &retry_config)
842 .await
843 .unwrap();
844 assert_eq!(
845 token1.token.as_ref(),
846 &AzureCredential::BearerToken("FIRST_TOKEN".into())
847 );
848 assert!(token1.expiry.is_some());
850
851 let _mock2 = server
853 .mock("POST", format!("/{tenant}/oauth2/v2.0/token").as_str())
854 .with_status(200)
855 .with_body(r#"{"access_token":"SECOND_TOKEN","expires_in":3600,"token_type":"Bearer"}"#)
856 .create_async()
857 .await;
858
859 let token2 = provider
860 .fetch_token(&client, &service, &retry_config)
861 .await
862 .unwrap();
863 assert_eq!(
864 token2.token.as_ref(),
865 &AzureCredential::BearerToken("SECOND_TOKEN".into())
866 );
867
868 _mock1.assert_async().await;
869 _mock2.assert_async().await;
870 }
871}