1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, OnceLock};
4use tokio::sync::Mutex;
5
6use reqwest::Client;
7
8use super::account::{CredentialRef, SeatIdentity};
9use super::provider::RefreshEndpoints;
10use super::storage::{
11 auth_file_path, load_provider_auth_typed_at, save_provider_auth_if_refresh_matches_typed_at,
12 CasOutcome, StoreError,
13};
14use super::{is_token_expired, now_millis, OAuthCredentials, TokenResponse, CLIENT_ID, TOKEN_URL};
15
16pub async fn exchange_code_for_tokens(
18 code: &str,
19 state: &str,
20 verifier: &str,
21 port: u16,
22) -> std::result::Result<OAuthCredentials, String> {
23 let redirect_uri = format!("http://localhost:{}/callback", port);
24
25 let body = serde_json::json!({
26 "grant_type": "authorization_code",
27 "client_id": CLIENT_ID,
28 "code": code,
29 "state": state,
30 "redirect_uri": redirect_uri,
31 "code_verifier": verifier,
32 });
33
34 let client = Client::builder()
35 .tls_built_in_webpki_certs(true)
36 .tls_built_in_native_certs(true)
37 .connect_timeout(std::time::Duration::from_secs(10))
38 .timeout(std::time::Duration::from_secs(30))
39 .build()
40 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
41 let resp = client
42 .post(TOKEN_URL)
43 .header("Content-Type", "application/json")
44 .header("Accept", "application/json")
45 .json(&body)
46 .send()
47 .await
48 .map_err(|e| format!("Token exchange request failed: {}", e))?;
49
50 if !resp.status().is_success() {
51 let status = resp.status();
52 let text = resp.text().await.unwrap_or_default();
56 return Err(format!(
57 "Token exchange failed ({}): {}",
58 status,
59 sanitize_oauth_error_body(&text)
60 ));
61 }
62
63 let token_resp: TokenResponse = resp
64 .json()
65 .await
66 .map_err(|e| format!("Failed to parse token response: {}", e))?;
67
68 let expires = now_millis() + (token_resp.expires_in * 1000) - (5 * 60 * 1000);
70
71 Ok(OAuthCredentials {
72 auth_type: "oauth".to_string(),
73 refresh: token_resp.refresh_token,
74 access: token_resp.access_token,
75 expires,
76 account_id: None,
77 })
78}
79
80pub async fn refresh_token(
84 client: &Client,
85 refresh: &str,
86) -> std::result::Result<OAuthCredentials, RefreshFailure> {
87 refresh_token_at(client, TOKEN_URL, refresh).await
88}
89
90pub(crate) async fn refresh_token_at(
91 client: &Client,
92 url: &str,
93 refresh: &str,
94) -> std::result::Result<OAuthCredentials, RefreshFailure> {
95 let body = serde_json::json!({
96 "grant_type": "refresh_token",
97 "client_id": CLIENT_ID,
98 "refresh_token": refresh,
99 });
100
101 let resp = client
102 .post(url)
103 .header("Content-Type", "application/json")
104 .header("Accept", "application/json")
105 .json(&body)
106 .send()
107 .await
108 .map_err(|e| RefreshFailure::transport(format!("Token refresh request failed: {}", e)))?;
109
110 if !resp.status().is_success() {
111 let status = resp.status();
112 let text = resp.text().await.unwrap_or_default();
113 return Err(RefreshFailure::status(
114 status.as_u16(),
115 format!(
116 "Token refresh failed ({}): {}",
117 status,
118 sanitize_oauth_error_body(&text)
119 ),
120 ));
121 }
122
123 let token_resp: TokenResponse = resp
126 .json()
127 .await
128 .map_err(|e| RefreshFailure::bad_response(format!("Failed to parse refresh response: {}", e)))?;
129
130 let expires = now_millis() + (token_resp.expires_in * 1000) - (5 * 60 * 1000);
131
132 Ok(OAuthCredentials {
133 auth_type: "oauth".to_string(),
134 refresh: token_resp.refresh_token,
135 access: token_resp.access_token,
136 expires,
137 account_id: None,
138 })
139}
140
141pub(crate) fn sanitize_oauth_error_body(text: &str) -> String {
146 fn cap(s: &str) -> String {
147 s.chars().take(120).collect()
148 }
149 let Ok(v) = serde_json::from_str::<serde_json::Value>(text) else {
150 return "<non-JSON error body omitted>".to_string();
151 };
152 let (code, desc) = match v.get("error") {
154 Some(serde_json::Value::String(e)) => (
155 Some(e.as_str()),
156 v.get("error_description").and_then(|d| d.as_str()),
157 ),
158 Some(serde_json::Value::Object(o)) => (
159 o.get("type").and_then(|t| t.as_str()),
160 o.get("message").and_then(|m| m.as_str()),
161 ),
162 _ => (None, None),
163 };
164 match (code, desc) {
165 (Some(c), Some(d)) => format!("{}: {}", cap(c), cap(d)),
166 (Some(c), None) => cap(c),
167 (None, Some(d)) => cap(d),
168 (None, None) => "<error body omitted>".to_string(),
169 }
170}
171
172pub async fn ensure_fresh_token(client: &Client) -> std::result::Result<OAuthCredentials, String> {
182 ensure_fresh_provider_token(client, super::provider::OAuthProviderId::Anthropic).await
183}
184
185fn refresh_gate(gate_key: &str) -> Arc<Mutex<()>> {
191 static GATES: OnceLock<std::sync::Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
192 let mut gates = GATES
193 .get_or_init(|| std::sync::Mutex::new(HashMap::new()))
194 .lock()
195 .expect("refresh gate registry poisoned");
196 gates
197 .entry(gate_key.to_string())
198 .or_insert_with(|| Arc::new(Mutex::new(())))
199 .clone()
200}
201
202pub async fn ensure_fresh_provider_token<P>(
206 client: &Client,
207 provider: P,
208) -> std::result::Result<OAuthCredentials, String>
209where
210 P: TryInto<super::provider::OAuthProviderId>,
211 P::Error: std::fmt::Display,
212{
213 let provider = provider.try_into().map_err(|e| e.to_string())?;
214 ensure_fresh_credential(client, &CredentialRef::default_for(provider)).await
215}
216
217pub async fn ensure_fresh_credential(
231 client: &Client,
232 cred: &CredentialRef,
233) -> std::result::Result<OAuthCredentials, String> {
234 let path = auth_file_path();
239 ensure_fresh_credential_at(client, cred, &path).await
240}
241
242#[derive(Debug)]
245pub enum SeatVendError {
246 SeatChanged,
249 Other(RefreshFailure),
252}
253
254impl std::fmt::Display for SeatVendError {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match self {
257 Self::SeatChanged => f.write_str(SEAT_CHANGED),
258 Self::Other(e) => write!(f, "{e}"),
259 }
260 }
261}
262
263const SEAT_CHANGED: &str = "credential changed since its capacity was read; nothing vended";
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum RefreshFailureKind {
270 Cooldown { retry_in_secs: u64 },
273 LockBusy,
276 Transient,
279 Credential,
281 Other,
283 ActionRequired,
287}
288
289impl RefreshFailureKind {
290 pub fn is_retryable(self) -> bool {
294 matches!(self, Self::Transient | Self::LockBusy | Self::Other)
295 }
296}
297
298#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct RefreshFailure {
304 pub kind: RefreshFailureKind,
305 message: String,
306 http_status: Option<u16>,
307}
308
309impl RefreshFailure {
310 pub fn new(kind: RefreshFailureKind, message: impl Into<String>) -> Self {
311 Self {
312 kind,
313 message: message.into(),
314 http_status: None,
315 }
316 }
317
318 pub fn message(&self) -> &str {
319 &self.message
320 }
321
322 pub fn http_status(&self) -> Option<u16> {
324 self.http_status
325 }
326
327 pub fn with_http_status(mut self, status: Option<u16>) -> Self {
330 self.http_status = status;
331 self
332 }
333}
334
335impl std::fmt::Display for RefreshFailure {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 f.write_str(&self.message)
338 }
339}
340
341impl From<String> for RefreshFailure {
344 fn from(message: String) -> Self {
345 Self::new(RefreshFailureKind::Credential, message)
346 }
347}
348
349impl From<StoreError> for RefreshFailure {
353 fn from(e: StoreError) -> Self {
354 match e {
355 StoreError::Busy(m) => Self::new(RefreshFailureKind::Other, m),
358 StoreError::Corrupt(m) => Self::new(RefreshFailureKind::ActionRequired, m),
359 StoreError::Failed(m) => Self::new(RefreshFailureKind::Credential, m),
360 }
361 }
362}
363
364impl From<RefreshFailure> for String {
365 fn from(f: RefreshFailure) -> Self {
366 f.message
367 }
368}
369
370pub fn status_kind(status: u16) -> RefreshFailureKind {
374 match status {
375 408 | 425 | 429 | 500..=599 => RefreshFailureKind::Transient,
376 _ => RefreshFailureKind::Credential,
377 }
378}
379
380impl RefreshFailure {
381 pub fn transport(message: impl Into<String>) -> Self {
384 Self::new(RefreshFailureKind::Transient, message)
385 }
386
387 pub fn status(status: u16, message: impl Into<String>) -> Self {
389 Self::new(status_kind(status), message).with_http_status(Some(status))
390 }
391
392 pub fn bad_response(message: impl Into<String>) -> Self {
394 Self::new(RefreshFailureKind::Transient, message)
395 }
396
397 pub fn credential(message: impl Into<String>) -> Self {
399 Self::new(RefreshFailureKind::Credential, message)
400 }
401
402 pub fn other(message: impl Into<String>) -> Self {
404 Self::new(RefreshFailureKind::Other, message)
405 }
406}
407
408pub(super) async fn ensure_fresh_credential_for_seat(
415 client: &Client,
416 cred: &CredentialRef,
417 expected: &SeatIdentity,
418 endpoints: &RefreshEndpoints,
419 memory: Option<Arc<dyn RefusalMemory>>,
420) -> std::result::Result<OAuthCredentials, SeatVendError> {
421 let path = auth_file_path();
422 ensure_credential_checked_at(client, cred, &path, Some(expected), None, endpoints, memory)
423 .await
424 .map_err(|f| {
425 if f.message() == SEAT_CHANGED {
426 SeatVendError::SeatChanged
427 } else {
428 SeatVendError::Other(f)
429 }
430 })
431}
432
433pub(crate) async fn ensure_fresh_credential_at(
435 client: &Client,
436 cred: &CredentialRef,
437 path: &Path,
438) -> std::result::Result<OAuthCredentials, String> {
439 ensure_fresh_credential_checked_at(client, cred, path, None).await
440}
441
442pub(crate) async fn ensure_fresh_credential_checked_at(
443 client: &Client,
444 cred: &CredentialRef,
445 path: &Path,
446 expected_seat: Option<&SeatIdentity>,
447) -> std::result::Result<OAuthCredentials, String> {
448 ensure_credential_checked_at(
449 client,
450 cred,
451 path,
452 expected_seat,
453 None,
454 &RefreshEndpoints::default(),
455 None,
456 )
457 .await
458 .map_err(String::from)
459}
460
461pub(super) async fn recover_rejected_credential(
465 client: &Client,
466 cred: &CredentialRef,
467 rejected: &str,
468 endpoints: &RefreshEndpoints,
469) -> std::result::Result<OAuthCredentials, RefreshFailure> {
470 ensure_credential_checked_at(
471 client,
472 cred,
473 &auth_file_path(),
474 None,
475 Some(rejected),
476 endpoints,
477 None,
478 )
479 .await
480}
481
482pub(super) async fn ensure_fresh_credential_via(
485 client: &Client,
486 cred: &CredentialRef,
487 endpoints: &RefreshEndpoints,
488 memory: Option<Arc<dyn RefusalMemory>>,
489) -> std::result::Result<OAuthCredentials, RefreshFailure> {
490 ensure_credential_checked_at(client, cred, &auth_file_path(), None, None, endpoints, memory)
491 .await
492}
493
494async fn ensure_credential_checked_at(
495 client: &Client,
496 cred: &CredentialRef,
497 path: &Path,
498 expected_seat: Option<&SeatIdentity>,
499 rejected: Option<&str>,
500 endpoints: &RefreshEndpoints,
501 memory: Option<Arc<dyn RefusalMemory>>,
502) -> std::result::Result<OAuthCredentials, RefreshFailure> {
503 let creds = load_checked(path, cred, expected_seat)?;
507 let rejected_now = rejected.is_some_and(|r| !r.is_empty() && r == creds.access);
508 if !rejected_now && !is_token_expired(&creds) {
509 return Ok(creds);
510 }
511 let (client, cred, path) = (client.clone(), cred.clone(), path.to_path_buf());
512 let (seat, rejected) = (expected_seat.cloned(), rejected.map(str::to_string));
513 let endpoints = endpoints.clone();
514 run_detached(async move {
515 let provider = cred.provider;
516 let (client, endpoints) = (&client, &endpoints);
517 refresh_credential_locked(
518 &cred,
519 &path,
520 seat.as_ref(),
521 rejected.as_deref(),
522 memory.as_deref(),
523 |refresh: String| async move {
524 super::provider::refresh_via(client, provider, &refresh, endpoints).await
525 },
526 )
527 .await
528 })
529 .await
530}
531
532async fn run_detached<T, E, F>(fut: F) -> std::result::Result<T, E>
541where
542 F: std::future::Future<Output = std::result::Result<T, E>> + Send + 'static,
543 T: Send + 'static,
544 E: From<RefreshFailure> + Send + 'static,
545{
546 run_detached_on(refresh_runtime(), fut).await
547}
548
549async fn run_detached_on<T, E, F>(
550 runtime: std::result::Result<&'static tokio::runtime::Runtime, RefreshFailure>,
551 fut: F,
552) -> std::result::Result<T, E>
553where
554 F: std::future::Future<Output = std::result::Result<T, E>> + Send + 'static,
555 T: Send + 'static,
556 E: From<RefreshFailure> + Send + 'static,
557{
558 runtime.map_err(E::from)?.spawn(fut).await.map_err(|e| {
559 E::from(RefreshFailure::new(
560 RefreshFailureKind::Other,
561 format!("credential refresh task failed: {e}"),
562 ))
563 })?
564}
565
566fn refresh_runtime() -> std::result::Result<&'static tokio::runtime::Runtime, RefreshFailure> {
574 static RT: std::sync::Mutex<Option<&'static tokio::runtime::Runtime>> =
575 std::sync::Mutex::new(None);
576 runtime_from(&RT, || {
577 tokio::runtime::Builder::new_multi_thread()
578 .worker_threads(2)
579 .thread_name("synaps-auth-refresh")
580 .enable_all()
581 .build()
582 })
583}
584
585fn runtime_from(
586 slot: &std::sync::Mutex<Option<&'static tokio::runtime::Runtime>>,
587 build: impl FnOnce() -> std::io::Result<tokio::runtime::Runtime>,
588) -> std::result::Result<&'static tokio::runtime::Runtime, RefreshFailure> {
589 let mut slot = slot.lock().unwrap_or_else(|e| e.into_inner());
590 if let Some(runtime) = *slot {
591 return Ok(runtime);
592 }
593 let runtime = build().map_err(|e| {
594 RefreshFailure::other(format!("could not start the auth refresh runtime: {e}"))
595 })?;
596 let runtime: &'static tokio::runtime::Runtime = Box::leak(Box::new(runtime));
598 *slot = Some(runtime);
599 Ok(runtime)
600}
601
602fn load_checked(
603 path: &Path,
604 cred: &CredentialRef,
605 expected_seat: Option<&SeatIdentity>,
606) -> std::result::Result<OAuthCredentials, RefreshFailure> {
607 let creds = load_provider_auth_typed_at(path, &cred.storage_key())?.ok_or_else(|| {
608 RefreshFailure::credential(format!(
609 "No credentials for {} at {}. Run `synaps login --provider {} --account {}`.",
610 cred,
611 path.display(),
612 cred.provider,
613 cred.account
614 ))
615 })?;
616 if let Some(expected) = expected_seat {
620 if SeatIdentity::of(cred.provider, &creds) != *expected {
621 return Err(RefreshFailure::credential(SEAT_CHANGED));
622 }
623 }
624 Ok(creds)
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub struct RefreshExchange {
631 pub at_ms: u64,
632 pub failure: Option<RefreshFailureKind>,
633 pub http_status: Option<u16>,
635}
636
637static REFRESH_EXCHANGES: std::sync::Mutex<std::collections::BTreeMap<String, RefreshExchange>> =
642 std::sync::Mutex::new(std::collections::BTreeMap::new());
643
644fn refresh_exchange_key(path: &Path, storage_key: &str) -> String {
645 format!("{}|{storage_key}", path.display())
646}
647
648fn record_refresh_exchange(path: &Path, storage_key: &str, failure: Option<&RefreshFailure>) {
649 let at_ms = crate::epoch_millis();
650 REFRESH_EXCHANGES
651 .lock()
652 .unwrap_or_else(|e| e.into_inner())
653 .insert(
654 refresh_exchange_key(path, storage_key),
655 RefreshExchange {
656 at_ms,
657 failure: failure.map(|f| f.kind),
658 http_status: failure.and_then(RefreshFailure::http_status),
659 },
660 );
661}
662
663pub fn last_refresh_exchange(path: &Path, storage_key: &str) -> Option<RefreshExchange> {
666 REFRESH_EXCHANGES
667 .lock()
668 .unwrap_or_else(|e| e.into_inner())
669 .get(&refresh_exchange_key(path, storage_key))
670 .copied()
671}
672
673pub(super) trait RefusalMemory: Send + Sync {
681 fn held(&self, creds: &OAuthCredentials) -> Option<RefreshFailure>;
684 fn refused(&self, creds: &OAuthCredentials, failure: &RefreshFailure);
688}
689
690async fn refresh_credential_locked<Refresh, RFut, RErr>(
696 cred: &CredentialRef,
697 path: &Path,
698 expected_seat: Option<&SeatIdentity>,
699 rejected: Option<&str>,
700 memory: Option<&dyn RefusalMemory>,
701 refresh: Refresh,
702) -> std::result::Result<OAuthCredentials, RefreshFailure>
703where
704 Refresh: Fn(String) -> RFut,
705 RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, RErr>>,
706 RErr: Into<RefreshFailure>,
707{
708 let key = cred.storage_key();
709 let refresh = |old_refresh: String| {
710 let exchange = refresh(old_refresh);
711 let key = &key;
712 async move {
713 let result: std::result::Result<OAuthCredentials, RefreshFailure> =
714 exchange.await.map_err(Into::into);
715 record_refresh_exchange(path, key, result.as_ref().err());
716 result
717 }
718 };
719 let gate_key = format!("{}|{}", path.display(), key);
720 let lock_path = refresh_lock_path(path, &key);
721 let load = || load_checked(path, cred, expected_seat);
722 let save = |old_refresh: &str, fresh: &OAuthCredentials| {
725 let (path, key) = (path.to_path_buf(), key.clone());
726 let (old_refresh, fresh) = (old_refresh.to_string(), fresh.clone());
727 async move {
728 tokio::task::spawn_blocking(move || {
729 save_provider_auth_if_refresh_matches_typed_at(
730 &path,
731 &key,
732 &old_refresh,
733 &fresh,
734 REFRESH_SAVE_WAIT,
735 )
736 })
737 .await
738 .map_err(|e| RefreshFailure::other(format!("credential save task failed: {e}")))?
739 .map_err(RefreshFailure::from)
740 }
741 };
742 ensure_gated_locked_policy_with(
743 refresh_gate(&gate_key),
744 Some(lock_path),
745 load,
746 refresh,
747 save,
748 rejected,
749 |last_refresh_ms| forced_refresh_allowed(cred, last_refresh_ms, crate::epoch_millis()),
750 expected_seat.is_none(),
751 memory,
752 )
753 .await
754}
755
756pub(crate) const FORCED_REFRESH_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(60);
768
769fn forced_refresh_allowed(
777 cred: &CredentialRef,
778 last_refresh_ms: Option<u64>,
779 now_ms: u64,
780) -> std::result::Result<(), RefreshFailure> {
781 let cooldown_ms = FORCED_REFRESH_COOLDOWN.as_millis() as u64;
782 match last_refresh_ms.filter(|t| *t <= now_ms).map(|t| now_ms - t) {
783 Some(age_ms) if age_ms < cooldown_ms => {
784 let retry_in_secs = (cooldown_ms - age_ms).div_ceil(1000);
785 Err(RefreshFailure::new(
786 RefreshFailureKind::Cooldown { retry_in_secs },
787 format!(
788 "{} rejected a token refreshed {}s ago; not refreshing again for {}s \
789 (likely an account or permission issue, not a stale token)",
790 cred,
791 age_ms / 1000,
792 retry_in_secs
793 ),
794 ))
795 }
796 _ => Ok(()),
797 }
798}
799
800fn refresh_lock_path(auth_path: &Path, storage_key: &str) -> PathBuf {
803 let file_name = auth_path
804 .file_name()
805 .and_then(|n| n.to_str())
806 .unwrap_or("auth.json");
807 auth_path.with_file_name(format!("{file_name}.{storage_key}.refresh.lock"))
808}
809
810const REFRESH_HTTP_DEADLINE: std::time::Duration = std::time::Duration::from_secs(60);
814
815const REFRESH_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
820
821const REFRESH_SAVE_WAIT: std::time::Duration = REFRESH_LOCK_WAIT;
826
827fn pending_path(lock_path: &Path) -> PathBuf {
829 lock_path.with_extension("pending")
830}
831
832fn park_unsaved_rotation(
836 lock: Option<&RefreshFileLock>,
837 old_refresh: &str,
838 fresh: OAuthCredentials,
839 err: RefreshFailure,
840) -> std::result::Result<OAuthCredentials, RefreshFailure> {
841 let Some(lock) = lock else { return Err(err) };
842 match lock.park_pending(old_refresh, &fresh) {
843 Ok(()) => {
844 tracing::warn!(
845 lock = %lock.path.display(),
846 error = %err,
847 "refreshed login could not be saved; parked it beside the refresh lock, \
848 it will be applied on next use"
849 );
850 Ok(fresh)
851 }
852 Err(e) => {
853 tracing::error!(
854 lock = %lock.path.display(),
855 save_error = %err,
856 park_error = %e,
857 "refreshed login could NOT be saved: if the provider rotated the refresh \
858 token, this account is now logged out and needs `synaps login`"
859 );
860 Err(err)
861 }
862 }
863}
864
865struct RefreshFileLock {
872 file: std::fs::File,
873 path: PathBuf,
875}
876
877fn refresh_floor() -> &'static std::sync::Mutex<HashMap<PathBuf, u64>> {
882 static FLOOR: OnceLock<std::sync::Mutex<HashMap<PathBuf, u64>>> = OnceLock::new();
883 FLOOR.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
884}
885
886impl RefreshFileLock {
887 fn last_refresh_ms(&self) -> Option<u64> {
890 let floor = refresh_floor()
891 .lock()
892 .unwrap_or_else(|e| e.into_inner())
893 .get(&self.path)
894 .copied();
895 self.stamped_ms().max(floor)
896 }
897
898 fn stamped_ms(&self) -> Option<u64> {
899 use std::io::{Read, Seek};
900 let mut file = &self.file;
901 file.rewind().ok()?;
902 let mut text = String::new();
903 file.take(32).read_to_string(&mut text).ok()?;
904 text.trim().parse().ok()
905 }
906
907 fn record_refreshed(&self, now_ms: u64) {
911 let mut floor = refresh_floor().lock().unwrap_or_else(|e| e.into_inner());
912 match self.stamp_refreshed(now_ms) {
913 Ok(()) => {
914 floor.remove(&self.path);
915 }
916 Err(e) => {
917 tracing::warn!(
918 lock = %self.path.display(),
919 error = %e,
920 "could not stamp the refresh lock file; keeping the forced-refresh \
921 cooldown in this process only"
922 );
923 floor.insert(self.path.clone(), now_ms);
924 }
925 }
926 }
927
928 fn park_pending(&self, expected_refresh: &str, fresh: &OAuthCredentials) -> std::io::Result<()> {
931 use std::io::Write;
932 let body = serde_json::json!({ "expected_refresh": expected_refresh, "credential": fresh });
933 let path = pending_path(&self.path);
934 let tmp = path.with_extension("pending.tmp");
935 let mut open = std::fs::OpenOptions::new();
936 open.create(true).write(true).truncate(true);
937 #[cfg(unix)]
938 {
939 use std::os::unix::fs::OpenOptionsExt;
940 open.mode(0o600);
941 }
942 let mut file = open.open(&tmp)?;
943 file.write_all(body.to_string().as_bytes())?;
944 file.sync_all()?;
945 std::fs::rename(&tmp, &path)
946 }
947
948 async fn apply_pending<S, SFut>(
957 &self,
958 save: &S,
959 ) -> std::result::Result<(), (RefreshFailure, Option<OAuthCredentials>)>
960 where
961 S: Fn(&str, &OAuthCredentials) -> SFut,
962 SFut: std::future::Future<Output = std::result::Result<CasOutcome, RefreshFailure>>,
963 {
964 let path = pending_path(&self.path);
965 let Ok(text) = std::fs::read_to_string(&path) else {
966 return Ok(());
967 };
968 let parked = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| {
969 let expected = v.get("expected_refresh")?.as_str()?.to_string();
970 let cred = serde_json::from_value::<OAuthCredentials>(v.get("credential")?.clone()).ok()?;
971 Some((expected, cred))
972 });
973 let Some((expected, cred)) = parked else {
974 let _ = std::fs::remove_file(&path);
975 return Ok(());
976 };
977 let failure = match save(&expected, &cred).await {
978 Ok(CasOutcome::Saved | CasOutcome::Replaced | CasOutcome::Removed) => {
979 let _ = std::fs::remove_file(&path);
980 return Ok(());
981 }
982 Ok(CasOutcome::StoreMissing) => RefreshFailure::new(
983 RefreshFailureKind::ActionRequired,
984 format!(
985 "the credential store no longer exists; this account's refreshed login is \
986 kept in {}. Put the store back (or repair it) to keep using it; logging in \
987 again replaces it.",
988 path.display()
989 ),
990 ),
991 Err(e) if e.kind == RefreshFailureKind::ActionRequired => RefreshFailure::new(
992 RefreshFailureKind::ActionRequired,
993 format!(
994 "{} This account's refreshed login is kept in {} and is applied once the \
995 store can be written.",
996 e.message(),
997 path.display()
998 ),
999 ),
1000 Err(e) => RefreshFailure::other(format!(
1001 "a refreshed login is waiting in {} and could not be applied yet ({e}); retry shortly",
1002 path.display()
1003 )),
1004 };
1005 Err((failure, Some(cred)))
1006 }
1007
1008 fn stamp_refreshed(&self, now_ms: u64) -> std::io::Result<()> {
1009 use std::io::{Seek, Write};
1010 let mut file = &self.file;
1011 file.set_len(0)?;
1012 file.rewind()?;
1013 file.write_all(now_ms.to_string().as_bytes())
1014 }
1015}
1016
1017async fn acquire_refresh_lock(
1021 path: PathBuf,
1022 wait: std::time::Duration,
1023) -> std::result::Result<RefreshFileLock, RefreshFailure> {
1024 tokio::task::spawn_blocking(move || {
1025 use fs4::fs_std::FileExt;
1026 let other = |m: String| RefreshFailure::new(RefreshFailureKind::Other, m);
1027 if let Some(parent) = path.parent() {
1028 std::fs::create_dir_all(parent)
1029 .map_err(|e| other(format!("Failed to create {}: {}", parent.display(), e)))?;
1030 }
1031 let mut open = std::fs::OpenOptions::new();
1032 open.create(true).read(true).write(true).truncate(false);
1035 #[cfg(unix)]
1036 {
1037 use std::os::unix::fs::OpenOptionsExt;
1038 open.mode(0o600);
1039 }
1040 let file = open
1041 .open(&path)
1042 .map_err(|e| other(format!("Failed to open refresh lock {}: {}", path.display(), e)))?;
1043 let deadline = std::time::Instant::now() + wait;
1044 loop {
1045 match FileExt::try_lock_exclusive(&file) {
1046 Ok(true) => return Ok(RefreshFileLock { file, path }),
1047 Ok(false) => {}
1048 Err(e) => {
1049 return Err(other(format!(
1050 "Failed to lock refresh lock {}: {}",
1051 path.display(),
1052 e
1053 )))
1054 }
1055 }
1056 if std::time::Instant::now() >= deadline {
1057 return Err(RefreshFailure::new(
1058 RefreshFailureKind::LockBusy,
1059 format!(
1060 "timed out waiting for another process to finish refreshing ({})",
1061 path.display()
1062 ),
1063 ));
1064 }
1065 std::thread::sleep(std::time::Duration::from_millis(25));
1066 }
1067 })
1068 .await
1069 .map_err(|e| RefreshFailure::new(RefreshFailureKind::Other, format!("refresh lock task failed: {e}")))?
1070}
1071
1072#[cfg(test)]
1085pub(crate) async fn ensure_fresh_gated<Load, Refresh, RFut, Save>(
1086 gate: Arc<Mutex<()>>,
1087 load: Load,
1088 refresh: Refresh,
1089 save: Save,
1090) -> std::result::Result<OAuthCredentials, String>
1091where
1092 Load: Fn() -> std::result::Result<OAuthCredentials, String>,
1093 Refresh: FnOnce(String) -> RFut,
1094 RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, String>>,
1095 Save: FnOnce(&OAuthCredentials) -> std::result::Result<(), String>,
1096{
1097 let _gate = gate.lock_owned().await;
1098 let creds = load()?;
1101 if !is_token_expired(&creds) {
1102 return Ok(creds);
1103 }
1104 let fresh = refresh(creds.refresh).await?;
1105 save(&fresh)?;
1106 Ok(fresh)
1107}
1108
1109const MAX_CAS_ROUNDS: usize = 2;
1112
1113#[cfg(test)]
1125pub(crate) async fn ensure_fresh_gated_locked<Load, Refresh, RFut, Save>(
1126 gate: Arc<Mutex<()>>,
1127 lock_path: Option<PathBuf>,
1128 load: Load,
1129 refresh: Refresh,
1130 save: Save,
1131) -> std::result::Result<OAuthCredentials, String>
1132where
1133 Load: Fn() -> std::result::Result<OAuthCredentials, String>,
1134 Refresh: Fn(String) -> RFut,
1135 RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, String>>,
1136 Save: Fn(&str, &OAuthCredentials) -> std::result::Result<CasOutcome, String>,
1137{
1138 let save = |old: &str, fresh: &OAuthCredentials| {
1139 std::future::ready(save(old, fresh).map_err(RefreshFailure::from))
1140 };
1141 let load = || load().map_err(RefreshFailure::from);
1142 ensure_gated_locked_policy(gate, lock_path, load, refresh, save, None, |_| Ok(()))
1143 .await
1144 .map_err(String::from)
1145}
1146
1147#[cfg(test)]
1156async fn ensure_gated_locked_policy<Load, Refresh, RFut, RErr, Save, SFut, MayForce>(
1157 gate: Arc<Mutex<()>>,
1158 lock_path: Option<PathBuf>,
1159 load: Load,
1160 refresh: Refresh,
1161 save: Save,
1162 rejected: Option<&str>,
1163 may_force: MayForce,
1164) -> std::result::Result<OAuthCredentials, RefreshFailure>
1165where
1166 Load: Fn() -> std::result::Result<OAuthCredentials, RefreshFailure>,
1167 Refresh: Fn(String) -> RFut,
1168 RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, RErr>>,
1169 RErr: Into<RefreshFailure>,
1170 Save: Fn(&str, &OAuthCredentials) -> SFut,
1171 SFut: std::future::Future<Output = std::result::Result<CasOutcome, RefreshFailure>>,
1172 MayForce: Fn(Option<u64>) -> std::result::Result<(), RefreshFailure>,
1173{
1174 ensure_gated_locked_policy_with(
1175 gate,
1176 lock_path,
1177 load,
1178 refresh,
1179 save,
1180 rejected,
1181 may_force,
1182 false,
1183 None,
1184 )
1185 .await
1186}
1187
1188#[allow(clippy::too_many_arguments)]
1195async fn ensure_gated_locked_policy_with<Load, Refresh, RFut, RErr, Save, SFut, MayForce>(
1196 gate: Arc<Mutex<()>>,
1197 lock_path: Option<PathBuf>,
1198 load: Load,
1199 refresh: Refresh,
1200 save: Save,
1201 rejected: Option<&str>,
1202 may_force: MayForce,
1203 serve_parked: bool,
1204 memory: Option<&dyn RefusalMemory>,
1205) -> std::result::Result<OAuthCredentials, RefreshFailure>
1206where
1207 Load: Fn() -> std::result::Result<OAuthCredentials, RefreshFailure>,
1208 Refresh: Fn(String) -> RFut,
1209 RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, RErr>>,
1210 RErr: Into<RefreshFailure>,
1211 Save: Fn(&str, &OAuthCredentials) -> SFut,
1212 SFut: std::future::Future<Output = std::result::Result<CasOutcome, RefreshFailure>>,
1213 MayForce: Fn(Option<u64>) -> std::result::Result<(), RefreshFailure>,
1214{
1215 let _gate = tokio::time::timeout(REFRESH_LOCK_WAIT, gate.lock_owned())
1217 .await
1218 .map_err(|_| {
1219 RefreshFailure::new(
1220 RefreshFailureKind::LockBusy,
1221 "timed out waiting for an in-flight token refresh",
1222 )
1223 })?;
1224 let file_lock = match lock_path {
1225 Some(path) => Some(acquire_refresh_lock(path, REFRESH_LOCK_WAIT).await?),
1226 None => None,
1227 };
1228 if let Some(lock) = &file_lock {
1231 if let Err((failure, parked)) = lock.apply_pending(&save).await {
1232 let usable = parked.filter(|p| {
1233 serve_parked
1234 && !is_token_expired(p)
1235 && !rejected.is_some_and(|r| !r.is_empty() && r == p.access)
1236 });
1237 let Some(mut parked) = usable else {
1238 return Err(failure);
1239 };
1240 tracing::warn!(error = %failure, "serving the parked access token until its refresh can be saved");
1241 parked.refresh.clear();
1242 return Ok(parked);
1243 }
1244 }
1245 for _ in 0..MAX_CAS_ROUNDS {
1246 let creds = load()?;
1249 let expired = is_token_expired(&creds);
1252 let still_rejected = rejected.is_some_and(|r| !r.is_empty() && creds.access == r);
1253 if !expired && !still_rejected {
1254 return Ok(creds);
1255 }
1256 if let Some(held) = memory.and_then(|m| m.held(&creds)) {
1260 return Err(held);
1261 }
1262 if !expired {
1263 may_force(file_lock.as_ref().and_then(RefreshFileLock::last_refresh_ms))?;
1265 }
1266 let old_refresh = creds.refresh.clone();
1267 let exchanged = tokio::time::timeout(REFRESH_HTTP_DEADLINE, refresh(old_refresh.clone()))
1270 .await
1271 .map_err(|_| RefreshFailure::new(RefreshFailureKind::Transient, "token refresh timed out"))?;
1272 let mut fresh = match exchanged {
1273 Ok(fresh) => fresh,
1274 Err(e) => {
1275 let failure: RefreshFailure = e.into();
1276 if let Some(memory) = memory {
1278 memory.refused(&creds, &failure);
1279 }
1280 return Err(failure);
1281 }
1282 };
1283 if fresh.account_id.is_none() {
1284 fresh.account_id = creds.account_id.clone();
1285 }
1286 let saved = match save(&old_refresh, &fresh).await {
1287 Ok(saved) => saved,
1288 Err(e) => return park_unsaved_rotation(file_lock.as_ref(), &old_refresh, fresh, e),
1289 };
1290 match saved {
1291 CasOutcome::Saved => {
1292 if let Some(lock) = &file_lock {
1293 lock.record_refreshed(crate::epoch_millis());
1294 }
1295 return Ok(fresh);
1296 }
1297 CasOutcome::StoreMissing => {
1298 let err = RefreshFailure::new(
1301 RefreshFailureKind::ActionRequired,
1302 "the credential store no longer exists",
1303 );
1304 return park_unsaved_rotation(file_lock.as_ref(), &old_refresh, fresh, err);
1305 }
1306 CasOutcome::Removed => {
1307 return Err(
1308 "credential was removed while its token was being refreshed; run `synaps login`"
1309 .to_string()
1310 .into(),
1311 )
1312 }
1313 CasOutcome::Replaced => {
1314 continue;
1318 }
1319 }
1320 }
1321 Err(RefreshFailure::new(
1322 RefreshFailureKind::Other,
1323 "credential kept changing during refresh; retry",
1324 ))
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329 use super::super::storage::save_provider_auth_at_test_hook;
1330 use super::super::OAuthCredentials;
1331
1332 fn fresh_creds(refresh: &str) -> OAuthCredentials {
1333 OAuthCredentials {
1334 auth_type: "oauth".to_string(),
1335 refresh: refresh.to_string(),
1336 access: "access".to_string(),
1337 expires: crate::epoch_millis() + 3_600_000,
1338 account_id: None,
1339 }
1340 }
1341
1342 #[test]
1359 fn bug2_write_produces_non_empty_file() {
1360 let dir = tempfile::tempdir().expect("tempdir");
1361 let path = dir.path().join("auth.json");
1362 save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok1"))
1363 .expect("save must succeed");
1364 let meta = std::fs::metadata(&path).expect("file must exist");
1365 assert!(meta.len() > 0, "auth.json must not be empty after save");
1366 }
1367
1368 #[test]
1373 fn bug2_tmp_file_cleaned_up_after_successful_write() {
1374 let dir = tempfile::tempdir().expect("tempdir");
1375 let path = dir.path().join("auth.json");
1376 save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok2"))
1377 .expect("save must succeed");
1378 let tmp = path.with_extension("json.tmp");
1379 assert!(
1380 !tmp.exists(),
1381 "auth.json.tmp must not persist after a successful atomic rename"
1382 );
1383 }
1384
1385 #[test]
1389 fn bug2_final_file_is_valid_json_with_correct_credential() {
1390 let dir = tempfile::tempdir().expect("tempdir");
1391 let path = dir.path().join("auth.json");
1392 save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok3"))
1393 .expect("save must succeed");
1394 let content = std::fs::read_to_string(&path).expect("must be readable");
1395 let parsed: serde_json::Value =
1396 serde_json::from_str(&content).expect("must be valid JSON after atomic write");
1397 assert_eq!(
1398 parsed["anthropic"]["refresh"].as_str(),
1399 Some("tok3"),
1400 "refresh token must be persisted correctly"
1401 );
1402 }
1403
1404 #[test]
1408 fn bug2_refresh_write_preserves_other_providers() {
1409 let dir = tempfile::tempdir().expect("tempdir");
1410 let path = dir.path().join("auth.json");
1411
1412 save_provider_auth_at_test_hook(&path, "openai-codex", &fresh_creds("codex-tok"))
1414 .expect("save codex");
1415 save_provider_auth_at_test_hook(&path, "future-provider", &fresh_creds("future-tok"))
1416 .expect("save future");
1417
1418 save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("anth-new"))
1420 .expect("save anthropic after refresh");
1421
1422 let content = std::fs::read_to_string(&path).expect("readable");
1423 let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid JSON");
1424
1425 assert_eq!(parsed["anthropic"]["refresh"].as_str(), Some("anth-new"));
1426 assert_eq!(
1427 parsed["openai-codex"]["refresh"].as_str(),
1428 Some("codex-tok"),
1429 "openai-codex must survive the anthropic refresh write"
1430 );
1431 assert_eq!(
1432 parsed["future-provider"]["refresh"].as_str(),
1433 Some("future-tok"),
1434 "future-provider must survive (fixes #184 — 3rd provider not dropped)"
1435 );
1436 }
1437
1438 use super::ensure_fresh_gated;
1446 use std::sync::atomic::{AtomicUsize, Ordering};
1447 use std::sync::{Arc, Mutex as StdMutex};
1448
1449 fn expired_creds(refresh: &str) -> OAuthCredentials {
1450 OAuthCredentials {
1451 auth_type: "oauth".to_string(),
1452 refresh: refresh.to_string(),
1453 access: "stale-access".to_string(),
1454 expires: 0,
1455 account_id: None,
1456 }
1457 }
1458
1459 #[derive(Clone)]
1461 struct FakeStore {
1462 creds: Arc<StdMutex<OAuthCredentials>>,
1463 refresh_calls: Arc<AtomicUsize>,
1464 save_calls: Arc<AtomicUsize>,
1465 }
1466
1467 impl FakeStore {
1468 fn new(initial: OAuthCredentials) -> Self {
1469 Self {
1470 creds: Arc::new(StdMutex::new(initial)),
1471 refresh_calls: Arc::new(AtomicUsize::new(0)),
1472 save_calls: Arc::new(AtomicUsize::new(0)),
1473 }
1474 }
1475
1476 async fn ensure(
1477 &self,
1478 gate: Arc<tokio::sync::Mutex<()>>,
1479 ) -> Result<OAuthCredentials, String> {
1480 let store = self.clone();
1481 let refresh_calls = self.refresh_calls.clone();
1482 ensure_fresh_gated(
1483 gate,
1484 move || Ok(store.creds.lock().unwrap().clone()),
1485 move |old_refresh| async move {
1486 refresh_calls.fetch_add(1, Ordering::SeqCst);
1487 Ok(OAuthCredentials {
1489 auth_type: "oauth".to_string(),
1490 refresh: format!("{old_refresh}-rotated"),
1491 access: "fresh-access".to_string(),
1492 expires: crate::epoch_millis() + 3_600_000,
1493 account_id: None,
1494 })
1495 },
1496 |c| {
1497 self.save_calls.fetch_add(1, Ordering::SeqCst);
1498 *self.creds.lock().unwrap() = c.clone();
1499 Ok(())
1500 },
1501 )
1502 .await
1503 }
1504 }
1505
1506 #[tokio::test]
1509 async fn anthropic_refresh_single_flight_under_concurrency() {
1510 let store = FakeStore::new(expired_creds("r0"));
1511 let gate = Arc::new(tokio::sync::Mutex::new(()));
1512 let mut handles = Vec::new();
1513 for _ in 0..16 {
1514 let store = store.clone();
1515 let gate = gate.clone();
1516 handles.push(tokio::spawn(async move { store.ensure(gate).await }));
1517 }
1518 for h in handles {
1519 let creds = h.await.unwrap().expect("refresh must succeed");
1520 assert_eq!(creds.access, "fresh-access");
1521 assert_eq!(creds.refresh, "r0-rotated");
1522 }
1523 assert_eq!(
1524 store.refresh_calls.load(Ordering::SeqCst),
1525 1,
1526 "exactly one caller may rotate the refresh token"
1527 );
1528 assert_eq!(store.save_calls.load(Ordering::SeqCst), 1);
1529 }
1530
1531 #[tokio::test]
1534 async fn refresh_rotation_chains_from_persisted_token() {
1535 let store = FakeStore::new(expired_creds("gen0"));
1536 let gate = Arc::new(tokio::sync::Mutex::new(()));
1537 store.ensure(gate.clone()).await.unwrap();
1538 assert_eq!(store.creds.lock().unwrap().refresh, "gen0-rotated");
1539
1540 store.creds.lock().unwrap().expires = 0;
1542 let second = store.ensure(gate).await.unwrap();
1543 assert_eq!(second.refresh, "gen0-rotated-rotated");
1544 assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 2);
1545 }
1546
1547 #[tokio::test]
1549 async fn fresh_credential_short_circuits_without_refresh() {
1550 let store = FakeStore::new(fresh_creds("keep"));
1551 let gate = Arc::new(tokio::sync::Mutex::new(()));
1552 let creds = store.ensure(gate).await.unwrap();
1553 assert_eq!(creds.refresh, "keep");
1554 assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 0);
1555 assert_eq!(store.save_calls.load(Ordering::SeqCst), 0);
1556 }
1557
1558 #[test]
1559 fn oauth_error_body_is_sanitized_and_utf8_safe() {
1560 use super::sanitize_oauth_error_body as s;
1561 assert_eq!(
1562 s(r#"{"error":"invalid_grant","error_description":"Refresh token revoked"}"#),
1563 "invalid_grant: Refresh token revoked"
1564 );
1565 assert_eq!(
1566 s(r#"{"type":"error","error":{"type":"authentication_error","message":"bad"}}"#),
1567 "authentication_error: bad"
1568 );
1569 let echoed = s(r#"{"grant_type":"refresh_token","refresh_token":"rt-SECRET"}"#);
1571 assert!(!echoed.contains("rt-SECRET"), "{echoed}");
1572 let html = format!("<html>{}</html>", "é".repeat(300));
1573 assert_eq!(s(&html), "<non-JSON error body omitted>");
1574 let long = format!(r#"{{"error":"{}"}}"#, "é".repeat(500));
1576 assert_eq!(s(&long).chars().count(), 120);
1577 let straddle = format!("{}é{}", "a".repeat(199), "b".repeat(50));
1580 assert!(!straddle.is_char_boundary(200));
1581 assert_eq!(s(&straddle), "<non-JSON error body omitted>");
1582 }
1583 #[tokio::test]
1586 async fn refresh_failure_persists_nothing() {
1587 let saves = Arc::new(AtomicUsize::new(0));
1588 let saves2 = saves.clone();
1589 let err = ensure_fresh_gated(
1590 Arc::new(tokio::sync::Mutex::new(())),
1591 || Ok(expired_creds("r-fail")),
1592 |_r| async move { Err("provider 500".to_string()) },
1593 move |_c| {
1594 saves2.fetch_add(1, Ordering::SeqCst);
1595 Ok(())
1596 },
1597 )
1598 .await
1599 .unwrap_err();
1600 assert!(err.contains("provider 500"));
1601 assert_eq!(
1602 saves.load(Ordering::SeqCst),
1603 0,
1604 "failed refresh must not persist"
1605 );
1606 }
1607
1608 use super::super::account::{Account, CredentialRef};
1611 use super::super::provider::OAuthProviderId;
1612 use super::super::storage::{
1613 load_provider_auth_at, remove_key_at, save_provider_auth_at,
1614 save_provider_auth_if_refresh_matches_at,
1615 };
1616 use super::{ensure_fresh_gated_locked, refresh_lock_path, CasOutcome};
1617
1618 fn named(label: &str) -> CredentialRef {
1619 CredentialRef::new(OAuthProviderId::OpenAiCodex, Account::named(label).unwrap())
1620 }
1621
1622 async fn ensure_on_file(
1626 path: &std::path::Path,
1627 cred: &CredentialRef,
1628 presented: Arc<StdMutex<Vec<String>>>,
1629 delay: std::time::Duration,
1630 ) -> Result<OAuthCredentials, String> {
1631 let key = cred.storage_key();
1632 let gate = super::refresh_gate(&format!("{}|{}", path.display(), key));
1633 let lock = refresh_lock_path(path, &key);
1634 let load_path = path.to_path_buf();
1635 let load_key = key.clone();
1636 ensure_fresh_gated_locked(
1637 gate,
1638 Some(lock),
1639 move || {
1640 load_provider_auth_at(&load_path, &load_key)?.ok_or_else(|| "missing".to_string())
1641 },
1642 |old| {
1643 let presented = presented.clone();
1644 async move {
1645 presented.lock().unwrap().push(old.clone());
1646 tokio::time::sleep(delay).await;
1647 Ok(OAuthCredentials {
1648 auth_type: "oauth".into(),
1649 refresh: format!("{old}-rotated"),
1650 access: format!("{old}-access"),
1651 expires: crate::epoch_millis() + 3_600_000,
1652 account_id: None,
1653 })
1654 }
1655 },
1656 |old, fresh| save_provider_auth_if_refresh_matches_at(path, &key, old, fresh),
1657 )
1658 .await
1659 }
1660
1661 #[tokio::test]
1665 async fn two_accounts_refresh_independently_without_cross_talk() {
1666 let dir = tempfile::tempdir().unwrap();
1667 let path = dir.path().join("auth.json");
1668 save_provider_auth_at(&path, "openai-codex", &fresh_creds("default-r")).unwrap();
1669 save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1670 save_provider_auth_at(&path, "openai-codex@b", &expired_creds("b-r")).unwrap();
1671 let presented = Arc::new(StdMutex::new(Vec::new()));
1672 let mut handles = Vec::new();
1673 for label in ["a", "b", "a", "b", "a", "b"] {
1674 let path = path.clone();
1675 let presented = presented.clone();
1676 handles.push(tokio::spawn(async move {
1677 ensure_on_file(&path, &named(label), presented, std::time::Duration::from_millis(30))
1678 .await
1679 .unwrap()
1680 }));
1681 }
1682 for h in handles {
1683 h.await.unwrap();
1684 }
1685 let mut seen = presented.lock().unwrap().clone();
1686 seen.sort();
1687 assert_eq!(seen, vec!["a-r".to_string(), "b-r".to_string()], "one rotation per account");
1688 let a = load_provider_auth_at(&path, "openai-codex@a").unwrap().unwrap();
1689 let b = load_provider_auth_at(&path, "openai-codex@b").unwrap().unwrap();
1690 let d = load_provider_auth_at(&path, "openai-codex").unwrap().unwrap();
1691 assert_eq!(a.refresh, "a-r-rotated");
1692 assert_eq!(b.refresh, "b-r-rotated");
1693 assert_eq!(d.refresh, "default-r", "default slot untouched");
1694 }
1695
1696 #[tokio::test]
1700 async fn refresh_waits_for_cross_process_lock_of_same_credential_only() {
1701 use fs4::fs_std::FileExt;
1702 let dir = tempfile::tempdir().unwrap();
1703 let path = dir.path().join("auth.json");
1704 save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1705 save_provider_auth_at(&path, "openai-codex@b", &expired_creds("b-r")).unwrap();
1706 let lock_a = refresh_lock_path(&path, "openai-codex@a");
1708 let holder = std::fs::OpenOptions::new()
1709 .create(true)
1710 .write(true)
1711 .truncate(false)
1712 .open(&lock_a)
1713 .unwrap();
1714 FileExt::lock_exclusive(&holder).unwrap();
1715
1716 let presented = Arc::new(StdMutex::new(Vec::new()));
1717 let started = std::time::Instant::now();
1719 ensure_on_file(&path, &named("b"), presented.clone(), std::time::Duration::ZERO)
1720 .await
1721 .unwrap();
1722 assert!(started.elapsed() < std::time::Duration::from_secs(5));
1723
1724 let path_a = path.clone();
1726 let presented_a = presented.clone();
1727 let task = tokio::spawn(async move {
1728 ensure_on_file(&path_a, &named("a"), presented_a, std::time::Duration::ZERO).await
1729 });
1730 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1731 assert!(
1732 !presented.lock().unwrap().contains(&"a-r".to_string()),
1733 "must not rotate while another process holds the credential lock"
1734 );
1735 save_provider_auth_at(&path, "openai-codex@a", &fresh_creds("a-r-by-peer")).unwrap();
1737 FileExt::unlock(&holder).unwrap();
1738 let result = task.await.unwrap().unwrap();
1739 assert_eq!(result.refresh, "a-r-by-peer", "waiter re-reads the peer's rotation");
1740 assert!(
1741 !presented.lock().unwrap().contains(&"a-r".to_string()),
1742 "no second rotation of an already-rotated token"
1743 );
1744 }
1745
1746 #[tokio::test]
1749 async fn removal_during_refresh_is_not_resurrected() {
1750 let dir = tempfile::tempdir().unwrap();
1751 let path = dir.path().join("auth.json");
1752 save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1753 save_provider_auth_at(&path, "openai-codex@b", &fresh_creds("b-r")).unwrap();
1754 let presented = Arc::new(StdMutex::new(Vec::new()));
1755 let path2 = path.clone();
1756 let task = tokio::spawn(async move {
1757 ensure_on_file(&path2, &named("a"), presented, std::time::Duration::from_millis(300)).await
1758 });
1759 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1760 assert!(remove_key_at(&path, "openai-codex@a").unwrap());
1761 let err = task.await.unwrap().unwrap_err();
1762 assert!(err.contains("removed"), "{err}");
1763 assert!(load_provider_auth_at(&path, "openai-codex@a").unwrap().is_none());
1764 assert_eq!(
1765 load_provider_auth_at(&path, "openai-codex@b").unwrap().unwrap().refresh,
1766 "b-r"
1767 );
1768 }
1769
1770 #[tokio::test]
1773 async fn relogin_during_refresh_returns_fresh_replacement_not_stale_rotation() {
1774 let dir = tempfile::tempdir().unwrap();
1775 let path = dir.path().join("auth.json");
1776 save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1777 let presented = Arc::new(StdMutex::new(Vec::new()));
1778 let path2 = path.clone();
1779 let presented2 = presented.clone();
1780 let task = tokio::spawn(async move {
1781 ensure_on_file(&path2, &named("a"), presented2, std::time::Duration::from_millis(300)).await
1782 });
1783 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1784 save_provider_auth_at(&path, "openai-codex@a", &fresh_creds("relogin-r")).unwrap();
1785 let result = task.await.unwrap().unwrap();
1786 assert_eq!(result.refresh, "relogin-r");
1787 assert_eq!(
1788 load_provider_auth_at(&path, "openai-codex@a").unwrap().unwrap().refresh,
1789 "relogin-r"
1790 );
1791 assert_eq!(presented.lock().unwrap().as_slice(), ["a-r"], "no refresh of the fresh replacement");
1792 }
1793
1794 #[tokio::test]
1797 async fn expired_replacement_is_refreshed_not_returned() {
1798 let dir = tempfile::tempdir().unwrap();
1799 let path = dir.path().join("auth.json");
1800 save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1801 let presented = Arc::new(StdMutex::new(Vec::new()));
1802 let path2 = path.clone();
1803 let presented2 = presented.clone();
1804 let task = tokio::spawn(async move {
1805 ensure_on_file(&path2, &named("a"), presented2, std::time::Duration::from_millis(300)).await
1806 });
1807 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1808 save_provider_auth_at(&path, "openai-codex@a", &expired_creds("relogin-expired")).unwrap();
1809 let result = task.await.unwrap().unwrap();
1810 assert!(!super::is_token_expired(&result));
1811 assert_eq!(result.refresh, "relogin-expired-rotated");
1812 assert_eq!(
1813 presented.lock().unwrap().as_slice(),
1814 ["a-r", "relogin-expired"]
1815 );
1816 }
1817
1818 #[tokio::test]
1821 async fn refresh_preserves_account_id_when_response_omits_it() {
1822 let dir = tempfile::tempdir().unwrap();
1823 let path = dir.path().join("auth.json");
1824 let mut stored = expired_creds("a-r");
1825 stored.account_id = Some("acct-keep".into());
1826 save_provider_auth_at(&path, "openai-codex@a", &stored).unwrap();
1827 let result = ensure_on_file(
1828 &path,
1829 &named("a"),
1830 Arc::new(StdMutex::new(Vec::new())),
1831 std::time::Duration::ZERO,
1832 )
1833 .await
1834 .unwrap();
1835 assert_eq!(result.account_id.as_deref(), Some("acct-keep"));
1836 assert_eq!(
1837 load_provider_auth_at(&path, "openai-codex@a").unwrap().unwrap().account_id.as_deref(),
1838 Some("acct-keep")
1839 );
1840 }
1841
1842 #[tokio::test]
1847 async fn seat_pinned_vend_refuses_a_replaced_slot() {
1848 use super::super::account::SeatIdentity;
1849 let dir = tempfile::tempdir().unwrap();
1850 let path = dir.path().join("auth.json");
1851 let mut stored = fresh_creds("a-r");
1852 stored.account_id = Some("acct-a".into());
1853 save_provider_auth_at(&path, "openai-codex@a", &stored).unwrap();
1854 let client = reqwest::Client::new();
1855 let seat = SeatIdentity::of(OAuthProviderId::OpenAiCodex, &stored);
1856 let ok = super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, Some(&seat))
1857 .await
1858 .unwrap();
1859 assert_eq!(ok.refresh, "a-r");
1860 let mut rotated = fresh_creds("a-r2");
1863 rotated.account_id = Some("acct-a".into());
1864 save_provider_auth_at(&path, "openai-codex@a", &rotated).unwrap();
1865 assert!(
1866 super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, Some(&seat))
1867 .await
1868 .is_ok()
1869 );
1870 let mut other = fresh_creds("b-r");
1872 other.account_id = Some("acct-b".into());
1873 save_provider_auth_at(&path, "openai-codex@a", &other).unwrap();
1874 let err = super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, Some(&seat))
1875 .await
1876 .unwrap_err();
1877 assert_eq!(err, super::SEAT_CHANGED);
1878 assert!(!err.contains("b-r") && !err.contains("acct"));
1879 assert_eq!(
1881 super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, None)
1882 .await
1883 .unwrap()
1884 .refresh,
1885 "b-r"
1886 );
1887 assert!(remove_key_at(&path, "openai-codex@a").unwrap());
1889 let err = super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, Some(&seat))
1890 .await
1891 .unwrap_err();
1892 assert!(err.starts_with("No credentials for "), "{err}");
1893 assert_eq!(
1894 super::SeatVendError::SeatChanged.to_string(),
1895 super::SEAT_CHANGED
1896 );
1897 }
1898
1899 #[tokio::test]
1903 async fn detached_refresh_persists_after_caller_is_cancelled() {
1904 let dir = tempfile::tempdir().unwrap();
1905 let path = dir.path().join("auth.json");
1906 let cred = named("a");
1907 let key = cred.storage_key();
1908 save_provider_auth_at(&path, &key, &expired_creds("a-r")).unwrap();
1909 let (task_path, task_key) = (path.clone(), key.clone());
1910 let caller = super::run_detached(async move {
1911 let gate = super::refresh_gate(&format!("{}|{}", task_path.display(), task_key));
1912 let lock = refresh_lock_path(&task_path, &task_key);
1913 ensure_fresh_gated_locked(
1914 gate,
1915 Some(lock),
1916 || {
1917 load_provider_auth_at(&task_path, &task_key)?
1918 .ok_or_else(|| "missing".to_string())
1919 },
1920 |old| async move {
1921 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1922 Ok(OAuthCredentials {
1923 auth_type: "oauth".into(),
1924 refresh: format!("{old}-rotated"),
1925 access: format!("{old}-access"),
1926 expires: crate::epoch_millis() + 3_600_000,
1927 account_id: None,
1928 })
1929 },
1930 |old, fresh| {
1931 save_provider_auth_if_refresh_matches_at(&task_path, &task_key, old, fresh)
1932 },
1933 )
1934 .await
1935 });
1936 assert!(
1938 tokio::time::timeout(std::time::Duration::from_millis(20), caller)
1939 .await
1940 .is_err(),
1941 "caller must still be waiting when cancelled"
1942 );
1943 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1944 loop {
1945 let stored = load_provider_auth_at(&path, &key).unwrap().unwrap();
1946 if stored.refresh == "a-r-rotated" {
1947 break;
1948 }
1949 assert!(
1950 std::time::Instant::now() < deadline,
1951 "rotated token was never persisted after cancel"
1952 );
1953 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1954 }
1955 }
1956
1957 fn slow_refresh_future(
1960 path: std::path::PathBuf,
1961 label: &str,
1962 started: Arc<std::sync::atomic::AtomicBool>,
1963 ) -> impl std::future::Future<Output = Result<OAuthCredentials, String>> + Send + 'static {
1964 let key = named(label).storage_key();
1965 async move {
1966 let gate = super::refresh_gate(&format!("{}|{}", path.display(), key));
1967 let lock = refresh_lock_path(&path, &key);
1968 ensure_fresh_gated_locked(
1969 gate,
1970 Some(lock),
1971 || load_provider_auth_at(&path, &key)?.ok_or_else(|| "missing".to_string()),
1972 |old| {
1973 let started = started.clone();
1974 async move {
1975 started.store(true, std::sync::atomic::Ordering::SeqCst);
1976 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1977 Ok(OAuthCredentials {
1978 auth_type: "oauth".into(),
1979 refresh: format!("{old}-rotated"),
1980 access: format!("{old}-access"),
1981 expires: crate::epoch_millis() + 3_600_000,
1982 account_id: None,
1983 })
1984 }
1985 },
1986 |old, fresh| save_provider_auth_if_refresh_matches_at(&path, &key, old, fresh),
1987 )
1988 .await
1989 }
1990 }
1991
1992 fn wait_for_rotation(path: &std::path::Path, label: &str, want: &str) {
1993 let key = named(label).storage_key();
1994 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1995 while load_provider_auth_at(path, &key).unwrap().unwrap().refresh != want {
1996 assert!(
1997 std::time::Instant::now() < deadline,
1998 "rotated token was never persisted after the caller's runtime died"
1999 );
2000 std::thread::sleep(std::time::Duration::from_millis(20));
2001 }
2002 }
2003
2004 #[test]
2008 fn refresh_survives_dropping_the_callers_runtime_mid_exchange() {
2009 let dir = tempfile::tempdir().unwrap();
2010 let path = dir.path().join("auth.json");
2011 save_provider_auth_at(&path, &named("a").storage_key(), &expired_creds("a-r")).unwrap();
2012 let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
2013 let worker = tokio::runtime::Builder::new_current_thread()
2014 .enable_all()
2015 .build()
2016 .unwrap();
2017 let fut = slow_refresh_future(path.clone(), "a", started.clone());
2018 worker.spawn(super::run_detached(fut));
2019 worker.block_on(async {
2020 while !started.load(std::sync::atomic::Ordering::SeqCst) {
2021 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2022 }
2023 });
2024 let t = std::time::Instant::now();
2025 drop(worker); assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed());
2027 wait_for_rotation(&path, "a", "a-r-rotated");
2028 }
2029
2030 #[test]
2035 fn dropping_the_callers_runtime_while_waiting_on_the_lock_is_prompt() {
2036 use fs4::fs_std::FileExt;
2037 let dir = tempfile::tempdir().unwrap();
2038 let path = dir.path().join("auth.json");
2039 let key = named("b").storage_key();
2040 save_provider_auth_at(&path, &key, &expired_creds("b-r")).unwrap();
2041 let holder = std::fs::OpenOptions::new()
2042 .create(true)
2043 .write(true)
2044 .truncate(false)
2045 .open(refresh_lock_path(&path, &key))
2046 .unwrap();
2047 FileExt::lock_exclusive(&holder).unwrap();
2048 let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
2049 let worker = tokio::runtime::Builder::new_current_thread()
2050 .enable_all()
2051 .build()
2052 .unwrap();
2053 worker.spawn(super::run_detached(slow_refresh_future(path.clone(), "b", started.clone())));
2054 worker.block_on(async { tokio::time::sleep(std::time::Duration::from_millis(100)).await });
2055 assert!(!started.load(std::sync::atomic::Ordering::SeqCst), "must wait for the peer");
2056 let t = std::time::Instant::now();
2057 drop(worker);
2058 assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed());
2059 drop(holder);
2060 wait_for_rotation(&path, "b", "b-r-rotated");
2061 }
2062
2063 #[tokio::test]
2069 async fn refresh_file_lock_excludes_second_holder_until_released() {
2070 let dir = tempfile::tempdir().unwrap();
2071 let path = dir.path().join("auth.json.anthropic.refresh.lock");
2072 let short = std::time::Duration::from_millis(150);
2073 let held = super::acquire_refresh_lock(path.clone(), short).await.unwrap();
2074 let err = match super::acquire_refresh_lock(path.clone(), short).await {
2075 Ok(_) => panic!("second holder must time out"),
2076 Err(e) => e,
2077 };
2078 assert_eq!(err.kind, super::RefreshFailureKind::LockBusy);
2079 assert!(err.message().contains("timed out"), "{err}");
2080 drop(held);
2081 super::acquire_refresh_lock(path, short).await.unwrap();
2082 }
2083
2084 #[tokio::test]
2087 async fn fresh_credential_skips_the_refresh_lock() {
2088 use fs4::fs_std::FileExt;
2089 let dir = tempfile::tempdir().unwrap();
2090 let path = dir.path().join("auth.json");
2091 let cred = named("a");
2092 save_provider_auth_at(&path, &cred.storage_key(), &fresh_creds("a-r")).unwrap();
2093 let holder = std::fs::OpenOptions::new()
2094 .create(true)
2095 .write(true)
2096 .truncate(false)
2097 .open(refresh_lock_path(&path, &cred.storage_key()))
2098 .unwrap();
2099 FileExt::lock_exclusive(&holder).unwrap();
2100 let got = tokio::time::timeout(
2101 std::time::Duration::from_secs(5),
2102 super::ensure_fresh_credential_at(&reqwest::Client::new(), &cred, &path),
2103 )
2104 .await
2105 .expect("fresh vend must not wait on the refresh lock")
2106 .unwrap();
2107 assert_eq!(got.refresh, "a-r");
2108 }
2109
2110 #[test]
2111 fn refresh_lock_path_is_per_credential_and_beside_the_file() {
2112 let path = std::path::Path::new("/tmp/x/auth.json");
2113 assert_eq!(
2114 refresh_lock_path(path, "openai-codex@astra2"),
2115 std::path::PathBuf::from("/tmp/x/auth.json.openai-codex@astra2.refresh.lock")
2116 );
2117 assert_eq!(
2121 refresh_lock_path(path, "anthropic"),
2122 std::path::PathBuf::from("/tmp/x/auth.json.anthropic.refresh.lock")
2123 );
2124 assert_ne!(
2125 refresh_lock_path(path, "openai-codex"),
2126 refresh_lock_path(path, "openai-codex@astra2")
2127 );
2128 let _ = CasOutcome::Saved;
2129 }
2130}
2131
2132#[cfg(test)]
2133mod rejected_token_tests {
2134 use super::*;
2135 use std::sync::atomic::{AtomicUsize, Ordering};
2136 use std::sync::Mutex as StdMutex;
2137
2138 fn credential(access: &str) -> OAuthCredentials {
2139 OAuthCredentials {
2140 auth_type: "oauth".into(),
2141 access: access.into(),
2142 refresh: "r0".into(),
2143 expires: crate::epoch_millis() + 3_600_000,
2144 account_id: Some("account".into()),
2145 }
2146 }
2147 async fn recover(
2148 store: &StdMutex<OAuthCredentials>,
2149 gate: Arc<Mutex<()>>,
2150 calls: &AtomicUsize,
2151 lock: Option<PathBuf>,
2152 rejected: &str,
2153 ) -> Result<OAuthCredentials, String> {
2154 ensure_gated_locked_policy(
2155 gate,
2156 lock,
2157 || Ok(store.lock().unwrap().clone()),
2158 |_| async {
2159 calls.fetch_add(1, Ordering::SeqCst);
2160 tokio::task::yield_now().await;
2161 Ok::<_, String>(credential("new"))
2162 },
2163 |_, fresh| {
2164 *store.lock().unwrap() = fresh.clone();
2165 std::future::ready(Ok(CasOutcome::Saved))
2166 },
2167 Some(rejected),
2168 |_| Ok(()),
2169 )
2170 .await
2171 .map_err(String::from)
2172 }
2173 #[test]
2176 fn refresh_runtime_build_is_retried_after_a_failure() {
2177 let slot = std::sync::Mutex::new(None);
2178 let err = runtime_from(&slot, || Err(std::io::Error::other("EAGAIN"))).unwrap_err();
2179 assert_eq!(err.kind, RefreshFailureKind::Other);
2180 assert!(err.message().contains("EAGAIN"), "{err}");
2181 let built = runtime_from(&slot, || {
2182 tokio::runtime::Builder::new_current_thread().build()
2183 })
2184 .expect("second attempt builds");
2185 let again = runtime_from(&slot, || panic!("must reuse the built runtime")).unwrap();
2186 assert!(std::ptr::eq(built, again));
2187 }
2188
2189 #[test]
2192 fn failed_stamp_write_keeps_an_in_process_cooldown_floor() {
2193 let dir = tempfile::tempdir().unwrap();
2194 let path = dir.path().join("auth.json.muse.refresh.lock");
2195 std::fs::write(&path, "").unwrap();
2196 let lock = RefreshFileLock {
2198 file: std::fs::File::open(&path).unwrap(),
2199 path: path.clone(),
2200 };
2201 let now = 5_000_000_000;
2202 lock.record_refreshed(now);
2203 assert_eq!(std::fs::read_to_string(&path).unwrap(), "", "stamp write failed");
2204 assert_eq!(lock.last_refresh_ms(), Some(now));
2205 let cred = CredentialRef::default_for(super::super::provider::OAuthProviderId::Muse);
2206 let refused = forced_refresh_allowed(&cred, lock.last_refresh_ms(), now + 5_000).unwrap_err();
2207 assert!(matches!(refused.kind, RefreshFailureKind::Cooldown { .. }), "{refused}");
2208
2209 let other_path = dir.path().join("auth.json.muse@work.refresh.lock");
2211 std::fs::write(&other_path, "").unwrap();
2212 let other = RefreshFileLock {
2213 file: std::fs::File::open(&other_path).unwrap(),
2214 path: other_path,
2215 };
2216 assert_eq!(other.last_refresh_ms(), None);
2217 }
2218
2219 #[test]
2222 fn corrupt_store_load_is_action_required_not_credential() {
2223 let dir = tempfile::tempdir().unwrap();
2224 let path = dir.path().join("auth.json");
2225 std::fs::write(&path, "{oops").unwrap();
2226 let cred = CredentialRef::default_for(super::super::provider::OAuthProviderId::Anthropic);
2227 let err = load_checked(&path, &cred, None).unwrap_err();
2228 assert_eq!(err.kind, RefreshFailureKind::ActionRequired, "{err}");
2229 assert!(err.message().contains("mv '"), "{err}");
2230 assert!(!err.message().contains("synaps login"), "{err}");
2231 }
2232
2233 #[tokio::test]
2236 async fn refresh_runtime_start_failure_is_an_error() {
2237 let err = run_detached_on(
2238 Err(RefreshFailure::other("could not start the auth refresh runtime: boom")),
2239 async { Ok::<u8, RefreshFailure>(1) },
2240 )
2241 .await
2242 .unwrap_err();
2243 assert_eq!(err.kind, RefreshFailureKind::Other);
2244 assert!(err.message().contains("refresh runtime"), "{err}");
2245 let ok: Result<u8, RefreshFailure> =
2247 run_detached_on(refresh_runtime(), async { Ok(7) }).await;
2248 assert_eq!(ok.unwrap(), 7);
2249 }
2250
2251 #[tokio::test]
2257 async fn rotated_token_survives_a_failed_save() {
2258 let dir = tempfile::tempdir().unwrap();
2259 let lock = dir.path().join("auth.json.anthropic.refresh.lock");
2260 let mut stale = credential("old-access");
2261 stale.expires = 0;
2262 let store = StdMutex::new(stale);
2263 let refreshes = AtomicUsize::new(0);
2264 let fail_saves = AtomicUsize::new(1);
2265 let run = || {
2266 ensure_gated_locked_policy(
2267 Arc::new(Mutex::new(())),
2268 Some(lock.clone()),
2269 || Ok(store.lock().unwrap().clone()),
2270 |old| {
2271 refreshes.fetch_add(1, Ordering::SeqCst);
2272 let mut c = credential("rotated-access");
2273 c.refresh = format!("{old}-rotated");
2274 async move { Ok::<_, String>(c) }
2275 },
2276 |expected: &str, fresh: &OAuthCredentials| {
2277 let out = if fail_saves.load(Ordering::SeqCst) > 0 {
2278 fail_saves.fetch_sub(1, Ordering::SeqCst);
2279 Err(StoreError::Busy("timed out waiting for auth.json.lock".into()).into())
2280 } else if store.lock().unwrap().refresh == expected {
2281 *store.lock().unwrap() = fresh.clone();
2282 Ok(CasOutcome::Saved)
2283 } else {
2284 Ok(CasOutcome::Replaced)
2285 };
2286 std::future::ready(out)
2287 },
2288 None,
2289 |_| Ok(()),
2290 )
2291 };
2292 let first = run().await.expect("the rotated token is handed out");
2293 assert_eq!(first.refresh, "r0-rotated");
2294 assert_eq!(store.lock().unwrap().refresh, "r0", "save failed");
2295 let pending = pending_path(&lock);
2296 assert!(pending.exists());
2297 #[cfg(unix)]
2298 {
2299 use std::os::unix::fs::PermissionsExt;
2300 let mode = std::fs::metadata(&pending).unwrap().permissions().mode();
2301 assert_eq!(mode & 0o777, 0o600);
2302 }
2303
2304 let second = run().await.unwrap();
2307 assert_eq!(second.refresh, "r0-rotated");
2308 assert_eq!(store.lock().unwrap().refresh, "r0-rotated");
2309 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
2310 assert!(!pending.exists());
2311 }
2312
2313 async fn blocked_apply(
2316 save_out: fn() -> Result<CasOutcome, RefreshFailure>,
2317 parked_expires: u64,
2318 rejected: Option<&str>,
2319 serve_parked: bool,
2320 ) -> (Result<OAuthCredentials, RefreshFailure>, bool, usize) {
2321 let dir = tempfile::tempdir().unwrap();
2322 let lock = dir.path().join("auth.json.anthropic.refresh.lock");
2323 let file_lock = RefreshFileLock {
2324 file: std::fs::File::create(&lock).unwrap(),
2325 path: lock.clone(),
2326 };
2327 let mut parked = credential("parked-access");
2328 parked.refresh = "parked-r".into();
2329 parked.expires = parked_expires;
2330 file_lock.park_pending("r0", &parked).unwrap();
2331 drop(file_lock);
2332 let mut stale = credential("old-access");
2333 stale.expires = 0;
2334 let refreshes = AtomicUsize::new(0);
2335 let out = ensure_gated_locked_policy_with(
2336 Arc::new(Mutex::new(())),
2337 Some(lock.clone()),
2338 || Ok(stale.clone()),
2339 |_| {
2340 refreshes.fetch_add(1, Ordering::SeqCst);
2341 async { Ok::<_, String>(credential("never")) }
2342 },
2343 |_: &str, _: &OAuthCredentials| std::future::ready(save_out()),
2344 rejected,
2345 |_| Ok(()),
2346 serve_parked,
2347 None,
2348 )
2349 .await;
2350 (out, pending_path(&lock).exists(), refreshes.load(Ordering::SeqCst))
2351 }
2352
2353 #[tokio::test]
2357 async fn parked_access_token_is_served_while_the_sidecar_cannot_be_applied() {
2358 let busy = || Err(StoreError::Busy("timed out waiting for auth.json.lock".into()).into());
2359 let live = crate::epoch_millis() + 3_600_000;
2360 let (out, kept, refreshes) = blocked_apply(busy, live, None, true).await;
2361 let served = out.expect("parked access token served");
2362 assert_eq!(served.access, "parked-access");
2363 assert!(served.refresh.is_empty(), "the parked refresh token never leaves");
2364 assert!(kept);
2365 assert_eq!(refreshes, 0);
2366 for (expires, rejected, serve) in [
2369 (live, None, false),
2370 (live, Some("parked-access"), true),
2371 (0, None, true),
2372 ] {
2373 let (out, kept, refreshes) = blocked_apply(busy, expires, rejected, serve).await;
2374 assert!(out.is_err());
2375 assert!(kept);
2376 assert_eq!(refreshes, 0);
2377 }
2378 }
2379
2380 #[tokio::test]
2383 async fn sidecar_blocked_by_a_corrupt_store_is_action_required() {
2384 let corrupt = || {
2385 Err(StoreError::Corrupt(
2386 "/x/auth.json is not valid JSON; Move it aside with `mv '/x/auth.json' '/x/auth.json.broken-1'`"
2387 .into(),
2388 )
2389 .into())
2390 };
2391 let (out, kept, _) = blocked_apply(corrupt, 0, None, false).await;
2392 let err = out.unwrap_err();
2393 assert_eq!(err.kind, RefreshFailureKind::ActionRequired, "{err}");
2394 assert!(err.message().contains("mv '/x/auth.json'"), "{err}");
2395 assert!(!err.message().contains("retry shortly"), "{err}");
2396 assert!(kept);
2397 }
2398
2399 #[tokio::test]
2403 async fn sidecar_is_kept_when_the_store_is_missing() {
2404 let (out, kept, refreshes) =
2405 blocked_apply(|| Ok(CasOutcome::StoreMissing), 0, None, false).await;
2406 let err = out.unwrap_err();
2407 assert_eq!(err.kind, RefreshFailureKind::ActionRequired, "{err}");
2408 assert!(err.message().contains("no longer exists"), "{err}");
2409 assert!(kept, "the only live token survives");
2410 assert_eq!(refreshes, 0);
2411 }
2412
2413 #[derive(Default)]
2415 struct Memory {
2416 told: StdMutex<Vec<(String, RefreshFailure)>>,
2417 }
2418
2419 impl Memory {
2420 fn told(&self) -> Vec<(String, RefreshFailureKind)> {
2422 let told = self.told.lock().unwrap();
2423 told.iter().map(|(access, f)| (access.clone(), f.kind)).collect()
2424 }
2425 }
2426
2427 impl RefusalMemory for Memory {
2428 fn held(&self, creds: &OAuthCredentials) -> Option<RefreshFailure> {
2429 let told = self.told.lock().unwrap();
2430 told.iter()
2431 .find(|(access, _)| *access == creds.access)
2432 .map(|(_, f)| f.clone())
2433 }
2434
2435 fn refused(&self, creds: &OAuthCredentials, failure: &RefreshFailure) {
2436 let record = (creds.access.clone(), failure.clone());
2437 self.told.lock().unwrap().push(record);
2438 }
2439 }
2440
2441 fn expired(access: &str) -> OAuthCredentials {
2442 let mut creds = credential(access);
2443 creds.expires = 0;
2444 creds
2445 }
2446
2447 async fn refused_refresh(
2451 gate: Arc<Mutex<()>>,
2452 stored: &StdMutex<OAuthCredentials>,
2453 rotate_to: Option<&OAuthCredentials>,
2454 exchanges: &AtomicUsize,
2455 memory: &Memory,
2456 ) -> Result<OAuthCredentials, RefreshFailure> {
2457 ensure_gated_locked_policy_with(
2458 gate,
2459 None,
2460 || Ok(stored.lock().unwrap().clone()),
2461 |_| {
2462 if exchanges.fetch_add(1, Ordering::SeqCst) == 0 {
2463 if let Some(next) = rotate_to {
2464 *stored.lock().unwrap() = next.clone();
2465 }
2466 }
2467 async {
2468 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2469 Err::<OAuthCredentials, _>(RefreshFailure::status(400, "invalid_grant"))
2470 }
2471 },
2472 |_: &str, _: &OAuthCredentials| std::future::ready(Ok(CasOutcome::Saved)),
2473 None,
2474 |_| Ok(()),
2475 false,
2476 Some(memory as &dyn RefusalMemory),
2477 )
2478 .await
2479 }
2480
2481 #[tokio::test]
2485 async fn a_waiter_takes_its_predecessors_refusal_without_an_exchange() {
2486 let gate = Arc::new(Mutex::new(()));
2487 let stored = StdMutex::new(expired("old-access"));
2488 let (exchanges, memory) = (AtomicUsize::new(0), Memory::default());
2489 let (first, second) = tokio::join!(
2490 refused_refresh(gate.clone(), &stored, None, &exchanges, &memory),
2491 refused_refresh(gate.clone(), &stored, None, &exchanges, &memory),
2492 );
2493 assert_eq!(exchanges.load(Ordering::SeqCst), 1, "one exchange");
2494 for out in [first, second] {
2495 let err = out.unwrap_err();
2496 assert_eq!(err.kind, RefreshFailureKind::Credential, "{err}");
2497 assert_eq!(err.http_status(), Some(400), "{err}");
2498 }
2499 assert_eq!(
2500 memory.told(),
2501 vec![("old-access".to_string(), RefreshFailureKind::Credential)]
2502 );
2503 }
2504
2505 #[tokio::test]
2510 async fn the_refusal_is_recorded_for_the_login_read_under_the_gate() {
2511 let gate = Arc::new(Mutex::new(()));
2512 let stored = StdMutex::new(expired("old-access"));
2513 let rotated = expired("rotated-access");
2514 let (exchanges, memory) = (AtomicUsize::new(0), Memory::default());
2515 let (first, second) = tokio::join!(
2516 refused_refresh(gate.clone(), &stored, Some(&rotated), &exchanges, &memory),
2517 refused_refresh(gate.clone(), &stored, Some(&rotated), &exchanges, &memory),
2518 );
2519 first.unwrap_err();
2520 second.unwrap_err();
2521 assert_eq!(exchanges.load(Ordering::SeqCst), 2);
2522 let refused = RefreshFailureKind::Credential;
2523 assert_eq!(
2524 memory.told(),
2525 vec![
2526 ("old-access".to_string(), refused),
2527 ("rotated-access".to_string(), refused),
2528 ]
2529 );
2530 refused_refresh(gate, &stored, None, &exchanges, &memory)
2531 .await
2532 .unwrap_err();
2533 assert_eq!(exchanges.load(Ordering::SeqCst), 2, "the new login is held");
2534 }
2535
2536 #[tokio::test]
2541 async fn store_failures_never_reach_the_refusal_memory() {
2542 let memory = Memory::default();
2543 let corrupt = ensure_gated_locked_policy_with(
2544 Arc::new(Mutex::new(())),
2545 None,
2546 || {
2547 Err::<OAuthCredentials, RefreshFailure>(
2548 StoreError::Corrupt("auth.json is not valid JSON".into()).into(),
2549 )
2550 },
2551 |_| async { Ok::<_, String>(credential("never")) },
2552 |_: &str, _: &OAuthCredentials| std::future::ready(Ok(CasOutcome::Saved)),
2553 None,
2554 |_| Ok(()),
2555 false,
2556 Some(&memory as &dyn RefusalMemory),
2557 )
2558 .await
2559 .unwrap_err();
2560 assert_eq!(corrupt.kind, RefreshFailureKind::ActionRequired, "{corrupt}");
2561
2562 let stale = expired("old-access");
2563 let unsaved = ensure_gated_locked_policy_with(
2564 Arc::new(Mutex::new(())),
2565 None,
2566 || Ok(stale.clone()),
2567 |_| async { Ok::<_, String>(credential("rotated")) },
2568 |_: &str, _: &OAuthCredentials| {
2569 std::future::ready(Err::<CasOutcome, RefreshFailure>(
2570 StoreError::Failed("disk full".into()).into(),
2571 ))
2572 },
2573 None,
2574 |_| Ok(()),
2575 false,
2576 Some(&memory as &dyn RefusalMemory),
2577 )
2578 .await
2579 .unwrap_err();
2580 assert_eq!(unsaved.kind, RefreshFailureKind::Credential, "{unsaved}");
2581 assert!(memory.told().is_empty(), "{:?}", memory.told());
2582 }
2583
2584 #[tokio::test]
2587 async fn rotation_is_parked_when_the_store_vanishes_mid_refresh() {
2588 let dir = tempfile::tempdir().unwrap();
2589 let lock = dir.path().join("auth.json.anthropic.refresh.lock");
2590 let mut stale = credential("old-access");
2591 stale.expires = 0;
2592 let fresh = ensure_gated_locked_policy(
2593 Arc::new(Mutex::new(())),
2594 Some(lock.clone()),
2595 || Ok(stale.clone()),
2596 |_| async { Ok::<_, String>(credential("rotated")) },
2597 |_: &str, _: &OAuthCredentials| std::future::ready(Ok(CasOutcome::StoreMissing)),
2598 None,
2599 |_| Ok(()),
2600 )
2601 .await
2602 .unwrap();
2603 assert_eq!(fresh.access, "rotated");
2604 assert!(pending_path(&lock).exists());
2605 }
2606
2607 #[tokio::test]
2611 async fn stale_or_unapplied_sidecar_is_handled_under_the_lock() {
2612 let dir = tempfile::tempdir().unwrap();
2613 let lock = dir.path().join("auth.json.anthropic.refresh.lock");
2614 let file_lock = RefreshFileLock {
2615 file: std::fs::File::create(&lock).unwrap(),
2616 path: lock.clone(),
2617 };
2618 let mut parked = credential("parked");
2619 parked.refresh = "parked-r".into();
2620 file_lock.park_pending("r-before-relogin", &parked).unwrap();
2621
2622 file_lock
2624 .apply_pending(&|_: &str, _: &OAuthCredentials| {
2625 std::future::ready(Ok(CasOutcome::Replaced))
2626 })
2627 .await
2628 .unwrap();
2629 assert!(!pending_path(&lock).exists());
2630
2631 file_lock.park_pending("r0", &parked).unwrap();
2633 let err = file_lock
2634 .apply_pending(&|_: &str, _: &OAuthCredentials| {
2635 std::future::ready(Err(StoreError::Busy("busy".into()).into()))
2636 })
2637 .await
2638 .unwrap_err()
2639 .0;
2640 assert_eq!(err.kind, RefreshFailureKind::Other);
2641 assert!(pending_path(&lock).exists());
2642 }
2643
2644 #[test]
2648 fn store_lock_timeout_is_described_honestly() {
2649 let busy: RefreshFailure =
2650 StoreError::Busy("timed out waiting for auth.json.lock".into()).into();
2651 assert_eq!(busy.kind, RefreshFailureKind::Other);
2652 assert!(busy.message().contains("auth.json.lock"));
2653 assert!(REFRESH_SAVE_WAIT >= REFRESH_LOCK_WAIT);
2654 assert!(REFRESH_SAVE_WAIT >= std::time::Duration::from_secs(120));
2655 assert_eq!(super::super::storage::STORE_LOCK_WAIT, std::time::Duration::from_secs(15));
2656 }
2657
2658 #[tokio::test]
2659 async fn changed_token_is_adopted_without_refresh() {
2660 let store = StdMutex::new(credential("peer-token"));
2661 let calls = AtomicUsize::new(0);
2662 let got = recover(&store, Arc::new(Mutex::new(())), &calls, None, "rejected")
2663 .await
2664 .unwrap();
2665 assert_eq!(got.access, "peer-token");
2666 assert_eq!(calls.load(Ordering::SeqCst), 0);
2667 }
2668 #[tokio::test]
2669 async fn unexpired_rejected_token_rotates_once_for_concurrent_waiters() {
2670 let store = StdMutex::new(credential("rejected"));
2671 let calls = AtomicUsize::new(0);
2672 let gate = Arc::new(Mutex::new(()));
2673 let (a, b) = tokio::join!(
2674 recover(&store, gate.clone(), &calls, None, "rejected"),
2675 recover(&store, gate, &calls, None, "rejected")
2676 );
2677 assert_eq!(a.unwrap().access, "new");
2678 assert_eq!(b.unwrap().access, "new");
2679 assert_eq!(calls.load(Ordering::SeqCst), 1);
2680 }
2681 #[tokio::test]
2682 async fn separate_process_gates_serialize_on_the_refresh_file_lock() {
2683 let dir = tempfile::tempdir().unwrap();
2684 let lock = dir.path().join("refresh.lock");
2685 let store = StdMutex::new(credential("rejected"));
2686 let calls = AtomicUsize::new(0);
2687 let (a, b) = tokio::join!(
2688 recover(
2689 &store,
2690 Arc::new(Mutex::new(())),
2691 &calls,
2692 Some(lock.clone()),
2693 "rejected"
2694 ),
2695 recover(
2696 &store,
2697 Arc::new(Mutex::new(())),
2698 &calls,
2699 Some(lock),
2700 "rejected"
2701 )
2702 );
2703 assert_eq!(a.unwrap().access, "new");
2704 assert_eq!(b.unwrap().access, "new");
2705 assert_eq!(calls.load(Ordering::SeqCst), 1);
2706 }
2707 #[tokio::test]
2708 async fn removed_slot_is_not_resurrected_by_forced_refresh() {
2709 let result = ensure_gated_locked_policy(
2710 Arc::new(Mutex::new(())),
2711 None,
2712 || Ok(credential("rejected")),
2713 |_| async { Ok::<_, String>(credential("new")) },
2714 |_, _| std::future::ready(Ok(CasOutcome::Removed)),
2715 Some("rejected"),
2716 |_| Ok(()),
2717 )
2718 .await;
2719 assert!(result.unwrap_err().to_string().contains("removed"));
2720 }
2721
2722 struct FakeStore {
2726 creds: StdMutex<OAuthCredentials>,
2727 refresh_calls: AtomicUsize,
2728 save_calls: AtomicUsize,
2729 }
2730
2731 impl FakeStore {
2732 fn new(initial: OAuthCredentials) -> Self {
2733 Self {
2734 creds: StdMutex::new(initial),
2735 refresh_calls: AtomicUsize::new(0),
2736 save_calls: AtomicUsize::new(0),
2737 }
2738 }
2739
2740 async fn ensure_rejecting(
2741 &self,
2742 gate: Arc<Mutex<()>>,
2743 rejected: &str,
2744 ) -> Result<OAuthCredentials, String> {
2745 ensure_gated_locked_policy(
2746 gate,
2747 None,
2748 || Ok(self.creds.lock().unwrap().clone()),
2749 |old_refresh| async move {
2750 self.refresh_calls.fetch_add(1, Ordering::SeqCst);
2751 tokio::task::yield_now().await;
2752 Ok::<_, String>(OAuthCredentials {
2753 auth_type: "oauth".into(),
2754 refresh: format!("{old_refresh}-rotated"),
2755 access: "fresh-access".into(),
2756 expires: crate::epoch_millis() + 3_600_000,
2757 account_id: None,
2758 })
2759 },
2760 |_, c| {
2761 self.save_calls.fetch_add(1, Ordering::SeqCst);
2762 *self.creds.lock().unwrap() = c.clone();
2763 std::future::ready(Ok(CasOutcome::Saved))
2764 },
2765 Some(rejected),
2766 |_| Ok(()),
2767 )
2768 .await
2769 .map_err(String::from)
2770 }
2771 }
2772
2773 #[tokio::test]
2776 async fn rejected_unexpired_token_forces_refresh() {
2777 let store = FakeStore::new(credential("rejected"));
2778 let creds = store
2779 .ensure_rejecting(Arc::new(Mutex::new(())), "rejected")
2780 .await
2781 .unwrap();
2782 assert_eq!(creds.access, "fresh-access");
2783 assert_eq!(creds.refresh, "r0-rotated");
2784 assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 1);
2785 assert_eq!(store.save_calls.load(Ordering::SeqCst), 1);
2786 }
2787
2788 #[tokio::test]
2791 async fn rejected_token_differs_from_disk_uses_disk_without_refresh() {
2792 let store = FakeStore::new(credential("keep"));
2793 let creds = store
2794 .ensure_rejecting(Arc::new(Mutex::new(())), "some-old-token")
2795 .await
2796 .unwrap();
2797 assert_eq!(creds.access, "keep");
2798 assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 0);
2799 assert_eq!(store.save_calls.load(Ordering::SeqCst), 0);
2800 }
2801
2802 #[tokio::test]
2805 async fn concurrent_rejections_single_flight() {
2806 let store = FakeStore::new(credential("rejected"));
2807 let gate = Arc::new(Mutex::new(()));
2808 let waiters: Vec<_> = (0..8)
2809 .map(|_| store.ensure_rejecting(gate.clone(), "rejected"))
2810 .collect();
2811 for got in futures::future::join_all(waiters).await {
2812 assert_eq!(got.unwrap().access, "fresh-access");
2813 }
2814 assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 1);
2815 }
2816
2817 #[tokio::test]
2819 async fn forced_refresh_denied_by_cooldown_spends_nothing() {
2820 let refreshes = AtomicUsize::new(0);
2821 let err = ensure_gated_locked_policy(
2822 Arc::new(Mutex::new(())),
2823 None,
2824 || Ok(credential("rejected")),
2825 |_| async {
2826 refreshes.fetch_add(1, Ordering::SeqCst);
2827 Ok::<_, String>(credential("never"))
2828 },
2829 |_, _| std::future::ready(Ok(CasOutcome::Saved)),
2830 Some("rejected"),
2831 |_| Err("cooldown".to_string().into()),
2832 )
2833 .await
2834 .unwrap_err();
2835 assert_eq!(err.to_string(), "cooldown");
2836 assert_eq!(refreshes.load(Ordering::SeqCst), 0);
2837 }
2838
2839 #[tokio::test]
2843 async fn expired_credential_ignores_force_cooldown() {
2844 for rejected in ["stale", "some-other-token"] {
2845 let creds = ensure_gated_locked_policy(
2846 Arc::new(Mutex::new(())),
2847 None,
2848 || {
2849 let mut c = credential("stale");
2850 c.expires = 0;
2851 Ok(c)
2852 },
2853 |_| async { Ok::<_, String>(credential("new")) },
2854 |_, _| std::future::ready(Ok(CasOutcome::Saved)),
2855 Some(rejected),
2856 |_| Err("cooldown".to_string().into()),
2857 )
2858 .await
2859 .unwrap();
2860 assert_eq!(creds.access, "new", "rejected = {rejected}");
2861 }
2862 }
2863
2864 use super::super::account::Account;
2865 use super::super::provider::OAuthProviderId;
2866 use super::super::storage::save_provider_auth_at;
2867
2868 fn account(label: &str) -> CredentialRef {
2869 CredentialRef::new(OAuthProviderId::Anthropic, Account::named(label).unwrap())
2870 }
2871
2872 fn seed_old(path: &Path, entries: &[(&CredentialRef, &str)]) {
2875 for (cred, access) in entries {
2876 save_provider_auth_at(path, &cred.storage_key(), &credential(access)).unwrap();
2877 }
2878 let old = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
2879 std::fs::File::options()
2880 .write(true)
2881 .open(path)
2882 .unwrap()
2883 .set_modified(old)
2884 .unwrap();
2885 }
2886
2887 async fn forced(
2890 path: &Path,
2891 cred: &CredentialRef,
2892 rejected: &str,
2893 calls: &AtomicUsize,
2894 ) -> Result<OAuthCredentials, String> {
2895 refresh_credential_locked(cred, path, None, Some(rejected), None, |old| async move {
2896 let n = calls.fetch_add(1, Ordering::SeqCst);
2897 Ok::<_, String>(OAuthCredentials {
2898 auth_type: "oauth".into(),
2899 refresh: format!("{old}-rotated"),
2900 access: format!("minted-{n}"),
2901 expires: crate::epoch_millis() + 3_600_000,
2902 account_id: None,
2903 })
2904 })
2905 .await
2906 .map_err(String::from)
2907 }
2908
2909 #[tokio::test]
2914 async fn forced_refresh_cooldown_is_per_account_and_stops_a_persistent_401() {
2915 let dir = tempfile::tempdir().unwrap();
2916 let path = dir.path().join("auth.json");
2917 let (a, b) = (account("a"), account("b"));
2918 seed_old(&path, &[(&a, "a-rejected"), (&b, "b-rejected")]);
2919 let calls = AtomicUsize::new(0);
2920
2921 let first = forced(&path, &a, "a-rejected", &calls).await.unwrap();
2922 assert_eq!(calls.load(Ordering::SeqCst), 1);
2923 let err = forced(&path, &a, &first.access, &calls).await.unwrap_err();
2924 assert!(err.contains("not refreshing again"), "{err}");
2925 assert!(!err.contains(&first.access), "error must not carry tokens: {err}");
2926 assert_eq!(calls.load(Ordering::SeqCst), 1, "cooldown spends nothing");
2927
2928 forced(&path, &b, "b-rejected", &calls).await.unwrap();
2930 assert_eq!(calls.load(Ordering::SeqCst), 2, "b has its own cooldown");
2931 }
2932
2933 #[tokio::test]
2938 async fn refresh_exchanges_are_recorded_per_slot() {
2939 let dir = tempfile::tempdir().unwrap();
2940 let path = dir.path().join("auth.json");
2941 let (a, b) = (account("a"), account("b"));
2942 seed_old(&path, &[(&a, "a-rejected"), (&b, "b-rejected")]);
2943 let key = a.storage_key();
2944 assert_eq!(last_refresh_exchange(&path, &key), None);
2945
2946 let refuse = |_old: String| async {
2947 Err::<OAuthCredentials, _>(RefreshFailure::status(503, "token endpoint answered 503"))
2948 };
2949 let err = refresh_credential_locked(&a, &path, None, Some("a-rejected"), None, refuse)
2950 .await
2951 .unwrap_err();
2952 assert_eq!(err.kind, RefreshFailureKind::Transient);
2953 let failed = last_refresh_exchange(&path, &key).expect("failed exchange recorded");
2954 assert_eq!(failed.failure, Some(RefreshFailureKind::Transient));
2955 assert_eq!(failed.http_status, Some(503));
2957
2958 let calls = AtomicUsize::new(0);
2959 let first = forced(&path, &a, "a-rejected", &calls).await.unwrap();
2960 let ok = last_refresh_exchange(&path, &key).expect("exchange recorded");
2961 assert_eq!(ok.failure, None);
2962 assert_eq!(ok.http_status, None);
2963 assert!(ok.at_ms >= failed.at_ms);
2964
2965 forced(&path, &a, &first.access, &calls).await.unwrap_err();
2966 assert_eq!(calls.load(Ordering::SeqCst), 1, "the cooldown refused it");
2967 assert_eq!(last_refresh_exchange(&path, &key), Some(ok));
2968 assert_eq!(last_refresh_exchange(&path, &b.storage_key()), None);
2969 }
2970
2971 fn stamp_as_other_process(path: &Path, cred: &CredentialRef, stamp: &str) {
2974 std::fs::write(refresh_lock_path(path, &cred.storage_key()), stamp).unwrap();
2975 }
2976
2977 fn now_stamp() -> String {
2978 crate::epoch_millis().to_string()
2979 }
2980
2981 #[tokio::test]
2984 async fn other_process_refresh_stamp_blocks_forced_refresh_of_that_account() {
2985 let dir = tempfile::tempdir().unwrap();
2986 let path = dir.path().join("auth.json");
2987 let a = account("a");
2988 seed_old(&path, &[(&a, "a-rejected")]);
2989 stamp_as_other_process(&path, &a, &now_stamp());
2990 let calls = AtomicUsize::new(0);
2991 let err = forced(&path, &a, "a-rejected", &calls).await.unwrap_err();
2992 assert!(err.contains("not refreshing again"), "{err}");
2993 assert_eq!(calls.load(Ordering::SeqCst), 0);
2994 }
2995
2996 #[tokio::test]
2998 async fn refresh_stamp_of_one_account_does_not_block_another() {
2999 let dir = tempfile::tempdir().unwrap();
3000 let path = dir.path().join("auth.json");
3001 let (a, b) = (account("a"), account("b"));
3002 seed_old(&path, &[(&a, "a-rejected"), (&b, "b-rejected")]);
3003 stamp_as_other_process(&path, &a, &now_stamp());
3004 let calls = AtomicUsize::new(0);
3005 forced(&path, &b, "b-rejected", &calls).await.unwrap();
3006 assert_eq!(calls.load(Ordering::SeqCst), 1);
3007 }
3008
3009 #[tokio::test]
3012 async fn foreign_auth_file_write_without_a_stamp_does_not_block() {
3013 let dir = tempfile::tempdir().unwrap();
3014 let path = dir.path().join("auth.json");
3015 let a = account("a");
3016 save_provider_auth_at(&path, &a.storage_key(), &credential("a-rejected")).unwrap();
3018 let calls = AtomicUsize::new(0);
3019 forced(&path, &a, "a-rejected", &calls).await.unwrap();
3020 assert_eq!(calls.load(Ordering::SeqCst), 1);
3021 }
3022
3023 #[tokio::test]
3028 async fn own_write_after_another_process_refreshed_a_does_not_hide_a_s_cooldown() {
3029 let dir = tempfile::tempdir().unwrap();
3030 let path = dir.path().join("auth.json");
3031 let (a, b) = (account("a"), account("b"));
3032 seed_old(&path, &[(&a, "a-new-from-peer"), (&b, "b-rejected")]);
3033 stamp_as_other_process(&path, &a, &now_stamp());
3034 let calls = AtomicUsize::new(0);
3035 forced(&path, &b, "b-rejected", &calls).await.unwrap();
3036 assert_eq!(calls.load(Ordering::SeqCst), 1, "b refreshed and wrote auth.json");
3037 let err = forced(&path, &a, "a-new-from-peer", &calls).await.unwrap_err();
3038 assert!(err.contains("not refreshing again"), "{err}");
3039 assert_eq!(calls.load(Ordering::SeqCst), 1, "a's refresh token not spent again");
3040 }
3041
3042 #[tokio::test]
3045 async fn expired_token_refreshes_inside_the_stamp_cooldown() {
3046 let dir = tempfile::tempdir().unwrap();
3047 let path = dir.path().join("auth.json");
3048 let a = account("a");
3049 let mut expired = credential("a-old");
3050 expired.expires = 0;
3051 save_provider_auth_at(&path, &a.storage_key(), &expired).unwrap();
3052 stamp_as_other_process(&path, &a, &now_stamp());
3053 let calls = AtomicUsize::new(0);
3054 let got = forced(&path, &a, "a-old", &calls).await.unwrap();
3055 assert_eq!(got.access, "minted-0");
3056 assert_eq!(calls.load(Ordering::SeqCst), 1);
3057 }
3058
3059 #[tokio::test]
3061 async fn garbage_or_future_stamps_allow_a_forced_refresh() {
3062 let future = (crate::epoch_millis() + 3_600_000).to_string();
3063 for stamp in ["", "not-a-number", "-5", "12abc", future.as_str()] {
3064 let dir = tempfile::tempdir().unwrap();
3065 let path = dir.path().join("auth.json");
3066 let a = account("a");
3067 seed_old(&path, &[(&a, "a-rejected")]);
3068 stamp_as_other_process(&path, &a, stamp);
3069 let calls = AtomicUsize::new(0);
3070 forced(&path, &a, "a-rejected", &calls)
3071 .await
3072 .unwrap_or_else(|e| panic!("stamp {stamp:?}: {e}"));
3073 assert_eq!(calls.load(Ordering::SeqCst), 1, "stamp {stamp:?}");
3074 }
3075 }
3076
3077 #[tokio::test]
3081 async fn refresh_stamp_survives_an_old_client_style_lock_open() {
3082 use fs4::fs_std::FileExt;
3083 let dir = tempfile::tempdir().unwrap();
3084 let path = dir.path().join("auth.json");
3085 let a = account("a");
3086 seed_old(&path, &[(&a, "a-rejected")]);
3087 let calls = AtomicUsize::new(0);
3088 let first = forced(&path, &a, "a-rejected", &calls).await.unwrap();
3089 let lock = refresh_lock_path(&path, &a.storage_key());
3090 let stamp = std::fs::read_to_string(&lock).unwrap();
3091 let ms: u64 = stamp.parse().expect("plain ASCII epoch ms");
3092 assert!(crate::epoch_millis().saturating_sub(ms) < 60_000, "{stamp}");
3093
3094 let old = std::fs::OpenOptions::new()
3096 .create(true)
3097 .truncate(false)
3098 .write(true)
3099 .open(&lock)
3100 .unwrap();
3101 FileExt::lock_exclusive(&old).unwrap();
3102 drop(old);
3103 assert_eq!(std::fs::read_to_string(&lock).unwrap(), stamp);
3104
3105 let err = forced(&path, &a, &first.access, &calls).await.unwrap_err();
3107 assert!(err.contains("not refreshing again"), "{err}");
3108 assert_eq!(calls.load(Ordering::SeqCst), 1);
3109 }
3110
3111 #[test]
3114 fn refresh_failures_are_typed() {
3115 let a = account("a");
3116 let now = 1_000_000_000;
3117 let f = forced_refresh_allowed(&a, Some(now - 12_000), now).unwrap_err();
3118 assert_eq!(f.kind, RefreshFailureKind::Cooldown { retry_in_secs: 48 });
3119 assert!(f.message().contains("not refreshing again for 48s"), "{f}");
3120 assert!(forced_refresh_allowed(&a, Some(now - 60_000), now).is_ok());
3121
3122 use RefreshFailureKind::Credential;
3123 assert_eq!(RefreshFailure::from("No credentials".to_string()).kind, Credential);
3125 }
3126}
3127
3128#[cfg(test)]
3131pub(crate) mod fake_http {
3132 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3133
3134 pub(crate) fn response(status: &str, body: &str) -> String {
3137 format!(
3138 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\
3139 Connection: close\r\n\r\n{body}",
3140 body.len()
3141 )
3142 }
3143
3144 fn request_complete(got: &[u8]) -> bool {
3145 let Some(end) = got.windows(4).position(|w| w == b"\r\n\r\n") else {
3146 return false;
3147 };
3148 let head = String::from_utf8_lossy(&got[..end]).to_ascii_lowercase();
3149 let len = head
3150 .lines()
3151 .find_map(|l| l.strip_prefix("content-length:"))
3152 .and_then(|v| v.trim().parse::<usize>().ok())
3153 .unwrap_or(0);
3154 got.len() >= end + 4 + len
3155 }
3156
3157 pub(crate) async fn serve(raw: String) -> String {
3159 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3160 let addr = listener.local_addr().unwrap();
3161 tokio::spawn(async move {
3162 while let Ok((mut sock, _)) = listener.accept().await {
3163 let raw = raw.clone();
3164 tokio::spawn(async move {
3165 let mut got = Vec::new();
3166 let mut buf = [0u8; 4096];
3167 while !request_complete(&got) {
3168 match sock.read(&mut buf).await {
3169 Ok(0) | Err(_) => break,
3170 Ok(n) => got.extend_from_slice(&buf[..n]),
3171 }
3172 }
3173 let _ = sock.write_all(raw.as_bytes()).await;
3174 let _ = sock.shutdown().await;
3175 });
3176 }
3177 });
3178 format!("http://{addr}")
3179 }
3180
3181 pub(crate) async fn dead_url() -> String {
3183 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3184 let addr = listener.local_addr().unwrap();
3185 drop(listener);
3186 format!("http://{addr}")
3187 }
3188}
3189
3190#[cfg(test)]
3193mod source_classification_tests {
3194 use super::fake_http::{dead_url, response, serve};
3195 use super::*;
3196 use RefreshFailureKind::{Credential, Transient};
3197
3198 #[test]
3199 fn status_kinds() {
3200 for s in [408, 425, 429, 500, 502, 503, 504] {
3201 assert_eq!(status_kind(s), Transient, "{s}");
3202 }
3203 for s in [400, 401, 403, 404] {
3204 assert_eq!(status_kind(s), Credential, "{s}");
3205 }
3206 }
3207
3208 async fn anthropic(raw: String) -> RefreshFailure {
3209 let url = format!("{}/v1/oauth/token", serve(raw).await);
3210 refresh_token_at(&Client::new(), &url, "rt").await.unwrap_err()
3211 }
3212
3213 #[tokio::test]
3214 async fn anthropic_refresh_failures_are_typed_at_the_source() {
3215 let portal = anthropic(response("200 OK", "<html>sign in to wifi</html>")).await;
3217 assert_eq!(portal.kind, Transient, "{portal}");
3218 let cut = anthropic(
3220 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 200\r\n\
3221 Connection: close\r\n\r\n{\"access_t"
3222 .to_string(),
3223 )
3224 .await;
3225 assert_eq!(cut.kind, Transient, "{cut}");
3226 let timeout = anthropic(response("408 Request Timeout", "{}")).await;
3227 assert_eq!(timeout.kind, Transient, "{timeout}");
3228 let busy = anthropic(response("503 Service Unavailable", "<html/>")).await;
3229 assert_eq!(busy.kind, Transient, "{busy}");
3230 let revoked = anthropic(response(
3232 "400 Bad Request",
3233 r#"{"error":"invalid_grant","error_description":"revoked"}"#,
3234 ))
3235 .await;
3236 assert_eq!(revoked.kind, Credential, "{revoked}");
3237 assert!(revoked.message().contains("invalid_grant"), "{revoked}");
3238 let url = format!("{}/v1/oauth/token", dead_url().await);
3240 let down = refresh_token_at(&Client::new(), &url, "rt").await.unwrap_err();
3241 assert_eq!(down.kind, Transient, "{down}");
3242 }
3243
3244 #[test]
3247 fn provider_errors_carry_their_kind() {
3248 use super::super::google_gemini::GeminiAuthError as G;
3249 assert_eq!(G::TokenRequestFailed(503).refresh_failure().kind, Transient);
3250 assert_eq!(G::TokenRequestFailed(400).refresh_failure().kind, Credential);
3251 assert_eq!(G::Transport.refresh_failure().kind, Transient);
3252 assert_eq!(G::EmptyRefreshToken.refresh_failure().kind, Credential);
3253
3254 use super::super::muse::MuseAuthError as M;
3255 assert_eq!(M::LoginRejected(401).refresh_failure().kind, Credential);
3256 assert_eq!(M::HttpStatus(503).refresh_failure().kind, Transient);
3257 assert_eq!(M::Transport.refresh_failure().kind, Transient);
3258 assert_eq!(M::InvalidMintResponse.refresh_failure().kind, Transient);
3259 assert_eq!(M::OnboardingRequired.refresh_failure().kind, Credential);
3260 assert_eq!(M::PaymentRequired.refresh_failure().kind, RefreshFailureKind::ActionRequired);
3262
3263 use super::super::kimi_code::KimiAuthError as K;
3264 assert_eq!(K::Unauthorized.refresh_failure().kind, Credential);
3265 assert_eq!(K::HttpStatus(502).refresh_failure().kind, Transient);
3266 assert_eq!(K::Transport.refresh_failure().kind, Transient);
3267
3268 use super::super::github_copilot::CopilotAuthError as C;
3269 assert_eq!(C::SessionMintRejected(401).refresh_failure().kind, Credential);
3270 assert_eq!(C::HttpStatus(500).refresh_failure().kind, Transient);
3271 assert_eq!(C::Transport.refresh_failure().kind, Transient);
3272 }
3273
3274 #[tokio::test]
3275 async fn gemini_503_is_transient_and_invalid_grant_is_credential() {
3276 use super::super::google_gemini::{refresh_with_registration_typed, GeminiRegistration};
3277 let reg = GeminiRegistration::test("client-id", None).unwrap();
3278 let run = |raw: String| {
3279 let reg = ®
3280 async move {
3281 let url = format!("{}/token", serve(raw).await);
3282 refresh_with_registration_typed(&Client::new(), &url, true, reg, "rt")
3283 .await
3284 .unwrap_err()
3285 }
3286 };
3287 let busy = run(response("503 Service Unavailable", "{}")).await;
3288 assert_eq!(busy.kind, Transient, "{busy}");
3289 assert!(busy.message().contains("(HTTP 503)"), "{busy}");
3290 let revoked = run(response("400 Bad Request", r#"{"error":"invalid_grant"}"#)).await;
3291 assert_eq!(revoked.kind, Credential, "{revoked}");
3292 }
3293
3294 #[tokio::test]
3295 async fn xai_discovery_failure_is_transient() {
3296 let url = format!("{}/.well-known/openid-configuration", dead_url().await);
3297 let err = super::super::xai::discover_at(&url).await.unwrap_err();
3298 assert_eq!(err.kind, Transient, "{err}");
3299 assert!(err.message().contains("discovery failed"), "{err}");
3300 }
3301
3302 #[tokio::test]
3303 async fn codex_refresh_failures_are_typed() {
3304 let run = |raw: String| async move {
3305 let url = format!("{}/oauth/token", serve(raw).await);
3306 super::super::openai_codex::token_request_at(&Client::new(), &url, &[("a", "b")])
3307 .await
3308 .unwrap_err()
3309 };
3310 assert_eq!(run(response("502 Bad Gateway", "")).await.kind, Transient);
3311 assert_eq!(run(response("200 OK", "<html/>")).await.kind, Transient);
3312 let revoked = run(response("401 Unauthorized", r#"{"error":"invalid_grant"}"#)).await;
3313 assert_eq!(revoked.kind, Credential);
3314 assert!(revoked.message().contains("invalid_grant"), "{revoked}");
3315 let echoed = run(response(
3318 "400 Bad Request",
3319 "grant_type=refresh_token&refresh_token=SECRET-RT-123",
3320 ))
3321 .await;
3322 assert!(!echoed.message().contains("SECRET-RT-123"), "{echoed}");
3323 let nested = run(response(
3324 "400 Bad Request",
3325 r#"{"error":"invalid_request","error_description":"bad","echo":"SECRET-RT-123"}"#,
3326 ))
3327 .await;
3328 assert!(!nested.message().contains("SECRET-RT-123"), "{nested}");
3329 assert!(nested.message().contains("invalid_request: bad"), "{nested}");
3330 }
3331}