Skip to main content

object_store/azure/
credential.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use 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
64/// OIDC scope used when interacting with OAuth2 APIs
65///
66/// <https://learn.microsoft.com/en-us/azure/active-directory/develop/scopes-oidc#the-default-scope>
67const AZURE_STORAGE_SCOPE: &str = "https://storage.azure.com/.default";
68
69/// Resource ID used when obtaining an access token from the metadata endpoint
70///
71/// <https://learn.microsoft.com/en-us/azure/storage/blobs/authorize-access-azure-active-directory#microsoft-authentication-library-msal>
72const 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/// A shared Azure Storage Account Key
112#[derive(Debug, Clone, Eq, PartialEq)]
113pub struct AzureAccessKey(Vec<u8>);
114
115impl AzureAccessKey {
116    /// Create a new [`AzureAccessKey`], checking it for validity
117    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/// An Azure storage credential
127#[derive(Debug, Eq, PartialEq)]
128pub enum AzureCredential {
129    /// A shared access key
130    ///
131    /// <https://learn.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key>
132    AccessKey(AzureAccessKey),
133    /// A shared access signature
134    ///
135    /// <https://learn.microsoft.com/en-us/rest/api/storageservices/delegate-access-with-shared-access-signature>
136    SASToken(Vec<(String, String)>),
137    /// An authorization token
138    ///
139    /// <https://learn.microsoft.com/en-us/rest/api/storageservices/authorize-with-azure-active-directory>
140    BearerToken(String),
141}
142
143impl AzureCredential {
144    /// Determines if the credential requires the request be treated as sensitive
145    pub fn sensitive_request(&self) -> bool {
146        match self {
147            Self::AccessKey(_) => false,
148            Self::BearerToken(_) => false,
149            // SAS tokens are sent as query parameters in the url
150            Self::SASToken(_) => true,
151        }
152    }
153}
154
155/// A list of known Azure authority hosts
156pub mod authority_hosts {
157    /// China-based Azure Authority Host
158    pub const AZURE_CHINA: &str = "https://login.chinacloudapi.cn";
159    /// Germany-based Azure Authority Host
160    pub const AZURE_GERMANY: &str = "https://login.microsoftonline.de";
161    /// US Government Azure Authority Host
162    pub const AZURE_GOVERNMENT: &str = "https://login.microsoftonline.us";
163    /// Public Cloud Azure Authority Host
164    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    // rfc2822 string should never contain illegal characters
222    let date = Utc::now();
223    let date_str = date.format(RFC1123_FMT).to_string();
224    // we formatted the data string ourselves, so unwrapping should be fine
225    let date_val = HeaderValue::from_str(&date_str).unwrap();
226    request.headers_mut().insert(DATE, date_val);
227    // Preserve an explicit per-request override when a call site needs a newer
228    // service version than the backend default.
229    if !request.headers().contains_key(&VERSION) {
230        request
231            .headers_mut()
232            .insert(&VERSION, AZURE_VERSION.clone());
233    }
234}
235
236/// Authorize a [`HttpRequest`] with an [`AzureAuthorizer`]
237#[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    /// Create a new [`AzureAuthorizer`]
246    pub fn new(credential: &'a AzureCredential, account: &'a str) -> Self {
247        AzureAuthorizer {
248            credential,
249            account,
250            crypto: None,
251        }
252    }
253
254    /// Specify the crypto provider
255    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    /// Authorize `request`
265    ///
266    /// # Panics
267    ///
268    /// Panics on cryptography error
269    #[deprecated(note = "use AzureAuthorizer::try_authorize")]
270    pub fn authorize(&self, request: &mut HttpRequest) {
271        self.try_authorize(request).unwrap()
272    }
273
274    /// Authorize `request`
275    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                // "signature" is a base 64 encoded string so it should never
292                // contain illegal characters
293                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    /// Apply authorization to requests against azure storage accounts
314    /// <https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-requests-to-azure-storage>
315    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
350/// Generate signed key for authorization via access keys
351/// <https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key>
352fn 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    // NOTE: for now only blob signing is supported.
388    let signed_resource = "b".to_string();
389
390    // https://learn.microsoft.com/en-us/rest/api/storageservices/create-service-sas#permissions-for-a-directory-container-or-blob
391    let signed_permissions = match *method {
392        // read and list permissions
393        Method::GET => match signed_resource.as_str() {
394            "c" => "rl",
395            "b" => "r",
396            _ => unreachable!(),
397        },
398        // write permissions (also allows crating a new blob in a sub-key)
399        Method::PUT => "w",
400        // delete permissions
401        Method::DELETE => "d",
402        // other methods are not used in any of the current operations
403        _ => "",
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        // NOTE: in case of the emulator, the account name is not part of the host
412        //      but the path starts with the account name
413        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
425/// Create a string to be signed for authorization via [service sas].
426///
427/// [service sas]: https://learn.microsoft.com/en-us/rest/api/storageservices/create-service-sas#version-2020-12-06-and-later
428fn 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        "",                              // signed identifier
445        "",                              // signed ip
446        "",                              // signed protocol
447        AZURE_VERSION.to_str().unwrap(), // signed version
448        signed_resource,                 // signed resource
449        "",                              // signed snapshot time
450        "",                              // signed encryption scope
451        "",                              // rscc - response header: Cache-Control
452        "",                              // rscd - response header: Content-Disposition
453        "",                              // rsce - response header: Content-Encoding
454        "",                              // rscl - response header: Content-Language
455        "",                              // rsct - response header: Content-Type
456    );
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
468/// Create a string to be signed for authorization via [user delegation sas].
469///
470/// [user delegation sas]: https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas#version-2020-12-06-and-later
471fn 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,       // signed key object id
489        delegation_key.signed_tid,       // signed key tenant id
490        delegation_key.signed_start,     // signed key start
491        delegation_key.signed_expiry,    // signed key expiry
492        delegation_key.signed_service,   // signed key service
493        delegation_key.signed_version,   // signed key version
494        "",                              // signed authorized user object id
495        "",                              // signed unauthorized user object id
496        "",                              // signed correlation id
497        "",                              // signed ip
498        "",                              // signed protocol
499        AZURE_VERSION.to_str().unwrap(), // signed version
500        signed_resource,                 // signed resource
501        "",                              // signed snapshot time
502        "",                              // signed encryption scope
503        "",                              // rscc - response header: Cache-Control
504        "",                              // rscd - response header: Content-Disposition
505        "",                              // rsce - response header: Content-Encoding
506        "",                              // rscl - response header: Content-Language
507        "",                              // rsct - response header: Content-Type
508    );
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
526/// <https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key#constructing-the-signature-string>
527fn string_to_sign(h: &HeaderMap, u: &Url, method: &Method, account: &str) -> String {
528    // content length must only be specified if != 0
529    // this is valid from 2015-02-21
530    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
557/// <https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key#constructing-the-canonicalized-headers-string>
558fn canonicalize_header(headers: &HeaderMap) -> String {
559    let mut names = headers
560        .iter()
561        .filter(|&(k, _)| k.as_str().starts_with("x-ms"))
562        // TODO remove unwraps
563        .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
577/// <https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key#constructing-the-canonicalized-resource-string>
578fn 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    // query parameters
586    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            // find correct parameter
599            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/// <https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#successful-response-1>
630#[derive(Deserialize, Debug)]
631struct OAuthTokenResponse {
632    access_token: String,
633    expires_in: u64,
634}
635
636/// Encapsulates the logic to perform an OAuth token challenge
637///
638/// <https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#first-case-access-token-request-with-a-shared-secret>
639#[derive(Debug)]
640pub(crate) struct ClientSecretOAuthProvider {
641    token_url: String,
642    client_id: String,
643    client_secret: String,
644}
645
646impl ClientSecretOAuthProvider {
647    /// Create a new [`ClientSecretOAuthProvider`] for an azure backed store
648    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    /// Fetch a token
674    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/// NOTE: expires_on is a String version of unix epoch time, not an integer.
719/// <https://learn.microsoft.com/en-gb/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http>
720/// <https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity?tabs=portal%2Chttp#connect-to-azure-services-in-app-code>
721#[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/// Attempts authentication using a managed identity that has been assigned to the deployment environment.
729///
730/// This authentication type works in Azure VMs, App Service and Azure Functions applications, as well as the Azure Cloud Shell
731/// <https://learn.microsoft.com/en-gb/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http>
732#[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    /// Create a new [`ImdsManagedIdentityProvider`] for an azure backed store
742    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    /// Fetch a token
765    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/// Credential for using workload identity federation
815///
816/// <https://learn.microsoft.com/en-us/azure/active-directory/develop/workload-identity-federation>
817#[derive(Debug)]
818pub(crate) struct WorkloadIdentityOAuthProvider {
819    token_url: String,
820    client_id: String,
821    federated_token_file: String,
822}
823
824impl WorkloadIdentityOAuthProvider {
825    /// Create a new [`WorkloadIdentityOAuthProvider`] for an azure backed store
826    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    /// Fetch a token
852    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        // https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow#third-case-access-token-request-with-a-federated-credential
861        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        // expiresOn from azure cli uses the local timezone
901        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    /// Fetch a token
932    async fn fetch_token(&self) -> Result<TemporaryToken<Arc<AzureCredential>>> {
933        // on window az is a cmd and it should be called like this
934        // see https://doc.rust-lang.org/nightly/std/process/struct.Command.html
935        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/// Encapsulates the logic to perform an OAuth token challenge for Fabric
1000#[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    /// Create a new [`FabricTokenOAuthProvider`] for an azure backed store
1017    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    /// Fetch a token
1064    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        // Test IMDS
1144        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        // Test IMDS
1204        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        // Create an expired initial token (1 hour in the past)
1326        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        // Create a fresh token that the mock API will return (1 hour in the future)
1332        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        // Mock the Fabric token service to return a fresh token
1339        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        // Verify we got a fresh token from the API (not the expired cached one)
1372        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}