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 3dc1cf1  feat(aliyun-oss): add assume role credential provider (#724)
3dc1cf1 is described below

commit 3dc1cf19b2d69b35459cbcecabbd488bf0e69a5b
Author: Xuanwo <[email protected]>
AuthorDate: Thu Mar 19 02:50:16 2026 +0800

    feat(aliyun-oss): add assume role credential provider (#724)
    
    This PR adds an AK-based AssumeRole provider for `reqsign-aliyun-oss`
    and wires it into the default credential builder.
    
    It extends the OSS credential parity work with a non-recursive base
    provider chain and keeps the builder semantics aligned with the new
    slot-based API.
---
 services/aliyun-oss/README.md                      |  41 +-
 services/aliyun-oss/examples/oss_operations.rs     |   7 +-
 services/aliyun-oss/src/constants.rs               |   1 +
 services/aliyun-oss/src/lib.rs                     |  35 +-
 .../src/provide_credential/assume_role.rs          | 640 +++++++++++++++++++++
 .../aliyun-oss/src/provide_credential/default.rs   | 196 ++++++-
 services/aliyun-oss/src/provide_credential/mod.rs  |   3 +
 7 files changed, 896 insertions(+), 27 deletions(-)

diff --git a/services/aliyun-oss/README.md b/services/aliyun-oss/README.md
index 9deae14..27582ff 100644
--- a/services/aliyun-oss/README.md
+++ b/services/aliyun-oss/README.md
@@ -10,9 +10,10 @@ This crate provides signing support for Alibaba Cloud Object 
Storage Service (OS
 
 ```rust
 use reqsign_aliyun_oss::{
-    AssumeRoleWithOidcCredentialProvider, ConfigFileCredentialProvider,
-    CredentialsFileCredentialProvider, DefaultCredentialProvider, 
EnvCredentialProvider,
-    OssProfileCredentialProvider, RequestSigner, SigningVersion, 
StaticCredentialProvider,
+    AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
+    ConfigFileCredentialProvider, CredentialsFileCredentialProvider, 
DefaultCredentialProvider,
+    EnvCredentialProvider, OssProfileCredentialProvider, RequestSigner,
+    SigningVersion, StaticCredentialProvider,
 };
 use reqsign_core::{Context, Result, Signer};
 use reqsign_file_read_tokio::TokioFileRead;
@@ -25,6 +26,7 @@ async fn main() -> Result<()> {
         .with_http_send(ReqwestHttpSend::default());
 
     let loader = DefaultCredentialProvider::builder()
+        .assume_role(AssumeRoleCredentialProvider::new())
         .env(EnvCredentialProvider::new())
         .oss_profile(OssProfileCredentialProvider::new())
         .credentials_file(CredentialsFileCredentialProvider::new())
@@ -61,8 +63,7 @@ async fn main() -> Result<()> {
 
 ## Features
 
-- **V1 and V4 Signing**: Supports both legacy OSS V1 signatures and Signature 
V4
-- **Multiple Credential Sources**: Environment variables, OSS profile files, 
Alibaba shared credential/config files, and OIDC-based STS exchange
+- **Multiple Credential Sources**: Environment variables, OSS profile files, 
Alibaba shared credential/config files, AssumeRole, and OIDC-based STS exchange
 - **V1 and V4 Signing**: Supports both legacy OSS V1 signatures and Signature 
V4
 - **STS Support**: Temporary credentials via Security Token Service
 - **All OSS Operations**: Object, bucket, and multipart operations
@@ -179,6 +180,36 @@ let loader = DefaultCredentialProvider::builder()
 
 The session name defaults to `reqsign`. To customize it, set 
`ALIBABA_CLOUD_ROLE_SESSION_NAME` or use 
`AssumeRoleWithOidcCredentialProvider::with_role_session_name`.
 
+### STS AssumeRole with Base AK Credentials
+
+```rust
+use reqsign_aliyun_oss::{
+    AssumeRoleCredentialProvider, DefaultCredentialProvider, 
StaticCredentialProvider,
+};
+
+// Use an explicit base access key source to call STS AssumeRole.
+let loader = DefaultCredentialProvider::builder()
+    .no_env()
+    .no_oss_profile()
+    .no_credentials_file()
+    .no_config_file()
+    .assume_role(
+        AssumeRoleCredentialProvider::new()
+            .with_base_provider(StaticCredentialProvider::new(
+                "your-access-key-id",
+                "your-access-key-secret",
+            ))
+            .with_role_arn("acs:ram::123456789012:role/example")
+            .with_role_session_name("my-session"),
+    )
+    .no_oidc()
+    .build();
+```
+
+Or rely on the default static base chain by setting
+`ALIBABA_CLOUD_ACCESS_KEY_ID`, `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,
+`ALIBABA_CLOUD_ROLE_ARN`, and optionally `ALIBABA_CLOUD_EXTERNAL_ID`.
+
 ## OSS Operations
 
 ### Object Operations
diff --git a/services/aliyun-oss/examples/oss_operations.rs 
b/services/aliyun-oss/examples/oss_operations.rs
index 3de6745..09b5c6c 100644
--- a/services/aliyun-oss/examples/oss_operations.rs
+++ b/services/aliyun-oss/examples/oss_operations.rs
@@ -16,8 +16,8 @@
 // under the License.
 
 use reqsign_aliyun_oss::{
-    AssumeRoleWithOidcCredentialProvider, DefaultCredentialProvider, 
EnvCredentialProvider,
-    RequestSigner, StaticCredentialProvider,
+    AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider, 
DefaultCredentialProvider,
+    EnvCredentialProvider, RequestSigner, StaticCredentialProvider,
 };
 use reqsign_core::Result;
 use reqsign_core::{Context, OsEnv, Signer};
@@ -62,8 +62,9 @@ async fn main() -> Result<()> {
             StaticCredentialProvider::new("LTAI4GDemoAccessKeyId", 
"DemoAccessKeySecretForExample");
         Signer::new(ctx.clone(), loader, builder)
     } else {
-        // Build the default env -> oidc chain explicitly via slot APIs.
+        // Build the default assume_role -> env -> oidc chain explicitly via 
slot APIs.
         let loader = DefaultCredentialProvider::builder()
+            .assume_role(AssumeRoleCredentialProvider::new())
             .env(EnvCredentialProvider::new())
             .oidc(AssumeRoleWithOidcCredentialProvider::new())
             .build();
diff --git a/services/aliyun-oss/src/constants.rs 
b/services/aliyun-oss/src/constants.rs
index 3657fa2..ca20192 100644
--- a/services/aliyun-oss/src/constants.rs
+++ b/services/aliyun-oss/src/constants.rs
@@ -29,6 +29,7 @@ pub const OSS_PROFILE: &str = "OSS_PROFILE";
 pub const OSS_CREDENTIAL_PROFILES_FILE: &str = "OSS_CREDENTIAL_PROFILES_FILE";
 pub const ALIBABA_CLOUD_ROLE_ARN: &str = "ALIBABA_CLOUD_ROLE_ARN";
 pub const ALIBABA_CLOUD_ROLE_SESSION_NAME: &str = 
"ALIBABA_CLOUD_ROLE_SESSION_NAME";
+pub const ALIBABA_CLOUD_EXTERNAL_ID: &str = "ALIBABA_CLOUD_EXTERNAL_ID";
 pub const ALIBABA_CLOUD_OIDC_PROVIDER_ARN: &str = 
"ALIBABA_CLOUD_OIDC_PROVIDER_ARN";
 pub const ALIBABA_CLOUD_OIDC_TOKEN_FILE: &str = 
"ALIBABA_CLOUD_OIDC_TOKEN_FILE";
 pub const ALIBABA_CLOUD_STS_ENDPOINT: &str = "ALIBABA_CLOUD_STS_ENDPOINT";
diff --git a/services/aliyun-oss/src/lib.rs b/services/aliyun-oss/src/lib.rs
index 80eb8ec..b10a938 100644
--- a/services/aliyun-oss/src/lib.rs
+++ b/services/aliyun-oss/src/lib.rs
@@ -33,9 +33,10 @@
 //!
 //! ```no_run
 //! use reqsign_aliyun_oss::{
-//!     AssumeRoleWithOidcCredentialProvider, ConfigFileCredentialProvider,
-//!     CredentialsFileCredentialProvider, DefaultCredentialProvider, 
EnvCredentialProvider,
-//!     OssProfileCredentialProvider, RequestSigner, SigningVersion, 
StaticCredentialProvider,
+//!     AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
+//!     ConfigFileCredentialProvider, CredentialsFileCredentialProvider, 
DefaultCredentialProvider,
+//!     EnvCredentialProvider, OssProfileCredentialProvider, RequestSigner,
+//!     SigningVersion, StaticCredentialProvider,
 //! };
 //! use reqsign_core::{Context, Signer, Result};
 //! use reqsign_file_read_tokio::TokioFileRead;
@@ -48,9 +49,10 @@
 //!         .with_file_read(TokioFileRead::default())
 //!         .with_http_send(ReqwestHttpSend::default());
 //!
-//!     // Create credential loader with the default env -> OSS profile ->
-//!     // shared credentials file -> config file -> oidc chain.
+//!     // Create credential loader with the default assume_role -> env ->
+//!     // OSS profile -> shared credentials file -> config file -> oidc chain.
 //!     let loader = DefaultCredentialProvider::builder()
+//!         .assume_role(AssumeRoleCredentialProvider::new())
 //!         .env(EnvCredentialProvider::new())
 //!         .oss_profile(OssProfileCredentialProvider::new())
 //!         .credentials_file(CredentialsFileCredentialProvider::new())
@@ -153,22 +155,33 @@
 //! ### STS AssumeRole
 //!
 //! ```no_run
-//! use reqsign_aliyun_oss::{AssumeRoleWithOidcCredentialProvider, 
DefaultCredentialProvider};
+//! use reqsign_aliyun_oss::{
+//!     AssumeRoleCredentialProvider, DefaultCredentialProvider, 
StaticCredentialProvider,
+//! };
 //!
-//! // Use environment variables
-//! // Set ALIBABA_CLOUD_ROLE_ARN, ALIBABA_CLOUD_OIDC_PROVIDER_ARN, 
ALIBABA_CLOUD_OIDC_TOKEN_FILE
-//! // Optionally set ALIBABA_CLOUD_ROLE_SESSION_NAME
+//! // Use an explicit base access key source to call STS AssumeRole.
 //! let loader = DefaultCredentialProvider::builder()
 //!     .no_env()
 //!     .no_oss_profile()
 //!     .no_credentials_file()
 //!     .no_config_file()
-//!     .oidc(
-//!         
AssumeRoleWithOidcCredentialProvider::new().with_role_session_name("my-session"),
+//!     .assume_role(
+//!         AssumeRoleCredentialProvider::new()
+//!             .with_base_provider(StaticCredentialProvider::new(
+//!                 "your-access-key-id",
+//!                 "your-access-key-secret",
+//!             ))
+//!             .with_role_arn("acs:ram::123456789012:role/example")
+//!             .with_role_session_name("my-session"),
 //!     )
+//!     .no_oidc()
 //!     .build();
 //! ```
 //!
+//! Or rely on the default static base chain by setting
+//! `ALIBABA_CLOUD_ACCESS_KEY_ID`, `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,
+//! `ALIBABA_CLOUD_ROLE_ARN`, and optionally `ALIBABA_CLOUD_EXTERNAL_ID`.
+//!
 //! ### Custom Endpoints
 //!
 //! ```no_run
diff --git a/services/aliyun-oss/src/provide_credential/assume_role.rs 
b/services/aliyun-oss/src/provide_credential/assume_role.rs
new file mode 100644
index 0000000..62391c2
--- /dev/null
+++ b/services/aliyun-oss/src/provide_credential/assume_role.rs
@@ -0,0 +1,640 @@
+// 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 crate::provide_credential::{
+    ConfigFileCredentialProvider, CredentialsFileCredentialProvider, 
EnvCredentialProvider,
+    OssProfileCredentialProvider,
+};
+use crate::{Credential, constants::*};
+use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
+use reqsign_core::hash::base64_hmac_sha1;
+use reqsign_core::time::Timestamp;
+use reqsign_core::{
+    Context, ProvideCredential, ProvideCredentialChain, ProvideCredentialDyn, 
Result,
+};
+use serde::Deserialize;
+use std::collections::{BTreeMap, HashMap};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicU64, Ordering};
+
+static ALIYUN_RPC_QUERY_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC
+    .remove(b'-')
+    .remove(b'.')
+    .remove(b'_')
+    .remove(b'~');
+static SIGNATURE_NONCE_COUNTER: AtomicU64 = AtomicU64::new(0);
+
+/// AssumeRoleCredentialProvider loads credentials via Alibaba Cloud STS 
AssumeRole.
+///
+/// `new()` reads role configuration from environment variables at runtime and
+/// resolves the base access key credential from the default static chain:
+/// env -> OSS profile -> shared credentials file -> config file.
+///
+/// Use `with_base_provider(...)` to make the base credential source explicit 
and
+/// to avoid depending on the default static chain.
+#[derive(Debug, Clone)]
+pub struct AssumeRoleCredentialProvider {
+    base_provider: Arc<dyn ProvideCredentialDyn<Credential = Credential>>,
+    uses_default_base_provider: bool,
+    role_arn: Option<String>,
+    role_session_name: Option<String>,
+    external_id: Option<String>,
+    sts_endpoint: Option<String>,
+    #[cfg(test)]
+    time: Option<Timestamp>,
+    #[cfg(test)]
+    signature_nonce: Option<String>,
+}
+
+impl Default for AssumeRoleCredentialProvider {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl AssumeRoleCredentialProvider {
+    /// Create a new `AssumeRoleCredentialProvider` instance.
+    ///
+    /// This provider reads role configuration from environment variables at
+    /// runtime. The base access key credential is loaded from the default
+    /// static chain unless overridden with `with_base_provider(...)`.
+    pub fn new() -> Self {
+        Self {
+            base_provider: Arc::new(default_base_provider_chain()),
+            uses_default_base_provider: true,
+            role_arn: None,
+            role_session_name: None,
+            external_id: None,
+            sts_endpoint: None,
+            #[cfg(test)]
+            time: None,
+            #[cfg(test)]
+            signature_nonce: None,
+        }
+    }
+
+    /// Set the base credential provider used to call STS.
+    ///
+    /// This source must yield an access key ID and access key secret. When 
set,
+    /// the provider no longer depends on the default static chain.
+    pub fn with_base_provider(
+        mut self,
+        provider: impl ProvideCredential<Credential = Credential> + 'static,
+    ) -> Self {
+        self.base_provider = Arc::new(provider);
+        self.uses_default_base_provider = false;
+        self
+    }
+
+    /// Set the role ARN.
+    ///
+    /// This setting takes precedence over `ALIBABA_CLOUD_ROLE_ARN`.
+    pub fn with_role_arn(mut self, role_arn: impl Into<String>) -> Self {
+        self.role_arn = Some(role_arn.into());
+        self
+    }
+
+    /// Set the role session name.
+    ///
+    /// This setting takes precedence over `ALIBABA_CLOUD_ROLE_SESSION_NAME`.
+    pub fn with_role_session_name(mut self, name: impl Into<String>) -> Self {
+        self.role_session_name = Some(name.into());
+        self
+    }
+
+    /// Set the external ID.
+    ///
+    /// This setting takes precedence over `ALIBABA_CLOUD_EXTERNAL_ID`.
+    pub fn with_external_id(mut self, external_id: impl Into<String>) -> Self {
+        self.external_id = Some(external_id.into());
+        self
+    }
+
+    /// Set the STS endpoint.
+    ///
+    /// This setting takes precedence over `ALIBABA_CLOUD_STS_ENDPOINT`.
+    pub fn with_sts_endpoint(mut self, endpoint: impl Into<String>) -> Self {
+        self.sts_endpoint = Some(endpoint.into());
+        self
+    }
+
+    pub(crate) fn with_default_base_provider(
+        mut self,
+        provider: impl ProvideCredential<Credential = Credential> + 'static,
+    ) -> Self {
+        if self.uses_default_base_provider {
+            self.base_provider = Arc::new(provider);
+        }
+        self
+    }
+
+    #[cfg(test)]
+    fn with_time(mut self, time: Timestamp) -> Self {
+        self.time = Some(time);
+        self
+    }
+
+    #[cfg(test)]
+    fn with_signature_nonce(mut self, nonce: impl Into<String>) -> Self {
+        self.signature_nonce = Some(nonce.into());
+        self
+    }
+
+    fn get_role_arn(&self, envs: &HashMap<String, String>) -> Option<String> {
+        self.role_arn
+            .clone()
+            .or_else(|| envs.get(ALIBABA_CLOUD_ROLE_ARN).cloned())
+    }
+
+    fn get_role_session_name(&self, envs: &HashMap<String, String>) -> String {
+        self.role_session_name
+            .clone()
+            .or_else(|| envs.get(ALIBABA_CLOUD_ROLE_SESSION_NAME).cloned())
+            .unwrap_or_else(|| "reqsign".to_string())
+    }
+
+    fn get_external_id(&self, envs: &HashMap<String, String>) -> 
Option<String> {
+        self.external_id
+            .clone()
+            .or_else(|| envs.get(ALIBABA_CLOUD_EXTERNAL_ID).cloned())
+    }
+
+    fn get_sts_endpoint(&self, envs: &HashMap<String, String>) -> String {
+        if let Some(endpoint) = &self.sts_endpoint {
+            return endpoint.clone();
+        }
+
+        match envs.get(ALIBABA_CLOUD_STS_ENDPOINT) {
+            Some(endpoint) => format!("https://{endpoint}";),
+            None => "https://sts.aliyuncs.com".to_string(),
+        }
+    }
+
+    fn get_time(&self) -> Timestamp {
+        #[cfg(test)]
+        if let Some(time) = self.time {
+            return time;
+        }
+
+        Timestamp::now()
+    }
+
+    fn get_signature_nonce(&self, signing_time: Timestamp) -> String {
+        #[cfg(test)]
+        if let Some(nonce) = &self.signature_nonce {
+            return nonce.clone();
+        }
+
+        let counter = SIGNATURE_NONCE_COUNTER.fetch_add(1, Ordering::Relaxed);
+        format!(
+            "{}-{}-{counter}",
+            signing_time.as_second(),
+            signing_time.subsec_nanosecond()
+        )
+    }
+}
+
+impl ProvideCredential for AssumeRoleCredentialProvider {
+    type Credential = Credential;
+
+    async fn provide_credential(&self, ctx: &Context) -> 
Result<Option<Self::Credential>> {
+        let envs = ctx.env_vars();
+
+        let Some(role_arn) = self.get_role_arn(&envs) else {
+            return Ok(None);
+        };
+
+        let Some(base_credential) = 
self.base_provider.provide_credential_dyn(ctx).await? else {
+            return Ok(None);
+        };
+        if base_credential.access_key_id.is_empty() || 
base_credential.access_key_secret.is_empty()
+        {
+            return Ok(None);
+        }
+
+        let signing_time = self.get_time();
+        let signature_nonce = self.get_signature_nonce(signing_time);
+        let role_session_name = self.get_role_session_name(&envs);
+
+        let mut params = BTreeMap::new();
+        params.insert(
+            "AccessKeyId".to_string(),
+            base_credential.access_key_id.clone(),
+        );
+        params.insert("Action".to_string(), "AssumeRole".to_string());
+        params.insert("Format".to_string(), "JSON".to_string());
+        params.insert("RoleArn".to_string(), role_arn);
+        params.insert("RoleSessionName".to_string(), role_session_name);
+        params.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string());
+        params.insert("SignatureNonce".to_string(), signature_nonce);
+        params.insert("SignatureVersion".to_string(), "1.0".to_string());
+        params.insert("Timestamp".to_string(), 
signing_time.format_rfc3339_zulu());
+        params.insert("Version".to_string(), "2015-04-01".to_string());
+
+        if let Some(external_id) = self.get_external_id(&envs) {
+            params.insert("ExternalId".to_string(), external_id);
+        }
+        if let Some(token) = &base_credential.security_token {
+            params.insert("SecurityToken".to_string(), token.clone());
+        }
+
+        let canonicalized_query_string = canonicalized_query_string(&params);
+        let string_to_sign = format!(
+            "GET&%2F&{}",
+            percent_encode_query_value(&canonicalized_query_string)
+        );
+        let signature = base64_hmac_sha1(
+            format!("{}&", base_credential.access_key_secret).as_bytes(),
+            string_to_sign.as_bytes(),
+        );
+
+        let url = format!(
+            "{}/?{}&Signature={}",
+            self.get_sts_endpoint(&envs),
+            canonicalized_query_string,
+            percent_encode_query_value(&signature)
+        );
+
+        let req = http::Request::builder()
+            .method(http::Method::GET)
+            .uri(&url)
+            .header(
+                http::header::CONTENT_TYPE,
+                "application/x-www-form-urlencoded",
+            )
+            .body(Vec::new())?;
+
+        let resp = ctx.http_send(req.map(Into::into)).await?;
+        if resp.status() != http::StatusCode::OK {
+            let content = String::from_utf8_lossy(resp.body());
+            return Err(reqsign_core::Error::unexpected(format!(
+                "request to Aliyun STS Services failed: {content}"
+            )));
+        }
+
+        let resp: AssumeRoleResponse = 
serde_json::from_slice(resp.body()).map_err(|e| {
+            reqsign_core::Error::unexpected(format!("Failed to parse STS 
response: {e}"))
+        })?;
+        let resp_cred = resp.credentials;
+
+        Ok(Some(Credential {
+            access_key_id: resp_cred.access_key_id,
+            access_key_secret: resp_cred.access_key_secret,
+            security_token: Some(resp_cred.security_token),
+            expires_in: Some(resp_cred.expiration.parse()?),
+        }))
+    }
+}
+
+fn default_base_provider_chain() -> ProvideCredentialChain<Credential> {
+    ProvideCredentialChain::new()
+        .push(EnvCredentialProvider::new())
+        .push(OssProfileCredentialProvider::new())
+        .push(CredentialsFileCredentialProvider::new())
+        .push(ConfigFileCredentialProvider::new())
+}
+
+fn canonicalized_query_string(params: &BTreeMap<String, String>) -> String {
+    params
+        .iter()
+        .map(|(key, value)| {
+            format!(
+                "{}={}",
+                percent_encode_query_value(key),
+                percent_encode_query_value(value)
+            )
+        })
+        .collect::<Vec<_>>()
+        .join("&")
+}
+
+fn percent_encode_query_value(value: &str) -> String {
+    utf8_percent_encode(value, &ALIYUN_RPC_QUERY_ENCODE_SET).to_string()
+}
+
+#[derive(Default, Debug, Deserialize)]
+#[serde(default)]
+struct AssumeRoleResponse {
+    #[serde(rename = "Credentials")]
+    credentials: AssumeRoleCredentials,
+}
+
+#[derive(Default, Debug, Deserialize)]
+#[serde(default, rename_all = "PascalCase")]
+struct AssumeRoleCredentials {
+    access_key_id: String,
+    access_key_secret: String,
+    security_token: String,
+    expiration: String,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::RequestSigner;
+    use bytes::Bytes;
+    use reqsign_core::{Context, HttpSend, Signer, StaticEnv};
+    use std::collections::HashMap;
+    use std::sync::atomic::{AtomicUsize, Ordering};
+    use std::sync::{Arc, Mutex};
+
+    #[derive(Debug, Clone)]
+    struct TestBaseCredentialProvider {
+        credential: Option<Credential>,
+    }
+
+    impl TestBaseCredentialProvider {
+        fn new(credential: Option<Credential>) -> Self {
+            Self { credential }
+        }
+    }
+
+    impl ProvideCredential for TestBaseCredentialProvider {
+        type Credential = Credential;
+
+        async fn provide_credential(&self, _ctx: &Context) -> 
Result<Option<Self::Credential>> {
+            Ok(self.credential.clone())
+        }
+    }
+
+    #[derive(Clone, Debug)]
+    struct CaptureHttpSend {
+        uri: Arc<Mutex<Option<String>>>,
+        bodies: Arc<Vec<Vec<u8>>>,
+        calls: Arc<AtomicUsize>,
+    }
+
+    impl CaptureHttpSend {
+        fn new(bodies: Vec<Vec<u8>>) -> Self {
+            Self {
+                uri: Arc::new(Mutex::new(None)),
+                bodies: Arc::new(bodies),
+                calls: Arc::new(AtomicUsize::new(0)),
+            }
+        }
+
+        fn uri(&self) -> Option<String> {
+            self.uri.lock().unwrap().clone()
+        }
+
+        fn calls(&self) -> usize {
+            self.calls.load(Ordering::SeqCst)
+        }
+    }
+
+    impl HttpSend for CaptureHttpSend {
+        async fn http_send(
+            &self,
+            req: http::Request<Bytes>,
+        ) -> reqsign_core::Result<http::Response<Bytes>> {
+            let index = self.calls.fetch_add(1, Ordering::SeqCst);
+            *self.uri.lock().unwrap() = Some(req.uri().to_string());
+            let body = self
+                .bodies
+                .get(index)
+                .cloned()
+                .or_else(|| self.bodies.last().cloned())
+                .unwrap_or_default();
+
+            Ok(http::Response::builder()
+                .status(http::StatusCode::OK)
+                .body(Bytes::from(body))
+                .expect("response must build"))
+        }
+    }
+
+    #[test]
+    fn test_parse_assume_role_response() -> Result<()> {
+        let content = r#"{
+    "RequestId": "3D57EAD2-8723-1F26-B69C-F8707D8B565D",
+    "AssumedRoleUser": {
+        "AssumedRoleId": "33157794895460****",
+        "Arn": "acs:ram::113511544585****:role/test-role/test-session"
+    },
+    "Credentials": {
+        "SecurityToken": "CAIShwJ1q6Ft5B2yfSjIr5bSEsj4g7BihPWGWHz****",
+        "Expiration": "2021-10-20T04:27:09Z",
+        "AccessKeySecret": "CVwjCkNzTMupZ8NbTCxCBRq3K16jtcWFTJAyBEv2****",
+        "AccessKeyId": "STS.NUgYrLnoC37mZZCNnAbez****"
+    }
+}"#;
+
+        let resp: AssumeRoleResponse =
+            serde_json::from_str(content).expect("json deserialize must 
succeed");
+
+        assert_eq!(
+            resp.credentials.access_key_id,
+            "STS.NUgYrLnoC37mZZCNnAbez****"
+        );
+        assert_eq!(
+            resp.credentials.access_key_secret,
+            "CVwjCkNzTMupZ8NbTCxCBRq3K16jtcWFTJAyBEv2****"
+        );
+        assert_eq!(
+            resp.credentials.security_token,
+            "CAIShwJ1q6Ft5B2yfSjIr5bSEsj4g7BihPWGWHz****"
+        );
+        assert_eq!(resp.credentials.expiration, "2021-10-20T04:27:09Z");
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_assume_role_loader_without_config() {
+        let ctx = Context::new().with_env(StaticEnv {
+            home_dir: None,
+            envs: HashMap::new(),
+        });
+
+        let provider = AssumeRoleCredentialProvider::new();
+        let credential = provider.provide_credential(&ctx).await.unwrap();
+
+        assert!(credential.is_none());
+    }
+
+    #[tokio::test]
+    async fn test_assume_role_signs_query_with_explicit_base_provider() -> 
Result<()> {
+        let base_credential = Credential {
+            access_key_id: "base-ak".to_string(),
+            access_key_secret: "base-sk".to_string(),
+            security_token: Some("base-token".to_string()),
+            expires_in: None,
+        };
+        let http_send = CaptureHttpSend::new(vec![
+            
br#"{"Credentials":{"SecurityToken":"sts-token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"sts-secret","AccessKeyId":"sts-ak"}}"#
+                .to_vec(),
+        ]);
+        let ctx = Context::new()
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::new(),
+            });
+
+        let signing_time: Timestamp = "2024-03-05T06:07:08Z".parse().unwrap();
+        let provider = AssumeRoleCredentialProvider::new()
+            .with_base_provider(TestBaseCredentialProvider::new(Some(
+                base_credential.clone(),
+            )))
+            .with_role_arn("acs:ram::123456789012:role/test-role")
+            .with_role_session_name("test-session")
+            .with_external_id("external-id")
+            .with_sts_endpoint("https://sts.example.com";)
+            .with_time(signing_time)
+            .with_signature_nonce("test-nonce");
+
+        let credential = provider.provide_credential(&ctx).await?.unwrap();
+        assert_eq!("sts-ak", credential.access_key_id);
+        assert_eq!("sts-secret", credential.access_key_secret);
+        assert_eq!(Some("sts-token".to_string()), credential.security_token);
+
+        let recorded_uri = http_send.uri().expect("request uri must be 
captured");
+        let uri: http::Uri = recorded_uri.parse().expect("uri must parse");
+        assert_eq!(
+            "https://sts.example.com/";,
+            format!("https://{}{}";, uri.authority().unwrap(), uri.path())
+        );
+        let query = uri.query().expect("query must exist");
+        let params: HashMap<String, String> = 
form_urlencoded::parse(query.as_bytes())
+            .into_owned()
+            .collect();
+
+        assert_eq!(
+            Some("base-ak"),
+            params.get("AccessKeyId").map(String::as_str)
+        );
+        assert_eq!(Some("AssumeRole"), 
params.get("Action").map(String::as_str));
+        assert_eq!(
+            Some("acs:ram::123456789012:role/test-role"),
+            params.get("RoleArn").map(String::as_str)
+        );
+        assert_eq!(
+            Some("test-session"),
+            params.get("RoleSessionName").map(String::as_str)
+        );
+        assert_eq!(
+            Some("external-id"),
+            params.get("ExternalId").map(String::as_str)
+        );
+        assert_eq!(
+            Some("base-token"),
+            params.get("SecurityToken").map(String::as_str)
+        );
+        assert_eq!(
+            Some("2024-03-05T06:07:08Z"),
+            params.get("Timestamp").map(String::as_str)
+        );
+        assert_eq!(
+            Some("test-nonce"),
+            params.get("SignatureNonce").map(String::as_str)
+        );
+
+        let mut expected_params = BTreeMap::new();
+        expected_params.insert("AccessKeyId".to_string(), 
"base-ak".to_string());
+        expected_params.insert("Action".to_string(), "AssumeRole".to_string());
+        expected_params.insert("ExternalId".to_string(), 
"external-id".to_string());
+        expected_params.insert("Format".to_string(), "JSON".to_string());
+        expected_params.insert(
+            "RoleArn".to_string(),
+            "acs:ram::123456789012:role/test-role".to_string(),
+        );
+        expected_params.insert("RoleSessionName".to_string(), 
"test-session".to_string());
+        expected_params.insert("SecurityToken".to_string(), 
"base-token".to_string());
+        expected_params.insert("SignatureMethod".to_string(), 
"HMAC-SHA1".to_string());
+        expected_params.insert("SignatureNonce".to_string(), 
"test-nonce".to_string());
+        expected_params.insert("SignatureVersion".to_string(), 
"1.0".to_string());
+        expected_params.insert("Timestamp".to_string(), 
"2024-03-05T06:07:08Z".to_string());
+        expected_params.insert("Version".to_string(), 
"2015-04-01".to_string());
+
+        let canonicalized = canonicalized_query_string(&expected_params);
+        let string_to_sign = format!("GET&%2F&{}", 
percent_encode_query_value(&canonicalized));
+        let expected_signature = base64_hmac_sha1(
+            format!("{}&", base_credential.access_key_secret).as_bytes(),
+            string_to_sign.as_bytes(),
+        );
+        assert_eq!(
+            Some(expected_signature.as_str()),
+            params.get("Signature").map(String::as_str)
+        );
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_assume_role_refreshes_expiring_credential() -> Result<()> {
+        let http_send = CaptureHttpSend::new(vec![
+            
br#"{"Credentials":{"SecurityToken":"sts-token-1","Expiration":"2024-03-05T06:08:00Z","AccessKeySecret":"sts-secret-1","AccessKeyId":"sts-ak-1"}}"#
+                .to_vec(),
+            
br#"{"Credentials":{"SecurityToken":"sts-token-2","Expiration":"2124-03-05T06:09:00Z","AccessKeySecret":"sts-secret-2","AccessKeyId":"sts-ak-2"}}"#
+                .to_vec(),
+        ]);
+        let ctx = Context::new()
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::new(),
+            });
+
+        let provider = AssumeRoleCredentialProvider::new()
+            
.with_base_provider(TestBaseCredentialProvider::new(Some(Credential {
+                access_key_id: "base-ak".to_string(),
+                access_key_secret: "base-sk".to_string(),
+                security_token: None,
+                expires_in: None,
+            })))
+            .with_role_arn("acs:ram::123456789012:role/test-role")
+            .with_role_session_name("test-session")
+            .with_sts_endpoint("https://sts.example.com";)
+            .with_time("2024-03-05T06:07:08Z".parse().unwrap())
+            .with_signature_nonce("test-nonce");
+        let signer = Signer::new(ctx, provider, 
RequestSigner::new("test-bucket"));
+
+        let mut first_req =
+            
http::Request::get("https://test-bucket.oss-cn-beijing.aliyuncs.com/object";)
+                .body(())
+                .unwrap()
+                .into_parts()
+                .0;
+        signer.sign(&mut first_req, None).await?;
+        assert!(
+            first_req
+                .headers
+                .get(http::header::AUTHORIZATION)
+                .and_then(|v| v.to_str().ok())
+                .expect("authorization must exist")
+                .starts_with("OSS sts-ak-1:")
+        );
+
+        let mut second_req =
+            
http::Request::get("https://test-bucket.oss-cn-beijing.aliyuncs.com/object";)
+                .body(())
+                .unwrap()
+                .into_parts()
+                .0;
+        signer.sign(&mut second_req, None).await?;
+
+        let authorization = second_req
+            .headers
+            .get(http::header::AUTHORIZATION)
+            .and_then(|v| v.to_str().ok())
+            .expect("authorization must exist");
+        assert!(authorization.starts_with("OSS sts-ak-2:"));
+        assert_eq!(2, http_send.calls());
+
+        Ok(())
+    }
+}
diff --git a/services/aliyun-oss/src/provide_credential/default.rs 
b/services/aliyun-oss/src/provide_credential/default.rs
index 41c581f..9e8b1c0 100644
--- a/services/aliyun-oss/src/provide_credential/default.rs
+++ b/services/aliyun-oss/src/provide_credential/default.rs
@@ -17,8 +17,9 @@
 
 use crate::Credential;
 use crate::provide_credential::{
-    AssumeRoleWithOidcCredentialProvider, ConfigFileCredentialProvider,
-    CredentialsFileCredentialProvider, EnvCredentialProvider, 
OssProfileCredentialProvider,
+    AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
+    ConfigFileCredentialProvider, CredentialsFileCredentialProvider, 
EnvCredentialProvider,
+    OssProfileCredentialProvider,
 };
 use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
 
@@ -26,11 +27,12 @@ use reqsign_core::{Context, ProvideCredential, 
ProvideCredentialChain, Result};
 ///
 /// Resolution order:
 ///
-/// 1. Environment variables
-/// 2. OSS profile file
-/// 3. Alibaba shared credentials file
-/// 4. Alibaba CLI config file
-/// 5. Assume Role with OIDC
+/// 1. AssumeRole via base AK credentials
+/// 2. Environment variables
+/// 3. OSS profile file
+/// 4. Alibaba shared credentials file
+/// 5. Alibaba CLI config file
+/// 6. Assume Role with OIDC
 #[derive(Debug)]
 pub struct DefaultCredentialProvider {
     chain: ProvideCredentialChain<Credential>,
@@ -85,6 +87,7 @@ impl DefaultCredentialProvider {
 /// Use `slot(provider)` to override a default provider or `no_slot()` to
 /// remove it from the chain before calling `build()`.
 pub struct DefaultCredentialProviderBuilder {
+    assume_role: Option<AssumeRoleCredentialProvider>,
     env: Option<EnvCredentialProvider>,
     oss_profile: Option<OssProfileCredentialProvider>,
     credentials_file: Option<CredentialsFileCredentialProvider>,
@@ -95,6 +98,7 @@ pub struct DefaultCredentialProviderBuilder {
 impl Default for DefaultCredentialProviderBuilder {
     fn default() -> Self {
         Self {
+            assume_role: Some(AssumeRoleCredentialProvider::new()),
             env: Some(EnvCredentialProvider::new()),
             oss_profile: Some(OssProfileCredentialProvider::new()),
             credentials_file: Some(CredentialsFileCredentialProvider::new()),
@@ -110,6 +114,18 @@ impl DefaultCredentialProviderBuilder {
         Self::default()
     }
 
+    /// Set the AssumeRole credential provider slot.
+    pub fn assume_role(mut self, provider: AssumeRoleCredentialProvider) -> 
Self {
+        self.assume_role = Some(provider);
+        self
+    }
+
+    /// Remove the AssumeRole credential provider slot.
+    pub fn no_assume_role(mut self) -> Self {
+        self.assume_role = None;
+        self
+    }
+
     /// Set the environment credential provider slot.
     pub fn env(mut self, provider: EnvCredentialProvider) -> Self {
         self.env = Some(provider);
@@ -157,6 +173,7 @@ impl DefaultCredentialProviderBuilder {
         self.config_file = None;
         self
     }
+
     /// Set the OIDC credential provider slot.
     pub fn oidc(mut self, provider: AssumeRoleWithOidcCredentialProvider) -> 
Self {
         self.oidc = Some(provider);
@@ -171,7 +188,15 @@ impl DefaultCredentialProviderBuilder {
 
     /// Build the `DefaultCredentialProvider` with the configured options.
     pub fn build(self) -> DefaultCredentialProvider {
+        let assume_role_base_chain = ProvideCredentialChain::new()
+            .push_opt(self.env.clone())
+            .push_opt(self.oss_profile.clone())
+            .push_opt(self.credentials_file.clone())
+            .push_opt(self.config_file.clone());
         let mut chain = ProvideCredentialChain::new();
+        if let Some(p) = self.assume_role {
+            chain = 
chain.push(p.with_default_base_provider(assume_role_base_chain));
+        }
         if let Some(p) = self.env {
             chain = chain.push(p);
         }
@@ -190,6 +215,25 @@ impl DefaultCredentialProviderBuilder {
         DefaultCredentialProvider::with_chain(chain)
     }
 }
+
+trait PushOptionalProvider {
+    fn push_opt<P>(self, provider: Option<P>) -> Self
+    where
+        P: ProvideCredential<Credential = Credential> + 'static;
+}
+
+impl PushOptionalProvider for ProvideCredentialChain<Credential> {
+    fn push_opt<P>(self, provider: Option<P>) -> Self
+    where
+        P: ProvideCredential<Credential = Credential> + 'static,
+    {
+        match provider {
+            Some(provider) => self.push(provider),
+            None => self,
+        }
+    }
+}
+
 impl ProvideCredential for DefaultCredentialProvider {
     type Credential = Credential;
 
@@ -386,6 +430,7 @@ access_key_secret = profile_secret_key
             });
 
         let credential = DefaultCredentialProvider::builder()
+            .no_assume_role()
             .no_oidc()
             .build()
             .provide_credential(&ctx)
@@ -396,6 +441,7 @@ access_key_secret = profile_secret_key
         assert_eq!(0, file_read.calls());
 
         let credential = DefaultCredentialProvider::builder()
+            .no_assume_role()
             .no_env()
             .no_oidc()
             .build()
@@ -408,6 +454,7 @@ access_key_secret = profile_secret_key
         assert_eq!(1, file_read.calls());
 
         let credential = DefaultCredentialProvider::builder()
+            .no_assume_role()
             .no_env()
             .no_oss_profile()
             .no_oidc()
@@ -460,7 +507,9 @@ access_key_secret = profile_secret_key
                 ]),
             });
 
-        let credential = DefaultCredentialProvider::new()
+        let credential = DefaultCredentialProvider::builder()
+            .no_assume_role()
+            .build()
             .provide_credential(&ctx)
             .await
             .unwrap()
@@ -517,6 +566,7 @@ access_key_secret=shared_secret_key
             });
 
         let credential = DefaultCredentialProvider::builder()
+            .no_assume_role()
             .no_env()
             .no_oss_profile()
             .no_oidc()
@@ -558,6 +608,7 @@ access_key_secret=shared_secret_key
             });
 
         let credential = DefaultCredentialProvider::builder()
+            .no_assume_role()
             .no_env()
             .no_oss_profile()
             .no_credentials_file()
@@ -600,6 +651,7 @@ access_key_secret=shared_secret_key
             });
 
         let credential = DefaultCredentialProvider::builder()
+            .no_assume_role()
             .no_env()
             .no_oss_profile()
             .no_credentials_file()
@@ -612,6 +664,7 @@ access_key_secret=shared_secret_key
         assert_eq!("config_access_key", credential.access_key_id);
 
         let credential = DefaultCredentialProvider::builder()
+            .no_assume_role()
             .no_env()
             .no_oss_profile()
             .no_credentials_file()
@@ -673,6 +726,7 @@ access_key_secret=shared_secret_key
             });
 
         let credential = DefaultCredentialProvider::builder()
