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#[derive(Debug, thiserror::Error)]
15pub(crate) enum AutoRefreshError {
16 #[error("No token found")]
18 NotFound,
19 #[error("Token has expired")]
21 Expired,
22 #[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
39pub(crate) struct AutoRefresh<R, S = NoStore> {
46 refresher: R,
47 state: Mutex<State>,
48 store: S,
49 refresh_in_progress: AtomicBool,
55 refresh_notify: Notify,
56 clock: SharedClock,
59}
60
61struct State {
62 token: Option<Token>,
63}
64
65struct 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 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 #[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 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 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(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 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 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 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 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 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 let notified = self.refresh_notify.notified();
273 drop(state);
274 notified.await;
275 let now = self.clock.now_unix_secs();
278 let state = self.state.lock().await;
279 state.require_usable_token(now)
280 }
281
282 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let strategy =
802 auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true));
803
804 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 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 let strategy =
838 auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true));
839
840 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 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 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 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 #[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 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 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 #[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), };
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 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 handle.abort();
1424 let _ = handle.await;
1425
1426 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 #[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 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 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 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 let s = Arc::clone(&strategy);
1495 let handle = tokio::spawn(async move { s.get_token().await });
1496 tokio::time::sleep(Duration::from_millis(200)).await;
1499 handle.abort();
1500 let _ = handle.await;
1501
1502 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 #[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), };
1525 let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await;
1526 let dir = tempfile::tempdir().unwrap();
1527 let strategy = Arc::new(auto_refresh_with_token(
1529 &dir,
1530 &base_url,
1531 make_token("still-usable", 30, true),
1532 ));
1533
1534 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 handle.abort();
1542 let _ = handle.await;
1543
1544 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#[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 const WAITERS: usize = 8;
1586
1587 struct GatedRefresher {
1591 started: Arc<Notify>,
1593 gate: Arc<Notify>,
1595 calls: Arc<AtomicUsize>,
1597 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 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 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 let first = {
1669 let s = Arc::clone(&strategy);
1670 tokio::spawn(async move { s.get_token().await })
1671 };
1672 started.notified().await;
1674
1675 clock.advance(20);
1678
1679 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 for _ in 0..32 {
1692 tokio::task::yield_now().await;
1693 }
1694
1695 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 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 #[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 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 let first = {
1792 let s = Arc::clone(&strategy);
1793 tokio::spawn(async move { s.get_token().await })
1794 };
1795 started.notified().await;
1796
1797 clock.advance(20);
1800
1801 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 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 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 #[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 #[tokio::test]
1873 async fn backwards_clock_does_not_panic_or_force_refresh() {
1874 let clock = TestClock::new(1_000_000);
1875 let token = make_token("fresh", clock.now() + 3600);
1877 let strategy = AutoRefresh::with_token_and_clock(NeverRefresher, token, clock.shared());
1878
1879 assert_eq!(strategy.get_token().await.unwrap().as_str(), "fresh");
1881
1882 clock.set(900_000);
1884
1885 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 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 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 let a = Arc::clone(&strategy);
1968 let handle = tokio::spawn(async move { a.get_token().await });
1969
1970 tokio::time::sleep(Duration::from_millis(100)).await;
1974 let held = strategy.state.lock().await;
1975
1976 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 handle.abort();
1985 let _ = handle.await;
1986 drop(held);
1987
1988 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 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}