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