@@ -23,6 +23,7 @@ use crate::client::get::GetClient;
2323use crate :: client:: header:: { HeaderConfig , get_put_result} ;
2424use crate :: client:: list:: ListClient ;
2525use crate :: client:: retry:: { RetryContext , RetryExt } ;
26+ use crate :: client:: token:: { TemporaryToken , TokenCache } ;
2627use crate :: client:: {
2728 CryptoProvider , DigestAlgorithm , GetOptionsExt , HttpClient , HttpError , HttpRequest ,
2829 HttpResponse , crypto_provider,
@@ -47,7 +48,7 @@ use rand::RngExt;
4748use serde:: { Deserialize , Serialize } ;
4849use std:: collections:: HashMap ;
4950use std:: sync:: Arc ;
50- use std:: time:: Duration ;
51+ use std:: time:: { Duration , Instant } ;
5152use url:: Url ;
5253
5354const VERSION_HEADER : & str = "x-ms-version-id" ;
@@ -662,16 +663,47 @@ async fn parse_blob_batch_delete_body(
662663 Ok ( results)
663664}
664665
666+ /// How long a freshly fetched user delegation key is requested to remain valid.
667+ ///
668+ /// The SAS tokens we sign with it stay short-lived; this only bounds how often
669+ /// we call `GetUserDelegationKey`. Azure caps the key lifetime at 7 days.
670+ const DELEGATION_KEY_VALIDITY : Duration = Duration :: from_secs ( 12 * 60 * 60 ) ;
671+
672+ /// Minimum remaining validity for a cached key to be reused.
673+ ///
674+ /// The cache only hands back a key with at least this much life left, so it is
675+ /// also the longest SAS lifetime the cache can safely serve (a SAS must not
676+ /// outlive the key it is signed with). Longer-lived SAS fetch a dedicated key.
677+ const DELEGATION_KEY_MIN_TTL : Duration = Duration :: from_secs ( 2 * 60 * 60 ) ;
678+
679+ /// Parse the validity Azure actually granted a user delegation key, falling back
680+ /// to the window we requested if the response can't be parsed.
681+ fn delegation_key_expiry ( key : & UserDelegationKey , requested : DateTime < Utc > ) -> DateTime < Utc > {
682+ DateTime :: parse_from_rfc3339 ( & key. signed_expiry )
683+ . map ( |t| t. with_timezone ( & Utc ) )
684+ . unwrap_or ( requested)
685+ }
686+
665687#[ derive( Debug ) ]
666688pub ( crate ) struct AzureClient {
667689 config : AzureConfig ,
668690 client : HttpClient ,
691+ /// Caches the user delegation key used to sign SAS URLs.
692+ ///
693+ /// Fetching a key is a network round-trip (`GetUserDelegationKey`) that Azure
694+ /// throttles under load, so we fetch a long-lived key once and reuse it to
695+ /// mint many short-lived SAS tokens.
696+ delegation_key_cache : TokenCache < UserDelegationKey > ,
669697}
670698
671699impl AzureClient {
672700 /// create a new instance of [AzureClient]
673701 pub ( crate ) fn new ( config : AzureConfig , client : HttpClient ) -> Self {
674- Self { config, client }
702+ Self {
703+ config,
704+ client,
705+ delegation_key_cache : TokenCache :: default ( ) . with_min_ttl ( DELEGATION_KEY_MIN_TTL ) ,
706+ }
675707 }
676708
677709 /// Returns the config
@@ -1020,7 +1052,7 @@ impl AzureClient {
10201052 match credential. as_deref ( ) {
10211053 Some ( AzureCredential :: BearerToken ( _) ) => {
10221054 let key = self
1023- . get_user_delegation_key ( & signed_start, & signed_expiry)
1055+ . user_delegation_key ( signed_start, signed_expiry, expires_in )
10241056 . await ?;
10251057 let signing_key = AzureAccessKey :: try_new ( & key. value ) ?;
10261058 Ok ( AzureSigner :: new (
@@ -1043,6 +1075,50 @@ impl AzureClient {
10431075 }
10441076 }
10451077
1078+ /// Return a user delegation key valid for a SAS over `[sas_start, sas_expiry]`.
1079+ ///
1080+ /// `GetUserDelegationKey` is a network round-trip that Azure throttles (HTTP
1081+ /// 503) under load, so a long-lived key is cached and reused to sign many
1082+ /// short-lived SAS URLs.
1083+ ///
1084+ /// The cache only returns a key with more than [`DELEGATION_KEY_MIN_TTL`]
1085+ /// remaining, so any SAS no longer than that is guaranteed to expire before
1086+ /// its key. The (rare) longer-lived SAS get a dedicated key instead.
1087+ async fn user_delegation_key (
1088+ & self ,
1089+ sas_start : DateTime < Utc > ,
1090+ sas_expiry : DateTime < Utc > ,
1091+ expires_in : Duration ,
1092+ ) -> Result < UserDelegationKey > {
1093+ if expires_in <= DELEGATION_KEY_MIN_TTL {
1094+ self . delegation_key_cache
1095+ . get_or_insert_with ( || self . fetch_delegation_key ( DELEGATION_KEY_VALIDITY ) )
1096+ . await
1097+ } else {
1098+ self . get_user_delegation_key ( & sas_start, & sas_expiry) . await
1099+ }
1100+ }
1101+
1102+ /// Fetch a user delegation key valid for `validity` and wrap it as a
1103+ /// [`TemporaryToken`] so [`TokenCache`] can expire it.
1104+ async fn fetch_delegation_key (
1105+ & self ,
1106+ validity : Duration ,
1107+ ) -> Result < TemporaryToken < UserDelegationKey > > {
1108+ let start = chrono:: Utc :: now ( ) ;
1109+ let requested_expiry = start + validity;
1110+ let key = self
1111+ . get_user_delegation_key ( & start, & requested_expiry)
1112+ . await ?;
1113+ // Expire the cache entry when the key Azure granted does (it may clamp it).
1114+ let expiry = delegation_key_expiry ( & key, requested_expiry) ;
1115+ let ttl = ( expiry - chrono:: Utc :: now ( ) ) . to_std ( ) . unwrap_or ( validity) ;
1116+ Ok ( TemporaryToken {
1117+ token : key,
1118+ expiry : Some ( Instant :: now ( ) + ttl) ,
1119+ } )
1120+ }
1121+
10461122 #[ cfg( test) ]
10471123 pub ( crate ) async fn get_blob_tagging ( & self , path : & Path ) -> Result < HttpResponse > {
10481124 let credential = self . get_credential ( ) . await ?;
@@ -1385,7 +1461,7 @@ impl BlockList {
13851461 }
13861462}
13871463
1388- #[ derive( Debug , Clone , PartialEq , Deserialize ) ]
1464+ #[ derive( Debug , Clone , Default , PartialEq , Deserialize ) ]
13891465#[ serde( rename_all = "PascalCase" ) ]
13901466pub ( crate ) struct UserDelegationKey {
13911467 pub signed_oid : String ,
@@ -1594,6 +1670,29 @@ mod tests {
15941670 quick_xml:: de:: from_str ( S ) . unwrap ( ) ;
15951671 }
15961672
1673+ #[ test]
1674+ fn test_delegation_key_expiry ( ) {
1675+ let at = |s : & str | DateTime :: parse_from_rfc3339 ( s) . unwrap ( ) . with_timezone ( & Utc ) ;
1676+ let requested = at ( "2026-06-25T06:00:00Z" ) ;
1677+
1678+ // A well-formed granted expiry is honored (e.g. Azure clamped it shorter).
1679+ let key = UserDelegationKey {
1680+ signed_expiry : "2026-06-25T05:00:00Z" . to_string ( ) ,
1681+ ..Default :: default ( )
1682+ } ;
1683+ assert_eq ! (
1684+ delegation_key_expiry( & key, requested) ,
1685+ at( "2026-06-25T05:00:00Z" )
1686+ ) ;
1687+
1688+ // An unparseable expiry falls back to the requested window.
1689+ let key = UserDelegationKey {
1690+ signed_expiry : "not a timestamp" . to_string ( ) ,
1691+ ..Default :: default ( )
1692+ } ;
1693+ assert_eq ! ( delegation_key_expiry( & key, requested) , requested) ;
1694+ }
1695+
15971696 #[ cfg( feature = "reqwest" ) ]
15981697 #[ tokio:: test]
15991698 async fn test_build_bulk_delete_body ( ) {
0 commit comments