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 20:25:03 UTC

(opendal-reqsign) branch main updated: feat(aliyun-oss): add credentials URI and ECS RAM role providers (#721)

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 85ed1dd  feat(aliyun-oss): add credentials URI and ECS RAM role providers (#721)
85ed1dd is described below

commit 85ed1dd30680682789f0869fcc7ec40d93b49bbe
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Thu Mar 19 04:24:58 2026 +0800

    feat(aliyun-oss): add credentials URI and ECS RAM role providers (#721)
    
    This stacked PR builds on #720 by adding the two network-based runtime
    credential sources needed for Aliyun OSS parity: `credentials_uri` and
    ECS RAM role metadata.
    
    It introduces dedicated slot/no-slot builder entries, keeps refresh
    behavior aligned with `reqsign_core::Signer` cache semantics, and adds
    focused tests for chain order, IMDS fallback, and refresh-before-expiry
    behavior. Related to #684.
---
 services/aliyun-oss/README.md                      |  43 +-
 services/aliyun-oss/examples/oss_operations.rs     |  14 +-
 services/aliyun-oss/src/constants.rs               |   6 +
 services/aliyun-oss/src/lib.rs                     |  24 +-
 .../src/provide_credential/credentials_uri.rs      | 411 ++++++++++++++
 .../aliyun-oss/src/provide_credential/default.rs   | 338 ++++++++++-
 .../src/provide_credential/ecs_ram_role.rs         | 618 +++++++++++++++++++++
 services/aliyun-oss/src/provide_credential/mod.rs  |   6 +
 8 files changed, 1424 insertions(+), 36 deletions(-)

diff --git a/services/aliyun-oss/README.md b/services/aliyun-oss/README.md
index 27582ff..10aefc9 100644
--- a/services/aliyun-oss/README.md
+++ b/services/aliyun-oss/README.md
@@ -11,9 +11,10 @@ This crate provides signing support for Alibaba Cloud Object Storage Service (OS
 ```rust
 use reqsign_aliyun_oss::{
     AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
-    ConfigFileCredentialProvider, CredentialsFileCredentialProvider, DefaultCredentialProvider,
-    EnvCredentialProvider, OssProfileCredentialProvider, RequestSigner,
-    SigningVersion, StaticCredentialProvider,
+    ConfigFileCredentialProvider, CredentialsFileCredentialProvider,
+    CredentialsUriCredentialProvider, DefaultCredentialProvider,
+    EcsRamRoleCredentialProvider, EnvCredentialProvider, OssProfileCredentialProvider,
+    RequestSigner, SigningVersion, StaticCredentialProvider,
 };
 use reqsign_core::{Context, Result, Signer};
 use reqsign_file_read_tokio::TokioFileRead;
@@ -31,6 +32,8 @@ async fn main() -> Result<()> {
         .oss_profile(OssProfileCredentialProvider::new())
         .credentials_file(CredentialsFileCredentialProvider::new())
         .config_file(ConfigFileCredentialProvider::new())
+        .credentials_uri(CredentialsUriCredentialProvider::new())
+        .ecs_ram_role(EcsRamRoleCredentialProvider::new())
         .oidc(AssumeRoleWithOidcCredentialProvider::new())
         .build();
 
@@ -65,6 +68,7 @@ async fn main() -> Result<()> {
 
 - **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
+- **Runtime Credential Sources**: Credentials URI, ECS RAM role metadata, and OIDC-based STS exchange
 - **STS Support**: Temporary credentials via Security Token Service
 - **All OSS Operations**: Object, bucket, and multipart operations
 
@@ -114,6 +118,28 @@ access_key_secret = prod-access-key-secret
 
 Override the file path with `OSS_CREDENTIAL_PROFILES_FILE` and the selected profile with `OSS_PROFILE`.
 
+### Credentials URI
+
+Load temporary credentials from a custom endpoint:
+
+```bash
+export ALIBABA_CLOUD_CREDENTIALS_URI=http://127.0.0.1:8080/credentials
+```
+
+The endpoint should return JSON containing `AccessKeyId`, `AccessKeySecret`, `SecurityToken`, and `Expiration`.
+
+### ECS RAM Role Metadata
+
+On Alibaba Cloud ECS instances, the provider reads temporary credentials from the metadata service.
+
+```bash
+export ALIBABA_CLOUD_ECS_METADATA=my-ram-role              # Optional role name override
+export ALIBABA_CLOUD_IMDSV1_DISABLED=true                 # Optional: require IMDSv2
+export ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT=http://127.0.0.1  # Optional override for tests
+```
+
+If `ALIBABA_CLOUD_ECS_METADATA` is unset, the provider resolves the role name from metadata first and then fetches the credentials.
+
 ### Alibaba Shared Credentials File
 
 Reads from `~/.alibabacloud/credentials.ini` first and falls back to `~/.aliyun/credentials.ini`:
@@ -158,6 +184,7 @@ Reads from `~/.aliyun/config.json` by default:
 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:
@@ -170,6 +197,8 @@ use reqsign_aliyun_oss::{AssumeRoleWithOidcCredentialProvider, DefaultCredential
 let loader = DefaultCredentialProvider::builder()
     .no_env()
     .no_oss_profile()
+    .no_credentials_uri()
+    .no_ecs_ram_role()
     .no_credentials_file()
     .no_config_file()
     .oidc(
@@ -331,7 +360,13 @@ let loader = StaticCredentialProvider::new("your-access-key-id", "your-access-ke
 ```rust
 use reqsign_aliyun_oss::DefaultCredentialProvider;
 
-let loader = DefaultCredentialProvider::builder().no_oidc().build();
+let loader = DefaultCredentialProvider::builder()
+    .no_credentials_file()
+    .no_config_file()
+    .no_credentials_uri()
+    .no_ecs_ram_role()
+    .no_oidc()
+    .build();
 ```
 
 ## License
diff --git a/services/aliyun-oss/examples/oss_operations.rs b/services/aliyun-oss/examples/oss_operations.rs
index 09b5c6c..68a4861 100644
--- a/services/aliyun-oss/examples/oss_operations.rs
+++ b/services/aliyun-oss/examples/oss_operations.rs
@@ -16,8 +16,10 @@
 // under the License.
 
 use reqsign_aliyun_oss::{
-    AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider, DefaultCredentialProvider,
-    EnvCredentialProvider, RequestSigner, StaticCredentialProvider,
+    AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
+    ConfigFileCredentialProvider, CredentialsFileCredentialProvider,
+    CredentialsUriCredentialProvider, DefaultCredentialProvider, EcsRamRoleCredentialProvider,
+    EnvCredentialProvider, OssProfileCredentialProvider, RequestSigner, StaticCredentialProvider,
 };
 use reqsign_core::Result;
 use reqsign_core::{Context, OsEnv, Signer};
@@ -62,10 +64,16 @@ async fn main() -> Result<()> {
             StaticCredentialProvider::new("LTAI4GDemoAccessKeyId", "DemoAccessKeySecretForExample");
         Signer::new(ctx.clone(), loader, builder)
     } else {
-        // Build the default assume_role -> env -> oidc chain explicitly via slot APIs.
+        // Build the default assume_role -> env -> oss_profile -> credentials_file ->
+        // config_file -> credentials_uri -> ecs_ram_role -> oidc chain.
         let loader = DefaultCredentialProvider::builder()
             .assume_role(AssumeRoleCredentialProvider::new())
             .env(EnvCredentialProvider::new())
+            .oss_profile(OssProfileCredentialProvider::new())
+            .credentials_file(CredentialsFileCredentialProvider::new())
+            .config_file(ConfigFileCredentialProvider::new())
+            .credentials_uri(CredentialsUriCredentialProvider::new())
+            .ecs_ram_role(EcsRamRoleCredentialProvider::new())
             .oidc(AssumeRoleWithOidcCredentialProvider::new())
             .build();
         Signer::new(ctx.clone(), loader, builder)
diff --git a/services/aliyun-oss/src/constants.rs b/services/aliyun-oss/src/constants.rs
index ca20192..590f11d 100644
--- a/services/aliyun-oss/src/constants.rs
+++ b/services/aliyun-oss/src/constants.rs
@@ -27,6 +27,12 @@ pub const OSS_ACCESS_KEY_SECRET: &str = "OSS_ACCESS_KEY_SECRET";
 pub const OSS_SESSION_TOKEN: &str = "OSS_SESSION_TOKEN";
 pub const OSS_PROFILE: &str = "OSS_PROFILE";
 pub const OSS_CREDENTIAL_PROFILES_FILE: &str = "OSS_CREDENTIAL_PROFILES_FILE";
+pub const ALIBABA_CLOUD_CREDENTIALS_URI: &str = "ALIBABA_CLOUD_CREDENTIALS_URI";
+pub const ALIBABA_CLOUD_ECS_METADATA: &str = "ALIBABA_CLOUD_ECS_METADATA";
+pub const ALIBABA_CLOUD_ECS_METADATA_DISABLED: &str = "ALIBABA_CLOUD_ECS_METADATA_DISABLED";
+pub const ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT: &str =
+    "ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT";
+pub const ALIBABA_CLOUD_IMDSV1_DISABLED: &str = "ALIBABA_CLOUD_IMDSV1_DISABLED";
 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";
diff --git a/services/aliyun-oss/src/lib.rs b/services/aliyun-oss/src/lib.rs
index b10a938..39c4f9d 100644
--- a/services/aliyun-oss/src/lib.rs
+++ b/services/aliyun-oss/src/lib.rs
@@ -34,9 +34,10 @@
 //! ```no_run
 //! use reqsign_aliyun_oss::{
 //!     AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
-//!     ConfigFileCredentialProvider, CredentialsFileCredentialProvider, DefaultCredentialProvider,
-//!     EnvCredentialProvider, OssProfileCredentialProvider, RequestSigner,
-//!     SigningVersion, StaticCredentialProvider,
+//!     ConfigFileCredentialProvider, CredentialsFileCredentialProvider,
+//!     CredentialsUriCredentialProvider, DefaultCredentialProvider,
+//!     EcsRamRoleCredentialProvider, EnvCredentialProvider, OssProfileCredentialProvider,
+//!     RequestSigner, SigningVersion, StaticCredentialProvider,
 //! };
 //! use reqsign_core::{Context, Signer, Result};
 //! use reqsign_file_read_tokio::TokioFileRead;
@@ -50,13 +51,16 @@
 //!         .with_http_send(ReqwestHttpSend::default());
 //!
 //!     // Create credential loader with the default assume_role -> env ->
-//!     // OSS profile -> shared credentials file -> config file -> oidc chain.
+//!     // OSS profile -> shared credentials file -> config file ->
+//!     // credentials URI -> ECS RAM role -> oidc chain.
 //!     let loader = DefaultCredentialProvider::builder()
 //!         .assume_role(AssumeRoleCredentialProvider::new())
 //!         .env(EnvCredentialProvider::new())
 //!         .oss_profile(OssProfileCredentialProvider::new())
 //!         .credentials_file(CredentialsFileCredentialProvider::new())
 //!         .config_file(ConfigFileCredentialProvider::new())
+//!         .credentials_uri(CredentialsUriCredentialProvider::new())
+//!         .ecs_ram_role(EcsRamRoleCredentialProvider::new())
 //!         .oidc(AssumeRoleWithOidcCredentialProvider::new())
 //!         .build();
 //!
@@ -106,6 +110,15 @@
 //! The crate can load credentials from the OSS profile file
 //! (typically `~/.oss/credentials`).
 //!
+//! ### Credentials URI
+//!
+//! The crate can load temporary credentials from
+//! `ALIBABA_CLOUD_CREDENTIALS_URI`.
+//!
+//! ### ECS RAM Role
+//!
+//! The crate can load temporary credentials from the ECS metadata service.
+//!
 //! ### Alibaba Shared Credential and Config Files
 //!
 //! The crate can also load static credentials from Alibaba shared SDK files
@@ -163,6 +176,8 @@
 //! let loader = DefaultCredentialProvider::builder()
 //!     .no_env()
 //!     .no_oss_profile()
+//!     .no_credentials_uri()
+//!     .no_ecs_ram_role()
 //!     .no_credentials_file()
 //!     .no_config_file()
 //!     .assume_role(
@@ -201,7 +216,6 @@
 //!
 //! Check out the examples directory:
 //! - [Basic OSS operations](examples/oss_operations.rs)
-//! - [STS authentication](examples/sts_auth.rs)
 
 mod constants;
 
diff --git a/services/aliyun-oss/src/provide_credential/credentials_uri.rs b/services/aliyun-oss/src/provide_credential/credentials_uri.rs
new file mode 100644
index 0000000..304d6e3
--- /dev/null
+++ b/services/aliyun-oss/src/provide_credential/credentials_uri.rs
@@ -0,0 +1,411 @@
+// 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, constants::ALIBABA_CLOUD_CREDENTIALS_URI};
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Error, ProvideCredential, Result};
+use serde::Deserialize;
+
+/// CredentialsUriCredentialProvider loads temporary credentials from a custom URI.
+///
+/// Configuration values can be provided directly via [`with_uri`] or through the
+/// `ALIBABA_CLOUD_CREDENTIALS_URI` environment variable.
+#[derive(Debug, Default, Clone)]
+pub struct CredentialsUriCredentialProvider {
+    uri: Option<String>,
+}
+
+impl CredentialsUriCredentialProvider {
+    /// Create a new `CredentialsUriCredentialProvider` instance.
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Set the credentials URI directly.
+    ///
+    /// This setting takes precedence over `ALIBABA_CLOUD_CREDENTIALS_URI`.
+    pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
+        self.uri = Some(uri.into());
+        self
+    }
+
+    fn get_uri(&self, ctx: &Context) -> Option<String> {
+        self.uri
+            .clone()
+            .or_else(|| ctx.env_var(ALIBABA_CLOUD_CREDENTIALS_URI))
+            .filter(|uri| !uri.is_empty())
+    }
+}
+
+impl ProvideCredential for CredentialsUriCredentialProvider {
+    type Credential = Credential;
+
+    async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
+        let Some(uri) = self.get_uri(ctx) else {
+            return Ok(None);
+        };
+
+        let req = http::Request::builder()
+            .method(http::Method::GET)
+            .uri(&uri)
+            .body(Vec::new())
+            .map_err(|e| {
+                Error::request_invalid("failed to build credentials URI request")
+                    .with_source(e)
+                    .with_context(format!("uri: {uri}"))
+            })?;
+
+        let resp = ctx
+            .http_send_as_string(req.map(Into::into))
+            .await
+            .map_err(|e| {
+                Error::unexpected("failed to fetch credentials from credentials URI")
+                    .with_source(e)
+                    .with_context(format!("uri: {uri}"))
+                    .set_retryable(true)
+            })?;
+
+        let status = resp.status();
+        let body = resp.into_body();
+
+        if status != http::StatusCode::OK {
+            let error = match status.as_u16() {
+                401 | 403 => {
+                    Error::permission_denied(format!("credentials URI returned {status}: {body}"))
+                }
+                404 => Error::config_invalid("credentials URI not found")
+                    .with_context(format!("uri: {uri}")),
+                500..=599 => {
+                    Error::unexpected(format!("credentials URI returned {status}: {body}"))
+                        .set_retryable(true)
+                }
+                _ => Error::unexpected(format!("credentials URI returned {status}: {body}")),
+            };
+
+            return Err(error.with_context(format!("uri: {uri}")));
+        }
+
+        let resp: CredentialsUriResponse = serde_json::from_str(&body).map_err(|e| {
+            Error::unexpected("failed to parse credentials URI response")
+                .with_source(e)
+                .with_context(format!("uri: {uri}"))
+        })?;
+
+        if !resp.code.is_empty() && resp.code != "Success" {
+            return Err(Error::unexpected(format!(
+                "credentials URI returned error code {}",
+                resp.code
+            ))
+            .with_context(format!("uri: {uri}")));
+        }
+
+        if resp.access_key_id.is_empty()
+            || resp.access_key_secret.is_empty()
+            || resp.security_token.is_empty()
+            || resp.expiration.is_empty()
+        {
+            return Err(Error::credential_invalid(
+                "credentials URI response is missing required fields",
+            )
+            .with_context(format!("uri: {uri}")));
+        }
+
+        let expires_in: Timestamp = resp.expiration.parse().map_err(|e| {
+            Error::credential_invalid("failed to parse credentials URI expiration")
+                .with_source(e)
+                .with_context(format!("uri: {uri}"))
+        })?;
+
+        Ok(Some(Credential {
+            access_key_id: resp.access_key_id,
+            access_key_secret: resp.access_key_secret,
+            security_token: Some(resp.security_token),
+            expires_in: Some(expires_in),
+        }))
+    }
+}
+
+#[derive(Default, Debug, Deserialize)]
+#[serde(default, rename_all = "PascalCase")]
+struct CredentialsUriResponse {
+    code: String,
+    access_key_id: String,
+    access_key_secret: String,
+    #[serde(alias = "SessionToken")]
+    security_token: String,
+    expiration: String,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use bytes::Bytes;
+    use reqsign_core::time::Timestamp;
+    use reqsign_core::{Context, HttpSend, StaticEnv};
+    use reqsign_file_read_tokio::TokioFileRead;
+    use std::collections::{HashMap, VecDeque};
+    use std::sync::{Arc, Mutex};
+    use std::time::Duration;
+
+    #[derive(Debug, Clone)]
+    struct MockResponse {
+        status: http::StatusCode,
+        body: String,
+    }
+
+    #[derive(Debug, Clone, PartialEq, Eq)]
+    struct CapturedRequest {
+        method: http::Method,
+        uri: String,
+    }
+
+    #[derive(Debug, Clone)]
+    struct SequenceHttpSend {
+        requests: Arc<Mutex<Vec<CapturedRequest>>>,
+        responses: Arc<Mutex<VecDeque<MockResponse>>>,
+    }
+
+    impl SequenceHttpSend {
+        fn new(responses: Vec<MockResponse>) -> Self {
+            Self {
+                requests: Arc::new(Mutex::new(Vec::new())),
+                responses: Arc::new(Mutex::new(responses.into())),
+            }
+        }
+
+        fn requests(&self) -> Vec<CapturedRequest> {
+            self.requests.lock().expect("lock poisoned").clone()
+        }
+
+        fn call_count(&self) -> usize {
+            self.requests.lock().expect("lock poisoned").len()
+        }
+    }
+
+    impl HttpSend for SequenceHttpSend {
+        async fn http_send(
+            &self,
+            req: http::Request<Bytes>,
+        ) -> reqsign_core::Result<http::Response<Bytes>> {
+            self.requests
+                .lock()
+                .expect("lock poisoned")
+                .push(CapturedRequest {
+                    method: req.method().clone(),
+                    uri: req.uri().to_string(),
+                });
+
+            let resp = self
+                .responses
+                .lock()
+                .expect("lock poisoned")
+                .pop_front()
+                .expect("mock response must exist");
+
+            Ok(http::Response::builder()
+                .status(resp.status)
+                .body(Bytes::from(resp.body))
+                .expect("response must build"))
+        }
+    }
+
+    fn credentials_response(expiration: String, access_key_id: &str) -> String {
+        format!(
+            r#"{{"Code":"Success","AccessKeyId":"{access_key_id}","AccessKeySecret":"secret","SecurityToken":"token","Expiration":"{expiration}"}}"#
+        )
+    }
+
+    #[tokio::test]
+    async fn test_credentials_uri_provider_without_config() -> anyhow::Result<()> {
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(SequenceHttpSend::new(Vec::new()))
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::new(),
+            });
+
+        let provider = CredentialsUriCredentialProvider::new();
+        let cred = provider.provide_credential(&ctx).await?;
+        assert!(cred.is_none());
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_credentials_uri_provider_reads_env_uri() -> anyhow::Result<()> {
+        let http_send = SequenceHttpSend::new(vec![MockResponse {
+            status: http::StatusCode::OK,
+            body: credentials_response("2124-05-25T11:45:17Z".to_string(), "uri_access_key"),
+        }]);
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([(
+                    ALIBABA_CLOUD_CREDENTIALS_URI.to_string(),
+                    "http://127.0.0.1/credentials".to_string(),
+                )]),
+            });
+
+        let cred = CredentialsUriCredentialProvider::new()
+            .provide_credential(&ctx)
+            .await?
+            .expect("credential must be loaded");
+
+        assert_eq!("uri_access_key", cred.access_key_id);
+        assert_eq!("secret", cred.access_key_secret);
+        assert_eq!(Some("token".to_string()), cred.security_token);
+        assert_eq!(
+            vec![CapturedRequest {
+                method: http::Method::GET,
+                uri: "http://127.0.0.1/credentials".to_string(),
+            }],
+            http_send.requests()
+        );
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_credentials_uri_provider_builder_overrides_env() -> anyhow::Result<()> {
+        let http_send = SequenceHttpSend::new(vec![MockResponse {
+            status: http::StatusCode::OK,
+            body: credentials_response("2124-05-25T11:45:17Z".to_string(), "override_access_key"),
+        }]);
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([(
+                    ALIBABA_CLOUD_CREDENTIALS_URI.to_string(),
+                    "http://127.0.0.1/env-credentials".to_string(),
+                )]),
+            });
+
+        let cred = CredentialsUriCredentialProvider::new()
+            .with_uri("http://127.0.0.1/builder-credentials")
+            .provide_credential(&ctx)
+            .await?
+            .expect("credential must be loaded");
+
+        assert_eq!("override_access_key", cred.access_key_id);
+        assert_eq!(
+            vec![CapturedRequest {
+                method: http::Method::GET,
+                uri: "http://127.0.0.1/builder-credentials".to_string(),
+            }],
+            http_send.requests()
+        );
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_credentials_uri_provider_refreshes_via_signer_cache() -> anyhow::Result<()> {
+        let http_send = SequenceHttpSend::new(vec![
+            MockResponse {
+                status: http::StatusCode::OK,
+                body: credentials_response(
+                    (Timestamp::now() + Duration::from_secs(60)).format_rfc3339_zulu(),
+                    "first_access_key",
+                ),
+            },
+            MockResponse {
+                status: http::StatusCode::OK,
+                body: credentials_response(
+                    (Timestamp::now() + Duration::from_secs(600)).format_rfc3339_zulu(),
+                    "second_access_key",
+                ),
+            },
+        ]);
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::new(),
+            });
+        let signer = reqsign_core::Signer::new(
+            ctx,
+            CredentialsUriCredentialProvider::new().with_uri("http://127.0.0.1/credentials"),
+            crate::RequestSigner::new("bucket"),
+        );
+
+        let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object")
+            .body(())
+            .unwrap()
+            .into_parts()
+            .0;
+        signer.sign(&mut req, None).await?;
+
+        let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object")
+            .body(())
+            .unwrap()
+            .into_parts()
+            .0;
+        signer.sign(&mut req, None).await?;
+
+        assert_eq!(2, http_send.call_count());
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_credentials_uri_provider_reuses_valid_credential_via_signer_cache()
+    -> anyhow::Result<()> {
+        let http_send = SequenceHttpSend::new(vec![MockResponse {
+            status: http::StatusCode::OK,
+            body: credentials_response(
+                (Timestamp::now() + Duration::from_secs(600)).format_rfc3339_zulu(),
+                "cached_access_key",
+            ),
+        }]);
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::new(),
+            });
+        let signer = reqsign_core::Signer::new(
+            ctx,
+            CredentialsUriCredentialProvider::new().with_uri("http://127.0.0.1/credentials"),
+            crate::RequestSigner::new("bucket"),
+        );
+
+        let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object")
+            .body(())
+            .unwrap()
+            .into_parts()
+            .0;
+        signer.sign(&mut req, None).await?;
+
+        let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object")
+            .body(())
+            .unwrap()
+            .into_parts()
+            .0;
+        signer.sign(&mut req, None).await?;
+
+        assert_eq!(1, http_send.call_count());
+
+        Ok(())
+    }
+}
diff --git a/services/aliyun-oss/src/provide_credential/default.rs b/services/aliyun-oss/src/provide_credential/default.rs
index 9e8b1c0..227920d 100644
--- a/services/aliyun-oss/src/provide_credential/default.rs
+++ b/services/aliyun-oss/src/provide_credential/default.rs
@@ -18,7 +18,8 @@
 use crate::Credential;
 use crate::provide_credential::{
     AssumeRoleCredentialProvider, AssumeRoleWithOidcCredentialProvider,
-    ConfigFileCredentialProvider, CredentialsFileCredentialProvider, EnvCredentialProvider,
+    ConfigFileCredentialProvider, CredentialsFileCredentialProvider,
+    CredentialsUriCredentialProvider, EcsRamRoleCredentialProvider, EnvCredentialProvider,
     OssProfileCredentialProvider,
 };
 use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
