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 3b749af  feat(google): support aws external account sources (#728)
3b749af is described below

commit 3b749afa8ed2ee249e126c41fb6432f48dbcbb3c
Author: Xuanwo <[email protected]>
AuthorDate: Thu Mar 19 05:27:34 2026 +0800

    feat(google): support aws external account sources (#728)
    
    Google external account credentials also support the AWS-specific
    `credential_source` shape from AIP-4117. This teaches `reqsign-google`
    to resolve `aws1` region and credentials from environment variables or
    IMDS, mint the signed `GetCallerIdentity` subject token, and exchange it
    through the existing STS flow.
    
    This follows the executable `external_account` support in #727 and keeps
    the change fully inside `services/google`.
---
 services/google/Cargo.toml                         |   1 +
 services/google/src/credential.rs                  |  61 ++
 .../src/provide_credential/external_account.rs     | 640 ++++++++++++++++++++-
 3 files changed, 700 insertions(+), 2 deletions(-)

diff --git a/services/google/Cargo.toml b/services/google/Cargo.toml
index 918abcd..88f8054 100644
--- a/services/google/Cargo.toml
+++ b/services/google/Cargo.toml
@@ -32,6 +32,7 @@ http = { workspace = true }
 jsonwebtoken = { workspace = true }
 log = { workspace = true }
 percent-encoding = { workspace = true }
+reqsign-aws-v4 = { workspace = true }
 reqsign-core = { workspace = true }
 rsa = { workspace = true }
 serde = { workspace = true }
diff --git a/services/google/src/credential.rs 
b/services/google/src/credential.rs
index d67c8e3..9a895b6 100644
--- a/services/google/src/credential.rs
+++ b/services/google/src/credential.rs
@@ -100,6 +100,9 @@ pub mod external_account {
     #[derive(Clone, Deserialize, Debug)]
     #[serde(untagged)]
     pub enum Source {
+        /// AWS provider-specific credential source.
+        #[serde(rename_all = "snake_case")]
+        Aws(AwsSource),
         /// URL-based credential source.
         #[serde(rename_all = "snake_case")]
         Url(UrlSource),
@@ -133,6 +136,22 @@ pub mod external_account {
         pub format: Format,
     }
 
+    /// Configuration for AWS provider-specific workload identity federation.
+    #[derive(Clone, Deserialize, Debug)]
+    #[serde(rename_all = "snake_case")]
+    pub struct AwsSource {
+        /// The environment identifier, currently `aws1`.
+        pub environment_id: String,
+        /// Metadata URL used to derive the region when env vars are absent.
+        pub region_url: Option<String>,
+        /// Metadata URL used to retrieve the role name and credentials.
+        pub url: Option<String>,
+        /// Regional GetCallerIdentity verification URL template.
+        pub regional_cred_verification_url: String,
+        /// Optional IMDSv2 token URL.
+        pub imdsv2_session_token_url: Option<String>,
+    }
+
     /// Configuration for executing a command to load credentials.
     #[derive(Clone, Deserialize, Debug)]
     #[serde(rename_all = "snake_case")]
@@ -407,6 +426,48 @@ mod tests {
         let cred = CredentialFile::from_slice(ea_json.as_bytes()).unwrap();
         assert!(matches!(cred, CredentialFile::ExternalAccount(_)));
 
+        let aws_ea_json = r#"{
+            "type": "external_account",
+            "audience": "test_audience",
+            "subject_token_type": 
"urn:ietf:params:aws:token-type:aws4_request",
+            "token_url": "https://example.com/token";,
+            "credential_source": {
+                "environment_id": "aws1",
+                "region_url": 
"http://169.254.169.254/latest/meta-data/placement/availability-zone";,
+                "url": 
"http://169.254.169.254/latest/meta-data/iam/security-credentials";,
+                "regional_cred_verification_url": 
"https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15";,
+                "imdsv2_session_token_url": 
"http://169.254.169.254/latest/api/token";
+            }
+        }"#;
+        let cred = CredentialFile::from_slice(aws_ea_json.as_bytes()).unwrap();
+        match cred {
+            CredentialFile::ExternalAccount(external_account) => match 
external_account
+                .credential_source
+            {
+                external_account::Source::Aws(source) => {
+                    assert_eq!(source.environment_id, "aws1");
+                    assert_eq!(
+                        source.region_url.as_deref(),
+                        
Some("http://169.254.169.254/latest/meta-data/placement/availability-zone";)
+                    );
+                    assert_eq!(
+                        source.url.as_deref(),
+                        
Some("http://169.254.169.254/latest/meta-data/iam/security-credentials";)
+                    );
+                    assert_eq!(
+                        source.regional_cred_verification_url,
+                        
"https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15";
+                    );
+                    assert_eq!(
+                        source.imdsv2_session_token_url.as_deref(),
+                        Some("http://169.254.169.254/latest/api/token";)
+                    );
+                }
+                _ => panic!("Expected Aws source"),
+            },
+            _ => panic!("Expected ExternalAccount"),
+        }
+
         let exec_ea_json = r#"{
             "type": "external_account",
             "audience": "test_audience",
diff --git a/services/google/src/provide_credential/external_account.rs 
b/services/google/src/provide_credential/external_account.rs
index bac0eaa..9c050c7 100644
--- a/services/google/src/provide_credential/external_account.rs
+++ b/services/google/src/provide_credential/external_account.rs
@@ -19,13 +19,18 @@ use std::collections::BTreeMap;
 use std::time::Duration;
 
 use form_urlencoded::Serializer;
-use http::header::{ACCEPT, CONTENT_TYPE};
+use http::Method;
+use http::header::{ACCEPT, CONTENT_LENGTH, CONTENT_TYPE};
 use log::{debug, error};
+use reqsign_aws_v4::{
+    Credential as AwsCredential, EnvCredentialProvider as 
AwsEnvCredentialProvider,
+    RequestSigner as AwsRequestSigner,
+};
 use serde::{Deserialize, Serialize};
 
 use crate::credential::{Credential, ExternalAccount, Token, external_account};
 use reqsign_core::time::Timestamp;
-use reqsign_core::{Context, ProvideCredential, Result};
+use reqsign_core::{Context, ProvideCredential, Result, SignRequest};
 
 /// The maximum impersonated token lifetime allowed, 1 hour.
 const MAX_LIFETIME: Duration = Duration::from_secs(3600);
@@ -42,6 +47,19 @@ const EXECUTABLE_RESPONSE_VERSION: u64 = 1;
 const TOKEN_TYPE_JWT: &str = "urn:ietf:params:oauth:token-type:jwt";
 const TOKEN_TYPE_ID_TOKEN: &str = "urn:ietf:params:oauth:token-type:id_token";
 const TOKEN_TYPE_SAML2: &str = "urn:ietf:params:oauth:token-type:saml2";
+const TOKEN_TYPE_AWS4_REQUEST: &str = 
"urn:ietf:params:aws:token-type:aws4_request";
+const AWS_REGION: &str = "AWS_REGION";
+const AWS_DEFAULT_REGION: &str = "AWS_DEFAULT_REGION";
+#[cfg(test)]
+const AWS_ACCESS_KEY_ID: &str = "AWS_ACCESS_KEY_ID";
+#[cfg(test)]
+const AWS_SECRET_ACCESS_KEY: &str = "AWS_SECRET_ACCESS_KEY";
+#[cfg(test)]
+const AWS_SESSION_TOKEN: &str = "AWS_SESSION_TOKEN";
+const AWS_EC2_METADATA_DISABLED: &str = "AWS_EC2_METADATA_DISABLED";
+const AWS_IMDSV2_TOKEN_HEADER: &str = "x-aws-ec2-metadata-token";
+const AWS_IMDSV2_TTL_HEADER: &str = "x-aws-ec2-metadata-token-ttl-seconds";
+const AWS_IMDSV2_TTL_SECONDS: &str = "300";
 
 /// STS token response.
 #[derive(Deserialize)]
@@ -88,6 +106,37 @@ struct ExecutableSubjectToken {
     expires_at: Option<Timestamp>,
 }
 
+#[derive(Deserialize)]
+#[serde(rename_all = "PascalCase")]
+struct AwsMetadataCredentialResponse {
+    #[serde(default)]
+    access_key_id: String,
+    #[serde(default)]
+    secret_access_key: String,
+    #[serde(default)]
+    token: Option<String>,
+    #[serde(default)]
+    expiration: Option<String>,
+    #[serde(default)]
+    code: Option<String>,
+    #[serde(default)]
+    message: Option<String>,
+}
+
+#[derive(Serialize)]
+struct AwsSignedRequest {
+    url: String,
+    method: String,
+    headers: Vec<AwsSignedHeader>,
+    body: String,
+}
+
+#[derive(Serialize)]
+struct AwsSignedHeader {
+    key: String,
+    value: String,
+}
+
 /// ExternalAccountCredentialProvider exchanges external account credentials 
for access tokens.
 #[derive(Debug, Clone)]
 pub struct ExternalAccountCredentialProvider {
@@ -119,6 +168,7 @@ impl ExternalAccountCredentialProvider {
 
     async fn load_oidc_token(&self, ctx: &Context) -> Result<String> {
         match &self.external_account.credential_source {
+            external_account::Source::Aws(source) => 
self.load_aws_subject_token(ctx, source).await,
             external_account::Source::File(source) => {
                 self.load_file_sourced_token(ctx, source).await
             }
@@ -196,6 +246,293 @@ impl ExternalAccountCredentialProvider {
         resolve_template(ctx, &self.external_account.subject_token_type)
     }
 
+    fn validate_aws_source(
+        &self,
+        ctx: &Context,
+        source: &external_account::AwsSource,
+    ) -> Result<ResolvedAwsSource> {
+        if source.environment_id != "aws1" {
+            return Err(reqsign_core::Error::config_invalid(format!(
+                "unsupported AWS external_account environment_id: {}",
+                source.environment_id
+            )));
+        }
+
+        let region_url = source
+            .region_url
+            .as_deref()
+            .map(|v| resolve_template(ctx, v))
+            .transpose()?;
+        let url = source
+            .url
+            .as_deref()
+            .map(|v| resolve_template(ctx, v))
+            .transpose()?;
+        let regional_cred_verification_url =
+            resolve_template(ctx, &source.regional_cred_verification_url)?;
+        let imdsv2_session_token_url = source
+            .imdsv2_session_token_url
+            .as_deref()
+            .map(|v| resolve_template(ctx, v))
+            .transpose()?;
+
+        for (field, value) in [
+            ("credential_source.region_url", region_url.as_deref()),
+            ("credential_source.url", url.as_deref()),
+            (
+                "credential_source.imdsv2_session_token_url",
+                imdsv2_session_token_url.as_deref(),
+            ),
+        ] {
+            if let Some(value) = value {
+                validate_aws_metadata_url(field, value)?;
+            }
+        }
+
+        Ok(ResolvedAwsSource {
+            region_url,
+            url,
+            regional_cred_verification_url,
+            imdsv2_session_token_url,
+        })
+    }
+
+    async fn load_aws_subject_token(
+        &self,
+        ctx: &Context,
+        source: &external_account::AwsSource,
+    ) -> Result<String> {
+        let subject_token_type = self.resolved_subject_token_type(ctx)?;
+        if subject_token_type != TOKEN_TYPE_AWS4_REQUEST {
+            return Err(reqsign_core::Error::config_invalid(format!(
+                "AWS credential_source requires subject_token_type 
{TOKEN_TYPE_AWS4_REQUEST}, got {subject_token_type}"
+            )));
+        }
+
+        let source = self.validate_aws_source(ctx, source)?;
+        let metadata_token = self.load_aws_imdsv2_token_if_needed(ctx, 
&source).await?;
+        let region = self
+            .resolve_aws_region(ctx, &source, metadata_token.as_deref())
+            .await?;
+        let credential = self
+            .resolve_aws_credential(ctx, &source, metadata_token.as_deref())
+            .await?;
+        self.build_aws_subject_token(ctx, &source, &region, credential)
+            .await
+    }
+
+    async fn resolve_aws_region(
+        &self,
+        ctx: &Context,
+        source: &ResolvedAwsSource,
+        metadata_token: Option<&str>,
+    ) -> Result<String> {
+        if let Some(region) = ctx
+            .env_var(AWS_REGION)
+            .filter(|v| !v.trim().is_empty())
+            .or_else(|| {
+                ctx.env_var(AWS_DEFAULT_REGION)
+                    .filter(|v| !v.trim().is_empty())
+            })
+        {
+            return Ok(region);
+        }
+
+        let region_url = source.region_url.as_deref().ok_or_else(|| {
+            reqsign_core::Error::config_invalid(
+                "credential_source.region_url is required when AWS region env 
vars are absent",
+            )
+        })?;
+        let zone = fetch_aws_metadata_text(ctx, region_url, 
metadata_token).await?;
+        availability_zone_to_region(zone.trim())
+    }
+
+    async fn resolve_aws_credential(
+        &self,
+        ctx: &Context,
+        source: &ResolvedAwsSource,
+        metadata_token: Option<&str>,
+    ) -> Result<AwsCredential> {
+        if let Some(credential) = AwsEnvCredentialProvider::new()
+            .provide_credential(ctx)
+            .await?
+        {
+            return Ok(credential);
+        }
+
+        let credentials_url = source.url.as_deref().ok_or_else(|| {
+            reqsign_core::Error::config_invalid(
+                "credential_source.url is required when AWS credential env 
vars are absent",
+            )
+        })?;
+        let role_name = fetch_aws_metadata_text(ctx, credentials_url, 
metadata_token).await?;
+        let role_name = role_name.trim();
+        if role_name.is_empty() {
+            return Err(reqsign_core::Error::credential_invalid(
+                "AWS metadata credentials role name is empty",
+            ));
+        }
+
+        let credentials_url = format!("{}/{}", 
credentials_url.trim_end_matches('/'), role_name);
+        let content = fetch_aws_metadata_text(ctx, &credentials_url, 
metadata_token).await?;
+        let response: AwsMetadataCredentialResponse = 
serde_json::from_str(content.trim())
+            .map_err(|e| {
+                reqsign_core::Error::unexpected("failed to parse AWS metadata 
credentials response")
+                    .with_source(e)
+            })?;
+
+        if let Some(code) = response.code.as_deref() {
+            if code != "Success" {
+                return Err(reqsign_core::Error::credential_invalid(format!(
+                    "AWS metadata credentials response returned [{}] {}",
+                    code,
+                    response.message.as_deref().unwrap_or_default()
+                )));
+            }
+        }
+        if response.access_key_id.is_empty() || 
response.secret_access_key.is_empty() {
+            return Err(reqsign_core::Error::credential_invalid(
+                "AWS metadata credentials response is missing access key id or 
secret access key",
+            ));
+        }
+
+        Ok(AwsCredential {
+            access_key_id: response.access_key_id,
+            secret_access_key: response.secret_access_key,
+            session_token: response.token.filter(|v| !v.trim().is_empty()),
+            expires_in: response
+                .expiration
+                .as_deref()
+                .map(str::trim)
+                .filter(|v| !v.is_empty())
+                .map(|v| {
+                    v.parse().map_err(|e| {
+                        reqsign_core::Error::unexpected(
+                            "failed to parse AWS metadata credential 
expiration",
+                        )
+                        .with_source(e)
+                    })
+                })
+                .transpose()?,
+        })
+    }
+
+    async fn load_aws_imdsv2_token_if_needed(
+        &self,
+        ctx: &Context,
+        source: &ResolvedAwsSource,
+    ) -> Result<Option<String>> {
+        let needs_metadata_region = ctx
+            .env_var(AWS_REGION)
+            .filter(|v| !v.trim().is_empty())
+            .or_else(|| {
+                ctx.env_var(AWS_DEFAULT_REGION)
+                    .filter(|v| !v.trim().is_empty())
+            })
+            .is_none()
+            && source.region_url.is_some();
+        let needs_metadata_cred = AwsEnvCredentialProvider::new()
+            .provide_credential(ctx)
+            .await?
+            .is_none()
+            && source.url.is_some();
+
+        let Some(token_url) = source.imdsv2_session_token_url.as_deref() else {
+            return Ok(None);
+        };
+        if !needs_metadata_region && !needs_metadata_cred {
+            return Ok(None);
+        }
+        if ctx
+            .env_var(AWS_EC2_METADATA_DISABLED)
+            .as_deref()
+            .is_some_and(|v| v.eq_ignore_ascii_case("true"))
+        {
+            return Err(reqsign_core::Error::config_invalid(
+                "AWS metadata access is disabled by AWS_EC2_METADATA_DISABLED",
+            ));
+        }
+        let req = http::Request::builder()
+            .method(Method::PUT)
+            .uri(token_url)
+            .header(CONTENT_LENGTH, "0")
+            .header(AWS_IMDSV2_TTL_HEADER, AWS_IMDSV2_TTL_SECONDS)
+            .body(Vec::<u8>::new().into())
+            .map_err(|e| {
+                reqsign_core::Error::unexpected("failed to build AWS IMDSv2 
token request")
+                    .with_source(e)
+            })?;
+        let resp = ctx.http_send_as_string(req).await?;
+        if resp.status() != http::StatusCode::OK {
+            return Err(reqsign_core::Error::unexpected(format!(
+                "failed to fetch AWS IMDSv2 session token: {}",
+                resp.body()
+            )));
+        }
+        let token = resp.into_body();
+        if token.trim().is_empty() {
+            return Err(reqsign_core::Error::credential_invalid(
+                "AWS IMDSv2 session token is empty",
+            ));
+        }
+        Ok(Some(token))
+    }
+
+    async fn build_aws_subject_token(
+        &self,
+        ctx: &Context,
+        source: &ResolvedAwsSource,
+        region: &str,
+        credential: AwsCredential,
+    ) -> Result<String> {
+        let audience = resolve_template(ctx, &self.external_account.audience)?;
+        let verification_url = source
+            .regional_cred_verification_url
+            .replace("{region}", region);
+
+        let req = http::Request::builder()
+            .method(Method::POST)
+            .uri(&verification_url)
+            .header("x-goog-cloud-target-resource", audience)
+            .body(())
+            .map_err(|e| {
+                reqsign_core::Error::unexpected("failed to build AWS subject 
token request")
+                    .with_source(e)
+            })?;
+        let (mut parts, _body) = req.into_parts();
+        AwsRequestSigner::new("sts", region)
+            .sign_request(ctx, &mut parts, Some(&credential), None)
+            .await?;
+
+        let mut headers = parts
+            .headers
+            .iter()
+            .map(|(key, value)| {
+                Ok(AwsSignedHeader {
+                    key: aws_subject_header_name(key.as_str()),
+                    value: value
+                        .to_str()
+                        .map_err(|e| {
+                            reqsign_core::Error::unexpected("AWS signed header 
is not valid UTF-8")
+                                .with_source(e)
+                        })?
+                        .to_string(),
+                })
+            })
+            .collect::<Result<Vec<_>>>()?;
+        headers.sort_by(|a, b| a.key.cmp(&b.key));
+
+        serde_json::to_string(&AwsSignedRequest {
+            url: parts.uri.to_string(),
+            method: parts.method.as_str().to_string(),
+            headers,
+            body: String::new(),
+        })
+        .map_err(|e| {
+            reqsign_core::Error::unexpected("failed to serialize AWS subject 
token").with_source(e)
+        })
+    }
+
     fn build_executable_env(
         &self,
         ctx: &Context,
@@ -661,6 +998,77 @@ fn parse_impersonated_service_account_email(url: &str) -> 
Result<String> {
     Ok(email.into_owned())
 }
 
+struct ResolvedAwsSource {
+    region_url: Option<String>,
+    url: Option<String>,
+    regional_cred_verification_url: String,
+    imdsv2_session_token_url: Option<String>,
+}
+
+fn validate_aws_metadata_url(field: &str, value: &str) -> Result<()> {
+    let uri: http::Uri = value.parse().map_err(|e| {
+        reqsign_core::Error::config_invalid(format!("{field} is not a valid 
URI")).with_source(e)
+    })?;
+    let host = uri.host().ok_or_else(|| {
+        reqsign_core::Error::config_invalid(format!("{field} is missing a 
host: {value}"))
+    })?;
+    if !matches!(host, "169.254.169.254" | "fd00:ec2::254") {
+        return Err(reqsign_core::Error::config_invalid(format!(
+            "{field} host must be 169.254.169.254 or fd00:ec2::254, got {host}"
+        )));
+    }
+    Ok(())
+}
+
+fn availability_zone_to_region(zone: &str) -> Result<String> {
+    let mut chars = zone.chars();
+    let last = chars
+        .next_back()
+        .ok_or_else(|| reqsign_core::Error::credential_invalid("AWS 
availability zone is empty"))?;
+    if !last.is_ascii_alphabetic() {
+        return Err(reqsign_core::Error::credential_invalid(format!(
+            "AWS availability zone must end with an alphabetic suffix, got 
{zone}"
+        )));
+    }
+    let region = chars.as_str();
+    if region.is_empty() {
+        return Err(reqsign_core::Error::credential_invalid(format!(
+            "failed to derive AWS region from availability zone {zone}"
+        )));
+    }
+    Ok(region.to_string())
+}
+
+async fn fetch_aws_metadata_text(
+    ctx: &Context,
+    url: &str,
+    session_token: Option<&str>,
+) -> Result<String> {
+    let mut req = http::Request::builder().method(Method::GET).uri(url);
+    if let Some(token) = session_token {
+        req = req.header(AWS_IMDSV2_TOKEN_HEADER, token);
+    }
+    let req = req.body(Vec::<u8>::new().into()).map_err(|e| {
+        reqsign_core::Error::unexpected("failed to build AWS metadata 
request").with_source(e)
+    })?;
+    let resp = ctx.http_send_as_string(req).await?;
+    if resp.status() != http::StatusCode::OK {
+        return Err(reqsign_core::Error::unexpected(format!(
+            "AWS metadata request to {url} failed: {}",
+            resp.body()
+        )));
+    }
+    Ok(resp.into_body())
+}
+
+fn aws_subject_header_name(name: &str) -> String {
+    if name.eq_ignore_ascii_case("authorization") {
+        "Authorization".to_string()
+    } else {
+        name.to_string()
+    }
+}
+
 async fn execute_command_with_env(
     ctx: &Context,
     command: &str,
@@ -952,6 +1360,80 @@ mod tests {
         }
     }
 
+    #[derive(Clone, Debug)]
+    struct AwsMetadataHttpSend {
+        imdsv2_session_token_url: String,
+        region_url: String,
+        credentials_url: String,
+        session_token: String,
+        region_response: String,
+        role_name: String,
+    }
+
+    impl HttpSend for AwsMetadataHttpSend {
+        async fn http_send(&self, req: http::Request<Bytes>) -> 
Result<http::Response<Bytes>> {
+            let uri = req.uri().to_string();
+            match (req.method().clone(), uri.as_str()) {
+                (Method::PUT, url) if url == self.imdsv2_session_token_url => {
+                    Ok(http::Response::builder()
+                        .status(http::StatusCode::OK)
+                        .body(self.session_token.clone().into_bytes().into())
+                        .expect("response must build"))
+                }
+                (Method::GET, url) if url == self.region_url => {
+                    assert_eq!(
+                        req.headers()
+                            .get(AWS_IMDSV2_TOKEN_HEADER)
+                            .expect("imdsv2 token header must exist")
+                            .to_str()
+                            .expect("imdsv2 token header must be valid"),
+                        self.session_token
+                    );
+                    Ok(http::Response::builder()
+                        .status(http::StatusCode::OK)
+                        .body(self.region_response.clone().into_bytes().into())
+                        .expect("response must build"))
+                }
+                (Method::GET, url) if url == self.credentials_url => {
+                    assert_eq!(
+                        req.headers()
+                            .get(AWS_IMDSV2_TOKEN_HEADER)
+                            .expect("imdsv2 token header must exist")
+                            .to_str()
+                            .expect("imdsv2 token header must be valid"),
+                        self.session_token
+                    );
+                    Ok(http::Response::builder()
+                        .status(http::StatusCode::OK)
+                        .body(self.role_name.clone().into_bytes().into())
+                        .expect("response must build"))
+                }
+                (Method::GET, url)
+                    if url == format!("{}/{}", self.credentials_url, 
self.role_name) =>
+                {
+                    assert_eq!(
+                        req.headers()
+                            .get(AWS_IMDSV2_TOKEN_HEADER)
+                            .expect("imdsv2 token header must exist")
+                            .to_str()
+                            .expect("imdsv2 token header must be valid"),
+                        self.session_token
+                    );
+                    let body = serde_json::json!({
+                        "AccessKeyId": "metadata-access-key",
+                        "SecretAccessKey": "metadata-secret-key",
+                        "Token": "metadata-session-token"
+                    });
+                    Ok(http::Response::builder()
+                        .status(http::StatusCode::OK)
+                        .body(serde_json::to_vec(&body).expect("json must 
encode").into())
+                        .expect("response must build"))
+                }
+                _ => panic!("unexpected AWS metadata request: {} {}", 
req.method(), uri),
+            }
+        }
+    }
+
     #[test]
     fn test_resolve_template() {
         let ctx = Context::new().with_env(MockEnv::default().with_var("FOO", 
"bar"));
@@ -1035,6 +1517,160 @@ mod tests {
         Ok(())
     }
 
+    fn aws_source() -> external_account::AwsSource {
+        external_account::AwsSource {
+            environment_id: "aws1".to_string(),
+            region_url: Some(
+                
"http://169.254.169.254/latest/meta-data/placement/availability-zone".to_string(),
+            ),
+            url: Some(
+                
"http://169.254.169.254/latest/meta-data/iam/security-credentials".to_string(),
+            ),
+            regional_cred_verification_url:
+                
"https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15";
+                    .to_string(),
+            imdsv2_session_token_url: 
Some("http://169.254.169.254/latest/api/token".to_string()),
+        }
+    }
+
+    fn aws_account(source: external_account::AwsSource) -> ExternalAccount {
+        ExternalAccount {
+            audience:
+                
"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider"
+                    .to_string(),
+            subject_token_type: TOKEN_TYPE_AWS4_REQUEST.to_string(),
+            token_url: "https://sts.googleapis.com/v1/token".to_string(),
+            credential_source: external_account::Source::Aws(source),
+            service_account_impersonation_url: None,
+            service_account_impersonation: None,
+        }
+    }
+
+    fn parse_aws_subject_token(token: &str) -> serde_json::Value {
+        serde_json::from_str(token).expect("aws subject token must be valid 
json")
+    }
+
+    fn header_value<'a>(headers: &'a [serde_json::Value], key: &str) -> 
Option<&'a str> {
+        headers.iter().find_map(|entry| {
+            let current = entry.get("key")?.as_str()?;
+            if current.eq_ignore_ascii_case(key) {
+                entry.get("value")?.as_str()
+            } else {
+                None
+            }
+        })
+    }
+
+    #[tokio::test]
+    async fn test_aws_source_uses_env_region_and_credentials() -> Result<()> {
+        let env = MockEnv::default()
+            .with_var(AWS_REGION, "us-east-1")
+            .with_var(AWS_ACCESS_KEY_ID, "test-access-key")
+            .with_var(AWS_SECRET_ACCESS_KEY, "test-secret-key")
+            .with_var(AWS_SESSION_TOKEN, "test-session-token");
+        let ctx = Context::new().with_env(env);
+        let provider = 
ExternalAccountCredentialProvider::new(aws_account(aws_source()));
+
+        let token = provider.load_oidc_token(&ctx).await?;
+        let json = parse_aws_subject_token(&token);
+        assert_eq!(json.get("method").and_then(|v| v.as_str()), Some("POST"));
+        assert_eq!(json.get("body").and_then(|v| v.as_str()), Some(""));
+        assert_eq!(
+            json.get("url").and_then(|v| v.as_str()),
+            Some(
+                
"https://sts.us-east-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15";,
+            )
+        );
+        let headers = json
+            .get("headers")
+            .and_then(|v| v.as_array())
+            .expect("headers must be an array");
+        assert_eq!(
+            header_value(headers, "x-goog-cloud-target-resource"),
+            Some(
+                
"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider"
+            )
+        );
+        assert_eq!(
+            header_value(headers, "x-amz-security-token"),
+            Some("test-session-token")
+        );
+        assert!(
+            header_value(headers, "Authorization")
+                .expect("authorization header must exist")
+                .starts_with("AWS4-HMAC-SHA256 Credential=test-access-key/")
+        );
+        assert!(header_value(headers, "x-amz-date").is_some());
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_aws_source_falls_back_to_metadata_with_imdsv2() -> 
Result<()> {
+        let http = AwsMetadataHttpSend {
+            imdsv2_session_token_url: 
"http://169.254.169.254/latest/api/token".to_string(),
+            region_url: 
"http://169.254.169.254/latest/meta-data/placement/availability-zone";
+                .to_string(),
+            credentials_url: 
"http://169.254.169.254/latest/meta-data/iam/security-credentials";
+                .to_string(),
+            session_token: "imdsv2-token".to_string(),
+            region_response: "us-west-2b".to_string(),
+            role_name: "test-role".to_string(),
+        };
+        let ctx = Context::new().with_http_send(http);
+        let provider = 
ExternalAccountCredentialProvider::new(aws_account(aws_source()));
+
+        let token = provider.load_oidc_token(&ctx).await?;
+        let json = parse_aws_subject_token(&token);
+        assert_eq!(
+            json.get("url").and_then(|v| v.as_str()),
+            Some(
+                
"https://sts.us-west-2.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15";,
+            )
+        );
+        assert_eq!(json.get("body").and_then(|v| v.as_str()), Some(""));
+        let headers = json
+            .get("headers")
+            .and_then(|v| v.as_array())
+            .expect("headers must be an array");
+        assert_eq!(
+            header_value(headers, "x-amz-security-token"),
+            Some("metadata-session-token")
+        );
+        assert!(
+            header_value(headers, "Authorization")
+                .expect("authorization header must exist")
+                .starts_with("AWS4-HMAC-SHA256 
Credential=metadata-access-key/")
+        );
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn test_aws_source_rejects_unsupported_environment_id() {
+        let mut source = aws_source();
+        source.environment_id = "aws2".to_string();
+
+        let provider = 
ExternalAccountCredentialProvider::new(aws_account(source));
+        let err = provider
+            .load_oidc_token(&Context::new())
+            .await
+            .expect_err("unsupported AWS environment_id must fail");
+        assert!(err.to_string().contains("aws2"));
+    }
+
+    #[tokio::test]
+    async fn test_aws_source_rejects_invalid_metadata_host() {
+        let mut source = aws_source();
+        source.region_url =
+            
Some("http://example.com/latest/meta-data/placement/availability-zone".to_string());
+
+        let provider = 
ExternalAccountCredentialProvider::new(aws_account(source));
+        let err = provider
+            .load_oidc_token(&Context::new())
+            .await
+            .expect_err("invalid metadata host must fail");
+        assert!(err.to_string().contains("169.254.169.254"));
+    }
+
     fn executable_source(
         command: &str,
         output_file: Option<&str>,

Reply via email to