You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/03/18 17:48:30 UTC

(opendal-reqsign) branch main updated: feat(aliyun-oss): add shared credentials and config file providers (#722)

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 8139992  feat(aliyun-oss): add shared credentials and config file providers (#722)
8139992 is described below

commit 81399926577bbc7913c9d10192bc1a56f3f35265
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Thu Mar 19 01:48:25 2026 +0800

    feat(aliyun-oss): add shared credentials and config file providers (#722)
    
    This stacked PR builds on #720 by adding the two Alibaba shared file
    sources that are distinct from the OSS profile file: shared
    `credentials.ini` and `config.json` providers.
    
    It keeps the slot boundaries explicit (`credentials_file` and
    `config_file`), limits this PR to direct static modes, and adds
    precedence tests showing these file-based providers slot cleanly in
    front of OIDC. Related to #684.
---
 services/aliyun-oss/README.md                      |  53 +++-
 services/aliyun-oss/src/constants.rs               |   3 +
 services/aliyun-oss/src/lib.rs                     |  18 +-
 .../src/provide_credential/config_file.rs          | 339 +++++++++++++++++++++
 .../src/provide_credential/credentials_file.rs     | 335 ++++++++++++++++++++
 .../aliyun-oss/src/provide_credential/default.rs   | 256 +++++++++++++++-
 services/aliyun-oss/src/provide_credential/mod.rs  |   6 +
 7 files changed, 1001 insertions(+), 9 deletions(-)

diff --git a/services/aliyun-oss/README.md b/services/aliyun-oss/README.md
index fd7da7c..908c9b8 100644
--- a/services/aliyun-oss/README.md
+++ b/services/aliyun-oss/README.md
@@ -10,7 +10,8 @@ This crate provides signing support for Alibaba Cloud Object Storage Service (OS
 
 ```rust
 use reqsign_aliyun_oss::{
-    AssumeRoleWithOidcCredentialProvider, DefaultCredentialProvider, EnvCredentialProvider,
+    AssumeRoleWithOidcCredentialProvider, ConfigFileCredentialProvider,
+    CredentialsFileCredentialProvider, DefaultCredentialProvider, EnvCredentialProvider,
     OssProfileCredentialProvider, RequestSigner, StaticCredentialProvider,
 };
 use reqsign_core::{Context, Result, Signer};
@@ -26,6 +27,8 @@ async fn main() -> Result<()> {
     let loader = DefaultCredentialProvider::builder()
         .env(EnvCredentialProvider::new())
         .oss_profile(OssProfileCredentialProvider::new())
+        .credentials_file(CredentialsFileCredentialProvider::new())
+        .config_file(ConfigFileCredentialProvider::new())
         .oidc(AssumeRoleWithOidcCredentialProvider::new())
         .build();
 
@@ -57,7 +60,7 @@ async fn main() -> Result<()> {
 ## Features
 
 - **HMAC-SHA1 Signing**: Complete implementation of Aliyun's signing algorithm
-- **Multiple Credential Sources**: Environment variables, OSS profile files, and OIDC-based STS exchange
+- **Multiple Credential Sources**: Environment variables, OSS profile files, Alibaba shared credential/config files, and OIDC-based STS exchange
 - **STS Support**: Temporary credentials via Security Token Service
 - **All OSS Operations**: Object, bucket, and multipart operations
 
@@ -106,6 +109,50 @@ access_key_secret = prod-access-key-secret
 
 Override the file path with `OSS_CREDENTIAL_PROFILES_FILE` and the selected profile with `OSS_PROFILE`.
 
+### Alibaba Shared Credentials File
+
+Reads from `~/.alibabacloud/credentials.ini` first and falls back to `~/.aliyun/credentials.ini`:
+
+```ini
+[default]
+enable = true
+type = access_key
+access_key_id = your-access-key-id
+access_key_secret = your-access-key-secret
+
+[prod]
+enable = true
+type = sts_token
+access_key_id = prod-access-key-id
+access_key_secret = prod-access-key-secret
+sts_token = optional-session-token
+```
+
+Override the file path with `ALIBABA_CLOUD_CREDENTIALS_FILE` and the selected profile with `ALIBABA_CLOUD_PROFILE`.
+
+Only direct static modes are loaded in this crate today: `access_key` and `sts_token`.
+
+### Alibaba CLI Config File
+
+Reads from `~/.aliyun/config.json` by default:
+
+```json
+{
+  "current": "default",
+  "profiles": [
+    {
+      "name": "default",
+      "mode": "AK",
+      "access_key_id": "your-access-key-id",
+      "access_key_secret": "your-access-key-secret"
+    }
+  ]
+}
+```
+
+Override the file path with `ALIBABA_CLOUD_CONFIG_FILE` and the selected profile with `ALIBABA_CLOUD_PROFILE`.
+
+Only direct static modes are loaded in this crate today: `AK` and `StsToken`.
 ### STS AssumeRole with OIDC
 
 For Kubernetes/ACK environments:
@@ -118,6 +165,8 @@ use reqsign_aliyun_oss::{AssumeRoleWithOidcCredentialProvider, DefaultCredential
 let loader = DefaultCredentialProvider::builder()
     .no_env()
     .no_oss_profile()
+    .no_credentials_file()
+    .no_config_file()
     .oidc(
         AssumeRoleWithOidcCredentialProvider::new().with_role_session_name("my-session"),
     )
diff --git a/services/aliyun-oss/src/constants.rs b/services/aliyun-oss/src/constants.rs
index ab6b857..3657fa2 100644
--- a/services/aliyun-oss/src/constants.rs
+++ b/services/aliyun-oss/src/constants.rs
@@ -19,6 +19,9 @@
 pub const ALIBABA_CLOUD_ACCESS_KEY_ID: &str = "ALIBABA_CLOUD_ACCESS_KEY_ID";
 pub const ALIBABA_CLOUD_ACCESS_KEY_SECRET: &str = "ALIBABA_CLOUD_ACCESS_KEY_SECRET";
 pub const ALIBABA_CLOUD_SECURITY_TOKEN: &str = "ALIBABA_CLOUD_SECURITY_TOKEN";
+pub const ALIBABA_CLOUD_PROFILE: &str = "ALIBABA_CLOUD_PROFILE";
+pub const ALIBABA_CLOUD_CREDENTIALS_FILE: &str = "ALIBABA_CLOUD_CREDENTIALS_FILE";
+pub const ALIBABA_CLOUD_CONFIG_FILE: &str = "ALIBABA_CLOUD_CONFIG_FILE";
 pub const OSS_ACCESS_KEY_ID: &str = "OSS_ACCESS_KEY_ID";
 pub const OSS_ACCESS_KEY_SECRET: &str = "OSS_ACCESS_KEY_SECRET";
 pub const OSS_SESSION_TOKEN: &str = "OSS_SESSION_TOKEN";
diff --git a/services/aliyun-oss/src/lib.rs b/services/aliyun-oss/src/lib.rs
index e242321..8bda35c 100644
--- a/services/aliyun-oss/src/lib.rs
+++ b/services/aliyun-oss/src/lib.rs
@@ -33,7 +33,8 @@
 //!
 //! ```no_run
 //! use reqsign_aliyun_oss::{
-//!     AssumeRoleWithOidcCredentialProvider, DefaultCredentialProvider, EnvCredentialProvider,
+//!     AssumeRoleWithOidcCredentialProvider, ConfigFileCredentialProvider,
+//!     CredentialsFileCredentialProvider, DefaultCredentialProvider, EnvCredentialProvider,
 //!     OssProfileCredentialProvider, RequestSigner, StaticCredentialProvider,
 //! };
 //! use reqsign_core::{Context, Signer, Result};
@@ -47,10 +48,13 @@
 //!         .with_file_read(TokioFileRead::default())
 //!         .with_http_send(ReqwestHttpSend::default());
 //!
-//!     // Create credential loader with the default env -> oidc chain.
+//!     // Create credential loader with the default env -> OSS profile ->
+//!     // shared credentials file -> config file -> oidc chain.
 //!     let loader = DefaultCredentialProvider::builder()
 //!         .env(EnvCredentialProvider::new())
 //!         .oss_profile(OssProfileCredentialProvider::new())
+//!         .credentials_file(CredentialsFileCredentialProvider::new())
+//!         .config_file(ConfigFileCredentialProvider::new())
 //!         .oidc(AssumeRoleWithOidcCredentialProvider::new())
 //!         .build();
 //!
@@ -98,10 +102,11 @@
 //! The crate can load credentials from the OSS profile file
 //! (typically `~/.oss/credentials`).
 //!
-//! ### ECS RAM Role
+//! ### Alibaba Shared Credential and Config Files
 //!
-//! When running on Alibaba Cloud ECS instances with RAM roles attached,
-//! credentials are automatically obtained from the metadata service.
+//! The crate can also load static credentials from Alibaba shared SDK files
+//! (`~/.alibabacloud/credentials.ini`, `~/.aliyun/credentials.ini`) and the
+//! Alibaba CLI config file (`~/.aliyun/config.json`).
 //!
 //! ## OSS Operations
 //!
@@ -153,6 +158,9 @@
 //! // Optionally set ALIBABA_CLOUD_ROLE_SESSION_NAME
 //! let loader = DefaultCredentialProvider::builder()
 //!     .no_env()
+//!     .no_oss_profile()
+//!     .no_credentials_file()
+//!     .no_config_file()
 //!     .oidc(
 //!         AssumeRoleWithOidcCredentialProvider::new().with_role_session_name("my-session"),
 //!     )
diff --git a/services/aliyun-oss/src/provide_credential/config_file.rs b/services/aliyun-oss/src/provide_credential/config_file.rs
new file mode 100644
index 0000000..bbd5335
--- /dev/null
+++ b/services/aliyun-oss/src/provide_credential/config_file.rs
@@ -0,0 +1,339 @@
+// 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::Credential;
+#[cfg(not(target_arch = "wasm32"))]
+use crate::constants::{ALIBABA_CLOUD_CONFIG_FILE, ALIBABA_CLOUD_PROFILE};
+#[cfg(not(target_arch = "wasm32"))]
+use log::debug;
+#[cfg(not(target_arch = "wasm32"))]
+use reqsign_core::Error;
+use reqsign_core::{Context, ProvideCredential, Result};
+#[cfg(not(target_arch = "wasm32"))]
+use serde::Deserialize;
+
+/// ConfigFileCredentialProvider loads credentials from Alibaba Cloud CLI config files.
+///
+/// This provider reads credentials from `~/.aliyun/config.json` by default. The
+/// file path can be overridden by `ALIBABA_CLOUD_CONFIG_FILE`, and the selected
+/// profile can be overridden by `ALIBABA_CLOUD_PROFILE`.
+#[derive(Debug, Clone)]
+pub struct ConfigFileCredentialProvider {
+    profile: Option<String>,
+    config_file: Option<String>,
+}
+
+impl Default for ConfigFileCredentialProvider {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl ConfigFileCredentialProvider {
+    /// Create a new config file provider.
+    pub fn new() -> Self {
+        Self {
+            profile: None,
+            config_file: None,
+        }
+    }
+
+    /// Set the profile name to use when `ALIBABA_CLOUD_PROFILE` is not set.
+    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
+        self.profile = Some(profile.into());
+        self
+    }
+
+    /// Set the path to the config file.
+    pub fn with_config_file(mut self, path: impl Into<String>) -> Self {
+        self.config_file = Some(path.into());
+        self
+    }
+
+    #[cfg(not(target_arch = "wasm32"))]
+    async fn load_config(&self, ctx: &Context, path: &str) -> Result<Option<ConfigFile>> {
+        let expanded_path = if path.starts_with("~/") {
+            match ctx.expand_home_dir(path) {
+                Some(expanded) => expanded,
+                None => {
+                    debug!("failed to expand homedir for path: {path}");
+                    return Ok(None);
+                }
+            }
+        } else {
+            path.to_string()
+        };
+
+        let content = match ctx.file_read(&expanded_path).await {
+            Ok(content) => content,
+            Err(err) => {
+                debug!("failed to read Alibaba config file {expanded_path}: {err:?}");
+                return Ok(None);
+            }
+        };
+
+        let config: ConfigFile = serde_json::from_slice(&content).map_err(|e| {
+            Error::config_invalid("failed to parse Alibaba config file").with_source(e)
+        })?;
+
+        Ok(Some(config))
+    }
+}
+
+impl ProvideCredential for ConfigFileCredentialProvider {
+    type Credential = Credential;
+
+    async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
+        #[cfg(target_arch = "wasm32")]
+        {
+            let _ = ctx;
+            Ok(None)
+        }
+
+        #[cfg(not(target_arch = "wasm32"))]
+        {
+            let path = self
+                .config_file
+                .clone()
+                .or_else(|| ctx.env_var(ALIBABA_CLOUD_CONFIG_FILE))
+                .unwrap_or_else(|| "~/.aliyun/config.json".to_string());
+
+            let Some(config) = self.load_config(ctx, &path).await? else {
+                return Ok(None);
+            };
+
+            let profile = ctx
+                .env_var(ALIBABA_CLOUD_PROFILE)
+                .or_else(|| self.profile.clone())
+                .or(config.current.clone())
+                .unwrap_or_else(|| "default".to_string());
+
+            let profile = match config.profiles.iter().find(|entry| entry.name == profile) {
+                Some(profile) => profile,
+                None => {
+                    debug!("profile not found in Alibaba config file");
+                    return Ok(None);
+                }
+            };
+
+            if !profile.supports_static_credential() {
+                debug!(
+                    "Alibaba config profile {} uses unsupported mode {:?}",
+                    profile.name, profile.mode
+                );
+                return Ok(None);
+            }
+
+            match (&profile.access_key_id, &profile.access_key_secret) {
+                (Some(ak), Some(sk)) => Ok(Some(Credential {
+                    access_key_id: ak.clone(),
+                    access_key_secret: sk.clone(),
+                    security_token: profile
+                        .security_token
+                        .clone()
+                        .or_else(|| profile.sts_token.clone()),
+                    expires_in: None,
+                })),
+                _ => Ok(None),
+            }
+        }
+    }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+#[derive(Debug, Deserialize)]
+struct ConfigFile {
+    current: Option<String>,
+    #[serde(default)]
+    profiles: Vec<ConfigProfile>,
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+#[derive(Debug, Deserialize)]
+struct ConfigProfile {
+    name: String,
+    mode: Option<String>,
+    access_key_id: Option<String>,
+    access_key_secret: Option<String>,
+    security_token: Option<String>,
+    sts_token: Option<String>,
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+impl ConfigProfile {
+    fn supports_static_credential(&self) -> bool {
+        match self.mode.as_deref() {
+            None => true,
+            Some(mode) => matches!(
+                mode.trim().to_ascii_lowercase().as_str(),
+                "ak" | "ststoken" | "sts_token" | "sts"
+            ),
+        }
+    }
+}
+
+#[cfg(test)]
+#[cfg(not(target_arch = "wasm32"))]
+mod tests {
+    use super::*;
+    use reqsign_core::{FileRead, StaticEnv};
+    use std::collections::HashMap;
+
+    #[derive(Debug)]
+    struct TestFileRead {
+        files: HashMap<String, Vec<u8>>,
+    }
+
+    impl TestFileRead {
+        fn new(files: HashMap<String, Vec<u8>>) -> Self {
+            Self { files }
+        }
+    }
+
+    impl FileRead for TestFileRead {
+        async fn file_read(&self, path: &str) -> Result<Vec<u8>> {
+            self.files.get(path).cloned().ok_or_else(|| {
+                reqsign_core::Error::config_invalid(format!("unexpected file path: {path}"))
+            })
+        }
+    }
+
+    #[tokio::test]
+    async fn test_config_file_provider_uses_current_profile() -> anyhow::Result<()> {
+        let ctx = Context::new()
+            .with_file_read(TestFileRead::new(HashMap::from([(
+                "/mock/config.json".to_string(),
+                br#"{
+  "current": "prod",
+  "profiles": [
+    {
+      "name": "default",
+      "mode": "AK",
+      "access_key_id": "default_access_key",
+      "access_key_secret": "default_secret_key"
+    },
+    {
+      "name": "prod",
+      "mode": "StsToken",
+      "access_key_id": "prod_access_key",
+      "access_key_secret": "prod_secret_key",
+      "sts_token": "prod_token"
+    }
+  ]
+}"#
+                .to_vec(),
+            )])))
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([(
+                    ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+                    "/mock/config.json".to_string(),
+                )]),
+            });
+
+        let cred = ConfigFileCredentialProvider::new()
+            .provide_credential(&ctx)
+            .await?
+            .unwrap();
+
+        assert_eq!(cred.access_key_id, "prod_access_key");
+        assert_eq!(cred.access_key_secret, "prod_secret_key");
+        assert_eq!(cred.security_token, Some("prod_token".to_string()));
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_config_file_provider_env_profile_overrides_current() -> anyhow::Result<()> {
+        let ctx = Context::new()
+            .with_file_read(TestFileRead::new(HashMap::from([(
+                "/mock/config.json".to_string(),
+                br#"{
+  "current": "prod",
+  "profiles": [
+    {
+      "name": "default",
+      "mode": "AK",
+      "access_key_id": "default_access_key",
+      "access_key_secret": "default_secret_key"
+    },
+    {
+      "name": "prod",
+      "mode": "AK",
+      "access_key_id": "prod_access_key",
+      "access_key_secret": "prod_secret_key"
+    }
+  ]
+}"#
+                .to_vec(),
+            )])))
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([
+                    (
+                        ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+                        "/mock/config.json".to_string(),
+                    ),
+                    (ALIBABA_CLOUD_PROFILE.to_string(), "default".to_string()),
+                ]),
+            });
+
+        let cred = ConfigFileCredentialProvider::new()
+            .provide_credential(&ctx)
+            .await?
+            .unwrap();
+
+        assert_eq!(cred.access_key_id, "default_access_key");
+        assert_eq!(cred.access_key_secret, "default_secret_key");
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_config_file_provider_ignores_unsupported_mode() -> anyhow::Result<()> {
+        let ctx = Context::new()
+            .with_file_read(TestFileRead::new(HashMap::from([(
+                "/mock/config.json".to_string(),
+                br#"{
+  "profiles": [
+    {
+      "name": "default",
+      "mode": "RamRoleArn",
+      "access_key_id": "shared_access_key",
+      "access_key_secret": "shared_secret_key"
+    }
+  ]
+}"#
+                .to_vec(),
+            )])))
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([(
+                    ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+                    "/mock/config.json".to_string(),
+                )]),
+            });
+
+        let cred = ConfigFileCredentialProvider::new()
+            .provide_credential(&ctx)
+            .await?;
+
+        assert!(cred.is_none());
+
+        Ok(())
+    }
+}
diff --git a/services/aliyun-oss/src/provide_credential/credentials_file.rs b/services/aliyun-oss/src/provide_credential/credentials_file.rs
new file mode 100644
index 0000000..c15e972
--- /dev/null
+++ b/services/aliyun-oss/src/provide_credential/credentials_file.rs
@@ -0,0 +1,335 @@
+// 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::Credential;
+#[cfg(not(target_arch = "wasm32"))]
+use crate::constants::{ALIBABA_CLOUD_CREDENTIALS_FILE, ALIBABA_CLOUD_PROFILE};
+#[cfg(not(target_arch = "wasm32"))]
+use ini::Ini;
+#[cfg(not(target_arch = "wasm32"))]
+use log::debug;
+use reqsign_core::{Context, ProvideCredential, Result};
+#[cfg(not(target_arch = "wasm32"))]
+use reqsign_core::{Error, ErrorKind};
+
+/// CredentialsFileCredentialProvider loads credentials from Alibaba Cloud shared credential files.
+///
+/// This provider reads credentials from `~/.alibabacloud/credentials.ini` first
+/// and falls back to `~/.aliyun/credentials.ini`. The file path can be overridden
+/// by `ALIBABA_CLOUD_CREDENTIALS_FILE`, and the selected profile can be overridden
+/// by `ALIBABA_CLOUD_PROFILE`.
+#[derive(Debug, Clone)]
+pub struct CredentialsFileCredentialProvider {
+    profile: Option<String>,
+    credentials_file: Option<String>,
+}
+
+impl Default for CredentialsFileCredentialProvider {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl CredentialsFileCredentialProvider {
+    /// Create a new credentials file provider.
+    pub fn new() -> Self {
+        Self {
+            profile: None,
+            credentials_file: None,
+        }
+    }
+
+    /// Set the profile name to use when `ALIBABA_CLOUD_PROFILE` is not set.
+    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
+        self.profile = Some(profile.into());
+        self
+    }
+
+    /// Set the path to the shared credentials file.
+    pub fn with_credentials_file(mut self, path: impl Into<String>) -> Self {
+        self.credentials_file = Some(path.into());
+        self
+    }
+
+    #[cfg(not(target_arch = "wasm32"))]
+    async fn load_from_path(
+        &self,
+        ctx: &Context,
+        path: &str,
+        profile: &str,
+    ) -> Result<Option<Credential>> {
+        let expanded_path = if path.starts_with("~/") {
+            match ctx.expand_home_dir(path) {
+                Some(expanded) => expanded,
+                None => {
+                    debug!("failed to expand homedir for path: {path}");
+                    return Ok(None);
+                }
+            }
+        } else {
+            path.to_string()
+        };
+
+        let content = match ctx.file_read(&expanded_path).await {
+            Ok(content) => content,
+            Err(err) => {
+                debug!("failed to read shared credentials file {expanded_path}: {err:?}");
+                if err.kind() != ErrorKind::ConfigInvalid {
+                    return Ok(None);
+                }
+                return Ok(None);
+            }
+        };
+
+        let conf = Ini::load_from_str(&String::from_utf8_lossy(&content)).map_err(|e| {
+            Error::config_invalid("failed to parse Alibaba shared credentials file").with_source(e)
+        })?;
+
+        let props = match conf.section(Some(profile)) {
+            Some(props) => props,
+            None => {
+                debug!("profile {profile} not found in shared credentials file");
+                return Ok(None);
+            }
+        };
+
+        if !is_enabled(props.get("enable")) {
+            debug!("profile {profile} is disabled in shared credentials file");
+            return Ok(None);
+        }
+
+        match props.get("type") {
+            Some(mode) if !is_supported_credentials_mode(mode) => {
+                debug!("shared credentials profile {profile} uses unsupported type {mode}");
+                Ok(None)
+            }
+            _ => parse_static_credential(props),
+        }
+    }
+}
+
+impl ProvideCredential for CredentialsFileCredentialProvider {
+    type Credential = Credential;
+
+    async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
+        #[cfg(target_arch = "wasm32")]
+        {
+            let _ = ctx;
+            Ok(None)
+        }
+
+        #[cfg(not(target_arch = "wasm32"))]
+        {
+            let profile = ctx
+                .env_var(ALIBABA_CLOUD_PROFILE)
+                .or_else(|| self.profile.clone())
+                .unwrap_or_else(|| "default".to_string());
+
+            if let Some(path) = self
+                .credentials_file
+                .clone()
+                .or_else(|| ctx.env_var(ALIBABA_CLOUD_CREDENTIALS_FILE))
+            {
+                return self.load_from_path(ctx, &path, &profile).await;
+            }
+
+            for path in [
+                "~/.alibabacloud/credentials.ini",
+                "~/.aliyun/credentials.ini",
+            ] {
+                if let Some(cred) = self.load_from_path(ctx, path, &profile).await? {
+                    return Ok(Some(cred));
+                }
+            }
+
+            Ok(None)
+        }
+    }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn is_enabled(value: Option<&str>) -> bool {
+    match value {
+        None => true,
+        Some(value) => matches!(
+            value.trim().to_ascii_lowercase().as_str(),
+            "true" | "1" | "yes" | "on"
+        ),
+    }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn is_supported_credentials_mode(mode: &str) -> bool {
+    matches!(
+        mode.trim().to_ascii_lowercase().as_str(),
+        "access_key" | "sts" | "sts_token"
+    )
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn parse_static_credential(props: &ini::Properties) -> Result<Option<Credential>> {
+    let access_key_id = props.get("access_key_id");
+    let access_key_secret = props.get("access_key_secret");
+
+    match (access_key_id, access_key_secret) {
+        (Some(ak), Some(sk)) => Ok(Some(Credential {
+            access_key_id: ak.to_string(),
+            access_key_secret: sk.to_string(),
+            security_token: props
+                .get("security_token")
+                .or_else(|| props.get("sts_token"))
+                .map(ToString::to_string),
+            expires_in: None,
+        })),
+        _ => Ok(None),
+    }
+}
+
+#[cfg(test)]
+#[cfg(not(target_arch = "wasm32"))]
+mod tests {
+    use super::*;
+    use reqsign_core::{FileRead, StaticEnv};
+    use std::collections::HashMap;
+
+    #[derive(Debug)]
+    struct TestFileRead {
+        files: HashMap<String, Vec<u8>>,
+    }
+
+    impl TestFileRead {
+        fn new(files: HashMap<String, Vec<u8>>) -> Self {
+            Self { files }
+        }
+    }
+
+    impl FileRead for TestFileRead {
+        async fn file_read(&self, path: &str) -> Result<Vec<u8>> {
+            self.files.get(path).cloned().ok_or_else(|| {
+                reqsign_core::Error::config_invalid(format!("unexpected file path: {path}"))
+            })
+        }
+    }
+
+    #[tokio::test]
+    async fn test_credentials_file_provider_reads_default_profile() -> anyhow::Result<()> {
+        let ctx = Context::new()
+            .with_file_read(TestFileRead::new(HashMap::from([(
+                "/mock/credentials.ini".to_string(),
+                br#"[default]
+enable=true
+type=access_key
+access_key_id=shared_access_key
+access_key_secret=shared_secret_key
+"#
+                .to_vec(),
+            )])))
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([(
+                    ALIBABA_CLOUD_CREDENTIALS_FILE.to_string(),
+                    "/mock/credentials.ini".to_string(),
+                )]),
+            });
+
+        let cred = CredentialsFileCredentialProvider::new()
+            .provide_credential(&ctx)
+            .await?
+            .unwrap();
+
+        assert_eq!(cred.access_key_id, "shared_access_key");
+        assert_eq!(cred.access_key_secret, "shared_secret_key");
+        assert_eq!(cred.security_token, None);
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_credentials_file_provider_env_profile_overrides_builder_profile()
+    -> anyhow::Result<()> {
+        let ctx = Context::new()
+            .with_file_read(TestFileRead::new(HashMap::from([(
+                "/mock/credentials.ini".to_string(),
+                br#"[default]
+enable=true
+type=access_key
+access_key_id=default_access_key
+access_key_secret=default_secret_key
+
+[prod]
+enable=true
+type=sts_token
+access_key_id=prod_access_key
+access_key_secret=prod_secret_key
+sts_token=prod_token
+"#
+                .to_vec(),
+            )])))
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([
+                    (
+                        ALIBABA_CLOUD_CREDENTIALS_FILE.to_string(),
+                        "/mock/credentials.ini".to_string(),
+                    ),
+                    (ALIBABA_CLOUD_PROFILE.to_string(), "prod".to_string()),
+                ]),
+            });
+
+        let cred = CredentialsFileCredentialProvider::new()
+            .with_profile("default")
+            .provide_credential(&ctx)
+            .await?
+            .unwrap();
+
+        assert_eq!(cred.access_key_id, "prod_access_key");
+        assert_eq!(cred.access_key_secret, "prod_secret_key");
+        assert_eq!(cred.security_token, Some("prod_token".to_string()));
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_credentials_file_provider_ignores_unsupported_mode() -> anyhow::Result<()> {
+        let ctx = Context::new()
+            .with_file_read(TestFileRead::new(HashMap::from([(
+                "/mock/credentials.ini".to_string(),
+                br#"[default]
+enable=true
+type=ram_role_arn
+access_key_id=shared_access_key
+access_key_secret=shared_secret_key
+"#
+                .to_vec(),
+            )])))
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([(
+                    ALIBABA_CLOUD_CREDENTIALS_FILE.to_string(),
+                    "/mock/credentials.ini".to_string(),
+                )]),
+            });
+
+        let cred = CredentialsFileCredentialProvider::new()
+            .provide_credential(&ctx)
+            .await?;
+
+        assert!(cred.is_none());
+
+        Ok(())
+    }
+}
diff --git a/services/aliyun-oss/src/provide_credential/default.rs b/services/aliyun-oss/src/provide_credential/default.rs
index 1d1e611..41c581f 100644
--- a/services/aliyun-oss/src/provide_credential/default.rs
+++ b/services/aliyun-oss/src/provide_credential/default.rs
@@ -17,7 +17,8 @@
 
 use crate::Credential;
 use crate::provide_credential::{
-    AssumeRoleWithOidcCredentialProvider, EnvCredentialProvider, OssProfileCredentialProvider,
+    AssumeRoleWithOidcCredentialProvider, ConfigFileCredentialProvider,
+    CredentialsFileCredentialProvider, EnvCredentialProvider, OssProfileCredentialProvider,
 };
 use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
 
