Skip to main content

stack_auth/
auto_refresh.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2
3use tokio::sync::{Mutex, MutexGuard, Notify};
4
5use crate::clock::{system_clock, SharedClock};
6use crate::refresher::Refresher;
7use crate::token_store::{NoStore, TokenStore};
8use crate::{ServiceToken, Token};
9
10/// Internal errors from [`AutoRefresh::get_token`].
11///
12/// Strategy wrappers convert these into [`AuthError`](crate::AuthError) for the
13/// public API.
14#[derive(Debug, thiserror::Error)]
15pub(crate) enum AutoRefreshError {
16    /// No token is cached and the strategy cannot self-authenticate.
17    #[error("No token found")]
18    NotFound,
19    /// The token has expired and refresh failed or is unavailable.
20    #[error("Token has expired")]
21    Expired,
22    /// The refresh/auth HTTP call failed.
23    #[error("Auth error: {0}")]
24    Auth(#[from] crate::AuthError),
25}
26
27impl From<AutoRefreshError> for crate::AuthError {
28    fn from(err: AutoRefreshError) -> Self {
29        match err {
30            AutoRefreshError::NotFound => crate::AuthError::NotAuthenticated,
31            AutoRefreshError::Expired => crate::AuthError::TokenExpired,
32            AutoRefreshError::Auth(e) => e,
33        }
34    }
35}
36
37/// Caches a token in memory and uses a [`Refresher`] to re-authenticate
38/// or refresh before expiry, optionally backed by an external [`TokenStore`]
39/// for persistence across short-lived strategy instances.
40///
41/// See the [crate-level documentation](crate#token-refresh) for a full
42/// description of the concurrency model and flow diagram.
43pub(crate) struct AutoRefresh<R, S = NoStore> {
44    refresher: R,
45    state: Mutex<State>,
46    store: S,
47    /// Set to `true` while a refresh HTTP call is in-flight.
48    ///
49    /// Stored as an [`AtomicBool`] rather than inside [`State`] so that
50    /// [`CancelGuard`] can reset it on future cancellation without acquiring
51    /// the mutex.
52    refresh_in_progress: AtomicBool,
53    refresh_notify: Notify,
54    /// Source of "now" for token-expiry checks. [`SystemClock`](crate::clock::SystemClock)
55    /// in production; an injected clock in tests so expiry is deterministic.
56    clock: SharedClock,
57}
58
59struct State {
60    token: Option<Token>,
61}
62
63/// Ensures [`AutoRefresh::refresh_in_progress`] is cleared and waiters are
64/// notified if the refresh future is cancelled (dropped) before completing.
65///
66/// On the normal path (success or handled error), the guard is defused before
67/// drop so that the regular cleanup code runs instead.
68struct CancelGuard<'a> {
69    in_progress: &'a AtomicBool,
70    notify: &'a Notify,
71    defused: bool,
72}
73
74impl Drop for CancelGuard<'_> {
75    fn drop(&mut self) {
76        if !self.defused {
77            self.in_progress.store(false, Ordering::Release);
78            self.notify.notify_waiters();
79        }
80    }
81}
82
83impl CancelGuard<'_> {
84    fn defuse(&mut self) {
85        self.defused = true;
86    }
87}
88
89impl State {
90    fn service_token(&self) -> Result<ServiceToken, AutoRefreshError> {
91        let token = self.token.as_ref().ok_or(AutoRefreshError::NotFound)?;
92        Ok(ServiceToken::new(token.access_token().clone()))
93    }
94
95    fn require_usable_token(&self, now: u64) -> Result<ServiceToken, AutoRefreshError> {
96        let token = self.token.as_ref().ok_or(AutoRefreshError::NotFound)?;
97        if token.is_usable_at(now) {
98            Ok(ServiceToken::new(token.access_token().clone()))
99        } else {
100            Err(AutoRefreshError::Expired)
101        }
102    }
103}
104
105impl<R> AutoRefresh<R, NoStore> {
106    /// Create a new `AutoRefresh` with a pre-loaded token and no external store.
107    ///
108    /// Use this for refreshers that cannot self-authenticate (e.g. OAuth,
109    /// which needs a refresh token from a prior device code flow).
110    pub(crate) fn with_token(refresher: R, token: Token) -> Self {
111        Self {
112            refresher,
113            state: Mutex::new(State { token: Some(token) }),
114            store: NoStore,
115            refresh_in_progress: AtomicBool::new(false),
116            refresh_notify: Notify::new(),
117            clock: system_clock(),
118        }
119    }
120
121    /// Like [`with_token`](Self::with_token) but with an injected clock, so tests
122    /// can drive token expiry deterministically.
123    #[cfg(test)]
124    pub(crate) fn with_token_and_clock(refresher: R, token: Token, clock: SharedClock) -> Self {
125        Self {
126            refresher,
127            state: Mutex::new(State { token: Some(token) }),
128            store: NoStore,
129            refresh_in_progress: AtomicBool::new(false),
130            refresh_notify: Notify::new(),
131            clock,
132        }
133    }
134}
135
136impl<R, S: TokenStore> AutoRefresh<R, S> {
137    /// Create a new `AutoRefresh` backed by `store` and no in-memory token.
138    ///
139    /// On the first `get_token` call the store is consulted before falling
140    /// through to initial authentication via `try_credential(None)`; every
141    /// successful refresh writes the new token back via `store.save()`. Pass
142    /// [`NoStore`] for the no-external-cache case — it's the default and
143    /// elides to a zero-cost no-op.
144    pub(crate) fn with_store(refresher: R, store: S) -> Self {
145        Self {
146            refresher,
147            state: Mutex::new(State { token: None }),
148            store,
149            refresh_in_progress: AtomicBool::new(false),
150            refresh_notify: Notify::new(),
151            clock: system_clock(),
152        }
153    }
154}
155
156impl<R: Refresher, S: TokenStore> AutoRefresh<R, S> {
157    /// Retrieve a valid access token, refreshing or re-authenticating as needed.
158    pub(crate) async fn get_token(&self) -> Result<ServiceToken, AutoRefreshError> {
159        let mut state = self.state.lock().await;
160
161        if state.token.is_none() {
162            // Drop the lock for the store read so a slow user-supplied backend
163            // (cookie, KV, Redis) doesn't serialise concurrent `get_token`
164            // callers. Re-acquire and double-check `state.token.is_none()` in
165            // case another caller populated it while we awaited.
166            drop(state);
167            let loaded = self.store.load().await;
168            state = self.state.lock().await;
169            if state.token.is_none() {
170                state.token = loaded;
171            }
172        }
173
174        if state.token.is_none() {
175            return self.initial_auth(&mut state).await;
176        }
177
178        // Read "now" once from the injected clock and use it for every expiry
179        // decision in this call, so the checks are mutually consistent and
180        // deterministic under test.
181        let now = self.clock.now_unix_secs();
182
183        if !state.token.as_ref().is_some_and(|t| t.is_expired_at(now)) {
184            return state.service_token();
185        }
186
187        if self.refresh_in_progress.load(Ordering::Acquire) {
188            return self.wait_for_in_flight_refresh(state, now).await;
189        }
190
191        let Some(credential) = self.refresher.try_credential(state.token.as_mut()) else {
192            return state.require_usable_token(now);
193        };
194
195        self.refresh_in_progress.store(true, Ordering::Release);
196
197        if state.token.as_ref().is_some_and(|t| t.is_usable_at(now)) {
198            self.refresh_non_blocking(state, credential).await
199        } else {
200            self.refresh_blocking(&mut state, credential).await
201        }
202    }
203
204    /// No cached token — authenticate via `try_credential(None)`.
205    ///
206    /// The lock is held throughout to prevent concurrent initial-auth attempts.
207    async fn initial_auth(&self, state: &mut State) -> Result<ServiceToken, AutoRefreshError> {
208        let Some(credential) = self.refresher.try_credential(None) else {
209            return Err(AutoRefreshError::NotFound);
210        };
211        self.refresh_in_progress.store(true, Ordering::Release);
212        let mut guard = CancelGuard {
213            in_progress: &self.refresh_in_progress,
214            notify: &self.refresh_notify,
215            defused: false,
216        };
217        match self.refresher.refresh(&credential).await {
218            Ok(new_token) => {
219                self.save_refreshed_token(&new_token).await;
220                let token = self.install_refreshed_token(state, new_token);
221                guard.defuse();
222                Ok(token)
223            }
224            Err(err) => {
225                guard.defuse();
226                self.refresh_in_progress.store(false, Ordering::Release);
227                Err(AutoRefreshError::Auth(err))
228            }
229        }
230    }
231
232    /// Persist a freshly refreshed token to the per-refresher sink and the
233    /// user-supplied `TokenStore`. Awaits the store write, so callers should
234    /// drop the state lock before invoking this where possible (the
235    /// non-blocking refresh path does; the blocking/initial paths hold the
236    /// state lock throughout by design).
237    async fn save_refreshed_token(&self, new_token: &Token) {
238        self.refresher.save(new_token);
239        self.store.save(new_token).await;
240    }
241
242    /// Install a freshly refreshed token in `state`, clear the in-progress
243    /// flag, and return the corresponding [`ServiceToken`]. Pure in-lock
244    /// work; caller is responsible for having already persisted the token via
245    /// [`save_refreshed_token`](Self::save_refreshed_token).
246    fn install_refreshed_token(&self, state: &mut State, new_token: Token) -> ServiceToken {
247        let service_token = ServiceToken::new(new_token.access_token().clone());
248        state.token = Some(new_token);
249        self.refresh_in_progress.store(false, Ordering::Release);
250        service_token
251    }
252
253    /// Another caller is already refreshing — return the current token if still
254    /// usable, otherwise wait for the in-flight refresh to complete via `Notify`.
255    ///
256    /// Takes `MutexGuard` by value because the lock is dropped before awaiting
257    /// the notification.
258    async fn wait_for_in_flight_refresh(
259        &self,
260        state: MutexGuard<'_, State>,
261        now: u64,
262    ) -> Result<ServiceToken, AutoRefreshError> {
263        if let Ok(token) = state.service_token() {
264            if state.token.as_ref().is_some_and(|t| t.is_usable_at(now)) {
265                return Ok(token);
266            }
267        }
268        // Token crossed real expiry during in-flight refresh. Wait for the
269        // refresh to complete rather than returning Expired.
270        let notified = self.refresh_notify.notified();
271        drop(state);
272        notified.await;
273        // Re-check after wake — refresh may have failed. Re-read the clock: an
274        // arbitrary amount of time may have passed while awaiting the refresh.
275        let now = self.clock.now_unix_secs();
276        let state = self.state.lock().await;
277        state.require_usable_token(now)
278    }
279
280    /// Token is expiring but still usable — drop the lock, refresh in the
281    /// background of this call, and return the old (still-valid) token.
282    ///
283    /// Takes `MutexGuard` by value because the lock is dropped before the HTTP
284    /// request. Notifies waiters after the refresh completes (success or error).
285    ///
286    /// A [`CancelGuard`] ensures that if this future is cancelled at any point
287    /// before the new token is installed — including the post-HTTP save +
288    /// install window — `refresh_in_progress` is cleared and waiters are
289    /// notified, so subsequent callers don't hang in
290    /// [`wait_for_in_flight_refresh`](Self::wait_for_in_flight_refresh).
291    /// The credential is not restored on cancellation (it's already gone from
292    /// `state.token`), so the next caller will get whatever the cached token
293    /// offers — usable, expired, or absent.
294    async fn refresh_non_blocking(
295        &self,
296        state: MutexGuard<'_, State>,
297        credential: R::Credential,
298    ) -> Result<ServiceToken, AutoRefreshError> {
299        let current_service_token = state.service_token()?;
300        drop(state);
301
302        let mut guard = CancelGuard {
303            in_progress: &self.refresh_in_progress,
304            notify: &self.refresh_notify,
305            defused: false,
306        };
307
308        match self.refresher.refresh(&credential).await {
309            Ok(new_token) => {
310                self.save_refreshed_token(&new_token).await;
311                let mut state = self.state.lock().await;
312                let _ = self.install_refreshed_token(&mut state, new_token);
313                guard.defuse();
314            }
315            Err(err) => {
316                tracing::warn!(%err, "token refresh failed (token still usable)");
317                // Defer `defuse()` until after the lock acquire so the
318                // CancelGuard's Drop still fires if cancellation lands on
319                // `state.lock().await`. Without this the in-progress flag
320                // would stay set with no `notify_waiters`, wedging every
321                // subsequent caller exactly like the Ok-path bug fixed
322                // earlier in this file.
323                let mut state = self.state.lock().await;
324                if let Some(token) = state.token.as_mut() {
325                    self.refresher.restore(token, credential);
326                }
327                self.refresh_in_progress.store(false, Ordering::Release);
328                guard.defuse();
329            }
330        }
331
332        self.refresh_notify.notify_waiters();
333        Ok(current_service_token)
334    }
335
336    /// Token is fully expired — refresh while holding the lock so concurrent
337    /// callers block on `lock().await` until the new token is available.
338    ///
339    /// A [`CancelGuard`] ensures that if this future is cancelled at any point
340    /// before the new token is installed — including the post-HTTP save
341    /// window — `refresh_in_progress` is cleared and waiters are notified so
342    /// they don't hang indefinitely. (The credential is lost on cancel —
343    /// see [`CancelGuard`] docs — but subsequent callers will get `Expired`
344    /// rather than blocking forever.)
345    async fn refresh_blocking(
346        &self,
347        state: &mut State,
348        credential: R::Credential,
349    ) -> Result<ServiceToken, AutoRefreshError> {
350        let mut guard = CancelGuard {
351            in_progress: &self.refresh_in_progress,
352            notify: &self.refresh_notify,
353            defused: false,
354        };
355        match self.refresher.refresh(&credential).await {
356            Ok(new_token) => {
357                self.save_refreshed_token(&new_token).await;
358                let token = self.install_refreshed_token(state, new_token);
359                guard.defuse();
360                Ok(token)
361            }
362            Err(err) => {
363                guard.defuse();
364                tracing::warn!(%err, "token refresh failed");
365                if let Some(token) = state.token.as_mut() {
366                    self.refresher.restore(token, credential);
367                }
368                self.refresh_in_progress.store(false, Ordering::Release);
369                Err(AutoRefreshError::Expired)
370            }
371        }
372    }
373}
374
375#[cfg(test)]
376#[allow(clippy::unwrap_used)]
377mod tests {
378    use super::*;
379    use crate::device_session_refresher::DeviceSessionRefresher;
380    use crate::SecretToken;
381    use mocktail::prelude::*;
382    use stack_profile::ProfileStore;
383    use std::sync::Arc;
384    use std::time::{SystemTime, UNIX_EPOCH};
385
386    #[test]
387    fn auto_refresh_error_maps_to_public_auth_error() {
388        use crate::AuthError;
389        assert!(matches!(
390            AuthError::from(AutoRefreshError::NotFound),
391            AuthError::NotAuthenticated
392        ));
393        assert!(matches!(
394            AuthError::from(AutoRefreshError::Expired),
395            AuthError::TokenExpired
396        ));
397        // The `Auth` variant passes the inner error through unchanged.
398        assert!(matches!(
399            AuthError::from(AutoRefreshError::Auth(AuthError::AccessDenied)),
400            AuthError::AccessDenied
401        ));
402    }
403
404    fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token {
405        let now = SystemTime::now()
406            .duration_since(UNIX_EPOCH)
407            .unwrap()
408            .as_secs();
409
410        Token {
411            access_token: SecretToken::new(access),
412            token_type: "Bearer".to_string(),
413            expires_at: now + expires_in,
414            refresh_token: if refresh {
415                Some(SecretToken::new("test-refresh-token"))
416            } else {
417                None
418            },
419            region: None,
420            client_id: None,
421            device_instance_id: None,
422        }
423    }
424
425    fn refresh_response_json(access: &str) -> serde_json::Value {
426        serde_json::json!({
427            "access_token": access,
428            "token_type": "Bearer",
429            "expires_in": 3600,
430            "refresh_token": "new-refresh-token"
431        })
432    }
433
434    fn error_json(error: &str) -> serde_json::Value {
435        serde_json::json!({
436            "error": error,
437            "error_description": format!("{error} occurred")
438        })
439    }
440
441    async fn start_server(mocks: MockSet) -> MockServer {
442        let server = MockServer::new_http("auto-refresh-test").with_mocks(mocks);
443        server.start().await.unwrap();
444        server
445    }
446
447    fn auto_refresh_with_token(
448        dir: &tempfile::TempDir,
449        server: &MockServer,
450        token: Token,
451    ) -> AutoRefresh<DeviceSessionRefresher> {
452        let store = ProfileStore::new(dir.path());
453        store.init_workspace("ZVATKW3VHMFG27DY").unwrap();
454        let ws_store = store.current_workspace_store().unwrap();
455        ws_store.save_profile(&token).unwrap();
456        let refresher = DeviceSessionRefresher::new(
457            Some(ws_store),
458            server.url(""),
459            "cli",
460            "ap-southeast-2.aws",
461            None,
462        );
463        AutoRefresh::with_token(refresher, token)
464    }
465
466    mod given_no_cached_token {
467        use super::*;
468
469        #[tokio::test]
470        async fn returns_not_found_for_oauth() {
471            let server = start_server(MockSet::new()).await;
472            let store = ProfileStore::new("/tmp/nonexistent");
473            let refresher = DeviceSessionRefresher::new(
474                Some(store),
475                server.url(""),
476                "cli",
477                "ap-southeast-2.aws",
478                None,
479            );
480            let strategy = AutoRefresh::with_store(refresher, NoStore);
481
482            let err = strategy.get_token().await.unwrap_err();
483
484            assert!(
485                matches!(err, AutoRefreshError::NotFound),
486                "expected NotFound, got: {err:?}"
487            );
488        }
489    }
490
491    mod given_fresh_token {
492        use super::*;
493
494        #[tokio::test]
495        async fn returns_cached_token() {
496            let dir = tempfile::tempdir().unwrap();
497            let server = start_server(MockSet::new()).await;
498            let strategy =
499                auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false));
500
501            let token = strategy.get_token().await.unwrap();
502
503            assert_eq!(
504                token.as_str(),
505                "my-access-token",
506                "should return the cached access token"
507            );
508        }
509
510        #[tokio::test]
511        async fn caches_across_calls() {
512            let dir = tempfile::tempdir().unwrap();
513            let server = start_server(MockSet::new()).await;
514            let strategy =
515                auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false));
516
517            let token1 = strategy.get_token().await.unwrap();
518            assert_eq!(
519                token1.as_str(),
520                "my-access-token",
521                "first call should return the cached token"
522            );
523
524            // Delete the file — second call should still return the cached token.
525            std::fs::remove_file(
526                dir.path()
527                    .join("workspaces")
528                    .join("ZVATKW3VHMFG27DY")
529                    .join("auth.json"),
530            )
531            .unwrap();
532
533            let token2 = strategy.get_token().await.unwrap();
534            assert_eq!(
535                token2.as_str(),
536                "my-access-token",
537                "second call should return the cached token even after file deletion"
538            );
539        }
540
541        #[tokio::test]
542        async fn does_not_trigger_refresh() {
543            // Mock that would fail if hit — proves no refresh request is made.
544            let mut mocks = MockSet::new();
545            mocks.mock(|when, then| {
546                when.post().path("/oauth/token");
547                then.internal_server_error()
548                    .json(error_json("should_not_be_called"));
549            });
550            let server = start_server(mocks).await;
551            let dir = tempfile::tempdir().unwrap();
552            let strategy =
553                auto_refresh_with_token(&dir, &server, make_token("fresh-token", 3600, true));
554
555            let token = strategy.get_token().await.unwrap();
556
557            assert_eq!(
558                token.as_str(),
559                "fresh-token",
560                "should return fresh token without triggering refresh"
561            );
562        }
563    }
564
565    mod given_fully_expired_token {
566        use super::*;
567
568        mod without_refresh_token {
569            use super::*;
570
571            #[tokio::test]
572            async fn returns_expired() {
573                let dir = tempfile::tempdir().unwrap();
574                let server = start_server(MockSet::new()).await;
575                let strategy =
576                    auto_refresh_with_token(&dir, &server, make_token("old-token", 0, false));
577
578                let err = strategy.get_token().await.unwrap_err();
579
580                assert!(
581                    matches!(err, AutoRefreshError::Expired),
582                    "expected Expired, got: {err:?}"
583                );
584            }
585        }
586
587        mod with_refresh_token {
588            use super::*;
589
590            #[tokio::test]
591            async fn refreshes_and_returns_new_token() {
592                let mut mocks = MockSet::new();
593                mocks.mock(|when, then| {
594                    when.post().path("/oauth/token");
595                    then.json(refresh_response_json("refreshed-token"));
596                });
597                let server = start_server(mocks).await;
598                let dir = tempfile::tempdir().unwrap();
599                let strategy =
600                    auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true));
601
602                let token = strategy.get_token().await.unwrap();
603
604                assert_eq!(
605                    token.as_str(),
606                    "refreshed-token",
607                    "should return the refreshed token"
608                );
609            }
610
611            #[tokio::test]
612            async fn persists_refreshed_token_to_disk() {
613                let mut mocks = MockSet::new();
614                mocks.mock(|when, then| {
615                    when.post().path("/oauth/token");
616                    then.json(refresh_response_json("refreshed-token"));
617                });
618                let server = start_server(mocks).await;
619                let dir = tempfile::tempdir().unwrap();
620                let strategy =
621                    auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true));
622
623                let _ = strategy.get_token().await.unwrap();
624
625                // Verify the refreshed token was saved to the workspace directory.
626                let store = ProfileStore::new(dir.path());
627                let ws_store = store.current_workspace_store().unwrap();
628                let on_disk: Token = ws_store.load_profile().unwrap();
629                assert_eq!(
630                    on_disk.access_token().as_str(),
631                    "refreshed-token",
632                    "refreshed token should be persisted to disk"
633                );
634            }
635
636            #[tokio::test]
637            async fn returns_expired_on_refresh_failure() {
638                let mut mocks = MockSet::new();
639                mocks.mock(|when, then| {
640                    when.post().path("/oauth/token");
641                    then.bad_request().json(error_json("invalid_grant"));
642                });
643                let server = start_server(mocks).await;
644                let dir = tempfile::tempdir().unwrap();
645                let strategy =
646                    auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true));
647
648                let err = strategy.get_token().await.unwrap_err();
649
650                assert!(
651                    matches!(err, AutoRefreshError::Expired),
652                    "expected Expired after failed refresh, got: {err:?}"
653                );
654            }
655
656            #[tokio::test]
657            async fn restores_refresh_token_after_failure() {
658                let mut mocks = MockSet::new();
659                mocks.mock(|when, then| {
660                    when.post().path("/oauth/token");
661                    then.bad_request().json(error_json("invalid_grant"));
662                });
663                let server = start_server(mocks).await;
664                let dir = tempfile::tempdir().unwrap();
665                let strategy =
666                    auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true));
667
668                // First call: refresh fails, returns Expired.
669                let err = strategy.get_token().await.unwrap_err();
670                assert!(
671                    matches!(err, AutoRefreshError::Expired),
672                    "expected Expired on first attempt, got: {err:?}"
673                );
674
675                // Verify the refresh token was restored so a retry is possible.
676                let state = strategy.state.lock().await;
677                assert!(
678                    state.token.is_some(),
679                    "token should still be cached after failed refresh"
680                );
681                assert!(
682                    state.token.as_ref().unwrap().refresh_token().is_some(),
683                    "refresh token should be restored for retry"
684                );
685                drop(state);
686
687                // Replace mock with a success response.
688                server.mocks().clear();
689                server.mocks().mock(|when, then| {
690                    when.post().path("/oauth/token");
691                    then.json(refresh_response_json("refreshed-token"));
692                });
693
694                // Second call: refresh token is available → retry succeeds.
695                let token = strategy.get_token().await.unwrap();
696                assert_eq!(
697                    token.as_str(),
698                    "refreshed-token",
699                    "retry should succeed with restored refresh token"
700                );
701            }
702
703            #[tokio::test]
704            async fn sequential_calls_only_refresh_once() {
705                let mut mocks = MockSet::new();
706                mocks.mock(|when, then| {
707                    when.post().path("/oauth/token");
708                    then.json(refresh_response_json("refreshed-once"));
709                });
710                let server = start_server(mocks).await;
711                let dir = tempfile::tempdir().unwrap();
712                let strategy =
713                    auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true));
714
715                // First call triggers refresh.
716                let token = strategy.get_token().await.unwrap();
717                assert_eq!(
718                    token.as_str(),
719                    "refreshed-once",
720                    "first call should trigger refresh"
721                );
722
723                // Swap mock to track if another refresh is attempted.
724                server.mocks().clear();
725                server.mocks().mock(|when, then| {
726                    when.post().path("/oauth/token");
727                    then.json(refresh_response_json("refreshed-twice"));
728                });
729
730                // Calls 2-5: the refreshed token is fresh, so no further refresh.
731                for _ in 0..4 {
732                    let token = strategy.get_token().await.unwrap();
733                    assert_eq!(
734                        token.as_str(),
735                        "refreshed-once",
736                        "should return cached refreshed token, not trigger another refresh"
737                    );
738                }
739            }
740
741            #[tokio::test]
742            async fn prevents_second_refresh_after_success() {
743                let mut mocks = MockSet::new();
744                mocks.mock(|when, then| {
745                    when.post().path("/oauth/token");
746                    then.json(refresh_response_json("refreshed-token"));
747                });
748                let server = start_server(mocks).await;
749                let dir = tempfile::tempdir().unwrap();
750                let strategy =
751                    auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true));
752
753                // First call refreshes successfully.
754                let token = strategy.get_token().await.unwrap();
755                assert_eq!(
756                    token.as_str(),
757                    "refreshed-token",
758                    "first call should refresh the token"
759                );
760
761                // Replace the mock with one that errors.
762                server.mocks().clear();
763                server.mocks().mock(|when, then| {
764                    when.post().path("/oauth/token");
765                    then.bad_request().json(error_json("should_not_be_called"));
766                });
767
768                // Second call should return the refreshed token without hitting
769                // the server again (the new token has a fresh expiry).
770                let token = strategy.get_token().await.unwrap();
771                assert_eq!(
772                    token.as_str(),
773                    "refreshed-token",
774                    "second call should return cached refreshed token"
775                );
776            }
777        }
778    }
779
780    mod given_expiring_but_usable_token {
781        use super::*;
782
783        mod when_refresh_fails {
784            use super::*;
785
786            #[tokio::test]
787            async fn returns_current_token() {
788                let mut mocks = MockSet::new();
789                mocks.mock(|when, then| {
790                    when.post().path("/oauth/token");
791                    then.bad_request().json(error_json("server_error"));
792                });
793                let server = start_server(mocks).await;
794                let dir = tempfile::tempdir().unwrap();
795                // Token expires in 30s (within the 90s leeway so is_expired() = true),
796                // but the access token is still technically usable.
797                let strategy =
798                    auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true));
799
800                // The refresh fails, but the access token should still be returned
801                // because it's still usable (30s remaining > 0).
802                let token = strategy.get_token().await.unwrap();
803                assert_eq!(
804                    token.as_str(),
805                    "still-usable",
806                    "should return still-usable token despite failed refresh"
807                );
808
809                // Verify the access token and refresh token are still present.
810                let state = strategy.state.lock().await;
811                assert!(state.token.is_some(), "token should still be cached");
812                assert_eq!(
813                    state.token.as_ref().unwrap().access_token().as_str(),
814                    "still-usable",
815                    "access token should be unchanged after failed refresh"
816                );
817                assert!(
818                    state.token.as_ref().unwrap().refresh_token().is_some(),
819                    "refresh token should be restored after failed refresh"
820                );
821            }
822
823            #[tokio::test]
824            async fn restores_refresh_token_for_retry() {
825                let mut mocks = MockSet::new();
826                mocks.mock(|when, then| {
827                    when.post().path("/oauth/token");
828                    then.bad_request().json(error_json("server_error"));
829                });
830                let server = start_server(mocks).await;
831                let dir = tempfile::tempdir().unwrap();
832                // Token expires in 30s — is_expired() = true, is_usable() = true.
833                let strategy =
834                    auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true));
835
836                // First call: refresh fails, but the still-usable token is returned.
837                let token = strategy.get_token().await.unwrap();
838                assert_eq!(
839                    token.as_str(),
840                    "still-usable",
841                    "first call should return still-usable token"
842                );
843
844                // Replace mock with a success response.
845                server.mocks().clear();
846                server.mocks().mock(|when, then| {
847                    when.post().path("/oauth/token");
848                    then.json(refresh_response_json("refreshed-token"));
849                });
850
851                // Second call: refresh token was restored, so the retry succeeds.
852                let token = strategy.get_token().await.unwrap();
853                assert!(
854                    token.as_str() == "still-usable" || token.as_str() == "refreshed-token",
855                    "expected old or refreshed token, got: {}",
856                    token.as_str()
857                );
858
859                // Verify the cache now holds the refreshed token.
860                let state = strategy.state.lock().await;
861                assert_eq!(
862                    state.token.as_ref().unwrap().access_token().as_str(),
863                    "refreshed-token",
864                    "cache should hold the refreshed token after retry"
865                );
866            }
867        }
868    }
869
870    mod given_concurrent_callers {
871        use super::*;
872
873        #[tokio::test]
874        async fn returns_usable_token_while_refreshing() {
875            let mut mocks = MockSet::new();
876            mocks.mock(|when, then| {
877                when.post().path("/oauth/token");
878                then.json(refresh_response_json("refreshed-token"));
879            });
880            let server = start_server(mocks).await;
881            let dir = tempfile::tempdir().unwrap();
882            let strategy = Arc::new(auto_refresh_with_token(
883                &dir,
884                &server,
885                make_token("still-usable", 30, true),
886            ));
887
888            let s1 = Arc::clone(&strategy);
889            let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() });
890
891            let s2 = Arc::clone(&strategy);
892            let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() });
893
894            let (result_a, result_b) = tokio::join!(handle_a, handle_b);
895            let token_a = result_a.unwrap();
896            let token_b = result_b.unwrap();
897
898            assert!(
899                token_a.as_str() == "still-usable" || token_a.as_str() == "refreshed-token",
900                "unexpected token_a: {}",
901                token_a.as_str()
902            );
903            assert!(
904                token_b.as_str() == "still-usable" || token_b.as_str() == "refreshed-token",
905                "unexpected token_b: {}",
906                token_b.as_str()
907            );
908        }
909
910        #[tokio::test]
911        async fn blocks_until_refresh_completes() {
912            let mut mocks = MockSet::new();
913            mocks.mock(|when, then| {
914                when.post().path("/oauth/token");
915                then.json(refresh_response_json("refreshed-token"));
916            });
917            let server = start_server(mocks).await;
918            let dir = tempfile::tempdir().unwrap();
919            let strategy = Arc::new(auto_refresh_with_token(
920                &dir,
921                &server,
922                make_token("expired-token", 0, true),
923            ));
924
925            let s1 = Arc::clone(&strategy);
926            let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() });
927
928            let s2 = Arc::clone(&strategy);
929            let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() });
930
931            let (result_a, result_b) = tokio::join!(handle_a, handle_b);
932            let token_a = result_a.unwrap();
933            let token_b = result_b.unwrap();
934
935            assert_eq!(
936                token_a.as_str(),
937                "refreshed-token",
938                "caller a should receive refreshed token"
939            );
940            assert_eq!(
941                token_b.as_str(),
942                "refreshed-token",
943                "caller b should receive refreshed token"
944            );
945        }
946    }
947}
948
949#[cfg(test)]
950#[allow(clippy::unwrap_used)]
951mod stress_tests {
952    use super::*;
953    use crate::device_session_refresher::DeviceSessionRefresher;
954    use crate::SecretToken;
955    use stack_profile::ProfileStore;
956    use std::sync::atomic::{AtomicUsize, Ordering};
957    use std::sync::Arc;
958    use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
959
960    /// Tracks in-flight and peak concurrency for test assertions.
961    #[derive(Clone)]
962    struct CountingState {
963        total: Arc<AtomicUsize>,
964        current: Arc<AtomicUsize>,
965        peak: Arc<AtomicUsize>,
966    }
967
968    impl CountingState {
969        fn new() -> Self {
970            Self {
971                total: Arc::new(AtomicUsize::new(0)),
972                current: Arc::new(AtomicUsize::new(0)),
973                peak: Arc::new(AtomicUsize::new(0)),
974            }
975        }
976
977        fn enter(&self) {
978            self.total.fetch_add(1, Ordering::SeqCst);
979            let prev = self.current.fetch_add(1, Ordering::SeqCst);
980            self.peak.fetch_max(prev + 1, Ordering::SeqCst);
981        }
982
983        fn exit(&self) {
984            self.current.fetch_sub(1, Ordering::SeqCst);
985        }
986
987        fn peak(&self) -> usize {
988            self.peak.load(Ordering::SeqCst)
989        }
990
991        fn total(&self) -> usize {
992            self.total.load(Ordering::SeqCst)
993        }
994    }
995
996    #[derive(Clone)]
997    struct DelayedRefreshState {
998        counting: CountingState,
999        delay: Duration,
1000    }
1001
1002    async fn delayed_refresh_handler(
1003        axum::extract::State(state): axum::extract::State<DelayedRefreshState>,
1004    ) -> axum::Json<serde_json::Value> {
1005        state.counting.enter();
1006        tokio::time::sleep(state.delay).await;
1007        state.counting.exit();
1008        axum::Json(serde_json::json!({
1009            "access_token": "refreshed-token",
1010            "token_type": "Bearer",
1011            "expires_in": 3600,
1012            "refresh_token": "new-refresh-token"
1013        }))
1014    }
1015
1016    async fn delayed_error_handler(
1017        axum::extract::State(state): axum::extract::State<DelayedRefreshState>,
1018    ) -> (axum::http::StatusCode, axum::Json<serde_json::Value>) {
1019        state.counting.enter();
1020        tokio::time::sleep(state.delay).await;
1021        state.counting.exit();
1022        (
1023            axum::http::StatusCode::BAD_REQUEST,
1024            axum::Json(serde_json::json!({
1025                "error": "invalid_grant",
1026                "error_description": "invalid_grant occurred"
1027            })),
1028        )
1029    }
1030
1031    async fn start_axum_server<H, T>(
1032        handler: H,
1033        state: DelayedRefreshState,
1034    ) -> (url::Url, CountingState)
1035    where
1036        H: axum::handler::Handler<T, DelayedRefreshState> + Clone + Send + 'static,
1037        T: 'static,
1038    {
1039        let counting = state.counting.clone();
1040        let app = axum::Router::new()
1041            .route("/oauth/token", axum::routing::post(handler))
1042            .with_state(state);
1043        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1044        let addr = listener.local_addr().unwrap();
1045        tokio::spawn(async move {
1046            axum::serve(listener, app).await.unwrap();
1047        });
1048        let base_url = url::Url::parse(&format!("http://{addr}")).unwrap();
1049        (base_url, counting)
1050    }
1051
1052    fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token {
1053        let now = SystemTime::now()
1054            .duration_since(UNIX_EPOCH)
1055            .unwrap()
1056            .as_secs();
1057
1058        Token {
1059            access_token: SecretToken::new(access),
1060            token_type: "Bearer".to_string(),
1061            expires_at: now + expires_in,
1062            refresh_token: if refresh {
1063                Some(SecretToken::new("test-refresh-token"))
1064            } else {
1065                None
1066            },
1067            region: None,
1068            client_id: None,
1069            device_instance_id: None,
1070        }
1071    }
1072
1073    fn auto_refresh_with_token(
1074        dir: &tempfile::TempDir,
1075        base_url: &url::Url,
1076        token: Token,
1077    ) -> AutoRefresh<DeviceSessionRefresher> {
1078        let store = ProfileStore::new(dir.path());
1079        store.init_workspace("ZVATKW3VHMFG27DY").unwrap();
1080        let ws_store = store.current_workspace_store().unwrap();
1081        ws_store.save_profile(&token).unwrap();
1082        let refresher = DeviceSessionRefresher::new(
1083            Some(ws_store),
1084            base_url.clone(),
1085            "cli",
1086            "ap-southeast-2.aws",
1087            None,
1088        );
1089        AutoRefresh::with_token(refresher, token)
1090    }
1091
1092    const CONCURRENCY: usize = 50;
1093
1094    mod given_fresh_token {
1095        use super::*;
1096
1097        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1098        async fn all_callers_return_immediately() {
1099            let counting = CountingState::new();
1100            let state = DelayedRefreshState {
1101                counting: counting.clone(),
1102                delay: Duration::from_millis(500),
1103            };
1104            let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await;
1105            let dir = tempfile::tempdir().unwrap();
1106            let strategy = Arc::new(auto_refresh_with_token(
1107                &dir,
1108                &base_url,
1109                make_token("fresh-token", 3600, true),
1110            ));
1111
1112            let start = Instant::now();
1113            let mut handles = Vec::with_capacity(CONCURRENCY);
1114            for _ in 0..CONCURRENCY {
1115                let s = Arc::clone(&strategy);
1116                handles.push(tokio::spawn(async move { s.get_token().await.unwrap() }));
1117            }
1118
1119            let results: Vec<_> = {
1120                let mut results = Vec::with_capacity(handles.len());
1121                for handle in handles {
1122                    results.push(handle.await.unwrap());
1123                }
1124                results
1125            };
1126            let elapsed = start.elapsed();
1127
1128            for token in &results {
1129                assert_eq!(
1130                    token.as_str(),
1131                    "fresh-token",
1132                    "all callers should receive the fresh token"
1133                );
1134            }
1135
1136            assert!(
1137                elapsed < Duration::from_millis(200),
1138                "expected < 200ms for fresh tokens, got {:?}",
1139                elapsed
1140            );
1141            assert_eq!(stats.total(), 0, "no refresh requests should be made");
1142        }
1143    }
1144
1145    mod given_expiring_but_usable_token {
1146        use super::*;
1147
1148        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1149        async fn non_blocking_reads_during_refresh() {
1150            let counting = CountingState::new();
1151            let state = DelayedRefreshState {
1152                counting: counting.clone(),
1153                delay: Duration::from_millis(500),
1154            };
1155            let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await;
1156            let dir = tempfile::tempdir().unwrap();
1157            let strategy = Arc::new(auto_refresh_with_token(
1158                &dir,
1159                &base_url,
1160                make_token("still-usable", 30, true),
1161            ));
1162
1163            let start = Instant::now();
1164            let mut handles = Vec::with_capacity(CONCURRENCY);
1165            for _ in 0..CONCURRENCY {
1166                let s = Arc::clone(&strategy);
1167                handles.push(tokio::spawn(async move {
1168                    let call_start = Instant::now();
1169                    let token = s.get_token().await.unwrap();
1170                    (token, call_start.elapsed())
1171                }));
1172            }
1173
1174            let results: Vec<_> = {
1175                let mut results = Vec::with_capacity(handles.len());
1176                for handle in handles {
1177                    results.push(handle.await.unwrap());
1178                }
1179                results
1180            };
1181            let elapsed = start.elapsed();
1182
1183            for (token, _) in &results {
1184                assert!(
1185                    token.as_str() == "still-usable" || token.as_str() == "refreshed-token",
1186                    "unexpected token: {}",
1187                    token.as_str()
1188                );
1189            }
1190
1191            let fast_callers = results
1192                .iter()
1193                .filter(|(_, dur)| *dur < Duration::from_millis(100))
1194                .count();
1195            assert!(
1196                fast_callers >= CONCURRENCY - 1,
1197                "expected at least {} fast callers, got {} (total elapsed: {:?})",
1198                CONCURRENCY - 1,
1199                fast_callers,
1200                elapsed
1201            );
1202
1203            assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint");
1204            assert_eq!(stats.total(), 1, "total refresh requests");
1205        }
1206    }
1207
1208    mod given_fully_expired_token {
1209        use super::*;
1210
1211        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1212        async fn all_callers_block_until_refresh() {
1213            let refresh_delay = Duration::from_millis(200);
1214            let counting = CountingState::new();
1215            let state = DelayedRefreshState {
1216                counting: counting.clone(),
1217                delay: refresh_delay,
1218            };
1219            let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await;
1220            let dir = tempfile::tempdir().unwrap();
1221            let strategy = Arc::new(auto_refresh_with_token(
1222                &dir,
1223                &base_url,
1224                make_token("expired-token", 0, true),
1225            ));
1226
1227            let start = Instant::now();
1228            let mut handles = Vec::with_capacity(CONCURRENCY);
1229            for _ in 0..CONCURRENCY {
1230                let s = Arc::clone(&strategy);
1231                handles.push(tokio::spawn(async move { s.get_token().await.unwrap() }));
1232            }
1233
1234            let results: Vec<_> = {
1235                let mut results = Vec::with_capacity(handles.len());
1236                for handle in handles {
1237                    results.push(handle.await.unwrap());
1238                }
1239                results
1240            };
1241            let elapsed = start.elapsed();
1242
1243            for token in &results {
1244                assert_eq!(
1245                    token.as_str(),
1246                    "refreshed-token",
1247                    "all callers should receive refreshed token"
1248                );
1249            }
1250
1251            assert!(
1252                elapsed < refresh_delay + Duration::from_millis(200),
1253                "expected < {:?} for blocked callers, got {:?}",
1254                refresh_delay + Duration::from_millis(200),
1255                elapsed
1256            );
1257
1258            assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint");
1259            assert_eq!(stats.total(), 1, "total refresh requests");
1260        }
1261
1262        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1263        async fn all_callers_receive_expired_on_failure() {
1264            let counting = CountingState::new();
1265            let state = DelayedRefreshState {
1266                counting: counting.clone(),
1267                delay: Duration::from_millis(10),
1268            };
1269            let (base_url, stats) = start_axum_server(delayed_error_handler, state).await;
1270            let dir = tempfile::tempdir().unwrap();
1271            let strategy = Arc::new(auto_refresh_with_token(
1272                &dir,
1273                &base_url,
1274                make_token("expired-token", 0, true),
1275            ));
1276
1277            let mut handles = Vec::with_capacity(CONCURRENCY);
1278            for _ in 0..CONCURRENCY {
1279                let s = Arc::clone(&strategy);
1280                handles.push(tokio::spawn(async move { s.get_token().await }));
1281            }
1282
1283            let results: Vec<_> = {
1284                let mut results = Vec::with_capacity(handles.len());
1285                for handle in handles {
1286                    results.push(handle.await.unwrap());
1287                }
1288                results
1289            };
1290
1291            for result in &results {
1292                assert!(result.is_err(), "expected Expired error, got Ok");
1293                let err = result.as_ref().unwrap_err();
1294                assert!(
1295                    matches!(err, AutoRefreshError::Expired),
1296                    "expected Expired, got: {err:?}"
1297                );
1298            }
1299
1300            let state = strategy.state.lock().await;
1301            assert!(
1302                state.token.as_ref().unwrap().refresh_token().is_some(),
1303                "refresh token should be restored after failed refresh"
1304            );
1305            drop(state);
1306
1307            assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint");
1308            assert!(
1309                stats.total() >= 1,
1310                "at least one refresh attempt should be made"
1311            );
1312        }
1313
1314        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1315        async fn retry_succeeds_after_failure() {
1316            // Phase 1: Server returns errors.
1317            let counting1 = CountingState::new();
1318            let state1 = DelayedRefreshState {
1319                counting: counting1.clone(),
1320                delay: Duration::from_millis(50),
1321            };
1322            let (base_url, _) = start_axum_server(delayed_error_handler, state1).await;
1323            let dir = tempfile::tempdir().unwrap();
1324            let strategy = Arc::new(auto_refresh_with_token(
1325                &dir,
1326                &base_url,
1327                make_token("expired-token", 0, true),
1328            ));
1329
1330            let mut handles = Vec::with_capacity(CONCURRENCY);
1331            for _ in 0..CONCURRENCY {
1332                let s = Arc::clone(&strategy);
1333                handles.push(tokio::spawn(async move { s.get_token().await }));
1334            }
1335
1336            let results: Vec<_> = {
1337                let mut results = Vec::with_capacity(handles.len());
1338                for handle in handles {
1339                    results.push(handle.await.unwrap());
1340                }
1341                results
1342            };
1343
1344            for result in &results {
1345                assert!(
1346                    result.is_err(),
1347                    "first wave: expected Expired, got Ok({})",
1348                    result.as_ref().unwrap().as_str()
1349                );
1350            }
1351
1352            // Phase 2: New server that returns success.
1353            let counting2 = CountingState::new();
1354            let state2 = DelayedRefreshState {
1355                counting: counting2.clone(),
1356                delay: Duration::from_millis(50),
1357            };
1358            let (base_url2, stats2) = start_axum_server(delayed_refresh_handler, state2).await;
1359
1360            let strategy2 = Arc::new(auto_refresh_with_token(
1361                &dir,
1362                &base_url2,
1363                make_token("expired-token", 0, true),
1364            ));
1365
1366            let mut handles = Vec::with_capacity(CONCURRENCY);
1367            for _ in 0..CONCURRENCY {
1368                let s = Arc::clone(&strategy2);
1369                handles.push(tokio::spawn(async move { s.get_token().await.unwrap() }));
1370            }
1371
1372            let results: Vec<_> = {
1373                let mut results = Vec::with_capacity(handles.len());
1374                for handle in handles {
1375                    results.push(handle.await.unwrap());
1376                }
1377                results
1378            };
1379
1380            for token in &results {
1381                assert_eq!(
1382                    token.as_str(),
1383                    "refreshed-token",
1384                    "retry callers should receive refreshed token"
1385                );
1386            }
1387
1388            assert_eq!(stats2.total(), 1, "only one retry refresh should be made");
1389        }
1390    }
1391
1392    mod given_cancelled_refresh {
1393        use super::*;
1394
1395        /// If a blocking refresh (fully expired token) is cancelled mid-flight,
1396        /// the `CancelGuard` must reset `refresh_in_progress` and notify waiters
1397        /// so the next caller doesn't hang in `wait_for_in_flight_refresh`.
1398        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1399        async fn blocked_callers_recover_after_cancellation() {
1400            let counting = CountingState::new();
1401            let state = DelayedRefreshState {
1402                counting: counting.clone(),
1403                delay: Duration::from_secs(10), // Very slow — will be cancelled
1404            };
1405            let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await;
1406            let dir = tempfile::tempdir().unwrap();
1407            let strategy = Arc::new(auto_refresh_with_token(
1408                &dir,
1409                &base_url,
1410                make_token("expired-token", 0, true),
1411            ));
1412
1413            // Spawn get_token and let the blocking refresh start.
1414            let s = Arc::clone(&strategy);
1415            let handle = tokio::spawn(async move { s.get_token().await });
1416            tokio::time::sleep(Duration::from_millis(100)).await;
1417
1418            // Cancel the refresh mid-flight.
1419            handle.abort();
1420            let _ = handle.await;
1421
1422            // The next caller must not hang. The credential is lost (refresh
1423            // token was taken before the HTTP call), so the result is Expired,
1424            // but the important thing is that it completes promptly.
1425            let s = Arc::clone(&strategy);
1426            let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await;
1427
1428            assert!(
1429                result.is_ok(),
1430                "get_token() should not hang after cancelled blocking refresh"
1431            );
1432        }
1433
1434        /// Regression test: cancellation in the window *after* the upstream
1435        /// HTTP refresh succeeds but *before* the new token is installed must
1436        /// still clear `refresh_in_progress` and notify waiters. The previous
1437        /// implementation defused the [`CancelGuard`] before
1438        /// `save_refreshed_token`, so a drop during the (async) store-save or
1439        /// the subsequent state-lock acquire would strand the flag — wedging
1440        /// any caller that later hit `wait_for_in_flight_refresh`.
1441        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1442        async fn save_phase_cancellation_does_not_strand_in_progress_flag() {
1443            use crate::token_store::TokenStore;
1444
1445            /// Store that returns a single pre-loaded token from `load()` and
1446            /// delays inside `save()` long enough for a test to cancel.
1447            struct SlowSaveStore {
1448                initial: tokio::sync::Mutex<Option<Token>>,
1449                delay: Duration,
1450            }
1451
1452            impl TokenStore for SlowSaveStore {
1453                async fn load(&self) -> Option<Token> {
1454                    self.initial.lock().await.take()
1455                }
1456
1457                async fn save(&self, _token: &Token) {
1458                    tokio::time::sleep(self.delay).await;
1459                }
1460            }
1461
1462            // Fast upstream HTTP — refresh succeeds in <50ms.
1463            let counting = CountingState::new();
1464            let state = DelayedRefreshState {
1465                counting: counting.clone(),
1466                delay: Duration::from_millis(10),
1467            };
1468            let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await;
1469            let dir = tempfile::tempdir().unwrap();
1470            let store = ProfileStore::new(dir.path());
1471            store.init_workspace("ZVATKW3VHMFG27DY").unwrap();
1472            let ws_store = store.current_workspace_store().unwrap();
1473            let refresher = DeviceSessionRefresher::new(
1474                Some(ws_store),
1475                base_url,
1476                "cli",
1477                "ap-southeast-2.aws",
1478                None,
1479            );
1480            // Slow async save — cancellation reliably lands here, in the
1481            // post-HTTP / pre-install window.
1482            let slow_store = SlowSaveStore {
1483                initial: tokio::sync::Mutex::new(Some(make_token("expired-token", 0, true))),
1484                delay: Duration::from_secs(10),
1485            };
1486            let strategy = Arc::new(AutoRefresh::with_store(refresher, slow_store));
1487
1488            // Trigger refresh; the task will complete the HTTP exchange and
1489            // then block inside store.save (the slow async path).
1490            let s = Arc::clone(&strategy);
1491            let handle = tokio::spawn(async move { s.get_token().await });
1492            // 200ms is comfortably past the 10ms HTTP delay but well inside
1493            // the 10s save delay — so abort() lands during save_refreshed_token.
1494            tokio::time::sleep(Duration::from_millis(200)).await;
1495            handle.abort();
1496            let _ = handle.await;
1497
1498            // The CancelGuard must have cleared refresh_in_progress on drop.
1499            // If the old (pre-fix) code regresses, the flag stays true and a
1500            // subsequent caller wedges on wait_for_in_flight_refresh waiting
1501            // for a notify that will never come — the timeout below catches it.
1502            let s = Arc::clone(&strategy);
1503            let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await;
1504
1505            assert!(
1506                result.is_ok(),
1507                "get_token() should not hang after cancellation in the save/install window"
1508            );
1509        }
1510
1511        /// If a non-blocking refresh (expiring-but-usable token) is cancelled
1512        /// mid-flight, the `CancelGuard` must reset `refresh_in_progress` and
1513        /// notify waiters so they don't hang once the token crosses real expiry.
1514        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1515        async fn non_blocking_callers_recover_after_cancellation() {
1516            let counting = CountingState::new();
1517            let state = DelayedRefreshState {
1518                counting: counting.clone(),
1519                delay: Duration::from_secs(10), // Very slow — will be cancelled
1520            };
1521            let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await;
1522            let dir = tempfile::tempdir().unwrap();
1523            // Token expires in 30s — is_expired() = true, is_usable() = true.
1524            let strategy = Arc::new(auto_refresh_with_token(
1525                &dir,
1526                &base_url,
1527                make_token("still-usable", 30, true),
1528            ));
1529
1530            // Spawn get_token — triggers non-blocking refresh, drops lock, then
1531            // blocks on the slow HTTP call.
1532            let s = Arc::clone(&strategy);
1533            let handle = tokio::spawn(async move { s.get_token().await });
1534            tokio::time::sleep(Duration::from_millis(100)).await;
1535
1536            // Cancel the refresh mid-flight.
1537            handle.abort();
1538            let _ = handle.await;
1539
1540            // The next caller must not hang. The token is still usable so it
1541            // should be returned even though the refresh was cancelled.
1542            let s = Arc::clone(&strategy);
1543            let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await;
1544
1545            assert!(
1546                result.is_ok(),
1547                "get_token() should not hang after cancelled non-blocking refresh"
1548            );
1549            let result = result.unwrap();
1550            assert!(
1551                result.is_ok(),
1552                "expected Ok with still-usable token, got: {:?}",
1553                result.unwrap_err()
1554            );
1555        }
1556    }
1557}
1558
1559/// Deterministic regression test for the "token crosses real expiry while a
1560/// non-blocking refresh is in flight" race.
1561///
1562/// Before the fix, late-arriving callers saw `refresh_in_progress = true` +
1563/// `!is_usable()` and returned `Err(Expired)` instead of waiting for the
1564/// in-flight refresh. The original reproduction (a wall-clock stress test) hung
1565/// the outcome on a ~1-second window — token expiry has whole-second
1566/// granularity — which made it latently flaky and broke outright under coverage
1567/// instrumentation. This version drives expiry with a [`TestClock`] and gates
1568/// the refresh with a [`Notify`], so it is fully deterministic: no real sleeps,
1569/// no network.
1570#[cfg(test)]
1571#[allow(clippy::unwrap_used)]
1572mod expiry_crossing_regression {
1573    use super::*;
1574    use crate::clock::TestClock;
1575    use crate::{AuthError, SecretToken};
1576    use std::future::Future;
1577    use std::sync::atomic::AtomicUsize;
1578    use std::sync::Arc;
1579
1580    /// Number of callers that arrive after the token crosses real expiry.
1581    const WAITERS: usize = 8;
1582
1583    /// A [`Refresher`] whose `refresh` blocks on a test-controlled gate, so the
1584    /// test can hold a refresh "in flight" while it advances the clock and
1585    /// launches waiters — with no wall-clock timing involved.
1586    struct GatedRefresher {
1587        /// Notified once `refresh` is entered (the refresh is now in flight).
1588        started: Arc<Notify>,
1589        /// `refresh` awaits this; the test releases it to complete the refresh.
1590        gate: Arc<Notify>,
1591        /// Counts `refresh` invocations — asserts exactly one refresh happens.
1592        calls: Arc<AtomicUsize>,
1593        /// Absolute expiry stamped on the refreshed token.
1594        refreshed_expires_at: u64,
1595    }
1596
1597    impl Refresher for GatedRefresher {
1598        type Credential = ();
1599
1600        fn save(&self, _token: &Token) {}
1601
1602        fn try_credential(&self, token: Option<&mut Token>) -> Option<Self::Credential> {
1603            // Refresh only when there's a token to refresh, matching the real
1604            // refreshers' "needs a prior token" contract.
1605            token.map(|_| ())
1606        }
1607
1608        fn restore(&self, _token: &mut Token, _credential: Self::Credential) {}
1609
1610        fn refresh(
1611            &self,
1612            _credential: &Self::Credential,
1613        ) -> impl Future<Output = Result<Token, AuthError>> + Send {
1614            let started = Arc::clone(&self.started);
1615            let gate = Arc::clone(&self.gate);
1616            let calls = Arc::clone(&self.calls);
1617            let refreshed_expires_at = self.refreshed_expires_at;
1618            async move {
1619                calls.fetch_add(1, Ordering::SeqCst);
1620                started.notify_one();
1621                gate.notified().await;
1622                Ok(make_token("refreshed-token", refreshed_expires_at))
1623            }
1624        }
1625    }
1626
1627    fn make_token(access: &str, expires_at: u64) -> Token {
1628        Token {
1629            access_token: SecretToken::new(access),
1630            refresh_token: Some(SecretToken::new("refresh-token")),
1631            token_type: "Bearer".to_string(),
1632            expires_at,
1633            region: None,
1634            client_id: None,
1635            device_instance_id: None,
1636        }
1637    }
1638
1639    #[tokio::test]
1640    async fn waiters_wait_for_refresh_when_token_crosses_expiry() {
1641        let clock = TestClock::new(1_000_000);
1642        let started = Arc::new(Notify::new());
1643        let gate = Arc::new(Notify::new());
1644        let calls = Arc::new(AtomicUsize::new(0));
1645
1646        let refresher = GatedRefresher {
1647            started: Arc::clone(&started),
1648            gate: Arc::clone(&gate),
1649            calls: Arc::clone(&calls),
1650            refreshed_expires_at: clock.now() + 3600,
1651        };
1652
1653        // Within the 90s leeway (so a refresh is triggered) but still usable at
1654        // the current clock value (so the first caller takes the non-blocking
1655        // path and gets the old token).
1656        let token = make_token("expiring-soon", clock.now() + 10);
1657        let strategy = Arc::new(AutoRefresh::with_token_and_clock(
1658            refresher,
1659            token,
1660            clock.shared(),
1661        ));
1662
1663        // 1. First caller starts the non-blocking refresh.
1664        let first = {
1665            let s = Arc::clone(&strategy);
1666            tokio::spawn(async move { s.get_token().await })
1667        };
1668        // Wait until the refresh is actually in flight (gated; won't complete yet).
1669        started.notified().await;
1670
1671        // 2. Advance the clock past real expiry while the refresh is still gated.
1672        //    The token is now both expired and unusable.
1673        clock.advance(20);
1674
1675        // 3. Launch waiters. They observe refresh_in_progress + !is_usable, so they
1676        //    must wait for the in-flight refresh rather than returning Expired.
1677        let waiters: Vec<_> = (0..WAITERS)
1678            .map(|_| {
1679                let s = Arc::clone(&strategy);
1680                tokio::spawn(async move { s.get_token().await })
1681            })
1682            .collect();
1683
1684        // Let the waiters reach their wait point. This is cooperative scheduling
1685        // on the current-thread runtime (yielding lets the spawned waiters run
1686        // until they park on the refresh notification), not a wall-clock delay.
1687        for _ in 0..32 {
1688            tokio::task::yield_now().await;
1689        }
1690
1691        // 4. Release the refresh. The first caller installs the new token and
1692        //    notifies the waiters, which then return the refreshed token.
1693        gate.notify_one();
1694
1695        let first = first.await.unwrap().unwrap();
1696        assert_eq!(
1697            first.as_str(),
1698            "expiring-soon",
1699            "first caller receives the old token (still usable when it was called)"
1700        );
1701
1702        for (i, waiter) in waiters.into_iter().enumerate() {
1703            let token = waiter.await.unwrap().unwrap_or_else(|e| {
1704                panic!("waiter {i} returned Err({e:?}), expected the refreshed token")
1705            });
1706            assert_eq!(
1707                token.as_str(),
1708                "refreshed-token",
1709                "waiter {i} should receive the refreshed token, not Expired"
1710            );
1711        }
1712
1713        assert_eq!(
1714            calls.load(Ordering::SeqCst),
1715            1,
1716            "exactly one refresh should occur for all callers combined"
1717        );
1718    }
1719
1720    /// A [`Refresher`] like [`GatedRefresher`], but whose gated `refresh`
1721    /// resolves to `Err` once released — so the test can exercise the *failure*
1722    /// axis of the in-flight-refresh race.
1723    struct FailingGatedRefresher {
1724        started: Arc<Notify>,
1725        gate: Arc<Notify>,
1726        calls: Arc<AtomicUsize>,
1727    }
1728
1729    impl Refresher for FailingGatedRefresher {
1730        type Credential = ();
1731
1732        fn save(&self, _token: &Token) {}
1733
1734        fn try_credential(&self, token: Option<&mut Token>) -> Option<Self::Credential> {
1735            token.map(|_| ())
1736        }
1737
1738        fn restore(&self, _token: &mut Token, _credential: Self::Credential) {}
1739
1740        fn refresh(
1741            &self,
1742            _credential: &Self::Credential,
1743        ) -> impl Future<Output = Result<Token, AuthError>> + Send {
1744            let started = Arc::clone(&self.started);
1745            let gate = Arc::clone(&self.gate);
1746            let calls = Arc::clone(&self.calls);
1747            async move {
1748                calls.fetch_add(1, Ordering::SeqCst);
1749                started.notify_one();
1750                gate.notified().await;
1751                Err(AuthError::TokenExpired)
1752            }
1753        }
1754    }
1755
1756    /// The failure counterpart to
1757    /// [`waiters_wait_for_refresh_when_token_crosses_expiry`]: when the in-flight
1758    /// refresh *fails* and the clock has crossed real expiry, waiters waking in
1759    /// [`AutoRefresh::wait_for_in_flight_refresh`] must re-read the clock, find
1760    /// the cached token unusable via `require_usable_token(now)`, and return
1761    /// `Expired` — they must not hang, and must not hand back a stale token. This
1762    /// is exactly the branch the post-wake clock re-read (the `now` re-read after
1763    /// `notified().await`) exists to make correct.
1764    #[tokio::test]
1765    async fn waiters_get_expired_when_in_flight_refresh_fails() {
1766        let clock = TestClock::new(1_000_000);
1767        let started = Arc::new(Notify::new());
1768        let gate = Arc::new(Notify::new());
1769        let calls = Arc::new(AtomicUsize::new(0));
1770
1771        let refresher = FailingGatedRefresher {
1772            started: Arc::clone(&started),
1773            gate: Arc::clone(&gate),
1774            calls: Arc::clone(&calls),
1775        };
1776
1777        // Within the 90s leeway (triggers a refresh) but still usable now, so the
1778        // first caller takes the non-blocking path and captures the old token.
1779        let token = make_token("expiring-soon", clock.now() + 10);
1780        let strategy = Arc::new(AutoRefresh::with_token_and_clock(
1781            refresher,
1782            token,
1783            clock.shared(),
1784        ));
1785
1786        // 1. First caller starts the (gated) non-blocking refresh.
1787        let first = {
1788            let s = Arc::clone(&strategy);
1789            tokio::spawn(async move { s.get_token().await })
1790        };
1791        started.notified().await;
1792
1793        // 2. Advance the clock past real expiry while the refresh is still gated.
1794        //    The cached token is now both expired and unusable.
1795        clock.advance(20);
1796
1797        // 3. Launch waiters. They observe refresh_in_progress + !is_usable, so
1798        //    they park in wait_for_in_flight_refresh rather than returning early.
1799        let waiters: Vec<_> = (0..WAITERS)
1800            .map(|_| {
1801                let s = Arc::clone(&strategy);
1802                tokio::spawn(async move { s.get_token().await })
1803            })
1804            .collect();
1805        for _ in 0..32 {
1806            tokio::task::yield_now().await;
1807        }
1808
1809        // 4. Release the refresh → it returns Err. The first caller still returns
1810        //    the old token it captured while it was usable; the waiters re-read
1811        //    the now-advanced clock, find the token unusable, and get Expired.
1812        gate.notify_one();
1813
1814        let first = first.await.unwrap();
1815        assert_eq!(
1816            first.unwrap().as_str(),
1817            "expiring-soon",
1818            "first caller keeps the old token it captured before the refresh failed"
1819        );
1820
1821        for (i, waiter) in waiters.into_iter().enumerate() {
1822            let result = waiter.await.unwrap();
1823            assert!(
1824                matches!(result, Err(AutoRefreshError::Expired)),
1825                "waiter {i} should get Expired after the failed refresh, got: {result:?}"
1826            );
1827        }
1828
1829        assert_eq!(
1830            calls.load(Ordering::SeqCst),
1831            1,
1832            "exactly one refresh attempt for all callers combined"
1833        );
1834    }
1835
1836    /// A [`Refresher`] whose `refresh` panics if it is ever called, so a test can
1837    /// assert that no refresh is triggered.
1838    struct NeverRefresher;
1839
1840    impl Refresher for NeverRefresher {
1841        type Credential = ();
1842
1843        fn save(&self, _token: &Token) {}
1844
1845        fn try_credential(&self, token: Option<&mut Token>) -> Option<Self::Credential> {
1846            token.map(|_| ())
1847        }
1848
1849        fn restore(&self, _token: &mut Token, _credential: Self::Credential) {}
1850
1851        // Keep the explicit `impl Future + Send` form to match the sibling test
1852        // refreshers; the trivial body would otherwise trip `manual_async_fn`.
1853        #[allow(clippy::manual_async_fn)]
1854        fn refresh(
1855            &self,
1856            _credential: &Self::Credential,
1857        ) -> impl Future<Output = Result<Token, AuthError>> + Send {
1858            async { panic!("refresh must not be called while the token reads as fresh") }
1859        }
1860    }
1861
1862    /// A wall clock running *backwards* (NTP step, VM snapshot restore) must not
1863    /// panic or spuriously force a refresh. Each `get_token` call samples `now`
1864    /// once and re-evaluates the pure `is_expired_at`/`is_usable_at` predicates,
1865    /// and the only time subtraction in the crate (`Token::expires_in`) saturates
1866    /// — so there is no cross-call delta to underflow. A rewind simply makes the
1867    /// token read as fresh again.
1868    #[tokio::test]
1869    async fn backwards_clock_does_not_panic_or_force_refresh() {
1870        let clock = TestClock::new(1_000_000);
1871        // Fresh token: expires well beyond the 90s leeway.
1872        let token = make_token("fresh", clock.now() + 3600);
1873        let strategy = AutoRefresh::with_token_and_clock(NeverRefresher, token, clock.shared());
1874
1875        // Forward reading returns the cached token without refreshing.
1876        assert_eq!(strategy.get_token().await.unwrap().as_str(), "fresh");
1877
1878        // The wall clock jumps 100_000s into the past.
1879        clock.set(900_000);
1880
1881        // Still fresh, still no refresh (NeverRefresher would panic), no hang.
1882        assert_eq!(strategy.get_token().await.unwrap().as_str(), "fresh");
1883    }
1884}