aws_config/imds/
credentials.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! IMDSv2 Credentials Provider
7//!
8//! # Important
9//! This credential provider will NOT fallback to IMDSv1. Ensure that IMDSv2 is enabled on your instances.
10
11use super::client::error::ImdsError;
12use crate::imds::{self, Client};
13use crate::json_credentials::{parse_json_credentials, JsonCredentials, RefreshableCredentials};
14use crate::provider_config::ProviderConfig;
15use aws_credential_types::provider::{self, error::CredentialsError, future, ProvideCredentials};
16use aws_credential_types::Credentials;
17use aws_smithy_async::time::SharedTimeSource;
18use aws_types::os_shim_internal::Env;
19use std::borrow::Cow;
20use std::error::Error as StdError;
21use std::fmt;
22use std::sync::{Arc, RwLock};
23use std::time::{Duration, SystemTime};
24
25const CREDENTIAL_EXPIRATION_INTERVAL: Duration = Duration::from_secs(10 * 60);
26const WARNING_FOR_EXTENDING_CREDENTIALS_EXPIRY: &str =
27    "Attempting credential expiration extension due to a credential service availability issue. \
28    A refresh of these credentials will be attempted again within the next";
29
30#[derive(Debug)]
31struct ImdsCommunicationError {
32    source: Box<dyn StdError + Send + Sync + 'static>,
33}
34
35impl fmt::Display for ImdsCommunicationError {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        write!(f, "could not communicate with IMDS")
38    }
39}
40
41impl StdError for ImdsCommunicationError {
42    fn source(&self) -> Option<&(dyn StdError + 'static)> {
43        Some(self.source.as_ref())
44    }
45}
46
47/// IMDSv2 Credentials Provider
48///
49/// _Note: This credentials provider will NOT fallback to the IMDSv1 flow._
50#[derive(Debug)]
51pub struct ImdsCredentialsProvider {
52    client: Client,
53    env: Env,
54    profile: Option<String>,
55    time_source: SharedTimeSource,
56    last_retrieved_credentials: Arc<RwLock<Option<Credentials>>>,
57}
58
59/// Builder for [`ImdsCredentialsProvider`]
60#[derive(Default, Debug)]
61pub struct Builder {
62    provider_config: Option<ProviderConfig>,
63    profile_override: Option<String>,
64    imds_override: Option<imds::Client>,
65    last_retrieved_credentials: Option<Credentials>,
66}
67
68impl Builder {
69    /// Override the configuration used for this provider
70    pub fn configure(mut self, provider_config: &ProviderConfig) -> Self {
71        self.provider_config = Some(provider_config.clone());
72        self
73    }
74
75    /// Override the [instance profile](instance-profile) used for this provider.
76    ///
77    /// When retrieving IMDS credentials, a call must first be made to
78    /// `<IMDS_BASE_URL>/latest/meta-data/iam/security-credentials/`. This returns the instance
79    /// profile used. By setting this parameter, retrieving the profile is skipped
80    /// and the provided value is used instead.
81    ///
82    /// [instance-profile]: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile
83    pub fn profile(mut self, profile: impl Into<String>) -> Self {
84        self.profile_override = Some(profile.into());
85        self
86    }
87
88    /// Override the IMDS client used for this provider
89    ///
90    /// The IMDS client will be loaded and configured via `~/.aws/config` and environment variables,
91    /// however, if necessary the entire client may be provided directly.
92    ///
93    /// For more information about IMDS client configuration loading see [`imds::Client`]
94    pub fn imds_client(mut self, client: imds::Client) -> Self {
95        self.imds_override = Some(client);
96        self
97    }
98
99    #[allow(dead_code)]
100    #[cfg(test)]
101    fn last_retrieved_credentials(mut self, credentials: Credentials) -> Self {
102        self.last_retrieved_credentials = Some(credentials);
103        self
104    }
105
106    /// Create an [`ImdsCredentialsProvider`] from this builder.
107    pub fn build(self) -> ImdsCredentialsProvider {
108        let provider_config = self.provider_config.unwrap_or_default();
109        let env = provider_config.env();
110        let client = self
111            .imds_override
112            .unwrap_or_else(|| imds::Client::builder().configure(&provider_config).build());
113        ImdsCredentialsProvider {
114            client,
115            env,
116            profile: self.profile_override,
117            time_source: provider_config.time_source(),
118            last_retrieved_credentials: Arc::new(RwLock::new(self.last_retrieved_credentials)),
119        }
120    }
121}
122
123mod codes {
124    pub(super) const ASSUME_ROLE_UNAUTHORIZED_ACCESS: &str = "AssumeRoleUnauthorizedAccess";
125}
126
127impl ProvideCredentials for ImdsCredentialsProvider {
128    fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a>
129    where
130        Self: 'a,
131    {
132        future::ProvideCredentials::new(self.credentials())
133    }
134
135    fn fallback_on_interrupt(&self) -> Option<Credentials> {
136        self.last_retrieved_credentials.read().unwrap().clone()
137    }
138}
139
140impl ImdsCredentialsProvider {
141    /// Builder for [`ImdsCredentialsProvider`]
142    pub fn builder() -> Builder {
143        Builder::default()
144    }
145
146    fn imds_disabled(&self) -> bool {
147        match self.env.get(super::env::EC2_METADATA_DISABLED) {
148            Ok(value) => value.eq_ignore_ascii_case("true"),
149            _ => false,
150        }
151    }
152
153    /// Retrieve the instance profile from IMDS
154    async fn get_profile_uncached(&self) -> Result<String, CredentialsError> {
155        match self
156            .client
157            .get("/latest/meta-data/iam/security-credentials/")
158            .await
159        {
160            Ok(profile) => Ok(profile.as_ref().into()),
161            Err(ImdsError::ErrorResponse(context))
162                if context.response().status().as_u16() == 404 =>
163            {
164                tracing::warn!(
165                    "received 404 from IMDS when loading profile information. \
166                    Hint: This instance may not have an IAM role associated."
167                );
168                Err(CredentialsError::not_loaded("received 404 from IMDS"))
169            }
170            Err(ImdsError::FailedToLoadToken(context)) if context.is_dispatch_failure() => {
171                Err(CredentialsError::not_loaded(ImdsCommunicationError {
172                    source: context.into_source().into(),
173                }))
174            }
175            Err(other) => Err(CredentialsError::provider_error(other)),
176        }
177    }
178
179    // Extend the cached expiration time if necessary
180    //
181    // This allows continued use of the credentials even when IMDS returns expired ones.
182    fn maybe_extend_expiration(&self, expiration: SystemTime) -> SystemTime {
183        let now = self.time_source.now();
184        // If credentials from IMDS are not stale, use them as they are.
185        if now < expiration {
186            return expiration;
187        }
188
189        let mut rng = fastrand::Rng::with_seed(
190            now.duration_since(SystemTime::UNIX_EPOCH)
191                .expect("now should be after UNIX EPOCH")
192                .as_secs(),
193        );
194        // Calculate credentials' refresh offset with jitter, which should be less than 15 minutes
195        // the smallest amount of time credentials are valid for.
196        // Setting it to something longer than that may have the risk of the credentials expiring
197        // before the next refresh.
198        let refresh_offset = CREDENTIAL_EXPIRATION_INTERVAL + Duration::from_secs(rng.u64(0..=300));
199        let new_expiry = now + refresh_offset;
200
201        tracing::warn!(
202            "{WARNING_FOR_EXTENDING_CREDENTIALS_EXPIRY} {:.2} minutes.",
203            refresh_offset.as_secs_f64() / 60.0,
204        );
205
206        new_expiry
207    }
208
209    async fn retrieve_credentials(&self) -> provider::Result {
210        if self.imds_disabled() {
211            let err = format!(
212                "IMDS disabled by {} env var set to `true`",
213                super::env::EC2_METADATA_DISABLED
214            );
215            tracing::debug!(err);
216            return Err(CredentialsError::not_loaded(err));
217        }
218        tracing::debug!("loading credentials from IMDS");
219        let profile: Cow<'_, str> = match &self.profile {
220            Some(profile) => profile.into(),
221            None => self.get_profile_uncached().await?.into(),
222        };
223        tracing::debug!(profile = %profile, "loaded profile");
224        let credentials = self
225            .client
226            .get(format!(
227                "/latest/meta-data/iam/security-credentials/{}",
228                profile
229            ))
230            .await
231            .map_err(CredentialsError::provider_error)?;
232        match parse_json_credentials(credentials.as_ref()) {
233            Ok(JsonCredentials::RefreshableCredentials(RefreshableCredentials {
234                access_key_id,
235                secret_access_key,
236                session_token,
237                account_id,
238                expiration,
239                ..
240            })) => {
241                // TODO(IMDSv2.X): Use `account_id` once the design is finalized
242                let _ = account_id;
243                let expiration = self.maybe_extend_expiration(expiration);
244                let creds = Credentials::new(
245                    access_key_id,
246                    secret_access_key,
247                    Some(session_token.to_string()),
248                    expiration.into(),
249                    "IMDSv2",
250                );
251                *self.last_retrieved_credentials.write().unwrap() = Some(creds.clone());
252                Ok(creds)
253            }
254            Ok(JsonCredentials::Error { code, message })
255                if code == codes::ASSUME_ROLE_UNAUTHORIZED_ACCESS =>
256            {
257                Err(CredentialsError::invalid_configuration(format!(
258                    "Incorrect IMDS/IAM configuration: [{}] {}. \
259                        Hint: Does this role have a trust relationship with EC2?",
260                    code, message
261                )))
262            }
263            Ok(JsonCredentials::Error { code, message }) => {
264                Err(CredentialsError::provider_error(format!(
265                    "Error retrieving credentials from IMDS: {} {}",
266                    code, message
267                )))
268            }
269            // got bad data from IMDS, should not occur during normal operation:
270            Err(invalid) => Err(CredentialsError::unhandled(invalid)),
271        }
272    }
273
274    async fn credentials(&self) -> provider::Result {
275        match self.retrieve_credentials().await {
276            creds @ Ok(_) => creds,
277            // Any failure while retrieving credentials MUST NOT impede use of existing credentials.
278            err => match &*self.last_retrieved_credentials.read().unwrap() {
279                Some(creds) => Ok(creds.clone()),
280                _ => err,
281            },
282        }
283    }
284}
285
286#[cfg(test)]
287mod test {
288    use super::*;
289    use crate::imds::client::test::{
290        imds_request, imds_response, make_imds_client, token_request, token_response,
291    };
292    use crate::provider_config::ProviderConfig;
293    use aws_credential_types::provider::ProvideCredentials;
294    use aws_smithy_async::test_util::instant_time_and_sleep;
295    use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
296    use aws_smithy_types::body::SdkBody;
297    use std::time::{Duration, UNIX_EPOCH};
298    use tracing_test::traced_test;
299
300    const TOKEN_A: &str = "token_a";
301
302    #[tokio::test]
303    async fn profile_is_not_cached() {
304        let http_client = StaticReplayClient::new(vec![
305            ReplayEvent::new(
306                token_request("http://169.254.169.254", 21600),
307                token_response(21600, TOKEN_A),
308            ),
309            ReplayEvent::new(
310                imds_request("http://169.254.169.254/latest/meta-data/iam/security-credentials/", TOKEN_A),
311                imds_response(r#"profile-name"#),
312            ),
313            ReplayEvent::new(
314                imds_request("http://169.254.169.254/latest/meta-data/iam/security-credentials/profile-name", TOKEN_A),
315                imds_response("{\n  \"Code\" : \"Success\",\n  \"LastUpdated\" : \"2021-09-20T21:42:26Z\",\n  \"Type\" : \"AWS-HMAC\",\n  \"AccessKeyId\" : \"ASIARTEST\",\n  \"SecretAccessKey\" : \"testsecret\",\n  \"Token\" : \"testtoken\",\n  \"Expiration\" : \"2021-09-21T04:16:53Z\"\n}"),
316            ),
317            ReplayEvent::new(
318                imds_request("http://169.254.169.254/latest/meta-data/iam/security-credentials/", TOKEN_A),
319                imds_response(r#"different-profile"#),
320            ),
321            ReplayEvent::new(
322                imds_request("http://169.254.169.254/latest/meta-data/iam/security-credentials/different-profile", TOKEN_A),
323                imds_response("{\n  \"Code\" : \"Success\",\n  \"LastUpdated\" : \"2021-09-20T21:42:26Z\",\n  \"Type\" : \"AWS-HMAC\",\n  \"AccessKeyId\" : \"ASIARTEST2\",\n  \"SecretAccessKey\" : \"testsecret\",\n  \"Token\" : \"testtoken\",\n  \"Expiration\" : \"2021-09-21T04:16:53Z\"\n}"),
324            ),
325        ]);
326        let client = ImdsCredentialsProvider::builder()
327            .imds_client(make_imds_client(&http_client))
328            .configure(&ProviderConfig::no_configuration())
329            .build();
330        let creds1 = client.provide_credentials().await.expect("valid creds");
331        let creds2 = client.provide_credentials().await.expect("valid creds");
332        assert_eq!(creds1.access_key_id(), "ASIARTEST");
333        assert_eq!(creds2.access_key_id(), "ASIARTEST2");
334        http_client.assert_requests_match(&[]);
335    }
336
337    #[tokio::test]
338    #[traced_test]
339    async fn credentials_not_stale_should_be_used_as_they_are() {
340        let http_client = StaticReplayClient::new(vec![
341            ReplayEvent::new(
342                token_request("http://169.254.169.254", 21600),
343                token_response(21600, TOKEN_A),
344            ),
345            ReplayEvent::new(
346                imds_request("http://169.254.169.254/latest/meta-data/iam/security-credentials/", TOKEN_A),
347                imds_response(r#"profile-name"#),
348            ),
349            ReplayEvent::new(
350                imds_request("http://169.254.169.254/latest/meta-data/iam/security-credentials/profile-name", TOKEN_A),
351                imds_response("{\n  \"Code\" : \"Success\",\n  \"LastUpdated\" : \"2021-09-20T21:42:26Z\",\n  \"Type\" : \"AWS-HMAC\",\n  \"AccessKeyId\" : \"ASIARTEST\",\n  \"SecretAccessKey\" : \"testsecret\",\n  \"Token\" : \"testtoken\",\n  \"Expiration\" : \"2021-09-21T04:16:53Z\"\n}"),
352            ),
353        ]);
354
355        // set to 2021-09-21T04:16:50Z that makes returned credentials' expiry (2021-09-21T04:16:53Z)
356        // not stale
357        let time_of_request_to_fetch_credentials = UNIX_EPOCH + Duration::from_secs(1632197810);
358        let (time_source, sleep) = instant_time_and_sleep(time_of_request_to_fetch_credentials);
359
360        let provider_config = ProviderConfig::no_configuration()
361            .with_http_client(http_client.clone())
362            .with_sleep_impl(sleep)
363            .with_time_source(time_source);
364        let client = crate::imds::Client::builder()
365            .configure(&provider_config)
366            .build();
367        let provider = ImdsCredentialsProvider::builder()
368            .configure(&provider_config)
369            .imds_client(client)
370            .build();
371        let creds = provider.provide_credentials().await.expect("valid creds");
372        // The expiry should be equal to what is originally set (==2021-09-21T04:16:53Z).
373        assert_eq!(
374            creds.expiry(),
375            UNIX_EPOCH.checked_add(Duration::from_secs(1632197813))
376        );
377        http_client.assert_requests_match(&[]);
378
379        // There should not be logs indicating credentials are extended for stability.
380        assert!(!logs_contain(WARNING_FOR_EXTENDING_CREDENTIALS_EXPIRY));
381    }
382    #[tokio::test]
383    #[traced_test]
384    async fn expired_credentials_should_be_extended() {
385        let http_client = StaticReplayClient::new(vec![
386                ReplayEvent::new(
387                    token_request("http://169.254.169.254", 21600),
388                    token_response(21600, TOKEN_A),
389                ),
390                ReplayEvent::new(
391                    imds_request("http://169.254.169.254/latest/meta-data/iam/security-credentials/", TOKEN_A),
392                    imds_response(r#"profile-name"#),
393                ),
394                ReplayEvent::new(
395                    imds_request("http://169.254.169.254/latest/meta-data/iam/security-credentials/profile-name", TOKEN_A),
396                    imds_response("{\n  \"Code\" : \"Success\",\n  \"LastUpdated\" : \"2021-09-20T21:42:26Z\",\n  \"Type\" : \"AWS-HMAC\",\n  \"AccessKeyId\" : \"ASIARTEST\",\n  \"SecretAccessKey\" : \"testsecret\",\n  \"Token\" : \"testtoken\",\n  \"Expiration\" : \"2021-09-21T04:16:53Z\"\n}"),
397                ),
398            ]);
399
400        // set to 2021-09-21T17:41:25Z that renders fetched credentials already expired (2021-09-21T04:16:53Z)
401        let time_of_request_to_fetch_credentials = UNIX_EPOCH + Duration::from_secs(1632246085);
402        let (time_source, sleep) = instant_time_and_sleep(time_of_request_to_fetch_credentials);
403
404        let provider_config = ProviderConfig::no_configuration()
405            .with_http_client(http_client.clone())
406            .with_sleep_impl(sleep)
407            .with_time_source(time_source);
408        let client = crate::imds::Client::builder()
409            .configure(&provider_config)
410            .build();
411        let provider = ImdsCredentialsProvider::builder()
412            .configure(&provider_config)
413            .imds_client(client)
414            .build();
415        let creds = provider.provide_credentials().await.expect("valid creds");
416        assert!(creds.expiry().unwrap() > time_of_request_to_fetch_credentials);
417        http_client.assert_requests_match(&[]);
418
419        // We should inform customers that expired credentials are being used for stability.
420        assert!(logs_contain(WARNING_FOR_EXTENDING_CREDENTIALS_EXPIRY));
421    }
422
423    #[tokio::test]
424    #[cfg(feature = "default-https-client")]
425    async fn read_timeout_during_credentials_refresh_should_yield_last_retrieved_credentials() {
426        let client = crate::imds::Client::builder()
427            // 240.* can never be resolved
428            .endpoint("http://240.0.0.0")
429            .unwrap()
430            .build();
431        let expected = aws_credential_types::Credentials::for_tests();
432        let provider = ImdsCredentialsProvider::builder()
433            .imds_client(client)
434            // seed fallback credentials for testing
435            .last_retrieved_credentials(expected.clone())
436            .build();
437        let actual = provider.provide_credentials().await;
438        assert_eq!(actual.unwrap(), expected);
439    }
440
441    #[tokio::test]
442    #[cfg(feature = "default-https-client")]
443    async fn read_timeout_during_credentials_refresh_should_error_without_last_retrieved_credentials(
444    ) {
445        let client = crate::imds::Client::builder()
446            // 240.* can never be resolved
447            .endpoint("http://240.0.0.0")
448            .unwrap()
449            .build();
450        let provider = ImdsCredentialsProvider::builder()
451            .imds_client(client)
452            // no fallback credentials provided
453            .build();
454        let actual = provider.provide_credentials().await;
455        assert!(
456            matches!(actual, Err(CredentialsError::CredentialsNotLoaded(_))),
457            "\nexpected: Err(CredentialsError::CredentialsNotLoaded(_))\nactual: {actual:?}"
458        );
459    }
460
461    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
462    #[cfg_attr(windows, ignore)]
463    #[tokio::test]
464    #[cfg(feature = "default-https-client")]
465    async fn external_timeout_during_credentials_refresh_should_yield_last_retrieved_credentials() {
466        use aws_smithy_async::rt::sleep::AsyncSleep;
467        let client = crate::imds::Client::builder()
468            // 240.* can never be resolved
469            .endpoint("http://240.0.0.0")
470            .unwrap()
471            .build();
472        let expected = aws_credential_types::Credentials::for_tests();
473        let provider = ImdsCredentialsProvider::builder()
474            .imds_client(client)
475            .configure(&ProviderConfig::no_configuration())
476            // seed fallback credentials for testing
477            .last_retrieved_credentials(expected.clone())
478            .build();
479        let sleeper = aws_smithy_async::rt::sleep::TokioSleep::new();
480        let timeout = aws_smithy_async::future::timeout::Timeout::new(
481            provider.provide_credentials(),
482            // make sure `sleeper.sleep` will be timed out first by setting a shorter duration than connect timeout
483            sleeper.sleep(std::time::Duration::from_millis(100)),
484        );
485        match timeout.await {
486            Ok(_) => panic!("provide_credentials completed before timeout future"),
487            Err(_err) => match provider.fallback_on_interrupt() {
488                Some(actual) => assert_eq!(actual, expected),
489                None => panic!(
490                    "provide_credentials timed out and no credentials returned from fallback_on_interrupt"
491                ),
492            },
493        };
494    }
495
496    #[tokio::test]
497    async fn fallback_credentials_should_be_used_when_imds_returns_500_during_credentials_refresh()
498    {
499        let http_client = StaticReplayClient::new(vec![
500                // The next three request/response pairs will correspond to the first call to `provide_credentials`.
501                // During the call, it populates last_retrieved_credentials.
502                ReplayEvent::new(
503                    token_request("http://169.254.169.254", 21600),
504                    token_response(21600, TOKEN_A),
505                ),
506                ReplayEvent::new(
507                    imds_request("http://169.254.169.254/latest/meta-data/iam/security-credentials/", TOKEN_A),
508                    imds_response(r#"profile-name"#),
509                ),
510                ReplayEvent::new(
511                    imds_request("http://169.254.169.254/latest/meta-data/iam/security-credentials/profile-name", TOKEN_A),
512                    imds_response("{\n  \"Code\" : \"Success\",\n  \"LastUpdated\" : \"2021-09-20T21:42:26Z\",\n  \"Type\" : \"AWS-HMAC\",\n  \"AccessKeyId\" : \"ASIARTEST\",\n  \"SecretAccessKey\" : \"testsecret\",\n  \"Token\" : \"testtoken\",\n  \"Expiration\" : \"2021-09-21T04:16:53Z\"\n}"),
513                ),
514                // The following request/response pair corresponds to the second call to `provide_credentials`.
515                // During the call, IMDS returns response code 500.
516                ReplayEvent::new(
517                    imds_request("http://169.254.169.254/latest/meta-data/iam/security-credentials/", TOKEN_A),
518                    http::Response::builder().status(500).body(SdkBody::empty()).unwrap(),
519                ),
520            ]);
521        let provider = ImdsCredentialsProvider::builder()
522            .imds_client(make_imds_client(&http_client))
523            .configure(&ProviderConfig::no_configuration())
524            .build();
525        let creds1 = provider.provide_credentials().await.expect("valid creds");
526        assert_eq!(creds1.access_key_id(), "ASIARTEST");
527        // `creds1` should be returned as fallback credentials and assigned to `creds2`
528        let creds2 = provider.provide_credentials().await.expect("valid creds");
529        assert_eq!(creds1, creds2);
530        http_client.assert_requests_match(&[]);
531    }
532}