+            .no_assume_role()
             .no_env()
             .no_oss_profile()
             .no_credentials_file()
@@ -719,6 +773,7 @@ access_key_secret=shared_secret_key
             });
 
         let credential = DefaultCredentialProvider::builder()
+            .no_assume_role()
             .no_env()
             .no_oss_profile()
             .oidc(AssumeRoleWithOidcCredentialProvider::new())
@@ -730,14 +785,139 @@ access_key_secret=shared_secret_key
         assert_eq!(1, file_read.calls());
         assert_eq!(1, http_send.calls());
 
+        let credential = DefaultCredentialProvider::builder()
+            .no_assume_role()
+            .no_env()
+            .no_oss_profile()
+            .no_oidc()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap();
+        assert!(credential.is_none());
+    }
+
+    #[tokio::test]
+    async fn 
test_default_loader_prefers_assume_role_over_raw_env_credentials() {
+        let http_send = CountingHttpSend::new(
+            
br#"{"Credentials":{"SecurityToken":"sts_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"assumed_secret_key","AccessKeyId":"assumed_access_key"}}"#
+                .to_vec(),
+        );
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from_iter([
+                    (
+                        ALIBABA_CLOUD_ACCESS_KEY_ID.to_string(),
+                        "base_access_key".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_ACCESS_KEY_SECRET.to_string(),
+                        "base_secret_key".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_ROLE_ARN.to_string(),
+                        "acs:ram::123456789012:role/test-role".to_string(),
+                    ),
+                ]),
+            });
+
+        let credential = DefaultCredentialProvider::builder()
+            .no_oidc()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap();
+
+        assert_eq!("assumed_access_key", credential.access_key_id);
+        assert_eq!("assumed_secret_key", credential.access_key_secret);
+        assert_eq!(Some("sts_token".to_string()), credential.security_token);
+        assert_eq!(1, http_send.calls());
+    }
+
+    #[tokio::test]
+    async fn test_builder_no_env_removes_env_from_assume_role_base_chain() {
+        let http_send = CountingHttpSend::new(
+            
br#"{"Credentials":{"SecurityToken":"sts_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"assumed_secret_key","AccessKeyId":"assumed_access_key"}}"#
+                .to_vec(),
+        );
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from_iter([
+                    (
+                        ALIBABA_CLOUD_ACCESS_KEY_ID.to_string(),
+                        "base_access_key".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_ACCESS_KEY_SECRET.to_string(),
+                        "base_secret_key".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_ROLE_ARN.to_string(),
+                        "acs:ram::123456789012:role/test-role".to_string(),
+                    ),
+                ]),
+            });
+
         let credential = DefaultCredentialProvider::builder()
             .no_env()
             .no_oss_profile()
