Skip to main content

yapi_ai/aws/
mod.rs

1//! AWS credentials, request signing and event streams for Amazon Bedrock.
2//!
3//! The credential chain ports the AWS SDK for JavaScript v3's
4//! `defaultProvider`, which pi `v1.0.0`'s Bedrock provider uses: environment
5//! keys, the shared `config` and `credentials` files (static keys, assumed
6//! roles, web identity, `credential_process` and IAM Identity Center), a web
7//! identity token file, then container or instance metadata.
8
9pub 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/// AWS credentials.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct Credentials {
28    /// Access key id.
29    pub access_key_id: String,
30    /// Secret access key.
31    pub secret_access_key: String,
32    /// Session token of temporary credentials.
33    pub session_token: Option<String>,
34    /// Expiry as Unix time in milliseconds.
35    pub expiration_ms: Option<u64>,
36}
37
38/// The environment the credential chain reads: the request's provider
39/// settings over the process environment.
40#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct AwsEnv(IndexMap<String, String>);
42
43impl AwsEnv {
44    /// The process's `AWS_*`, `HOME` and `USERPROFILE` variables, with
45    /// `overrides` on top.
46    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    /// Exactly these variables, for tests.
59    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    /// A non-empty variable.
68    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
76/// Why credentials could not be found. `next` lets the chain try its next
77/// source, as the SDK's `tryNextLink`.
78struct 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
107/// Credentials that expire within five minutes are fetched again, as the
108/// SDK's `credentialsTreatedAsExpired`.
109const 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
116/// The default credential chain for `profile` (else `AWS_PROFILE`), with
117/// `region` for Security Token Service (STS) calls. Results are cached until
118/// five minutes before they expire.
119pub 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
175/// `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` and
176/// `AWS_CREDENTIAL_EXPIRATION`.
177fn 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
228/// The SDK's `resolveProfileData`: one profile's credentials, following
229/// `source_profile` chains.
230fn 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
367/// `AWS_WEB_IDENTITY_TOKEN_FILE` with `AWS_ROLE_ARN`, as IAM roles for service
368/// accounts (IRSA) set them.
369async 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
406/// The text of the first `<tag>` element.
407fn 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("&lt;", "<")
413            .replace("&gt;", ">")
414            .replace("&quot;", "\"")
415            .replace("&apos;", "'")
416            .replace("&amp;", "&"),
417    )
418}
419
420/// The STS endpoint: `AWS_ENDPOINT_URL_STS`, else `AWS_ENDPOINT_URL`, else
421/// the regional one.
422fn 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
434/// An STS query call, signed with `signer` when given.
435async 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
491/// A profile's `credential_process`: run through the shell, its JSON output
492/// parsed as version 1 credentials.
493async 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
546/// IAM Identity Center: the cached `aws sso login` token exchanged for role
547/// credentials.
548async 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
641/// Metadata services answer within a second or not at all.
642const METADATA_TIMEOUT: Duration = Duration::from_secs(1);
643
644/// A client for metadata services. As in the SDK, it never uses a proxy, which
645/// would see the plain-HTTP tokens and credentials.
646fn 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
654/// Whether the SDK's `checkUrl` accepts a container credentials endpoint:
655/// HTTPS, or HTTP to a loopback address or the ECS or EKS container host.
656fn 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
673/// Container credentials, else instance metadata unless disabled. Failures
674/// leave the chain's final error.
675async 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
694/// Credentials from a JSON metadata document.
695fn 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
710/// ECS and EKS Pod Identity credentials.
711async 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
750/// EC2 instance metadata with an Instance Metadata Service v2 (IMDSv2)
751/// session token.
752async 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
805/// The region of a profile in the config file, as the SDK's region chain
806/// falls back to it.
807pub 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        // Environment keys win only while no profile is chosen.
875        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&amp;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}