1use super::client::UserDelegationKey;
19use crate::RetryConfig;
20use crate::azure::STORE;
21use crate::client::builder::{HttpRequestBuilder, add_query_pairs};
22use crate::client::retry::RetryExt;
23use crate::client::token::{TemporaryToken, TokenCache};
24use crate::client::{
25 CredentialProvider, CryptoProvider, DigestAlgorithm, HttpClient, HttpError, HttpRequest,
26 TokenProvider, crypto_provider,
27};
28use async_trait::async_trait;
29use base64::Engine;
30use base64::prelude::{BASE64_STANDARD, BASE64_URL_SAFE_NO_PAD};
31use chrono::{DateTime, SecondsFormat, Utc};
32use http::Method;
33use http::header::{
34 ACCEPT, AUTHORIZATION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_LENGTH, CONTENT_TYPE, DATE,
35 HeaderMap, HeaderName, HeaderValue, IF_MATCH, IF_MODIFIED_SINCE, IF_NONE_MATCH,
36 IF_UNMODIFIED_SINCE, RANGE,
37};
38use serde::Deserialize;
39use std::borrow::Cow;
40use std::collections::HashMap;
41use std::fmt::Debug;
42use std::ops::Deref;
43use std::process::Command;
44use std::str;
45use std::sync::Arc;
46use std::time::{Duration, Instant, SystemTime};
47use url::Url;
48
49static AZURE_VERSION: HeaderValue = HeaderValue::from_static("2023-11-03");
50pub(crate) static VERSION: HeaderName = HeaderName::from_static("x-ms-version");
51pub(crate) static BLOB_TYPE: HeaderName = HeaderName::from_static("x-ms-blob-type");
52pub(crate) static COPY_SOURCE: HeaderName = HeaderName::from_static("x-ms-copy-source");
53static CONTENT_MD5: HeaderName = HeaderName::from_static("content-md5");
54static PARTNER_TOKEN: HeaderName = HeaderName::from_static("x-ms-partner-token");
55static CLUSTER_IDENTIFIER: HeaderName = HeaderName::from_static("x-ms-cluster-identifier");
56static WORKLOAD_RESOURCE: HeaderName = HeaderName::from_static("x-ms-workload-resource-moniker");
57static PROXY_HOST: HeaderName = HeaderName::from_static("x-ms-proxy-host");
58pub(crate) const RFC1123_FMT: &str = "%a, %d %h %Y %T GMT";
59const CONTENT_TYPE_JSON: &str = "application/json";
60const MSI_SECRET_ENV_KEY: &str = "IDENTITY_HEADER";
61const MSI_API_VERSION: &str = "2019-08-01";
62const TOKEN_MIN_TTL: u64 = 300;
63
64const AZURE_STORAGE_SCOPE: &str = "https://storage.azure.com/.default";
68
69const AZURE_STORAGE_RESOURCE: &str = "https://storage.azure.com";
73
74#[derive(Debug, thiserror::Error)]
75pub enum Error {
76 #[error("Error performing token request: {}", source)]
77 TokenRequest {
78 source: crate::client::retry::RetryError,
79 },
80
81 #[error("Error getting token response body: {}", source)]
82 TokenResponseBody { source: HttpError },
83
84 #[error("Error reading federated token file ")]
85 FederatedTokenFile,
86
87 #[error("Invalid Access Key: {}", source)]
88 InvalidAccessKey { source: base64::DecodeError },
89
90 #[error("'az account get-access-token' command failed: {message}")]
91 AzureCli { message: String },
92
93 #[error("Failed to parse azure cli response: {source}")]
94 AzureCliResponse { source: serde_json::Error },
95
96 #[error("Generating SAS keys with SAS tokens auth is not supported")]
97 SASforSASNotSupported,
98}
99
100pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
101
102impl From<Error> for crate::Error {
103 fn from(value: Error) -> Self {
104 Self::Generic {
105 store: STORE,
106 source: Box::new(value),
107 }
108 }
109}
110
111#[derive(Debug, Clone, Eq, PartialEq)]
113pub struct AzureAccessKey(Vec<u8>);
114
115impl AzureAccessKey {
116 pub fn try_new(key: &str) -> Result<Self> {
118 let key = BASE64_STANDARD
119 .decode(key)
120 .map_err(|source| Error::InvalidAccessKey { source })?;
121
122 Ok(Self(key))
123 }
124}
125
126#[derive(Debug, Eq, PartialEq)]
128pub enum AzureCredential {
129 AccessKey(AzureAccessKey),
133 SASToken(Vec<(String, String)>),
137 BearerToken(String),
141}
142
143impl AzureCredential {
144 pub fn sensitive_request(&self) -> bool {
146 match self {
147 Self::AccessKey(_) => false,
148 Self::BearerToken(_) => false,
149 Self::SASToken(_) => true,
151 }
152 }
153}
154
155pub mod authority_hosts {
157 pub const AZURE_CHINA: &str = "https://login.chinacloudapi.cn";
159 pub const AZURE_GERMANY: &str = "https://login.microsoftonline.de";
161 pub const AZURE_GOVERNMENT: &str = "https://login.microsoftonline.us";
163 pub const AZURE_PUBLIC_CLOUD: &str = "https://login.microsoftonline.com";
165}
166
167pub(crate) struct AzureSigner {
168 signing_key: AzureAccessKey,
169 start: DateTime<Utc>,
170 end: DateTime<Utc>,
171 account: String,
172 delegation_key: Option<UserDelegationKey>,
173}
174
175impl AzureSigner {
176 pub(crate) fn new(
177 signing_key: AzureAccessKey,
178 account: String,
179 start: DateTime<Utc>,
180 end: DateTime<Utc>,
181 delegation_key: Option<UserDelegationKey>,
182 ) -> Self {
183 Self {
184 signing_key,
185 account,
186 start,
187 end,
188 delegation_key,
189 }
190 }
191
192 pub(crate) fn sign(
193 &self,
194 crypto: &dyn CryptoProvider,
195 method: &Method,
196 url: &mut Url,
197 ) -> crate::Result<()> {
198 let (str_to_sign, query_pairs) = match &self.delegation_key {
199 Some(delegation_key) => string_to_sign_user_delegation_sas(
200 url,
201 method,
202 &self.account,
203 &self.start,
204 &self.end,
205 delegation_key,
206 ),
207 None => string_to_sign_service_sas(url, method, &self.account, &self.start, &self.end),
208 };
209 let mut ctx = crypto.hmac(DigestAlgorithm::Sha256, &self.signing_key.0)?;
210 ctx.update(str_to_sign.as_bytes());
211 let auth = ctx.finish()?;
212
213 url.query_pairs_mut().extend_pairs(query_pairs);
214 url.query_pairs_mut()
215 .append_pair("sig", BASE64_STANDARD.encode(auth).as_str());
216 Ok(())
217 }
218}
219
220fn add_date_and_version_headers(request: &mut HttpRequest) {
221 let date = Utc::now();
223 let date_str = date.format(RFC1123_FMT).to_string();
224 let date_val = HeaderValue::from_str(&date_str).unwrap();
226 request.headers_mut().insert(DATE, date_val);
227 if !request.headers().contains_key(&VERSION) {
230 request
231 .headers_mut()
232 .insert(&VERSION, AZURE_VERSION.clone());
233 }
234}
235
236#[derive(Debug)]
238pub struct AzureAuthorizer<'a> {
239 credential: &'a AzureCredential,
240 crypto: Option<&'a dyn CryptoProvider>,
241 account: &'a str,
242}
243
244impl<'a> AzureAuthorizer<'a> {
245 pub fn new(credential: &'a AzureCredential, account: &'a str) -> Self {
247 AzureAuthorizer {
248 credential,
249 account,
250 crypto: None,
251 }
252 }
253
254 pub fn with_crypto(mut self, crypto: &'a dyn CryptoProvider) -> Self {
256 self.crypto = Some(crypto);
257 self
258 }
259
260 fn crypto(&self) -> crate::Result<&dyn CryptoProvider> {
261 crypto_provider(self.crypto)
262 }
263
264 #[deprecated(note = "use AzureAuthorizer::try_authorize")]
270 pub fn authorize(&self, request: &mut HttpRequest) {
271 self.try_authorize(request).unwrap()
272 }
273
274 pub fn try_authorize(&self, request: &mut HttpRequest) -> crate::Result<()> {
276 add_date_and_version_headers(request);
277
278 match self.credential {
279 AzureCredential::AccessKey(key) => {
280 let crypto = self.crypto()?;
281 let url = Url::parse(&request.uri().to_string()).unwrap();
282 let signature = generate_authorization(
283 crypto,
284 request.headers(),
285 &url,
286 request.method(),
287 self.account,
288 key,
289 )?;
290
291 request.headers_mut().append(
294 AUTHORIZATION,
295 HeaderValue::from_str(signature.as_str()).unwrap(),
296 );
297 }
298 AzureCredential::BearerToken(token) => {
299 request.headers_mut().append(
300 AUTHORIZATION,
301 HeaderValue::from_str(format!("Bearer {token}").as_str()).unwrap(),
302 );
303 }
304 AzureCredential::SASToken(query_pairs) => {
305 add_query_pairs(request.uri_mut(), query_pairs);
306 }
307 }
308 Ok(())
309 }
310}
311
312pub(crate) trait CredentialExt: Sized {
313 fn with_azure_authorization(
316 self,
317 crypto: Option<&dyn CryptoProvider>,
318 credential: &Option<impl Deref<Target = AzureCredential>>,
319 account: &str,
320 ) -> crate::Result<Self>;
321}
322
323impl CredentialExt for HttpRequestBuilder {
324 fn with_azure_authorization(
325 self,
326 crypto: Option<&dyn CryptoProvider>,
327 credential: &Option<impl Deref<Target = AzureCredential>>,
328 account: &str,
329 ) -> crate::Result<Self> {
330 let (client, request) = self.into_parts();
331 let mut request = request.expect("request valid");
332
333 match credential.as_deref() {
334 Some(credential) => {
335 let mut authorizer = AzureAuthorizer::new(credential, account);
336 if let Some(crypto) = crypto {
337 authorizer = authorizer.with_crypto(crypto);
338 }
339 authorizer.try_authorize(&mut request)?;
340 }
341 None => {
342 add_date_and_version_headers(&mut request);
343 }
344 }
345
346 Ok(Self::from_parts(client, request))
347 }
348}
349
350fn generate_authorization(
353 crypto: &dyn CryptoProvider,
354 h: &HeaderMap,
355 u: &Url,
356 method: &Method,
357 account: &str,
358 key: &AzureAccessKey,
359) -> crate::Result<String> {
360 let str_to_sign = string_to_sign(h, u, method, account);
361 let mut ctx = crypto.hmac(DigestAlgorithm::Sha256, &key.0)?;
362 ctx.update(str_to_sign.as_bytes());
363 let auth = ctx.finish()?;
364 Ok(format!(
365 "SharedKey {}:{}",
366 account,
367 BASE64_STANDARD.encode(auth)
368 ))
369}
370
371fn add_if_exists<'a>(h: &'a HeaderMap, key: &HeaderName) -> &'a str {
372 h.get(key)
373 .map(|s| s.to_str())
374 .transpose()
375 .ok()
376 .flatten()
377 .unwrap_or_default()
378}
379
380fn string_to_sign_sas(
381 u: &Url,
382 method: &Method,
383 account: &str,
384 start: &DateTime<Utc>,
385 end: &DateTime<Utc>,
386) -> (String, String, String, String, String) {
387 let signed_resource = "b".to_string();
389
390 let signed_permissions = match *method {
392 Method::GET => match signed_resource.as_str() {
394 "c" => "rl",
395 "b" => "r",
396 _ => unreachable!(),
397 },
398 Method::PUT => "w",
400 Method::DELETE => "d",
402 _ => "",
404 }
405 .to_string();
406 let signed_start = start.to_rfc3339_opts(SecondsFormat::Secs, true);
407 let signed_expiry = end.to_rfc3339_opts(SecondsFormat::Secs, true);
408 let canonicalized_resource = if u.host_str().unwrap_or_default().contains(account) {
409 format!("/blob/{}{}", account, u.path())
410 } else {
411 format!("/blob{}", u.path())
414 };
415
416 (
417 signed_resource,
418 signed_permissions,
419 signed_start,
420 signed_expiry,
421 canonicalized_resource,
422 )
423}
424
425fn string_to_sign_service_sas(
429 u: &Url,
430 method: &Method,
431 account: &str,
432 start: &DateTime<Utc>,
433 end: &DateTime<Utc>,
434) -> (String, HashMap<&'static str, String>) {
435 let (signed_resource, signed_permissions, signed_start, signed_expiry, canonicalized_resource) =
436 string_to_sign_sas(u, method, account, start, end);
437
438 let string_to_sign = format!(
439 "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
440 signed_permissions,
441 signed_start,
442 signed_expiry,
443 canonicalized_resource,
444 "", "", "", AZURE_VERSION.to_str().unwrap(), signed_resource, "", "", "", "", "", "", "", );
457
458 let mut pairs = HashMap::new();
459 pairs.insert("sv", AZURE_VERSION.to_str().unwrap().to_string());
460 pairs.insert("sp", signed_permissions);
461 pairs.insert("st", signed_start);
462 pairs.insert("se", signed_expiry);
463 pairs.insert("sr", signed_resource);
464
465 (string_to_sign, pairs)
466}
467
468fn string_to_sign_user_delegation_sas(
472 u: &Url,
473 method: &Method,
474 account: &str,
475 start: &DateTime<Utc>,
476 end: &DateTime<Utc>,
477 delegation_key: &UserDelegationKey,
478) -> (String, HashMap<&'static str, String>) {
479 let (signed_resource, signed_permissions, signed_start, signed_expiry, canonicalized_resource) =
480 string_to_sign_sas(u, method, account, start, end);
481
482 let string_to_sign = format!(
483 "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
484 signed_permissions,
485 signed_start,
486 signed_expiry,
487 canonicalized_resource,
488 delegation_key.signed_oid, delegation_key.signed_tid, delegation_key.signed_start, delegation_key.signed_expiry, delegation_key.signed_service, delegation_key.signed_version, "", "", "", "", "", AZURE_VERSION.to_str().unwrap(), signed_resource, "", "", "", "", "", "", "", );
509
510 let mut pairs = HashMap::new();
511 pairs.insert("sv", AZURE_VERSION.to_str().unwrap().to_string());
512 pairs.insert("sp", signed_permissions);
513 pairs.insert("st", signed_start);
514 pairs.insert("se", signed_expiry);
515 pairs.insert("sr", signed_resource);
516 pairs.insert("skoid", delegation_key.signed_oid.clone());
517 pairs.insert("sktid", delegation_key.signed_tid.clone());
518 pairs.insert("skt", delegation_key.signed_start.clone());
519 pairs.insert("ske", delegation_key.signed_expiry.clone());
520 pairs.insert("sks", delegation_key.signed_service.clone());
521 pairs.insert("skv", delegation_key.signed_version.clone());
522
523 (string_to_sign, pairs)
524}
525
526fn string_to_sign(h: &HeaderMap, u: &Url, method: &Method, account: &str) -> String {
528 let content_length = h
531 .get(&CONTENT_LENGTH)
532 .map(|s| s.to_str())
533 .transpose()
534 .ok()
535 .flatten()
536 .filter(|&v| v != "0")
537 .unwrap_or_default();
538 format!(
539 "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}{}",
540 method.as_ref(),
541 add_if_exists(h, &CONTENT_ENCODING),
542 add_if_exists(h, &CONTENT_LANGUAGE),
543 content_length,
544 add_if_exists(h, &CONTENT_MD5),
545 add_if_exists(h, &CONTENT_TYPE),
546 add_if_exists(h, &DATE),
547 add_if_exists(h, &IF_MODIFIED_SINCE),
548 add_if_exists(h, &IF_MATCH),
549 add_if_exists(h, &IF_NONE_MATCH),
550 add_if_exists(h, &IF_UNMODIFIED_SINCE),
551 add_if_exists(h, &RANGE),
552 canonicalize_header(h),
553 canonicalize_resource(account, u)
554 )
555}
556
557fn canonicalize_header(headers: &HeaderMap) -> String {
559 let mut names = headers
560 .iter()
561 .filter(|&(k, _)| k.as_str().starts_with("x-ms"))
562 .map(|(k, _)| (k.as_str(), headers.get(k).unwrap().to_str().unwrap()))
564 .collect::<Vec<_>>();
565 names.sort_unstable();
566
567 let mut result = String::new();
568 for (name, value) in names {
569 result.push_str(name);
570 result.push(':');
571 result.push_str(value);
572 result.push('\n');
573 }
574 result
575}
576
577fn canonicalize_resource(account: &str, uri: &Url) -> String {
579 let mut can_res: String = String::new();
580 can_res.push('/');
581 can_res.push_str(account);
582 can_res.push_str(uri.path().to_string().as_str());
583 can_res.push('\n');
584
585 let query_pairs = uri.query_pairs();
587 {
588 let mut qps: Vec<String> = Vec::new();
589 for (q, _) in query_pairs {
590 if !(qps.iter().any(|x| x == &*q)) {
591 qps.push(q.into_owned());
592 }
593 }
594
595 qps.sort();
596
597 for qparam in qps {
598 let ret = lexy_sort(query_pairs, &qparam);
600
601 can_res = can_res + &qparam.to_lowercase() + ":";
602
603 for (i, item) in ret.iter().enumerate() {
604 if i > 0 {
605 can_res.push(',');
606 }
607 can_res.push_str(item);
608 }
609
610 can_res.push('\n');
611 }
612 };
613
614 can_res[0..can_res.len() - 1].to_owned()
615}
616
617fn lexy_sort<'a>(
618 vec: impl Iterator<Item = (Cow<'a, str>, Cow<'a, str>)> + 'a,
619 query_param: &str,
620) -> Vec<Cow<'a, str>> {
621 let mut values = vec
622 .filter(|(k, _)| *k == query_param)
623 .map(|(_, v)| v)
624 .collect::<Vec<_>>();
625 values.sort_unstable();
626 values
627}
628
629#[derive(Deserialize, Debug)]
631struct OAuthTokenResponse {
632 access_token: String,
633 expires_in: u64,
634}
635
636#[derive(Debug)]
640pub(crate) struct ClientSecretOAuthProvider {
641 token_url: String,
642 client_id: String,
643 client_secret: String,
644}
645
646impl ClientSecretOAuthProvider {
647 pub(crate) fn new(
649 client_id: String,
650 client_secret: String,
651 tenant_id: impl AsRef<str>,
652 authority_host: Option<String>,
653 ) -> Self {
654 let authority_host =
655 authority_host.unwrap_or_else(|| authority_hosts::AZURE_PUBLIC_CLOUD.to_owned());
656
657 Self {
658 token_url: format!(
659 "{}/{}/oauth2/v2.0/token",
660 authority_host,
661 tenant_id.as_ref()
662 ),
663 client_id,
664 client_secret,
665 }
666 }
667}
668
669#[async_trait::async_trait]
670impl TokenProvider for ClientSecretOAuthProvider {
671 type Credential = AzureCredential;
672
673 async fn fetch_token(
675 &self,
676 client: &HttpClient,
677 retry: &RetryConfig,
678 ) -> crate::Result<TemporaryToken<Arc<AzureCredential>>> {
679 let response: OAuthTokenResponse = client
680 .request(Method::POST, &self.token_url)
681 .header(ACCEPT, HeaderValue::from_static(CONTENT_TYPE_JSON))
682 .form([
683 ("client_id", self.client_id.as_str()),
684 ("client_secret", self.client_secret.as_str()),
685 ("scope", AZURE_STORAGE_SCOPE),
686 ("grant_type", "client_credentials"),
687 ])
688 .retryable(retry)
689 .idempotent(true)
690 .send()
691 .await
692 .map_err(|source| Error::TokenRequest { source })?
693 .into_body()
694 .json()
695 .await
696 .map_err(|source| Error::TokenResponseBody { source })?;
697
698 Ok(TemporaryToken {
699 token: Arc::new(AzureCredential::BearerToken(response.access_token)),
700 expiry: Some(Instant::now() + Duration::from_secs(response.expires_in)),
701 })
702 }
703}
704
705fn expires_on_string<'de, D>(deserializer: D) -> std::result::Result<Instant, D::Error>
706where
707 D: serde::de::Deserializer<'de>,
708{
709 let v = String::deserialize(deserializer)?;
710 let v = v.parse::<u64>().map_err(serde::de::Error::custom)?;
711 let now = SystemTime::now()
712 .duration_since(SystemTime::UNIX_EPOCH)
713 .map_err(serde::de::Error::custom)?;
714
715 Ok(Instant::now() + Duration::from_secs(v.saturating_sub(now.as_secs())))
716}
717
718#[derive(Debug, Clone, Deserialize)]
722struct ImdsTokenResponse {
723 pub access_token: String,
724 #[serde(deserialize_with = "expires_on_string")]
725 pub expires_on: Instant,
726}
727
728#[derive(Debug)]
733pub(crate) struct ImdsManagedIdentityProvider {
734 msi_endpoint: String,
735 client_id: Option<String>,
736 object_id: Option<String>,
737 msi_res_id: Option<String>,
738}
739
740impl ImdsManagedIdentityProvider {
741 pub(crate) fn new(
743 client_id: Option<String>,
744 object_id: Option<String>,
745 msi_res_id: Option<String>,
746 msi_endpoint: Option<String>,
747 ) -> Self {
748 let msi_endpoint = msi_endpoint
749 .unwrap_or_else(|| "http://169.254.169.254/metadata/identity/oauth2/token".to_owned());
750
751 Self {
752 msi_endpoint,
753 client_id,
754 object_id,
755 msi_res_id,
756 }
757 }
758}
759
760#[async_trait::async_trait]
761impl TokenProvider for ImdsManagedIdentityProvider {
762 type Credential = AzureCredential;
763
764 async fn fetch_token(
766 &self,
767 client: &HttpClient,
768 retry: &RetryConfig,
769 ) -> crate::Result<TemporaryToken<Arc<AzureCredential>>> {
770 let mut query_items = vec![
771 ("api-version", MSI_API_VERSION),
772 ("resource", AZURE_STORAGE_RESOURCE),
773 ];
774
775 let mut identity = None;
776 if let Some(client_id) = &self.client_id {
777 identity = Some(("client_id", client_id));
778 }
779 if let Some(object_id) = &self.object_id {
780 identity = Some(("object_id", object_id));
781 }
782 if let Some(msi_res_id) = &self.msi_res_id {
783 identity = Some(("msi_res_id", msi_res_id));
784 }
785 if let Some((key, value)) = identity {
786 query_items.push((key, value));
787 }
788
789 let mut builder = client
790 .request(Method::GET, &self.msi_endpoint)
791 .header("metadata", "true")
792 .query(&query_items);
793
794 if let Ok(val) = std::env::var(MSI_SECRET_ENV_KEY) {
795 builder = builder.header("x-identity-header", val);
796 };
797
798 let response: ImdsTokenResponse = builder
799 .send_retry(retry)
800 .await
801 .map_err(|source| Error::TokenRequest { source })?
802 .into_body()
803 .json()
804 .await
805 .map_err(|source| Error::TokenResponseBody { source })?;
806
807 Ok(TemporaryToken {
808 token: Arc::new(AzureCredential::BearerToken(response.access_token)),
809 expiry: Some(response.expires_on),
810 })
811 }
812}
813
814#[derive(Debug)]
818pub(crate) struct WorkloadIdentityOAuthProvider {
819 token_url: String,
820 client_id: String,
821 federated_token_file: String,
822}
823
824impl WorkloadIdentityOAuthProvider {
825 pub(crate) fn new(
827 client_id: impl Into<String>,
828 federated_token_file: impl Into<String>,
829 tenant_id: impl AsRef<str>,
830 authority_host: Option<String>,
831 ) -> Self {
832 let authority_host =
833 authority_host.unwrap_or_else(|| authority_hosts::AZURE_PUBLIC_CLOUD.to_owned());
834
835 Self {
836 token_url: format!(
837 "{}/{}/oauth2/v2.0/token",
838 authority_host,
839 tenant_id.as_ref()
840 ),
841 client_id: client_id.into(),
842 federated_token_file: federated_token_file.into(),
843 }
844 }
845}
846
847#[async_trait::async_trait]
848impl TokenProvider for WorkloadIdentityOAuthProvider {
849 type Credential = AzureCredential;
850
851 async fn fetch_token(
853 &self,
854 client: &HttpClient,
855 retry: &RetryConfig,
856 ) -> crate::Result<TemporaryToken<Arc<AzureCredential>>> {
857 let token_str = std::fs::read_to_string(&self.federated_token_file)
858 .map_err(|_| Error::FederatedTokenFile)?;
859
860 let response: OAuthTokenResponse = client
862 .request(Method::POST, &self.token_url)
863 .header(ACCEPT, HeaderValue::from_static(CONTENT_TYPE_JSON))
864 .form([
865 ("client_id", self.client_id.as_str()),
866 (
867 "client_assertion_type",
868 "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
869 ),
870 ("client_assertion", token_str.as_str()),
871 ("scope", AZURE_STORAGE_SCOPE),
872 ("grant_type", "client_credentials"),
873 ])
874 .retryable(retry)
875 .idempotent(true)
876 .send()
877 .await
878 .map_err(|source| Error::TokenRequest { source })?
879 .into_body()
880 .json()
881 .await
882 .map_err(|source| Error::TokenResponseBody { source })?;
883
884 Ok(TemporaryToken {
885 token: Arc::new(AzureCredential::BearerToken(response.access_token)),
886 expiry: Some(Instant::now() + Duration::from_secs(response.expires_in)),
887 })
888 }
889}
890
891mod az_cli_date_format {
892 use chrono::{DateTime, TimeZone};
893 use serde::{self, Deserialize, Deserializer};
894
895 pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<chrono::Local>, D::Error>
896 where
897 D: Deserializer<'de>,
898 {
899 let s = String::deserialize(deserializer)?;
900 let date = chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S.%6f")
902 .map_err(serde::de::Error::custom)?;
903 chrono::Local
904 .from_local_datetime(&date)
905 .single()
906 .ok_or(serde::de::Error::custom(
907 "azure cli returned ambiguous expiry date",
908 ))
909 }
910}
911
912#[derive(Debug, Clone, Deserialize)]
913#[serde(rename_all = "camelCase")]
914struct AzureCliTokenResponse {
915 pub access_token: String,
916 #[serde(with = "az_cli_date_format")]
917 pub expires_on: DateTime<chrono::Local>,
918 pub token_type: String,
919}
920
921#[derive(Default, Debug)]
922pub(crate) struct AzureCliCredential {
923 cache: TokenCache<Arc<AzureCredential>>,
924}
925
926impl AzureCliCredential {
927 pub(crate) fn new() -> Self {
928 Self::default()
929 }
930
931 async fn fetch_token(&self) -> Result<TemporaryToken<Arc<AzureCredential>>> {
933 let program = if cfg!(target_os = "windows") {
936 "cmd"
937 } else {
938 "az"
939 };
940 let mut args = Vec::new();
941 if cfg!(target_os = "windows") {
942 args.push("/C");
943 args.push("az");
944 }
945 args.push("account");
946 args.push("get-access-token");
947 args.push("--output");
948 args.push("json");
949 args.push("--scope");
950 args.push(AZURE_STORAGE_SCOPE);
951
952 match Command::new(program).args(args).output() {
953 Ok(az_output) if az_output.status.success() => {
954 let output = str::from_utf8(&az_output.stdout).map_err(|_| Error::AzureCli {
955 message: "az response is not a valid utf-8 string".to_string(),
956 })?;
957
958 let token_response = serde_json::from_str::<AzureCliTokenResponse>(output)
959 .map_err(|source| Error::AzureCliResponse { source })?;
960
961 if !token_response.token_type.eq_ignore_ascii_case("bearer") {
962 return Err(Error::AzureCli {
963 message: format!(
964 "got unexpected token type from azure cli: {0}",
965 token_response.token_type
966 ),
967 });
968 }
969 let duration =
970 token_response.expires_on.naive_local() - chrono::Local::now().naive_local();
971 Ok(TemporaryToken {
972 token: Arc::new(AzureCredential::BearerToken(token_response.access_token)),
973 expiry: Some(
974 Instant::now()
975 + duration.to_std().map_err(|_| Error::AzureCli {
976 message: "az returned invalid lifetime".to_string(),
977 })?,
978 ),
979 })
980 }
981 Ok(az_output) => {
982 let message = String::from_utf8_lossy(&az_output.stderr);
983 Err(Error::AzureCli {
984 message: message.into(),
985 })
986 }
987 Err(e) => match e.kind() {
988 std::io::ErrorKind::NotFound => Err(Error::AzureCli {
989 message: "Azure Cli not installed".into(),
990 }),
991 error_kind => Err(Error::AzureCli {
992 message: format!("io error: {error_kind:?}"),
993 }),
994 },
995 }
996 }
997}
998
999#[derive(Debug)]
1001pub(crate) struct FabricTokenOAuthProvider {
1002 fabric_token_service_url: String,
1003 fabric_workload_host: String,
1004 fabric_session_token: String,
1005 fabric_cluster_identifier: String,
1006 storage_access_token: Option<String>,
1007 token_expiry: Option<u64>,
1008}
1009
1010#[derive(Debug, Deserialize)]
1011struct Claims {
1012 exp: u64,
1013}
1014
1015impl FabricTokenOAuthProvider {
1016 pub(crate) fn new(
1018 fabric_token_service_url: impl Into<String>,
1019 fabric_workload_host: impl Into<String>,
1020 fabric_session_token: impl Into<String>,
1021 fabric_cluster_identifier: impl Into<String>,
1022 storage_access_token: Option<String>,
1023 ) -> Self {
1024 let (storage_access_token, token_expiry) = match storage_access_token {
1025 Some(token) => match Self::validate_and_get_expiry(&token) {
1026 Some(expiry) if expiry > Self::get_current_timestamp() + TOKEN_MIN_TTL => {
1027 (Some(token), Some(expiry))
1028 }
1029 _ => (None, None),
1030 },
1031 None => (None, None),
1032 };
1033
1034 Self {
1035 fabric_token_service_url: fabric_token_service_url.into(),
1036 fabric_workload_host: fabric_workload_host.into(),
1037 fabric_session_token: fabric_session_token.into(),
1038 fabric_cluster_identifier: fabric_cluster_identifier.into(),
1039 storage_access_token,
1040 token_expiry,
1041 }
1042 }
1043
1044 fn validate_and_get_expiry(token: &str) -> Option<u64> {
1045 let payload = token.split('.').nth(1)?;
1046 let decoded_bytes = BASE64_URL_SAFE_NO_PAD.decode(payload).ok()?;
1047 let decoded_str = str::from_utf8(&decoded_bytes).ok()?;
1048 let claims: Claims = serde_json::from_str(decoded_str).ok()?;
1049 Some(claims.exp)
1050 }
1051
1052 fn get_current_timestamp() -> u64 {
1053 SystemTime::now()
1054 .duration_since(SystemTime::UNIX_EPOCH)
1055 .map_or(0, |d| d.as_secs())
1056 }
1057}
1058
1059#[async_trait::async_trait]
1060impl TokenProvider for FabricTokenOAuthProvider {
1061 type Credential = AzureCredential;
1062
1063 async fn fetch_token(
1065 &self,
1066 client: &HttpClient,
1067 retry: &RetryConfig,
1068 ) -> crate::Result<TemporaryToken<Arc<AzureCredential>>> {
1069 if let Some(storage_access_token) = &self.storage_access_token {
1070 if let Some(expiry) = self.token_expiry {
1071 let exp_in = expiry.saturating_sub(Self::get_current_timestamp());
1072 if exp_in > TOKEN_MIN_TTL {
1073 return Ok(TemporaryToken {
1074 token: Arc::new(AzureCredential::BearerToken(storage_access_token.clone())),
1075 expiry: Some(Instant::now() + Duration::from_secs(exp_in)),
1076 });
1077 }
1078 }
1079 }
1080
1081 let query_items = vec![("resource", AZURE_STORAGE_RESOURCE)];
1082 let access_token: String = client
1083 .request(Method::GET, &self.fabric_token_service_url)
1084 .header(&PARTNER_TOKEN, self.fabric_session_token.as_str())
1085 .header(&CLUSTER_IDENTIFIER, self.fabric_cluster_identifier.as_str())
1086 .header(&WORKLOAD_RESOURCE, self.fabric_cluster_identifier.as_str())
1087 .header(&PROXY_HOST, self.fabric_workload_host.as_str())
1088 .query(&query_items)
1089 .retryable(retry)
1090 .idempotent(true)
1091 .send()
1092 .await
1093 .map_err(|source| Error::TokenRequest { source })?
1094 .into_body()
1095 .text()
1096 .await
1097 .map_err(|source| Error::TokenResponseBody { source })?;
1098 let exp_in = Self::validate_and_get_expiry(&access_token).map_or(3600, |expiry| {
1099 expiry.saturating_sub(Self::get_current_timestamp())
1100 });
1101 Ok(TemporaryToken {
1102 token: Arc::new(AzureCredential::BearerToken(access_token)),
1103 expiry: Some(Instant::now() + Duration::from_secs(exp_in)),
1104 })
1105 }
1106}
1107
1108#[async_trait]
1109impl CredentialProvider for AzureCliCredential {
1110 type Credential = AzureCredential;
1111
1112 async fn get_credential(&self) -> crate::Result<Arc<Self::Credential>> {
1113 Ok(self.cache.get_or_insert_with(|| self.fetch_token()).await?)
1114 }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use futures_executor::block_on;
1120 use http::{Response, StatusCode};
1121 use http_body_util::BodyExt;
1122 use reqwest::{Client, Method};
1123 use tempfile::NamedTempFile;
1124
1125 use super::*;
1126 use crate::azure::MicrosoftAzureBuilder;
1127 use crate::azure::client::RequestVersionExt;
1128 use crate::client::HttpRequestBody;
1129 use crate::client::mock_server::MockServer;
1130 use crate::{ObjectStoreExt, Path};
1131
1132 #[cfg(feature = "reqwest")]
1133 #[tokio::test]
1134 async fn test_managed_identity() {
1135 let server = MockServer::new().await;
1136
1137 unsafe { std::env::set_var(MSI_SECRET_ENV_KEY, "env-secret") };
1138
1139 let endpoint = server.url();
1140 let client = HttpClient::new(Client::new());
1141 let retry_config = RetryConfig::default();
1142
1143 server.push_fn(|req| {
1145 assert_eq!(req.uri().path(), "/metadata/identity/oauth2/token");
1146 assert!(req.uri().query().unwrap().contains("client_id=client_id"));
1147 assert_eq!(req.method(), &Method::GET);
1148 let t = req
1149 .headers()
1150 .get("x-identity-header")
1151 .unwrap()
1152 .to_str()
1153 .unwrap();
1154 assert_eq!(t, "env-secret");
1155 let t = req.headers().get("metadata").unwrap().to_str().unwrap();
1156 assert_eq!(t, "true");
1157 Response::new(
1158 r#"
1159 {
1160 "access_token": "TOKEN",
1161 "refresh_token": "",
1162 "expires_in": "3599",
1163 "expires_on": "1506484173",
1164 "not_before": "1506480273",
1165 "resource": "https://management.azure.com/",
1166 "token_type": "Bearer"
1167 }
1168 "#
1169 .to_string(),
1170 )
1171 });
1172
1173 let credential = ImdsManagedIdentityProvider::new(
1174 Some("client_id".into()),
1175 None,
1176 None,
1177 Some(format!("{endpoint}/metadata/identity/oauth2/token")),
1178 );
1179
1180 let token = credential
1181 .fetch_token(&client, &retry_config)
1182 .await
1183 .unwrap();
1184
1185 assert_eq!(
1186 token.token.as_ref(),
1187 &AzureCredential::BearerToken("TOKEN".into())
1188 );
1189 }
1190
1191 #[cfg(feature = "reqwest")]
1192 #[tokio::test]
1193 async fn test_workload_identity() {
1194 let server = MockServer::new().await;
1195 let tokenfile = NamedTempFile::new().unwrap();
1196 let tenant = "tenant";
1197 std::fs::write(tokenfile.path(), "federated-token").unwrap();
1198
1199 let endpoint = server.url();
1200 let client = HttpClient::new(Client::new());
1201 let retry_config = RetryConfig::default();
1202
1203 server.push_fn(move |req| {
1205 assert_eq!(req.uri().path(), format!("/{tenant}/oauth2/v2.0/token"));
1206 assert_eq!(req.method(), &Method::POST);
1207 let body = block_on(async move { req.into_body().collect().await.unwrap().to_bytes() });
1208 let body = String::from_utf8(body.to_vec()).unwrap();
1209 assert!(body.contains("federated-token"));
1210 Response::new(
1211 r#"
1212 {
1213 "access_token": "TOKEN",
1214 "refresh_token": "",
1215 "expires_in": 3599,
1216 "expires_on": "1506484173",
1217 "not_before": "1506480273",
1218 "resource": "https://management.azure.com/",
1219 "token_type": "Bearer"
1220 }
1221 "#
1222 .to_string(),
1223 )
1224 });
1225
1226 let credential = WorkloadIdentityOAuthProvider::new(
1227 "client_id",
1228 tokenfile.path().to_str().unwrap(),
1229 tenant,
1230 Some(endpoint.to_string()),
1231 );
1232
1233 let token = credential
1234 .fetch_token(&client, &retry_config)
1235 .await
1236 .unwrap();
1237
1238 assert_eq!(
1239 token.token.as_ref(),
1240 &AzureCredential::BearerToken("TOKEN".into())
1241 );
1242 }
1243
1244 #[cfg(feature = "reqwest")]
1245 #[tokio::test]
1246 async fn test_no_credentials() {
1247 let server = MockServer::new().await;
1248
1249 let endpoint = server.url();
1250 let store = MicrosoftAzureBuilder::new()
1251 .with_account("test")
1252 .with_container_name("test")
1253 .with_allow_http(true)
1254 .with_bearer_token_authorization("token")
1255 .with_endpoint(endpoint.to_string())
1256 .with_skip_signature(true)
1257 .build()
1258 .unwrap();
1259
1260 server.push_fn(|req| {
1261 assert_eq!(req.method(), &Method::GET);
1262 assert!(req.headers().get("Authorization").is_none());
1263 Response::builder()
1264 .status(StatusCode::NOT_FOUND)
1265 .body("not found".to_string())
1266 .unwrap()
1267 });
1268
1269 let path = Path::from("file.txt");
1270 match store.get(&path).await {
1271 Err(crate::Error::NotFound { .. }) => {}
1272 _ => {
1273 panic!("unexpected response");
1274 }
1275 }
1276 }
1277
1278 #[test]
1279 fn bearer_authorization_does_not_require_crypto_provider() {
1280 let credential = Some(Arc::new(AzureCredential::BearerToken("TOKEN".into())));
1281 let builder = HttpRequestBuilder::new(HttpClient::new(Client::new()))
1282 .method(Method::GET)
1283 .uri("https://account.blob.core.windows.net/container/file.txt")
1284 .body(HttpRequestBody::empty())
1285 .with_azure_authorization(None, &credential, "account")
1286 .unwrap();
1287
1288 let (_, request) = builder.into_parts();
1289 let request = request.unwrap();
1290 assert_eq!(
1291 request.headers().get(AUTHORIZATION).unwrap(),
1292 "Bearer TOKEN"
1293 );
1294 assert!(request.headers().contains_key(&DATE));
1295 assert!(request.headers().contains_key(&VERSION));
1296 }
1297
1298 #[test]
1299 fn sas_authorization_does_not_require_crypto_provider() {
1300 let credential = Some(Arc::new(AzureCredential::SASToken(vec![
1301 ("sig".into(), "signature".into()),
1302 ("se".into(), "expiry".into()),
1303 ])));
1304 let builder = HttpRequestBuilder::new(HttpClient::new(Client::new()))
1305 .method(Method::GET)
1306 .uri("https://account.blob.core.windows.net/container/file.txt")
1307 .body(HttpRequestBody::empty())
1308 .with_azure_authorization(None, &credential, "account")
1309 .unwrap();
1310
1311 let (_, request) = builder.into_parts();
1312 let request = request.unwrap();
1313 let query = request.uri().query().unwrap();
1314 assert!(query.contains("sig=signature"));
1315 assert!(query.contains("se=expiry"));
1316 assert!(request.headers().contains_key(&DATE));
1317 assert!(request.headers().contains_key(&VERSION));
1318 }
1319
1320 #[cfg(feature = "reqwest")]
1321 #[tokio::test]
1322 async fn test_fabric_refresh_expired_token() {
1323 let server = MockServer::new().await;
1324
1325 let expired_timestamp = FabricTokenOAuthProvider::get_current_timestamp() - 3600;
1327 let claims = format!(r#"{{"exp":{expired_timestamp}}}"#);
1328 let encoded_claims = BASE64_URL_SAFE_NO_PAD.encode(claims.as_bytes());
1329 let expired_token = format!("header.{encoded_claims}.signature");
1330
1331 let fresh_timestamp = FabricTokenOAuthProvider::get_current_timestamp() + 3600;
1333 let fresh_claims = format!(r#"{{"exp":{fresh_timestamp}}}"#);
1334 let fresh_encoded = BASE64_URL_SAFE_NO_PAD.encode(fresh_claims.as_bytes());
1335 let fresh_token = format!("header.{fresh_encoded}.signature");
1336 let expected_token = fresh_token.clone();
1337
1338 server.push_fn(move |req| {
1340 assert_eq!(req.headers().get(&PARTNER_TOKEN).unwrap(), "session-token");
1341 assert_eq!(
1342 req.headers().get(&CLUSTER_IDENTIFIER).unwrap(),
1343 "cluster-id"
1344 );
1345 assert_eq!(req.headers().get(&PROXY_HOST).unwrap(), "fake");
1346
1347 Response::new(fresh_token)
1348 });
1349
1350 let provider = FabricTokenOAuthProvider {
1351 fabric_token_service_url: server.url().to_string(),
1352 fabric_workload_host: "fake".to_string(),
1353 fabric_session_token: "session-token".to_string(),
1354 fabric_cluster_identifier: "cluster-id".to_string(),
1355 storage_access_token: Some(expired_token),
1356 token_expiry: Some(expired_timestamp),
1357 };
1358
1359 let client = HttpClient::new(Client::new());
1360 let retry = RetryConfig::default();
1361
1362 let result = provider.fetch_token(&client, &retry).await;
1363
1364 assert!(
1365 result.is_ok(),
1366 "fetch_token should handle expired cached token gracefully"
1367 );
1368
1369 let temp_token = result.unwrap();
1370
1371 if let AzureCredential::BearerToken(token) = temp_token.token.as_ref() {
1373 assert_eq!(
1374 token, &expected_token,
1375 "Should have fetched fresh token from API, not returned expired cached token"
1376 );
1377 }
1378 }
1379
1380 #[cfg(feature = "reqwest")]
1381 #[test]
1382 fn test_azure_authorization_preserves_explicit_version() {
1383 let credential = Some(Arc::new(AzureCredential::BearerToken("token".to_string())));
1384 let client = HttpClient::new(Client::new());
1385
1386 let request = client
1387 .request(Method::GET, "http://example.com/container/blob")
1388 .with_azure_version("2026-02-06")
1389 .with_azure_authorization(None, &credential, "account")
1390 .unwrap()
1391 .into_parts()
1392 .1
1393 .unwrap();
1394
1395 assert_eq!(request.headers().get(&VERSION).unwrap(), "2026-02-06");
1396 }
1397}