+            .no_credentials_file()
+            .no_config_file()
             .no_oidc()
             .build()
             .provide_credential(&ctx)
             .await
             .unwrap();
+
         assert!(credential.is_none());
+        assert_eq!(0, http_send.calls());
+    }
+
+    #[tokio::test]
+    async fn test_builder_no_assume_role_removes_assume_role_provider() {
+        let http_send = CountingHttpSend::new(
+            
br#"{"Credentials":{"SecurityToken":"sts_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"assumed_secret_key","AccessKeyId":"assumed_access_key"}}"#
+                .to_vec(),
+        );
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from_iter([
+                    (
+                        ALIBABA_CLOUD_ACCESS_KEY_ID.to_string(),
+                        "base_access_key".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_ACCESS_KEY_SECRET.to_string(),
+                        "base_secret_key".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_ROLE_ARN.to_string(),
+                        "acs:ram::123456789012:role/test-role".to_string(),
+                    ),
+                ]),
+            });
+
+        let credential = DefaultCredentialProvider::builder()
+            .no_assume_role()
+            .no_oidc()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap();
+
+        assert_eq!("base_access_key", credential.access_key_id);
+        assert_eq!("base_secret_key", credential.access_key_secret);
+        assert_eq!(0, http_send.calls());
     }
 }
diff --git a/services/aliyun-oss/src/provide_credential/mod.rs 
b/services/aliyun-oss/src/provide_credential/mod.rs
index 92a6704..48b5dff 100644
--- a/services/aliyun-oss/src/provide_credential/mod.rs
+++ b/services/aliyun-oss/src/provide_credential/mod.rs
@@ -15,6 +15,9 @@
 // specific language governing permissions and limitations
 // under the License.
 
+mod assume_role;
+pub use assume_role::AssumeRoleCredentialProvider;
+
 mod assume_role_with_oidc;
 pub use assume_role_with_oidc::AssumeRoleWithOidcCredentialProvider;
 


Reply via email to