aws_config/sso/
token.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6//! SSO Token Provider
7//!
8//! This token provider enables loading an access token from `~/.aws/sso/cache`. For more information,
9//! see [AWS Builder ID for developers](https://docs.aws.amazon.com/toolkit-for-vscode/latest/userguide/builder-id.html).
10//!
11//! This provider is included automatically when profiles are loaded.
12
13use crate::identity::IdentityCache;
14use crate::sso::cache::{
15    load_cached_token, save_cached_token, CachedSsoToken, CachedSsoTokenError,
16};
17use aws_credential_types::provider::token::ProvideToken;
18use aws_credential_types::provider::{
19    error::TokenError, future::ProvideToken as ProvideTokenFuture,
20};
21use aws_sdk_ssooidc::error::DisplayErrorContext;
22use aws_sdk_ssooidc::operation::create_token::CreateTokenOutput;
23use aws_sdk_ssooidc::Client as SsoOidcClient;
24use aws_smithy_async::time::SharedTimeSource;
25use aws_smithy_runtime::expiring_cache::ExpiringCache;
26use aws_smithy_runtime_api::client::identity::http::Token;
27use aws_smithy_runtime_api::client::identity::{IdentityFuture, ResolveIdentity};
28use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
29use aws_smithy_types::config_bag::ConfigBag;
30use aws_types::os_shim_internal::{Env, Fs};
31use aws_types::region::Region;
32use aws_types::SdkConfig;
33use std::error::Error as StdError;
34use std::fmt;
35use std::sync::{Arc, Mutex};
36use std::time::{Duration, SystemTime};
37use zeroize::Zeroizing;
38
39const REFRESH_BUFFER_TIME: Duration = Duration::from_secs(5 * 60 /* 5 minutes */);
40const MIN_TIME_BETWEEN_REFRESH: Duration = Duration::from_secs(30);
41
42/// SSO Token Provider
43///
44/// This token provider will use cached SSO tokens stored in `~/.aws/sso/cache/<hash>.json`.
45/// `<hash>` is computed based on the configured [`session_name`](Builder::session_name).
46///
47/// If possible, the cached token will be refreshed when it gets close to expiring.
48#[derive(Debug)]
49pub struct SsoTokenProvider {
50    inner: Arc<Inner>,
51    token_cache: ExpiringCache<CachedSsoToken, SsoTokenProviderError>,
52}
53
54#[derive(Debug)]
55struct Inner {
56    env: Env,
57    fs: Fs,
58    region: Region,
59    session_name: String,
60    start_url: String,
61    sdk_config: SdkConfig,
62    last_refresh_attempt: Mutex<Option<SystemTime>>,
63}
64
65impl SsoTokenProvider {
66    /// Creates a `SsoTokenProvider` builder.
67    pub fn builder() -> Builder {
68        Default::default()
69    }
70
71    async fn refresh_cached_token(
72        inner: &Inner,
73        cached_token: &CachedSsoToken,
74        identifier: &str,
75        now: SystemTime,
76    ) -> Result<Option<CachedSsoToken>, SsoTokenProviderError> {
77        // TODO(enableNewSmithyRuntimeCleanup): Use `customize().config_override()` to set the region instead of creating a new client once middleware is removed
78        let config = inner
79            .sdk_config
80            .to_builder()
81            .region(Some(inner.region.clone()))
82            .identity_cache(IdentityCache::no_cache())
83            .build();
84        let client = SsoOidcClient::new(&config);
85        let resp = client
86            .create_token()
87            .grant_type("refresh_token")
88            .client_id(
89                cached_token
90                    .client_id
91                    .as_ref()
92                    .expect("required for token refresh")
93                    .clone(),
94            )
95            .client_secret(
96                cached_token
97                    .client_secret
98                    .as_ref()
99                    .expect("required for token refresh")
100                    .as_str(),
101            )
102            .refresh_token(
103                cached_token
104                    .refresh_token
105                    .as_ref()
106                    .expect("required for token refresh")
107                    .as_str(),
108            )
109            .send()
110            .await;
111        match resp {
112            Ok(CreateTokenOutput {
113                access_token: Some(access_token),
114                refresh_token,
115                expires_in,
116                ..
117            }) => {
118                let refreshed_token = CachedSsoToken {
119                    access_token: Zeroizing::new(access_token),
120                    client_id: cached_token.client_id.clone(),
121                    client_secret: cached_token.client_secret.clone(),
122                    expires_at: now
123                        + Duration::from_secs(
124                            u64::try_from(expires_in)
125                                .map_err(|_| SsoTokenProviderError::BadExpirationTimeFromSsoOidc)?,
126                        ),
127                    refresh_token: refresh_token
128                        .map(Zeroizing::new)
129                        .or_else(|| cached_token.refresh_token.clone()),
130                    region: Some(inner.region.to_string()),
131                    registration_expires_at: cached_token.registration_expires_at,
132                    start_url: Some(inner.start_url.clone()),
133                };
134                save_cached_token(&inner.env, &inner.fs, identifier, &refreshed_token).await?;
135                tracing::debug!("saved refreshed SSO token");
136                Ok(Some(refreshed_token))
137            }
138            Ok(_) => {
139                tracing::debug!("SSO OIDC CreateToken responded without an access token");
140                Ok(None)
141            }
142            Err(err) => {
143                tracing::debug!(
144                    "call to SSO OIDC CreateToken for SSO token refresh failed: {}",
145                    DisplayErrorContext(&err)
146                );
147                Ok(None)
148            }
149        }
150    }
151
152    pub(super) fn resolve_token(
153        &self,
154        time_source: SharedTimeSource,
155    ) -> impl std::future::Future<Output = Result<CachedSsoToken, TokenError>> + 'static {
156        let token_cache = self.token_cache.clone();
157        let inner = self.inner.clone();
158
159        async move {
160            if let Some(token) = token_cache
161                .yield_or_clear_if_expired(time_source.now())
162                .await
163            {
164                tracing::debug!("using cached SSO token");
165                return Ok(token);
166            }
167            let token = token_cache
168                .get_or_load(|| async move {
169                    tracing::debug!("expiring cache asked for an updated SSO token");
170                    let mut token =
171                        load_cached_token(&inner.env, &inner.fs, &inner.session_name).await?;
172                    tracing::debug!("loaded cached SSO token");
173
174                    let now = time_source.now();
175                    let expired = token.expires_at <= now;
176                    let expires_soon = token.expires_at - REFRESH_BUFFER_TIME <= now;
177                    let last_refresh = *inner.last_refresh_attempt.lock().unwrap();
178                    let min_time_passed = last_refresh
179                        .map(|lr| {
180                            now.duration_since(lr).expect("last_refresh is in the past")
181                                >= MIN_TIME_BETWEEN_REFRESH
182                        })
183                        .unwrap_or(true);
184                    let registration_expired = token
185                        .registration_expires_at
186                        .map(|t| t <= now)
187                        .unwrap_or(true);
188                    let refreshable =
189                        token.refreshable() && min_time_passed && !registration_expired;
190
191                    tracing::debug!(
192                        expired = ?expired,
193                        expires_soon = ?expires_soon,
194                        min_time_passed = ?min_time_passed,
195                        registration_expired = ?registration_expired,
196                        refreshable = ?refreshable,
197                        will_refresh = ?(expires_soon && refreshable),
198                        "cached SSO token refresh decision"
199                    );
200
201                    // Fail fast if the token has expired and we can't refresh it
202                    if expired && !refreshable {
203                        tracing::debug!("cached SSO token is expired and cannot be refreshed");
204                        return Err(SsoTokenProviderError::ExpiredToken);
205                    }
206
207                    // Refresh the token if it is going to expire soon
208                    if expires_soon && refreshable {
209                        tracing::debug!("attempting to refresh SSO token");
210                        if let Some(refreshed_token) =
211                            Self::refresh_cached_token(&inner, &token, &inner.session_name, now)
212                                .await?
213                        {
214                            token = refreshed_token;
215                        }
216                        *inner.last_refresh_attempt.lock().unwrap() = Some(now);
217                    }
218
219                    let expires_at = token.expires_at;
220                    Ok((token, expires_at))
221                })
222                .await
223                .map_err(TokenError::provider_error)?;
224
225            Ok(token)
226        }
227    }
228}
229
230impl ProvideToken for SsoTokenProvider {
231    fn provide_token<'a>(&'a self) -> ProvideTokenFuture<'a>
232    where
233        Self: 'a,
234    {
235        let time_source = self
236            .inner
237            .sdk_config
238            .time_source()
239            .expect("a time source required by SsoTokenProvider");
240        let token_future = self.resolve_token(time_source);
241        ProvideTokenFuture::new(Box::pin(async move {
242            let token = token_future.await?;
243            Ok(Token::new(
244                token.access_token.as_str(),
245                Some(token.expires_at),
246            ))
247        }))
248    }
249}
250
251impl ResolveIdentity for SsoTokenProvider {
252    fn resolve_identity<'a>(
253        &'a self,
254        runtime_components: &'a RuntimeComponents,
255        config_bag: &'a ConfigBag,
256    ) -> IdentityFuture<'a> {
257        IdentityFuture::new(Box::pin(async move {
258            self.provide_token()
259                .await?
260                .resolve_identity(runtime_components, config_bag)
261                .await
262        }))
263    }
264}
265
266/// Builder for [`SsoTokenProvider`].
267#[derive(Debug, Default)]
268pub struct Builder {
269    sdk_config: Option<SdkConfig>,
270    region: Option<Region>,
271    session_name: Option<String>,
272    start_url: Option<String>,
273}
274
275impl Builder {
276    /// Creates a new builder for [`SsoTokenProvider`].
277    pub fn new() -> Self {
278        Default::default()
279    }
280
281    /// Override the configuration used for this provider
282    pub fn configure(mut self, sdk_config: &SdkConfig) -> Self {
283        self.sdk_config = Some(sdk_config.clone());
284        self
285    }
286
287    /// Sets the SSO region.
288    ///
289    /// This is a required field.
290    pub fn region(mut self, region: impl Into<Region>) -> Self {
291        self.region = Some(region.into());
292        self
293    }
294
295    /// Sets the SSO region.
296    ///
297    /// This is a required field.
298    pub fn set_region(&mut self, region: Option<Region>) -> &mut Self {
299        self.region = region;
300        self
301    }
302
303    /// Sets the SSO session name.
304    ///
305    /// This is a required field.
306    pub fn session_name(mut self, session_name: impl Into<String>) -> Self {
307        self.session_name = Some(session_name.into());
308        self
309    }
310
311    /// Sets the SSO session name.
312    ///
313    /// This is a required field.
314    pub fn set_session_name(&mut self, session_name: Option<String>) -> &mut Self {
315        self.session_name = session_name;
316        self
317    }
318
319    /// Sets the SSO start URL.
320    ///
321    /// This is a required field.
322    pub fn start_url(mut self, start_url: impl Into<String>) -> Self {
323        self.start_url = Some(start_url.into());
324        self
325    }
326
327    /// Sets the SSO start URL.
328    ///
329    /// This is a required field.
330    pub fn set_start_url(&mut self, start_url: Option<String>) -> &mut Self {
331        self.start_url = start_url;
332        self
333    }
334
335    /// Builds the [`SsoTokenProvider`].
336    ///
337    /// # Panics
338    ///
339    /// This will panic if any of the required fields are not given.
340    pub async fn build(mut self) -> SsoTokenProvider {
341        if self.sdk_config.is_none() {
342            self.sdk_config = Some(crate::load_defaults(crate::BehaviorVersion::latest()).await);
343        }
344        self.build_with(Env::real(), Fs::real())
345    }
346
347    pub(crate) fn build_with(self, env: Env, fs: Fs) -> SsoTokenProvider {
348        SsoTokenProvider {
349            inner: Arc::new(Inner {
350                env,
351                fs,
352                region: self.region.expect("region is required"),
353                session_name: self.session_name.expect("session_name is required"),
354                start_url: self.start_url.expect("start_url is required"),
355                sdk_config: self.sdk_config.expect("sdk_config is required"),
356                last_refresh_attempt: Mutex::new(None),
357            }),
358            token_cache: ExpiringCache::new(REFRESH_BUFFER_TIME),
359        }
360    }
361}
362
363#[derive(Debug)]
364pub(super) enum SsoTokenProviderError {
365    BadExpirationTimeFromSsoOidc,
366    FailedToLoadToken {
367        source: Box<dyn StdError + Send + Sync>,
368    },
369    ExpiredToken,
370}
371
372impl fmt::Display for SsoTokenProviderError {
373    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374        match self {
375            Self::BadExpirationTimeFromSsoOidc => {
376                f.write_str("SSO OIDC responded with a negative expiration duration")
377            }
378            Self::ExpiredToken => f.write_str("the SSO token has expired and cannot be refreshed"),
379            Self::FailedToLoadToken { .. } => f.write_str("failed to load the cached SSO token"),
380        }
381    }
382}
383
384impl StdError for SsoTokenProviderError {
385    fn cause(&self) -> Option<&dyn StdError> {
386        match self {
387            Self::BadExpirationTimeFromSsoOidc => None,
388            Self::ExpiredToken => None,
389            Self::FailedToLoadToken { source } => Some(source.as_ref()),
390        }
391    }
392}
393
394impl From<CachedSsoTokenError> for SsoTokenProviderError {
395    fn from(source: CachedSsoTokenError) -> Self {
396        Self::FailedToLoadToken {
397            source: source.into(),
398        }
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use aws_sdk_sso::config::{AsyncSleep, SharedAsyncSleep};
406    use aws_smithy_async::rt::sleep::TokioSleep;
407    use aws_smithy_async::test_util::instant_time_and_sleep;
408    use aws_smithy_async::time::{StaticTimeSource, TimeSource};
409    use aws_smithy_runtime::test_util::capture_test_logs::capture_test_logs;
410    use aws_smithy_runtime::{
411        assert_str_contains,
412        client::http::test_util::{capture_request, ReplayEvent, StaticReplayClient},
413    };
414    use aws_smithy_runtime_api::client::http::HttpClient;
415    use aws_smithy_runtime_api::client::runtime_components::RuntimeComponentsBuilder;
416    use aws_smithy_types::body::SdkBody;
417    use aws_smithy_types::date_time::Format;
418    use aws_smithy_types::retry::RetryConfig;
419    use aws_smithy_types::DateTime;
420
421    fn time(s: &str) -> SystemTime {
422        SystemTime::try_from(DateTime::from_str(s, Format::DateTime).unwrap()).unwrap()
423    }
424
425    struct TestHarness {
426        time_source: SharedTimeSource,
427        token_provider: SsoTokenProvider,
428        env: Env,
429        fs: Fs,
430    }
431
432    impl TestHarness {
433        fn new(
434            time_source: impl TimeSource + 'static,
435            sleep_impl: impl AsyncSleep + 'static,
436            http_client: impl HttpClient + 'static,
437            fs: Fs,
438        ) -> Self {
439            let env = Env::from_slice(&[("HOME", "/home/user")]);
440            let time_source = SharedTimeSource::new(time_source);
441            let config = SdkConfig::builder()
442                .http_client(http_client)
443                .time_source(time_source.clone())
444                .sleep_impl(SharedAsyncSleep::new(sleep_impl))
445                // disable retry to simplify testing
446                .retry_config(RetryConfig::disabled())
447                .behavior_version(crate::BehaviorVersion::latest())
448                .build();
449            Self {
450                time_source,
451                token_provider: SsoTokenProvider::builder()
452                    .configure(&config)
453                    .session_name("test")
454                    .region(Region::new("us-west-2"))
455                    .start_url("https://d-123.awsapps.com/start")
456                    .build_with(env.clone(), fs.clone()),
457                env,
458                fs,
459            }
460        }
461
462        async fn expect_sso_token(&self, value: &str, expires_at: &str) -> CachedSsoToken {
463            let token = self
464                .token_provider
465                .resolve_token(self.time_source.clone())
466                .await
467                .unwrap();
468            assert_eq!(value, token.access_token.as_str());
469            assert_eq!(time(expires_at), token.expires_at);
470            token
471        }
472
473        async fn expect_token(&self, value: &str, expires_at: &str) {
474            let runtime_components = RuntimeComponentsBuilder::for_tests()
475                .with_time_source(Some(self.time_source.clone()))
476                .build()
477                .unwrap();
478            let config_bag = ConfigBag::base();
479            let identity = self
480                .token_provider
481                .resolve_identity(&runtime_components, &config_bag)
482                .await
483                .unwrap();
484            let token = identity.data::<Token>().unwrap().clone();
485            assert_eq!(value, token.token());
486            assert_eq!(time(expires_at), identity.expiration().unwrap());
487        }
488
489        async fn expect_expired_token_err(&self) {
490            let err = DisplayErrorContext(
491                &self
492                    .token_provider
493                    .resolve_token(self.time_source.clone())
494                    .await
495                    .expect_err("expected failure"),
496            )
497            .to_string();
498            assert_str_contains!(err, "the SSO token has expired");
499        }
500
501        fn last_refresh_attempt_time(&self) -> Option<String> {
502            self.token_provider
503                .inner
504                .last_refresh_attempt
505                .lock()
506                .unwrap()
507                .map(|time| {
508                    DateTime::try_from(time)
509                        .unwrap()
510                        .fmt(Format::DateTime)
511                        .unwrap()
512                })
513        }
514    }
515
516    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
517    #[cfg_attr(windows, ignore)]
518    #[tokio::test]
519    async fn use_unexpired_cached_token() {
520        let fs = Fs::from_slice(&[(
521            "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json",
522            r#"
523            { "accessToken": "some-token",
524              "expiresAt": "1975-01-01T00:00:00Z" }
525            "#,
526        )]);
527
528        let now = time("1974-12-25T00:00:00Z");
529        let time_source = SharedTimeSource::new(StaticTimeSource::new(now));
530
531        let (conn, req_rx) = capture_request(None);
532        let harness = TestHarness::new(time_source, TokioSleep::new(), conn, fs);
533
534        harness
535            .expect_token("some-token", "1975-01-01T00:00:00Z")
536            .await;
537        // it can't refresh this token
538        req_rx.expect_no_request();
539    }
540
541    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
542    #[cfg_attr(windows, ignore)]
543    #[tokio::test]
544    async fn expired_cached_token() {
545        let fs = Fs::from_slice(&[(
546            "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json",
547            r#"
548            { "accessToken": "some-token",
549              "expiresAt": "1999-12-15T00:00:00Z" }
550            "#,
551        )]);
552
553        let now = time("2023-01-01T00:00:00Z");
554        let time_source = SharedTimeSource::new(StaticTimeSource::new(now));
555
556        let (conn, req_rx) = capture_request(None);
557        let harness = TestHarness::new(time_source, TokioSleep::new(), conn, fs);
558
559        harness.expect_expired_token_err().await;
560        // it can't refresh this token
561        req_rx.expect_no_request();
562    }
563
564    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
565    #[cfg_attr(windows, ignore)]
566    #[tokio::test]
567    async fn expired_token_and_expired_client_registration() {
568        let fs = Fs::from_slice(&[(
569            "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json",
570            r#"
571            { "startUrl": "https://d-123.awsapps.com/start",
572              "region": "us-west-2",
573              "accessToken": "cachedtoken",
574              "expiresAt": "2021-10-25T13:00:00Z",
575              "clientId": "clientid",
576              "clientSecret": "YSBzZWNyZXQ=",
577              "registrationExpiresAt": "2021-11-25T13:30:00Z",
578              "refreshToken": "cachedrefreshtoken" }
579            "#,
580        )]);
581
582        let now = time("2023-08-11T04:11:17Z");
583        let time_source = SharedTimeSource::new(StaticTimeSource::new(now));
584
585        let (conn, req_rx) = capture_request(None);
586        let harness = TestHarness::new(time_source, TokioSleep::new(), conn, fs);
587
588        // the registration has expired, so the token can't be refreshed
589        harness.expect_expired_token_err().await;
590        req_rx.expect_no_request();
591    }
592
593    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
594    #[cfg_attr(windows, ignore)]
595    #[tokio::test]
596    async fn expired_token_refresh_with_refresh_token() {
597        let fs = Fs::from_slice(&[(
598            "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json",
599            r#"
600            { "startUrl": "https://d-123.awsapps.com/start",
601              "region": "us-west-2",
602              "accessToken": "cachedtoken",
603              "expiresAt": "2021-12-25T13:00:00Z",
604              "clientId": "clientid",
605              "clientSecret": "YSBzZWNyZXQ=",
606              "registrationExpiresAt": "2022-12-25T13:30:00Z",
607              "refreshToken": "cachedrefreshtoken" }
608            "#,
609        )]);
610
611        let now = time("2021-12-25T13:30:00Z");
612        let time_source = SharedTimeSource::new(StaticTimeSource::new(now));
613
614        let (conn, req_rx) = capture_request(Some(
615            http::Response::builder()
616                .status(200)
617                .body(SdkBody::from(
618                    r#"
619                    { "tokenType": "Bearer",
620                      "accessToken": "newtoken",
621                      "expiresIn": 28800,
622                      "refreshToken": "newrefreshtoken" }
623                    "#,
624                ))
625                .unwrap(),
626        ));
627        let harness = TestHarness::new(time_source, TokioSleep::new(), conn, fs);
628
629        let returned_token = harness
630            .expect_sso_token("newtoken", "2021-12-25T21:30:00Z")
631            .await;
632        let cached_token = load_cached_token(&harness.env, &harness.fs, "test")
633            .await
634            .unwrap();
635        assert_eq!(returned_token, cached_token);
636        assert_eq!(
637            "newrefreshtoken",
638            returned_token.refresh_token.unwrap().as_str()
639        );
640        assert_eq!(
641            "https://d-123.awsapps.com/start",
642            returned_token.start_url.unwrap()
643        );
644        assert_eq!("us-west-2", returned_token.region.unwrap().to_string());
645        assert_eq!("clientid", returned_token.client_id.unwrap());
646        assert_eq!(
647            "YSBzZWNyZXQ=",
648            returned_token.client_secret.unwrap().as_str()
649        );
650        assert_eq!(
651            SystemTime::UNIX_EPOCH + Duration::from_secs(1_671_975_000),
652            returned_token.registration_expires_at.unwrap()
653        );
654
655        let refresh_req = req_rx.expect_request();
656        let parsed_req: serde_json::Value =
657            serde_json::from_slice(refresh_req.body().bytes().unwrap()).unwrap();
658        let parsed_req = parsed_req.as_object().unwrap();
659        assert_eq!(
660            "clientid",
661            parsed_req.get("clientId").unwrap().as_str().unwrap()
662        );
663        assert_eq!(
664            "YSBzZWNyZXQ=",
665            parsed_req.get("clientSecret").unwrap().as_str().unwrap()
666        );
667        assert_eq!(
668            "refresh_token",
669            parsed_req.get("grantType").unwrap().as_str().unwrap()
670        );
671        assert_eq!(
672            "cachedrefreshtoken",
673            parsed_req.get("refreshToken").unwrap().as_str().unwrap()
674        );
675    }
676
677    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
678    #[cfg_attr(windows, ignore)]
679    #[tokio::test]
680    async fn expired_token_refresh_fails() {
681        let fs = Fs::from_slice(&[(
682            "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json",
683            r#"
684            { "startUrl": "https://d-123.awsapps.com/start",
685              "region": "us-west-2",
686              "accessToken": "cachedtoken",
687              "expiresAt": "2021-12-25T13:00:00Z",
688              "clientId": "clientid",
689              "clientSecret": "YSBzZWNyZXQ=",
690              "registrationExpiresAt": "2022-12-25T13:30:00Z",
691              "refreshToken": "cachedrefreshtoken" }
692            "#,
693        )]);
694
695        let now = time("2021-12-25T13:30:00Z");
696        let time_source = SharedTimeSource::new(StaticTimeSource::new(now));
697
698        let (conn, req_rx) = capture_request(Some(
699            http::Response::builder()
700                .status(500)
701                .body(SdkBody::from(""))
702                .unwrap(),
703        ));
704        let harness = TestHarness::new(time_source, TokioSleep::new(), conn, fs);
705
706        // it should return the previous token since refresh failed and it hasn't expired yet
707        let returned_token = harness
708            .expect_sso_token("cachedtoken", "2021-12-25T13:00:00Z")
709            .await;
710        let cached_token = load_cached_token(&harness.env, &harness.fs, "test")
711            .await
712            .unwrap();
713        assert_eq!(returned_token, cached_token);
714
715        let _ = req_rx.expect_request();
716    }
717
718    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
719    #[cfg_attr(windows, ignore)]
720    // Expired token refresh without new refresh token
721    #[tokio::test]
722    async fn expired_token_refresh_without_new_refresh_token() {
723        let fs = Fs::from_slice(&[(
724            "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json",
725            r#"
726            { "startUrl": "https://d-123.awsapps.com/start",
727              "region": "us-west-2",
728              "accessToken": "cachedtoken",
729              "expiresAt": "2021-12-25T13:00:00Z",
730              "clientId": "clientid",
731              "clientSecret": "YSBzZWNyZXQ=",
732              "registrationExpiresAt": "2022-12-25T13:30:00Z",
733              "refreshToken": "cachedrefreshtoken" }
734            "#,
735        )]);
736
737        let now = time("2021-12-25T13:30:00Z");
738        let time_source = SharedTimeSource::new(StaticTimeSource::new(now));
739
740        let (conn, req_rx) = capture_request(Some(
741            http::Response::builder()
742                .status(200)
743                .body(SdkBody::from(
744                    r#"
745                    { "tokenType": "Bearer",
746                      "accessToken": "newtoken",
747                      "expiresIn": 28800 }
748                    "#,
749                ))
750                .unwrap(),
751        ));
752        let harness = TestHarness::new(time_source, TokioSleep::new(), conn, fs);
753
754        let returned_token = harness
755            .expect_sso_token("newtoken", "2021-12-25T21:30:00Z")
756            .await;
757        let cached_token = load_cached_token(&harness.env, &harness.fs, "test")
758            .await
759            .unwrap();
760        assert_eq!(returned_token, cached_token);
761        assert_eq!(
762            "cachedrefreshtoken",
763            returned_token.refresh_token.unwrap().as_str(),
764            "it should have kept the old refresh token"
765        );
766
767        let _ = req_rx.expect_request();
768    }
769
770    // TODO(https://github.com/awslabs/aws-sdk-rust/issues/1117) This test is ignored on Windows because it uses Unix-style paths
771    #[cfg_attr(windows, ignore)]
772    #[tokio::test]
773    async fn refresh_timings() {
774        let _logs = capture_test_logs();
775
776        let start_time = DateTime::from_str("2023-01-01T00:00:00Z", Format::DateTime).unwrap();
777        let (time_source, sleep_impl) = instant_time_and_sleep(start_time.try_into().unwrap());
778        let shared_time_source = SharedTimeSource::new(time_source.clone());
779
780        let fs = Fs::from_slice(&[(
781            "/home/user/.aws/sso/cache/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.json",
782            r#"
783            { "startUrl": "https://d-123.awsapps.com/start",
784              "region": "us-west-2",
785              "accessToken": "first_token",
786              "_comment_expiresAt": "-------- Ten minutes after the start time: ------",
787              "expiresAt": "2023-01-01T00:10:00Z",
788              "clientId": "clientid",
789              "clientSecret": "YSBzZWNyZXQ=",
790              "registrationExpiresAt": "2023-01-02T12:00:00Z",
791              "refreshToken": "cachedrefreshtoken" }
792            "#,
793        )]);
794
795        let events = vec![
796            // First refresh attempt should fail
797            ReplayEvent::new(
798                http::Request::new(SdkBody::from("")), // don't really care what the request looks like
799                http::Response::builder()
800                    .status(500)
801                    .body(SdkBody::from(""))
802                    .unwrap(),
803            ),
804            // Second refresh attempt should also fail
805            ReplayEvent::new(
806                http::Request::new(SdkBody::from("")), // don't really care what the request looks like
807                http::Response::builder()
808                    .status(500)
809                    .body(SdkBody::from(""))
810                    .unwrap(),
811            ),
812            // Third refresh attempt will succeed
813            ReplayEvent::new(
814                http::Request::new(SdkBody::from("")), // don't really care what the request looks like
815                http::Response::builder()
816                    .status(200)
817                    .body(SdkBody::from(
818                        r#"
819                        { "tokenType": "Bearer",
820                          "accessToken": "second_token",
821                          "expiresIn": 28800 }
822                        "#,
823                    ))
824                    .unwrap(),
825            ),
826        ];
827        let http_client = StaticReplayClient::new(events);
828        let harness = TestHarness::new(shared_time_source, sleep_impl, http_client, fs);
829
830        tracing::info!("test: first token retrieval should return the cached token");
831        assert!(
832            harness.last_refresh_attempt_time().is_none(),
833            "the last attempt time should start empty"
834        );
835        harness
836            .expect_token("first_token", "2023-01-01T00:10:00Z")
837            .await;
838        assert!(
839            harness.last_refresh_attempt_time().is_none(),
840            "it shouldn't have tried to refresh, so the last refresh attempt time shouldn't be set"
841        );
842
843        tracing::info!("test: advance 3 minutes");
844        time_source.advance(Duration::from_secs(3 * 60));
845
846        tracing::info!("test: the token shouldn't get refreshed since it's not in the 5 minute buffer time yet");
847        harness
848            .expect_token("first_token", "2023-01-01T00:10:00Z")
849            .await;
850        assert!(
851            harness.last_refresh_attempt_time().is_none(),
852            "it shouldn't have tried to refresh since the token isn't expiring soon"
853        );
854
855        tracing::info!("test: advance 2 minutes");
856        time_source.advance(Duration::from_secs(2 * 60));
857
858        tracing::info!(
859            "test: the token will fail to refresh, and the old cached token will be returned"
860        );
861        harness
862            .expect_token("first_token", "2023-01-01T00:10:00Z")
863            .await;
864        assert_eq!(
865            Some("2023-01-01T00:05:00Z"),
866            harness.last_refresh_attempt_time().as_deref(),
867            "it should update the last refresh attempt time since the expiration time is soon"
868        );
869
870        tracing::info!("test: advance 15 seconds");
871        time_source.advance(Duration::from_secs(15));
872
873        tracing::info!(
874            "test: the token will not refresh because the minimum time hasn't passed between attempts"
875        );
876        harness
877            .expect_token("first_token", "2023-01-01T00:10:00Z")
878            .await;
879
880        tracing::info!("test: advance 15 seconds");
881        time_source.advance(Duration::from_secs(15));
882
883        tracing::info!(
884            "test: the token will fail to refresh, and the old cached token will be returned"
885        );
886        harness
887            .expect_token("first_token", "2023-01-01T00:10:00Z")
888            .await;
889
890        tracing::info!("test: advance 30 seconds");
891        time_source.advance(Duration::from_secs(30));
892
893        tracing::info!("test: the token will refresh successfully");
894        harness
895            .expect_token("second_token", "2023-01-01T08:06:00Z")
896            .await;
897    }
898}