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

xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git


The following commit(s) were added to refs/heads/main by this push:
     new ab9b0fc  feat(azure): Add scoped SAS support (#672)
ab9b0fc is described below

commit ab9b0fcb8c6cf58a14e9ab33d9854ea7e0d2c091
Author: Xuanwo <[email protected]>
AuthorDate: Fri Dec 26 20:13:31 2025 +0800

    feat(azure): Add scoped SAS support (#672)
    
    This PR will add scoped SAS support
    
    
    ---
    
    **Parts of this PR were drafted with assistance from Codex (with
    `gpt-5.2`) and fully reviewed and edited by me. I take full
    responsibility for all changes.**
---
 services/azure-storage/src/account_sas.rs     | 148 ---------
 services/azure-storage/src/lib.rs             |   5 +-
 services/azure-storage/src/service_sas.rs     | 260 +++++++++++++++
 services/azure-storage/src/sign_request.rs    | 439 ++++++++++++++++++++++++--
 services/azure-storage/src/user_delegation.rs | 263 +++++++++++++++
 5 files changed, 944 insertions(+), 171 deletions(-)

diff --git a/services/azure-storage/src/account_sas.rs 
b/services/azure-storage/src/account_sas.rs
deleted file mode 100644
index 916c336..0000000
--- a/services/azure-storage/src/account_sas.rs
+++ /dev/null
@@ -1,148 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-use reqsign_core::Result;
-
-use reqsign_core::hash;
-use reqsign_core::time::Timestamp;
-
-/// The default parameters that make up a SAS token
-/// 
https://learn.microsoft.com/en-us/rest/api/storageservices/create-account-sas#specify-the-account-sas-parameters
-const ACCOUNT_SAS_VERSION: &str = "2018-11-09";
-const ACCOUNT_SAS_RESOURCE: &str = "bqtf";
-const ACCOUNT_SAS_RESOURCE_TYPE: &str = "sco";
-const ACCOUNT_SAS_PERMISSIONS: &str = "rwdlacu";
-
-pub struct AccountSharedAccessSignature {
-    account: String,
-    key: String,
-    version: String,
-    resource: String,
-    resource_type: String,
-    permissions: String,
-    expiry: Timestamp,
-    start: Option<Timestamp>,
-    ip: Option<String>,
-    protocol: Option<String>,
-}
-
-impl AccountSharedAccessSignature {
-    /// Create a SAS token signer with default parameters
-    pub fn new(account: String, key: String, expiry: Timestamp) -> Self {
-        Self {
-            account,
-            key,
-            expiry,
-            start: None,
-            ip: None,
-            protocol: None,
-            version: ACCOUNT_SAS_VERSION.to_string(),
-            resource: ACCOUNT_SAS_RESOURCE.to_string(),
-            resource_type: ACCOUNT_SAS_RESOURCE_TYPE.to_string(),
-            permissions: ACCOUNT_SAS_PERMISSIONS.to_string(),
-        }
-    }
-
-    // Azure documentation: 
https://learn.microsoft.com/en-us/rest/api/storageservices/create-account-sas#construct-the-signature-string
-    fn signature(&self) -> Result<String> {
-        let string_to_sign = format!(
-            "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n",
-            self.account,
-            self.permissions,
-            self.resource,
-            self.resource_type,
-            self.start
-                .as_ref()
-                .map_or("".to_string(), |v| 
urlencoded(v.format_rfc3339_zulu())),
-            self.expiry.format_rfc3339_zulu(),
-            self.ip.clone().unwrap_or_default(),
-            self.protocol
-                .as_ref()
-                .map_or("".to_string(), |v| v.to_string()),
-            self.version,
-        );
-
-        let decode_content = hash::base64_decode(self.key.clone().as_str())?;
-
-        Ok(hash::base64_hmac_sha256(
-            &decode_content,
-            string_to_sign.as_bytes(),
-        ))
-    }
-
-    /// 
[Example](https://docs.microsoft.com/rest/api/storageservices/create-service-sas#service-sas-example)
 from Azure documentation.
-    pub fn token(&self) -> Result<Vec<(String, String)>> {
-        let mut elements: Vec<(String, String)> = vec![
-            ("sv".to_string(), self.version.to_string()),
-            ("ss".to_string(), self.resource.to_string()),
-            ("srt".to_string(), self.resource_type.to_string()),
-            (
-                "se".to_string(),
-                urlencoded(self.expiry.format_rfc3339_zulu()),
-            ),
-            ("sp".to_string(), self.permissions.to_string()),
-        ];
-
-        if let Some(start) = &self.start {
-            elements.push(("st".to_string(), 
urlencoded(start.format_rfc3339_zulu())))
-        }
-        if let Some(ip) = &self.ip {
-            elements.push(("sip".to_string(), ip.to_string()))
-        }
-        if let Some(protocol) = &self.protocol {
-            elements.push(("spr".to_string(), protocol.to_string()))
-        }
-
-        let sig = AccountSharedAccessSignature::signature(self)?;
-        elements.push(("sig".to_string(), urlencoded(sig)));
-
-        Ok(elements)
-    }
-}
-
-fn urlencoded(s: String) -> String {
-    form_urlencoded::byte_serialize(s.as_bytes()).collect()
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use std::str::FromStr;
-    use std::time::Duration;
-
-    fn test_time() -> Timestamp {
-        Timestamp::from_str("2022-03-01T08:12:34Z").unwrap()
-    }
-
-    #[test]
-    fn test_can_generate_sas_token() {
-        let key = hash::base64_encode("key".as_bytes());
-        let expiry = test_time() + Duration::from_secs(300);
-        let sign = AccountSharedAccessSignature::new("account".to_string(), 
key, expiry);
-        let token_content = sign.token().expect("token decode failed");
-        let token = token_content
-            .iter()
-            .map(|(k, v)| format!("{k}={v}"))
-            .collect::<Vec<String>>()
-            .join("&");
-
-        assert_eq!(
-            token,
-            
"sv=2018-11-09&ss=bqtf&srt=sco&se=2022-03-01T08%3A17%3A34Z&sp=rwdlacu&sig=jgK9nDUT0ntH%2Fp28LPs0jzwxsk91W6hePLPlfrElv4k%3D"
-        );
-    }
-}
diff --git a/services/azure-storage/src/lib.rs 
b/services/azure-storage/src/lib.rs
index 9d757a3..180c756 100644
--- a/services/azure-storage/src/lib.rs
+++ b/services/azure-storage/src/lib.rs
@@ -156,12 +156,15 @@
 //! - [Blob storage operations](examples/blob_storage.rs)
 //! - [SAS token generation](examples/sas_token.rs)
 
-mod account_sas;
 mod constants;
+mod service_sas;
+mod user_delegation;
 
 mod credential;
 pub use credential::Credential;
 
+pub use service_sas::{ServiceSasResource, ServiceSharedAccessSignature};
+
 mod sign_request;
 pub use sign_request::RequestSigner;
 
diff --git a/services/azure-storage/src/service_sas.rs 
b/services/azure-storage/src/service_sas.rs
new file mode 100644
index 0000000..a5e68e6
--- /dev/null
+++ b/services/azure-storage/src/service_sas.rs
@@ -0,0 +1,260 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use reqsign_core::Result;
+use reqsign_core::hash;
+use reqsign_core::time::Timestamp;
+
+const SERVICE_SAS_VERSION: &str = "2020-12-06";
+const BLOB_SERVICE: &str = "blob";
+
+/// Resource level for Azure Storage Service SAS.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum ServiceSasResource {
+    /// A container resource.
+    Container { container: String },
+    /// A blob resource.
+    Blob { container: String, blob: String },
+}
+
+impl ServiceSasResource {
+    /// Build a resource from a request path.
+    ///
+    /// The input path must be percent-decoded.
+    pub fn from_path_percent_decoded(path: &str) -> Result<Self> {
+        let path = path.strip_prefix('/').unwrap_or(path);
+        let mut segments = path.split('/').filter(|v| !v.is_empty());
+
+        let container = segments
+            .next()
+            .ok_or_else(|| reqsign_core::Error::request_invalid("missing 
container in path"))?
+            .to_string();
+
+        let rest = segments.collect::<Vec<_>>();
+        if rest.is_empty() {
+            Ok(ServiceSasResource::Container { container })
+        } else {
+            Ok(ServiceSasResource::Blob {
+                container,
+                blob: rest.join("/"),
+            })
+        }
+    }
+
+    pub(crate) fn signed_resource(&self) -> &'static str {
+        match self {
+            ServiceSasResource::Container { .. } => "c",
+            ServiceSasResource::Blob { .. } => "b",
+        }
+    }
+
+    pub(crate) fn canonicalized_resource(&self, account: &str) -> String {
+        match self {
+            ServiceSasResource::Container { container } => {
+                format!("/{BLOB_SERVICE}/{account}/{container}")
+            }
+            ServiceSasResource::Blob { container, blob } => {
+                format!("/{BLOB_SERVICE}/{account}/{container}/{blob}")
+            }
+        }
+    }
+}
+
+/// Service SAS generator using Shared Key.
+///
+/// Reference: 
<https://learn.microsoft.com/en-us/rest/api/storageservices/create-service-sas>
+pub struct ServiceSharedAccessSignature {
+    account: String,
+    key: String,
+
+    resource: ServiceSasResource,
+    permissions: String,
+    expiry: Timestamp,
+    start: Option<Timestamp>,
+    ip: Option<String>,
+    protocol: Option<String>,
+    version: String,
+}
+
+impl ServiceSharedAccessSignature {
+    /// Create a Service SAS signer.
+    pub fn new(
+        account: String,
+        key: String,
+        resource: ServiceSasResource,
+        permissions: String,
+        expiry: Timestamp,
+    ) -> Self {
+        Self {
+            account,
+            key,
+            resource,
+            permissions,
+            expiry,
+            start: None,
+            ip: None,
+            protocol: None,
+            version: SERVICE_SAS_VERSION.to_string(),
+        }
+    }
+
+    /// Set the start time.
+    pub fn with_start(mut self, start: Timestamp) -> Self {
+        self.start = Some(start);
+        self
+    }
+
+    /// Set the IP restriction.
+    pub fn with_ip(mut self, ip: impl Into<String>) -> Self {
+        self.ip = Some(ip.into());
+        self
+    }
+
+    /// Set the allowed protocol.
+    pub fn with_protocol(mut self, protocol: impl Into<String>) -> Self {
+        self.protocol = Some(protocol.into());
+        self
+    }
+
+    /// Set the service version.
+    pub fn with_version(mut self, version: impl Into<String>) -> Self {
+        self.version = version.into();
+        self
+    }
+
+    fn signature(&self) -> Result<String> {
+        let canonicalized_resource = 
self.resource.canonicalized_resource(&self.account);
+
+        // Signed identifier (si), snapshot time, encryption scope, response 
headers are not
+        // supported for now. Keep them empty.
+        let string_to_sign = format!(
+            "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
+            self.permissions,
+            self.start
+                .as_ref()
+                .map_or("".to_string(), |v| v.format_rfc3339_zulu()),
+            self.expiry.format_rfc3339_zulu(),
+            canonicalized_resource,
+            "",                                        // si
+            self.ip.clone().unwrap_or_default(),       // sip
+            self.protocol.clone().unwrap_or_default(), // spr
+            &self.version,                             // sv
+            self.resource.signed_resource(),           // sr
+            "",                                        // snapshot time
+            "",                                        // encryption scope
+            "",                                        // rscc
+            "",                                        // rscd
+            "",                                        // rsce
+            "",                                        // rscl
+            "",                                        // rsct
+        );
+
+        let decoded_key = hash::base64_decode(&self.key)?;
+        Ok(hash::base64_hmac_sha256(
+            &decoded_key,
+            string_to_sign.as_bytes(),
+        ))
+    }
+
+    /// Generate SAS query parameters.
+    pub fn token(&self) -> Result<Vec<(String, String)>> {
+        let mut elements: Vec<(String, String)> = vec![
+            ("sv".to_string(), self.version.to_string()),
+            ("se".to_string(), self.expiry.format_rfc3339_zulu()),
+            ("sp".to_string(), self.permissions.to_string()),
+            (
+                "sr".to_string(),
+                self.resource.signed_resource().to_string(),
+            ),
+        ];
+
+        if let Some(start) = &self.start {
+            elements.push(("st".to_string(), start.format_rfc3339_zulu()))
+        }
+        if let Some(ip) = &self.ip {
+            elements.push(("sip".to_string(), ip.to_string()))
+        }
+        if let Some(protocol) = &self.protocol {
+            elements.push(("spr".to_string(), protocol.to_string()))
+        }
+
+        let sig = self.signature()?;
+        elements.push(("sig".to_string(), sig));
+
+        Ok(elements)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::str::FromStr;
+    use std::time::Duration;
+
+    fn test_time() -> Timestamp {
+        Timestamp::from_str("2022-03-01T08:12:34Z").unwrap()
+    }
+
+    #[test]
+    fn test_can_generate_service_sas_token_for_blob() {
+        let key = hash::base64_encode("key".as_bytes());
+        let expiry = test_time() + Duration::from_secs(300);
+
+        let resource = ServiceSasResource::Blob {
+            container: "container".to_string(),
+            blob: "path/to/blob.txt".to_string(),
+        };
+
+        let sign = ServiceSharedAccessSignature::new(
+            "account".to_string(),
+            key,
+            resource,
+            "r".to_string(),
+            expiry,
+        );
+
+        let token_content = sign.token().expect("token generation failed");
+        let token = token_content
+            .iter()
+            .map(|(k, v)| format!("{k}={v}"))
+            .collect::<Vec<String>>()
+            .join("&");
+
+        assert_eq!(
+            token,
+            
"sv=2020-12-06&se=2022-03-01T08:17:34Z&sp=r&sr=b&sig=CP9a2LIrR9zeG4I4jZjqPetJSXWJ77QeUA7c3GMypyM="
+        );
+    }
+
+    #[test]
+    fn test_service_sas_resource_from_path() {
+        assert_eq!(
+            
ServiceSasResource::from_path_percent_decoded("/container").unwrap(),
+            ServiceSasResource::Container {
+                container: "container".to_string()
+            }
+        );
+
+        assert_eq!(
+            
ServiceSasResource::from_path_percent_decoded("/container/blob").unwrap(),
+            ServiceSasResource::Blob {
+                container: "container".to_string(),
+                blob: "blob".to_string()
+            }
+        );
+    }
+}
diff --git a/services/azure-storage/src/sign_request.rs 
b/services/azure-storage/src/sign_request.rs
index 47384a4..75dd702 100644
--- a/services/azure-storage/src/sign_request.rs
+++ b/services/azure-storage/src/sign_request.rs
@@ -26,20 +26,141 @@ use reqsign_core::hash::{base64_decode, 
base64_hmac_sha256};
 use reqsign_core::time::Timestamp;
 use reqsign_core::{Context, Result, SignRequest, SigningMethod, 
SigningRequest};
 use std::fmt::Write;
+use std::sync::Mutex;
 use std::time::Duration;
 
+/// Resource kind required by SAS generation.
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum SasResourceKind {
+    /// Container SAS.
+    Container,
+    /// Blob SAS.
+    Blob,
+}
+
 /// RequestSigner that implement Azure Storage Shared Key Authorization.
 ///
 /// - [Authorize with Shared 
Key](https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key)
 #[derive(Debug)]
 pub struct RequestSigner {
     time: Option<Timestamp>,
+    service_sas_permissions: Option<String>,
+    service_sas_start: Option<Timestamp>,
+    service_sas_ip: Option<String>,
+    service_sas_protocol: Option<String>,
+    service_sas_version: Option<String>,
+
+    user_delegation_presign: Option<UserDelegationPresignConfig>,
+    user_delegation_key_cache: 
Mutex<Option<crate::user_delegation::UserDelegationKey>>,
+}
+
+#[derive(Clone, Debug)]
+struct UserDelegationPresignConfig {
+    resource: SasResourceKind,
+    permissions: String,
+    start: Option<Timestamp>,
+    ip: Option<String>,
+    protocol: Option<String>,
+    version: Option<String>,
 }
 
 impl RequestSigner {
     /// Create a new builder for Azure Storage signer.
     pub fn new() -> Self {
-        Self { time: None }
+        Self {
+            time: None,
+            service_sas_permissions: None,
+            service_sas_start: None,
+            service_sas_ip: None,
+            service_sas_protocol: None,
+            service_sas_version: None,
+
+            user_delegation_presign: None,
+            user_delegation_key_cache: Mutex::new(None),
+        }
+    }
+
+    /// Configure Service SAS presign permissions for Shared Key query signing.
+    ///
+    /// This setting is required when `expires_in` is provided and credential 
is `SharedKey`.
+    pub fn with_service_sas_permissions(mut self, permissions: &str) -> Self {
+        self.service_sas_permissions = Some(permissions.to_string());
+        self
+    }
+
+    /// Configure Service SAS presign start time for Shared Key query signing.
+    pub fn with_service_sas_start(mut self, start: Timestamp) -> Self {
+        self.service_sas_start = Some(start);
+        self
+    }
+
+    /// Configure Service SAS presign allowed IP range.
+    pub fn with_service_sas_ip(mut self, ip: &str) -> Self {
+        self.service_sas_ip = Some(ip.to_string());
+        self
+    }
+
+    /// Configure Service SAS presign allowed protocol, e.g. `https` or 
`https,http`.
+    pub fn with_service_sas_protocol(mut self, protocol: &str) -> Self {
+        self.service_sas_protocol = Some(protocol.to_string());
+        self
+    }
+
+    /// Configure Service SAS presign service version.
+    pub fn with_service_sas_version(mut self, version: &str) -> Self {
+        self.service_sas_version = Some(version.to_string());
+        self
+    }
+
+    /// Enable User Delegation SAS presign for Bearer Token query signing.
+    ///
+    /// This is only used when `expires_in` is provided and credential is 
`BearerToken`.
+    pub fn with_user_delegation_presign(
+        mut self,
+        resource: SasResourceKind,
+        permissions: &str,
+    ) -> Self {
+        self.user_delegation_presign = Some(UserDelegationPresignConfig {
+            resource,
+            permissions: permissions.to_string(),
+            start: None,
+            ip: None,
+            protocol: None,
+            version: None,
+        });
+        self
+    }
+
+    /// Configure User Delegation SAS presign start time.
+    pub fn with_user_delegation_start(mut self, start: Timestamp) -> Self {
+        if let Some(cfg) = self.user_delegation_presign.as_mut() {
+            cfg.start = Some(start);
+        }
+        self
+    }
+
+    /// Configure User Delegation SAS presign allowed IP range.
+    pub fn with_user_delegation_ip(mut self, ip: &str) -> Self {
+        if let Some(cfg) = self.user_delegation_presign.as_mut() {
+            cfg.ip = Some(ip.to_string());
+        }
+        self
+    }
+
+    /// Configure User Delegation SAS presign allowed protocol, e.g. `https` 
or `https,http`.
+    pub fn with_user_delegation_protocol(mut self, protocol: &str) -> Self {
+        if let Some(cfg) = self.user_delegation_presign.as_mut() {
+            cfg.protocol = Some(protocol.to_string());
+        }
+        self
+    }
+
+    /// Configure User Delegation SAS presign service version.
+    pub fn with_user_delegation_version(mut self, version: &str) -> Self {
+        if let Some(cfg) = self.user_delegation_presign.as_mut() {
+            cfg.version = Some(version.to_string());
+        }
+        self
     }
 
     /// Specify the signing time.
@@ -67,7 +188,7 @@ impl SignRequest for RequestSigner {
 
     async fn sign_request(
         &self,
-        _: &Context,
+        context: &Context,
         req: &mut Parts,
         credential: Option<&Self::Credential>,
         expires_in: Option<Duration>,
@@ -82,31 +203,142 @@ impl SignRequest for RequestSigner {
             SigningMethod::Header
         };
 
-        let mut ctx = SigningRequest::build(req)?;
+        let mut sctx = SigningRequest::build(req)?;
 
         // Handle different credential types
         match cred {
             Credential::SasToken { token } => {
                 // SAS token authentication
-                ctx.query_append(token);
+                sctx.query_append(token);
             }
             Credential::BearerToken { token, .. } => {
                 // Bearer token authentication
                 match method {
-                    SigningMethod::Query(_) => {
-                        return Err(reqsign_core::Error::request_invalid(
-                            "BearerToken can't be used in query string",
-                        ));
+                    SigningMethod::Query(d) => {
+                        let Some(cfg) = &self.user_delegation_presign else {
+                            return Err(reqsign_core::Error::request_invalid(
+                                "BearerToken can't be used in query string",
+                            ));
+                        };
+
+                        let now_time = 
self.time.unwrap_or_else(Timestamp::now);
+                        let expiry = now_time + d;
+
+                        let resource =
+                            
crate::service_sas::ServiceSasResource::from_path_percent_decoded(
+                                sctx.path_percent_decoded().as_ref(),
+                            )?;
+                        match (cfg.resource, &resource) {
+                            (
+                                SasResourceKind::Container,
+                                
crate::service_sas::ServiceSasResource::Container { .. },
+                            ) => {}
+                            (
+                                SasResourceKind::Blob,
+                                crate::service_sas::ServiceSasResource::Blob { 
.. },
+                            ) => {}
+                            _ => {
+                                return 
Err(reqsign_core::Error::request_invalid(
+                                    "request resource doesn't match configured 
SAS resource kind",
+                                ));
+                            }
+                        }
+
+                        let account = 
infer_account_name(sctx.authority.as_str())?;
+
+                        let key = {
+                            let cached = self
+                                .user_delegation_key_cache
+                                .lock()
+                                .expect("lock poisoned")
+                                .clone();
+                            if let Some(cached) = cached {
+                                if cached.signed_expiry > expiry + 
Duration::from_secs(20) {
+                                    cached
+                                } else {
+                                    let version = 
cfg.version.as_deref().unwrap_or("2020-12-06");
+                                    let fetched = 
crate::user_delegation::get_user_delegation_key(
+                                        context,
+                                        
crate::user_delegation::UserDelegationKeyRequest {
+                                            scheme: sctx.scheme.as_str(),
+                                            authority: sctx.authority.as_str(),
+                                            bearer_token: token,
+                                            start: now_time,
+                                            expiry,
+                                            service_version: version,
+                                            now: now_time,
+                                        },
+                                    )
+                                    .await?;
+                                    *self
+                                        .user_delegation_key_cache
+                                        .lock()
+                                        .expect("lock poisoned") = 
Some(fetched.clone());
+                                    fetched
+                                }
+                            } else {
+                                let version = 
cfg.version.as_deref().unwrap_or("2020-12-06");
+                                let fetched = 
crate::user_delegation::get_user_delegation_key(
+                                    context,
+                                    
crate::user_delegation::UserDelegationKeyRequest {
+                                        scheme: sctx.scheme.as_str(),
+                                        authority: sctx.authority.as_str(),
+                                        bearer_token: token,
+                                        start: now_time,
+                                        expiry,
+                                        service_version: version,
+                                        now: now_time,
+                                    },
+                                )
+                                .await?;
+                                *self
+                                    .user_delegation_key_cache
+                                    .lock()
+                                    .expect("lock poisoned") = 
Some(fetched.clone());
+                                fetched
+                            }
+                        };
+
+                        let mut signer =
+                            
crate::user_delegation::UserDelegationSharedAccessSignature::new(
+                                account,
+                                key,
+                                resource,
+                                cfg.permissions.to_string(),
+                                expiry,
+                            );
+                        if let Some(start) = cfg.start {
+                            signer = signer.with_start(start);
+                        }
+                        if let Some(ip) = &cfg.ip {
+                            signer = signer.with_ip(ip);
+                        }
+                        if let Some(protocol) = &cfg.protocol {
+                            signer = signer.with_protocol(protocol);
+                        }
+                        if let Some(version) = &cfg.version {
+                            signer = signer.with_version(version);
+                        }
+
+                        let signer_token = signer.token().map_err(|e| {
+                            reqsign_core::Error::unexpected(
+                                "failed to generate user delegation SAS token",
+                            )
+                            .with_source(e)
+                        })?;
+                        signer_token
+                            .into_iter()
+                            .for_each(|(k, v)| sctx.query_push(k, v));
                     }
                     SigningMethod::Header => {
-                        ctx.headers.insert(
+                        sctx.headers.insert(
                             X_MS_DATE,
                             
Timestamp::now().format_http_date().parse().map_err(|e| {
                                 reqsign_core::Error::unexpected("failed to 
parse date header")
                                     .with_source(e)
                             })?,
                         );
-                        ctx.headers.insert(header::AUTHORIZATION, {
+                        sctx.headers.insert(header::AUTHORIZATION, {
                             let mut value: HeaderValue =
                                 format!("Bearer {token}").parse().map_err(|e| {
                                     reqsign_core::Error::unexpected(
@@ -127,23 +359,49 @@ impl SignRequest for RequestSigner {
                 // Shared key authentication
                 match method {
                     SigningMethod::Query(d) => {
-                        // try sign request use account_sas token
-                        let signer = 
crate::account_sas::AccountSharedAccessSignature::new(
+                        let now_time = 
self.time.unwrap_or_else(Timestamp::now);
+                        let Some(permissions) = &self.service_sas_permissions 
else {
+                            return Err(reqsign_core::Error::request_invalid(
+                                "Service SAS permissions are required for 
presign",
+                            ));
+                        };
+
+                        let resource =
+                            
crate::service_sas::ServiceSasResource::from_path_percent_decoded(
+                                sctx.path_percent_decoded().as_ref(),
+                            )?;
+
+                        let mut signer = 
crate::service_sas::ServiceSharedAccessSignature::new(
                             account_name.clone(),
                             account_key.clone(),
-                            Timestamp::now() + d,
+                            resource,
+                            permissions.to_string(),
+                            now_time + d,
                         );
+                        if let Some(start) = self.service_sas_start {
+                            signer = signer.with_start(start);
+                        }
+                        if let Some(ip) = &self.service_sas_ip {
+                            signer = signer.with_ip(ip);
+                        }
+                        if let Some(protocol) = &self.service_sas_protocol {
+                            signer = signer.with_protocol(protocol);
+                        }
+                        if let Some(version) = &self.service_sas_version {
+                            signer = signer.with_version(version);
+                        }
+
                         let signer_token = signer.token().map_err(|e| {
-                            reqsign_core::Error::unexpected("failed to 
generate account SAS token")
+                            reqsign_core::Error::unexpected("failed to 
generate service SAS token")
                                 .with_source(e)
                         })?;
-                        signer_token.iter().for_each(|(k, v)| {
-                            ctx.query_push(k, v);
-                        });
+                        signer_token
+                            .into_iter()
+                            .for_each(|(k, v)| sctx.query_push(k, v));
                     }
                     SigningMethod::Header => {
                         let now_time = 
self.time.unwrap_or_else(Timestamp::now);
-                        let string_to_sign = string_to_sign(&mut ctx, 
account_name, now_time)?;
+                        let string_to_sign = string_to_sign(&mut sctx, 
account_name, now_time)?;
                         let decode_content = 
base64_decode(account_key).map_err(|e| {
                             reqsign_core::Error::unexpected("failed to decode 
account key")
                                 .with_source(e)
@@ -151,7 +409,7 @@ impl SignRequest for RequestSigner {
                         let signature =
                             base64_hmac_sha256(&decode_content, 
string_to_sign.as_bytes());
 
-                        ctx.headers.insert(header::AUTHORIZATION, {
+                        sctx.headers.insert(header::AUTHORIZATION, {
                             let mut value: HeaderValue =
                                 format!("SharedKey {account_name}:{signature}")
                                     .parse()
@@ -170,14 +428,25 @@ impl SignRequest for RequestSigner {
         }
 
         // Apply percent encoding for query parameters
-        for (_, v) in ctx.query.iter_mut() {
+        for (_, v) in sctx.query.iter_mut() {
             *v = percent_encode(v.as_bytes(), 
&AZURE_QUERY_ENCODE_SET).to_string();
         }
 
-        ctx.apply(req)
+        sctx.apply(req)
     }
 }
 
+fn infer_account_name(authority: &str) -> Result<String> {
+    let host = authority.split('@').next_back().unwrap_or(authority);
+    let host = host.split(':').next().unwrap_or(host);
+    let Some((account, _)) = host.split_once('.') else {
+        return Err(reqsign_core::Error::request_invalid(
+            "failed to infer account name from authority",
+        ));
+    };
+    Ok(account.to_string())
+}
+
 /// Construct string to sign
 ///
 /// ## Format
@@ -408,10 +677,13 @@ fn canonicalize_resource(ctx: &mut SigningRequest, 
account_name: &str) -> String
 #[cfg(test)]
 mod tests {
     use super::*;
+    use async_trait::async_trait;
+    use bytes::Bytes;
     use http::Request;
-    use reqsign_core::{Context, OsEnv};
+    use reqsign_core::{Context, HttpSend, OsEnv};
     use reqsign_file_read_tokio::TokioFileRead;
     use reqsign_http_send_reqwest::ReqwestHttpSend;
+    use std::str::FromStr;
     use std::time::Duration;
 
     #[tokio::test]
@@ -494,4 +766,127 @@ mod tests {
                 .is_err()
         );
     }
+
+    #[tokio::test]
+    async fn test_shared_key_presign_service_sas() {
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(ReqwestHttpSend::default())
+            .with_env(OsEnv);
+
+        let now = Timestamp::from_str("2022-03-01T08:12:34Z").unwrap();
+        let key = reqsign_core::hash::base64_encode("key".as_bytes());
+        let cred = Credential::with_shared_key("account", &key);
+
+        let builder = RequestSigner::new()
+            .with_time(now)
+            .with_service_sas_permissions("r");
+
+        let req = Request::builder()
+            
.uri("https://account.blob.core.windows.net/container/path/to/blob.txt";)
+            .body(())
+            .unwrap();
+        let (mut parts, _) = req.into_parts();
+
+        builder
+            .sign_request(
+                &ctx,
+                &mut parts,
+                Some(&cred),
+                Some(Duration::from_secs(300)),
+            )
+            .await
+            .unwrap();
+
+        assert_eq!(
+            parts.uri.to_string(),
+            
"https://account.blob.core.windows.net/container/path/to/blob.txt?sv=2020-12-06&se=2022-03-01T08%3A17%3A34Z&sp=r&sr=b&sig=CP9a2LIrR9zeG4I4jZjqPetJSXWJ77QeUA7c3GMypyM%3D";
+        );
+    }
+
+    #[derive(Debug)]
+    struct MockUserDelegationHttpSend;
+
+    #[async_trait]
+    impl HttpSend for MockUserDelegationHttpSend {
+        async fn http_send(
+            &self,
+            req: http::Request<Bytes>,
+        ) -> reqsign_core::Result<http::Response<Bytes>> {
+            let uri = req.uri().to_string();
+            if uri
+                != 
"https://account.blob.core.windows.net/?restype=service&comp=userdelegationkey";
+            {
+                return Err(
+                    reqsign_core::Error::unexpected("unexpected request 
uri").with_context(uri)
+                );
+            }
+
+            let auth = req
+                .headers()
+                .get("authorization")
+                .and_then(|v| v.to_str().ok())
+                .unwrap_or_default()
+                .to_string();
+            if auth != "Bearer token" {
+                return Err(
+                    reqsign_core::Error::unexpected("unexpected authorization 
header")
+                        .with_context(auth),
+                );
+            }
+
+            let body = r#"
+<UserDelegationKey>
+  <SignedOid>oid</SignedOid>
+  <SignedTid>tid</SignedTid>
+  <SignedStart>2022-03-01T08:12:34Z</SignedStart>
+  <SignedExpiry>2022-03-01T09:12:34Z</SignedExpiry>
+  <SignedService>b</SignedService>
+  <SignedVersion>2020-12-06</SignedVersion>
+  <Value>a2V5</Value>
+</UserDelegationKey>
+"#;
+
+            Ok(http::Response::builder()
+                .status(200)
+                .body(Bytes::from(body))
+                .unwrap())
+        }
+    }
+
+    #[tokio::test]
+    async fn test_bearer_token_presign_user_delegation_sas() {
+        let now = Timestamp::from_str("2022-03-01T08:12:34Z").unwrap();
+
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(MockUserDelegationHttpSend)
+            .with_env(OsEnv);
+
+        let cred = Credential::with_bearer_token("token", None);
+        let builder = RequestSigner::new()
+            .with_time(now)
+            .with_user_delegation_presign(SasResourceKind::Blob, "r");
+
+        let req = Request::builder()
+            
.uri("https://account.blob.core.windows.net/container/path/to/blob.txt";)
+            .body(())
+            .unwrap();
+        let (mut parts, _) = req.into_parts();
+
+        builder
+            .sign_request(
+                &ctx,
+                &mut parts,
+                Some(&cred),
+                Some(Duration::from_secs(300)),
+            )
+            .await
+            .unwrap();
+
+        assert_eq!(
+            parts.uri.to_string(),
+            
"https://account.blob.core.windows.net/container/path/to/blob.txt?sv=2020-12-06&se=2022-03-01T08%3A17%3A34Z&sp=r&sr=b&skoid=oid&sktid=tid&skt=2022-03-01T08%3A12%3A34Z&ske=2022-03-01T09%3A12%3A34Z&sks=b&skv=2020-12-06&sig=VkI3h/LWkD6qcDzshjQzCuCdMPDCFA3tMEbxM%2BED5Nc%3D";
+        );
+    }
 }
diff --git a/services/azure-storage/src/user_delegation.rs 
b/services/azure-storage/src/user_delegation.rs
new file mode 100644
index 0000000..5c3ccab
--- /dev/null
+++ b/services/azure-storage/src/user_delegation.rs
@@ -0,0 +1,263 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use bytes::Bytes;
+use http::header;
+use reqsign_core::Context;
+use reqsign_core::Result;
+use reqsign_core::hash;
+use reqsign_core::time::Timestamp;
+
+use crate::service_sas::ServiceSasResource;
+
+const DEFAULT_USER_DELEGATION_SAS_VERSION: &str = "2020-12-06";
+
+#[derive(Clone, Debug)]
+pub(crate) struct UserDelegationKey {
+    pub signed_oid: String,
+    pub signed_tid: String,
+    pub signed_start: Timestamp,
+    pub signed_expiry: Timestamp,
+    pub signed_service: String,
+    pub signed_version: String,
+    pub value: String,
+}
+
+pub(crate) struct UserDelegationKeyRequest<'a> {
+    pub scheme: &'a str,
+    pub authority: &'a str,
+    pub bearer_token: &'a str,
+    pub start: Timestamp,
+    pub expiry: Timestamp,
+    pub service_version: &'a str,
+    pub now: Timestamp,
+}
+
+pub(crate) async fn get_user_delegation_key(
+    ctx: &Context,
+    request: UserDelegationKeyRequest<'_>,
+) -> Result<UserDelegationKey> {
+    let uri: http::Uri = format!(
+        "{}://{}/?restype=service&comp=userdelegationkey",
+        request.scheme, request.authority
+    )
+    .parse()
+    .map_err(|e| {
+        reqsign_core::Error::request_invalid("invalid user delegation key 
URI").with_source(e)
+    })?;
+
+    let body = format!(
+        
"<UserDelegationKey><SignedStart>{}</SignedStart><SignedExpiry>{}</SignedExpiry></UserDelegationKey>",
+        request.start.format_rfc3339_zulu(),
+        request.expiry.format_rfc3339_zulu(),
+    );
+
+    let req = http::Request::post(uri)
+        .header("x-ms-version", request.service_version)
+        .header("x-ms-date", request.now.format_http_date())
+        .header(header::CONTENT_TYPE, "application/xml")
+        .header(
+            header::AUTHORIZATION,
+            format!("Bearer {}", request.bearer_token),
+        )
+        .body(Bytes::from(body))
+        .map_err(|e| {
+            reqsign_core::Error::unexpected("failed to build user delegation 
key request")
+                .with_source(e)
+        })?;
+
+    let resp = ctx.http_send(req).await?;
+    let (parts, body) = resp.into_parts();
+    if !parts.status.is_success() {
+        return Err(
+            reqsign_core::Error::unexpected("user delegation key request 
failed")
+                .with_context(format!("status: {}", parts.status)),
+        );
+    }
+
+    let xml = String::from_utf8_lossy(&body).to_string();
+
+    let signed_oid = extract_tag(&xml, "SignedOid")?;
+    let signed_tid = extract_tag(&xml, "SignedTid")?;
+    let signed_start = parse_timestamp(&extract_tag(&xml, "SignedStart")?)?;
+    let signed_expiry = parse_timestamp(&extract_tag(&xml, "SignedExpiry")?)?;
+    let signed_service = extract_tag(&xml, "SignedService")?;
+    let signed_version = extract_tag(&xml, "SignedVersion")?;
+    let value = extract_tag(&xml, "Value")?;
+
+    Ok(UserDelegationKey {
+        signed_oid,
+        signed_tid,
+        signed_start,
+        signed_expiry,
+        signed_service,
+        signed_version,
+        value,
+    })
+}
+
+fn parse_timestamp(s: &str) -> Result<Timestamp> {
+    s.parse::<Timestamp>()
+        .map_err(|e| reqsign_core::Error::request_invalid("invalid 
timestamp").with_source(e))
+}
+
+fn extract_tag(xml: &str, tag: &str) -> Result<String> {
+    let open = format!("<{tag}>");
+    let close = format!("</{tag}>");
+
+    let start = xml
+        .find(&open)
+        .ok_or_else(|| reqsign_core::Error::unexpected("missing xml 
tag").with_context(tag))?
+        + open.len();
+    let end = xml[start..]
+        .find(&close)
+        .ok_or_else(|| reqsign_core::Error::unexpected("missing xml end 
tag").with_context(tag))?
+        + start;
+
+    Ok(xml[start..end].trim().to_string())
+}
+
+pub(crate) struct UserDelegationSharedAccessSignature {
+    account: String,
+    key: UserDelegationKey,
+
+    resource: ServiceSasResource,
+    permissions: String,
+    expiry: Timestamp,
+    start: Option<Timestamp>,
+    ip: Option<String>,
+    protocol: Option<String>,
+    version: String,
+}
+
+impl UserDelegationSharedAccessSignature {
+    pub(crate) fn new(
+        account: String,
+        key: UserDelegationKey,
+        resource: ServiceSasResource,
+        permissions: String,
+        expiry: Timestamp,
+    ) -> Self {
+        Self {
+            account,
+            key,
+            resource,
+            permissions,
+            expiry,
+            start: None,
+            ip: None,
+            protocol: None,
+            version: DEFAULT_USER_DELEGATION_SAS_VERSION.to_string(),
+        }
+    }
+
+    pub(crate) fn with_start(mut self, start: Timestamp) -> Self {
+        self.start = Some(start);
+        self
+    }
+
+    pub(crate) fn with_ip(mut self, ip: impl Into<String>) -> Self {
+        self.ip = Some(ip.into());
+        self
+    }
+
+    pub(crate) fn with_protocol(mut self, protocol: impl Into<String>) -> Self 
{
+        self.protocol = Some(protocol.into());
+        self
+    }
+
+    pub(crate) fn with_version(mut self, version: impl Into<String>) -> Self {
+        self.version = version.into();
+        self
+    }
+
+    fn signature(&self) -> Result<String> {
+        let canonicalized_resource = 
self.resource.canonicalized_resource(&self.account);
+
+        let string_to_sign = format!(
+            
"{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
+            self.permissions,
+            self.start
+                .as_ref()
+                .map_or("".to_string(), |v| v.format_rfc3339_zulu()),
+            self.expiry.format_rfc3339_zulu(),
+            canonicalized_resource,
+            self.key.signed_oid,
+            self.key.signed_tid,
+            self.key.signed_start.format_rfc3339_zulu(),
+            self.key.signed_expiry.format_rfc3339_zulu(),
+            self.key.signed_service,
+            self.key.signed_version,
+            self.ip.clone().unwrap_or_default(),
+            self.protocol.clone().unwrap_or_default(),
+            &self.version,
+            self.resource.signed_resource(),
+            "", // snapshot time
+            "", // encryption scope
+            "", // rscc
+            "", // rscd
+            "", // rsce
+            "", // rscl
+            "", // rsct
+        );
+
+        let decoded_key = hash::base64_decode(&self.key.value)?;
+        Ok(hash::base64_hmac_sha256(
+            &decoded_key,
+            string_to_sign.as_bytes(),
+        ))
+    }
+
+    pub(crate) fn token(&self) -> Result<Vec<(String, String)>> {
+        let mut elements: Vec<(String, String)> = vec![
+            ("sv".to_string(), self.version.to_string()),
+            ("se".to_string(), self.expiry.format_rfc3339_zulu()),
+            ("sp".to_string(), self.permissions.to_string()),
+            (
+                "sr".to_string(),
+                self.resource.signed_resource().to_string(),
+            ),
+            ("skoid".to_string(), self.key.signed_oid.to_string()),
+            ("sktid".to_string(), self.key.signed_tid.to_string()),
+            (
+                "skt".to_string(),
+                self.key.signed_start.format_rfc3339_zulu(),
+            ),
+            (
+                "ske".to_string(),
+                self.key.signed_expiry.format_rfc3339_zulu(),
+            ),
+            ("sks".to_string(), self.key.signed_service.to_string()),
+            ("skv".to_string(), self.key.signed_version.to_string()),
+        ];
+
+        if let Some(start) = &self.start {
+            elements.push(("st".to_string(), start.format_rfc3339_zulu()))
+        }
+        if let Some(ip) = &self.ip {
+            elements.push(("sip".to_string(), ip.to_string()))
+        }
+        if let Some(protocol) = &self.protocol {
+            elements.push(("spr".to_string(), protocol.to_string()))
+        }
+
+        let sig = self.signature()?;
+        elements.push(("sig".to_string(), sig));
+
+        Ok(elements)
+    }
+}


Reply via email to