@@ -32,7 +33,9 @@ use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
 /// 3. OSS profile file
 /// 4. Alibaba shared credentials file
 /// 5. Alibaba CLI config file
-/// 6. Assume Role with OIDC
+/// 6. Credentials URI
+/// 7. ECS RAM role metadata
+/// 8. Assume Role with OIDC
 #[derive(Debug)]
 pub struct DefaultCredentialProvider {
     chain: ProvideCredentialChain<Credential>,
@@ -90,6 +93,8 @@ pub struct DefaultCredentialProviderBuilder {
     assume_role: Option<AssumeRoleCredentialProvider>,
     env: Option<EnvCredentialProvider>,
     oss_profile: Option<OssProfileCredentialProvider>,
+    credentials_uri: Option<CredentialsUriCredentialProvider>,
+    ecs_ram_role: Option<EcsRamRoleCredentialProvider>,
     credentials_file: Option<CredentialsFileCredentialProvider>,
     config_file: Option<ConfigFileCredentialProvider>,
     oidc: Option<AssumeRoleWithOidcCredentialProvider>,
@@ -101,6 +106,8 @@ impl Default for DefaultCredentialProviderBuilder {
             assume_role: Some(AssumeRoleCredentialProvider::new()),
             env: Some(EnvCredentialProvider::new()),
             oss_profile: Some(OssProfileCredentialProvider::new()),
+            credentials_uri: Some(CredentialsUriCredentialProvider::new()),
+            ecs_ram_role: Some(EcsRamRoleCredentialProvider::new()),
             credentials_file: Some(CredentialsFileCredentialProvider::new()),
             config_file: Some(ConfigFileCredentialProvider::new()),
             oidc: Some(AssumeRoleWithOidcCredentialProvider::new()),
@@ -174,6 +181,29 @@ impl DefaultCredentialProviderBuilder {
         self
     }
 
+    /// Set the credentials URI credential provider slot.
+    pub fn credentials_uri(mut self, provider: CredentialsUriCredentialProvider) -> Self {
+        self.credentials_uri = Some(provider);
+        self
+    }
+
+    /// Remove the credentials URI credential provider slot.
+    pub fn no_credentials_uri(mut self) -> Self {
+        self.credentials_uri = None;
+        self
+    }
+
+    /// Set the ECS RAM role credential provider slot.
+    pub fn ecs_ram_role(mut self, provider: EcsRamRoleCredentialProvider) -> Self {
+        self.ecs_ram_role = Some(provider);
+        self
+    }
+
+    /// Remove the ECS RAM role credential provider slot.
+    pub fn no_ecs_ram_role(mut self) -> Self {
+        self.ecs_ram_role = None;
+        self
+    }
     /// Set the OIDC credential provider slot.
     pub fn oidc(mut self, provider: AssumeRoleWithOidcCredentialProvider) -> Self {
         self.oidc = Some(provider);
@@ -209,6 +239,12 @@ impl DefaultCredentialProviderBuilder {
         if let Some(p) = self.config_file {
             chain = chain.push(p);
         }
+        if let Some(p) = self.credentials_uri {
+            chain = chain.push(p);
+        }
+        if let Some(p) = self.ecs_ram_role {
+            chain = chain.push(p);
+        }
         if let Some(p) = self.oidc {
             chain = chain.push(p);
         }
@@ -250,9 +286,9 @@ mod tests {
     use reqsign_core::{FileRead, HttpSend, OsEnv, StaticEnv};
     use reqsign_file_read_tokio::TokioFileRead;
     use reqsign_http_send_reqwest::ReqwestHttpSend;
-    use std::collections::HashMap;
+    use std::collections::{HashMap, VecDeque};
     use std::sync::{
-        Arc,
+        Arc, Mutex,
         atomic::{AtomicUsize, Ordering},
     };
 
@@ -287,31 +323,47 @@ mod tests {
     #[derive(Clone, Debug)]
     struct CountingHttpSend {
         calls: Arc<AtomicUsize>,
-        body: Vec<u8>,
+        requests: Arc<Mutex<Vec<String>>>,
+        responses: Arc<Mutex<VecDeque<Vec<u8>>>>,
     }
 
     impl CountingHttpSend {
-        fn new(body: impl Into<Vec<u8>>) -> Self {
+        fn new(responses: impl IntoIterator<Item = Vec<u8>>) -> Self {
             Self {
                 calls: Arc::new(AtomicUsize::new(0)),
-                body: body.into(),
+                requests: Arc::new(Mutex::new(Vec::new())),
+                responses: Arc::new(Mutex::new(responses.into_iter().collect())),
             }
         }
 
         fn calls(&self) -> usize {
             self.calls.load(Ordering::SeqCst)
         }
+
+        fn requests(&self) -> Vec<String> {
+            self.requests.lock().expect("lock poisoned").clone()
+        }
     }
 
     impl HttpSend for CountingHttpSend {
         async fn http_send(
             &self,
-            _req: http::Request<Bytes>,
+            req: http::Request<Bytes>,
         ) -> reqsign_core::Result<http::Response<Bytes>> {
             self.calls.fetch_add(1, Ordering::SeqCst);
+            self.requests
+                .lock()
+                .expect("lock poisoned")
+                .push(req.uri().to_string());
+            let body = self
+                .responses
+                .lock()
+                .expect("lock poisoned")
+                .pop_front()
+                .unwrap_or_default();
             Ok(http::Response::builder()
                 .status(http::StatusCode::OK)
-                .body(Bytes::from(self.body.clone()))
+                .body(Bytes::from(body))
                 .expect("response must build"))
         }
     }
@@ -326,7 +378,10 @@ mod tests {
             .with_env(OsEnv);
         let ctx = ctx.with_env(StaticEnv {
             home_dir: None,
-            envs: HashMap::new(),
+            envs: HashMap::from([(
+                ALIBABA_CLOUD_ECS_METADATA_DISABLED.to_string(),
+                "true".to_string(),
+            )]),
         });
 
         let loader = DefaultCredentialProvider::new();
@@ -479,8 +534,8 @@ access_key_secret = profile_secret_key
             ("/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(),
+            [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.clone())
@@ -569,6 +624,8 @@ access_key_secret=shared_secret_key
             .no_assume_role()
             .no_env()
             .no_oss_profile()
+            .no_credentials_uri()
+            .no_ecs_ram_role()
             .no_oidc()
             .build()
             .provide_credential(&ctx)
@@ -612,6 +669,8 @@ access_key_secret=shared_secret_key
             .no_env()
             .no_oss_profile()
             .no_credentials_file()
+            .no_credentials_uri()
+            .no_ecs_ram_role()
             .no_oidc()
             .build()
             .provide_credential(&ctx)
@@ -655,6 +714,8 @@ access_key_secret=shared_secret_key
             .no_env()
             .no_oss_profile()
             .no_credentials_file()
+            .no_credentials_uri()
+            .no_ecs_ram_role()
             .no_oidc()
             .build()
             .provide_credential(&ctx)
@@ -669,6 +730,8 @@ access_key_secret=shared_secret_key
             .no_oss_profile()
             .no_credentials_file()
             .no_config_file()
+            .no_credentials_uri()
+            .no_ecs_ram_role()
             .no_oidc()
             .build()
             .provide_credential(&ctx)
@@ -696,10 +759,7 @@ access_key_secret=shared_secret_key
             ),
             ("/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 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())
@@ -741,6 +801,231 @@ access_key_secret=shared_secret_key
         assert_eq!(0, http_send.calls());
     }
 
+    #[tokio::test]
+    async fn test_default_loader_prefers_credentials_uri_over_ecs_and_oidc() {
+        let file_read = CountingFileRead::new(HashMap::from([(
+            "/mock/token".to_string(),
+            b"token".to_vec(),
+        )]));
+        let http_send = CountingHttpSend::new([br#"{"Code":"Success","AccessKeyId":"uri_access_key","AccessKeySecret":"uri_secret_key","SecurityToken":"uri_token","Expiration":"2124-05-25T11:45:17Z"}"#.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_CREDENTIALS_URI.to_string(),
+                        "http://127.0.0.1/credentials".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_ECS_METADATA.to_string(),
+                        "ecs-role".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT.to_string(),
+                        "http://127.0.0.1".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()
+            .no_config_file()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!("uri_access_key", credential.access_key_id);
+        assert_eq!("uri_secret_key", credential.access_key_secret);
+        assert_eq!(1, http_send.calls());
+        assert_eq!(
+            vec!["http://127.0.0.1/credentials".to_string()],
+            http_send.requests()
+        );
+    }
+
+    #[tokio::test]
+    async fn test_default_loader_prefers_ecs_over_oidc() {
+        let file_read = CountingFileRead::new(HashMap::from([(
+            "/mock/token".to_string(),
+            b"token".to_vec(),
+        )]));
+        let http_send = CountingHttpSend::new([
+            b"metadata-token".to_vec(),
+            br#"{"Code":"Success","AccessKeyId":"ecs_access_key","AccessKeySecret":"ecs_secret_key","SecurityToken":"ecs_token","Expiration":"2124-05-25T11:45:17Z"}"#.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_ECS_METADATA.to_string(),
+                        "ecs-role".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT.to_string(),
+                        "http://127.0.0.1".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()
+            .no_config_file()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!("ecs_access_key", credential.access_key_id);
+        assert_eq!("ecs_secret_key", credential.access_key_secret);
+        assert_eq!(2, http_send.calls());
+        assert_eq!(
+            vec![
+                "http://127.0.0.1/latest/api/token".to_string(),
+                "http://127.0.0.1/latest/meta-data/ram/security-credentials/ecs-role".to_string(),
+            ],
+            http_send.requests()
+        );
+    }
+
+    #[tokio::test]
+    async fn test_builder_no_credentials_uri_removes_credentials_uri_provider() {
+        let http_send = CountingHttpSend::new([br#"{"Code":"Success","AccessKeyId":"uri_access_key","AccessKeySecret":"uri_secret_key","SecurityToken":"uri_token","Expiration":"2124-05-25T11:45:17Z"}"#.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([
+                    (
+                        ALIBABA_CLOUD_CREDENTIALS_URI.to_string(),
+                        "http://127.0.0.1/credentials".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_ECS_METADATA_DISABLED.to_string(),
+                        "true".to_string(),
+                    ),
+                ]),
+            });
+
+        let credential = DefaultCredentialProvider::builder()
+            .no_env()
+            .no_oss_profile()
+            .no_credentials_file()
+            .no_config_file()
+            .no_ecs_ram_role()
+            .no_oidc()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!("uri_access_key", credential.access_key_id);
+        assert_eq!(1, http_send.calls());
+
+        let credential = DefaultCredentialProvider::builder()
+            .no_env()
+            .no_oss_profile()
+            .no_credentials_file()
+            .no_config_file()
+            .no_credentials_uri()
+            .no_ecs_ram_role()
+            .no_oidc()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap();
+        assert!(credential.is_none());
+        assert_eq!(1, http_send.calls());
+    }
+
+    #[tokio::test]
+    async fn test_builder_no_ecs_ram_role_removes_ecs_provider() {
+        let http_send = CountingHttpSend::new([
+            b"metadata-token".to_vec(),
+            br#"{"Code":"Success","AccessKeyId":"ecs_access_key","AccessKeySecret":"ecs_secret_key","SecurityToken":"ecs_token","Expiration":"2124-05-25T11:45:17Z"}"#.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([
+                    (
+                        ALIBABA_CLOUD_ECS_METADATA.to_string(),
+                        "ecs-role".to_string(),
+                    ),
+                    (
+                        ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT.to_string(),
+                        "http://127.0.0.1".to_string(),
+                    ),
+                ]),
+            });
+
+        let credential = DefaultCredentialProvider::builder()
+            .no_env()
+            .no_oss_profile()
+            .no_credentials_file()
+            .no_config_file()
+            .no_credentials_uri()
+            .no_oidc()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!("ecs_access_key", credential.access_key_id);
+        assert_eq!(2, http_send.calls());
+
+        let credential = DefaultCredentialProvider::builder()
+            .no_env()
+            .no_oss_profile()
+            .no_credentials_file()
+            .no_config_file()
+            .no_credentials_uri()
+            .no_ecs_ram_role()
+            .no_oidc()
+            .build()
+            .provide_credential(&ctx)
+            .await
+            .unwrap();
+        assert!(credential.is_none());
+        assert_eq!(2, http_send.calls());
+    }
+
     #[tokio::test]
     async fn test_builder_no_oidc_removes_oidc_provider() {
         let file_read = CountingFileRead::new(HashMap::from([(
@@ -748,8 +1033,8 @@ access_key_secret=shared_secret_key
             b"token".to_vec(),
         )]));
         let http_send = CountingHttpSend::new(
-            br#"{"Credentials":{"SecurityToken":"security_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"secret_access_key","AccessKeyId":"access_key_id"}}"#
-                .to_vec(),
+            [br#"{"Credentials":{"SecurityToken":"security_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"secret_access_key","AccessKeyId":"access_key_id"}}"#
+                .to_vec()],
         );
         let ctx = Context::new()
             .with_file_read(file_read.clone())
@@ -776,6 +1061,8 @@ access_key_secret=shared_secret_key
             .no_assume_role()
             .no_env()
             .no_oss_profile()
+            .no_credentials_uri()
+            .no_ecs_ram_role()
             .oidc(AssumeRoleWithOidcCredentialProvider::new())
             .build()
             .provide_credential(&ctx)
@@ -789,6 +1076,8 @@ access_key_secret=shared_secret_key
             .no_assume_role()
             .no_env()
             .no_oss_profile()
+            .no_credentials_uri()
+            .no_ecs_ram_role()
             .no_oidc()
             .build()
             .provide_credential(&ctx)
@@ -799,10 +1088,10 @@ access_key_secret=shared_secret_key
 
     #[tokio::test]
     async fn test_default_loader_prefers_assume_role_over_raw_env_credentials() {
-        let http_send = CountingHttpSend::new(
+        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())
@@ -840,10 +1129,10 @@ access_key_secret=shared_secret_key
 
     #[tokio::test]
     async fn test_builder_no_env_removes_env_from_assume_role_base_chain() {
-        let http_send = CountingHttpSend::new(
+        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())
@@ -870,6 +1159,7 @@ access_key_secret=shared_secret_key
             .no_oss_profile()
             .no_credentials_file()
             .no_config_file()
+            .no_ecs_ram_role()
             .no_oidc()
             .build()
             .provide_credential(&ctx)
@@ -882,10 +1172,10 @@ access_key_secret=shared_secret_key
 
     #[tokio::test]
     async fn test_builder_no_assume_role_removes_assume_role_provider() {
-        let http_send = CountingHttpSend::new(
+        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())
diff --git a/services/aliyun-oss/src/provide_credential/ecs_ram_role.rs b/services/aliyun-oss/src/provide_credential/ecs_ram_role.rs
new file mode 100644
index 0000000..53da0af
--- /dev/null
+++ b/services/aliyun-oss/src/provide_credential/ecs_ram_role.rs
@@ -0,0 +1,618 @@
+// 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;
+use crate::constants::{
+    ALIBABA_CLOUD_ECS_METADATA, ALIBABA_CLOUD_ECS_METADATA_DISABLED,
+    ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT, ALIBABA_CLOUD_IMDSV1_DISABLED,
+};
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Error, ProvideCredential, Result};
+use serde::Deserialize;
+
+const DEFAULT_ECS_METADATA_ENDPOINT: &str = "http://100.100.100.200";
+const ECS_METADATA_TOKEN_TTL_SECONDS: u64 = 21600;
+
+/// EcsRamRoleCredentialProvider loads temporary credentials from the ECS metadata service.
+///
+/// Configuration values can be provided directly via builder methods or through
+/// the following environment variables:
+/// - `ALIBABA_CLOUD_ECS_METADATA`: Optional ECS RAM role name
+/// - `ALIBABA_CLOUD_ECS_METADATA_DISABLED`: Disable the provider when set to `true`
+/// - `ALIBABA_CLOUD_IMDSV1_DISABLED`: Require IMDSv2 token requests when set to `true`
+/// - `ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT`: Override the metadata endpoint
+#[derive(Debug, Default, Clone)]
+pub struct EcsRamRoleCredentialProvider {
+    endpoint: Option<String>,
+    role_name: Option<String>,
+    disable_imdsv1: Option<bool>,
+}
+
+impl EcsRamRoleCredentialProvider {
+    /// Create a new `EcsRamRoleCredentialProvider` instance.
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Set the metadata service endpoint directly.
+    ///
+    /// This setting takes precedence over `ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT`.
+    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
+        self.endpoint = Some(endpoint.into());
+        self
+    }
+
+    /// Set the ECS RAM role name directly.
+    ///
+    /// This setting takes precedence over `ALIBABA_CLOUD_ECS_METADATA`.
+    pub fn with_role_name(mut self, role_name: impl Into<String>) -> Self {
+        self.role_name = Some(role_name.into());
+        self
+    }
+
+    /// Configure whether IMDSv1 fallback is disabled.
+    ///
+    /// This setting takes precedence over `ALIBABA_CLOUD_IMDSV1_DISABLED`.
+    pub fn with_disable_imdsv1(mut self, disable: bool) -> Self {
+        self.disable_imdsv1 = Some(disable);
+        self
+    }
+
+    fn is_disabled(&self, ctx: &Context) -> bool {
+        parse_bool_env(ctx.env_var(ALIBABA_CLOUD_ECS_METADATA_DISABLED))
+    }
+
+    fn disable_imdsv1(&self, ctx: &Context) -> bool {
+        self.disable_imdsv1
+            .unwrap_or_else(|| parse_bool_env(ctx.env_var(ALIBABA_CLOUD_IMDSV1_DISABLED)))
+    }
+
+    fn get_endpoint(&self, ctx: &Context) -> String {
+        self.endpoint
+            .clone()
+            .or_else(|| ctx.env_var(ALIBABA_CLOUD_ECS_METADATA_SERVICE_ENDPOINT))
+            .filter(|endpoint| !endpoint.is_empty())
+            .unwrap_or_else(|| DEFAULT_ECS_METADATA_ENDPOINT.to_string())
+    }
+
+    fn get_role_name(&self, ctx: &Context) -> Option<String> {
+        self.role_name
+            .clone()
+            .or_else(|| ctx.env_var(ALIBABA_CLOUD_ECS_METADATA))
+            .filter(|role_name| !role_name.is_empty())
+    }
+
+    async fn get_metadata_token(&self, ctx: &Context) -> Result<Option<String>> {
+        let endpoint = self.get_endpoint(ctx);
+        let url = format!("{endpoint}/latest/api/token");
+        let req = http::Request::builder()
+            .method(http::Method::PUT)
+            .uri(&url)
+            .header(
+                "X-aliyun-ecs-metadata-token-ttl-seconds",
+                ECS_METADATA_TOKEN_TTL_SECONDS.to_string(),
+            )
+            .body(Vec::new())
+            .map_err(|e| {
+                Error::request_invalid("failed to build ECS metadata token request")
+                    .with_source(e)
+                    .with_context(format!("url: {url}"))
+            })?;
+
+        let resp = ctx.http_send_as_string(req.map(Into::into)).await;
+        match resp {
+            Ok(resp) if resp.status() == http::StatusCode::OK => {
+                Ok(Some(resp.into_body().trim().to_string()))
+            }
+            Ok(resp) if self.disable_imdsv1(ctx) => Err(Error::unexpected(format!(
+                "failed to fetch ECS metadata token: {}",
+                resp.into_body()
+            ))
+            .with_context(format!("url: {url}"))),
+            Ok(_) => Ok(None),
+            Err(err) if self.disable_imdsv1(ctx) => {
+                Err(Error::unexpected("failed to fetch ECS metadata token")
+                    .with_source(err)
+                    .with_context(format!("url: {url}")))
+            }
+            Err(_) => Ok(None),
+        }
+    }
+
+    async fn load_role_name(&self, ctx: &Context, metadata_token: Option<&str>) -> Result<String> {
+        let endpoint = self.get_endpoint(ctx);
+        let url = format!("{endpoint}/latest/meta-data/ram/security-credentials/");
+        let mut req = http::Request::builder()
+            .method(http::Method::GET)
+            .uri(&url)
+            .body(Vec::new())
+            .map_err(|e| {
+                Error::request_invalid("failed to build ECS role name request")
+                    .with_source(e)
+                    .with_context(format!("url: {url}"))
+            })?;
+
+        if let Some(metadata_token) = metadata_token {
+            req.headers_mut().insert(
+                "X-aliyun-ecs-metadata-token",
+                metadata_token.parse().map_err(|e| {
+                    Error::config_invalid("invalid ECS metadata token")
+                        .with_source(e)
+                        .with_context(format!("url: {url}"))
+                })?,
+            );
+        }
+
+        let resp = ctx
+            .http_send_as_string(req.map(Into::into))
+            .await
+            .map_err(|e| {
+                Error::unexpected("failed to fetch ECS role name")
+                    .with_source(e)
+                    .with_context(format!("url: {url}"))
+                    .set_retryable(true)
+            })?;
+
+        if resp.status() != http::StatusCode::OK {
+            return Err(Error::unexpected(format!(
+                "failed to fetch ECS role name: {}",
+                resp.into_body()
+            ))
+            .with_context(format!("url: {url}")));
+        }
+
+        let role_name = resp
+            .into_body()
+            .lines()
+            .next()
+            .unwrap_or_default()
+            .trim()
+            .to_string();
+
+        if role_name.is_empty() {
+            return Err(
+                Error::config_invalid("ECS metadata did not return a RAM role name")
+                    .with_context(format!("url: {url}")),
+            );
+        }
+
+        Ok(role_name)
+    }
+
+    async fn load_credentials(
+        &self,
+        ctx: &Context,
+        metadata_token: Option<&str>,
+        role_name: &str,
+    ) -> Result<Credential> {
+        let endpoint = self.get_endpoint(ctx);
+        let url = format!("{endpoint}/latest/meta-data/ram/security-credentials/{role_name}");
+        let mut req = http::Request::builder()
+            .method(http::Method::GET)
+            .uri(&url)
+            .body(Vec::new())
+            .map_err(|e| {
+                Error::request_invalid("failed to build ECS credential request")
+                    .with_source(e)
+                    .with_context(format!("url: {url}"))
+            })?;
+
+        if let Some(metadata_token) = metadata_token {
+            req.headers_mut().insert(
+                "X-aliyun-ecs-metadata-token",
+                metadata_token.parse().map_err(|e| {
+                    Error::config_invalid("invalid ECS metadata token")
+                        .with_source(e)
+                        .with_context(format!("url: {url}"))
+                })?,
+            );
+        }
+
+        let resp = ctx
+            .http_send_as_string(req.map(Into::into))
+            .await
+            .map_err(|e| {
+                Error::unexpected("failed to fetch ECS RAM role credentials")
+                    .with_source(e)
+                    .with_context(format!("url: {url}"))
+                    .set_retryable(true)
+            })?;
+
+        let status = resp.status();
+        let body = resp.into_body();
+        if status != http::StatusCode::OK {
+            let error = match status.as_u16() {
+                401 | 403 => Error::permission_denied(format!(
+                    "ECS metadata denied credential access: {body}"
+                )),
+                404 => Error::config_invalid("ECS RAM role credentials not found")
+                    .with_context(format!("role_name: {role_name}")),
+                500..=599 => Error::unexpected(format!("ECS metadata returned {status}: {body}"))
+                    .set_retryable(true),
+                _ => Error::unexpected(format!("ECS metadata returned {status}: {body}")),
+            };
+            return Err(error.with_context(format!("url: {url}")));
+        }
+
+        let resp: EcsRamRoleResponse = serde_json::from_str(&body).map_err(|e| {
+            Error::unexpected("failed to parse ECS RAM role credentials")
+                .with_source(e)
+                .with_context(format!("url: {url}"))
+        })?;
+
+        if !resp.code.is_empty() && resp.code != "Success" {
+            return Err(Error::unexpected(format!(
+                "ECS metadata returned error code {}",
+                resp.code
+            ))
+            .with_context(format!("role_name: {role_name}")));
+        }
+
+        if resp.access_key_id.is_empty()
+            || resp.access_key_secret.is_empty()
+            || resp.security_token.is_empty()
+            || resp.expiration.is_empty()
+        {
+            return Err(Error::credential_invalid(
+                "ECS metadata response is missing required fields",
+            )
+            .with_context(format!("role_name: {role_name}")));
+        }
+
+        let expires_in: Timestamp = resp.expiration.parse().map_err(|e| {
+            Error::credential_invalid("failed to parse ECS credential expiration")
+                .with_source(e)
+                .with_context(format!("role_name: {role_name}"))
+        })?;
+
+        Ok(Credential {
+            access_key_id: resp.access_key_id,
+            access_key_secret: resp.access_key_secret,
+            security_token: Some(resp.security_token),
+            expires_in: Some(expires_in),
+        })
+    }
+}
+
+impl ProvideCredential for EcsRamRoleCredentialProvider {
+    type Credential = Credential;
+
+    async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
+        if self.is_disabled(ctx) {
+            return Ok(None);
+        }
+
+        let metadata_token = self.get_metadata_token(ctx).await?;
+        let role_name = match self.get_role_name(ctx) {
+            Some(role_name) => role_name,
+            None => self.load_role_name(ctx, metadata_token.as_deref()).await?,
+        };
+
+        let cred = self
+            .load_credentials(ctx, metadata_token.as_deref(), &role_name)
+            .await?;
+        Ok(Some(cred))
+    }
+}
+
+#[derive(Default, Debug, Deserialize)]
+#[serde(default, rename_all = "PascalCase")]
+struct EcsRamRoleResponse {
+    code: String,
+    access_key_id: String,
+    access_key_secret: String,
+    security_token: String,
+    expiration: String,
+}
+
+fn parse_bool_env(value: Option<String>) -> bool {
+    value
+        .map(|value| value.eq_ignore_ascii_case("true"))
+        .unwrap_or(false)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use bytes::Bytes;
+    use reqsign_core::time::Timestamp;
+    use reqsign_core::{Context, HttpSend, StaticEnv};
+    use reqsign_file_read_tokio::TokioFileRead;
+    use std::collections::{HashMap, VecDeque};
+    use std::sync::{Arc, Mutex};
+    use std::time::Duration;
+
+    #[derive(Debug, Clone)]
+    struct MockResponse {
+        status: http::StatusCode,
+        body: String,
+    }
+
+    #[derive(Debug, Clone)]
+    struct CapturedRequest {
+        method: http::Method,
+        uri: String,
+        metadata_token: Option<String>,
+        metadata_token_ttl: Option<String>,
+    }
+
+    #[derive(Debug, Clone)]
+    struct SequenceHttpSend {
+        requests: Arc<Mutex<Vec<CapturedRequest>>>,
+        responses: Arc<Mutex<VecDeque<MockResponse>>>,
+    }
+
+    impl SequenceHttpSend {
+        fn new(responses: Vec<MockResponse>) -> Self {
+            Self {
+                requests: Arc::new(Mutex::new(Vec::new())),
+                responses: Arc::new(Mutex::new(responses.into())),
+            }
+        }
+
+        fn requests(&self) -> Vec<CapturedRequest> {
+            self.requests.lock().expect("lock poisoned").clone()
+        }
+
+        fn call_count(&self) -> usize {
+            self.requests.lock().expect("lock poisoned").len()
+        }
+    }
+
+    impl HttpSend for SequenceHttpSend {
+        async fn http_send(
+            &self,
+            req: http::Request<Bytes>,
+        ) -> reqsign_core::Result<http::Response<Bytes>> {
+            let metadata_token = req
+                .headers()
+                .get("X-aliyun-ecs-metadata-token")
+                .and_then(|value| value.to_str().ok())
+                .map(ToString::to_string);
+            let metadata_token_ttl = req
+                .headers()
+                .get("X-aliyun-ecs-metadata-token-ttl-seconds")
+                .and_then(|value| value.to_str().ok())
+                .map(ToString::to_string);
+
+            self.requests
+                .lock()
+                .expect("lock poisoned")
+                .push(CapturedRequest {
+                    method: req.method().clone(),
+                    uri: req.uri().to_string(),
+                    metadata_token,
+                    metadata_token_ttl,
+                });
+
+            let resp = self
+                .responses
+                .lock()
+                .expect("lock poisoned")
+                .pop_front()
+                .expect("mock response must exist");
+
+            Ok(http::Response::builder()
+                .status(resp.status)
+                .body(Bytes::from(resp.body))
+                .expect("response must build"))
+        }
+    }
+
+    fn ecs_credential_response(expiration: String, access_key_id: &str) -> String {
+        format!(
+            r#"{{"Code":"Success","AccessKeyId":"{access_key_id}","AccessKeySecret":"secret","SecurityToken":"token","Expiration":"{expiration}"}}"#
+        )
+    }
+
+    #[tokio::test]
+    async fn test_ecs_ram_role_provider_disabled_by_env() -> anyhow::Result<()> {
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(SequenceHttpSend::new(Vec::new()))
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([(
+                    ALIBABA_CLOUD_ECS_METADATA_DISABLED.to_string(),
+                    "true".to_string(),
+                )]),
+            });
+
+        let cred = EcsRamRoleCredentialProvider::new()
+            .provide_credential(&ctx)
+            .await?;
+        assert!(cred.is_none());
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_ecs_ram_role_provider_uses_imdsv2_token() -> anyhow::Result<()> {
+        let http_send = SequenceHttpSend::new(vec![
+            MockResponse {
+                status: http::StatusCode::OK,
+                body: "metadata-token".to_string(),
+            },
+            MockResponse {
+                status: http::StatusCode::OK,
+                body: ecs_credential_response("2124-05-25T11:45:17Z".to_string(), "ecs_access_key"),
+            },
+        ]);
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([(
+                    ALIBABA_CLOUD_ECS_METADATA.to_string(),
+                    "test-role".to_string(),
+                )]),
+            });
+
+        let cred = EcsRamRoleCredentialProvider::new()
+            .provide_credential(&ctx)
+            .await?
+            .expect("credential must be loaded");
+
+        assert_eq!("ecs_access_key", cred.access_key_id);
+        let requests = http_send.requests();
+        assert_eq!(2, requests.len());
+        assert_eq!(http::Method::PUT, requests[0].method);
+        assert_eq!(
+            Some(ECS_METADATA_TOKEN_TTL_SECONDS.to_string()),
+            requests[0].metadata_token_ttl
+        );
+        assert_eq!(
+            "http://100.100.100.200/latest/meta-data/ram/security-credentials/test-role",
+            requests[1].uri
+        );
+        assert_eq!(
+            Some("metadata-token".to_string()),
+            requests[1].metadata_token
+        );
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_ecs_ram_role_provider_falls_back_to_imdsv1() -> anyhow::Result<()> {
+        let http_send = SequenceHttpSend::new(vec![
+            MockResponse {
+                status: http::StatusCode::INTERNAL_SERVER_ERROR,
+                body: "token failed".to_string(),
+            },
+            MockResponse {
+                status: http::StatusCode::OK,
+                body: "test-role".to_string(),
+            },
+            MockResponse {
+                status: http::StatusCode::OK,
+                body: ecs_credential_response("2124-05-25T11:45:17Z".to_string(), "ecs_access_key"),
+            },
+        ]);
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::new(),
+            });
+
+        let cred = EcsRamRoleCredentialProvider::new()
+            .with_endpoint("http://127.0.0.1")
+            .provide_credential(&ctx)
+            .await?
+            .expect("credential must be loaded");
+
+        assert_eq!("ecs_access_key", cred.access_key_id);
+        let requests = http_send.requests();
+        assert_eq!(3, requests.len());
+        assert_eq!(None, requests[1].metadata_token);
+        assert_eq!(None, requests[2].metadata_token);
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_ecs_ram_role_provider_respects_imdsv1_disable() {
+        let http_send = SequenceHttpSend::new(vec![MockResponse {
+            status: http::StatusCode::INTERNAL_SERVER_ERROR,
+            body: "token failed".to_string(),
+        }]);
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(http_send)
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([(
+                    ALIBABA_CLOUD_IMDSV1_DISABLED.to_string(),
+                    "true".to_string(),
+                )]),
+            });
+
+        let err = EcsRamRoleCredentialProvider::new()
+            .with_endpoint("http://127.0.0.1")
+            .provide_credential(&ctx)
+            .await
+            .expect_err("provider must fail when IMDSv1 is disabled");
+
+        assert!(
+            err.to_string()
+                .contains("failed to fetch ECS metadata token")
+        );
+    }
+
+    #[tokio::test]
+    async fn test_ecs_ram_role_provider_refreshes_via_signer_cache() -> anyhow::Result<()> {
+        let http_send = SequenceHttpSend::new(vec![
+            MockResponse {
+                status: http::StatusCode::OK,
+                body: "metadata-token".to_string(),
+            },
+            MockResponse {
+                status: http::StatusCode::OK,
+                body: ecs_credential_response(
+                    (Timestamp::now() + Duration::from_secs(60)).format_rfc3339_zulu(),
+                    "first_access_key",
+                ),
+            },
+            MockResponse {
+                status: http::StatusCode::OK,
+                body: "metadata-token-2".to_string(),
+            },
+            MockResponse {
+                status: http::StatusCode::OK,
+                body: ecs_credential_response(
+                    (Timestamp::now() + Duration::from_secs(600)).format_rfc3339_zulu(),
+                    "second_access_key",
+                ),
+            },
+        ]);
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_http_send(http_send.clone())
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::new(),
+            });
+        let signer = reqsign_core::Signer::new(
+            ctx,
+            EcsRamRoleCredentialProvider::new()
+                .with_endpoint("http://127.0.0.1")
+                .with_role_name("test-role"),
+            crate::RequestSigner::new("bucket"),
+        );
+
+        let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object")
+            .body(())
+            .unwrap()
+            .into_parts()
+            .0;
+        signer.sign(&mut req, None).await?;
+
+        let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object")
+            .body(())
+            .unwrap()
+            .into_parts()
+            .0;
+        signer.sign(&mut req, None).await?;
+
+        assert_eq!(4, http_send.call_count());
+
+        Ok(())
+    }
+}
diff --git a/services/aliyun-oss/src/provide_credential/mod.rs b/services/aliyun-oss/src/provide_credential/mod.rs
index 48b5dff..867cc74 100644
--- a/services/aliyun-oss/src/provide_credential/mod.rs
+++ b/services/aliyun-oss/src/provide_credential/mod.rs
@@ -27,9 +27,15 @@ pub use config_file::ConfigFileCredentialProvider;
 mod credentials_file;
 pub use credentials_file::CredentialsFileCredentialProvider;
 
+mod credentials_uri;
+pub use credentials_uri::CredentialsUriCredentialProvider;
+
 mod default;
 pub use default::{DefaultCredentialProvider, DefaultCredentialProviderBuilder};
 
+mod ecs_ram_role;
+pub use ecs_ram_role::EcsRamRoleCredentialProvider;
+
 mod env;
 pub use env::EnvCredentialProvider;