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) -> std::result::Result<OAuthCredentials, SeatVendError> {
420 let path = auth_file_path();
421 ensure_credential_checked_at(client, cred, &path, Some(expected), None, endpoints)
422 .await
423 .map_err(|f| {
424 if f.message() == SEAT_CHANGED {
425 SeatVendError::SeatChanged
426 } else {
427 SeatVendError::Other(f)
428 }
429 })
430}
431
432pub(crate) async fn ensure_fresh_credential_at(
434 client: &Client,
435 cred: &CredentialRef,
436 path: &Path,
437) -> std::result::Result<OAuthCredentials, String> {
438 ensure_fresh_credential_checked_at(client, cred, path, None).await
439}
440
441pub(crate) async fn ensure_fresh_credential_checked_at(
442 client: &Client,
443 cred: &CredentialRef,
444 path: &Path,
445 expected_seat: Option<&SeatIdentity>,
446) -> std::result::Result<OAuthCredentials, String> {
447 ensure_credential_checked_at(
448 client,
449 cred,
450 path,
451 expected_seat,
452 None,
453 &RefreshEndpoints::default(),
454 )
455 .await
456 .map_err(String::from)
457}
458
459pub(super) async fn recover_rejected_credential(
463 client: &Client,
464 cred: &CredentialRef,
465 rejected: &str,
466 endpoints: &RefreshEndpoints,
467) -> std::result::Result<OAuthCredentials, RefreshFailure> {
468 ensure_credential_checked_at(client, cred, &auth_file_path(), None, Some(rejected), endpoints)
469 .await
470}
471
472pub(super) async fn ensure_fresh_credential_via(
475 client: &Client,
476 cred: &CredentialRef,
477 endpoints: &RefreshEndpoints,
478) -> std::result::Result<OAuthCredentials, RefreshFailure> {
479 ensure_credential_checked_at(client, cred, &auth_file_path(), None, None, endpoints).await
480}
481
482async fn ensure_credential_checked_at(
483 client: &Client,
484 cred: &CredentialRef,
485 path: &Path,
486 expected_seat: Option<&SeatIdentity>,
487 rejected: Option<&str>,
488 endpoints: &RefreshEndpoints,
489) -> std::result::Result<OAuthCredentials, RefreshFailure> {
490 let creds = load_checked(path, cred, expected_seat)?;
494 let rejected_now = rejected.is_some_and(|r| !r.is_empty() && r == creds.access);
495 if !rejected_now && !is_token_expired(&creds) {
496 return Ok(creds);
497 }
498 let (client, cred, path) = (client.clone(), cred.clone(), path.to_path_buf());
499 let (seat, rejected) = (expected_seat.cloned(), rejected.map(str::to_string));
500 let endpoints = endpoints.clone();
501 run_detached(async move {
502 let provider = cred.provider;
503 let (client, endpoints) = (&client, &endpoints);
504 refresh_credential_locked(
505 &cred,
506 &path,
507 seat.as_ref(),
508 rejected.as_deref(),
509 |refresh: String| async move {
510 super::provider::refresh_via(client, provider, &refresh, endpoints).await
511 },
512 )
513 .await
514 })
515 .await
516}
517
518async fn run_detached<T, E, F>(fut: F) -> std::result::Result<T, E>
527where
528 F: std::future::Future<Output = std::result::Result<T, E>> + Send + 'static,
529 T: Send + 'static,
530 E: From<RefreshFailure> + Send + 'static,
531{
532 run_detached_on(refresh_runtime(), fut).await
533}
534
535async fn run_detached_on<T, E, F>(
536 runtime: std::result::Result<&'static tokio::runtime::Runtime, RefreshFailure>,
537 fut: F,
538) -> std::result::Result<T, E>
539where
540 F: std::future::Future<Output = std::result::Result<T, E>> + Send + 'static,
541 T: Send + 'static,
542 E: From<RefreshFailure> + Send + 'static,
543{
544 runtime.map_err(E::from)?.spawn(fut).await.map_err(|e| {
545 E::from(RefreshFailure::new(
546 RefreshFailureKind::Other,
547 format!("credential refresh task failed: {e}"),
548 ))
549 })?
550}
551
552fn refresh_runtime() -> std::result::Result<&'static tokio::runtime::Runtime, RefreshFailure> {
560 static RT: std::sync::Mutex<Option<&'static tokio::runtime::Runtime>> =
561 std::sync::Mutex::new(None);
562 runtime_from(&RT, || {
563 tokio::runtime::Builder::new_multi_thread()
564 .worker_threads(2)
565 .thread_name("synaps-auth-refresh")
566 .enable_all()
567 .build()
568 })
569}
570
571fn runtime_from(
572 slot: &std::sync::Mutex<Option<&'static tokio::runtime::Runtime>>,
573 build: impl FnOnce() -> std::io::Result<tokio::runtime::Runtime>,
574) -> std::result::Result<&'static tokio::runtime::Runtime, RefreshFailure> {
575 let mut slot = slot.lock().unwrap_or_else(|e| e.into_inner());
576 if let Some(runtime) = *slot {
577 return Ok(runtime);
578 }
579 let runtime = build().map_err(|e| {
580 RefreshFailure::other(format!("could not start the auth refresh runtime: {e}"))
581 })?;
582 let runtime: &'static tokio::runtime::Runtime = Box::leak(Box::new(runtime));
584 *slot = Some(runtime);
585 Ok(runtime)
586}
587
588fn load_checked(
589 path: &Path,
590 cred: &CredentialRef,
591 expected_seat: Option<&SeatIdentity>,
592) -> std::result::Result<OAuthCredentials, RefreshFailure> {
593 let creds = load_provider_auth_typed_at(path, &cred.storage_key())?.ok_or_else(|| {
594 RefreshFailure::credential(format!(
595 "No credentials for {} at {}. Run `synaps login --provider {} --account {}`.",
596 cred,
597 path.display(),
598 cred.provider,
599 cred.account
600 ))
601 })?;
602 if let Some(expected) = expected_seat {
606 if SeatIdentity::of(cred.provider, &creds) != *expected {
607 return Err(RefreshFailure::credential(SEAT_CHANGED));
608 }
609 }
610 Ok(creds)
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616pub struct RefreshExchange {
617 pub at_ms: u64,
618 pub failure: Option<RefreshFailureKind>,
619 pub http_status: Option<u16>,
621}
622
623static REFRESH_EXCHANGES: std::sync::Mutex<std::collections::BTreeMap<String, RefreshExchange>> =
628 std::sync::Mutex::new(std::collections::BTreeMap::new());
629
630fn refresh_exchange_key(path: &Path, storage_key: &str) -> String {
631 format!("{}|{storage_key}", path.display())
632}
633
634fn record_refresh_exchange(path: &Path, storage_key: &str, failure: Option<&RefreshFailure>) {
635 let at_ms = crate::epoch_millis();
636 REFRESH_EXCHANGES
637 .lock()
638 .unwrap_or_else(|e| e.into_inner())
639 .insert(
640 refresh_exchange_key(path, storage_key),
641 RefreshExchange {
642 at_ms,
643 failure: failure.map(|f| f.kind),
644 http_status: failure.and_then(RefreshFailure::http_status),
645 },
646 );
647}
648
649pub fn last_refresh_exchange(path: &Path, storage_key: &str) -> Option<RefreshExchange> {
652 REFRESH_EXCHANGES
653 .lock()
654 .unwrap_or_else(|e| e.into_inner())
655 .get(&refresh_exchange_key(path, storage_key))
656 .copied()
657}
658
659async fn refresh_credential_locked<Refresh, RFut, RErr>(
664 cred: &CredentialRef,
665 path: &Path,
666 expected_seat: Option<&SeatIdentity>,
667 rejected: Option<&str>,
668 refresh: Refresh,
669) -> std::result::Result<OAuthCredentials, RefreshFailure>
670where
671 Refresh: Fn(String) -> RFut,
672 RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, RErr>>,
673 RErr: Into<RefreshFailure>,
674{
675 let key = cred.storage_key();
676 let refresh = |old_refresh: String| {
677 let exchange = refresh(old_refresh);
678 let key = &key;
679 async move {
680 let result: std::result::Result<OAuthCredentials, RefreshFailure> =
681 exchange.await.map_err(Into::into);
682 record_refresh_exchange(path, key, result.as_ref().err());
683 result
684 }
685 };
686 let gate_key = format!("{}|{}", path.display(), key);
687 let lock_path = refresh_lock_path(path, &key);
688 let load = || load_checked(path, cred, expected_seat);
689 let save = |old_refresh: &str, fresh: &OAuthCredentials| {
692 let (path, key) = (path.to_path_buf(), key.clone());
693 let (old_refresh, fresh) = (old_refresh.to_string(), fresh.clone());
694 async move {
695 tokio::task::spawn_blocking(move || {
696 save_provider_auth_if_refresh_matches_typed_at(
697 &path,
698 &key,
699 &old_refresh,
700 &fresh,
701 REFRESH_SAVE_WAIT,
702 )
703 })
704 .await
705 .map_err(|e| RefreshFailure::other(format!("credential save task failed: {e}")))?
706 .map_err(RefreshFailure::from)
707 }
708 };
709 ensure_gated_locked_policy_with(
710 refresh_gate(&gate_key),
711 Some(lock_path),
712 load,
713 refresh,
714 save,
715 rejected,
716 |last_refresh_ms| forced_refresh_allowed(cred, last_refresh_ms, crate::epoch_millis()),
717 expected_seat.is_none(),
718 )
719 .await
720}
721
722pub(crate) const FORCED_REFRESH_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(60);
734
735fn forced_refresh_allowed(
743 cred: &CredentialRef,
744 last_refresh_ms: Option<u64>,
745 now_ms: u64,
746) -> std::result::Result<(), RefreshFailure> {
747 let cooldown_ms = FORCED_REFRESH_COOLDOWN.as_millis() as u64;
748 match last_refresh_ms.filter(|t| *t <= now_ms).map(|t| now_ms - t) {
749 Some(age_ms) if age_ms < cooldown_ms => {
750 let retry_in_secs = (cooldown_ms - age_ms).div_ceil(1000);
751 Err(RefreshFailure::new(
752 RefreshFailureKind::Cooldown { retry_in_secs },
753 format!(
754 "{} rejected a token refreshed {}s ago; not refreshing again for {}s \
755 (likely an account or permission issue, not a stale token)",
756 cred,
757 age_ms / 1000,
758 retry_in_secs
759 ),
760 ))
761 }
762 _ => Ok(()),
763 }
764}
765
766fn refresh_lock_path(auth_path: &Path, storage_key: &str) -> PathBuf {
769 let file_name = auth_path
770 .file_name()
771 .and_then(|n| n.to_str())
772 .unwrap_or("auth.json");
773 auth_path.with_file_name(format!("{file_name}.{storage_key}.refresh.lock"))
774}
775
776const REFRESH_HTTP_DEADLINE: std::time::Duration = std::time::Duration::from_secs(60);
780
781const REFRESH_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
786
787const REFRESH_SAVE_WAIT: std::time::Duration = REFRESH_LOCK_WAIT;
792
793fn pending_path(lock_path: &Path) -> PathBuf {
795 lock_path.with_extension("pending")
796}
797
798fn park_unsaved_rotation(
802 lock: Option<&RefreshFileLock>,
803 old_refresh: &str,
804 fresh: OAuthCredentials,
805 err: RefreshFailure,
806) -> std::result::Result<OAuthCredentials, RefreshFailure> {
807 let Some(lock) = lock else { return Err(err) };
808 match lock.park_pending(old_refresh, &fresh) {
809 Ok(()) => {
810 tracing::warn!(
811 lock = %lock.path.display(),
812 error = %err,
813 "refreshed login could not be saved; parked it beside the refresh lock, \
814 it will be applied on next use"
815 );
816 Ok(fresh)
817 }
818 Err(e) => {
819 tracing::error!(
820 lock = %lock.path.display(),
821 save_error = %err,
822 park_error = %e,
823 "refreshed login could NOT be saved: if the provider rotated the refresh \
824 token, this account is now logged out and needs `synaps login`"
825 );
826 Err(err)
827 }
828 }
829}
830
831struct RefreshFileLock {
838 file: std::fs::File,
839 path: PathBuf,
841}
842
843fn refresh_floor() -> &'static std::sync::Mutex<HashMap<PathBuf, u64>> {
848 static FLOOR: OnceLock<std::sync::Mutex<HashMap<PathBuf, u64>>> = OnceLock::new();
849 FLOOR.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
850}
851
852impl RefreshFileLock {
853 fn last_refresh_ms(&self) -> Option<u64> {
856 let floor = refresh_floor()
857 .lock()
858 .unwrap_or_else(|e| e.into_inner())
859 .get(&self.path)
860 .copied();
861 self.stamped_ms().max(floor)
862 }
863
864 fn stamped_ms(&self) -> Option<u64> {
865 use std::io::{Read, Seek};
866 let mut file = &self.file;
867 file.rewind().ok()?;
868 let mut text = String::new();
869 file.take(32).read_to_string(&mut text).ok()?;
870 text.trim().parse().ok()
871 }
872
873 fn record_refreshed(&self, now_ms: u64) {
877 let mut floor = refresh_floor().lock().unwrap_or_else(|e| e.into_inner());
878 match self.stamp_refreshed(now_ms) {
879 Ok(()) => {
880 floor.remove(&self.path);
881 }
882 Err(e) => {
883 tracing::warn!(
884 lock = %self.path.display(),
885 error = %e,
886 "could not stamp the refresh lock file; keeping the forced-refresh \
887 cooldown in this process only"
888 );
889 floor.insert(self.path.clone(), now_ms);
890 }
891 }
892 }
893
894 fn park_pending(&self, expected_refresh: &str, fresh: &OAuthCredentials) -> std::io::Result<()> {
897 use std::io::Write;
898 let body = serde_json::json!({ "expected_refresh": expected_refresh, "credential": fresh });
899 let path = pending_path(&self.path);
900 let tmp = path.with_extension("pending.tmp");
901 let mut open = std::fs::OpenOptions::new();
902 open.create(true).write(true).truncate(true);
903 #[cfg(unix)]
904 {
905 use std::os::unix::fs::OpenOptionsExt;
906 open.mode(0o600);
907 }
908 let mut file = open.open(&tmp)?;
909 file.write_all(body.to_string().as_bytes())?;
910 file.sync_all()?;
911 std::fs::rename(&tmp, &path)
912 }
913
914 async fn apply_pending<S, SFut>(
923 &self,
924 save: &S,
925 ) -> std::result::Result<(), (RefreshFailure, Option<OAuthCredentials>)>
926 where
927 S: Fn(&str, &OAuthCredentials) -> SFut,
928 SFut: std::future::Future<Output = std::result::Result<CasOutcome, RefreshFailure>>,
929 {
930 let path = pending_path(&self.path);
931 let Ok(text) = std::fs::read_to_string(&path) else {
932 return Ok(());
933 };
934 let parked = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| {
935 let expected = v.get("expected_refresh")?.as_str()?.to_string();
936 let cred = serde_json::from_value::<OAuthCredentials>(v.get("credential")?.clone()).ok()?;
937 Some((expected, cred))
938 });
939 let Some((expected, cred)) = parked else {
940 let _ = std::fs::remove_file(&path);
941 return Ok(());
942 };
943 let failure = match save(&expected, &cred).await {
944 Ok(CasOutcome::Saved | CasOutcome::Replaced | CasOutcome::Removed) => {
945 let _ = std::fs::remove_file(&path);
946 return Ok(());
947 }
948 Ok(CasOutcome::StoreMissing) => RefreshFailure::new(
949 RefreshFailureKind::ActionRequired,
950 format!(
951 "the credential store no longer exists; this account's refreshed login is \
952 kept in {}. Put the store back (or repair it) to keep using it; logging in \
953 again replaces it.",
954 path.display()
955 ),
956 ),
957 Err(e) if e.kind == RefreshFailureKind::ActionRequired => RefreshFailure::new(
958 RefreshFailureKind::ActionRequired,
959 format!(
960 "{} This account's refreshed login is kept in {} and is applied once the \
961 store can be written.",
962 e.message(),
963 path.display()
964 ),
965 ),
966 Err(e) => RefreshFailure::other(format!(
967 "a refreshed login is waiting in {} and could not be applied yet ({e}); retry shortly",
968 path.display()
969 )),
970 };
971 Err((failure, Some(cred)))
972 }
973
974 fn stamp_refreshed(&self, now_ms: u64) -> std::io::Result<()> {
975 use std::io::{Seek, Write};
976 let mut file = &self.file;
977 file.set_len(0)?;
978 file.rewind()?;
979 file.write_all(now_ms.to_string().as_bytes())
980 }
981}
982
983async fn acquire_refresh_lock(
987 path: PathBuf,
988 wait: std::time::Duration,
989) -> std::result::Result<RefreshFileLock, RefreshFailure> {
990 tokio::task::spawn_blocking(move || {
991 use fs4::fs_std::FileExt;
992 let other = |m: String| RefreshFailure::new(RefreshFailureKind::Other, m);
993 if let Some(parent) = path.parent() {
994 std::fs::create_dir_all(parent)
995 .map_err(|e| other(format!("Failed to create {}: {}", parent.display(), e)))?;
996 }
997 let mut open = std::fs::OpenOptions::new();
998 open.create(true).read(true).write(true).truncate(false);
1001 #[cfg(unix)]
1002 {
1003 use std::os::unix::fs::OpenOptionsExt;
1004 open.mode(0o600);
1005 }
1006 let file = open
1007 .open(&path)
1008 .map_err(|e| other(format!("Failed to open refresh lock {}: {}", path.display(), e)))?;
1009 let deadline = std::time::Instant::now() + wait;
1010 loop {
1011 match FileExt::try_lock_exclusive(&file) {
1012 Ok(true) => return Ok(RefreshFileLock { file, path }),
1013 Ok(false) => {}
1014 Err(e) => {
1015 return Err(other(format!(
1016 "Failed to lock refresh lock {}: {}",
1017 path.display(),
1018 e
1019 )))
1020 }
1021 }
1022 if std::time::Instant::now() >= deadline {
1023 return Err(RefreshFailure::new(
1024 RefreshFailureKind::LockBusy,
1025 format!(
1026 "timed out waiting for another process to finish refreshing ({})",
1027 path.display()
1028 ),
1029 ));
1030 }
1031 std::thread::sleep(std::time::Duration::from_millis(25));
1032 }
1033 })
1034 .await
1035 .map_err(|e| RefreshFailure::new(RefreshFailureKind::Other, format!("refresh lock task failed: {e}")))?
1036}
1037
1038#[cfg(test)]
1051pub(crate) async fn ensure_fresh_gated<Load, Refresh, RFut, Save>(
1052 gate: Arc<Mutex<()>>,
1053 load: Load,
1054 refresh: Refresh,
1055 save: Save,
1056) -> std::result::Result<OAuthCredentials, String>
1057where
1058 Load: Fn() -> std::result::Result<OAuthCredentials, String>,
1059 Refresh: FnOnce(String) -> RFut,
1060 RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, String>>,
1061 Save: FnOnce(&OAuthCredentials) -> std::result::Result<(), String>,
1062{
1063 let _gate = gate.lock_owned().await;
1064 let creds = load()?;
1067 if !is_token_expired(&creds) {
1068 return Ok(creds);
1069 }
1070 let fresh = refresh(creds.refresh).await?;
1071 save(&fresh)?;
1072 Ok(fresh)
1073}
1074
1075const MAX_CAS_ROUNDS: usize = 2;
1078
1079#[cfg(test)]
1091pub(crate) async fn ensure_fresh_gated_locked<Load, Refresh, RFut, Save>(
1092 gate: Arc<Mutex<()>>,
1093 lock_path: Option<PathBuf>,
1094 load: Load,
1095 refresh: Refresh,
1096 save: Save,
1097) -> std::result::Result<OAuthCredentials, String>
1098where
1099 Load: Fn() -> std::result::Result<OAuthCredentials, String>,
1100 Refresh: Fn(String) -> RFut,
1101 RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, String>>,
1102 Save: Fn(&str, &OAuthCredentials) -> std::result::Result<CasOutcome, String>,
1103{
1104 let save = |old: &str, fresh: &OAuthCredentials| {
1105 std::future::ready(save(old, fresh).map_err(RefreshFailure::from))
1106 };
1107 let load = || load().map_err(RefreshFailure::from);
1108 ensure_gated_locked_policy(gate, lock_path, load, refresh, save, None, |_| Ok(()))
1109 .await
1110 .map_err(String::from)
1111}
1112
1113#[cfg(test)]
1122async fn ensure_gated_locked_policy<Load, Refresh, RFut, RErr, Save, SFut, MayForce>(
1123 gate: Arc<Mutex<()>>,
1124 lock_path: Option<PathBuf>,
1125 load: Load,
1126 refresh: Refresh,
1127 save: Save,
1128 rejected: Option<&str>,
1129 may_force: MayForce,
1130) -> std::result::Result<OAuthCredentials, RefreshFailure>
1131where
1132 Load: Fn() -> std::result::Result<OAuthCredentials, RefreshFailure>,
1133 Refresh: Fn(String) -> RFut,
1134 RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, RErr>>,
1135 RErr: Into<RefreshFailure>,
1136 Save: Fn(&str, &OAuthCredentials) -> SFut,
1137 SFut: std::future::Future<Output = std::result::Result<CasOutcome, RefreshFailure>>,
1138 MayForce: Fn(Option<u64>) -> std::result::Result<(), RefreshFailure>,
1139{
1140 ensure_gated_locked_policy_with(gate, lock_path, load, refresh, save, rejected, may_force, false)
1141 .await
1142}
1143
1144#[allow(clippy::too_many_arguments)]
1149async fn ensure_gated_locked_policy_with<Load, Refresh, RFut, RErr, Save, SFut, MayForce>(
1150 gate: Arc<Mutex<()>>,
1151 lock_path: Option<PathBuf>,
1152 load: Load,
1153 refresh: Refresh,
1154 save: Save,
1155 rejected: Option<&str>,
1156 may_force: MayForce,
1157 serve_parked: bool,
1158) -> std::result::Result<OAuthCredentials, RefreshFailure>
1159where
1160 Load: Fn() -> std::result::Result<OAuthCredentials, RefreshFailure>,
1161 Refresh: Fn(String) -> RFut,
1162 RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, RErr>>,
1163 RErr: Into<RefreshFailure>,
1164 Save: Fn(&str, &OAuthCredentials) -> SFut,
1165 SFut: std::future::Future<Output = std::result::Result<CasOutcome, RefreshFailure>>,
1166 MayForce: Fn(Option<u64>) -> std::result::Result<(), RefreshFailure>,
1167{
1168 let _gate = tokio::time::timeout(REFRESH_LOCK_WAIT, gate.lock_owned())
1170 .await
1171 .map_err(|_| {
1172 RefreshFailure::new(
1173 RefreshFailureKind::LockBusy,
1174 "timed out waiting for an in-flight token refresh",
1175 )
1176 })?;
1177 let file_lock = match lock_path {
1178 Some(path) => Some(acquire_refresh_lock(path, REFRESH_LOCK_WAIT).await?),
1179 None => None,
1180 };
1181 if let Some(lock) = &file_lock {
1184 if let Err((failure, parked)) = lock.apply_pending(&save).await {
1185 let usable = parked.filter(|p| {
1186 serve_parked
1187 && !is_token_expired(p)
1188 && !rejected.is_some_and(|r| !r.is_empty() && r == p.access)
1189 });
1190 let Some(mut parked) = usable else {
1191 return Err(failure);
1192 };
1193 tracing::warn!(error = %failure, "serving the parked access token until its refresh can be saved");
1194 parked.refresh.clear();
1195 return Ok(parked);
1196 }
1197 }
1198 for _ in 0..MAX_CAS_ROUNDS {
1199 let creds = load()?;
1202 let expired = is_token_expired(&creds);
1205 let still_rejected = rejected.is_some_and(|r| !r.is_empty() && creds.access == r);
1206 if !expired && !still_rejected {
1207 return Ok(creds);
1208 }
1209 if !expired {
1210 may_force(file_lock.as_ref().and_then(RefreshFileLock::last_refresh_ms))?;
1212 }
1213 let old_refresh = creds.refresh.clone();
1214 let mut fresh = tokio::time::timeout(REFRESH_HTTP_DEADLINE, refresh(old_refresh.clone()))
1217 .await
1218 .map_err(|_| RefreshFailure::new(RefreshFailureKind::Transient, "token refresh timed out"))?
1219 .map_err(Into::into)?;
1220 if fresh.account_id.is_none() {
1221 fresh.account_id = creds.account_id.clone();
1222 }
1223 let saved = match save(&old_refresh, &fresh).await {
1224 Ok(saved) => saved,
1225 Err(e) => return park_unsaved_rotation(file_lock.as_ref(), &old_refresh, fresh, e),
1226 };
1227 match saved {
1228 CasOutcome::Saved => {
1229 if let Some(lock) = &file_lock {
1230 lock.record_refreshed(crate::epoch_millis());
1231 }
1232 return Ok(fresh);
1233 }
1234 CasOutcome::StoreMissing => {
1235 let err = RefreshFailure::new(
1238 RefreshFailureKind::ActionRequired,
1239 "the credential store no longer exists",
1240 );
1241 return park_unsaved_rotation(file_lock.as_ref(), &old_refresh, fresh, err);
1242 }
1243 CasOutcome::Removed => {
1244 return Err(
1245 "credential was removed while its token was being refreshed; run `synaps login`"
1246 .to_string()
1247 .into(),
1248 )
1249 }
1250 CasOutcome::Replaced => {
1251 continue;
1255 }
1256 }
1257 }
1258 Err(RefreshFailure::new(
1259 RefreshFailureKind::Other,
1260 "credential kept changing during refresh; retry",
1261 ))
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266 use super::super::storage::save_provider_auth_at_test_hook;
1267 use super::super::OAuthCredentials;
1268
1269 fn fresh_creds(refresh: &str) -> OAuthCredentials {
1270 OAuthCredentials {
1271 auth_type: "oauth".to_string(),
1272 refresh: refresh.to_string(),
1273 access: "access".to_string(),
1274 expires: crate::epoch_millis() + 3_600_000,
1275 account_id: None,
1276 }
1277 }
1278
1279 #[test]
1296 fn bug2_write_produces_non_empty_file() {
1297 let dir = tempfile::tempdir().expect("tempdir");
1298 let path = dir.path().join("auth.json");
1299 save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok1"))
1300 .expect("save must succeed");
1301 let meta = std::fs::metadata(&path).expect("file must exist");
1302 assert!(meta.len() > 0, "auth.json must not be empty after save");
1303 }
1304
1305 #[test]
1310 fn bug2_tmp_file_cleaned_up_after_successful_write() {
1311 let dir = tempfile::tempdir().expect("tempdir");
1312 let path = dir.path().join("auth.json");
1313 save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok2"))
1314 .expect("save must succeed");
1315 let tmp = path.with_extension("json.tmp");
1316 assert!(
1317 !tmp.exists(),
1318 "auth.json.tmp must not persist after a successful atomic rename"
1319 );
1320 }
1321
1322 #[test]
1326 fn bug2_final_file_is_valid_json_with_correct_credential() {
1327 let dir = tempfile::tempdir().expect("tempdir");
1328 let path = dir.path().join("auth.json");
1329 save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok3"))
1330 .expect("save must succeed");
1331 let content = std::fs::read_to_string(&path).expect("must be readable");
1332 let parsed: serde_json::Value =
1333 serde_json::from_str(&content).expect("must be valid JSON after atomic write");
1334 assert_eq!(
1335 parsed["anthropic"]["refresh"].as_str(),
1336 Some("tok3"),
1337 "refresh token must be persisted correctly"
1338 );
1339 }
1340
1341 #[test]
1345 fn bug2_refresh_write_preserves_other_providers() {
1346 let dir = tempfile::tempdir().expect("tempdir");
1347 let path = dir.path().join("auth.json");
1348
1349 save_provider_auth_at_test_hook(&path, "openai-codex", &fresh_creds("codex-tok"))
1351 .expect("save codex");
1352 save_provider_auth_at_test_hook(&path, "future-provider", &fresh_creds("future-tok"))
1353 .expect("save future");
1354
1355 save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("anth-new"))
1357 .expect("save anthropic after refresh");
1358
1359 let content = std::fs::read_to_string(&path).expect("readable");
1360 let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid JSON");
1361
1362 assert_eq!(parsed["anthropic"]["refresh"].as_str(), Some("anth-new"));
1363 assert_eq!(
1364 parsed["openai-codex"]["refresh"].as_str(),
1365 Some("codex-tok"),
1366 "openai-codex must survive the anthropic refresh write"
1367 );
1368 assert_eq!(
1369 parsed["future-provider"]["refresh"].as_str(),
1370 Some("future-tok"),
1371 "future-provider must survive (fixes #184 — 3rd provider not dropped)"
1372 );
1373 }
1374
1375 use super::ensure_fresh_gated;
1383 use std::sync::atomic::{AtomicUsize, Ordering};
1384 use std::sync::{Arc, Mutex as StdMutex};
1385
1386 fn expired_creds(refresh: &str) -> OAuthCredentials {
1387 OAuthCredentials {
1388 auth_type: "oauth".to_string(),
1389 refresh: refresh.to_string(),
1390 access: "stale-access".to_string(),
1391 expires: 0,
1392 account_id: None,
1393 }
1394 }
1395
1396 #[derive(Clone)]
1398 struct FakeStore {
1399 creds: Arc<StdMutex<OAuthCredentials>>,
1400 refresh_calls: Arc<AtomicUsize>,
1401 save_calls: Arc<AtomicUsize>,
1402 }
1403
1404 impl FakeStore {
1405 fn new(initial: OAuthCredentials) -> Self {
1406 Self {
1407 creds: Arc::new(StdMutex::new(initial)),
1408 refresh_calls: Arc::new(AtomicUsize::new(0)),
1409 save_calls: Arc::new(AtomicUsize::new(0)),
1410 }
1411 }
1412
1413 async fn ensure(
1414 &self,
1415 gate: Arc<tokio::sync::Mutex<()>>,
1416 ) -> Result<OAuthCredentials, String> {
1417 let store = self.clone();
1418 let refresh_calls = self.refresh_calls.clone();
1419 ensure_fresh_gated(
1420 gate,
1421 move || Ok(store.creds.lock().unwrap().clone()),
1422 move |old_refresh| async move {
1423 refresh_calls.fetch_add(1, Ordering::SeqCst);
1424 Ok(OAuthCredentials {
1426 auth_type: "oauth".to_string(),
1427 refresh: format!("{old_refresh}-rotated"),
1428 access: "fresh-access".to_string(),
1429 expires: crate::epoch_millis() + 3_600_000,
1430 account_id: None,
1431 })
1432 },
1433 |c| {
1434 self.save_calls.fetch_add(1, Ordering::SeqCst);
1435 *self.creds.lock().unwrap() = c.clone();
1436 Ok(())
1437 },
1438 )
1439 .await
1440 }
1441 }
1442
1443 #[tokio::test]
1446 async fn anthropic_refresh_single_flight_under_concurrency() {
1447 let store = FakeStore::new(expired_creds("r0"));
1448 let gate = Arc::new(tokio::sync::Mutex::new(()));
1449 let mut handles = Vec::new();
1450 for _ in 0..16 {
1451 let store = store.clone();
1452 let gate = gate.clone();
1453 handles.push(tokio::spawn(async move { store.ensure(gate).await }));
1454 }
1455 for h in handles {
1456 let creds = h.await.unwrap().expect("refresh must succeed");
1457 assert_eq!(creds.access, "fresh-access");
1458 assert_eq!(creds.refresh, "r0-rotated");
1459 }
1460 assert_eq!(
1461 store.refresh_calls.load(Ordering::SeqCst),
1462 1,
1463 "exactly one caller may rotate the refresh token"
1464 );
1465 assert_eq!(store.save_calls.load(Ordering::SeqCst), 1);
1466 }
1467
1468 #[tokio::test]
1471 async fn refresh_rotation_chains_from_persisted_token() {
1472 let store = FakeStore::new(expired_creds("gen0"));
1473 let gate = Arc::new(tokio::sync::Mutex::new(()));
1474 store.ensure(gate.clone()).await.unwrap();
1475 assert_eq!(store.creds.lock().unwrap().refresh, "gen0-rotated");
1476
1477 store.creds.lock().unwrap().expires = 0;
1479 let second = store.ensure(gate).await.unwrap();
1480 assert_eq!(second.refresh, "gen0-rotated-rotated");
1481 assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 2);
1482 }
1483
1484 #[tokio::test]
1486 async fn fresh_credential_short_circuits_without_refresh() {
1487 let store = FakeStore::new(fresh_creds("keep"));
1488 let gate = Arc::new(tokio::sync::Mutex::new(()));
1489 let creds = store.ensure(gate).await.unwrap();
1490 assert_eq!(creds.refresh, "keep");
1491 assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 0);
1492 assert_eq!(store.save_calls.load(Ordering::SeqCst), 0);
1493 }
1494
1495 #[test]
1496 fn oauth_error_body_is_sanitized_and_utf8_safe() {
1497 use super::sanitize_oauth_error_body as s;
1498 assert_eq!(
1499 s(r#"{"error":"invalid_grant","error_description":"Refresh token revoked"}"#),
1500 "invalid_grant: Refresh token revoked"
1501 );
1502 assert_eq!(
1503 s(r#"{"type":"error","error":{"type":"authentication_error","message":"bad"}}"#),
1504 "authentication_error: bad"
1505 );
1506 let echoed = s(r#"{"grant_type":"refresh_token","refresh_token":"rt-SECRET"}"#);
1508 assert!(!echoed.contains("rt-SECRET"), "{echoed}");
1509 let html = format!("<html>{}</html>", "é".repeat(300));
1510 assert_eq!(s(&html), "<non-JSON error body omitted>");
1511 let long = format!(r#"{{"error":"{}"}}"#, "é".repeat(500));
1513 assert_eq!(s(&long).chars().count(), 120);
1514 let straddle = format!("{}é{}", "a".repeat(199), "b".repeat(50));
1517 assert!(!straddle.is_char_boundary(200));
1518 assert_eq!(s(&straddle), "<non-JSON error body omitted>");
1519 }
1520 #[tokio::test]
1523 async fn refresh_failure_persists_nothing() {
1524 let saves = Arc::new(AtomicUsize::new(0));
1525 let saves2 = saves.clone();
1526 let err = ensure_fresh_gated(
1527 Arc::new(tokio::sync::Mutex::new(())),
1528 || Ok(expired_creds("r-fail")),
1529 |_r| async move { Err("provider 500".to_string()) },
1530 move |_c| {
1531 saves2.fetch_add(1, Ordering::SeqCst);
1532 Ok(())
1533 },
1534 )
1535 .await
1536 .unwrap_err();
1537 assert!(err.contains("provider 500"));
1538 assert_eq!(
1539 saves.load(Ordering::SeqCst),
1540 0,
1541 "failed refresh must not persist"
1542 );
1543 }
1544
1545 use super::super::account::{Account, CredentialRef};
1548 use super::super::provider::OAuthProviderId;
1549 use super::super::storage::{
1550 load_provider_auth_at, remove_key_at, save_provider_auth_at,
1551 save_provider_auth_if_refresh_matches_at,
1552 };
1553 use super::{ensure_fresh_gated_locked, refresh_lock_path, CasOutcome};
1554
1555 fn named(label: &str) -> CredentialRef {
1556 CredentialRef::new(OAuthProviderId::OpenAiCodex, Account::named(label).unwrap())
1557 }
1558
1559 async fn ensure_on_file(
1563 path: &std::path::Path,
1564 cred: &CredentialRef,
1565 presented: Arc<StdMutex<Vec<String>>>,
1566 delay: std::time::Duration,
1567 ) -> Result<OAuthCredentials, String> {
1568 let key = cred.storage_key();
1569 let gate = super::refresh_gate(&format!("{}|{}", path.display(), key));
1570 let lock = refresh_lock_path(path, &key);
1571 let load_path = path.to_path_buf();
1572 let load_key = key.clone();
1573 ensure_fresh_gated_locked(
1574 gate,
1575 Some(lock),
1576 move || {
1577 load_provider_auth_at(&load_path, &load_key)?.ok_or_else(|| "missing".to_string())
1578 },
1579 |old| {
1580 let presented = presented.clone();
1581 async move {
1582 presented.lock().unwrap().push(old.clone());
1583 tokio::time::sleep(delay).await;
1584 Ok(OAuthCredentials {
1585 auth_type: "oauth".into(),
1586 refresh: format!("{old}-rotated"),
1587 access: format!("{old}-access"),
1588 expires: crate::epoch_millis() + 3_600_000,
1589 account_id: None,
1590 })
1591 }
1592 },
1593 |old, fresh| save_provider_auth_if_refresh_matches_at(path, &key, old, fresh),
1594 )
1595 .await
1596 }
1597
1598 #[tokio::test]
1602 async fn two_accounts_refresh_independently_without_cross_talk() {
1603 let dir = tempfile::tempdir().unwrap();
1604 let path = dir.path().join("auth.json");
1605 save_provider_auth_at(&path, "openai-codex", &fresh_creds("default-r")).unwrap();
1606 save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1607 save_provider_auth_at(&path, "openai-codex@b", &expired_creds("b-r")).unwrap();
1608 let presented = Arc::new(StdMutex::new(Vec::new()));
1609 let mut handles = Vec::new();
1610 for label in ["a", "b", "a", "b", "a", "b"] {
1611 let path = path.clone();
1612 let presented = presented.clone();
1613 handles.push(tokio::spawn(async move {
1614 ensure_on_file(&path, &named(label), presented, std::time::Duration::from_millis(30))
1615 .await
1616 .unwrap()
1617 }));
1618 }
1619 for h in handles {
1620 h.await.unwrap();
1621 }
1622 let mut seen = presented.lock().unwrap().clone();
1623 seen.sort();
1624 assert_eq!(seen, vec!["a-r".to_string(), "b-r".to_string()], "one rotation per account");
1625 let a = load_provider_auth_at(&path, "openai-codex@a").unwrap().unwrap();
1626 let b = load_provider_auth_at(&path, "openai-codex@b").unwrap().unwrap();
1627 let d = load_provider_auth_at(&path, "openai-codex").unwrap().unwrap();
1628 assert_eq!(a.refresh, "a-r-rotated");
1629 assert_eq!(b.refresh, "b-r-rotated");
1630 assert_eq!(d.refresh, "default-r", "default slot untouched");
1631 }
1632
1633 #[tokio::test]
1637 async fn refresh_waits_for_cross_process_lock_of_same_credential_only() {
1638 use fs4::fs_std::FileExt;
1639 let dir = tempfile::tempdir().unwrap();
1640 let path = dir.path().join("auth.json");
1641 save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1642 save_provider_auth_at(&path, "openai-codex@b", &expired_creds("b-r")).unwrap();
1643 let lock_a = refresh_lock_path(&path, "openai-codex@a");
1645 let holder = std::fs::OpenOptions::new()
1646 .create(true)
1647 .write(true)
1648 .truncate(false)
1649 .open(&lock_a)
1650 .unwrap();
1651 FileExt::lock_exclusive(&holder).unwrap();
1652
1653 let presented = Arc::new(StdMutex::new(Vec::new()));
1654 let started = std::time::Instant::now();
1656 ensure_on_file(&path, &named("b"), presented.clone(), std::time::Duration::ZERO)
1657 .await
1658 .unwrap();
1659 assert!(started.elapsed() < std::time::Duration::from_secs(5));
1660
1661 let path_a = path.clone();
1663 let presented_a = presented.clone();
1664 let task = tokio::spawn(async move {
1665 ensure_on_file(&path_a, &named("a"), presented_a, std::time::Duration::ZERO).await
1666 });
1667 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1668 assert!(
1669 !presented.lock().unwrap().contains(&"a-r".to_string()),
1670 "must not rotate while another process holds the credential lock"
1671 );
1672 save_provider_auth_at(&path, "openai-codex@a", &fresh_creds("a-r-by-peer")).unwrap();
1674 FileExt::unlock(&holder).unwrap();
1675 let result = task.await.unwrap().unwrap();
1676 assert_eq!(result.refresh, "a-r-by-peer", "waiter re-reads the peer's rotation");
1677 assert!(
1678 !presented.lock().unwrap().contains(&"a-r".to_string()),
1679 "no second rotation of an already-rotated token"
1680 );
1681 }
1682
1683 #[tokio::test]
1686 async fn removal_during_refresh_is_not_resurrected() {
1687 let dir = tempfile::tempdir().unwrap();
1688 let path = dir.path().join("auth.json");
1689 save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1690 save_provider_auth_at(&path, "openai-codex@b", &fresh_creds("b-r")).unwrap();
1691 let presented = Arc::new(StdMutex::new(Vec::new()));
1692 let path2 = path.clone();
1693 let task = tokio::spawn(async move {
1694 ensure_on_file(&path2, &named("a"), presented, std::time::Duration::from_millis(300)).await
1695 });
1696 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1697 assert!(remove_key_at(&path, "openai-codex@a").unwrap());
1698 let err = task.await.unwrap().unwrap_err();
1699 assert!(err.contains("removed"), "{err}");
1700 assert!(load_provider_auth_at(&path, "openai-codex@a").unwrap().is_none());
1701 assert_eq!(
1702 load_provider_auth_at(&path, "openai-codex@b").unwrap().unwrap().refresh,
1703 "b-r"
1704 );
1705 }
1706
1707 #[tokio::test]
1710 async fn relogin_during_refresh_returns_fresh_replacement_not_stale_rotation() {
1711 let dir = tempfile::tempdir().unwrap();
1712 let path = dir.path().join("auth.json");
1713 save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1714 let presented = Arc::new(StdMutex::new(Vec::new()));
1715 let path2 = path.clone();
1716 let presented2 = presented.clone();
1717 let task = tokio::spawn(async move {
1718 ensure_on_file(&path2, &named("a"), presented2, std::time::Duration::from_millis(300)).await
1719 });
1720 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1721 save_provider_auth_at(&path, "openai-codex@a", &fresh_creds("relogin-r")).unwrap();
1722 let result = task.await.unwrap().unwrap();
1723 assert_eq!(result.refresh, "relogin-r");
1724 assert_eq!(
1725 load_provider_auth_at(&path, "openai-codex@a").unwrap().unwrap().refresh,
1726 "relogin-r"
1727 );
1728 assert_eq!(presented.lock().unwrap().as_slice(), ["a-r"], "no refresh of the fresh replacement");
1729 }
1730
1731 #[tokio::test]
1734 async fn expired_replacement_is_refreshed_not_returned() {
1735 let dir = tempfile::tempdir().unwrap();
1736 let path = dir.path().join("auth.json");
1737 save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1738 let presented = Arc::new(StdMutex::new(Vec::new()));
1739 let path2 = path.clone();
1740 let presented2 = presented.clone();
1741 let task = tokio::spawn(async move {
1742 ensure_on_file(&path2, &named("a"), presented2, std::time::Duration::from_millis(300)).await
1743 });
1744 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1745 save_provider_auth_at(&path, "openai-codex@a", &expired_creds("relogin-expired")).unwrap();
1746 let result = task.await.unwrap().unwrap();
1747 assert!(!super::is_token_expired(&result));
1748 assert_eq!(result.refresh, "relogin-expired-rotated");
1749 assert_eq!(
1750 presented.lock().unwrap().as_slice(),
1751 ["a-r", "relogin-expired"]
1752 );
1753 }
1754
1755 #[tokio::test]
1758 async fn refresh_preserves_account_id_when_response_omits_it() {
1759 let dir = tempfile::tempdir().unwrap();
1760 let path = dir.path().join("auth.json");
1761 let mut stored = expired_creds("a-r");
1762 stored.account_id = Some("acct-keep".into());
1763 save_provider_auth_at(&path, "openai-codex@a", &stored).unwrap();
1764 let result = ensure_on_file(
1765 &path,
1766 &named("a"),
1767 Arc::new(StdMutex::new(Vec::new())),
1768 std::time::Duration::ZERO,
1769 )
1770 .await
1771 .unwrap();
1772 assert_eq!(result.account_id.as_deref(), Some("acct-keep"));
1773 assert_eq!(
1774 load_provider_auth_at(&path, "openai-codex@a").unwrap().unwrap().account_id.as_deref(),
1775 Some("acct-keep")
1776 );
1777 }
1778
1779 #[tokio::test]
1784 async fn seat_pinned_vend_refuses_a_replaced_slot() {
1785 use super::super::account::SeatIdentity;
1786 let dir = tempfile::tempdir().unwrap();
1787 let path = dir.path().join("auth.json");
1788 let mut stored = fresh_creds("a-r");
1789 stored.account_id = Some("acct-a".into());
1790 save_provider_auth_at(&path, "openai-codex@a", &stored).unwrap();
1791 let client = reqwest::Client::new();
1792 let seat = SeatIdentity::of(OAuthProviderId::OpenAiCodex, &stored);
1793 let ok = super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, Some(&seat))
1794 .await
1795 .unwrap();
1796 assert_eq!(ok.refresh, "a-r");
1797 let mut rotated = fresh_creds("a-r2");
1800 rotated.account_id = Some("acct-a".into());
1801 save_provider_auth_at(&path, "openai-codex@a", &rotated).unwrap();
1802 assert!(
1803 super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, Some(&seat))
1804 .await
1805 .is_ok()
1806 );
1807 let mut other = fresh_creds("b-r");
1809 other.account_id = Some("acct-b".into());
1810 save_provider_auth_at(&path, "openai-codex@a", &other).unwrap();
1811 let err = super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, Some(&seat))
1812 .await
1813 .unwrap_err();
1814 assert_eq!(err, super::SEAT_CHANGED);
1815 assert!(!err.contains("b-r") && !err.contains("acct"));
1816 assert_eq!(
1818 super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, None)
1819 .await
1820 .unwrap()
1821 .refresh,
1822 "b-r"
1823 );
1824 assert!(remove_key_at(&path, "openai-codex@a").unwrap());
1826 let err = super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, Some(&seat))
1827 .await
1828 .unwrap_err();
1829 assert!(err.starts_with("No credentials for "), "{err}");
1830 assert_eq!(
1831 super::SeatVendError::SeatChanged.to_string(),
1832 super::SEAT_CHANGED
1833 );
1834 }
1835
1836 #[tokio::test]
1840 async fn detached_refresh_persists_after_caller_is_cancelled() {
1841 let dir = tempfile::tempdir().unwrap();
1842 let path = dir.path().join("auth.json");
1843 let cred = named("a");
1844 let key = cred.storage_key();
1845 save_provider_auth_at(&path, &key, &expired_creds("a-r")).unwrap();
1846 let (task_path, task_key) = (path.clone(), key.clone());
1847 let caller = super::run_detached(async move {
1848 let gate = super::refresh_gate(&format!("{}|{}", task_path.display(), task_key));
1849 let lock = refresh_lock_path(&task_path, &task_key);
1850 ensure_fresh_gated_locked(
1851 gate,
1852 Some(lock),
1853 || {
1854 load_provider_auth_at(&task_path, &task_key)?
1855 .ok_or_else(|| "missing".to_string())
1856 },
1857 |old| async move {
1858 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1859 Ok(OAuthCredentials {
1860 auth_type: "oauth".into(),
1861 refresh: format!("{old}-rotated"),
1862 access: format!("{old}-access"),
1863 expires: crate::epoch_millis() + 3_600_000,
1864 account_id: None,
1865 })
1866 },
1867 |old, fresh| {
1868 save_provider_auth_if_refresh_matches_at(&task_path, &task_key, old, fresh)
1869 },
1870 )
1871 .await
1872 });
1873 assert!(
1875 tokio::time::timeout(std::time::Duration::from_millis(20), caller)
1876 .await
1877 .is_err(),
1878 "caller must still be waiting when cancelled"
1879 );
1880 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1881 loop {
1882 let stored = load_provider_auth_at(&path, &key).unwrap().unwrap();
1883 if stored.refresh == "a-r-rotated" {
1884 break;
1885 }
1886 assert!(
1887 std::time::Instant::now() < deadline,
1888 "rotated token was never persisted after cancel"
1889 );
1890 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1891 }
1892 }
1893
1894 fn slow_refresh_future(
1897 path: std::path::PathBuf,
1898 label: &str,
1899 started: Arc<std::sync::atomic::AtomicBool>,
1900 ) -> impl std::future::Future<Output = Result<OAuthCredentials, String>> + Send + 'static {
1901 let key = named(label).storage_key();
1902 async move {
1903 let gate = super::refresh_gate(&format!("{}|{}", path.display(), key));
1904 let lock = refresh_lock_path(&path, &key);
1905 ensure_fresh_gated_locked(
1906 gate,
1907 Some(lock),
1908 || load_provider_auth_at(&path, &key)?.ok_or_else(|| "missing".to_string()),
1909 |old| {
1910 let started = started.clone();
1911 async move {
1912 started.store(true, std::sync::atomic::Ordering::SeqCst);
1913 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1914 Ok(OAuthCredentials {
1915 auth_type: "oauth".into(),
1916 refresh: format!("{old}-rotated"),
1917 access: format!("{old}-access"),
1918 expires: crate::epoch_millis() + 3_600_000,
1919 account_id: None,
1920 })
1921 }
1922 },
1923 |old, fresh| save_provider_auth_if_refresh_matches_at(&path, &key, old, fresh),
1924 )
1925 .await
1926 }
1927 }
1928
1929 fn wait_for_rotation(path: &std::path::Path, label: &str, want: &str) {
1930 let key = named(label).storage_key();
1931 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1932 while load_provider_auth_at(path, &key).unwrap().unwrap().refresh != want {
1933 assert!(
1934 std::time::Instant::now() < deadline,
1935 "rotated token was never persisted after the caller's runtime died"
1936 );
1937 std::thread::sleep(std::time::Duration::from_millis(20));
1938 }
1939 }
1940
1941 #[test]
1945 fn refresh_survives_dropping_the_callers_runtime_mid_exchange() {
1946 let dir = tempfile::tempdir().unwrap();
1947 let path = dir.path().join("auth.json");
1948 save_provider_auth_at(&path, &named("a").storage_key(), &expired_creds("a-r")).unwrap();
1949 let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
1950 let worker = tokio::runtime::Builder::new_current_thread()
1951 .enable_all()
1952 .build()
1953 .unwrap();
1954 let fut = slow_refresh_future(path.clone(), "a", started.clone());
1955 worker.spawn(super::run_detached(fut));
1956 worker.block_on(async {
1957 while !started.load(std::sync::atomic::Ordering::SeqCst) {
1958 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1959 }
1960 });
1961 let t = std::time::Instant::now();
1962 drop(worker); assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed());
1964 wait_for_rotation(&path, "a", "a-r-rotated");
1965 }
1966
1967 #[test]
1972 fn dropping_the_callers_runtime_while_waiting_on_the_lock_is_prompt() {
1973 use fs4::fs_std::FileExt;
1974 let dir = tempfile::tempdir().unwrap();
1975 let path = dir.path().join("auth.json");
1976 let key = named("b").storage_key();
1977 save_provider_auth_at(&path, &key, &expired_creds("b-r")).unwrap();
1978 let holder = std::fs::OpenOptions::new()
1979 .create(true)
1980 .write(true)
1981 .truncate(false)
1982 .open(refresh_lock_path(&path, &key))
1983 .unwrap();
1984 FileExt::lock_exclusive(&holder).unwrap();
1985 let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
1986 let worker = tokio::runtime::Builder::new_current_thread()
1987 .enable_all()
1988 .build()
1989 .unwrap();
1990 worker.spawn(super::run_detached(slow_refresh_future(path.clone(), "b", started.clone())));
1991 worker.block_on(async { tokio::time::sleep(std::time::Duration::from_millis(100)).await });
1992 assert!(!started.load(std::sync::atomic::Ordering::SeqCst), "must wait for the peer");
1993 let t = std::time::Instant::now();
1994 drop(worker);
1995 assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed());
1996 drop(holder);
1997 wait_for_rotation(&path, "b", "b-r-rotated");
1998 }
1999
2000 #[tokio::test]
2006 async fn refresh_file_lock_excludes_second_holder_until_released() {
2007 let dir = tempfile::tempdir().unwrap();
2008 let path = dir.path().join("auth.json.anthropic.refresh.lock");
2009 let short = std::time::Duration::from_millis(150);
2010 let held = super::acquire_refresh_lock(path.clone(), short).await.unwrap();
2011 let err = match super::acquire_refresh_lock(path.clone(), short).await {
2012 Ok(_) => panic!("second holder must time out"),
2013 Err(e) => e,
2014 };
2015 assert_eq!(err.kind, super::RefreshFailureKind::LockBusy);
2016 assert!(err.message().contains("timed out"), "{err}");
2017 drop(held);
2018 super::acquire_refresh_lock(path, short).await.unwrap();
2019 }
2020
2021 #[tokio::test]
2024 async fn fresh_credential_skips_the_refresh_lock() {
2025 use fs4::fs_std::FileExt;
2026 let dir = tempfile::tempdir().unwrap();
2027 let path = dir.path().join("auth.json");
2028 let cred = named("a");
2029 save_provider_auth_at(&path, &cred.storage_key(), &fresh_creds("a-r")).unwrap();
2030 let holder = std::fs::OpenOptions::new()
2031 .create(true)
2032 .write(true)
2033 .truncate(false)
2034 .open(refresh_lock_path(&path, &cred.storage_key()))
2035 .unwrap();
2036 FileExt::lock_exclusive(&holder).unwrap();
2037 let got = tokio::time::timeout(
2038 std::time::Duration::from_secs(5),
2039 super::ensure_fresh_credential_at(&reqwest::Client::new(), &cred, &path),
2040 )
2041 .await
2042 .expect("fresh vend must not wait on the refresh lock")
2043 .unwrap();
2044 assert_eq!(got.refresh, "a-r");
2045 }
2046
2047 #[test]
2048 fn refresh_lock_path_is_per_credential_and_beside_the_file() {
2049 let path = std::path::Path::new("/tmp/x/auth.json");
2050 assert_eq!(
2051 refresh_lock_path(path, "openai-codex@astra2"),
2052 std::path::PathBuf::from("/tmp/x/auth.json.openai-codex@astra2.refresh.lock")
2053 );
2054 assert_eq!(
2058 refresh_lock_path(path, "anthropic"),
2059 std::path::PathBuf::from("/tmp/x/auth.json.anthropic.refresh.lock")
2060 );
2061 assert_ne!(
2062 refresh_lock_path(path, "openai-codex"),
2063 refresh_lock_path(path, "openai-codex@astra2")
2064 );
2065 let _ = CasOutcome::Saved;
2066 }
2067}
2068
2069#[cfg(test)]
2070mod rejected_token_tests {
2071 use super::*;
2072 use std::sync::atomic::{AtomicUsize, Ordering};
2073 use std::sync::Mutex as StdMutex;
2074
2075 fn credential(access: &str) -> OAuthCredentials {
2076 OAuthCredentials {
2077 auth_type: "oauth".into(),
2078 access: access.into(),
2079 refresh: "r0".into(),
2080 expires: crate::epoch_millis() + 3_600_000,
2081 account_id: Some("account".into()),
2082 }
2083 }
2084 async fn recover(
2085 store: &StdMutex<OAuthCredentials>,
2086 gate: Arc<Mutex<()>>,
2087 calls: &AtomicUsize,
2088 lock: Option<PathBuf>,
2089 rejected: &str,
2090 ) -> Result<OAuthCredentials, String> {
2091 ensure_gated_locked_policy(
2092 gate,
2093 lock,
2094 || Ok(store.lock().unwrap().clone()),
2095 |_| async {
2096 calls.fetch_add(1, Ordering::SeqCst);
2097 tokio::task::yield_now().await;
2098 Ok::<_, String>(credential("new"))
2099 },
2100 |_, fresh| {
2101 *store.lock().unwrap() = fresh.clone();
2102 std::future::ready(Ok(CasOutcome::Saved))
2103 },
2104 Some(rejected),
2105 |_| Ok(()),
2106 )
2107 .await
2108 .map_err(String::from)
2109 }
2110 #[test]
2113 fn refresh_runtime_build_is_retried_after_a_failure() {
2114 let slot = std::sync::Mutex::new(None);
2115 let err = runtime_from(&slot, || Err(std::io::Error::other("EAGAIN"))).unwrap_err();
2116 assert_eq!(err.kind, RefreshFailureKind::Other);
2117 assert!(err.message().contains("EAGAIN"), "{err}");
2118 let built = runtime_from(&slot, || {
2119 tokio::runtime::Builder::new_current_thread().build()
2120 })
2121 .expect("second attempt builds");
2122 let again = runtime_from(&slot, || panic!("must reuse the built runtime")).unwrap();
2123 assert!(std::ptr::eq(built, again));
2124 }
2125
2126 #[test]
2129 fn failed_stamp_write_keeps_an_in_process_cooldown_floor() {
2130 let dir = tempfile::tempdir().unwrap();
2131 let path = dir.path().join("auth.json.muse.refresh.lock");
2132 std::fs::write(&path, "").unwrap();
2133 let lock = RefreshFileLock {
2135 file: std::fs::File::open(&path).unwrap(),
2136 path: path.clone(),
2137 };
2138 let now = 5_000_000_000;
2139 lock.record_refreshed(now);
2140 assert_eq!(std::fs::read_to_string(&path).unwrap(), "", "stamp write failed");
2141 assert_eq!(lock.last_refresh_ms(), Some(now));
2142 let cred = CredentialRef::default_for(super::super::provider::OAuthProviderId::Muse);
2143 let refused = forced_refresh_allowed(&cred, lock.last_refresh_ms(), now + 5_000).unwrap_err();
2144 assert!(matches!(refused.kind, RefreshFailureKind::Cooldown { .. }), "{refused}");
2145
2146 let other_path = dir.path().join("auth.json.muse@work.refresh.lock");
2148 std::fs::write(&other_path, "").unwrap();
2149 let other = RefreshFileLock {
2150 file: std::fs::File::open(&other_path).unwrap(),
2151 path: other_path,
2152 };
2153 assert_eq!(other.last_refresh_ms(), None);
2154 }
2155
2156 #[test]
2159 fn corrupt_store_load_is_action_required_not_credential() {
2160 let dir = tempfile::tempdir().unwrap();
2161 let path = dir.path().join("auth.json");
2162 std::fs::write(&path, "{oops").unwrap();
2163 let cred = CredentialRef::default_for(super::super::provider::OAuthProviderId::Anthropic);
2164 let err = load_checked(&path, &cred, None).unwrap_err();
2165 assert_eq!(err.kind, RefreshFailureKind::ActionRequired, "{err}");
2166 assert!(err.message().contains("mv '"), "{err}");
2167 assert!(!err.message().contains("synaps login"), "{err}");
2168 }
2169
2170 #[tokio::test]
2173 async fn refresh_runtime_start_failure_is_an_error() {
2174 let err = run_detached_on(
2175 Err(RefreshFailure::other("could not start the auth refresh runtime: boom")),
2176 async { Ok::<u8, RefreshFailure>(1) },
2177 )
2178 .await
2179 .unwrap_err();
2180 assert_eq!(err.kind, RefreshFailureKind::Other);
2181 assert!(err.message().contains("refresh runtime"), "{err}");
2182 let ok: Result<u8, RefreshFailure> =
2184 run_detached_on(refresh_runtime(), async { Ok(7) }).await;
2185 assert_eq!(ok.unwrap(), 7);
2186 }
2187
2188 #[tokio::test]
2194 async fn rotated_token_survives_a_failed_save() {
2195 let dir = tempfile::tempdir().unwrap();
2196 let lock = dir.path().join("auth.json.anthropic.refresh.lock");
2197 let mut stale = credential("old-access");
2198 stale.expires = 0;
2199 let store = StdMutex::new(stale);
2200 let refreshes = AtomicUsize::new(0);
2201 let fail_saves = AtomicUsize::new(1);
2202 let run = || {
2203 ensure_gated_locked_policy(
2204 Arc::new(Mutex::new(())),
2205 Some(lock.clone()),
2206 || Ok(store.lock().unwrap().clone()),
2207 |old| {
2208 refreshes.fetch_add(1, Ordering::SeqCst);
2209 let mut c = credential("rotated-access");
2210 c.refresh = format!("{old}-rotated");
2211 async move { Ok::<_, String>(c) }
2212 },
2213 |expected: &str, fresh: &OAuthCredentials| {
2214 let out = if fail_saves.load(Ordering::SeqCst) > 0 {
2215 fail_saves.fetch_sub(1, Ordering::SeqCst);
2216 Err(StoreError::Busy("timed out waiting for auth.json.lock".into()).into())
2217 } else if store.lock().unwrap().refresh == expected {
2218 *store.lock().unwrap() = fresh.clone();
2219 Ok(CasOutcome::Saved)
2220 } else {
2221 Ok(CasOutcome::Replaced)
2222 };
2223 std::future::ready(out)
2224 },
2225 None,
2226 |_| Ok(()),
2227 )
2228 };
2229 let first = run().await.expect("the rotated token is handed out");
2230 assert_eq!(first.refresh, "r0-rotated");
2231 assert_eq!(store.lock().unwrap().refresh, "r0", "save failed");
2232 let pending = pending_path(&lock);
2233 assert!(pending.exists());
2234 #[cfg(unix)]
2235 {
2236 use std::os::unix::fs::PermissionsExt;
2237 let mode = std::fs::metadata(&pending).unwrap().permissions().mode();
2238 assert_eq!(mode & 0o777, 0o600);
2239 }
2240
2241 let second = run().await.unwrap();
2244 assert_eq!(second.refresh, "r0-rotated");
2245 assert_eq!(store.lock().unwrap().refresh, "r0-rotated");
2246 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
2247 assert!(!pending.exists());
2248 }
2249
2250 async fn blocked_apply(
2253 save_out: fn() -> Result<CasOutcome, RefreshFailure>,
2254 parked_expires: u64,
2255 rejected: Option<&str>,
2256 serve_parked: bool,
2257 ) -> (Result<OAuthCredentials, RefreshFailure>, bool, usize) {
2258 let dir = tempfile::tempdir().unwrap();
2259 let lock = dir.path().join("auth.json.anthropic.refresh.lock");
2260 let file_lock = RefreshFileLock {
2261 file: std::fs::File::create(&lock).unwrap(),
2262 path: lock.clone(),
2263 };
2264 let mut parked = credential("parked-access");
2265 parked.refresh = "parked-r".into();
2266 parked.expires = parked_expires;
2267 file_lock.park_pending("r0", &parked).unwrap();
2268 drop(file_lock);
2269 let mut stale = credential("old-access");
2270 stale.expires = 0;
2271 let refreshes = AtomicUsize::new(0);
2272 let out = ensure_gated_locked_policy_with(
2273 Arc::new(Mutex::new(())),
2274 Some(lock.clone()),
2275 || Ok(stale.clone()),
2276 |_| {
2277 refreshes.fetch_add(1, Ordering::SeqCst);
2278 async { Ok::<_, String>(credential("never")) }
2279 },
2280 |_: &str, _: &OAuthCredentials| std::future::ready(save_out()),
2281 rejected,
2282 |_| Ok(()),
2283 serve_parked,
2284 )
2285 .await;
2286 (out, pending_path(&lock).exists(), refreshes.load(Ordering::SeqCst))
2287 }
2288
2289 #[tokio::test]
2293 async fn parked_access_token_is_served_while_the_sidecar_cannot_be_applied() {
2294 let busy = || Err(StoreError::Busy("timed out waiting for auth.json.lock".into()).into());
2295 let live = crate::epoch_millis() + 3_600_000;
2296 let (out, kept, refreshes) = blocked_apply(busy, live, None, true).await;
2297 let served = out.expect("parked access token served");
2298 assert_eq!(served.access, "parked-access");
2299 assert!(served.refresh.is_empty(), "the parked refresh token never leaves");
2300 assert!(kept);
2301 assert_eq!(refreshes, 0);
2302 for (expires, rejected, serve) in [
2305 (live, None, false),
2306 (live, Some("parked-access"), true),
2307 (0, None, true),
2308 ] {
2309 let (out, kept, refreshes) = blocked_apply(busy, expires, rejected, serve).await;
2310 assert!(out.is_err());
2311 assert!(kept);
2312 assert_eq!(refreshes, 0);
2313 }
2314 }
2315
2316 #[tokio::test]
2319 async fn sidecar_blocked_by_a_corrupt_store_is_action_required() {
2320 let corrupt = || {
2321 Err(StoreError::Corrupt(
2322 "/x/auth.json is not valid JSON; Move it aside with `mv '/x/auth.json' '/x/auth.json.broken-1'`"
2323 .into(),
2324 )
2325 .into())
2326 };
2327 let (out, kept, _) = blocked_apply(corrupt, 0, None, false).await;
2328 let err = out.unwrap_err();
2329 assert_eq!(err.kind, RefreshFailureKind::ActionRequired, "{err}");
2330 assert!(err.message().contains("mv '/x/auth.json'"), "{err}");
2331 assert!(!err.message().contains("retry shortly"), "{err}");
2332 assert!(kept);
2333 }
2334
2335 #[tokio::test]
2339 async fn sidecar_is_kept_when_the_store_is_missing() {
2340 let (out, kept, refreshes) =
2341 blocked_apply(|| Ok(CasOutcome::StoreMissing), 0, None, false).await;
2342 let err = out.unwrap_err();
2343 assert_eq!(err.kind, RefreshFailureKind::ActionRequired, "{err}");
2344 assert!(err.message().contains("no longer exists"), "{err}");
2345 assert!(kept, "the only live token survives");
2346 assert_eq!(refreshes, 0);
2347 }
2348
2349 #[tokio::test]
2352 async fn rotation_is_parked_when_the_store_vanishes_mid_refresh() {
2353 let dir = tempfile::tempdir().unwrap();
2354 let lock = dir.path().join("auth.json.anthropic.refresh.lock");
2355 let mut stale = credential("old-access");
2356 stale.expires = 0;
2357 let fresh = ensure_gated_locked_policy(
2358 Arc::new(Mutex::new(())),
2359 Some(lock.clone()),
2360 || Ok(stale.clone()),
2361 |_| async { Ok::<_, String>(credential("rotated")) },
2362 |_: &str, _: &OAuthCredentials| std::future::ready(Ok(CasOutcome::StoreMissing)),
2363 None,
2364 |_| Ok(()),
2365 )
2366 .await
2367 .unwrap();
2368 assert_eq!(fresh.access, "rotated");
2369 assert!(pending_path(&lock).exists());
2370 }
2371
2372 #[tokio::test]
2376 async fn stale_or_unapplied_sidecar_is_handled_under_the_lock() {
2377 let dir = tempfile::tempdir().unwrap();
2378 let lock = dir.path().join("auth.json.anthropic.refresh.lock");
2379 let file_lock = RefreshFileLock {
2380 file: std::fs::File::create(&lock).unwrap(),
2381 path: lock.clone(),
2382 };
2383 let mut parked = credential("parked");
2384 parked.refresh = "parked-r".into();
2385 file_lock.park_pending("r-before-relogin", &parked).unwrap();
2386
2387 file_lock
2389 .apply_pending(&|_: &str, _: &OAuthCredentials| {
2390 std::future::ready(Ok(CasOutcome::Replaced))
2391 })
2392 .await
2393 .unwrap();
2394 assert!(!pending_path(&lock).exists());
2395
2396 file_lock.park_pending("r0", &parked).unwrap();
2398 let err = file_lock
2399 .apply_pending(&|_: &str, _: &OAuthCredentials| {
2400 std::future::ready(Err(StoreError::Busy("busy".into()).into()))
2401 })
2402 .await
2403 .unwrap_err()
2404 .0;
2405 assert_eq!(err.kind, RefreshFailureKind::Other);
2406 assert!(pending_path(&lock).exists());
2407 }
2408
2409 #[test]
2413 fn store_lock_timeout_is_described_honestly() {
2414 let busy: RefreshFailure =
2415 StoreError::Busy("timed out waiting for auth.json.lock".into()).into();
2416 assert_eq!(busy.kind, RefreshFailureKind::Other);
2417 assert!(busy.message().contains("auth.json.lock"));
2418 assert!(REFRESH_SAVE_WAIT >= REFRESH_LOCK_WAIT);
2419 assert!(REFRESH_SAVE_WAIT >= std::time::Duration::from_secs(120));
2420 assert_eq!(super::super::storage::STORE_LOCK_WAIT, std::time::Duration::from_secs(15));
2421 }
2422
2423 #[tokio::test]
2424 async fn changed_token_is_adopted_without_refresh() {
2425 let store = StdMutex::new(credential("peer-token"));
2426 let calls = AtomicUsize::new(0);
2427 let got = recover(&store, Arc::new(Mutex::new(())), &calls, None, "rejected")
2428 .await
2429 .unwrap();
2430 assert_eq!(got.access, "peer-token");
2431 assert_eq!(calls.load(Ordering::SeqCst), 0);
2432 }
2433 #[tokio::test]
2434 async fn unexpired_rejected_token_rotates_once_for_concurrent_waiters() {
2435 let store = StdMutex::new(credential("rejected"));
2436 let calls = AtomicUsize::new(0);
2437 let gate = Arc::new(Mutex::new(()));
2438 let (a, b) = tokio::join!(
2439 recover(&store, gate.clone(), &calls, None, "rejected"),
2440 recover(&store, gate, &calls, None, "rejected")
2441 );
2442 assert_eq!(a.unwrap().access, "new");
2443 assert_eq!(b.unwrap().access, "new");
2444 assert_eq!(calls.load(Ordering::SeqCst), 1);
2445 }
2446 #[tokio::test]
2447 async fn separate_process_gates_serialize_on_the_refresh_file_lock() {
2448 let dir = tempfile::tempdir().unwrap();
2449 let lock = dir.path().join("refresh.lock");
2450 let store = StdMutex::new(credential("rejected"));
2451 let calls = AtomicUsize::new(0);
2452 let (a, b) = tokio::join!(
2453 recover(
2454 &store,
2455 Arc::new(Mutex::new(())),
2456 &calls,
2457 Some(lock.clone()),
2458 "rejected"
2459 ),
2460 recover(
2461 &store,
2462 Arc::new(Mutex::new(())),
2463 &calls,
2464 Some(lock),
2465 "rejected"
2466 )
2467 );
2468 assert_eq!(a.unwrap().access, "new");
2469 assert_eq!(b.unwrap().access, "new");
2470 assert_eq!(calls.load(Ordering::SeqCst), 1);
2471 }
2472 #[tokio::test]
2473 async fn removed_slot_is_not_resurrected_by_forced_refresh() {
2474 let result = ensure_gated_locked_policy(
2475 Arc::new(Mutex::new(())),
2476 None,
2477 || Ok(credential("rejected")),
2478 |_| async { Ok::<_, String>(credential("new")) },
2479 |_, _| std::future::ready(Ok(CasOutcome::Removed)),
2480 Some("rejected"),
2481 |_| Ok(()),
2482 )
2483 .await;
2484 assert!(result.unwrap_err().to_string().contains("removed"));
2485 }
2486
2487 struct FakeStore {
2491 creds: StdMutex<OAuthCredentials>,
2492 refresh_calls: AtomicUsize,
2493 save_calls: AtomicUsize,
2494 }
2495
2496 impl FakeStore {
2497 fn new(initial: OAuthCredentials) -> Self {
2498 Self {
2499 creds: StdMutex::new(initial),
2500 refresh_calls: AtomicUsize::new(0),
2501 save_calls: AtomicUsize::new(0),
2502 }
2503 }
2504
2505 async fn ensure_rejecting(
2506 &self,
2507 gate: Arc<Mutex<()>>,
2508 rejected: &str,
2509 ) -> Result<OAuthCredentials, String> {
2510 ensure_gated_locked_policy(
2511 gate,
2512 None,
2513 || Ok(self.creds.lock().unwrap().clone()),
2514 |old_refresh| async move {
2515 self.refresh_calls.fetch_add(1, Ordering::SeqCst);
2516 tokio::task::yield_now().await;
2517 Ok::<_, String>(OAuthCredentials {
2518 auth_type: "oauth".into(),
2519 refresh: format!("{old_refresh}-rotated"),
2520 access: "fresh-access".into(),
2521 expires: crate::epoch_millis() + 3_600_000,
2522 account_id: None,
2523 })
2524 },
2525 |_, c| {
2526 self.save_calls.fetch_add(1, Ordering::SeqCst);
2527 *self.creds.lock().unwrap() = c.clone();
2528 std::future::ready(Ok(CasOutcome::Saved))
2529 },
2530 Some(rejected),
2531 |_| Ok(()),
2532 )
2533 .await
2534 .map_err(String::from)
2535 }
2536 }
2537
2538 #[tokio::test]
2541 async fn rejected_unexpired_token_forces_refresh() {
2542 let store = FakeStore::new(credential("rejected"));
2543 let creds = store
2544 .ensure_rejecting(Arc::new(Mutex::new(())), "rejected")
2545 .await
2546 .unwrap();
2547 assert_eq!(creds.access, "fresh-access");
2548 assert_eq!(creds.refresh, "r0-rotated");
2549 assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 1);
2550 assert_eq!(store.save_calls.load(Ordering::SeqCst), 1);
2551 }
2552
2553 #[tokio::test]
2556 async fn rejected_token_differs_from_disk_uses_disk_without_refresh() {
2557 let store = FakeStore::new(credential("keep"));
2558 let creds = store
2559 .ensure_rejecting(Arc::new(Mutex::new(())), "some-old-token")
2560 .await
2561 .unwrap();
2562 assert_eq!(creds.access, "keep");
2563 assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 0);
2564 assert_eq!(store.save_calls.load(Ordering::SeqCst), 0);
2565 }
2566
2567 #[tokio::test]
2570 async fn concurrent_rejections_single_flight() {
2571 let store = FakeStore::new(credential("rejected"));
2572 let gate = Arc::new(Mutex::new(()));
2573 let waiters: Vec<_> = (0..8)
2574 .map(|_| store.ensure_rejecting(gate.clone(), "rejected"))
2575 .collect();
2576 for got in futures::future::join_all(waiters).await {
2577 assert_eq!(got.unwrap().access, "fresh-access");
2578 }
2579 assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 1);
2580 }
2581
2582 #[tokio::test]
2584 async fn forced_refresh_denied_by_cooldown_spends_nothing() {
2585 let refreshes = AtomicUsize::new(0);
2586 let err = ensure_gated_locked_policy(
2587 Arc::new(Mutex::new(())),
2588 None,
2589 || Ok(credential("rejected")),
2590 |_| async {
2591 refreshes.fetch_add(1, Ordering::SeqCst);
2592 Ok::<_, String>(credential("never"))
2593 },
2594 |_, _| std::future::ready(Ok(CasOutcome::Saved)),
2595 Some("rejected"),
2596 |_| Err("cooldown".to_string().into()),
2597 )
2598 .await
2599 .unwrap_err();
2600 assert_eq!(err.to_string(), "cooldown");
2601 assert_eq!(refreshes.load(Ordering::SeqCst), 0);
2602 }
2603
2604 #[tokio::test]
2608 async fn expired_credential_ignores_force_cooldown() {
2609 for rejected in ["stale", "some-other-token"] {
2610 let creds = ensure_gated_locked_policy(
2611 Arc::new(Mutex::new(())),
2612 None,
2613 || {
2614 let mut c = credential("stale");
2615 c.expires = 0;
2616 Ok(c)
2617 },
2618 |_| async { Ok::<_, String>(credential("new")) },
2619 |_, _| std::future::ready(Ok(CasOutcome::Saved)),
2620 Some(rejected),
2621 |_| Err("cooldown".to_string().into()),
2622 )
2623 .await
2624 .unwrap();
2625 assert_eq!(creds.access, "new", "rejected = {rejected}");
2626 }
2627 }
2628
2629 use super::super::account::Account;
2630 use super::super::provider::OAuthProviderId;
2631 use super::super::storage::save_provider_auth_at;
2632
2633 fn account(label: &str) -> CredentialRef {
2634 CredentialRef::new(OAuthProviderId::Anthropic, Account::named(label).unwrap())
2635 }
2636
2637 fn seed_old(path: &Path, entries: &[(&CredentialRef, &str)]) {
2640 for (cred, access) in entries {
2641 save_provider_auth_at(path, &cred.storage_key(), &credential(access)).unwrap();
2642 }
2643 let old = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
2644 std::fs::File::options()
2645 .write(true)
2646 .open(path)
2647 .unwrap()
2648 .set_modified(old)
2649 .unwrap();
2650 }
2651
2652 async fn forced(
2655 path: &Path,
2656 cred: &CredentialRef,
2657 rejected: &str,
2658 calls: &AtomicUsize,
2659 ) -> Result<OAuthCredentials, String> {
2660 refresh_credential_locked(cred, path, None, Some(rejected), |old| async move {
2661 let n = calls.fetch_add(1, Ordering::SeqCst);
2662 Ok::<_, String>(OAuthCredentials {
2663 auth_type: "oauth".into(),
2664 refresh: format!("{old}-rotated"),
2665 access: format!("minted-{n}"),
2666 expires: crate::epoch_millis() + 3_600_000,
2667 account_id: None,
2668 })
2669 })
2670 .await
2671 .map_err(String::from)
2672 }
2673
2674 #[tokio::test]
2679 async fn forced_refresh_cooldown_is_per_account_and_stops_a_persistent_401() {
2680 let dir = tempfile::tempdir().unwrap();
2681 let path = dir.path().join("auth.json");
2682 let (a, b) = (account("a"), account("b"));
2683 seed_old(&path, &[(&a, "a-rejected"), (&b, "b-rejected")]);
2684 let calls = AtomicUsize::new(0);
2685
2686 let first = forced(&path, &a, "a-rejected", &calls).await.unwrap();
2687 assert_eq!(calls.load(Ordering::SeqCst), 1);
2688 let err = forced(&path, &a, &first.access, &calls).await.unwrap_err();
2689 assert!(err.contains("not refreshing again"), "{err}");
2690 assert!(!err.contains(&first.access), "error must not carry tokens: {err}");
2691 assert_eq!(calls.load(Ordering::SeqCst), 1, "cooldown spends nothing");
2692
2693 forced(&path, &b, "b-rejected", &calls).await.unwrap();
2695 assert_eq!(calls.load(Ordering::SeqCst), 2, "b has its own cooldown");
2696 }
2697
2698 #[tokio::test]
2703 async fn refresh_exchanges_are_recorded_per_slot() {
2704 let dir = tempfile::tempdir().unwrap();
2705 let path = dir.path().join("auth.json");
2706 let (a, b) = (account("a"), account("b"));
2707 seed_old(&path, &[(&a, "a-rejected"), (&b, "b-rejected")]);
2708 let key = a.storage_key();
2709 assert_eq!(last_refresh_exchange(&path, &key), None);
2710
2711 let err = refresh_credential_locked(&a, &path, None, Some("a-rejected"), |_old| async {
2712 Err::<OAuthCredentials, _>(RefreshFailure::status(503, "token endpoint answered 503"))
2713 })
2714 .await
2715 .unwrap_err();
2716 assert_eq!(err.kind, RefreshFailureKind::Transient);
2717 let failed = last_refresh_exchange(&path, &key).expect("failed exchange recorded");
2718 assert_eq!(failed.failure, Some(RefreshFailureKind::Transient));
2719 assert_eq!(failed.http_status, Some(503));
2721
2722 let calls = AtomicUsize::new(0);
2723 let first = forced(&path, &a, "a-rejected", &calls).await.unwrap();
2724 let ok = last_refresh_exchange(&path, &key).expect("exchange recorded");
2725 assert_eq!(ok.failure, None);
2726 assert_eq!(ok.http_status, None);
2727 assert!(ok.at_ms >= failed.at_ms);
2728
2729 forced(&path, &a, &first.access, &calls).await.unwrap_err();
2730 assert_eq!(calls.load(Ordering::SeqCst), 1, "the cooldown refused it");
2731 assert_eq!(last_refresh_exchange(&path, &key), Some(ok));
2732 assert_eq!(last_refresh_exchange(&path, &b.storage_key()), None);
2733 }
2734
2735 fn stamp_as_other_process(path: &Path, cred: &CredentialRef, stamp: &str) {
2738 std::fs::write(refresh_lock_path(path, &cred.storage_key()), stamp).unwrap();
2739 }
2740
2741 fn now_stamp() -> String {
2742 crate::epoch_millis().to_string()
2743 }
2744
2745 #[tokio::test]
2748 async fn other_process_refresh_stamp_blocks_forced_refresh_of_that_account() {
2749 let dir = tempfile::tempdir().unwrap();
2750 let path = dir.path().join("auth.json");
2751 let a = account("a");
2752 seed_old(&path, &[(&a, "a-rejected")]);
2753 stamp_as_other_process(&path, &a, &now_stamp());
2754 let calls = AtomicUsize::new(0);
2755 let err = forced(&path, &a, "a-rejected", &calls).await.unwrap_err();
2756 assert!(err.contains("not refreshing again"), "{err}");
2757 assert_eq!(calls.load(Ordering::SeqCst), 0);
2758 }
2759
2760 #[tokio::test]
2762 async fn refresh_stamp_of_one_account_does_not_block_another() {
2763 let dir = tempfile::tempdir().unwrap();
2764 let path = dir.path().join("auth.json");
2765 let (a, b) = (account("a"), account("b"));
2766 seed_old(&path, &[(&a, "a-rejected"), (&b, "b-rejected")]);
2767 stamp_as_other_process(&path, &a, &now_stamp());
2768 let calls = AtomicUsize::new(0);
2769 forced(&path, &b, "b-rejected", &calls).await.unwrap();
2770 assert_eq!(calls.load(Ordering::SeqCst), 1);
2771 }
2772
2773 #[tokio::test]
2776 async fn foreign_auth_file_write_without_a_stamp_does_not_block() {
2777 let dir = tempfile::tempdir().unwrap();
2778 let path = dir.path().join("auth.json");
2779 let a = account("a");
2780 save_provider_auth_at(&path, &a.storage_key(), &credential("a-rejected")).unwrap();
2782 let calls = AtomicUsize::new(0);
2783 forced(&path, &a, "a-rejected", &calls).await.unwrap();
2784 assert_eq!(calls.load(Ordering::SeqCst), 1);
2785 }
2786
2787 #[tokio::test]
2792 async fn own_write_after_another_process_refreshed_a_does_not_hide_a_s_cooldown() {
2793 let dir = tempfile::tempdir().unwrap();
2794 let path = dir.path().join("auth.json");
2795 let (a, b) = (account("a"), account("b"));
2796 seed_old(&path, &[(&a, "a-new-from-peer"), (&b, "b-rejected")]);
2797 stamp_as_other_process(&path, &a, &now_stamp());
2798 let calls = AtomicUsize::new(0);
2799 forced(&path, &b, "b-rejected", &calls).await.unwrap();
2800 assert_eq!(calls.load(Ordering::SeqCst), 1, "b refreshed and wrote auth.json");
2801 let err = forced(&path, &a, "a-new-from-peer", &calls).await.unwrap_err();
2802 assert!(err.contains("not refreshing again"), "{err}");
2803 assert_eq!(calls.load(Ordering::SeqCst), 1, "a's refresh token not spent again");
2804 }
2805
2806 #[tokio::test]
2809 async fn expired_token_refreshes_inside_the_stamp_cooldown() {
2810 let dir = tempfile::tempdir().unwrap();
2811 let path = dir.path().join("auth.json");
2812 let a = account("a");
2813 let mut expired = credential("a-old");
2814 expired.expires = 0;
2815 save_provider_auth_at(&path, &a.storage_key(), &expired).unwrap();
2816 stamp_as_other_process(&path, &a, &now_stamp());
2817 let calls = AtomicUsize::new(0);
2818 let got = forced(&path, &a, "a-old", &calls).await.unwrap();
2819 assert_eq!(got.access, "minted-0");
2820 assert_eq!(calls.load(Ordering::SeqCst), 1);
2821 }
2822
2823 #[tokio::test]
2825 async fn garbage_or_future_stamps_allow_a_forced_refresh() {
2826 let future = (crate::epoch_millis() + 3_600_000).to_string();
2827 for stamp in ["", "not-a-number", "-5", "12abc", future.as_str()] {
2828 let dir = tempfile::tempdir().unwrap();
2829 let path = dir.path().join("auth.json");
2830 let a = account("a");
2831 seed_old(&path, &[(&a, "a-rejected")]);
2832 stamp_as_other_process(&path, &a, stamp);
2833 let calls = AtomicUsize::new(0);
2834 forced(&path, &a, "a-rejected", &calls)
2835 .await
2836 .unwrap_or_else(|e| panic!("stamp {stamp:?}: {e}"));
2837 assert_eq!(calls.load(Ordering::SeqCst), 1, "stamp {stamp:?}");
2838 }
2839 }
2840
2841 #[tokio::test]
2845 async fn refresh_stamp_survives_an_old_client_style_lock_open() {
2846 use fs4::fs_std::FileExt;
2847 let dir = tempfile::tempdir().unwrap();
2848 let path = dir.path().join("auth.json");
2849 let a = account("a");
2850 seed_old(&path, &[(&a, "a-rejected")]);
2851 let calls = AtomicUsize::new(0);
2852 let first = forced(&path, &a, "a-rejected", &calls).await.unwrap();
2853 let lock = refresh_lock_path(&path, &a.storage_key());
2854 let stamp = std::fs::read_to_string(&lock).unwrap();
2855 let ms: u64 = stamp.parse().expect("plain ASCII epoch ms");
2856 assert!(crate::epoch_millis().saturating_sub(ms) < 60_000, "{stamp}");
2857
2858 let old = std::fs::OpenOptions::new()
2860 .create(true)
2861 .truncate(false)
2862 .write(true)
2863 .open(&lock)
2864 .unwrap();
2865 FileExt::lock_exclusive(&old).unwrap();
2866 drop(old);
2867 assert_eq!(std::fs::read_to_string(&lock).unwrap(), stamp);
2868
2869 let err = forced(&path, &a, &first.access, &calls).await.unwrap_err();
2871 assert!(err.contains("not refreshing again"), "{err}");
2872 assert_eq!(calls.load(Ordering::SeqCst), 1);
2873 }
2874
2875 #[test]
2878 fn refresh_failures_are_typed() {
2879 let a = account("a");
2880 let now = 1_000_000_000;
2881 let f = forced_refresh_allowed(&a, Some(now - 12_000), now).unwrap_err();
2882 assert_eq!(f.kind, RefreshFailureKind::Cooldown { retry_in_secs: 48 });
2883 assert!(f.message().contains("not refreshing again for 48s"), "{f}");
2884 assert!(forced_refresh_allowed(&a, Some(now - 60_000), now).is_ok());
2885
2886 use RefreshFailureKind::Credential;
2887 assert_eq!(RefreshFailure::from("No credentials".to_string()).kind, Credential);
2889 }
2890}
2891
2892#[cfg(test)]
2895pub(crate) mod fake_http {
2896 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2897
2898 pub(crate) fn response(status: &str, body: &str) -> String {
2901 format!(
2902 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\
2903 Connection: close\r\n\r\n{body}",
2904 body.len()
2905 )
2906 }
2907
2908 fn request_complete(got: &[u8]) -> bool {
2909 let Some(end) = got.windows(4).position(|w| w == b"\r\n\r\n") else {
2910 return false;
2911 };
2912 let head = String::from_utf8_lossy(&got[..end]).to_ascii_lowercase();
2913 let len = head
2914 .lines()
2915 .find_map(|l| l.strip_prefix("content-length:"))
2916 .and_then(|v| v.trim().parse::<usize>().ok())
2917 .unwrap_or(0);
2918 got.len() >= end + 4 + len
2919 }
2920
2921 pub(crate) async fn serve(raw: String) -> String {
2923 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2924 let addr = listener.local_addr().unwrap();
2925 tokio::spawn(async move {
2926 while let Ok((mut sock, _)) = listener.accept().await {
2927 let raw = raw.clone();
2928 tokio::spawn(async move {
2929 let mut got = Vec::new();
2930 let mut buf = [0u8; 4096];
2931 while !request_complete(&got) {
2932 match sock.read(&mut buf).await {
2933 Ok(0) | Err(_) => break,
2934 Ok(n) => got.extend_from_slice(&buf[..n]),
2935 }
2936 }
2937 let _ = sock.write_all(raw.as_bytes()).await;
2938 let _ = sock.shutdown().await;
2939 });
2940 }
2941 });
2942 format!("http://{addr}")
2943 }
2944
2945 pub(crate) async fn dead_url() -> String {
2947 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2948 let addr = listener.local_addr().unwrap();
2949 drop(listener);
2950 format!("http://{addr}")
2951 }
2952}
2953
2954#[cfg(test)]
2957mod source_classification_tests {
2958 use super::fake_http::{dead_url, response, serve};
2959 use super::*;
2960 use RefreshFailureKind::{Credential, Transient};
2961
2962 #[test]
2963 fn status_kinds() {
2964 for s in [408, 425, 429, 500, 502, 503, 504] {
2965 assert_eq!(status_kind(s), Transient, "{s}");
2966 }
2967 for s in [400, 401, 403, 404] {
2968 assert_eq!(status_kind(s), Credential, "{s}");
2969 }
2970 }
2971
2972 async fn anthropic(raw: String) -> RefreshFailure {
2973 let url = format!("{}/v1/oauth/token", serve(raw).await);
2974 refresh_token_at(&Client::new(), &url, "rt").await.unwrap_err()
2975 }
2976
2977 #[tokio::test]
2978 async fn anthropic_refresh_failures_are_typed_at_the_source() {
2979 let portal = anthropic(response("200 OK", "<html>sign in to wifi</html>")).await;
2981 assert_eq!(portal.kind, Transient, "{portal}");
2982 let cut = anthropic(
2984 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 200\r\n\
2985 Connection: close\r\n\r\n{\"access_t"
2986 .to_string(),
2987 )
2988 .await;
2989 assert_eq!(cut.kind, Transient, "{cut}");
2990 let timeout = anthropic(response("408 Request Timeout", "{}")).await;
2991 assert_eq!(timeout.kind, Transient, "{timeout}");
2992 let busy = anthropic(response("503 Service Unavailable", "<html/>")).await;
2993 assert_eq!(busy.kind, Transient, "{busy}");
2994 let revoked = anthropic(response(
2996 "400 Bad Request",
2997 r#"{"error":"invalid_grant","error_description":"revoked"}"#,
2998 ))
2999 .await;
3000 assert_eq!(revoked.kind, Credential, "{revoked}");
3001 assert!(revoked.message().contains("invalid_grant"), "{revoked}");
3002 let url = format!("{}/v1/oauth/token", dead_url().await);
3004 let down = refresh_token_at(&Client::new(), &url, "rt").await.unwrap_err();
3005 assert_eq!(down.kind, Transient, "{down}");
3006 }
3007
3008 #[test]
3011 fn provider_errors_carry_their_kind() {
3012 use super::super::google_gemini::GeminiAuthError as G;
3013 assert_eq!(G::TokenRequestFailed(503).refresh_failure().kind, Transient);
3014 assert_eq!(G::TokenRequestFailed(400).refresh_failure().kind, Credential);
3015 assert_eq!(G::Transport.refresh_failure().kind, Transient);
3016 assert_eq!(G::EmptyRefreshToken.refresh_failure().kind, Credential);
3017
3018 use super::super::muse::MuseAuthError as M;
3019 assert_eq!(M::LoginRejected(401).refresh_failure().kind, Credential);
3020 assert_eq!(M::HttpStatus(503).refresh_failure().kind, Transient);
3021 assert_eq!(M::Transport.refresh_failure().kind, Transient);
3022 assert_eq!(M::InvalidMintResponse.refresh_failure().kind, Transient);
3023 assert_eq!(M::OnboardingRequired.refresh_failure().kind, Credential);
3024 assert_eq!(M::PaymentRequired.refresh_failure().kind, RefreshFailureKind::ActionRequired);
3026
3027 use super::super::kimi_code::KimiAuthError as K;
3028 assert_eq!(K::Unauthorized.refresh_failure().kind, Credential);
3029 assert_eq!(K::HttpStatus(502).refresh_failure().kind, Transient);
3030 assert_eq!(K::Transport.refresh_failure().kind, Transient);
3031
3032 use super::super::github_copilot::CopilotAuthError as C;
3033 assert_eq!(C::SessionMintRejected(401).refresh_failure().kind, Credential);
3034 assert_eq!(C::HttpStatus(500).refresh_failure().kind, Transient);
3035 assert_eq!(C::Transport.refresh_failure().kind, Transient);
3036 }
3037
3038 #[tokio::test]
3039 async fn gemini_503_is_transient_and_invalid_grant_is_credential() {
3040 use super::super::google_gemini::{refresh_with_registration_typed, GeminiRegistration};
3041 let reg = GeminiRegistration::test("client-id", None).unwrap();
3042 let run = |raw: String| {
3043 let reg = ®
3044 async move {
3045 let url = format!("{}/token", serve(raw).await);
3046 refresh_with_registration_typed(&Client::new(), &url, true, reg, "rt")
3047 .await
3048 .unwrap_err()
3049 }
3050 };
3051 let busy = run(response("503 Service Unavailable", "{}")).await;
3052 assert_eq!(busy.kind, Transient, "{busy}");
3053 assert!(busy.message().contains("(HTTP 503)"), "{busy}");
3054 let revoked = run(response("400 Bad Request", r#"{"error":"invalid_grant"}"#)).await;
3055 assert_eq!(revoked.kind, Credential, "{revoked}");
3056 }
3057
3058 #[tokio::test]
3059 async fn xai_discovery_failure_is_transient() {
3060 let url = format!("{}/.well-known/openid-configuration", dead_url().await);
3061 let err = super::super::xai::discover_at(&url).await.unwrap_err();
3062 assert_eq!(err.kind, Transient, "{err}");
3063 assert!(err.message().contains("discovery failed"), "{err}");
3064 }
3065
3066 #[tokio::test]
3067 async fn codex_refresh_failures_are_typed() {
3068 let run = |raw: String| async move {
3069 let url = format!("{}/oauth/token", serve(raw).await);
3070 super::super::openai_codex::token_request_at(&Client::new(), &url, &[("a", "b")])
3071 .await
3072 .unwrap_err()
3073 };
3074 assert_eq!(run(response("502 Bad Gateway", "")).await.kind, Transient);
3075 assert_eq!(run(response("200 OK", "<html/>")).await.kind, Transient);
3076 let revoked = run(response("401 Unauthorized", r#"{"error":"invalid_grant"}"#)).await;
3077 assert_eq!(revoked.kind, Credential);
3078 assert!(revoked.message().contains("invalid_grant"), "{revoked}");
3079 let echoed = run(response(
3082 "400 Bad Request",
3083 "grant_type=refresh_token&refresh_token=SECRET-RT-123",
3084 ))
3085 .await;
3086 assert!(!echoed.message().contains("SECRET-RT-123"), "{echoed}");
3087 let nested = run(response(
3088 "400 Bad Request",
3089 r#"{"error":"invalid_request","error_description":"bad","echo":"SECRET-RT-123"}"#,
3090 ))
3091 .await;
3092 assert!(!nested.message().contains("SECRET-RT-123"), "{nested}");
3093 assert!(nested.message().contains("invalid_request: bad"), "{nested}");
3094 }
3095}