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