|
| 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 | +} |
0 commit comments