Skip to main content

alien_aws_clients/aws/
mod.rs

1use alien_client_core::{ErrorData, Result};
2use alien_error::{AlienError, Context, IntoAlienError};
3use aws_credential_types::Credentials;
4use serde::Deserialize;
5use std::{collections::HashMap, time::Duration};
6
7// Re-export types from alien-core
8pub use alien_core::{
9    AwsClientConfig, AwsCredentials, AwsImpersonationConfig,
10    AwsServiceOverrides as ServiceOverrides, AwsWebIdentityConfig,
11};
12
13/// Trait for AWS platform configuration operations
14#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
15#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
16pub trait AwsClientConfigExt {
17    /// Create a new `AwsClientConfig` from environment variables.
18    async fn from_env(environment_variables: &HashMap<String, String>) -> Result<AwsClientConfig>;
19
20    /// Create a new `AwsClientConfig` from standard environment variables.
21    async fn from_std_env() -> Result<AwsClientConfig>;
22
23    /// Assume an AWS IAM role and return a new platform config with the assumed credentials
24    async fn impersonate(&self, config: AwsImpersonationConfig) -> Result<AwsClientConfig>;
25
26    /// Get AWS credentials from this config
27    fn get_credentials(&self) -> Credentials;
28
29    /// Get credentials for web identity token authentication
30    async fn get_web_identity_credentials(&self) -> Result<AwsClientConfig>;
31
32    /// Get service endpoint, checking for overrides first
33    fn get_service_endpoint(&self, service_name: &str, default_endpoint: &str) -> String;
34
35    /// Get the endpoint for a specific service, with override support (returns Option)
36    fn get_service_endpoint_option(&self, service_name: &str) -> Option<&str>;
37
38    /// Create a config with service endpoint overrides for testing
39    #[cfg(any(test, feature = "test-utils"))]
40    fn with_service_overrides(self, overrides: ServiceOverrides) -> Self;
41
42    /// Create a mock AwsClientConfig with dummy values for testing
43    #[cfg(any(test, feature = "test-utils"))]
44    fn mock() -> Self;
45}
46
47pub mod acm;
48pub mod apigateway;
49pub mod apigatewayv2;
50pub mod autoscaling;
51pub mod aws_request_utils;
52pub mod cloudformation;
53pub mod cloudwatch;
54pub mod codebuild;
55pub mod credential_provider;
56pub mod dynamodb;
57pub mod ec2;
58pub mod ecr;
59pub mod eks;
60pub mod elbv2;
61pub mod eventbridge;
62pub mod iam;
63pub mod lambda;
64pub mod rds;
65pub mod resourcegroupstagging;
66pub mod s3;
67pub mod secrets_manager;
68pub mod sqs;
69pub mod ssm;
70pub mod sts;
71
72const AWS_IMDS_ENDPOINT: &str = "http://169.254.169.254";
73const AWS_IMDS_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(500);
74const AWS_IMDS_CREDENTIALS_TIMEOUT: Duration = Duration::from_secs(5);
75
76#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
77#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
78impl AwsClientConfigExt for AwsClientConfig {
79    /// Create a new `AwsClientConfig` from environment variables.
80    async fn from_env(environment_variables: &HashMap<String, String>) -> Result<Self> {
81        let region = resolve_region(environment_variables).await?;
82        let credentials = resolve_credentials(environment_variables).await?;
83        let service_overrides =
84            parse_service_overrides(environment_variables.get("AWS_SERVICE_OVERRIDES_ENDPOINTS"))?;
85        let account_id = infer_account_id(
86            environment_variables,
87            &region,
88            &credentials,
89            service_overrides.as_ref(),
90        )
91        .await?;
92
93        let config = Self {
94            account_id,
95            region,
96            credentials,
97            service_overrides,
98        };
99
100        Ok(config)
101    }
102
103    /// Create a new `AwsClientConfig` from standard environment variables.
104    async fn from_std_env() -> Result<Self> {
105        let env_vars: HashMap<String, String> = std::env::vars().collect();
106        Self::from_env(&env_vars).await
107    }
108
109    /// Assume an AWS IAM role and return a new platform config with the assumed credentials
110    async fn impersonate(&self, config: AwsImpersonationConfig) -> Result<AwsClientConfig> {
111        use crate::aws::sts::{AssumeRoleRequest, StsApi, StsClient};
112        use reqwest::Client;
113        use uuid::Uuid;
114
115        // Extract the target account ID from the role ARN (arn:aws:iam::{account_id}:role/...).
116        // This ensures cross-account impersonation produces a config with the correct account.
117        let target_account_id = extract_account_id_from_role_arn(&config.role_arn)
118            .unwrap_or_else(|| self.account_id.clone());
119
120        let target_region = config.target_region.unwrap_or_else(|| self.region.clone());
121
122        // Resolve the source before calling AssumeRole, which requires signed credentials.
123        let base_config = self.get_web_identity_credentials().await?;
124        let sts_client = StsClient::new(Client::new(), base_config);
125
126        let session_name = config
127            .session_name
128            .unwrap_or_else(|| format!("alien-impersonation-{}", Uuid::new_v4().simple()));
129
130        let assume_role_request = AssumeRoleRequest::builder()
131            .role_arn(config.role_arn)
132            .role_session_name(session_name)
133            .maybe_duration_seconds(config.duration_seconds)
134            .maybe_external_id(config.external_id)
135            .build();
136
137        let response = sts_client.assume_role(assume_role_request).await?;
138
139        let credentials = response.assume_role_result.credentials;
140
141        Ok(AwsClientConfig {
142            account_id: target_account_id,
143            region: target_region,
144            credentials: AwsCredentials::SessionCredentials {
145                access_key_id: credentials.access_key_id,
146                secret_access_key: credentials.secret_access_key,
147                session_token: credentials.session_token,
148                expires_at: credentials.expiration,
149            },
150            service_overrides: self.service_overrides.clone(),
151        })
152    }
153
154    /// Get AWS credentials from this config.
155    ///
156    /// Refreshable sources must be resolved before this synchronous method is
157    /// called. Callers that sign requests should use `AwsCredentialProvider`.
158    fn get_credentials(&self) -> Credentials {
159        match &self.credentials {
160            AwsCredentials::AccessKeys {
161                access_key_id,
162                secret_access_key,
163                session_token,
164            } => Credentials::new(
165                access_key_id.clone(),
166                secret_access_key.clone(),
167                session_token.clone(),
168                None,
169                "ProvidedCredentials",
170            ),
171            AwsCredentials::SessionCredentials {
172                access_key_id,
173                secret_access_key,
174                session_token,
175                ..
176            } => Credentials::new(
177                access_key_id.clone(),
178                secret_access_key.clone(),
179                Some(session_token.clone()),
180                None,
181                "SessionCredentials",
182            ),
183            AwsCredentials::Imds { .. }
184            | AwsCredentials::Profile { .. }
185            | AwsCredentials::WebIdentity { .. } => Credentials::new(
186                "PLACEHOLDER_ACCESS_KEY".to_string(),
187                "PLACEHOLDER_SECRET_KEY".to_string(),
188                None,
189                None,
190                "UnresolvedCredentialSource",
191            ),
192        }
193    }
194
195    /// Get credentials for refreshable credential sources.
196    async fn get_web_identity_credentials(&self) -> Result<AwsClientConfig> {
197        match &self.credentials {
198            AwsCredentials::WebIdentity { config } => {
199                use crate::aws::sts::{AssumeRoleWithWebIdentityRequest, StsApi, StsClient};
200                use reqwest::Client;
201                use uuid::Uuid;
202
203                let token = std::fs::read_to_string(&config.web_identity_token_file)
204                    .into_alien_error()
205                    .context(ErrorData::InvalidClientConfig {
206                        message: format!(
207                            "Failed to read web identity token file: {}",
208                            config.web_identity_token_file
209                        ),
210                        errors: None,
211                    })?
212                    .trim()
213                    .to_string();
214
215                let temp_config = AwsClientConfig {
216                    account_id: self.account_id.clone(),
217                    region: self.region.clone(),
218                    credentials: AwsCredentials::AccessKeys {
219                        access_key_id: "TEMP".to_string(),
220                        secret_access_key: "TEMP".to_string(),
221                        session_token: None,
222                    },
223                    service_overrides: self.service_overrides.clone(),
224                };
225
226                let sts_client = StsClient::new(Client::new(), temp_config);
227
228                let session_name = config
229                    .session_name
230                    .clone()
231                    .unwrap_or_else(|| format!("alien-web-identity-{}", Uuid::new_v4().simple()));
232
233                let assume_role_request = AssumeRoleWithWebIdentityRequest::builder()
234                    .role_arn(config.role_arn.clone())
235                    .role_session_name(session_name)
236                    .web_identity_token(token)
237                    .maybe_duration_seconds(config.duration_seconds)
238                    .build();
239
240                let response = sts_client
241                    .assume_role_with_web_identity(assume_role_request)
242                    .await?;
243                let credentials = response.assume_role_with_web_identity_result.credentials;
244
245                Ok(AwsClientConfig {
246                    account_id: self.account_id.clone(),
247                    region: self.region.clone(),
248                    credentials: AwsCredentials::SessionCredentials {
249                        access_key_id: credentials.access_key_id,
250                        secret_access_key: credentials.secret_access_key,
251                        session_token: credentials.session_token,
252                        expires_at: credentials.expiration,
253                    },
254                    service_overrides: self.service_overrides.clone(),
255                })
256            }
257            AwsCredentials::Imds { endpoint } => {
258                let credentials = load_imds_session_credentials(endpoint.as_deref()).await?;
259                Ok(AwsClientConfig {
260                    account_id: self.account_id.clone(),
261                    region: self.region.clone(),
262                    credentials,
263                    service_overrides: self.service_overrides.clone(),
264                })
265            }
266            AwsCredentials::Profile { name } => {
267                let credentials = load_profile_session_credentials(name)?;
268                Ok(AwsClientConfig {
269                    account_id: self.account_id.clone(),
270                    region: self.region.clone(),
271                    credentials,
272                    service_overrides: self.service_overrides.clone(),
273                })
274            }
275            AwsCredentials::AccessKeys { .. } | AwsCredentials::SessionCredentials { .. } => {
276                Ok(self.clone())
277            }
278        }
279    }
280
281    /// Get service endpoint, checking for overrides first
282    fn get_service_endpoint(&self, service_name: &str, default_endpoint: &str) -> String {
283        self.service_overrides
284            .as_ref()
285            .and_then(|overrides| overrides.endpoints.get(service_name))
286            .map(|s| s.clone())
287            .unwrap_or_else(|| default_endpoint.to_string())
288    }
289
290    /// Get the endpoint for a specific service, with override support (returns Option)
291    fn get_service_endpoint_option(&self, service_name: &str) -> Option<&str> {
292        self.service_overrides
293            .as_ref()
294            .and_then(|overrides| overrides.endpoints.get(service_name))
295            .map(|s| s.as_str())
296    }
297
298    /// Create a config with service endpoint overrides for testing
299    #[cfg(any(test, feature = "test-utils"))]
300    fn with_service_overrides(mut self, overrides: ServiceOverrides) -> Self {
301        self.service_overrides = Some(overrides);
302        self
303    }
304
305    /// Create a mock AwsClientConfig with dummy values for testing
306    #[cfg(any(test, feature = "test-utils"))]
307    fn mock() -> Self {
308        Self {
309            account_id: "123456789012".to_string(),
310            region: "us-east-1".to_string(),
311            credentials: AwsCredentials::AccessKeys {
312                access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
313                secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
314                session_token: None,
315            },
316            service_overrides: None,
317        }
318    }
319}
320
321async fn resolve_region(environment_variables: &HashMap<String, String>) -> Result<String> {
322    if let Some(region) = environment_variables.get("AWS_REGION") {
323        return Ok(region.clone());
324    }
325
326    if let Some(region) = environment_variables.get("AWS_DEFAULT_REGION") {
327        return Ok(region.clone());
328    }
329
330    let imds_error = if !metadata_disabled(environment_variables) {
331        match load_imds_region(environment_variables).await {
332            Ok(region) => return Ok(region),
333            Err(error) => Some(error),
334        }
335    } else {
336        None
337    };
338
339    let profile = profile_name(environment_variables);
340    match load_profile_region(&profile) {
341        Ok(Some(region)) => return Ok(region),
342        Ok(None) => {}
343        Err(profile_error) => {
344            if let Some(imds_error) = imds_error {
345                return Err(AlienError::new(ErrorData::InvalidClientConfig {
346                    message: format!(
347                        "Failed to resolve AWS region from IMDS and fallback profile '{}': {}; IMDS error: {}",
348                        profile, profile_error, imds_error
349                    ),
350                    errors: None,
351                }));
352            }
353            return Err(profile_error);
354        }
355    }
356
357    Err(AlienError::new(ErrorData::InvalidClientConfig {
358        message: "Missing AWS region. Set AWS_REGION, AWS_DEFAULT_REGION, or configure a default region in your AWS profile.".to_string(),
359        errors: None,
360    }))
361}
362
363async fn resolve_credentials(
364    environment_variables: &HashMap<String, String>,
365) -> Result<AwsCredentials> {
366    if let (Some(role_arn), Some(token_file)) = (
367        environment_variables.get("AWS_ROLE_ARN"),
368        environment_variables.get("AWS_WEB_IDENTITY_TOKEN_FILE"),
369    ) {
370        return Ok(AwsCredentials::WebIdentity {
371            config: AwsWebIdentityConfig {
372                role_arn: role_arn.clone(),
373                session_name: environment_variables.get("AWS_ROLE_SESSION_NAME").cloned(),
374                web_identity_token_file: token_file.clone(),
375                duration_seconds: environment_variables
376                    .get("AWS_ROLE_DURATION_SECONDS")
377                    .and_then(|s| s.parse().ok()),
378            },
379        });
380    }
381
382    if let (Some(access_key_id), Some(secret_access_key)) = (
383        environment_variables.get("AWS_ACCESS_KEY_ID"),
384        environment_variables.get("AWS_SECRET_ACCESS_KEY"),
385    ) {
386        return Ok(AwsCredentials::AccessKeys {
387            access_key_id: access_key_id.clone(),
388            secret_access_key: secret_access_key.clone(),
389            session_token: environment_variables
390                .get("AWS_SESSION_TOKEN")
391                .filter(|token| !token.trim().is_empty())
392                .cloned(),
393        });
394    }
395
396    if profile_is_explicit(environment_variables) {
397        let profile = profile_name(environment_variables);
398        return Ok(AwsCredentials::Profile { name: profile });
399    }
400
401    let imds_error = if !metadata_disabled(environment_variables) {
402        match discover_imds_credentials(environment_variables).await {
403            Ok(()) => {
404                return Ok(AwsCredentials::Imds {
405                    endpoint: environment_variables
406                        .get("AWS_EC2_METADATA_SERVICE_ENDPOINT")
407                        .cloned(),
408                })
409            }
410            Err(error) => Some(error),
411        }
412    } else {
413        None
414    };
415
416    let profile = profile_name(environment_variables);
417    match load_profile_session_credentials(&profile) {
418        Ok(_) => Ok(AwsCredentials::Profile { name: profile }),
419        Err(profile_error) => {
420            if let Some(imds_error) = imds_error {
421                return Err(AlienError::new(ErrorData::InvalidClientConfig {
422                    message: format!(
423                        "Failed to resolve AWS credentials from IMDS and fallback profile '{}': {}; IMDS error: {}",
424                        profile, profile_error, imds_error
425                    ),
426                    errors: None,
427                }));
428            }
429            Err(profile_error)
430        }
431    }
432}
433
434fn profile_is_explicit(environment_variables: &HashMap<String, String>) -> bool {
435    environment_variables.contains_key("AWS_PROFILE")
436        || environment_variables.contains_key("AWS_DEFAULT_PROFILE")
437}
438
439fn metadata_disabled(environment_variables: &HashMap<String, String>) -> bool {
440    environment_variables
441        .get("AWS_EC2_METADATA_DISABLED")
442        .map(|value| value.eq_ignore_ascii_case("true"))
443        .unwrap_or(false)
444}
445
446#[derive(Debug, Deserialize)]
447#[serde(rename_all = "PascalCase")]
448struct AwsImdsCredentials {
449    access_key_id: String,
450    secret_access_key: String,
451    token: String,
452    expiration: String,
453}
454
455async fn discover_imds_credentials(environment_variables: &HashMap<String, String>) -> Result<()> {
456    let endpoint = environment_variables
457        .get("AWS_EC2_METADATA_SERVICE_ENDPOINT")
458        .map(String::as_str);
459    load_imds_session_credentials(endpoint).await.map(|_| ())
460}
461
462async fn load_imds_session_credentials(endpoint: Option<&str>) -> Result<AwsCredentials> {
463    let endpoint = endpoint.unwrap_or(AWS_IMDS_ENDPOINT).trim_end_matches('/');
464
465    let client = reqwest::Client::builder()
466        .build()
467        .into_alien_error()
468        .context(ErrorData::InvalidClientConfig {
469            message: "Failed to create AWS IMDS HTTP client".to_string(),
470            errors: None,
471        })?;
472
473    let token_url = format!("{endpoint}/latest/api/token");
474    let token = client
475        .put(&token_url)
476        .timeout(AWS_IMDS_DISCOVERY_TIMEOUT)
477        .header("X-aws-ec2-metadata-token-ttl-seconds", "21600")
478        .send()
479        .await
480        .into_alien_error()
481        .context(ErrorData::InvalidClientConfig {
482            message: "Failed to request AWS IMDSv2 token".to_string(),
483            errors: None,
484        })?
485        .error_for_status()
486        .into_alien_error()
487        .context(ErrorData::InvalidClientConfig {
488            message: "AWS IMDSv2 token request failed".to_string(),
489            errors: None,
490        })?
491        .text()
492        .await
493        .into_alien_error()
494        .context(ErrorData::InvalidClientConfig {
495            message: "Failed to read AWS IMDSv2 token".to_string(),
496            errors: None,
497        })?;
498
499    let role_url = format!("{endpoint}/latest/meta-data/iam/security-credentials/");
500    let role_name = client
501        .get(&role_url)
502        .timeout(AWS_IMDS_DISCOVERY_TIMEOUT)
503        .header("X-aws-ec2-metadata-token", &token)
504        .send()
505        .await
506        .into_alien_error()
507        .context(ErrorData::InvalidClientConfig {
508            message: "Failed to request AWS IMDS role name".to_string(),
509            errors: None,
510        })?
511        .error_for_status()
512        .into_alien_error()
513        .context(ErrorData::InvalidClientConfig {
514            message: "AWS IMDS role name request failed".to_string(),
515            errors: None,
516        })?
517        .text()
518        .await
519        .into_alien_error()
520        .context(ErrorData::InvalidClientConfig {
521            message: "Failed to read AWS IMDS role name".to_string(),
522            errors: None,
523        })?;
524
525    let role_name = role_name
526        .lines()
527        .map(str::trim)
528        .find(|line| !line.is_empty())
529        .ok_or_else(|| {
530            AlienError::new(ErrorData::InvalidClientConfig {
531                message: "AWS IMDS did not return an IAM role name".to_string(),
532                errors: None,
533            })
534        })?;
535
536    let credentials_url = format!("{role_url}{role_name}");
537    let credentials: AwsImdsCredentials = client
538        .get(&credentials_url)
539        .timeout(AWS_IMDS_CREDENTIALS_TIMEOUT)
540        .header("X-aws-ec2-metadata-token", &token)
541        .send()
542        .await
543        .into_alien_error()
544        .context(ErrorData::InvalidClientConfig {
545            message: "Failed to request AWS IMDS credentials".to_string(),
546            errors: None,
547        })?
548        .error_for_status()
549        .into_alien_error()
550        .context(ErrorData::InvalidClientConfig {
551            message: "AWS IMDS credentials request failed".to_string(),
552            errors: None,
553        })?
554        .json()
555        .await
556        .into_alien_error()
557        .context(ErrorData::InvalidClientConfig {
558            message: "Failed to parse AWS IMDS credentials".to_string(),
559            errors: None,
560        })?;
561
562    Ok(AwsCredentials::SessionCredentials {
563        access_key_id: credentials.access_key_id,
564        secret_access_key: credentials.secret_access_key,
565        session_token: credentials.token,
566        expires_at: credentials.expiration,
567    })
568}
569
570async fn load_imds_region(environment_variables: &HashMap<String, String>) -> Result<String> {
571    let endpoint = environment_variables
572        .get("AWS_EC2_METADATA_SERVICE_ENDPOINT")
573        .map(String::as_str)
574        .unwrap_or(AWS_IMDS_ENDPOINT)
575        .trim_end_matches('/');
576
577    let client = reqwest::Client::builder()
578        .build()
579        .into_alien_error()
580        .context(ErrorData::InvalidClientConfig {
581            message: "Failed to create AWS IMDS HTTP client".to_string(),
582            errors: None,
583        })?;
584
585    let token_url = format!("{endpoint}/latest/api/token");
586    let token = client
587        .put(&token_url)
588        .timeout(AWS_IMDS_DISCOVERY_TIMEOUT)
589        .header("X-aws-ec2-metadata-token-ttl-seconds", "21600")
590        .send()
591        .await
592        .into_alien_error()
593        .context(ErrorData::InvalidClientConfig {
594            message: "Failed to request AWS IMDSv2 token".to_string(),
595            errors: None,
596        })?
597        .error_for_status()
598        .into_alien_error()
599        .context(ErrorData::InvalidClientConfig {
600            message: "AWS IMDSv2 token request failed".to_string(),
601            errors: None,
602        })?
603        .text()
604        .await
605        .into_alien_error()
606        .context(ErrorData::InvalidClientConfig {
607            message: "Failed to read AWS IMDSv2 token".to_string(),
608            errors: None,
609        })?;
610
611    let region_url = format!("{endpoint}/latest/meta-data/placement/region");
612    let region = client
613        .get(&region_url)
614        .timeout(AWS_IMDS_DISCOVERY_TIMEOUT)
615        .header("X-aws-ec2-metadata-token", token.trim())
616        .send()
617        .await
618        .into_alien_error()
619        .context(ErrorData::InvalidClientConfig {
620            message: "Failed to request AWS IMDS region".to_string(),
621            errors: None,
622        })?
623        .error_for_status()
624        .into_alien_error()
625        .context(ErrorData::InvalidClientConfig {
626            message: "AWS IMDS region request failed".to_string(),
627            errors: None,
628        })?
629        .text()
630        .await
631        .into_alien_error()
632        .context(ErrorData::InvalidClientConfig {
633            message: "Failed to read AWS IMDS region".to_string(),
634            errors: None,
635        })?;
636
637    let region = region.trim();
638    if region.is_empty() {
639        return Err(AlienError::new(ErrorData::InvalidClientConfig {
640            message: "AWS IMDS did not return a region".to_string(),
641            errors: None,
642        }));
643    }
644
645    Ok(region.to_string())
646}
647
648fn parse_service_overrides(endpoints_json: Option<&String>) -> Result<Option<ServiceOverrides>> {
649    if let Some(endpoints_json) = endpoints_json {
650        let endpoints: HashMap<String, String> = serde_json::from_str(endpoints_json)
651            .into_alien_error()
652            .context(ErrorData::InvalidClientConfig {
653                message: "Failed to parse AWS_SERVICE_OVERRIDES_ENDPOINTS".to_string(),
654                errors: None,
655            })?;
656        Ok(Some(ServiceOverrides { endpoints }))
657    } else {
658        Ok(None)
659    }
660}
661
662async fn infer_account_id(
663    environment_variables: &HashMap<String, String>,
664    region: &str,
665    credentials: &AwsCredentials,
666    service_overrides: Option<&ServiceOverrides>,
667) -> Result<String> {
668    if let Some(account_id) = environment_variables.get("AWS_ACCOUNT_ID") {
669        return Ok(account_id.clone());
670    }
671
672    if let Some(role_arn) = environment_variables.get("AWS_ROLE_ARN") {
673        if let Some(account_id) = extract_account_id_from_role_arn(role_arn) {
674            return Ok(account_id);
675        }
676    }
677
678    if let AwsCredentials::WebIdentity { config } = credentials {
679        if let Some(account_id) = extract_account_id_from_role_arn(&config.role_arn) {
680            return Ok(account_id);
681        }
682    }
683
684    use crate::aws::sts::{StsApi, StsClient};
685    let mut probe_config = AwsClientConfig {
686        account_id: String::new(),
687        region: region.to_string(),
688        credentials: credentials.clone(),
689        service_overrides: service_overrides.cloned(),
690    };
691
692    if matches!(
693        probe_config.credentials,
694        AwsCredentials::WebIdentity { .. }
695            | AwsCredentials::Imds { .. }
696            | AwsCredentials::Profile { .. }
697    ) {
698        probe_config = probe_config.get_web_identity_credentials().await?;
699    }
700
701    let caller_identity = StsClient::new(reqwest::Client::new(), probe_config)
702        .get_caller_identity()
703        .await
704        .context(ErrorData::InvalidClientConfig {
705            message: "Failed to infer AWS account ID from credentials".to_string(),
706            errors: None,
707        })?;
708
709    caller_identity
710        .get_caller_identity_result
711        .account
712        .ok_or_else(|| {
713            AlienError::new(ErrorData::InvalidClientConfig {
714                message: "Failed to infer AWS account ID from STS caller identity".to_string(),
715                errors: None,
716            })
717        })
718}
719
720fn profile_name(environment_variables: &HashMap<String, String>) -> String {
721    environment_variables
722        .get("AWS_PROFILE")
723        .or_else(|| environment_variables.get("AWS_DEFAULT_PROFILE"))
724        .cloned()
725        .unwrap_or_else(|| "default".to_string())
726}
727
728#[cfg(not(target_arch = "wasm32"))]
729fn load_profile_session_credentials(profile: &str) -> Result<AwsCredentials> {
730    let output = std::process::Command::new("aws")
731        .args([
732            "configure",
733            "export-credentials",
734            "--profile",
735            profile,
736            "--format",
737            "process",
738        ])
739        .output()
740        .into_alien_error()
741        .context(ErrorData::InvalidClientConfig {
742            message: format!("Failed to invoke AWS CLI for profile '{}'", profile),
743            errors: None,
744        })?;
745
746    if !output.status.success() {
747        return Err(AlienError::new(ErrorData::InvalidClientConfig {
748            message: format!(
749                "Failed to export AWS credentials for profile '{}': {}",
750                profile,
751                String::from_utf8_lossy(&output.stderr).trim()
752            ),
753            errors: None,
754        }));
755    }
756
757    let exported: AwsCliExportCredentials = serde_json::from_slice(&output.stdout)
758        .into_alien_error()
759        .context(ErrorData::InvalidClientConfig {
760            message: format!(
761                "Failed to parse exported AWS credentials for profile '{}'",
762                profile
763            ),
764            errors: None,
765        })?;
766
767    if let (Some(session_token), Some(expires_at)) = (exported.session_token, exported.expiration) {
768        Ok(AwsCredentials::SessionCredentials {
769            access_key_id: exported.access_key_id,
770            secret_access_key: exported.secret_access_key,
771            session_token,
772            expires_at,
773        })
774    } else {
775        Ok(AwsCredentials::AccessKeys {
776            access_key_id: exported.access_key_id,
777            secret_access_key: exported.secret_access_key,
778            session_token: None,
779        })
780    }
781}
782
783#[cfg(target_arch = "wasm32")]
784fn load_profile_session_credentials(profile: &str) -> Result<AwsCredentials> {
785    Err(AlienError::new(ErrorData::InvalidClientConfig {
786        message: format!(
787            "AWS_PROFILE ('{}') is not supported in wasm builds; provide explicit credentials",
788            profile
789        ),
790        errors: None,
791    }))
792}
793
794#[cfg(not(target_arch = "wasm32"))]
795fn load_profile_region(profile: &str) -> Result<Option<String>> {
796    let output = std::process::Command::new("aws")
797        .args(["configure", "get", "region", "--profile", profile])
798        .output()
799        .into_alien_error()
800        .context(ErrorData::InvalidClientConfig {
801            message: format!("Failed to invoke AWS CLI for profile '{}'", profile),
802            errors: None,
803        })?;
804
805    if !output.status.success() {
806        return Ok(None);
807    }
808
809    let region = String::from_utf8_lossy(&output.stdout).trim().to_string();
810    if region.is_empty() {
811        Ok(None)
812    } else {
813        Ok(Some(region))
814    }
815}
816
817#[cfg(target_arch = "wasm32")]
818fn load_profile_region(_profile: &str) -> Result<Option<String>> {
819    Ok(None)
820}
821
822#[derive(Debug, Deserialize)]
823#[serde(rename_all = "PascalCase")]
824struct AwsCliExportCredentials {
825    access_key_id: String,
826    secret_access_key: String,
827    session_token: Option<String>,
828    expiration: Option<String>,
829}
830
831/// Extract the AWS account ID from a role ARN.
832///
833/// Role ARNs follow the format `arn:aws:iam::{account_id}:role/{role_name}`.
834/// Returns `None` if the ARN doesn't match the expected format.
835fn extract_account_id_from_role_arn(role_arn: &str) -> Option<String> {
836    let parts: Vec<&str> = role_arn.split(':').collect();
837    if parts.len() >= 5 && !parts[4].is_empty() {
838        Some(parts[4].to_string())
839    } else {
840        None
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use std::collections::HashMap;
848    use tokio::{
849        io::{AsyncReadExt, AsyncWriteExt},
850        net::TcpListener,
851    };
852
853    #[test]
854    fn test_extract_account_id_from_role_arn() {
855        assert_eq!(
856            extract_account_id_from_role_arn("arn:aws:iam::123456789012:role/MyRole"),
857            Some("123456789012".to_string())
858        );
859        assert_eq!(
860            extract_account_id_from_role_arn("arn:aws:iam::987654321098:role/cross-account-role"),
861            Some("987654321098".to_string())
862        );
863        assert_eq!(extract_account_id_from_role_arn("invalid-arn"), None);
864        assert_eq!(
865            extract_account_id_from_role_arn("arn:aws:iam:::role/NoAccount"),
866            None
867        );
868    }
869
870    #[test]
871    fn test_profile_name_prefers_aws_profile() {
872        let mut env = HashMap::new();
873        env.insert("AWS_PROFILE".to_string(), "primary".to_string());
874        env.insert("AWS_DEFAULT_PROFILE".to_string(), "fallback".to_string());
875
876        assert_eq!(profile_name(&env), "primary".to_string());
877    }
878
879    #[test]
880    fn test_profile_name_falls_back_to_default() {
881        let env = HashMap::new();
882        assert_eq!(profile_name(&env), "default".to_string());
883    }
884
885    #[test]
886    fn test_profile_name_uses_aws_default_profile() {
887        let mut env = HashMap::new();
888        env.insert("AWS_DEFAULT_PROFILE".to_string(), "fallback".to_string());
889
890        assert_eq!(profile_name(&env), "fallback".to_string());
891    }
892
893    #[tokio::test]
894    async fn test_resolve_region_uses_default_region_fallback() {
895        let mut env = HashMap::new();
896        env.insert("AWS_DEFAULT_REGION".to_string(), "us-west-2".to_string());
897
898        assert_eq!(resolve_region(&env).await.unwrap(), "us-west-2");
899    }
900
901    #[test]
902    fn test_parse_service_overrides() {
903        let parsed =
904            parse_service_overrides(Some(&"{\"sts\":\"http://localhost:4566\"}".to_string()))
905                .unwrap()
906                .unwrap();
907
908        assert_eq!(
909            parsed.endpoints.get("sts"),
910            Some(&"http://localhost:4566".to_string())
911        );
912    }
913
914    #[tokio::test]
915    async fn test_resolve_credentials_prefers_explicit_keys() {
916        let mut env = HashMap::new();
917        env.insert("AWS_ACCESS_KEY_ID".to_string(), "AKIA123".to_string());
918        env.insert("AWS_SECRET_ACCESS_KEY".to_string(), "secret".to_string());
919        env.insert("AWS_SESSION_TOKEN".to_string(), "token".to_string());
920        env.insert("AWS_PROFILE".to_string(), "should-not-be-used".to_string());
921
922        let credentials = resolve_credentials(&env).await.unwrap();
923        assert_eq!(
924            credentials,
925            AwsCredentials::AccessKeys {
926                access_key_id: "AKIA123".to_string(),
927                secret_access_key: "secret".to_string(),
928                session_token: Some("token".to_string()),
929            }
930        );
931    }
932
933    #[tokio::test]
934    async fn test_resolve_credentials_ignores_empty_session_token() {
935        let mut env = HashMap::new();
936        env.insert("AWS_ACCESS_KEY_ID".to_string(), "AKIA123".to_string());
937        env.insert("AWS_SECRET_ACCESS_KEY".to_string(), "secret".to_string());
938        env.insert("AWS_SESSION_TOKEN".to_string(), "".to_string());
939
940        let credentials = resolve_credentials(&env).await.unwrap();
941        assert_eq!(
942            credentials,
943            AwsCredentials::AccessKeys {
944                access_key_id: "AKIA123".to_string(),
945                secret_access_key: "secret".to_string(),
946                session_token: None,
947            }
948        );
949    }
950
951    #[tokio::test]
952    async fn test_from_env_uses_imds_for_region_and_credentials() {
953        let endpoint = start_mock_imds().await;
954        let mut env = HashMap::new();
955        env.insert("AWS_ACCOUNT_ID".to_string(), "123456789012".to_string());
956        env.insert(
957            "AWS_EC2_METADATA_SERVICE_ENDPOINT".to_string(),
958            endpoint.clone(),
959        );
960
961        let config = AwsClientConfig::from_env(&env).await.unwrap();
962
963        assert_eq!(config.region, "us-east-1");
964        // Discovery validates the IMDS credential document (the mock would
965        // reject a parse failure), but the stored credential stays deferred:
966        // role credentials expire, so they are resolved at use time.
967        assert_eq!(
968            config.credentials,
969            AwsCredentials::Imds {
970                endpoint: Some(endpoint),
971            }
972        );
973    }
974
975    async fn start_mock_imds() -> String {
976        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
977        let addr = listener.local_addr().unwrap();
978
979        tokio::spawn(async move {
980            loop {
981                let Ok((mut stream, _)) = listener.accept().await else {
982                    break;
983                };
984
985                tokio::spawn(async move {
986                    let mut buffer = [0u8; 2048];
987                    let Ok(n) = stream.read(&mut buffer).await else {
988                        return;
989                    };
990                    let request = String::from_utf8_lossy(&buffer[..n]);
991                    let body = if request.starts_with("PUT /latest/api/token ") {
992                        "token".to_string()
993                    } else if request.starts_with("GET /latest/meta-data/placement/region ") {
994                        "us-east-1".to_string()
995                    } else if request
996                        .starts_with("GET /latest/meta-data/iam/security-credentials/ ")
997                    {
998                        "test-role".to_string()
999                    } else if request
1000                        .starts_with("GET /latest/meta-data/iam/security-credentials/test-role ")
1001                    {
1002                        // Real IMDS role credentials always carry an Expiration.
1003                        r#"{"AccessKeyId":"AKIAIMDS","SecretAccessKey":"secret","Token":"session","Expiration":"2099-01-01T00:00:00Z"}"#
1004                            .to_string()
1005                    } else {
1006                        let response =
1007                            "HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\n\r\n".to_string();
1008                        let _ = stream.write_all(response.as_bytes()).await;
1009                        return;
1010                    };
1011
1012                    let response = format!(
1013                        "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}",
1014                        body.len(),
1015                        body
1016                    );
1017                    let _ = stream.write_all(response.as_bytes()).await;
1018                });
1019            }
1020        });
1021
1022        format!("http://{}", addr)
1023    }
1024}