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)
+ }
+}