1use 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
67static EMPTY_SHA256_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
69static UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
70static STREAMING_PAYLOAD: &str = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD";
71
72#[derive(Eq, PartialEq)]
74pub struct AwsCredential {
75 pub key_id: String,
77 pub secret_key: String,
79 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 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#[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 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 pub fn with_sign_payload(mut self, signed: bool) -> Self {
167 self.sign_payload = signed;
168 self
169 }
170
171 pub(crate) fn with_token_header(mut self, header: HeaderName) -> Self {
173 self.token_header = Some(header);
174 self
175 }
176
177 pub fn with_request_payer(mut self, request_payer: bool) -> Self {
181 self.request_payer = request_payer;
182 self
183 }
184
185 pub fn with_crypto(mut self, crypto: &'a dyn CryptoProvider) -> Self {
187 self.crypto = Some(crypto);
188 self
189 }
190
191 #[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 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 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 let signature =
282 self.credential
283 .sign(crypto, &string_to_sign, date, self.region, self.service)?;
284
285 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 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 url.query_pairs_mut()
319 .append_pair("x-amz-request-payer", "requester");
320 }
321
322 if let Some(ref token) = self.credential.token {
325 url.query_pairs_mut()
326 .append_pair("X-Amz-Security-Token", token);
327 }
328
329 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 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 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 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
441fn 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
484fn 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#[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#[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
612async 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") .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
707async 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#[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
777async 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#[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
830async fn eks_credential(
834 client: &HttpClient,
835 retry: &RetryConfig,
836 url: &str,
837 token_file: &str,
838) -> Result<TemporaryToken<Arc<AwsCredential>>, StdError> {
839 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 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#[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 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 #[cfg(feature = "reqwest")]
932 #[test]
933 fn test_sign_with_signed_payload() {
934 let client = HttpClient::new(Client::new());
935
936 let credential = AwsCredential {
938 key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
939 secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
940 token: None,
941 };
942
943 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 let credential = AwsCredential {
984 key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
985 secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
986 token: None,
987 };
988
989 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 let credential = AwsCredential {
1030 key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
1031 secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
1032 token: None,
1033 };
1034
1035 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 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 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 let endpoint = env::var("EC2_METADATA_ENDPOINT").unwrap();
1213 let client = HttpClient::new(Client::new());
1214 let retry_config = RetryConfig::default();
1215
1216 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 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 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 server.push(
1324 Response::builder()
1325 .status(StatusCode::FORBIDDEN)
1326 .body(String::new())
1327 .unwrap(),
1328 );
1329
1330 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 let test_cases = vec![
1400 ("本語", "本語"),
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 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}