Skip to content

Commit be8f9b9

Browse files
committed
[storage] Add azure_storage_sas crate for User Delegation SAS generation
1 parent 22f5e9d commit be8f9b9

20 files changed

Lines changed: 4002 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ members = [
2929
"sdk/canary/azure_canary",
3030
"sdk/storage/azure_storage_blob",
3131
"sdk/storage/azure_storage_queue",
32+
"sdk/storage/azure_storage_sas"
3233
]
3334
exclude = [
3435
"eng/scripts/",
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[package]
2+
name = "azure_storage_sas"
3+
version = "1.1.0-beta.1"
4+
description = "Microsoft Azure Storage User Delegation Shared Access Signature library for Rust"
5+
readme = "README.md"
6+
authors.workspace = true
7+
edition.workspace = true
8+
license.workspace = true
9+
repository.workspace = true
10+
rust-version.workspace = true
11+
12+
[dependencies]
13+
azure_core = { workspace = true, features = ["hmac_rust"] }
14+
azure_identity.workspace = true
15+
hmac.workspace = true
16+
sha2.workspace = true
17+
base64.workspace = true
18+
time = { workspace = true, features = ["formatting", "macros"] }
19+
url.workspace = true
20+
reqwest = { workspace = true, default-features = false, features = ["json", "rustls"] }
21+
quick-xml.workspace = true
22+
serde.workspace = true
23+
thiserror.workspace = true
24+
uuid.workspace = true
25+
26+
[dev-dependencies]
27+
time = { workspace = true, features = ["formatting", "macros"] }
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# azure_storage_sas
2+
3+
User Delegation Shared Access Signature for Azure Storage.
4+
5+
Generates signed URLs for Azure Blob, Queue, Table, and File Storage using an Entra ID (Azure AD) credential — no storage account keys required.
6+
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#[derive(Debug, thiserror::Error)]
2+
pub enum SasError {
3+
#[error("failed to acquire storage token: {0}")]
4+
TokenError(#[from] azure_core::Error),
5+
6+
#[error("user delegation key request failed with HTTP {status}: {message}")]
7+
DelegationKeyError { status: u16, message: String },
8+
9+
#[error("HTTP error: {0}")]
10+
HttpError(#[from] reqwest::Error),
11+
12+
#[error("XML parse error: {0}")]
13+
XmlError(#[from] quick_xml::DeError),
14+
15+
#[error("base64 decode error: {0}")]
16+
Base64Error(#[from] base64::DecodeError),
17+
18+
#[error("HMAC key initialization failed: invalid key bytes")]
19+
HmacError,
20+
21+
#[error("time format error: {0}")]
22+
TimeError(#[from] time::error::Format),
23+
24+
#[error("URL parse error: {0}")]
25+
UrlError(#[from] url::ParseError),
26+
27+
#[error(
28+
"key_expiry_duration exceeds the Azure-enforced maximum; see UserDelegationSasBuilder::MAX_KEY_EXPIRY"
29+
)]
30+
KeyExpiryTooLong,
31+
}
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
use std::sync::Arc;
2+
3+
use azure_core::credentials::{TokenCredential, TokenRequestOptions};
4+
use base64::{Engine, engine::general_purpose::STANDARD};
5+
use hmac::{Hmac, Mac};
6+
use serde::Deserialize;
7+
use sha2::Sha256;
8+
use time::{Duration, OffsetDateTime, macros::format_description};
9+
use url::Url;
10+
use uuid::Uuid;
11+
12+
use crate::error::SasError;
13+
14+
pub(crate) fn format_sas_time(dt: OffsetDateTime) -> Result<String, time::error::Format> {
15+
dt.format(format_description!(
16+
"[year]-[month]-[day]T[hour]:[minute]:[second]Z"
17+
))
18+
}
19+
20+
/// The user delegation key returned by Azure Storage.
21+
///
22+
/// Keys are valid for up to [`crate::UserDelegationSasBuilder::MAX_KEY_EXPIRY`] and can be reused to sign multiple SAS tokens within
23+
/// that window. Obtain one via [`UserDelegationKeyFetcher::fetch`] or [`crate::UserDelegationSasBuilder::fetch_key`] and pass it to
24+
/// subsequent builders with [`crate::UserDelegationSasBuilder::with_key`].
25+
///
26+
/// <https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas#request-the-user-delegation-key>
27+
/// <https://learn.microsoft.com/en-us/rest/api/storageservices/get-user-delegation-key>
28+
#[derive(Clone, Debug, Deserialize)]
29+
#[serde(rename_all = "PascalCase")]
30+
pub struct UserDelegationKey {
31+
/// The immutable identifier for an object in the Microsoft identity system.
32+
pub signed_oid: String,
33+
/// A GUID that represents the Microsoft Entra tenant that the user is from.
34+
pub signed_tid: String,
35+
/// The start time of the user delegation key, in ISO date format.
36+
pub signed_start: String,
37+
/// The expiration time of the user delegation key, in ISO date format.
38+
pub signed_expiry: String,
39+
/// The service that the user delegation key can be used for, where b represents Blob Storage.
40+
pub signed_service: String,
41+
/// The REST API version that's used to get the user delegation key.
42+
pub signed_version: String,
43+
/// The user delegation key.
44+
pub value: String,
45+
}
46+
47+
impl UserDelegationKey {
48+
/// Fetches a user delegation key from the given storage service endpoint.
49+
///
50+
/// `delegated_user_tid` is the tenant ID of the end user for cross-tenant scenarios.
51+
/// When provided it is sent as `<DelegatedUserTid>` in the request body.
52+
pub(crate) async fn fetch(
53+
endpoint: &Url,
54+
signed_version: &str,
55+
credential: &Arc<dyn TokenCredential>,
56+
client: &reqwest::Client,
57+
start_str: &str,
58+
expiry_str: &str,
59+
delegated_user_tid: Option<&str>,
60+
) -> Result<Self, SasError> {
61+
let token = credential
62+
.get_token(
63+
&["https://storage.azure.com/.default"],
64+
Some(TokenRequestOptions::default()),
65+
)
66+
.await?;
67+
68+
let mut key_url = endpoint.clone();
69+
key_url.set_query(Some("restype=service&comp=userdelegationkey"));
70+
let dutid_elem = delegated_user_tid
71+
.map(|t| format!("<DelegatedUserTid>{t}</DelegatedUserTid>"))
72+
.unwrap_or_default();
73+
let xml_body = format!(
74+
r#"<?xml version="1.0" encoding="utf-8"?>
75+
<KeyInfo>
76+
<Start>{start_str}</Start>
77+
<Expiry>{expiry_str}</Expiry>
78+
{dutid_elem}
79+
</KeyInfo>
80+
"#
81+
);
82+
83+
let response = client
84+
.post(key_url)
85+
.header("Authorization", format!("Bearer {}", token.token.secret()))
86+
.header("x-ms-version", signed_version)
87+
.header("Content-Type", "application/xml")
88+
.body(xml_body)
89+
.send()
90+
.await?;
91+
92+
if !response.status().is_success() {
93+
return Err(SasError::DelegationKeyError {
94+
status: response.status().as_u16(),
95+
message: response.text().await?,
96+
});
97+
}
98+
99+
let xml = response.text().await?;
100+
Ok(quick_xml::de::from_str(&xml)?)
101+
}
102+
103+
/// Signs `string_to_sign` with HMAC-SHA256 using this key's value.
104+
///
105+
/// <https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas#specify-the-signature>
106+
pub(crate) fn compute_signature(&self, string_to_sign: &str) -> Result<String, SasError> {
107+
let key_bytes = STANDARD.decode(&self.value)?;
108+
let mut mac =
109+
Hmac::<Sha256>::new_from_slice(&key_bytes).map_err(|_| SasError::HmacError)?;
110+
mac.update(string_to_sign.as_bytes());
111+
Ok(STANDARD.encode(mac.finalize().into_bytes()))
112+
}
113+
}
114+
115+
/// Fetches a [`UserDelegationKey`] independently of any [`crate::UserDelegationSasBuilder`].
116+
///
117+
/// Use this when you want to obtain a key once and share it across multiple builders
118+
/// via [`crate::UserDelegationSasBuilder::with_key`], without tying the fetch to any
119+
/// particular resource or permission set.
120+
///
121+
/// `GetUserDelegationKey` is a Blob Storage service operation, so the key is always fetched
122+
/// from the blob endpoint regardless of which storage service you plan to use the SAS for.
123+
///
124+
/// # Example
125+
///
126+
/// ```ignore
127+
/// use std::sync::Arc;
128+
/// use azure_identity::DefaultAzureCredential;
129+
/// use azure_storage_sas::{UserDelegationKeyFetcher, UserDelegationSasBuilder};
130+
/// use azure_storage_sas::blob::{BlobResource, BlobSasPermissions};
131+
///
132+
/// let credential = Arc::new(DefaultAzureCredential::default());
133+
/// let key = UserDelegationKeyFetcher::new("myaccount", credential)
134+
/// .fetch()
135+
/// .await?;
136+
///
137+
/// for blob in blobs {
138+
/// let url = UserDelegationSasBuilder::new("myaccount", blob, permissions, expiry)
139+
/// .with_key(key.clone())
140+
/// .build()?;
141+
/// }
142+
/// ```
143+
pub struct UserDelegationKeyFetcher {
144+
account: String,
145+
credential: Arc<dyn TokenCredential>,
146+
key_expiry: Duration,
147+
endpoint: Option<Url>,
148+
http_client: Option<reqwest::Client>,
149+
version: Option<String>,
150+
delegated_user_tenant_id: Option<Uuid>,
151+
}
152+
153+
impl UserDelegationKeyFetcher {
154+
/// Azure-enforced upper limit on user delegation key validity.
155+
pub const MAX_KEY_EXPIRY: Duration = Duration::days(7);
156+
157+
/// Creates a new fetcher for the given storage account.
158+
pub fn new(account: impl Into<String>, credential: Arc<dyn TokenCredential>) -> Self {
159+
Self {
160+
account: account.into(),
161+
credential,
162+
key_expiry: Self::MAX_KEY_EXPIRY,
163+
endpoint: None,
164+
http_client: None,
165+
version: None,
166+
delegated_user_tenant_id: None,
167+
}
168+
}
169+
170+
/// Sets the validity period of the delegation key.
171+
///
172+
/// Defaults to [`Self::MAX_KEY_EXPIRY`].
173+
pub fn key_expiry(mut self, duration: Duration) -> Self {
174+
self.key_expiry = duration;
175+
self
176+
}
177+
178+
/// Overrides the blob service endpoint used to call `GetUserDelegationKey`.
179+
///
180+
/// Defaults to `https://{account}.blob.core.windows.net`. Override for local emulators,
181+
/// sovereign clouds, or custom domains.
182+
pub fn endpoint(mut self, endpoint: Url) -> Self {
183+
self.endpoint = Some(endpoint);
184+
self
185+
}
186+
187+
/// Provides a shared [`reqwest::Client`] to reuse an existing connection pool.
188+
pub fn http_client(mut self, client: reqwest::Client) -> Self {
189+
self.http_client = Some(client);
190+
self
191+
}
192+
193+
/// Overrides the API version sent in `x-ms-version`.
194+
///
195+
/// Defaults to [`crate::blob::BLOB_DEFAULT_VERSION`].
196+
pub fn api_version(mut self, version: impl Into<String>) -> Self {
197+
self.version = Some(version.into());
198+
self
199+
}
200+
201+
/// Sets the tenant ID of the delegated user for cross-tenant scenarios.
202+
///
203+
/// When provided, it is sent as `<DelegatedUserTid>` in the `GetUserDelegationKey`
204+
/// request body. Pass the same value as `delegated_user_tenant_id` on the SAS builder
205+
/// to include it as `skdutid` in the signed token.
206+
pub fn delegated_user_tenant_id(mut self, tid: Uuid) -> Self {
207+
self.delegated_user_tenant_id = Some(tid);
208+
self
209+
}
210+
211+
/// Fetches the user delegation key from Azure Storage.
212+
///
213+
/// # Errors
214+
///
215+
/// - [`SasError::KeyExpiryTooLong`] — `key_expiry` exceeds [`Self::MAX_KEY_EXPIRY`].
216+
/// - [`SasError::TokenError`] — credential failed to produce a token.
217+
/// - [`SasError::DelegationKeyError`] — the storage service rejected the key request.
218+
/// - [`SasError::HttpError`] — a network error occurred.
219+
pub async fn fetch(mut self) -> Result<UserDelegationKey, SasError> {
220+
if self.key_expiry > Self::MAX_KEY_EXPIRY {
221+
return Err(SasError::KeyExpiryTooLong);
222+
}
223+
224+
let now = OffsetDateTime::now_utc();
225+
let start_str = format_sas_time(now)?;
226+
let expiry_str = format_sas_time(now + self.key_expiry)?;
227+
228+
let endpoint = self.endpoint.unwrap_or_else(|| {
229+
Url::parse(&format!("https://{}.blob.core.windows.net", self.account))
230+
.expect("valid blob endpoint URL")
231+
});
232+
let version = self
233+
.version
234+
.unwrap_or_else(|| crate::resource::blob::BLOB_DEFAULT_VERSION.to_owned());
235+
236+
let delegated_user_tid_str = self.delegated_user_tenant_id.map(|u| u.to_string());
237+
let client = self
238+
.http_client
239+
.get_or_insert_with(reqwest::Client::default);
240+
241+
UserDelegationKey::fetch(
242+
&endpoint,
243+
&version,
244+
&self.credential,
245+
client,
246+
&start_str,
247+
&expiry_str,
248+
delegated_user_tid_str.as_deref(),
249+
)
250+
.await
251+
}
252+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#![doc = include_str!("../README.md")]
2+
3+
mod key;
4+
pub(crate) mod resource;
5+
pub(crate) mod sas;
6+
7+
#[cfg(test)]
8+
pub(crate) mod test_utils;
9+
10+
pub mod error;
11+
pub use key::{UserDelegationKey, UserDelegationKeyFetcher};
12+
pub use resource::{blob, file, queue, table, Resource};
13+
pub use sas::builder::UserDelegationSasBuilder;
14+
pub use sas::SignedProtocol;
15+
16+
/// Re-export of the [`time`] crate.
17+
pub use time;
18+
/// Re-export of the [`url`] crate.
19+
pub use url;

0 commit comments

Comments
 (0)