Skip to main content

object_store/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 crate::aws::{AwsCredentialProvider, STORE, STRICT_ENCODE_SET, STRICT_PATH_ENCODE_SET};
19use crate::client::builder::HttpRequestBuilder;
20use crate::client::retry::RetryExt;
21use crate::client::token::{TemporaryToken, TokenCache};
22use crate::client::{
23    CryptoProvider, DigestAlgorithm, HttpClient, HttpError, HttpRequest, TokenProvider,
24    crypto_provider,
25};
26use crate::util::{hex_digest, hex_encode};
27use crate::{CredentialProvider, Result, RetryConfig};
28use async_trait::async_trait;
29use bytes::Buf;
30use chrono::{DateTime, Utc};
31use http::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
32use http::{Method, StatusCode};
33use percent_encoding::utf8_percent_encode;
34use serde::Deserialize;
35use std::collections::BTreeMap;
36use std::sync::Arc;
37use std::time::{Duration, Instant};
38use tracing::warn;
39use url::Url;
40
41#[derive(Debug, thiserror::Error)]
42#[allow(clippy::enum_variant_names)]
43enum Error {
44    #[error("Error performing CreateSession request: {source}")]
45    CreateSessionRequest {
46        source: crate::client::retry::RetryError,
47    },
48
49    #[error("Error getting CreateSession response: {source}")]
50    CreateSessionResponse { source: HttpError },
51
52    #[error("Invalid CreateSessionOutput response: {source}")]
53    CreateSessionOutput { source: quick_xml::DeError },
54}
55
56impl From<Error> for crate::Error {
57    fn from(value: Error) -> Self {
58        Self::Generic {
59            store: STORE,
60            source: Box::new(value),
61        }
62    }
63}
64
65type StdError = Box<dyn std::error::Error + Send + Sync>;
66
67/// SHA256 hash of empty string
68static EMPTY_SHA256_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
69static UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
70static STREAMING_PAYLOAD: &str = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD";
71
72/// A set of AWS security credentials
73#[derive(Eq, PartialEq)]
74pub struct AwsCredential {
75    /// AWS_ACCESS_KEY_ID
76    pub key_id: String,
77    /// AWS_SECRET_ACCESS_KEY
78    pub secret_key: String,
79    /// AWS_SESSION_TOKEN
80    pub token: Option<String>,
81}
82
83impl std::fmt::Debug for AwsCredential {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.debug_struct("AwsCredential")
86            .field("key_id", &self.key_id)
87            .field("secret_key", &"******")
88            .field("token", &self.token.as_ref().map(|_| "******"))
89            .finish()
90    }
91}
92
93impl AwsCredential {
94    /// Signs a string
95    ///
96    /// <https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html>
97    fn sign(
98        &self,
99        crypto: &dyn CryptoProvider,
100        to_sign: &str,
101        date: DateTime<Utc>,
102        region: &str,
103        service: &str,
104    ) -> Result<String> {
105        let date_string = date.format("%Y%m%d").to_string();
106        let secret_key = format!("AWS4{}", self.secret_key);
107
108        let mut ctx = crypto.hmac(DigestAlgorithm::Sha256, secret_key.as_bytes())?;
109        ctx.update(date_string.as_bytes());
110
111        let mut ctx = crypto.hmac(DigestAlgorithm::Sha256, ctx.finish()?)?;
112        ctx.update(region.as_bytes());
113
114        let mut ctx = crypto.hmac(DigestAlgorithm::Sha256, ctx.finish()?)?;
115        ctx.update(service.as_bytes());
116
117        let mut ctx = crypto.hmac(DigestAlgorithm::Sha256, ctx.finish()?)?;
118        ctx.update(b"aws4_request");
119
120        let mut ctx = crypto.hmac(DigestAlgorithm::Sha256, ctx.finish()?)?;
121        ctx.update(to_sign.as_bytes());
122
123        Ok(hex_encode(ctx.finish()?))
124    }
125}
126
127/// Authorize a [`HttpRequest`] with an [`AwsCredential`] using [AWS SigV4]
128///
129/// [AWS SigV4]: https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
130#[derive(Debug)]
131pub struct AwsAuthorizer<'a> {
132    date: Option<DateTime<Utc>>,
133    credential: &'a AwsCredential,
134    crypto: Option<&'a dyn CryptoProvider>,
135    service: &'a str,
136    region: &'a str,
137    token_header: Option<HeaderName>,
138    sign_payload: bool,
139    request_payer: bool,
140}
141
142static DATE_HEADER: HeaderName = HeaderName::from_static("x-amz-date");
143static HASH_HEADER: HeaderName = HeaderName::from_static("x-amz-content-sha256");
144static TOKEN_HEADER: HeaderName = HeaderName::from_static("x-amz-security-token");
145static REQUEST_PAYER_HEADER: HeaderName = HeaderName::from_static("x-amz-request-payer");
146static REQUEST_PAYER_HEADER_VALUE: HeaderValue = HeaderValue::from_static("requester");
147const ALGORITHM: &str = "AWS4-HMAC-SHA256";
148
149impl<'a> AwsAuthorizer<'a> {
150    /// Create a new [`AwsAuthorizer`]
151    pub fn new(credential: &'a AwsCredential, service: &'a str, region: &'a str) -> Self {
152        Self {
153            credential,
154            service,
155            region,
156            crypto: None,
157            date: None,
158            sign_payload: true,
159            token_header: None,
160            request_payer: false,
161        }
162    }
163
164    /// Controls whether this [`AwsAuthorizer`] will attempt to sign the request payload,
165    /// the default is `true`
166    pub fn with_sign_payload(mut self, signed: bool) -> Self {
167        self.sign_payload = signed;
168        self
169    }
170
171    /// Overrides the header name for security tokens, defaults to `x-amz-security-token`
172    pub(crate) fn with_token_header(mut self, header: HeaderName) -> Self {
173        self.token_header = Some(header);
174        self
175    }
176
177    /// Set whether to include requester pays headers
178    ///
179    /// <https://docs.aws.amazon.com/AmazonS3/latest/userguide/ObjectsinRequesterPaysBuckets.html>
180    pub fn with_request_payer(mut self, request_payer: bool) -> Self {
181        self.request_payer = request_payer;
182        self
183    }
184
185    /// Specify the crypto provider
186    pub fn with_crypto(mut self, crypto: &'a dyn CryptoProvider) -> Self {
187        self.crypto = Some(crypto);
188        self
189    }
190
191    /// Authorize `request` with an optional pre-calculated SHA256 digest by attaching
192    /// the relevant [AWS SigV4] headers
193    ///
194    /// # Panics
195    ///
196    /// Panics on cryptography error
197    ///
198    #[deprecated(note = "use AwsAuthorized::try_authorize")]
199    pub fn authorize(&self, request: &mut HttpRequest, pre_calculated_digest: Option<&[u8]>) {
200        self.try_authorize(request, pre_calculated_digest).unwrap()
201    }
202
203    /// Authorize `request` with an optional pre-calculated SHA256 digest by attaching
204    /// the relevant [AWS SigV4] headers
205    ///
206    /// # Payload Signature
207    ///
208    /// AWS SigV4 requests must contain the `x-amz-content-sha256` header, it is set as follows:
209    ///
210    /// * If not configured to sign payloads, it is set to `UNSIGNED-PAYLOAD`
211    /// * If a `pre_calculated_digest` is provided, it is set to the hex encoding of it
212    /// * If it is a streaming request, it is set to `STREAMING-AWS4-HMAC-SHA256-PAYLOAD`
213    /// * Otherwise it is set to the hex encoded SHA256 of the request body
214    ///
215    /// [AWS SigV4]: https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
216    pub fn try_authorize(
217        &self,
218        request: &mut HttpRequest,
219        pre_calculated_digest: Option<&[u8]>,
220    ) -> Result<()> {
221        let crypto = crypto_provider(self.crypto)?;
222        let url = Url::parse(&request.uri().to_string()).unwrap();
223
224        if let Some(ref token) = self.credential.token {
225            let token_val = HeaderValue::from_str(token).unwrap();
226            let header = self.token_header.as_ref().unwrap_or(&TOKEN_HEADER);
227            request.headers_mut().insert(header, token_val);
228        }
229
230        let host = &url[url::Position::BeforeHost..url::Position::AfterPort];
231        let host_val = HeaderValue::from_str(host).unwrap();
232        request.headers_mut().insert("host", host_val);
233
234        let date = self.date.unwrap_or_else(Utc::now);
235        let date_str = date.format("%Y%m%dT%H%M%SZ").to_string();
236        let date_val = HeaderValue::from_str(&date_str).unwrap();
237        request.headers_mut().insert(&DATE_HEADER, date_val);
238
239        let digest = match self.sign_payload {
240            false => UNSIGNED_PAYLOAD.to_string(),
241            true => match pre_calculated_digest {
242                Some(digest) => hex_encode(digest),
243                None => match request.body().is_empty() {
244                    true => EMPTY_SHA256_HASH.to_string(),
245                    false => match request.body().as_bytes() {
246                        Some(bytes) => hex_digest(crypto, bytes)?,
247                        None => STREAMING_PAYLOAD.to_string(),
248                    },
249                },
250            },
251        };
252
253        let header_digest = HeaderValue::from_str(&digest).unwrap();
254        request.headers_mut().insert(&HASH_HEADER, header_digest);
255
256        if self.request_payer {
257            // For DELETE, GET, HEAD, POST, and PUT requests, include x-amz-request-payer :
258            // requester in the header
259            // https://docs.aws.amazon.com/AmazonS3/latest/userguide/ObjectsinRequesterPaysBuckets.html
260            request
261                .headers_mut()
262                .insert(&REQUEST_PAYER_HEADER, REQUEST_PAYER_HEADER_VALUE.clone());
263        }
264
265        let (signed_headers, canonical_headers) = canonicalize_headers(request.headers());
266
267        let scope = self.scope(date);
268
269        let string_to_sign = self.string_to_sign(
270            crypto,
271            date,
272            &scope,
273            request.method(),
274            &url,
275            &canonical_headers,
276            &signed_headers,
277            &digest,
278        )?;
279
280        // sign the string
281        let signature =
282            self.credential
283                .sign(crypto, &string_to_sign, date, self.region, self.service)?;
284
285        // build the actual auth header
286        let authorisation = format!(
287            "{} Credential={}/{}, SignedHeaders={}, Signature={}",
288            ALGORITHM, self.credential.key_id, scope, signed_headers, signature
289        );
290
291        let authorization_val = HeaderValue::from_str(&authorisation).unwrap();
292        request
293            .headers_mut()
294            .insert(&AUTHORIZATION, authorization_val);
295        Ok(())
296    }
297
298    pub(crate) fn sign(&self, method: Method, url: &mut Url, expires_in: Duration) -> Result<()> {
299        let crypto = crypto_provider(self.crypto)?;
300
301        let date = self.date.unwrap_or_else(Utc::now);
302        let scope = self.scope(date);
303
304        // https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
305        url.query_pairs_mut()
306            .append_pair("X-Amz-Algorithm", ALGORITHM)
307            .append_pair(
308                "X-Amz-Credential",
309                &format!("{}/{}", self.credential.key_id, scope),
310            )
311            .append_pair("X-Amz-Date", &date.format("%Y%m%dT%H%M%SZ").to_string())
312            .append_pair("X-Amz-Expires", &expires_in.as_secs().to_string())
313            .append_pair("X-Amz-SignedHeaders", "host");
314
315        if self.request_payer {
316            // For signed URLs, include x-amz-request-payer=requester in the request
317            // https://docs.aws.amazon.com/AmazonS3/latest/userguide/ObjectsinRequesterPaysBuckets.html
318            url.query_pairs_mut()
319                .append_pair("x-amz-request-payer", "requester");
320        }
321
322        // For S3, you must include the X-Amz-Security-Token query parameter in the URL if
323        // using credentials sourced from the STS service.
324        if let Some(ref token) = self.credential.token {
325            url.query_pairs_mut()
326                .append_pair("X-Amz-Security-Token", token);
327        }
328
329        // We don't have a payload; the user is going to send the payload directly themselves.
330        let digest = UNSIGNED_PAYLOAD;
331
332        let host = &url[url::Position::BeforeHost..url::Position::AfterPort].to_string();
333        let mut headers = HeaderMap::new();
334        let host_val = HeaderValue::from_str(host).unwrap();
335        headers.insert("host", host_val);
336
337        let (signed_headers, canonical_headers) = canonicalize_headers(&headers);
338
339        let string_to_sign = self.string_to_sign(
340            crypto,
341            date,
342            &scope,
343            &method,
344            url,
345            &canonical_headers,
346            &signed_headers,
347            digest,
348        )?;
349
350        let signature =
351            self.credential
352                .sign(crypto, &string_to_sign, date, self.region, self.service)?;
353
354        url.query_pairs_mut()
355            .append_pair("X-Amz-Signature", &signature);
356        Ok(())
357    }
358
359    #[allow(clippy::too_many_arguments)]
360    fn string_to_sign(
361        &self,
362        crypto: &dyn CryptoProvider,
363        date: DateTime<Utc>,
364        scope: &str,
365        request_method: &Method,
366        url: &Url,
367        canonical_headers: &str,
368        signed_headers: &str,
369        digest: &str,
370    ) -> Result<String> {
371        // Each path segment must be URI-encoded twice (except for Amazon S3 which only gets
372        // URI-encoded once).
373        // see https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
374        let canonical_uri = match self.service {
375            "s3" => url.path().to_string(),
376            _ => utf8_percent_encode(url.path(), &STRICT_PATH_ENCODE_SET).to_string(),
377        };
378
379        let canonical_query = canonicalize_query(url);
380
381        // https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
382        let canonical_request = format!(
383            "{}\n{}\n{}\n{}\n{}\n{}",
384            request_method.as_str(),
385            canonical_uri,
386            canonical_query,
387            canonical_headers,
388            signed_headers,
389            digest
390        );
391
392        let hashed_canonical_request = hex_digest(crypto, canonical_request.as_bytes())?;
393
394        Ok(format!(
395            "{}\n{}\n{}\n{}",
396            ALGORITHM,
397            date.format("%Y%m%dT%H%M%SZ"),
398            scope,
399            hashed_canonical_request
400        ))
401    }
402
403    fn scope(&self, date: DateTime<Utc>) -> String {
404        format!(
405            "{}/{}/{}/aws4_request",
406            date.format("%Y%m%d"),
407            self.region,
408            self.service
409        )
410    }
411}
412
413pub(crate) trait CredentialExt: Sized {
414    /// Sign a request <https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html>
415    fn with_aws_sigv4(
416        self,
417        authorizer: Option<AwsAuthorizer<'_>>,
418        payload_sha256: Option<&[u8]>,
419    ) -> Result<Self>;
420}
421
422impl CredentialExt for HttpRequestBuilder {
423    fn with_aws_sigv4(
424        self,
425        authorizer: Option<AwsAuthorizer<'_>>,
426        payload_sha256: Option<&[u8]>,
427    ) -> Result<Self> {
428        match authorizer {
429            Some(authorizer) => {
430                let (client, request) = self.into_parts();
431                let mut request = request.expect("request valid");
432                authorizer.try_authorize(&mut request, payload_sha256)?;
433
434                Ok(Self::from_parts(client, request))
435            }
436            None => Ok(self),
437        }
438    }
439}
440
441/// Canonicalizes query parameters into the AWS canonical form
442///
443/// <https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html>
444fn canonicalize_query(url: &Url) -> String {
445    use std::fmt::Write;
446
447    let capacity = match url.query() {
448        Some(q) if !q.is_empty() => q.len(),
449        _ => return String::new(),
450    };
451    let mut encoded = String::with_capacity(capacity + 1);
452
453    let mut headers = url.query_pairs().collect::<Vec<_>>();
454    headers.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
455
456    let mut first = true;
457    for (k, v) in headers {
458        if !first {
459            encoded.push('&');
460        }
461        first = false;
462        let _ = write!(
463            encoded,
464            "{}={}",
465            utf8_percent_encode(k.as_ref(), &STRICT_ENCODE_SET),
466            utf8_percent_encode(v.as_ref(), &STRICT_ENCODE_SET)
467        );
468    }
469    encoded
470}
471
472fn append_normalized_whitespace_value(headers: &'_ mut String, input: &str) {
473    let mut iter = input.split_whitespace();
474
475    if let Some(first) = iter.next() {
476        headers.push_str(first);
477        for word in iter {
478            headers.push(' ');
479            headers.push_str(word);
480        }
481    }
482}
483
484/// Canonicalizes headers into the AWS Canonical Form.
485///
486/// <https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html>
487fn canonicalize_headers(header_map: &HeaderMap) -> (String, String) {
488    let mut headers = BTreeMap::<&str, Vec<&str>>::new();
489    let mut value_count = 0;
490    let mut value_bytes = 0;
491    let mut key_bytes = 0;
492
493    for (key, value) in header_map {
494        let key = key.as_str();
495        if ["authorization", "content-length", "user-agent"].contains(&key) {
496            continue;
497        }
498
499        let value = std::str::from_utf8(value.as_bytes()).unwrap();
500        key_bytes += key.len();
501        value_bytes += value.len();
502        value_count += 1;
503        headers.entry(key).or_default().push(value);
504    }
505
506    let mut signed_headers = String::with_capacity(key_bytes + headers.len());
507    let mut canonical_headers =
508        String::with_capacity(key_bytes + value_bytes + headers.len() + value_count);
509
510    for (header_idx, (name, values)) in headers.into_iter().enumerate() {
511        if header_idx != 0 {
512            signed_headers.push(';');
513        }
514
515        signed_headers.push_str(name);
516        canonical_headers.push_str(name);
517        canonical_headers.push(':');
518        for (value_idx, value) in values.into_iter().enumerate() {
519            if value_idx != 0 {
520                canonical_headers.push(',');
521            }
522            append_normalized_whitespace_value(&mut canonical_headers, value.trim());
523        }
524        canonical_headers.push('\n');
525    }
526
527    (signed_headers, canonical_headers)
528}
529
530/// Credentials sourced from the instance metadata service
531///
532/// <https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html>
533#[derive(Debug)]
534pub(crate) struct InstanceCredentialProvider {
535    pub imdsv1_fallback: bool,
536    pub metadata_endpoint: String,
537}
538
539#[async_trait]
540impl TokenProvider for InstanceCredentialProvider {
541    type Credential = AwsCredential;
542
543    async fn fetch_token(
544        &self,
545        client: &HttpClient,
546        retry: &RetryConfig,
547    ) -> Result<TemporaryToken<Arc<AwsCredential>>> {
548        instance_creds(client, retry, &self.metadata_endpoint, self.imdsv1_fallback)
549            .await
550            .map_err(|source| crate::Error::Generic {
551                store: STORE,
552                source,
553            })
554    }
555}
556
557/// Credentials sourced using AssumeRoleWithWebIdentity
558///
559/// <https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts-technical-overview.html>
560#[derive(Debug)]
561pub(crate) struct WebIdentityProvider {
562    pub token_path: String,
563    pub role_arn: String,
564    pub session_name: String,
565    pub endpoint: String,
566}
567
568#[async_trait]
569impl TokenProvider for WebIdentityProvider {
570    type Credential = AwsCredential;
571
572    async fn fetch_token(
573        &self,
574        client: &HttpClient,
575        retry: &RetryConfig,
576    ) -> Result<TemporaryToken<Arc<AwsCredential>>> {
577        web_identity(
578            client,
579            retry,
580            &self.token_path,
581            &self.role_arn,
582            &self.session_name,
583            &self.endpoint,
584        )
585        .await
586        .map_err(|source| crate::Error::Generic {
587            store: STORE,
588            source,
589        })
590    }
591}
592
593#[derive(Debug, Deserialize)]
594#[serde(rename_all = "PascalCase")]
595struct InstanceCredentials {
596    access_key_id: String,
597    secret_access_key: String,
598    token: String,
599    expiration: DateTime<Utc>,
600}
601
602impl From<InstanceCredentials> for AwsCredential {
603    fn from(s: InstanceCredentials) -> Self {
604        Self {
605            key_id: s.access_key_id,
606            secret_key: s.secret_access_key,
607            token: Some(s.token),
608        }
609    }
610}
611
612/// <https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#instance-metadata-security-credentials>
613async fn instance_creds(
614    client: &HttpClient,
615    retry_config: &RetryConfig,
616    endpoint: &str,
617    imdsv1_fallback: bool,
618) -> Result<TemporaryToken<Arc<AwsCredential>>, StdError> {
619    const CREDENTIALS_PATH: &str = "latest/meta-data/iam/security-credentials";
620    const AWS_EC2_METADATA_TOKEN_HEADER: &str = "X-aws-ec2-metadata-token";
621
622    let token_url = format!("{endpoint}/latest/api/token");
623
624    let token_result = client
625        .request(Method::PUT, token_url)
626        .header("X-aws-ec2-metadata-token-ttl-seconds", "600") // 10 minute TTL
627        .retryable(retry_config)
628        .idempotent(true)
629        .send()
630        .await;
631
632    let token = match token_result {
633        Ok(t) => Some(t.into_body().text().await?),
634        Err(e) if imdsv1_fallback && matches!(e.status(), Some(StatusCode::FORBIDDEN)) => {
635            warn!("received 403 from metadata endpoint, falling back to IMDSv1");
636            None
637        }
638        Err(e) => return Err(e.into()),
639    };
640
641    let role_url = format!("{endpoint}/{CREDENTIALS_PATH}/");
642    let mut role_request = client.request(Method::GET, role_url);
643
644    if let Some(token) = &token {
645        role_request = role_request.header(AWS_EC2_METADATA_TOKEN_HEADER, token);
646    }
647
648    let role = role_request
649        .send_retry(retry_config)
650        .await?
651        .into_body()
652        .text()
653        .await?;
654
655    let creds_url = format!("{endpoint}/{CREDENTIALS_PATH}/{role}");
656    let mut creds_request = client.request(Method::GET, creds_url);
657    if let Some(token) = &token {
658        creds_request = creds_request.header(AWS_EC2_METADATA_TOKEN_HEADER, token);
659    }
660
661    let creds: InstanceCredentials = creds_request
662        .send_retry(retry_config)
663        .await?
664        .into_body()
665        .json()
666        .await?;
667
668    let now = Utc::now();
669    let ttl = (creds.expiration - now).to_std().unwrap_or_default();
670    Ok(TemporaryToken {
671        token: Arc::new(creds.into()),
672        expiry: Some(Instant::now() + ttl),
673    })
674}
675
676#[derive(Debug, Deserialize)]
677#[serde(rename_all = "PascalCase")]
678struct AssumeRoleResponse {
679    assume_role_with_web_identity_result: AssumeRoleResult,
680}
681
682#[derive(Debug, Deserialize)]
683#[serde(rename_all = "PascalCase")]
684struct AssumeRoleResult {
685    credentials: SessionCredentials,
686}
687
688#[derive(Debug, Deserialize)]
689#[serde(rename_all = "PascalCase")]
690struct SessionCredentials {
691    session_token: String,
692    secret_access_key: String,
693    access_key_id: String,
694    expiration: DateTime<Utc>,
695}
696
697impl From<SessionCredentials> for AwsCredential {
698    fn from(s: SessionCredentials) -> Self {
699        Self {
700            key_id: s.access_key_id,
701            secret_key: s.secret_access_key,
702            token: Some(s.session_token),
703        }
704    }
705}
706
707/// <https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts-technical-overview.html>
708async fn web_identity(
709    client: &HttpClient,
710    retry_config: &RetryConfig,
711    token_path: &str,
712    role_arn: &str,
713    session_name: &str,
714    endpoint: &str,
715) -> Result<TemporaryToken<Arc<AwsCredential>>, StdError> {
716    let token = std::fs::read_to_string(token_path)
717        .map_err(|e| format!("Failed to read token file '{token_path}': {e}"))?;
718
719    let bytes = client
720        .post(endpoint)
721        .query(&[
722            ("Action", "AssumeRoleWithWebIdentity"),
723            ("DurationSeconds", "3600"),
724            ("RoleArn", role_arn),
725            ("RoleSessionName", session_name),
726            ("Version", "2011-06-15"),
727            ("WebIdentityToken", &token),
728        ])
729        .retryable(retry_config)
730        .idempotent(true)
731        .sensitive(true)
732        .send()
733        .await?
734        .into_body()
735        .bytes()
736        .await?;
737
738    let resp: AssumeRoleResponse = quick_xml::de::from_reader(bytes.reader())
739        .map_err(|e| format!("Invalid AssumeRoleWithWebIdentity response: {e}"))?;
740
741    let creds = resp.assume_role_with_web_identity_result.credentials;
742    let now = Utc::now();
743    let ttl = (creds.expiration - now).to_std().unwrap_or_default();
744
745    Ok(TemporaryToken {
746        token: Arc::new(creds.into()),
747        expiry: Some(Instant::now() + ttl),
748    })
749}
750
751/// Credentials sourced from a task IAM role
752///
753/// <https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html>
754#[derive(Debug)]
755pub(crate) struct TaskCredentialProvider {
756    pub url: String,
757    pub retry: RetryConfig,
758    pub client: HttpClient,
759    pub cache: TokenCache<Arc<AwsCredential>>,
760}
761
762#[async_trait]
763impl CredentialProvider for TaskCredentialProvider {
764    type Credential = AwsCredential;
765
766    async fn get_credential(&self) -> Result<Arc<AwsCredential>> {
767        self.cache
768            .get_or_insert_with(|| task_credential(&self.client, &self.retry, &self.url))
769            .await
770            .map_err(|source| crate::Error::Generic {
771                store: STORE,
772                source,
773            })
774    }
775}
776
777/// <https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html>
778async fn task_credential(
779    client: &HttpClient,
780    retry: &RetryConfig,
781    url: &str,
782) -> Result<TemporaryToken<Arc<AwsCredential>>, StdError> {
783    let creds: InstanceCredentials = client
784        .get(url)
785        .send_retry(retry)
786        .await?
787        .into_body()
788        .json()
789        .await?;
790
791    let now = Utc::now();
792    let ttl = (creds.expiration - now).to_std().unwrap_or_default();
793    Ok(TemporaryToken {
794        token: Arc::new(creds.into()),
795        expiry: Some(Instant::now() + ttl),
796    })
797}
798
799/// EKS Pod Identity credential provider.
800///
801/// Uses the endpoint in `AWS_CONTAINER_CREDENTIALS_FULL_URI`
802/// and the bearer token in `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE`
803/// to fetch ephemeral AWS credentials from an EKS pod.
804#[derive(Debug)]
805pub(crate) struct EKSPodCredentialProvider {
806    pub url: String,
807    pub token_file: String,
808    pub retry: RetryConfig,
809    pub client: HttpClient,
810    pub cache: TokenCache<Arc<AwsCredential>>,
811}
812
813#[async_trait]
814impl CredentialProvider for EKSPodCredentialProvider {
815    type Credential = AwsCredential;
816
817    async fn get_credential(&self) -> Result<Arc<AwsCredential>> {
818        self.cache
819            .get_or_insert_with(|| {
820                eks_credential(&self.client, &self.retry, &self.url, &self.token_file)
821            })
822            .await
823            .map_err(|source| crate::Error::Generic {
824                store: STORE,
825                source,
826            })
827    }
828}
829
830/// Performs the actual credential retrieval and parsing for `EKSPodCredentialProvider`.
831///
832/// <https://mozillazg.com/2023/12/security-deep-dive-into-aws-eks-pod-identity-feature-en.html>
833async fn eks_credential(
834    client: &HttpClient,
835    retry: &RetryConfig,
836    url: &str,
837    token_file: &str,
838) -> Result<TemporaryToken<Arc<AwsCredential>>, StdError> {
839    // Spawn IO to blocking tokio pool if running in tokio context
840    let token = match tokio::runtime::Handle::try_current() {
841        Ok(runtime) => {
842            let path = token_file.to_string();
843            runtime
844                .spawn_blocking(move || std::fs::read_to_string(&path))
845                .await?
846        }
847        Err(_) => std::fs::read_to_string(token_file),
848    }
849    .map_err(|e| format!("Failed to read EKS token file '{token_file}': {e}"))?;
850
851    let mut req = client.request(Method::GET, url);
852    req = req.header("Authorization", token);
853
854    // The JSON from the EKS credential endpoint has the same shape as ECS task credentials
855    let creds: InstanceCredentials = req.send_retry(retry).await?.into_body().json().await?;
856
857    let now = Utc::now();
858    let ttl = (creds.expiration - now).to_std().unwrap_or_default();
859
860    Ok(TemporaryToken {
861        token: Arc::new(creds.into()),
862        expiry: Some(Instant::now() + ttl),
863    })
864}
865
866/// A session provider as used by S3 Express One Zone
867///
868/// <https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateSession.html>
869#[derive(Debug)]
870pub(crate) struct SessionProvider {
871    pub endpoint: String,
872    pub region: String,
873    pub credentials: AwsCredentialProvider,
874    pub crypto: Option<Arc<dyn CryptoProvider>>,
875}
876
877#[async_trait]
878impl TokenProvider for SessionProvider {
879    type Credential = AwsCredential;
880
881    async fn fetch_token(
882        &self,
883        client: &HttpClient,
884        retry: &RetryConfig,
885    ) -> Result<TemporaryToken<Arc<Self::Credential>>> {
886        let crypto = crypto_provider(self.crypto.as_deref())?;
887        let creds = self.credentials.get_credential().await?;
888        let authorizer = AwsAuthorizer::new(&creds, "s3", &self.region).with_crypto(crypto);
889
890        let bytes = client
891            .get(format!("{}?session", self.endpoint))
892            .with_aws_sigv4(Some(authorizer), None)?
893            .send_retry(retry)
894            .await
895            .map_err(|source| Error::CreateSessionRequest { source })?
896            .into_body()
897            .bytes()
898            .await
899            .map_err(|source| Error::CreateSessionResponse { source })?;
900
901        let resp: CreateSessionOutput = quick_xml::de::from_reader(bytes.reader())
902            .map_err(|source| Error::CreateSessionOutput { source })?;
903
904        let creds = resp.credentials;
905        Ok(TemporaryToken {
906            token: Arc::new(creds.into()),
907            // Credentials last 5 minutes - https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateSession.html
908            expiry: Some(Instant::now() + Duration::from_secs(5 * 60)),
909        })
910    }
911}
912
913#[derive(Debug, Deserialize)]
914#[serde(rename_all = "PascalCase")]
915struct CreateSessionOutput {
916    credentials: SessionCredentials,
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use crate::aws::{AmazonS3Builder, AmazonS3ConfigKey};
923    use crate::client::HttpClient;
924    use crate::client::mock_server::MockServer;
925    use http::{Method, Response};
926    #[cfg(feature = "reqwest")]
927    use reqwest::Client;
928    use std::env;
929
930    // Test generated using https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html
931    #[cfg(feature = "reqwest")]
932    #[test]
933    fn test_sign_with_signed_payload() {
934        let client = HttpClient::new(Client::new());
935
936        // Test credentials from https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html
937        let credential = AwsCredential {
938            key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
939            secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
940            token: None,
941        };
942
943        // method = 'GET'
944        // service = 'ec2'
945        // host = 'ec2.amazonaws.com'
946        // region = 'us-east-1'
947        // endpoint = 'https://ec2.amazonaws.com'
948        // request_parameters = ''
949        let date = DateTime::parse_from_rfc3339("2022-08-06T18:01:34Z")
950            .unwrap()
951            .with_timezone(&Utc);
952
953        let mut request = client
954            .request(Method::GET, "https://ec2.amazon.com/")
955            .into_parts()
956            .1
957            .unwrap();
958
959        let signer = AwsAuthorizer {
960            date: Some(date),
961            crypto: None,
962            credential: &credential,
963            service: "ec2",
964            region: "us-east-1",
965            sign_payload: true,
966            token_header: None,
967            request_payer: false,
968        };
969
970        signer.try_authorize(&mut request, None).unwrap();
971        assert_eq!(
972            request.headers().get(&AUTHORIZATION).unwrap(),
973            "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20220806/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=a3c787a7ed37f7fdfbfd2d7056a3d7c9d85e6d52a2bfbec73793c0be6e7862d4"
974        )
975    }
976
977    #[cfg(feature = "reqwest")]
978    #[test]
979    fn test_sign_with_signed_payload_request_payer() {
980        let client = HttpClient::new(Client::new());
981
982        // Test credentials from https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html
983        let credential = AwsCredential {
984            key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
985            secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
986            token: None,
987        };
988
989        // method = 'GET'
990        // service = 'ec2'
991        // host = 'ec2.amazonaws.com'
992        // region = 'us-east-1'
993        // endpoint = 'https://ec2.amazonaws.com'
994        // request_parameters = ''
995        let date = DateTime::parse_from_rfc3339("2022-08-06T18:01:34Z")
996            .unwrap()
997            .with_timezone(&Utc);
998
999        let mut request = client
1000            .request(Method::GET, "https://ec2.amazon.com/")
1001            .into_parts()
1002            .1
1003            .unwrap();
1004
1005        let signer = AwsAuthorizer {
1006            date: Some(date),
1007            crypto: None,
1008            credential: &credential,
1009            service: "ec2",
1010            region: "us-east-1",
1011            sign_payload: true,
1012            token_header: None,
1013            request_payer: true,
1014        };
1015
1016        signer.try_authorize(&mut request, None).unwrap();
1017        assert_eq!(
1018            request.headers().get(&AUTHORIZATION).unwrap(),
1019            "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20220806/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-request-payer, Signature=7030625a9e9b57ed2a40e63d749f4a4b7714b6e15004cab026152f870dd8565d"
1020        )
1021    }
1022
1023    #[cfg(feature = "reqwest")]
1024    #[test]
1025    fn test_sign_with_unsigned_payload() {
1026        let client = HttpClient::new(Client::new());
1027
1028        // Test credentials from https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html
1029        let credential = AwsCredential {
1030            key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
1031            secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
1032            token: None,
1033        };
1034
1035        // method = 'GET'
1036        // service = 'ec2'
1037        // host = 'ec2.amazonaws.com'
1038        // region = 'us-east-1'
1039        // endpoint = 'https://ec2.amazonaws.com'
1040        // request_parameters = ''
1041        let date = DateTime::parse_from_rfc3339("2022-08-06T18:01:34Z")
1042            .unwrap()
1043            .with_timezone(&Utc);
1044
1045        let mut request = client
1046            .request(Method::GET, "https://ec2.amazon.com/")
1047            .into_parts()
1048            .1
1049            .unwrap();
1050
1051        let authorizer = AwsAuthorizer {
1052            date: Some(date),
1053            crypto: None,
1054            credential: &credential,
1055            service: "ec2",
1056            region: "us-east-1",
1057            token_header: None,
1058            sign_payload: false,
1059            request_payer: false,
1060        };
1061
1062        authorizer.try_authorize(&mut request, None).unwrap();
1063        assert_eq!(
1064            request.headers().get(&AUTHORIZATION).unwrap(),
1065            "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20220806/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=653c3d8ea261fd826207df58bc2bb69fbb5003e9eb3c0ef06e4a51f2a81d8699"
1066        );
1067    }
1068
1069    #[test]
1070    fn signed_get_url() {
1071        // Values from https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
1072        let credential = AwsCredential {
1073            key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
1074            secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
1075            token: None,
1076        };
1077
1078        let date = DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1079            .unwrap()
1080            .with_timezone(&Utc);
1081
1082        let authorizer = AwsAuthorizer {
1083            date: Some(date),
1084            crypto: None,
1085            credential: &credential,
1086            service: "s3",
1087            region: "us-east-1",
1088            token_header: None,
1089            sign_payload: false,
1090            request_payer: false,
1091        };
1092
1093        let mut url = Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
1094        authorizer
1095            .sign(Method::GET, &mut url, Duration::from_secs(86400))
1096            .unwrap();
1097
1098        assert_eq!(
1099            url,
1100            Url::parse(
1101                "https://examplebucket.s3.amazonaws.com/test.txt?\
1102                X-Amz-Algorithm=AWS4-HMAC-SHA256&\
1103                X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&\
1104                X-Amz-Date=20130524T000000Z&\
1105                X-Amz-Expires=86400&\
1106                X-Amz-SignedHeaders=host&\
1107                X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"
1108            )
1109            .unwrap()
1110        );
1111    }
1112
1113    #[test]
1114    fn signed_get_url_request_payer() {
1115        // Values from https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
1116        let credential = AwsCredential {
1117            key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
1118            secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
1119            token: None,
1120        };
1121
1122        let date = DateTime::parse_from_rfc3339("2013-05-24T00:00:00Z")
1123            .unwrap()
1124            .with_timezone(&Utc);
1125
1126        let authorizer = AwsAuthorizer {
1127            date: Some(date),
1128            crypto: None,
1129            credential: &credential,
1130            service: "s3",
1131            region: "us-east-1",
1132            token_header: None,
1133            sign_payload: false,
1134            request_payer: true,
1135        };
1136
1137        let mut url = Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
1138        authorizer
1139            .sign(Method::GET, &mut url, Duration::from_secs(86400))
1140            .unwrap();
1141
1142        assert_eq!(
1143            url,
1144            Url::parse(
1145                "https://examplebucket.s3.amazonaws.com/test.txt?\
1146                X-Amz-Algorithm=AWS4-HMAC-SHA256&\
1147                X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&\
1148                X-Amz-Date=20130524T000000Z&\
1149                X-Amz-Expires=86400&\
1150                X-Amz-SignedHeaders=host&\
1151                x-amz-request-payer=requester&\
1152                X-Amz-Signature=9ad7c781cc30121f199b47d35ed3528473e4375b63c5d91cd87c927803e4e00a"
1153            )
1154            .unwrap()
1155        );
1156    }
1157
1158    #[cfg(feature = "reqwest")]
1159    #[test]
1160    fn test_sign_port() {
1161        let client = HttpClient::new(Client::new());
1162
1163        let credential = AwsCredential {
1164            key_id: "H20ABqCkLZID4rLe".to_string(),
1165            secret_key: "jMqRDgxSsBqqznfmddGdu1TmmZOJQxdM".to_string(),
1166            token: None,
1167        };
1168
1169        let date = DateTime::parse_from_rfc3339("2022-08-09T13:05:25Z")
1170            .unwrap()
1171            .with_timezone(&Utc);
1172
1173        let mut request = client
1174            .request(Method::GET, "http://localhost:9000/tsm-schemas")
1175            .query(&[
1176                ("delimiter", "/"),
1177                ("encoding-type", "url"),
1178                ("list-type", "2"),
1179                ("prefix", ""),
1180            ])
1181            .into_parts()
1182            .1
1183            .unwrap();
1184
1185        let authorizer = AwsAuthorizer {
1186            date: Some(date),
1187            crypto: None,
1188            credential: &credential,
1189            service: "s3",
1190            region: "us-east-1",
1191            token_header: None,
1192            sign_payload: true,
1193            request_payer: false,
1194        };
1195
1196        authorizer.try_authorize(&mut request, None).unwrap();
1197        assert_eq!(
1198            request.headers().get(&AUTHORIZATION).unwrap(),
1199            "AWS4-HMAC-SHA256 Credential=H20ABqCkLZID4rLe/20220809/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=9ebf2f92872066c99ac94e573b4e1b80f4dbb8a32b1e8e23178318746e7d1b4d"
1200        )
1201    }
1202
1203    #[cfg(feature = "reqwest")]
1204    #[tokio::test]
1205    async fn test_instance_metadata() {
1206        if env::var("TEST_INTEGRATION").is_err() {
1207            eprintln!("skipping AWS integration test");
1208            return;
1209        }
1210
1211        // For example https://github.com/aws/amazon-ec2-metadata-mock
1212        let endpoint = env::var("EC2_METADATA_ENDPOINT").unwrap();
1213        let client = HttpClient::new(Client::new());
1214        let retry_config = RetryConfig::default();
1215
1216        // Verify only allows IMDSv2
1217        let (client, req) = client
1218            .request(Method::GET, format!("{endpoint}/latest/meta-data/ami-id"))
1219            .into_parts();
1220
1221        let resp = client.execute(req.unwrap()).await.unwrap();
1222
1223        assert_eq!(
1224            resp.status(),
1225            StatusCode::UNAUTHORIZED,
1226            "Ensure metadata endpoint is set to only allow IMDSv2"
1227        );
1228
1229        let creds = instance_creds(&client, &retry_config, &endpoint, false)
1230            .await
1231            .unwrap();
1232
1233        let id = &creds.token.key_id;
1234        let secret = &creds.token.secret_key;
1235        let token = creds.token.token.as_ref().unwrap();
1236
1237        assert!(!id.is_empty());
1238        assert!(!secret.is_empty());
1239        assert!(!token.is_empty())
1240    }
1241
1242    #[cfg(feature = "reqwest")]
1243    #[tokio::test]
1244    async fn test_mock() {
1245        let server = MockServer::new().await;
1246
1247        const IMDSV2_HEADER: &str = "X-aws-ec2-metadata-token";
1248
1249        let secret_access_key = "SECRET";
1250        let access_key_id = "KEYID";
1251        let token = "TOKEN";
1252
1253        let endpoint = server.url();
1254        let client = HttpClient::new(Client::new());
1255        let retry_config = RetryConfig::default();
1256
1257        // Test IMDSv2
1258        server.push_fn(|req| {
1259            assert_eq!(req.uri().path(), "/latest/api/token");
1260            assert_eq!(req.method(), &Method::PUT);
1261            Response::new("cupcakes".to_string())
1262        });
1263        server.push_fn(|req| {
1264            assert_eq!(
1265                req.uri().path(),
1266                "/latest/meta-data/iam/security-credentials/"
1267            );
1268            assert_eq!(req.method(), &Method::GET);
1269            let t = req.headers().get(IMDSV2_HEADER).unwrap().to_str().unwrap();
1270            assert_eq!(t, "cupcakes");
1271            Response::new("myrole".to_string())
1272        });
1273        server.push_fn(|req| {
1274            assert_eq!(req.uri().path(), "/latest/meta-data/iam/security-credentials/myrole");
1275            assert_eq!(req.method(), &Method::GET);
1276            let t = req.headers().get(IMDSV2_HEADER).unwrap().to_str().unwrap();
1277            assert_eq!(t, "cupcakes");
1278            Response::new(r#"{"AccessKeyId":"KEYID","Code":"Success","Expiration":"2022-08-30T10:51:04Z","LastUpdated":"2022-08-30T10:21:04Z","SecretAccessKey":"SECRET","Token":"TOKEN","Type":"AWS-HMAC"}"#.to_string())
1279        });
1280
1281        let creds = instance_creds(&client, &retry_config, endpoint, true)
1282            .await
1283            .unwrap();
1284
1285        assert_eq!(creds.token.token.as_deref().unwrap(), token);
1286        assert_eq!(&creds.token.key_id, access_key_id);
1287        assert_eq!(&creds.token.secret_key, secret_access_key);
1288
1289        // Test IMDSv1 fallback
1290        server.push_fn(|req| {
1291            assert_eq!(req.uri().path(), "/latest/api/token");
1292            assert_eq!(req.method(), &Method::PUT);
1293            Response::builder()
1294                .status(StatusCode::FORBIDDEN)
1295                .body(String::new())
1296                .unwrap()
1297        });
1298        server.push_fn(|req| {
1299            assert_eq!(
1300                req.uri().path(),
1301                "/latest/meta-data/iam/security-credentials/"
1302            );
1303            assert_eq!(req.method(), &Method::GET);
1304            assert!(req.headers().get(IMDSV2_HEADER).is_none());
1305            Response::new("myrole".to_string())
1306        });
1307        server.push_fn(|req| {
1308            assert_eq!(req.uri().path(), "/latest/meta-data/iam/security-credentials/myrole");
1309            assert_eq!(req.method(), &Method::GET);
1310            assert!(req.headers().get(IMDSV2_HEADER).is_none());
1311            Response::new(r#"{"AccessKeyId":"KEYID","Code":"Success","Expiration":"2022-08-30T10:51:04Z","LastUpdated":"2022-08-30T10:21:04Z","SecretAccessKey":"SECRET","Token":"TOKEN","Type":"AWS-HMAC"}"#.to_string())
1312        });
1313
1314        let creds = instance_creds(&client, &retry_config, endpoint, true)
1315            .await
1316            .unwrap();
1317
1318        assert_eq!(creds.token.token.as_deref().unwrap(), token);
1319        assert_eq!(&creds.token.key_id, access_key_id);
1320        assert_eq!(&creds.token.secret_key, secret_access_key);
1321
1322        // Test IMDSv1 fallback disabled
1323        server.push(
1324            Response::builder()
1325                .status(StatusCode::FORBIDDEN)
1326                .body(String::new())
1327                .unwrap(),
1328        );
1329
1330        // Should fail
1331        instance_creds(&client, &retry_config, endpoint, false)
1332            .await
1333            .unwrap_err();
1334    }
1335
1336    #[cfg(feature = "reqwest")]
1337    #[tokio::test]
1338    async fn test_eks_pod_credential_provider() {
1339        use crate::client::mock_server::MockServer;
1340        use http::Response;
1341        use std::fs::File;
1342        use std::io::Write;
1343
1344        let mock_server = MockServer::new().await;
1345
1346        mock_server.push(Response::new(
1347            r#"{
1348            "AccessKeyId": "TEST_KEY",
1349            "SecretAccessKey": "TEST_SECRET",
1350            "Token": "TEST_SESSION_TOKEN",
1351            "Expiration": "2100-01-01T00:00:00Z"
1352        }"#
1353            .to_string(),
1354        ));
1355
1356        let token_file = tempfile::NamedTempFile::new().expect("cannot create temp file");
1357        let path = token_file.path().to_string_lossy().into_owned();
1358        let mut f = File::create(token_file.path()).unwrap();
1359        write!(f, "TEST_BEARER_TOKEN").unwrap();
1360
1361        let builder = AmazonS3Builder::new()
1362            .with_bucket_name("some-bucket")
1363            .with_config(
1364                AmazonS3ConfigKey::ContainerCredentialsFullUri,
1365                mock_server.url(),
1366            )
1367            .with_config(AmazonS3ConfigKey::ContainerAuthorizationTokenFile, &path);
1368
1369        let s3 = builder.build().unwrap();
1370
1371        let cred = s3.client.config.credentials.get_credential().await.unwrap();
1372
1373        assert_eq!(cred.key_id, "TEST_KEY");
1374        assert_eq!(cred.secret_key, "TEST_SECRET");
1375        assert_eq!(cred.token.as_deref(), Some("TEST_SESSION_TOKEN"));
1376    }
1377
1378    #[test]
1379    fn test_output_masks_all_fields() {
1380        let cred = AwsCredential {
1381            key_id: "AKIAXXX".to_string(),
1382            secret_key: "super_secret".to_string(),
1383            token: Some("temp_token".to_string()),
1384        };
1385
1386        let debug_output = format!("{cred:?}");
1387
1388        assert!(debug_output.contains("key_id: \"AKIAXXX\""));
1389        assert!(debug_output.contains("secret_key: \"******\""));
1390        assert!(debug_output.contains("token: Some(\"******\")"));
1391
1392        assert!(!debug_output.contains("super_secret"));
1393        assert!(!debug_output.contains("temp_token"));
1394    }
1395
1396    #[test]
1397    fn test_normalize_whitespace() {
1398        // test cases from minio: https://github.com/minio/minio/blob/05e569960ac584c8927a9af76755708d20f16129/cmd/signature-v4-utils_test.go#L307-L324
1399        let test_cases = vec![
1400            // input, expected
1401            ("本語", "本語"),
1402            (" abc ", "abc"),
1403            (" a b ", "a b"),
1404            ("a b ", "a b"),
1405            ("a  b", "a b"),
1406            ("a   b", "a b"),
1407            ("   a   b  c   ", "a b c"),
1408            ("a \t b  c   ", "a b c"),
1409            ("\"a \t b  c   ", "\"a b c"),
1410            (
1411                " \t\n\u{000b}\r\u{000c}a \t\n\u{000b}\r\u{000c} b \t\n\u{000b}\r\u{000c} c \t\n\u{000b}\r\u{000c}",
1412                "a b c",
1413            ),
1414        ];
1415
1416        for (input, expected) in test_cases {
1417            let mut headers = String::new();
1418
1419            append_normalized_whitespace_value(&mut headers, input);
1420            assert_eq!(headers, expected);
1421        }
1422    }
1423
1424    #[test]
1425    fn test_canonicalize_headers_whitespace_normalization() {
1426        use http::header::HeaderMap;
1427
1428        let mut headers = HeaderMap::new();
1429        headers.insert("x-amz-meta-example", "  foo   bar  ".parse().unwrap());
1430        headers.insert(
1431            "x-amz-meta-another",
1432            "  multiple                  spaces here  ".parse().unwrap(),
1433        );
1434        headers.insert(
1435            "x-amz-meta-and-another-one",
1436            "foo\t\t\t bar".parse().unwrap(),
1437        );
1438        // ignored headers
1439        headers.insert("authorization", "SHOULD_BE_IGNORED".parse().unwrap());
1440        headers.insert("content-length", "1337".parse().unwrap());
1441
1442        let (signed_headers, canonical_headers) = super::canonicalize_headers(&headers);
1443
1444        assert_eq!(
1445            signed_headers,
1446            "x-amz-meta-and-another-one;x-amz-meta-another;x-amz-meta-example"
1447        );
1448
1449        let expected_canonical_headers = "x-amz-meta-and-another-one:foo bar\n\
1450            x-amz-meta-another:multiple spaces here\n\
1451            x-amz-meta-example:foo bar\n";
1452        assert_eq!(canonical_headers, expected_canonical_headers);
1453    }
1454}