Skip to main content

olai_http/aws/
credential.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::collections::BTreeMap;
19use std::sync::Arc;
20use std::time::Instant;
21
22use async_trait::async_trait;
23use bytes::Buf;
24use chrono::{DateTime, Utc};
25use percent_encoding::utf8_percent_encode;
26use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
27use reqwest::{Client, Method, Request, RequestBuilder, StatusCode};
28use serde::Deserialize;
29use tracing::warn;
30use url::Url;
31
32use crate::retry::RetryExt;
33use crate::service::HttpService;
34use crate::token::{TemporaryToken, TokenCache};
35use crate::util::{hex_digest, hex_encode, hmac_sha256};
36use crate::{CredentialProvider, Result, RetryConfig, TokenProvider};
37
38use crate::util::STRICT_ENCODE_SET;
39
40/// This is used to maintain the URI path encoding
41const STRICT_PATH_ENCODE_SET: percent_encoding::AsciiSet = STRICT_ENCODE_SET.remove(b'/');
42
43type StdError = Box<dyn std::error::Error + Send + Sync>;
44
45/// SHA256 hash of empty string
46static EMPTY_SHA256_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
47
48/// A set of AWS security credentials
49#[derive(Debug, Eq, PartialEq)]
50pub struct AwsCredential {
51    /// AWS_ACCESS_KEY_ID
52    pub key_id: String,
53    /// AWS_SECRET_ACCESS_KEY
54    pub secret_key: String,
55    /// AWS_SESSION_TOKEN
56    pub token: Option<String>,
57}
58
59impl AwsCredential {
60    /// Signs a string
61    ///
62    /// <https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html>
63    fn sign(&self, to_sign: &str, date: DateTime<Utc>, region: &str, service: &str) -> String {
64        let date_string = date.format("%Y%m%d").to_string();
65        let date_hmac = hmac_sha256(format!("AWS4{}", self.secret_key), date_string);
66        let region_hmac = hmac_sha256(date_hmac, region);
67        let service_hmac = hmac_sha256(region_hmac, service);
68        let signing_hmac = hmac_sha256(service_hmac, b"aws4_request");
69        hex_encode(hmac_sha256(signing_hmac, to_sign).as_ref())
70    }
71}
72
73/// Authorize a [`Request`] with an [`AwsCredential`] using [AWS SigV4]
74///
75/// [AWS SigV4]: https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
76#[derive(Debug)]
77pub struct AwsAuthorizer<'a> {
78    date: Option<DateTime<Utc>>,
79    credential: &'a AwsCredential,
80    service: &'a str,
81    region: &'a str,
82}
83
84static DATE_HEADER: hyper::header::HeaderName =
85    hyper::header::HeaderName::from_static("x-amz-date");
86static HASH_HEADER: hyper::header::HeaderName =
87    hyper::header::HeaderName::from_static("x-amz-content-sha256");
88static TOKEN_HEADER: hyper::header::HeaderName =
89    hyper::header::HeaderName::from_static("x-amz-security-token");
90const ALGORITHM: &str = "AWS4-HMAC-SHA256";
91
92impl<'a> AwsAuthorizer<'a> {
93    /// Create a new [`AwsAuthorizer`]
94    pub fn new(credential: &'a AwsCredential, service: &'a str, region: &'a str) -> Self {
95        Self {
96            credential,
97            service,
98            region,
99            date: None,
100        }
101    }
102
103    /// Authorize `request` with an optional pre-calculated SHA256 digest by attaching
104    /// the relevant [AWS SigV4] headers
105    ///
106    /// [AWS SigV4]: https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
107    pub fn authorize(
108        &self,
109        request: &mut Request,
110        pre_calculated_digest: Option<&[u8]>,
111    ) -> crate::Result<()> {
112        if let Some(ref token) = self.credential.token {
113            let token_val = HeaderValue::from_str(token)?;
114            request.headers_mut().insert(&TOKEN_HEADER, token_val);
115        }
116
117        let host = &request.url()[url::Position::BeforeHost..url::Position::AfterPort];
118        let host_val = HeaderValue::from_str(host)?;
119        request.headers_mut().insert("host", host_val);
120
121        let date = self.date.unwrap_or_else(Utc::now);
122        let date_str = date.format("%Y%m%dT%H%M%SZ").to_string();
123        let date_val = HeaderValue::from_str(&date_str)?;
124        request.headers_mut().insert(&DATE_HEADER, date_val);
125
126        let digest = match pre_calculated_digest {
127            Some(digest) => hex_encode(digest),
128            None => match request.body() {
129                None => EMPTY_SHA256_HASH.to_string(),
130                Some(body) => match body.as_bytes() {
131                    Some(bytes) => hex_digest(bytes),
132                    None => EMPTY_SHA256_HASH.to_string(),
133                },
134            },
135        };
136
137        let header_digest = HeaderValue::from_str(&digest)?;
138        request.headers_mut().insert(&HASH_HEADER, header_digest);
139
140        let (signed_headers, canonical_headers) = canonicalize_headers(request.headers());
141
142        let scope = self.scope(date);
143
144        let string_to_sign = self.string_to_sign(
145            date,
146            &scope,
147            request.method(),
148            request.url(),
149            &canonical_headers,
150            &signed_headers,
151            &digest,
152        );
153
154        let signature = self
155            .credential
156            .sign(&string_to_sign, date, self.region, self.service);
157
158        let authorisation = format!(
159            "{} Credential={}/{}, SignedHeaders={}, Signature={}",
160            ALGORITHM, self.credential.key_id, scope, signed_headers, signature
161        );
162
163        let authorization_val = HeaderValue::from_str(&authorisation)?;
164        request
165            .headers_mut()
166            .insert(&AUTHORIZATION, authorization_val);
167        Ok(())
168    }
169
170    #[allow(clippy::too_many_arguments)]
171    fn string_to_sign(
172        &self,
173        date: DateTime<Utc>,
174        scope: &str,
175        request_method: &Method,
176        url: &Url,
177        canonical_headers: &str,
178        signed_headers: &str,
179        digest: &str,
180    ) -> String {
181        // Each path segment must be URI-encoded twice (except for Amazon S3 which only gets
182        // URI-encoded once).
183        // see https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
184        let canonical_uri = match self.service {
185            "s3" => url.path().to_string(),
186            _ => utf8_percent_encode(url.path(), &STRICT_PATH_ENCODE_SET).to_string(),
187        };
188
189        let canonical_query = canonicalize_query(url);
190
191        let canonical_request = format!(
192            "{}\n{}\n{}\n{}\n{}\n{}",
193            request_method.as_str(),
194            canonical_uri,
195            canonical_query,
196            canonical_headers,
197            signed_headers,
198            digest
199        );
200
201        let hashed_canonical_request = hex_digest(canonical_request.as_bytes());
202
203        format!(
204            "{}\n{}\n{}\n{}",
205            ALGORITHM,
206            date.format("%Y%m%dT%H%M%SZ"),
207            scope,
208            hashed_canonical_request
209        )
210    }
211
212    fn scope(&self, date: DateTime<Utc>) -> String {
213        format!(
214            "{}/{}/{}/aws4_request",
215            date.format("%Y%m%d"),
216            self.region,
217            self.service
218        )
219    }
220}
221
222pub(crate) trait CredentialExt {
223    /// Sign a request <https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html>
224    fn with_aws_sigv4(
225        self,
226        authorizer: Option<AwsAuthorizer<'_>>,
227        payload_sha256: Option<&[u8]>,
228    ) -> Self;
229}
230
231impl CredentialExt for RequestBuilder {
232    fn with_aws_sigv4(
233        self,
234        authorizer: Option<AwsAuthorizer<'_>>,
235        payload_sha256: Option<&[u8]>,
236    ) -> Self {
237        match authorizer {
238            Some(authorizer) => {
239                let (client, request) = self.build_split();
240                let mut request = request.expect("request valid");
241                authorizer
242                    .authorize(&mut request, payload_sha256)
243                    .expect("credential values must be valid header characters");
244
245                Self::from_parts(client, request)
246            }
247            None => self,
248        }
249    }
250}
251
252/// Canonicalizes query parameters into the AWS canonical form
253///
254/// <https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html>
255fn canonicalize_query(url: &Url) -> String {
256    use std::fmt::Write;
257
258    let capacity = match url.query() {
259        Some(q) if !q.is_empty() => q.len(),
260        _ => return String::new(),
261    };
262    let mut encoded = String::with_capacity(capacity + 1);
263
264    let mut headers = url.query_pairs().collect::<Vec<_>>();
265    headers.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
266
267    let mut first = true;
268    for (k, v) in headers {
269        if !first {
270            encoded.push('&');
271        }
272        first = false;
273        let _ = write!(
274            encoded,
275            "{}={}",
276            utf8_percent_encode(k.as_ref(), &STRICT_ENCODE_SET),
277            utf8_percent_encode(v.as_ref(), &STRICT_ENCODE_SET)
278        );
279    }
280    encoded
281}
282
283/// Canonicalizes headers into the AWS Canonical Form.
284///
285/// <https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html>
286fn canonicalize_headers(header_map: &HeaderMap) -> (String, String) {
287    // Use owned Strings for values since header bytes may not be valid UTF-8;
288    // we convert using lossy UTF-8 to avoid panicking on unusual header values.
289    let mut headers = BTreeMap::<&str, Vec<String>>::new();
290    let mut value_count = 0;
291    let mut value_bytes = 0;
292    let mut key_bytes = 0;
293
294    for (key, value) in header_map {
295        let key = key.as_str();
296        if ["authorization", "content-length", "user-agent"].contains(&key) {
297            continue;
298        }
299
300        let value = String::from_utf8_lossy(value.as_bytes()).into_owned();
301        key_bytes += key.len();
302        value_bytes += value.len();
303        value_count += 1;
304        headers.entry(key).or_default().push(value);
305    }
306
307    let mut signed_headers = String::with_capacity(key_bytes + headers.len());
308    let mut canonical_headers =
309        String::with_capacity(key_bytes + value_bytes + headers.len() + value_count);
310
311    for (header_idx, (name, values)) in headers.into_iter().enumerate() {
312        if header_idx != 0 {
313            signed_headers.push(';');
314        }
315
316        signed_headers.push_str(name);
317        canonical_headers.push_str(name);
318        canonical_headers.push(':');
319        for (value_idx, value) in values.into_iter().enumerate() {
320            if value_idx != 0 {
321                canonical_headers.push(',');
322            }
323            canonical_headers.push_str(value.trim());
324        }
325        canonical_headers.push('\n');
326    }
327
328    (signed_headers, canonical_headers)
329}
330
331/// Credentials sourced from the instance metadata service
332///
333/// Fetches short-lived `AwsCredential` values from the EC2 Instance Metadata
334/// Service (IMDS).  Supports both IMDSv2 (token-protected) and IMDSv1 as a
335/// fallback when `imdsv1_fallback` is set.
336///
337/// # References
338/// - <https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html>
339/// - <https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html>
340#[derive(Debug)]
341pub(crate) struct InstanceCredentialProvider {
342    pub imdsv1_fallback: bool,
343    pub metadata_endpoint: String,
344}
345
346#[async_trait]
347impl TokenProvider for InstanceCredentialProvider {
348    type Credential = AwsCredential;
349
350    async fn fetch_token(
351        &self,
352        client: &Client,
353        service: &Arc<dyn HttpService>,
354        retry: &RetryConfig,
355    ) -> Result<TemporaryToken<Arc<AwsCredential>>> {
356        instance_creds(
357            client,
358            service,
359            retry,
360            &self.metadata_endpoint,
361            self.imdsv1_fallback,
362        )
363        .await
364        .map_err(|source| crate::Error::Generic { source })
365    }
366}
367
368/// Credentials sourced using `AssumeRoleWithWebIdentity`.
369///
370/// Exchanges an OIDC token (e.g. a Kubernetes projected service-account token)
371/// for temporary AWS credentials by calling the STS
372/// `AssumeRoleWithWebIdentity` API.  Commonly used in EKS pod identity
373/// scenarios where the token file path is supplied via the
374/// `AWS_WEB_IDENTITY_TOKEN_FILE` environment variable.
375///
376/// # References
377/// - <https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html>
378/// - <https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts-technical-overview.html>
379#[derive(Debug)]
380pub(crate) struct WebIdentityProvider {
381    pub token_path: String,
382    pub role_arn: String,
383    pub session_name: String,
384    pub endpoint: String,
385}
386
387#[async_trait]
388impl TokenProvider for WebIdentityProvider {
389    type Credential = AwsCredential;
390
391    async fn fetch_token(
392        &self,
393        client: &Client,
394        service: &Arc<dyn HttpService>,
395        retry: &RetryConfig,
396    ) -> Result<TemporaryToken<Arc<AwsCredential>>> {
397        web_identity(
398            client,
399            service,
400            retry,
401            &self.token_path,
402            &self.role_arn,
403            &self.session_name,
404            &self.endpoint,
405        )
406        .await
407        .map_err(|source| crate::Error::Generic { source })
408    }
409}
410
411#[derive(Debug, Deserialize)]
412#[serde(rename_all = "PascalCase")]
413struct InstanceCredentials {
414    access_key_id: String,
415    secret_access_key: String,
416    token: String,
417    expiration: DateTime<Utc>,
418}
419
420impl From<InstanceCredentials> for AwsCredential {
421    fn from(s: InstanceCredentials) -> Self {
422        Self {
423            key_id: s.access_key_id,
424            secret_key: s.secret_access_key,
425            token: Some(s.token),
426        }
427    }
428}
429
430/// <https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#instance-metadata-security-credentials>
431async fn instance_creds(
432    client: &Client,
433    service: &Arc<dyn HttpService>,
434    retry_config: &RetryConfig,
435    endpoint: &str,
436    imdsv1_fallback: bool,
437) -> Result<TemporaryToken<Arc<AwsCredential>>, StdError> {
438    const CREDENTIALS_PATH: &str = "latest/meta-data/iam/security-credentials";
439    const AWS_EC2_METADATA_TOKEN_HEADER: &str = "X-aws-ec2-metadata-token";
440
441    let token_url = format!("{endpoint}/latest/api/token");
442
443    let token_result = client
444        .request(Method::PUT, token_url)
445        .header("X-aws-ec2-metadata-token-ttl-seconds", "600") // 10 minute TTL
446        .retryable(retry_config, service.clone())
447        .idempotent(true)
448        .send()
449        .await;
450
451    let token = match token_result {
452        Ok(t) => Some(t.text().await?),
453        Err(e) if imdsv1_fallback && matches!(e.status(), Some(StatusCode::FORBIDDEN)) => {
454            warn!("received 403 from metadata endpoint, falling back to IMDSv1");
455            None
456        }
457        Err(e) => return Err(e.into()),
458    };
459
460    let role_url = format!("{endpoint}/{CREDENTIALS_PATH}/");
461    let mut role_request = client.request(Method::GET, role_url);
462
463    if let Some(token) = &token {
464        role_request = role_request.header(AWS_EC2_METADATA_TOKEN_HEADER, token);
465    }
466
467    let role = role_request
468        .send_retry(retry_config, service.clone())
469        .await?
470        .text()
471        .await?;
472
473    let creds_url = format!("{endpoint}/{CREDENTIALS_PATH}/{role}");
474    let mut creds_request = client.request(Method::GET, creds_url);
475    if let Some(token) = &token {
476        creds_request = creds_request.header(AWS_EC2_METADATA_TOKEN_HEADER, token);
477    }
478
479    let creds: InstanceCredentials = creds_request
480        .send_retry(retry_config, service.clone())
481        .await?
482        .json()
483        .await?;
484
485    let now = Utc::now();
486    let ttl = (creds.expiration - now).to_std().unwrap_or_default();
487    Ok(TemporaryToken {
488        token: Arc::new(creds.into()),
489        expiry: Some(Instant::now() + ttl),
490    })
491}
492
493/// Exchanges long-term or instance credentials for temporary credentials by
494/// calling the STS [`AssumeRole`] API.  The caller's base credentials are used
495/// to SigV4-sign the STS request.
496///
497/// Wire this provider via [`AmazonBuilder::with_role_arn`] or set the
498/// `AWS_ROLE_ARN` / `AWS_ROLE_SESSION_NAME` environment variables alongside a
499/// static or instance credential source.
500///
501/// # References
502/// - <https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html>
503/// - <https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-api.html>
504#[derive(Debug)]
505pub(crate) struct AssumeRoleProvider {
506    pub role_arn: String,
507    pub session_name: String,
508    pub endpoint: String,
509    pub base_credentials: Arc<dyn CredentialProvider<Credential = AwsCredential>>,
510    pub region: String,
511    /// Optional inline session policy (URL-encoded JSON).
512    /// Intersected with the role's own policy to further restrict access.
513    pub policy: Option<String>,
514}
515
516#[async_trait]
517impl TokenProvider for AssumeRoleProvider {
518    type Credential = AwsCredential;
519
520    async fn fetch_token(
521        &self,
522        client: &Client,
523        service: &Arc<dyn HttpService>,
524        retry: &RetryConfig,
525    ) -> Result<TemporaryToken<Arc<AwsCredential>>> {
526        let base_cred = self
527            .base_credentials
528            .get_credential()
529            .await
530            .map_err(|source| crate::Error::Generic {
531                source: Box::new(source),
532            })?;
533
534        assume_role(
535            client,
536            service,
537            retry,
538            &base_cred,
539            &self.region,
540            &self.role_arn,
541            &self.session_name,
542            &self.endpoint,
543            self.policy.as_deref(),
544        )
545        .await
546        .map_err(|source| crate::Error::Generic { source })
547    }
548}
549
550/// Calls `STS:AssumeRole` and returns temporary credentials.
551///
552/// The request is SigV4-signed using the provided `base_cred`.
553///
554/// # References
555/// - <https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html>
556#[allow(clippy::too_many_arguments)]
557async fn assume_role(
558    client: &Client,
559    service: &Arc<dyn HttpService>,
560    retry_config: &RetryConfig,
561    base_cred: &AwsCredential,
562    region: &str,
563    role_arn: &str,
564    session_name: &str,
565    endpoint: &str,
566    policy: Option<&str>,
567) -> Result<TemporaryToken<Arc<AwsCredential>>, StdError> {
568    let mut query: Vec<(&str, &str)> = vec![
569        ("Action", "AssumeRole"),
570        ("DurationSeconds", "3600"),
571        ("RoleArn", role_arn),
572        ("RoleSessionName", session_name),
573        ("Version", "2011-06-15"),
574    ];
575    if let Some(p) = policy {
576        query.push(("Policy", p));
577    }
578    let bytes = client
579        .request(Method::POST, endpoint)
580        .query(&query)
581        .with_aws_sigv4(Some(AwsAuthorizer::new(base_cred, "sts", region)), None)
582        .retryable(retry_config, service.clone())
583        .idempotent(true)
584        .send()
585        .await?
586        .bytes()
587        .await?;
588
589    let resp: AssumeRoleXmlResponse = quick_xml::de::from_reader(bytes.reader())
590        .map_err(|e| format!("Invalid AssumeRole response: {e}"))?;
591
592    let creds = resp.assume_role_result.credentials;
593    let now = Utc::now();
594    let ttl = (creds.expiration - now).to_std().unwrap_or_default();
595
596    Ok(TemporaryToken {
597        token: Arc::new(creds.into()),
598        expiry: Some(Instant::now() + ttl),
599    })
600}
601
602/// XML response for `AssumeRole`.
603#[derive(Debug, Deserialize)]
604#[serde(rename_all = "PascalCase")]
605struct AssumeRoleXmlResponse {
606    assume_role_result: AssumeRoleResult,
607}
608
609#[derive(Debug, Deserialize)]
610#[serde(rename_all = "PascalCase")]
611struct AssumeRoleResponse {
612    assume_role_with_web_identity_result: AssumeRoleResult,
613}
614
615#[derive(Debug, Deserialize)]
616#[serde(rename_all = "PascalCase")]
617struct AssumeRoleResult {
618    credentials: SessionCredentials,
619}
620
621#[derive(Debug, Deserialize)]
622#[serde(rename_all = "PascalCase")]
623struct SessionCredentials {
624    session_token: String,
625    secret_access_key: String,
626    access_key_id: String,
627    expiration: DateTime<Utc>,
628}
629
630impl From<SessionCredentials> for AwsCredential {
631    fn from(s: SessionCredentials) -> Self {
632        Self {
633            key_id: s.access_key_id,
634            secret_key: s.secret_access_key,
635            token: Some(s.session_token),
636        }
637    }
638}
639
640/// <https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts-technical-overview.html>
641async fn web_identity(
642    client: &Client,
643    service: &Arc<dyn HttpService>,
644    retry_config: &RetryConfig,
645    token_path: &str,
646    role_arn: &str,
647    session_name: &str,
648    endpoint: &str,
649) -> Result<TemporaryToken<Arc<AwsCredential>>, StdError> {
650    let token = std::fs::read_to_string(token_path)
651        .map_err(|e| format!("Failed to read token file '{token_path}': {e}"))?;
652
653    let bytes = client
654        .request(Method::POST, endpoint)
655        .query(&[
656            ("Action", "AssumeRoleWithWebIdentity"),
657            ("DurationSeconds", "3600"),
658            ("RoleArn", role_arn),
659            ("RoleSessionName", session_name),
660            ("Version", "2011-06-15"),
661            ("WebIdentityToken", &token),
662        ])
663        .retryable(retry_config, service.clone())
664        .idempotent(true)
665        .sensitive(true)
666        .send()
667        .await?
668        .bytes()
669        .await?;
670
671    let resp: AssumeRoleResponse = quick_xml::de::from_reader(bytes.reader())
672        .map_err(|e| format!("Invalid AssumeRoleWithWebIdentity response: {e}"))?;
673
674    let creds = resp.assume_role_with_web_identity_result.credentials;
675    let now = Utc::now();
676    let ttl = (creds.expiration - now).to_std().unwrap_or_default();
677
678    Ok(TemporaryToken {
679        token: Arc::new(creds.into()),
680        expiry: Some(Instant::now() + ttl),
681    })
682}
683
684/// Credentials sourced from a task IAM role.
685///
686/// Fetches short-lived `AwsCredential` values from the ECS task-metadata
687/// credential endpoint.  The URL is resolved from environment variables in
688/// this priority order:
689///
690/// 1. `AWS_CONTAINER_CREDENTIALS_FULL_URI` — absolute URL (used by EKS Pod
691///    Identity and Lambda)
692/// 2. `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` — path appended to
693///    `http://169.254.170.2` (classic ECS task role)
694///
695/// When `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE` is set the file contents are
696/// sent as the `Authorization` request header, enabling EKS Pod Identity.
697/// Results are cached in the embedded [`TokenCache`] until they approach expiry.
698///
699/// # References
700/// - <https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html>
701/// - <https://docs.aws.amazon.com/eks/latest/userguide/pod-id-how-it-works.html>
702#[derive(Debug)]
703pub(crate) struct TaskCredentialProvider {
704    pub url: String,
705    /// Optional authorization token sent as the `Authorization` header.
706    /// Used by EKS Pod Identity (`AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE`).
707    pub auth_token_file: Option<String>,
708    pub retry: RetryConfig,
709    pub client: Client,
710    pub service: Arc<dyn HttpService>,
711    pub cache: TokenCache<Arc<AwsCredential>>,
712}
713
714#[async_trait]
715impl CredentialProvider for TaskCredentialProvider {
716    type Credential = AwsCredential;
717
718    async fn get_credential(&self) -> Result<Arc<AwsCredential>> {
719        self.cache
720            .get_or_insert_with(|| {
721                task_credential(
722                    &self.client,
723                    &self.service,
724                    &self.retry,
725                    &self.url,
726                    self.auth_token_file.as_deref(),
727                )
728            })
729            .await
730            .map_err(|source| crate::Error::Generic { source })
731    }
732}
733
734/// <https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html>
735async fn task_credential(
736    client: &Client,
737    service: &Arc<dyn HttpService>,
738    retry: &RetryConfig,
739    url: &str,
740    auth_token_file: Option<&str>,
741) -> Result<TemporaryToken<Arc<AwsCredential>>, StdError> {
742    let mut req = client.get(url);
743
744    // EKS Pod Identity requires an Authorization header read from a file
745    if let Some(token_file) = auth_token_file {
746        let token = std::fs::read_to_string(token_file)
747            .map_err(|e| format!("Failed to read auth token file '{token_file}': {e}"))?;
748        req = req.header(reqwest::header::AUTHORIZATION, token.trim());
749    }
750
751    let creds: InstanceCredentials = req.send_retry(retry, service.clone()).await?.json().await?;
752
753    let now = Utc::now();
754    let ttl = (creds.expiration - now).to_std().unwrap_or_default();
755    Ok(TemporaryToken {
756        token: Arc::new(creds.into()),
757        expiry: Some(Instant::now() + ttl),
758    })
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764    use crate::service::ReqwestService;
765    use reqwest::{Client, Method};
766    use std::env;
767
768    #[test]
769    fn test_sign_with_signed_payload() {
770        let client = Client::new();
771
772        let credential = AwsCredential {
773            key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), // gitleaks:allow
774            secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
775            token: None,
776        };
777
778        let date = DateTime::parse_from_rfc3339("2022-08-06T18:01:34Z")
779            .unwrap()
780            .with_timezone(&Utc);
781
782        let mut request = client
783            .request(Method::GET, "https://ec2.amazon.com/")
784            .build()
785            .unwrap();
786
787        let signer = AwsAuthorizer {
788            date: Some(date),
789            credential: &credential,
790            service: "ec2",
791            region: "us-east-1",
792        };
793
794        signer.authorize(&mut request, None).unwrap();
795        assert_eq!(
796            request.headers().get(&AUTHORIZATION).unwrap(),
797            "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20220806/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=a3c787a7ed37f7fdfbfd2d7056a3d7c9d85e6d52a2bfbec73793c0be6e7862d4" // gitleaks:allow
798        )
799        // gitleaks:allow
800    }
801
802    #[test]
803    fn test_sign_port() {
804        let client = Client::new();
805
806        let credential = AwsCredential {
807            key_id: "H20ABqCkLZID4rLe".to_string(),
808            secret_key: "jMqRDgxSsBqqznfmddGdu1TmmZOJQxdM".to_string(),
809            token: None,
810        };
811
812        let date = DateTime::parse_from_rfc3339("2022-08-09T13:05:25Z")
813            .unwrap()
814            .with_timezone(&Utc);
815
816        let mut request = client
817            .request(Method::GET, "http://localhost:9000/tsm-schemas")
818            .query(&[
819                ("delimiter", "/"),
820                ("encoding-type", "url"),
821                ("list-type", "2"),
822                ("prefix", ""),
823            ])
824            .build()
825            .unwrap();
826
827        let authorizer = AwsAuthorizer {
828            date: Some(date),
829            credential: &credential,
830            service: "s3",
831            region: "us-east-1",
832        };
833
834        authorizer.authorize(&mut request, None).unwrap();
835        assert_eq!(
836            request.headers().get(&AUTHORIZATION).unwrap(),
837            "AWS4-HMAC-SHA256 Credential=H20ABqCkLZID4rLe/20220809/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=9ebf2f92872066c99ac94e573b4e1b80f4dbb8a32b1e8e23178318746e7d1b4d"
838        )
839    }
840
841    #[tokio::test]
842    async fn test_instance_metadata() {
843        if env::var("TEST_INTEGRATION").is_err() {
844            eprintln!("skipping AWS integration test");
845            return;
846        }
847
848        let endpoint = env::var("EC2_METADATA_ENDPOINT").unwrap();
849        let client = Client::new();
850        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
851        let retry_config = RetryConfig::default();
852
853        let resp = client
854            .request(Method::GET, format!("{endpoint}/latest/meta-data/ami-id"))
855            .send()
856            .await
857            .unwrap();
858
859        assert_eq!(
860            resp.status(),
861            StatusCode::UNAUTHORIZED,
862            "Ensure metadata endpoint is set to only allow IMDSv2"
863        );
864
865        let creds = instance_creds(&client, &service, &retry_config, &endpoint, false)
866            .await
867            .unwrap();
868
869        let id = &creds.token.key_id;
870        let secret = &creds.token.secret_key;
871        let token = creds.token.token.as_ref().unwrap();
872
873        assert!(!id.is_empty());
874        assert!(!secret.is_empty());
875        assert!(!token.is_empty())
876    }
877    #[tokio::test]
878    async fn test_mock() {
879        let mut server = mockito::Server::new_async().await;
880
881        const IMDSV2_HEADER: &str = "X-aws-ec2-metadata-token";
882
883        let secret_access_key = "SECRET";
884        let access_key_id = "KEYID";
885        let token = "TOKEN";
886
887        let endpoint = server.url();
888        let client = Client::new();
889        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
890        let retry_config = RetryConfig::default();
891
892        // Test IMDSv2
893        let _mock1 = server
894            .mock("PUT", "/latest/api/token")
895            .with_status(200)
896            .with_body("cupcakes")
897            .create_async()
898            .await;
899
900        let _mock2 = server
901            .mock("GET", "/latest/meta-data/iam/security-credentials/")
902            .match_header(IMDSV2_HEADER, "cupcakes")
903            .with_status(200)
904            .with_body("myrole")
905            .create_async()
906            .await;
907
908        let _mock3 = server
909            .mock("GET", "/latest/meta-data/iam/security-credentials/myrole")
910            .match_header(IMDSV2_HEADER, "cupcakes")
911            .with_status(200)
912            .with_body(r#"{"AccessKeyId":"KEYID","Code":"Success","Expiration":"2022-08-30T10:51:04Z","LastUpdated":"2022-08-30T10:21:04Z","SecretAccessKey":"SECRET","Token":"TOKEN","Type":"AWS-HMAC"}"#)
913            .create_async()
914            .await;
915
916        let creds = instance_creds(&client, &service, &retry_config, &endpoint, true)
917            .await
918            .unwrap();
919
920        assert_eq!(creds.token.token.as_deref().unwrap(), token);
921        assert_eq!(&creds.token.key_id, access_key_id);
922        assert_eq!(&creds.token.secret_key, secret_access_key);
923
924        // Test IMDSv1 fallback
925        let _mock4 = server
926            .mock("PUT", "/latest/api/token")
927            .with_status(403)
928            .with_body("")
929            .create_async()
930            .await;
931
932        let _mock5 = server
933            .mock("GET", "/latest/meta-data/iam/security-credentials/")
934            .with_status(200)
935            .with_body("myrole")
936            .create_async()
937            .await;
938
939        let _mock6 = server
940            .mock("GET", "/latest/meta-data/iam/security-credentials/myrole")
941            .with_status(200)
942            .with_body(r#"{"AccessKeyId":"KEYID","Code":"Success","Expiration":"2022-08-30T10:51:04Z","LastUpdated":"2022-08-30T10:21:04Z","SecretAccessKey":"SECRET","Token":"TOKEN","Type":"AWS-HMAC"}"#)
943            .create_async()
944            .await;
945
946        let creds = instance_creds(&client, &service, &retry_config, &endpoint, true)
947            .await
948            .unwrap();
949
950        assert_eq!(creds.token.token.as_deref().unwrap(), token);
951        assert_eq!(&creds.token.key_id, access_key_id);
952        assert_eq!(&creds.token.secret_key, secret_access_key);
953
954        // Test IMDSv1 fallback disabled
955        let _mock7 = server
956            .mock("PUT", "/latest/api/token")
957            .with_status(403)
958            .with_body("")
959            .create_async()
960            .await;
961
962        // Should fail
963        instance_creds(&client, &service, &retry_config, &endpoint, false)
964            .await
965            .unwrap_err();
966    }
967
968    const STS_WEB_IDENTITY_XML: &str = r#"<AssumeRoleWithWebIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/"><AssumeRoleWithWebIdentityResult><Credentials><AccessKeyId>AKIAIOSFODNN7EXAMPLE</AccessKeyId><SecretAccessKey>wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY</SecretAccessKey><SessionToken>FwoGZXIvYXdzEJr//////////wEaDHer8</SessionToken><Expiration>2099-01-01T00:00:00Z</Expiration></Credentials></AssumeRoleWithWebIdentityResult></AssumeRoleWithWebIdentityResponse>"#; // gitleaks:allow
969
970    const STS_ASSUME_ROLE_XML: &str = r#"<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/"><AssumeRoleResult><Credentials><AccessKeyId>AKIAIOSFODNN7EXAMPLE</AccessKeyId><SecretAccessKey>wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY</SecretAccessKey><SessionToken>FwoGZXIvYXdzEJr//////////wEaDHer8</SessionToken><Expiration>2099-01-01T00:00:00Z</Expiration></Credentials></AssumeRoleResult></AssumeRoleResponse>"#; // gitleaks:allow
971
972    #[tokio::test]
973    async fn test_web_identity_provider() {
974        use std::io::Write as _;
975        let mut server = mockito::Server::new_async().await;
976
977        // Write a fake JWT to a temp file
978        let mut token_file = tempfile::NamedTempFile::new().unwrap();
979        write!(token_file, "fake-jwt-token").unwrap();
980
981        let sts_url = server.url();
982
983        let _mock = server
984            .mock("POST", "/")
985            .match_query(mockito::Matcher::AllOf(vec![
986                mockito::Matcher::UrlEncoded("Action".into(), "AssumeRoleWithWebIdentity".into()),
987                mockito::Matcher::UrlEncoded(
988                    "RoleArn".into(),
989                    "arn:aws:iam::123456789012:role/TestRole".into(),
990                ),
991                mockito::Matcher::UrlEncoded("WebIdentityToken".into(), "fake-jwt-token".into()),
992            ]))
993            .with_status(200)
994            .with_body(STS_WEB_IDENTITY_XML)
995            .create_async()
996            .await;
997
998        let client = Client::new();
999        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
1000        let retry_config = RetryConfig::default();
1001
1002        let provider = WebIdentityProvider {
1003            token_path: token_file.path().to_str().unwrap().to_owned(),
1004            role_arn: "arn:aws:iam::123456789012:role/TestRole".into(),
1005            session_name: "test-session".into(),
1006            endpoint: sts_url,
1007        };
1008
1009        let creds = provider
1010            .fetch_token(&client, &service, &retry_config)
1011            .await
1012            .unwrap();
1013
1014        assert_eq!(creds.token.key_id, "AKIAIOSFODNN7EXAMPLE"); // gitleaks:allow
1015        assert!(creds.token.token.is_some());
1016        assert!(creds.expiry.is_some());
1017
1018        _mock.assert_async().await;
1019    }
1020
1021    #[tokio::test]
1022    async fn test_task_credential_provider() {
1023        let mut server = mockito::Server::new_async().await;
1024
1025        let endpoint = server.url();
1026
1027        let _mock = server
1028            .mock("GET", "/v2/credentials/test")
1029            .with_status(200)
1030            .with_body(r#"{"AccessKeyId":"TASKID","Code":"Success","Expiration":"2099-01-01T00:00:00Z","LastUpdated":"2022-08-30T10:21:04Z","SecretAccessKey":"TASKSECRET","Token":"TASKTOKEN","Type":"AWS-HMAC"}"#)
1031            .create_async()
1032            .await;
1033
1034        let client = Client::new();
1035        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
1036        let retry = RetryConfig::default();
1037
1038        let provider = TaskCredentialProvider {
1039            url: format!("{}/v2/credentials/test", endpoint),
1040            auth_token_file: None,
1041            retry: retry.clone(),
1042            client: client.clone(),
1043            service: service.clone(),
1044            cache: Default::default(),
1045        };
1046
1047        let creds = provider.get_credential().await.unwrap();
1048
1049        assert_eq!(creds.key_id, "TASKID");
1050        assert_eq!(creds.secret_key, "TASKSECRET");
1051        assert_eq!(creds.token.as_deref(), Some("TASKTOKEN"));
1052
1053        _mock.assert_async().await;
1054    }
1055
1056    #[tokio::test]
1057    async fn test_task_credential_with_auth_token() {
1058        use std::io::Write as _;
1059        let mut server = mockito::Server::new_async().await;
1060
1061        // Write auth token to a temp file
1062        let mut auth_file = tempfile::NamedTempFile::new().unwrap();
1063        write!(auth_file, "my-pod-identity-token").unwrap();
1064
1065        let endpoint = server.url();
1066
1067        let _mock = server
1068            .mock("GET", "/v2/credentials/eks")
1069            .match_header("Authorization", "my-pod-identity-token")
1070            .with_status(200)
1071            .with_body(r#"{"AccessKeyId":"EKSID","Code":"Success","Expiration":"2099-01-01T00:00:00Z","LastUpdated":"2022-08-30T10:21:04Z","SecretAccessKey":"EKSSECRET","Token":"EKSTOKEN","Type":"AWS-HMAC"}"#)
1072            .create_async()
1073            .await;
1074
1075        let client = Client::new();
1076        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
1077        let retry = RetryConfig::default();
1078
1079        let provider = TaskCredentialProvider {
1080            url: format!("{}/v2/credentials/eks", endpoint),
1081            auth_token_file: Some(auth_file.path().to_str().unwrap().to_owned()),
1082            retry: retry.clone(),
1083            client: client.clone(),
1084            service: service.clone(),
1085            cache: Default::default(),
1086        };
1087
1088        let creds = provider.get_credential().await.unwrap();
1089
1090        assert_eq!(creds.key_id, "EKSID");
1091        assert_eq!(creds.token.as_deref(), Some("EKSTOKEN"));
1092
1093        _mock.assert_async().await;
1094    }
1095
1096    #[tokio::test]
1097    async fn test_assume_role_provider() {
1098        use crate::StaticCredentialProvider;
1099        let mut server = mockito::Server::new_async().await;
1100
1101        let sts_endpoint = server.url();
1102
1103        let _mock = server
1104            .mock("POST", "/")
1105            .match_query(mockito::Matcher::AllOf(vec![
1106                mockito::Matcher::UrlEncoded("Action".into(), "AssumeRole".into()),
1107                mockito::Matcher::UrlEncoded(
1108                    "RoleArn".into(),
1109                    "arn:aws:iam::123456789012:role/AssumedRole".into(),
1110                ),
1111            ]))
1112            .with_status(200)
1113            .with_body(STS_ASSUME_ROLE_XML)
1114            .create_async()
1115            .await;
1116
1117        let base_cred = AwsCredential {
1118            key_id: "BASEKEYID".to_string(),
1119            secret_key: "BASESECRET".to_string(),
1120            token: None,
1121        };
1122        let base_provider: Arc<dyn CredentialProvider<Credential = AwsCredential>> =
1123            Arc::new(StaticCredentialProvider::new(base_cred));
1124
1125        let client = Client::new();
1126        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(client.clone()));
1127        let retry_config = RetryConfig::default();
1128
1129        let provider = AssumeRoleProvider {
1130            role_arn: "arn:aws:iam::123456789012:role/AssumedRole".into(),
1131            session_name: "test-assume-session".into(),
1132            endpoint: format!("{}/", sts_endpoint),
1133            base_credentials: base_provider,
1134            region: "us-east-1".into(),
1135            policy: None,
1136        };
1137
1138        let token = provider
1139            .fetch_token(&client, &service, &retry_config)
1140            .await
1141            .unwrap();
1142
1143        assert_eq!(token.token.key_id, "AKIAIOSFODNN7EXAMPLE"); // gitleaks:allow
1144        assert!(token.token.token.is_some());
1145        assert!(token.expiry.is_some());
1146
1147        _mock.assert_async().await;
1148    }
1149}