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