This is an automated email from the ASF dual-hosted git repository.

alamb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs-object-store.git


The following commit(s) were added to refs/heads/main by this push:
     new c66dd12  Cache Azure user delegation key for SAS signing (#781)
c66dd12 is described below

commit c66dd123a7ff7704e45ce75237653bb82f027609
Author: Emil Ernerfeldt <[email protected]>
AuthorDate: Fri Jul 10 06:51:52 2026 -0700

    Cache Azure user delegation key for SAS signing (#781)
    
    * fixup: typo unparseable -> unparsable
    
    * Redact secret parts of `UserDelegationKey` from `Debug`
    
    * test(azure): cover delegation key cache behavior
    
    * Add evidence for 7 day claim, rename functions
    
    ---------
    
    Co-authored-by: Andrew Lamb <[email protected]>
---
 src/azure/client.rs | 246 ++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/client/token.rs |   2 +-
 2 files changed, 242 insertions(+), 6 deletions(-)

diff --git a/src/azure/client.rs b/src/azure/client.rs
index 1c21fa9..e0c7a5b 100644
--- a/src/azure/client.rs
+++ b/src/azure/client.rs
@@ -23,6 +23,7 @@ use crate::client::get::GetClient;
 use crate::client::header::{HeaderConfig, get_put_result};
 use crate::client::list::ListClient;
 use crate::client::retry::{RetryContext, RetryExt};
+use crate::client::token::{TemporaryToken, TokenCache};
 use crate::client::{
     CryptoProvider, DigestAlgorithm, GetOptionsExt, HttpClient, HttpError, 
HttpRequest,
     HttpResponse, crypto_provider,
@@ -47,7 +48,7 @@ use rand::RngExt;
 use serde::{Deserialize, Serialize};
 use std::collections::HashMap;
 use std::sync::Arc;
-use std::time::Duration;
+use std::time::{Duration, Instant};
 use url::Url;
 
 const VERSION_HEADER: &str = "x-ms-version-id";
@@ -662,16 +663,50 @@ async fn parse_blob_batch_delete_body(
     Ok(results)
 }
 
+/// How long a freshly fetched user delegation key is requested to remain 
valid.
+///
+/// The shared access signature (SAS) tokens we sign with it stay short-lived;
+/// this only bounds how often we call `GetUserDelegationKey`. Azure caps the 
key
+/// lifetime at 7 days (the `Start` and `Expiry` of the key must be within 
seven
+/// days of each other):
+/// 
<https://learn.microsoft.com/en-us/rest/api/storageservices/get-user-delegation-key#request-body>
+const DELEGATION_KEY_VALIDITY: Duration = Duration::from_secs(12 * 60 * 60);
+
+/// Minimum remaining validity for a cached key to be reused.
+///
+/// The cache only hands back a key with at least this much life left, so it is
+/// also the longest SAS lifetime the cache can safely serve (a SAS must not
+/// outlive the key it is signed with). Longer-lived SAS fetch a dedicated key.
+const DELEGATION_KEY_MIN_TTL: Duration = Duration::from_secs(2 * 60 * 60);
+
+/// Parse the validity Azure actually granted a user delegation key, falling 
back
+/// to the window we requested if the response can't be parsed.
+fn delegation_key_expiry(key: &UserDelegationKey, requested: DateTime<Utc>) -> 
DateTime<Utc> {
+    DateTime::parse_from_rfc3339(&key.signed_expiry)
+        .map(|t| t.with_timezone(&Utc))
+        .unwrap_or(requested)
+}
+
 #[derive(Debug)]
 pub(crate) struct AzureClient {
     config: AzureConfig,
     client: HttpClient,
+    /// Caches the user delegation key used to sign SAS URLs.
+    ///
+    /// Fetching a key is a network round-trip (`GetUserDelegationKey`) that 
Azure
+    /// throttles under load, so we fetch a long-lived key once and reuse it to
+    /// mint many short-lived SAS tokens.
+    delegation_key_cache: TokenCache<UserDelegationKey>,
 }
 
 impl AzureClient {
     /// create a new instance of [AzureClient]
     pub(crate) fn new(config: AzureConfig, client: HttpClient) -> Self {
-        Self { config, client }
+        Self {
+            config,
+            client,
+            delegation_key_cache: 
TokenCache::default().with_min_ttl(DELEGATION_KEY_MIN_TTL),
+        }
     }
 
     /// Returns the config
@@ -966,7 +1001,7 @@ impl AzureClient {
 
     /// Make a Get User Delegation Key request
     /// 
<https://docs.microsoft.com/en-us/rest/api/storageservices/get-user-delegation-key>
-    async fn get_user_delegation_key(
+    async fn get_delegation_key_inner(
         &self,
         start: &DateTime<Utc>,
         end: &DateTime<Utc>,
@@ -1020,7 +1055,7 @@ impl AzureClient {
         match credential.as_deref() {
             Some(AzureCredential::BearerToken(_)) => {
                 let key = self
-                    .get_user_delegation_key(&signed_start, &signed_expiry)
+                    .user_delegation_key(signed_start, signed_expiry, 
expires_in)
                     .await?;
                 let signing_key = AzureAccessKey::try_new(&key.value)?;
                 Ok(AzureSigner::new(
@@ -1043,6 +1078,50 @@ impl AzureClient {
         }
     }
 
+    /// Return a user delegation key valid for a SAS over `[sas_start, 
sas_expiry]`.
+    ///
+    /// `GetUserDelegationKey` is a network round-trip that Azure throttles 
(HTTP
+    /// 503) under load, so a long-lived key is cached and reused to sign many
+    /// short-lived SAS URLs.
+    ///
+    /// The cache only returns a key with more than [`DELEGATION_KEY_MIN_TTL`]
+    /// remaining, so any SAS no longer than that is guaranteed to expire 
before
+    /// its key. The (rare) longer-lived SAS get a dedicated key instead.
+    async fn user_delegation_key(
+        &self,
+        sas_start: DateTime<Utc>,
+        sas_expiry: DateTime<Utc>,
+        expires_in: Duration,
+    ) -> Result<UserDelegationKey> {
+        if expires_in <= DELEGATION_KEY_MIN_TTL {
+            self.delegation_key_cache
+                .get_or_insert_with(|| 
self.get_delegation_key(DELEGATION_KEY_VALIDITY))
+                .await
+        } else {
+            self.get_delegation_key_inner(&sas_start, &sas_expiry).await
+        }
+    }
+
+    /// Fetch a user delegation key valid for `validity` and wrap it as a
+    /// [`TemporaryToken`] so [`TokenCache`] can expire it.
+    async fn get_delegation_key(
+        &self,
+        validity: Duration,
+    ) -> Result<TemporaryToken<UserDelegationKey>> {
+        let start = chrono::Utc::now();
+        let requested_expiry = start + validity;
+        let key = self
+            .get_delegation_key_inner(&start, &requested_expiry)
+            .await?;
+        // Expire the cache entry when the key Azure granted does (it may 
clamp it).
+        let expiry = delegation_key_expiry(&key, requested_expiry);
+        let ttl = (expiry - chrono::Utc::now()).to_std().unwrap_or(validity);
+        Ok(TemporaryToken {
+            token: key,
+            expiry: Some(Instant::now() + ttl),
+        })
+    }
+
     #[cfg(test)]
     pub(crate) async fn get_blob_tagging(&self, path: &Path) -> 
Result<HttpResponse> {
         let credential = self.get_credential().await?;
@@ -1385,7 +1464,7 @@ impl BlockList {
     }
 }
 
-#[derive(Debug, Clone, PartialEq, Deserialize)]
+#[derive(Clone, Default, PartialEq, Deserialize)]
 #[serde(rename_all = "PascalCase")]
 pub(crate) struct UserDelegationKey {
     pub signed_oid: String,
@@ -1397,6 +1476,29 @@ pub(crate) struct UserDelegationKey {
     pub value: String,
 }
 
+impl std::fmt::Debug for UserDelegationKey {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let Self {
+            signed_oid,
+            signed_tid,
+            signed_start,
+            signed_expiry,
+            signed_service,
+            signed_version,
+            value: _, // secret => redacted
+        } = self;
+        f.debug_struct("UserDelegationKey")
+            .field("signed_oid", signed_oid)
+            .field("signed_tid", signed_tid)
+            .field("signed_start", signed_start)
+            .field("signed_expiry", signed_expiry)
+            .field("signed_service", signed_service)
+            .field("signed_version", signed_version)
+            .field("value", &"******")
+            .finish()
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1405,6 +1507,7 @@ mod tests {
     use bytes::Bytes;
     use regex::bytes::Regex;
     use reqwest::Client;
+    use std::sync::atomic::{AtomicUsize, Ordering};
 
     #[test]
     fn deserde_azure() {
@@ -1594,6 +1697,139 @@ mod tests {
             quick_xml::de::from_str(S).unwrap();
     }
 
+    #[test]
+    fn test_delegation_key_expiry() {
+        let at = |s: &str| 
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
+        let requested = at("2026-06-25T06:00:00Z");
+
+        // A well-formed granted expiry is honored (e.g. Azure clamped it 
shorter).
+        let key = UserDelegationKey {
+            signed_expiry: "2026-06-25T05:00:00Z".to_string(),
+            ..Default::default()
+        };
+        assert_eq!(
+            delegation_key_expiry(&key, requested),
+            at("2026-06-25T05:00:00Z")
+        );
+
+        // An unparsable expiry falls back to the requested window.
+        let key = UserDelegationKey {
+            signed_expiry: "not a timestamp".to_string(),
+            ..Default::default()
+        };
+        assert_eq!(delegation_key_expiry(&key, requested), requested);
+    }
+
+    #[cfg(feature = "reqwest")]
+    fn test_client(service: &str) -> AzureClient {
+        let credential_provider = Arc::new(StaticCredentialProvider::new(
+            AzureCredential::BearerToken("static-token".to_string()),
+        ));
+
+        let config = AzureConfig {
+            account: "testaccount".to_string(),
+            container: "testcontainer".to_string(),
+            credentials: credential_provider,
+            crypto: None,
+            service: service.try_into().unwrap(),
+            retry_config: Default::default(),
+            is_emulator: false,
+            skip_signature: false,
+            disable_tagging: false,
+            client_options: Default::default(),
+            encryption_headers: Default::default(),
+        };
+
+        AzureClient::new(config, HttpClient::new(Client::new()))
+    }
+
+    fn delegation_key_response(expiry: DateTime<Utc>) -> String {
+        let expiry = expiry.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
+        format!(
+            r#"<?xml version="1.0" encoding="utf-8"?>
+<UserDelegationKey>
+    <SignedOid>oid</SignedOid>
+    <SignedTid>tid</SignedTid>
+    <SignedStart>2026-06-25T00:00:00Z</SignedStart>
+    <SignedExpiry>{expiry}</SignedExpiry>
+    <SignedService>b</SignedService>
+    <SignedVersion>2025-11-05</SignedVersion>
+    <Value>secret</Value>
+</UserDelegationKey>"#
+        )
+    }
+
+    #[cfg(feature = "reqwest")]
+    #[tokio::test]
+    async fn test_user_delegation_key_cache_reuse_and_refresh() {
+        let now = Utc::now();
+        let one_min = Duration::from_secs(60);
+        let three_hours = Duration::from_secs(3 * 60 * 60);
+
+        // A key with more than DELEGATION_KEY_MIN_TTL remaining should be 
reused
+        // for short-lived SAS tokens instead of triggering another
+        // GetUserDelegationKey request.
+        let server = crate::client::mock_server::MockServer::new().await;
+        let fetches = Arc::new(AtomicUsize::new(0));
+        let client = test_client(server.url());
+
+        let fetches_clone = Arc::clone(&fetches);
+        server.push_fn(move |_| {
+            fetches_clone.fetch_add(1, Ordering::SeqCst);
+            http::Response::new(delegation_key_response(now + three_hours))
+        });
+
+        client
+            .user_delegation_key(now, now + one_min, one_min)
+            .await
+            .unwrap();
+        client
+            .user_delegation_key(now, now + one_min, one_min)
+            .await
+            .unwrap();
+        assert_eq!(fetches.load(Ordering::SeqCst), 1);
+
+        // SAS tokens longer than DELEGATION_KEY_MIN_TTL are not safe to mint
+        // with the cached key, so they fetch a dedicated delegation key.
+        let fetches_clone = Arc::clone(&fetches);
+        server.push_fn(move |_| {
+            fetches_clone.fetch_add(1, Ordering::SeqCst);
+            http::Response::new(delegation_key_response(now + three_hours))
+        });
+
+        client
+            .user_delegation_key(now, now + three_hours, three_hours)
+            .await
+            .unwrap();
+        assert_eq!(fetches.load(Ordering::SeqCst), 2);
+
+        // A newly fetched key whose Azure-granted expiry is already below
+        // DELEGATION_KEY_MIN_TTL is cached, but should be refreshed after the
+        // TokenCache fetch backoff instead of being reused indefinitely.
+        let server = crate::client::mock_server::MockServer::new().await;
+        let fetches = Arc::new(AtomicUsize::new(0));
+        let client = test_client(server.url());
+
+        for _ in 0..2 {
+            let fetches_clone = Arc::clone(&fetches);
+            server.push_fn(move |_| {
+                fetches_clone.fetch_add(1, Ordering::SeqCst);
+                http::Response::new(delegation_key_response(now + one_min))
+            });
+        }
+
+        client
+            .user_delegation_key(now, now + one_min, one_min)
+            .await
+            .unwrap();
+        tokio::time::sleep(Duration::from_millis(150)).await;
+        client
+            .user_delegation_key(now, now + one_min, one_min)
+            .await
+            .unwrap();
+        assert_eq!(fetches.load(Ordering::SeqCst), 2);
+    }
+
     #[cfg(feature = "reqwest")]
     #[tokio::test]
     async fn test_build_bulk_delete_body() {
diff --git a/src/client/token.rs b/src/client/token.rs
index 147c77a..7743213 100644
--- a/src/client/token.rs
+++ b/src/client/token.rs
@@ -58,7 +58,7 @@ impl<T> Default for TokenCache<T> {
 
 impl<T: Clone + Send + Sync> TokenCache<T> {
     /// Override the minimum remaining TTL for a cached token to be used
-    #[cfg(any(feature = "aws-base", feature = "gcp-base"))]
+    #[cfg(any(feature = "aws-base", feature = "azure-base", feature = 
"gcp-base"))]
     pub(crate) fn with_min_ttl(self, min_ttl: Duration) -> Self {
         Self { min_ttl, ..self }
     }

Reply via email to