@@ -27,7 +28,9 @@ use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
 ///
 /// 1. Environment variables
 /// 2. OSS profile file
-/// 3. Assume Role with OIDC
+/// 3. Alibaba shared credentials file
+/// 4. Alibaba CLI config file
+/// 5. Assume Role with OIDC
 #[derive(Debug)]
 pub struct DefaultCredentialProvider {
     chain: ProvideCredentialChain<Credential>,
@@ -84,6 +87,8 @@ impl DefaultCredentialProvider {
 pub struct DefaultCredentialProviderBuilder {
     env: Option<EnvCredentialProvider>,
     oss_profile: Option<OssProfileCredentialProvider>,
+    credentials_file: Option<CredentialsFileCredentialProvider>,
+    config_file: Option<ConfigFileCredentialProvider>,
     oidc: Option<AssumeRoleWithOidcCredentialProvider>,
 }
 
@@ -92,6 +97,8 @@ impl Default for DefaultCredentialProviderBuilder {
         Self {
             env: Some(EnvCredentialProvider::new()),
             oss_profile: Some(OssProfileCredentialProvider::new()),
+            credentials_file: Some(CredentialsFileCredentialProvider::new()),
+            config_file: Some(ConfigFileCredentialProvider::new()),
             oidc: Some(AssumeRoleWithOidcCredentialProvider::new()),
         }
     }
@@ -127,6 +134,29 @@ impl DefaultCredentialProviderBuilder {
         self
     }
 
+    /// Set the Alibaba shared credentials file provider slot.
+    pub fn credentials_file(mut self, provider: CredentialsFileCredentialProvider) -> Self {
+        self.credentials_file = Some(provider);
+        self
+    }
+
+    /// Remove the Alibaba shared credentials file provider slot.
+    pub fn no_credentials_file(mut self) -> Self {
+        self.credentials_file = None;
+        self
+    }
+
+    /// Set the Alibaba config file provider slot.
+    pub fn config_file(mut self, provider: ConfigFileCredentialProvider) -> Self {
+        self.config_file = Some(provider);
+        self
+    }
+
+    /// Remove the Alibaba config file provider slot.
+    pub fn no_config_file(mut self) -> Self {
+        self.config_file = None;
+        self
+    }
     /// Set the OIDC credential provider slot.
     pub fn oidc(mut self, provider: AssumeRoleWithOidcCredentialProvider) -> Self {
         self.oidc = Some(provider);
@@ -148,6 +178,12 @@ impl DefaultCredentialProviderBuilder {
         if let Some(p) = self.oss_profile {
             chain = chain.push(p);
         }
+        if let Some(p) = self.credentials_file {
+            chain = chain.push(p);
+        }
+        if let Some(p) = self.config_file {
+            chain = chain.push(p);
+        }
         if let Some(p) = self.oidc {
             chain = chain.push(p);
         }
@@ -435,6 +471,222 @@ access_key_secret = profile_secret_key
         assert_eq!(0, http_send.calls());
     }
 
+    #[tokio::test]
+    async fn test_default_loader_prefers_credentials_file_over_config_file() {
+        let file_read = CountingFileRead::new(HashMap::from([
+            (
+                "/mock/credentials.ini".to_string(),
+                br#"[default]
+enable=true
+type=access_key
+access_key_id=shared_access_key
+access_key_secret=shared_secret_key
+"#
+                .to_vec(),
+            ),
+            (
+                "/mock/config.json".to_string(),
+                br#"{
+  "profiles": [
+    {
+      "name": "default",
+      "mode": "AK",
+      "access_key_id": "config_access_key",
+      "access_key_secret": "config_secret_key"
+    }
+  ]
+}"#
+                .to_vec(),
+            ),
+        ]));
+        let ctx = Context::new()
+            .with_file_read(file_read)
+            .with_http_send(ReqwestHttpSend::default())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from_iter([
+                    (
+                        ALIBABA_CLOUD_CREDENTIALS_FILE.to_string(),
+                        "/mock/credentials.ini".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+                        "/mock/config.json".to_string(),
+                    ),
+                ]),
+            });
+
+        let credential = DefaultCredentialProvider::builder()
+            .no_env()
+            .no_oss_profile()
+            .no_oidc()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap();
+
+        assert_eq!("shared_access_key", credential.access_key_id);
+        assert_eq!("shared_secret_key", credential.access_key_secret);
+    }
+
+    #[tokio::test]
+    async fn test_builder_no_credentials_file_removes_credentials_file_provider() {
+        let file_read = CountingFileRead::new(HashMap::from([(
+            "/mock/config.json".to_string(),
+            br#"{
+  "profiles": [
+    {
+      "name": "default",
+      "mode": "AK",
+      "access_key_id": "config_access_key",
+      "access_key_secret": "config_secret_key"
+    }
+  ]
+}"#
+            .to_vec(),
+        )]));
+        let ctx = Context::new()
+            .with_file_read(file_read)
+            .with_http_send(ReqwestHttpSend::default())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([(
+                    ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+                    "/mock/config.json".to_string(),
+                )]),
+            });
+
+        let credential = DefaultCredentialProvider::builder()
+            .no_env()
+            .no_oss_profile()
+            .no_credentials_file()
+            .no_oidc()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap();
+
+        assert_eq!("config_access_key", credential.access_key_id);
+        assert_eq!("config_secret_key", credential.access_key_secret);
+    }
+
+    #[tokio::test]
+    async fn test_builder_no_config_file_removes_config_file_provider() {
+        let file_read = CountingFileRead::new(HashMap::from([(
+            "/mock/config.json".to_string(),
+            br#"{
+  "profiles": [
+    {
+      "name": "default",
+      "mode": "AK",
+      "access_key_id": "config_access_key",
+      "access_key_secret": "config_secret_key"
+    }
+  ]
+}"#
+            .to_vec(),
+        )]));
+        let ctx = Context::new()
+            .with_file_read(file_read)
+            .with_http_send(ReqwestHttpSend::default())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([(
+                    ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+                    "/mock/config.json".to_string(),
+                )]),
+            });
+
+        let credential = DefaultCredentialProvider::builder()
+            .no_env()
+            .no_oss_profile()
+            .no_credentials_file()
+            .no_oidc()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!("config_access_key", credential.access_key_id);
+
+        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());
+    }
+
+    #[tokio::test]
+    async fn test_default_loader_prefers_config_file_over_oidc() {
+        let file_read = CountingFileRead::new(HashMap::from([
+            (
+                "/mock/config.json".to_string(),
+                br#"{
+  "profiles": [
+    {
+      "name": "default",
+      "mode": "AK",
+      "access_key_id": "config_access_key",
+      "access_key_secret": "config_secret_key"
+    }
+  ]
+}"#
+                .to_vec(),
+            ),
+            ("/mock/token".to_string(), b"token".to_vec()),
+        ]));
+        let http_send = CountingHttpSend::new(
+            br#"{"Credentials":{"SecurityToken":"security_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"oidc_secret_key","AccessKeyId":"oidc_access_key"}}"#
+                .to_vec(),
+        );
+        let ctx = Context::new()
+            .with_file_read(file_read)
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from_iter([
+                    (
+                        ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+                        "/mock/config.json".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_OIDC_TOKEN_FILE.to_string(),
+                        "/mock/token".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_ROLE_ARN.to_string(),
+                        "acs:ram::123456789012:role/test-role".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_OIDC_PROVIDER_ARN.to_string(),
+                        "acs:ram::123456789012:oidc-provider/test-provider".to_string(),
+                    ),
+                ]),
+            });
+
+        let credential = DefaultCredentialProvider::builder()
+            .no_env()
+            .no_oss_profile()
+            .no_credentials_file()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap();
+
+        assert_eq!("config_access_key", credential.access_key_id);
+        assert_eq!("config_secret_key", credential.access_key_secret);
+        assert_eq!(0, http_send.calls());
+    }
+
     #[tokio::test]
     async fn test_builder_no_oidc_removes_oidc_provider() {
         let file_read = CountingFileRead::new(HashMap::from([(
diff --git a/services/aliyun-oss/src/provide_credential/mod.rs b/services/aliyun-oss/src/provide_credential/mod.rs
index a54db4b..92a6704 100644
--- a/services/aliyun-oss/src/provide_credential/mod.rs
+++ b/services/aliyun-oss/src/provide_credential/mod.rs
@@ -18,6 +18,12 @@
 mod assume_role_with_oidc;
 pub use assume_role_with_oidc::AssumeRoleWithOidcCredentialProvider;
 
+mod config_file;
+pub use config_file::ConfigFileCredentialProvider;
+
+mod credentials_file;
+pub use credentials_file::CredentialsFileCredentialProvider;
+
 mod default;
 pub use default::{DefaultCredentialProvider, DefaultCredentialProviderBuilder};