Skip to content

Commit 2690d70

Browse files
emilkclaude
andcommitted
Cache Azure user delegation key for SAS signing
`AzureClient::signer()` fetched a fresh user delegation key on every signing request — a `GetUserDelegationKey` round-trip (`POST /?restype=service&comp=userdelegationkey`). Under load Azure throttles these with HTTP 503, which surfaces as signing failures. Unlike the AWS signer (which signs locally) and GCP (which caches its signing credentials), the Azure signer made an uncached network call on top of the already-cached AAD token. Reuse the existing `TokenCache<T>` to cache the user delegation key: a long-lived (12h) key is fetched once and reused to sign many short-lived SAS tokens. `TokenCache` only returns a key with more than the configured `min_ttl` (2h) remaining, so any SAS no longer than that is guaranteed to expire before its key; the rare longer-lived SAS fetch a dedicated key. `with_min_ttl` is extended to the `azure-base` feature so the Azure client can configure the cache. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 93f6f88 commit 2690d70

2 files changed

Lines changed: 104 additions & 5 deletions

File tree

src/azure/client.rs

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use crate::client::get::GetClient;
2323
use crate::client::header::{HeaderConfig, get_put_result};
2424
use crate::client::list::ListClient;
2525
use crate::client::retry::{RetryContext, RetryExt};
26+
use crate::client::token::{TemporaryToken, TokenCache};
2627
use crate::client::{
2728
CryptoProvider, DigestAlgorithm, GetOptionsExt, HttpClient, HttpError, HttpRequest,
2829
HttpResponse, crypto_provider,
@@ -47,7 +48,7 @@ use rand::RngExt;
4748
use serde::{Deserialize, Serialize};
4849
use std::collections::HashMap;
4950
use std::sync::Arc;
50-
use std::time::Duration;
51+
use std::time::{Duration, Instant};
5152
use url::Url;
5253

5354
const 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)]
666688
pub(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

671699
impl 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")]
13901466
pub(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() {

src/client/token.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ impl<T> Default for TokenCache<T> {
5858

5959
impl<T: Clone + Send + Sync> TokenCache<T> {
6060
/// Override the minimum remaining TTL for a cached token to be used
61-
#[cfg(any(feature = "aws-base", feature = "gcp-base"))]
61+
#[cfg(any(feature = "aws-base", feature = "azure-base", feature = "gcp-base"))]
6262
pub(crate) fn with_min_ttl(self, min_ttl: Duration) -> Self {
6363
Self { min_ttl, ..self }
6464
}

0 commit comments

Comments
 (0)