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
7pub use alien_core::{
9 AwsClientConfig, AwsCredentials, AwsImpersonationConfig,
10 AwsServiceOverrides as ServiceOverrides, AwsWebIdentityConfig,
11};
12
13#[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 async fn from_env(environment_variables: &HashMap<String, String>) -> Result<AwsClientConfig>;
19
20 async fn from_std_env() -> Result<AwsClientConfig>;
22
23 async fn impersonate(&self, config: AwsImpersonationConfig) -> Result<AwsClientConfig>;
25
26 fn get_credentials(&self) -> Credentials;
28
29 async fn get_web_identity_credentials(&self) -> Result<AwsClientConfig>;
31
32 fn get_service_endpoint(&self, service_name: &str, default_endpoint: &str) -> String;
34
35 fn get_service_endpoint_option(&self, service_name: &str) -> Option<&str>;
37
38 #[cfg(any(test, feature = "test-utils"))]
40 fn with_service_overrides(self, overrides: ServiceOverrides) -> Self;
41
42 #[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 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 ®ion,
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 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 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 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 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 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 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 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 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 #[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 #[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(®ion_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
831fn 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 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 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}