1pub mod eventstream;
10pub mod ini;
11pub mod sigv4;
12
13use std::collections::HashMap;
14use std::sync::{Mutex, OnceLock};
15use std::time::Duration;
16
17use indexmap::IndexMap;
18use serde_json::Value;
19use tokio_util::sync::CancellationToken;
20
21use crate::auth::now_ms;
22use crate::credentials::ProviderEnv;
23use yapi_types::time::parse_iso;
24
25#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct Credentials {
28 pub access_key_id: String,
30 pub secret_access_key: String,
32 pub session_token: Option<String>,
34 pub expiration_ms: Option<u64>,
36}
37
38#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct AwsEnv(IndexMap<String, String>);
42
43impl AwsEnv {
44 pub fn from_process(overrides: Option<&ProviderEnv>) -> AwsEnv {
47 let mut vars: IndexMap<String, String> = std::env::vars()
48 .filter(|(name, _)| name.starts_with("AWS_") || name == "HOME" || name == "USERPROFILE")
49 .collect();
50 for (name, value) in overrides.into_iter().flatten() {
51 if !value.is_empty() {
52 vars.insert(name.clone(), value.clone());
53 }
54 }
55 AwsEnv(vars)
56 }
57
58 pub fn from_vars<'a>(vars: impl IntoIterator<Item = (&'a str, &'a str)>) -> AwsEnv {
60 AwsEnv(
61 vars.into_iter()
62 .map(|(name, value)| (name.to_owned(), value.to_owned()))
63 .collect(),
64 )
65 }
66
67 pub fn get(&self, name: &str) -> Option<&str> {
69 self.0
70 .get(name)
71 .map(String::as_str)
72 .filter(|value| !value.is_empty())
73 }
74}
75
76struct ChainError {
79 message: String,
80 next: bool,
81}
82
83impl ChainError {
84 fn next(message: impl Into<String>) -> ChainError {
85 ChainError {
86 message: message.into(),
87 next: true,
88 }
89 }
90
91 fn fatal(message: impl Into<String>) -> ChainError {
92 ChainError {
93 message: message.into(),
94 next: false,
95 }
96 }
97}
98
99impl From<String> for ChainError {
100 fn from(message: String) -> ChainError {
101 ChainError::fatal(message)
102 }
103}
104
105type Chained = Result<Credentials, ChainError>;
106
107const EXPIRY_WINDOW_MS: u64 = 5 * 60 * 1000;
110
111fn cache() -> &'static Mutex<HashMap<String, Credentials>> {
112 static CACHE: OnceLock<Mutex<HashMap<String, Credentials>>> = OnceLock::new();
113 CACHE.get_or_init(Default::default)
114}
115
116pub async fn default_credentials(
120 env: &AwsEnv,
121 profile: Option<&str>,
122 region: Option<&str>,
123 cancel: &CancellationToken,
124) -> Result<Credentials, String> {
125 let key = format!("{profile:?}\0{region:?}\0{:?}", env.0);
126 if let Ok(cache) = cache().lock()
127 && let Some(credentials) = cache.get(&key)
128 && credentials
129 .expiration_ms
130 .is_none_or(|expires| expires > now_ms() + EXPIRY_WINDOW_MS)
131 {
132 return Ok(credentials.clone());
133 }
134 let credentials = chain(env, profile, region, cancel).await?;
135 if let Ok(mut cache) = cache().lock() {
136 cache.insert(key, credentials.clone());
137 }
138 Ok(credentials)
139}
140
141async fn chain(
142 env: &AwsEnv,
143 profile: Option<&str>,
144 region: Option<&str>,
145 cancel: &CancellationToken,
146) -> Result<Credentials, String> {
147 let profile_name = profile
148 .or_else(|| env.get("AWS_PROFILE"))
149 .unwrap_or("default")
150 .to_owned();
151 let profile_set = profile.is_some() || env.get("AWS_PROFILE").is_some();
152 let mut last = String::new();
153 for step in 0..5 {
154 let result = match step {
155 0 if profile_set => Err(ChainError::next(
156 "AWS_PROFILE is set, skipping fromEnv provider.",
157 )),
158 0 => from_env(env),
159 1 => from_ini(env, &profile_name, region, cancel).await,
160 2 => from_token_file(env, region, cancel).await,
161 3 => remote(env, cancel).await,
162 _ => Err(ChainError::fatal(
163 "Could not load credentials from any providers",
164 )),
165 };
166 match result {
167 Ok(credentials) => return Ok(credentials),
168 Err(err) if err.next => last = err.message,
169 Err(err) => return Err(err.message),
170 }
171 }
172 Err(last)
173}
174
175fn from_env(env: &AwsEnv) -> Chained {
178 match (
179 env.get("AWS_ACCESS_KEY_ID"),
180 env.get("AWS_SECRET_ACCESS_KEY"),
181 ) {
182 (Some(id), Some(secret)) => Ok(Credentials {
183 access_key_id: id.to_owned(),
184 secret_access_key: secret.to_owned(),
185 session_token: env.get("AWS_SESSION_TOKEN").map(str::to_owned),
186 expiration_ms: env.get("AWS_CREDENTIAL_EXPIRATION").and_then(parse_iso),
187 }),
188 _ => Err(ChainError::next(
189 "Unable to find environment variable credentials.",
190 )),
191 }
192}
193
194async fn from_ini(
195 env: &AwsEnv,
196 profile: &str,
197 region: Option<&str>,
198 cancel: &CancellationToken,
199) -> Chained {
200 let profiles = ini::load(env).await;
201 resolve_profile(env, &profiles, profile, region, Vec::new(), false, cancel).await
202}
203
204type Section = IndexMap<String, String>;
205
206fn is_static(data: &Section) -> bool {
207 data.contains_key("aws_access_key_id") && data.contains_key("aws_secret_access_key")
208}
209
210fn static_credentials(data: &Section) -> Credentials {
211 Credentials {
212 access_key_id: data["aws_access_key_id"].clone(),
213 secret_access_key: data["aws_secret_access_key"].clone(),
214 session_token: data.get("aws_session_token").cloned(),
215 expiration_ms: None,
216 }
217}
218
219fn is_assume_role(data: &Section) -> bool {
220 data.contains_key("role_arn")
221 && (data.contains_key("source_profile") != data.contains_key("credential_source"))
222}
223
224fn credential_source_without_role(data: &Section) -> bool {
225 !data.contains_key("role_arn") && data.contains_key("credential_source")
226}
227
228fn resolve_profile<'a>(
231 env: &'a AwsEnv,
232 profiles: &'a ini::Profiles,
233 name: &'a str,
234 region: Option<&'a str>,
235 visited: Vec<String>,
236 assume_role_source: bool,
237 cancel: &'a CancellationToken,
238) -> futures_util::future::BoxFuture<'a, Chained> {
239 Box::pin(async move {
240 let empty = Section::new();
241 let data = profiles.profiles.get(name).unwrap_or(&empty);
242 if !visited.is_empty() && is_static(data) {
243 return Ok(static_credentials(data));
244 }
245 if assume_role_source || is_assume_role(data) {
246 return assume_role(env, profiles, name, data, region, visited, cancel).await;
247 }
248 if is_static(data) {
249 return Ok(static_credentials(data));
250 }
251 if let (Some(token_file), Some(role)) =
252 (data.get("web_identity_token_file"), data.get("role_arn"))
253 {
254 let token = read_token_file(token_file).await?;
255 let session = data.get("role_session_name").cloned();
256 return Ok(
257 assume_role_with_web_identity(env, role, session, &token, region, cancel).await?,
258 );
259 }
260 if let Some(command) = data.get("credential_process") {
261 return Ok(credential_process(name, command, cancel).await?);
262 }
263 if [
264 "sso_start_url",
265 "sso_account_id",
266 "sso_session",
267 "sso_region",
268 "sso_role_name",
269 ]
270 .iter()
271 .any(|key| data.contains_key(*key))
272 {
273 return Ok(sso(env, profiles, name, data, cancel).await?);
274 }
275 if data.contains_key("login_session") {
276 return Err(ChainError::fatal(format!(
277 "Profile {name} uses console login credentials, which yapi cannot read yet. Run `aws configure export-credentials` or use another credential source."
278 )));
279 }
280 Err(ChainError::next(format!(
281 "Could not resolve credentials using profile: [{name}] in configuration/credentials file(s)."
282 )))
283 })
284}
285
286async fn assume_role(
287 env: &AwsEnv,
288 profiles: &ini::Profiles,
289 name: &str,
290 data: &Section,
291 region: Option<&str>,
292 visited: Vec<String>,
293 cancel: &CancellationToken,
294) -> Chained {
295 let region = data.get("region").map(String::as_str).or(region);
296 let source = if let Some(source) = data.get("source_profile") {
297 if visited.contains(source) {
298 return Err(ChainError::fatal(format!(
299 "Detected a cycle attempting to resolve credentials for profile {name}. Profiles visited: {}",
300 visited.join(", ")
301 )));
302 }
303 let mut next = visited.clone();
304 next.push(source.clone());
305 let recursive = profiles
306 .profiles
307 .get(source)
308 .is_some_and(credential_source_without_role);
309 resolve_profile(env, profiles, source, region, next, recursive, cancel).await?
310 } else {
311 let source = data
312 .get("credential_source")
313 .map(String::as_str)
314 .unwrap_or_default();
315 match source {
316 "EcsContainer" => container(env, cancel).await?,
317 "Ec2InstanceMetadata" => instance_metadata(env, cancel).await?,
318 "Environment" => from_env(env)?,
319 other => {
320 return Err(ChainError::fatal(format!(
321 "Unsupported credential source in profile {name}. Got {other}, expected EcsContainer or Ec2InstanceMetadata or Environment."
322 )));
323 }
324 }
325 };
326 if credential_source_without_role(data) {
327 return Ok(source);
328 }
329 if data.contains_key("mfa_serial") {
330 return Err(ChainError::fatal(format!(
331 "Profile {name} requires multi-factor authentication, but no MFA code callback was provided."
332 )));
333 }
334 let session = data
335 .get("role_session_name")
336 .cloned()
337 .unwrap_or_else(|| format!("aws-sdk-js-{}", now_ms()));
338 let mut params = vec![
339 ("Action", "AssumeRole".to_owned()),
340 ("Version", "2011-06-15".to_owned()),
341 ("RoleArn", data["role_arn"].clone()),
342 ("RoleSessionName", session),
343 ];
344 if let Some(external) = data.get("external_id") {
345 params.push(("ExternalId", external.clone()));
346 }
347 params.push((
348 "DurationSeconds",
349 data.get("duration_seconds")
350 .cloned()
351 .unwrap_or_else(|| "3600".into()),
352 ));
353 Ok(sts(env, params, Some(&source), region, cancel).await?)
354}
355
356async fn read_token_file(path: &str) -> Result<String, ChainError> {
357 tokio::fs::read_to_string(path)
358 .await
359 .map(|text| text.trim().to_owned())
360 .map_err(|err| {
361 ChainError::fatal(format!(
362 "Failed to read web identity token file {path}: {err}"
363 ))
364 })
365}
366
367async fn from_token_file(
370 env: &AwsEnv,
371 region: Option<&str>,
372 cancel: &CancellationToken,
373) -> Chained {
374 let (Some(file), Some(role)) = (
375 env.get("AWS_WEB_IDENTITY_TOKEN_FILE"),
376 env.get("AWS_ROLE_ARN"),
377 ) else {
378 return Err(ChainError::next("Web identity configuration not specified"));
379 };
380 let token = read_token_file(file).await?;
381 let session = env.get("AWS_ROLE_SESSION_NAME").map(str::to_owned);
382 Ok(assume_role_with_web_identity(env, role, session, &token, region, cancel).await?)
383}
384
385async fn assume_role_with_web_identity(
386 env: &AwsEnv,
387 role: &str,
388 session: Option<String>,
389 token: &str,
390 region: Option<&str>,
391 cancel: &CancellationToken,
392) -> Result<Credentials, String> {
393 let params = vec![
394 ("Action", "AssumeRoleWithWebIdentity".to_owned()),
395 ("Version", "2011-06-15".to_owned()),
396 ("RoleArn", role.to_owned()),
397 (
398 "RoleSessionName",
399 session.unwrap_or_else(|| format!("aws-sdk-js-session-{}", now_ms())),
400 ),
401 ("WebIdentityToken", token.to_owned()),
402 ];
403 sts(env, params, None, region, cancel).await
404}
405
406fn xml_text(xml: &str, tag: &str) -> Option<String> {
408 let start = xml.find(&format!("<{tag}>"))? + tag.len() + 2;
409 let end = start + xml[start..].find(&format!("</{tag}>"))?;
410 Some(
411 xml[start..end]
412 .replace("<", "<")
413 .replace(">", ">")
414 .replace(""", "\"")
415 .replace("'", "'")
416 .replace("&", "&"),
417 )
418}
419
420fn sts_endpoint(env: &AwsEnv, region: &str) -> String {
423 env.get("AWS_ENDPOINT_URL_STS")
424 .or_else(|| env.get("AWS_ENDPOINT_URL"))
425 .map_or_else(
426 || {
427 let suffix = if region.starts_with("cn-") { ".cn" } else { "" };
428 format!("https://sts.{region}.amazonaws.com{suffix}")
429 },
430 str::to_owned,
431 )
432}
433
434async fn sts(
436 env: &AwsEnv,
437 params: Vec<(&str, String)>,
438 signer: Option<&Credentials>,
439 region: Option<&str>,
440 cancel: &CancellationToken,
441) -> Result<Credentials, String> {
442 let region = region.unwrap_or("us-east-1");
443 let endpoint = sts_endpoint(env, region);
444 let pairs: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
445 let body = crate::auth::form(&pairs);
446 let url = url::Url::parse(&endpoint).map_err(|err| err.to_string())?;
447 let mut headers = vec![(
448 "content-type".to_owned(),
449 "application/x-www-form-urlencoded".to_owned(),
450 )];
451 if let Some(credentials) = signer {
452 sigv4::sign_request(
453 &url,
454 &mut headers,
455 body.as_bytes(),
456 credentials,
457 region,
458 "sts",
459 );
460 }
461 let mut request = crate::http::client().post(url.as_str()).body(body);
462 for (name, value) in &headers {
463 request = request.header(name.as_str(), value.as_str());
464 }
465 let response = send(request, cancel).await?;
466 let status = response.status().as_u16();
467 let text = response.text().await.unwrap_or_default();
468 if !(200..300).contains(&status) {
469 let code = xml_text(&text, "Code").unwrap_or_else(|| "Unknown".into());
470 let message = xml_text(&text, "Message").unwrap_or_else(|| format!("HTTP {status}"));
471 return Err(format!("{code}: {message}"));
472 }
473 Ok(Credentials {
474 access_key_id: xml_text(&text, "AccessKeyId").ok_or("STS returned no AccessKeyId")?,
475 secret_access_key: xml_text(&text, "SecretAccessKey")
476 .ok_or("STS returned no SecretAccessKey")?,
477 session_token: xml_text(&text, "SessionToken"),
478 expiration_ms: xml_text(&text, "Expiration").as_deref().and_then(parse_iso),
479 })
480}
481
482async fn send(
483 request: reqwest::RequestBuilder,
484 cancel: &CancellationToken,
485) -> Result<reqwest::Response, String> {
486 crate::auth::send(request, cancel)
487 .await
488 .map_err(|err| err.to_string())
489}
490
491async fn credential_process(
494 profile: &str,
495 command: &str,
496 cancel: &CancellationToken,
497) -> Result<Credentials, String> {
498 let run = tokio::process::Command::new("sh")
499 .arg("-c")
500 .arg(command)
501 .stdin(std::process::Stdio::null())
502 .output();
503 let Some(output) = cancel.run_until_cancelled(run).await else {
504 return Err(crate::http::ABORTED_BEFORE_RESPONSE.into());
505 };
506 let output = output.map_err(|err| err.to_string())?;
507 if !output.status.success() {
508 return Err(format!(
509 "Command failed: {command}\n{}",
510 String::from_utf8_lossy(&output.stderr)
511 ));
512 }
513 let data: Value =
514 serde_json::from_slice(String::from_utf8_lossy(&output.stdout).trim().as_bytes())
515 .map_err(|_| format!("Profile {profile} credential_process returned invalid JSON."))?;
516 if data["Version"].as_u64() != Some(1) {
517 return Err(format!(
518 "Profile {profile} credential_process did not return Version 1."
519 ));
520 }
521 let (Some(id), Some(secret)) = (
522 data["AccessKeyId"].as_str(),
523 data["SecretAccessKey"].as_str(),
524 ) else {
525 return Err(format!(
526 "Profile {profile} credential_process returned invalid credentials."
527 ));
528 };
529 let expiration_ms = data["Expiration"].as_str().and_then(parse_iso);
530 if expiration_ms.is_some_and(|expires| expires < now_ms()) {
531 return Err(format!(
532 "Profile {profile} credential_process returned expired credentials."
533 ));
534 }
535 Ok(Credentials {
536 access_key_id: id.to_owned(),
537 secret_access_key: secret.to_owned(),
538 session_token: data["SessionToken"].as_str().map(str::to_owned),
539 expiration_ms,
540 })
541}
542
543const SSO_REFRESH: &str =
544 "To refresh this SSO session run aws sso login with the corresponding profile.";
545
546async fn sso(
549 env: &AwsEnv,
550 profiles: &ini::Profiles,
551 name: &str,
552 data: &Section,
553 cancel: &CancellationToken,
554) -> Result<Credentials, String> {
555 let session = data.get("sso_session");
556 let session_data = session.and_then(|session| profiles.sso_sessions.get(session));
557 let setting = |key: &str| {
558 data.get(key)
559 .or_else(|| session_data.and_then(|session| session.get(key)))
560 .cloned()
561 };
562 let (Some(account), Some(role), Some(region)) = (
563 setting("sso_account_id"),
564 setting("sso_role_name"),
565 setting("sso_region"),
566 ) else {
567 return Err(format!(
568 "Profile is configured with invalid SSO credentials. Required parameters \"sso_account_id\", \"sso_region\", \"sso_role_name\", \"sso_start_url\". Got {}\nReference: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html",
569 data.keys().cloned().collect::<Vec<_>>().join(", ")
570 ));
571 };
572 let cache_id = match session {
573 Some(session) => session.clone(),
574 None => setting("sso_start_url").unwrap_or_default(),
575 };
576 let file = ini::home(env).join(".aws/sso/cache").join(format!(
577 "{}.json",
578 yapi_types::time::hex(
579 aws_lc_rs::digest::digest(
580 &aws_lc_rs::digest::SHA1_FOR_LEGACY_USE_ONLY,
581 cache_id.as_bytes()
582 )
583 .as_ref()
584 )
585 ));
586 let invalid =
587 || format!("The SSO session associated with this profile is invalid. {SSO_REFRESH}");
588 let token: Value = tokio::fs::read_to_string(&file)
589 .await
590 .ok()
591 .and_then(|text| serde_json::from_str(&text).ok())
592 .ok_or_else(invalid)?;
593 let access = token["accessToken"].as_str().ok_or_else(invalid)?;
594 let expires = token["expiresAt"].as_str().and_then(parse_iso).unwrap_or(0);
595 if expires <= now_ms() {
596 return Err(format!(
597 "The SSO session associated with this profile has expired. {SSO_REFRESH}"
598 ));
599 }
600 let base = env
601 .get("AWS_ENDPOINT_URL_SSO")
602 .or_else(|| env.get("AWS_ENDPOINT_URL"))
603 .map_or_else(
604 || format!("https://portal.sso.{region}.amazonaws.com"),
605 str::to_owned,
606 );
607 let mut url = url::Url::parse(&format!("{base}/federation/credentials"))
608 .map_err(|err| err.to_string())?;
609 url.query_pairs_mut()
610 .append_pair("role_name", &role)
611 .append_pair("account_id", &account);
612 let request = crate::http::client()
613 .get(url.as_str())
614 .header("x-amz-sso_bearer_token", access);
615 let response = send(request, cancel).await?;
616 let status = response.status().as_u16();
617 let body = crate::auth::json_body(response).await;
618 if !(200..300).contains(&status) {
619 return Err(body["message"].as_str().map_or_else(
620 || format!("SSO GetRoleCredentials failed with status {status} for profile {name}"),
621 str::to_owned,
622 ));
623 }
624 let credentials = &body["roleCredentials"];
625 match (
626 credentials["accessKeyId"].as_str(),
627 credentials["secretAccessKey"].as_str(),
628 credentials["sessionToken"].as_str(),
629 credentials["expiration"].as_u64(),
630 ) {
631 (Some(id), Some(secret), Some(token), Some(expiration)) => Ok(Credentials {
632 access_key_id: id.to_owned(),
633 secret_access_key: secret.to_owned(),
634 session_token: Some(token.to_owned()),
635 expiration_ms: Some(expiration),
636 }),
637 _ => Err("SSO returns an invalid temporary credential.".into()),
638 }
639}
640
641const METADATA_TIMEOUT: Duration = Duration::from_secs(1);
643
644fn metadata_client() -> Result<reqwest::Client, String> {
647 reqwest::Client::builder()
648 .no_proxy()
649 .user_agent(crate::http::user_agent())
650 .build()
651 .map_err(|err| err.to_string())
652}
653
654fn container_url_accepted(url: &url::Url) -> bool {
657 use std::net::{Ipv4Addr, Ipv6Addr};
658 url.scheme() == "https"
659 || match url.host() {
660 Some(url::Host::Domain(host)) => host == "localhost",
661 Some(url::Host::Ipv4(ip)) => {
662 ip.is_loopback()
663 || ip == Ipv4Addr::new(169, 254, 170, 2)
664 || ip == Ipv4Addr::new(169, 254, 170, 23)
665 }
666 Some(url::Host::Ipv6(ip)) => {
667 ip.is_loopback() || ip == Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x23)
668 }
669 None => false,
670 }
671}
672
673async fn remote(env: &AwsEnv, cancel: &CancellationToken) -> Chained {
676 if env.get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI").is_some()
677 || env.get("AWS_CONTAINER_CREDENTIALS_FULL_URI").is_some()
678 {
679 return container(env, cancel).await.map_err(ChainError::next);
680 }
681 if env
682 .get("AWS_EC2_METADATA_DISABLED")
683 .is_some_and(|value| value != "false")
684 {
685 return Err(ChainError::next(
686 "EC2 Instance Metadata Service access disabled",
687 ));
688 }
689 instance_metadata(env, cancel)
690 .await
691 .map_err(ChainError::next)
692}
693
694fn metadata_credentials(body: &Value) -> Result<Credentials, String> {
696 match (
697 body["AccessKeyId"].as_str(),
698 body["SecretAccessKey"].as_str(),
699 ) {
700 (Some(id), Some(secret)) => Ok(Credentials {
701 access_key_id: id.to_owned(),
702 secret_access_key: secret.to_owned(),
703 session_token: body["Token"].as_str().map(str::to_owned),
704 expiration_ms: body["Expiration"].as_str().and_then(parse_iso),
705 }),
706 _ => Err("Invalid response received from instance metadata service.".into()),
707 }
708}
709
710async fn container(env: &AwsEnv, cancel: &CancellationToken) -> Result<Credentials, String> {
712 let url = match (
713 env.get("AWS_CONTAINER_CREDENTIALS_FULL_URI"),
714 env.get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"),
715 ) {
716 (_, Some(relative)) => format!("http://169.254.170.2{relative}"),
717 (Some(full), None) => full.to_owned(),
718 (None, None) => {
719 return Err("No container credentials endpoint is configured".into());
720 }
721 };
722 let url = url::Url::parse(&url).map_err(|err| err.to_string())?;
723 if !container_url_accepted(&url) {
724 return Err(
725 "URL not accepted. It must either be HTTPS or match one of the following:
726 - loopback CIDR 127.0.0.0/8 or [::1/128]
727 - ECS container host 169.254.170.2
728 - EKS container host 169.254.170.23 or [fd00:ec2::23]"
729 .into(),
730 );
731 }
732 let mut request = metadata_client()?.get(url).timeout(METADATA_TIMEOUT);
733 let token = match env.get("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE") {
734 Some(file) => tokio::fs::read_to_string(file)
735 .await
736 .ok()
737 .map(|text| text.trim().to_owned()),
738 None => env
739 .get("AWS_CONTAINER_AUTHORIZATION_TOKEN")
740 .map(str::to_owned),
741 };
742 if let Some(token) = token {
743 request = request.header("authorization", token);
744 }
745 let response = send(request, cancel).await?;
746 let body = crate::auth::json_body(response).await;
747 metadata_credentials(&body)
748}
749
750async fn instance_metadata(
753 env: &AwsEnv,
754 cancel: &CancellationToken,
755) -> Result<Credentials, String> {
756 let base = env
757 .get("AWS_EC2_METADATA_SERVICE_ENDPOINT")
758 .unwrap_or("http://169.254.169.254")
759 .trim_end_matches('/')
760 .to_owned();
761 let failed = |_| "Could not load credentials from the instance metadata service".to_owned();
762 let client = metadata_client()?;
763 let token = send(
764 client
765 .put(format!("{base}/latest/api/token"))
766 .header("x-aws-ec2-metadata-token-ttl-seconds", "21600")
767 .timeout(METADATA_TIMEOUT),
768 cancel,
769 )
770 .await
771 .map_err(failed)?
772 .text()
773 .await
774 .unwrap_or_default();
775 let get = |path: String| {
776 client
777 .get(format!("{base}{path}"))
778 .header("x-aws-ec2-metadata-token", token.clone())
779 .timeout(METADATA_TIMEOUT)
780 };
781 let names = send(
782 get("/latest/meta-data/iam/security-credentials/".into()),
783 cancel,
784 )
785 .await
786 .map_err(failed)?
787 .text()
788 .await
789 .unwrap_or_default();
790 let role = names
791 .lines()
792 .next()
793 .filter(|role| !role.is_empty())
794 .ok_or_else(|| "No instance profile is attached to this instance".to_owned())?;
795 let response = send(
796 get(format!("/latest/meta-data/iam/security-credentials/{role}")),
797 cancel,
798 )
799 .await
800 .map_err(failed)?;
801 let body = crate::auth::json_body(response).await;
802 metadata_credentials(&body)
803}
804
805pub async fn profile_region(env: &AwsEnv, profile: Option<&str>) -> Option<String> {
808 let name = profile
809 .or_else(|| env.get("AWS_PROFILE"))
810 .unwrap_or("default");
811 ini::load(env)
812 .await
813 .profiles
814 .get(name)
815 .and_then(|data| data.get("region"))
816 .cloned()
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 fn scratch(name: &str) -> std::path::PathBuf {
824 let dir = std::env::temp_dir().join(format!("yapi-aws-{name}-{}", std::process::id()));
825 let _ = std::fs::remove_dir_all(&dir);
826 std::fs::create_dir_all(dir.join(".aws")).unwrap();
827 dir
828 }
829
830 #[tokio::test]
831 async fn profiles_ignore_process_env() {
832 let home = scratch("profiles");
833 std::fs::write(
834 home.join(".aws/credentials"),
835 "[default]\naws_access_key_id = AKIDDEFAULT\naws_secret_access_key = s1\n\n[static]\naws_access_key_id = AKIDSTATIC\naws_secret_access_key = s2\naws_session_token = t2\n",
836 )
837 .unwrap();
838 std::fs::write(
839 home.join(".aws/config"),
840 "[profile proc]\ncredential_process = printf '{\"Version\":1,\"AccessKeyId\":\"AKIDPROC\",\"SecretAccessKey\":\"s3\"}'\n\n[profile loop]\nrole_arn = arn:aws:iam::1:role/r\nsource_profile = loop\n\n[profile mfa]\nrole_arn = arn:aws:iam::1:role/r\nsource_profile = static\nmfa_serial = arn:aws:iam::1:mfa/me\n\n[profile region-only]\nregion = eu-west-3\n",
841 )
842 .unwrap();
843 let home_str = home.to_str().unwrap();
844 let env = AwsEnv::from_vars([("HOME", home_str), ("AWS_EC2_METADATA_DISABLED", "true")]);
845 let cancel = CancellationToken::new();
846 let id = |profile: Option<&'static str>| {
847 let env = env.clone();
848 let cancel = cancel.clone();
849 async move {
850 chain(&env, profile, None, &cancel)
851 .await
852 .map(|credentials| credentials.access_key_id)
853 }
854 };
855 assert_eq!(id(None).await.as_deref(), Ok("AKIDDEFAULT"));
856 assert_eq!(id(Some("static")).await.as_deref(), Ok("AKIDSTATIC"));
857 assert_eq!(id(Some("proc")).await.as_deref(), Ok("AKIDPROC"));
858 assert!(
859 id(Some("loop"))
860 .await
861 .unwrap_err()
862 .contains("Detected a cycle")
863 );
864 assert!(
865 id(Some("mfa"))
866 .await
867 .unwrap_err()
868 .contains("requires multi-factor authentication")
869 );
870 assert_eq!(
871 id(Some("missing")).await.unwrap_err(),
872 "Could not load credentials from any providers"
873 );
874 let keyed = AwsEnv::from_vars([
876 ("HOME", home_str),
877 ("AWS_ACCESS_KEY_ID", "AKIDENV"),
878 ("AWS_SECRET_ACCESS_KEY", "s4"),
879 ]);
880 assert_eq!(
881 chain(&keyed, None, None, &cancel)
882 .await
883 .unwrap()
884 .access_key_id,
885 "AKIDENV"
886 );
887 assert_eq!(
888 chain(&keyed, Some("static"), None, &cancel)
889 .await
890 .unwrap()
891 .access_key_id,
892 "AKIDSTATIC"
893 );
894 assert_eq!(
895 profile_region(&env, Some("region-only")).await.as_deref(),
896 Some("eu-west-3")
897 );
898 std::fs::remove_dir_all(&home).unwrap();
899 }
900
901 #[tokio::test]
902 async fn credential_process_stderr() {
903 let command = "echo oops >&2; exit 1";
904 let error = credential_process("proc", command, &CancellationToken::new())
905 .await
906 .err();
907 assert_eq!(error, Some(format!("Command failed: {command}\noops\n")));
908 }
909
910 #[tokio::test]
911 async fn container_urls_checked() {
912 let remote = AwsEnv::from_vars([(
913 "AWS_CONTAINER_CREDENTIALS_FULL_URI",
914 "http://example.com/credentials",
915 )]);
916 let error = container(&remote, &CancellationToken::new()).await;
917 assert!(error.unwrap_err().starts_with("URL not accepted."));
918 for (url, accepted) in [
919 ("https://example.com/", true),
920 ("http://localhost:8080/", true),
921 ("http://127.1.2.3/", true),
922 ("http://[::1]/", true),
923 ("http://169.254.170.2/", true),
924 ("http://169.254.170.23/", true),
925 ("http://[fd00:ec2::23]/", true),
926 ("http://example.com/", false),
927 ("http://169.254.169.254/", false),
928 ("http://[::ffff:127.0.0.1]/", false),
929 ] {
930 let url = url::Url::parse(url).unwrap();
931 assert_eq!(container_url_accepted(&url), accepted, "{url}");
932 }
933 }
934
935 #[test]
936 fn reads_sts_xml() {
937 let xml = "<AssumeRoleResponse><AssumeRoleResult><Credentials><AccessKeyId>ASIA</AccessKeyId><SecretAccessKey>s&k</SecretAccessKey><SessionToken>tok</SessionToken><Expiration>2026-10-04T20:00:00Z</Expiration></Credentials></AssumeRoleResult></AssumeRoleResponse>";
938 assert_eq!(xml_text(xml, "SecretAccessKey").as_deref(), Some("s&k"));
939 assert_eq!(xml_text(xml, "Missing"), None);
940 }
941}