Skip to main content

agent_core/core/auth/
token.rs

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
16/// Exchange an authorization code for access + refresh tokens.
17pub 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        // Server responses may contain sensitive data (a proxy echoing the
53        // code/verifier, internal errors): keep only the OAuth error fields.
54        // Byte slicing here also panicked on a non-ASCII body.
55        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    // expires_in is seconds; store as epoch millis with 5-minute buffer (matches Pi/Claude Code)
69    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
80/// Refresh an expired OAuth token. Failures are typed at the source: no
81/// response or an unusable success body is transient, a status is
82/// classified by [`status_kind`].
83pub 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    // A 200 whose body is not a token response (captive portal HTML, a
124    // connection cut mid-body) says nothing about the login.
125    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
141/// Reduce an OAuth token-endpoint error body to its RFC 6749 `error` /
142/// `error_description` fields (each capped at 120 chars, char-boundary safe).
143/// Anything else — HTML from a captive portal, a proxy echoing the request
144/// (which carries the refresh token) — is dropped, never surfaced.
145pub(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    // Anthropic nests as {"error":{"type":..,"message":..}}; RFC 6749 is flat.
153    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
172/// Acquire the Anthropic refresh gate, check token freshness, refresh if
173/// needed, and persist the result atomically. Returns the current (possibly
174/// refreshed) credentials.
175///
176/// Anthropic now flows through the exact same single-flight gate + atomic
177/// merge-persistence path as every other OAuth provider
178/// (`ensure_fresh_provider_token`), so concurrent async callers can never
179/// double-rotate the refresh token, and the write preserves every other
180/// provider entry (fixes #184) via `save_provider_auth` → tmp+rename(2).
181pub 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
185/// Process-wide refresh gates keyed by `(credential file, storage key)`. The
186/// file lock protects persistence and cross-process rotation; these gates
187/// additionally keep concurrent async callers in this process from rotating
188/// the same refresh token while the network request is in flight. Two
189/// accounts of one provider have independent gates.
190fn 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
202/// Ensure an OAuth provider (including Anthropic) has a fresh token in its
203/// **default** account slot. Equivalent to
204/// [`ensure_fresh_credential`] with [`CredentialRef::default_for`].
205pub 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
217/// Ensure one addressed credential (provider + account) has a fresh token.
218///
219/// Single-flight per credential: concurrent async callers in this process
220/// serialize on a `(file, storage key)` gate, and concurrent *processes*
221/// serialize on an exclusive `flock` of `<auth.json>.<key>.refresh.lock`
222/// held across load → refresh → save, so exactly one party ever rotates a
223/// given refresh token. Different accounts never wait on each other.
224///
225/// The rotated credential is written compare-and-swap: if the slot was
226/// removed or re-logged-in while the network call was in flight, the stale
227/// rotation is not written. A removed slot is an error; a replaced slot is
228/// re-loaded and, if needed, refreshed on its own (bounded) so the returned
229/// credential is always fresh and always the one that is actually stored.
230pub async fn ensure_fresh_credential(
231    client: &Client,
232    cred: &CredentialRef,
233) -> std::result::Result<OAuthCredentials, String> {
234    // The rotation is written back to the file the refresh token was READ
235    // from. With an active profile that has no auth.json yet, reads fall
236    // back to the base file — the rotated token must return there, never be
237    // forked into a fresh profile copy (the base copy would be dead).
238    let path = auth_file_path();
239    ensure_fresh_credential_at(client, cred, &path).await
240}
241
242/// Why a seat-pinned vend ([`ensure_fresh_credential_for_seat`]) produced no
243/// token.
244#[derive(Debug)]
245pub enum SeatVendError {
246    /// The slot no longer holds the seat the caller's evidence was about
247    /// (re-login, removal, or rotation by another party). Nothing was vended.
248    SeatChanged,
249    /// The refresh failed; typed so a transient failure is never reported
250    /// as an unusable login.
251    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/// Why a locked refresh produced no token, so callers can tell the user
266/// the right thing: only [`RefreshFailureKind::Credential`] means the login
267/// itself is unusable ("run `synaps login`").
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum RefreshFailureKind {
270    /// A forced (401-driven) refresh was refused by the per-account
271    /// [`FORCED_REFRESH_COOLDOWN`].
272    Cooldown { retry_in_secs: u64 },
273    /// Another refresh of this account (this process or another) did not
274    /// finish within the lock wait.
275    LockBusy,
276    /// The provider exchange failed for a transient reason: network, a
277    /// timeout, or a 5xx / 429 from the token endpoint.
278    Transient,
279    /// The stored credential is missing, removed or rejected by the provider.
280    Credential,
281    /// Local failure (lock file I/O, the refresh task itself).
282    Other,
283    /// The user must act outside synaps (a corrupt credential store, a
284    /// provider billing requirement); the message says exactly what. Not
285    /// retryable, and logging in again does not help.
286    ActionRequired,
287}
288
289impl RefreshFailureKind {
290    /// Retrying later can succeed without the user doing anything: a
291    /// transient provider/network failure, a busy refresh lock, or a local
292    /// failure. The single definition the broker and the runtime share.
293    pub fn is_retryable(self) -> bool {
294        matches!(self, Self::Transient | Self::LockBusy | Self::Other)
295    }
296}
297
298/// A refresh failure: its kind plus a log-safe message (our own texts, or
299/// provider errors that went through the provider's sanitizer) and, when
300/// the token endpoint answered with a failure, its HTTP status. Never
301/// carries token material.
302#[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    /// The token endpoint's HTTP status, when it answered with a failure.
323    pub fn http_status(&self) -> Option<u16> {
324        self.http_status
325    }
326
327    /// The same failure carrying `status` (kept when a caller rewords the
328    /// message).
329    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
341/// Storage / load / CAS errors (strings from the storage layer and our own
342/// slot checks) are credential problems.
343impl From<String> for RefreshFailure {
344    fn from(message: String) -> Self {
345        Self::new(RefreshFailureKind::Credential, message)
346    }
347}
348
349/// A store lock that stayed held past its deadline is a local failure
350/// (`Other`, retry shortly); any other store failure keeps the credential
351/// classification.
352impl From<StoreError> for RefreshFailure {
353    fn from(e: StoreError) -> Self {
354        match e {
355            // The credential store's own lock, not this account's refresh
356            // lock: its message names `auth.json.lock`.
357            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
370/// Kind of a non-success token-endpoint status: request timeout, too
371/// early, rate limit and 5xx are transient; every other status (400
372/// `invalid_grant`, 401, 403, ...) means the login was rejected.
373pub 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    /// The request produced no usable response (connect, TLS, timeout, a
382    /// body cut mid-transfer).
383    pub fn transport(message: impl Into<String>) -> Self {
384        Self::new(RefreshFailureKind::Transient, message)
385    }
386
387    /// The token endpoint answered `status`; see [`status_kind`].
388    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    /// A success status whose body is not a usable token response.
393    pub fn bad_response(message: impl Into<String>) -> Self {
394        Self::new(RefreshFailureKind::Transient, message)
395    }
396
397    /// The stored login is missing or was rejected.
398    pub fn credential(message: impl Into<String>) -> Self {
399        Self::new(RefreshFailureKind::Credential, message)
400    }
401
402    /// A local or configuration failure (pinning, client build).
403    pub fn other(message: impl Into<String>) -> Self {
404        Self::new(RefreshFailureKind::Other, message)
405    }
406}
407
408/// [`ensure_fresh_credential`] for a slot whose stored credential the caller
409/// already inspected (e.g. read its usage): the slot must still hold the same
410/// [`SeatIdentity`], checked under the same locks as the refresh, so the
411/// returned token is derived from exactly that seat — never from a
412/// credential that replaced it in between. `endpoints` are the broker's
413/// refresh test seams. A failure stays typed ([`SeatVendError::Other`]).
414pub(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
432/// Path-explicit core of [`ensure_fresh_credential`] (tests use temp dirs).
433pub(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
459/// Recovery is broker-owned: no refresh credentials cross into the runtime.
460/// Typed so the caller can tell a cooldown / busy lock / network failure
461/// from an unusable login.
462pub(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
472/// [`ensure_fresh_credential`] through `endpoints` (the broker's test
473/// seams).
474pub(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    // Cheap pre-check without gate, file lock or task: the common
491    // fresh-token path stays a single file read. The locked core re-loads
492    // and re-decides under both locks.
493    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
518/// Run the locked refresh DETACHED from the caller, on the process-lifetime
519/// [`refresh_runtime`]. If the caller is cancelled (Esc mid-request, a
520/// dropped turn) or its whole runtime is dropped (a subagent worker's
521/// throwaway `current_thread` runtime on timeout/cancel) after the provider
522/// has rotated the refresh token but before the CAS save, the refresh still
523/// completes and persists it — dropping a rotated refresh token would log
524/// the user out. The lock poller's `spawn_blocking` also lives on that
525/// runtime, so tearing down a caller's runtime never waits on it.
526async 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
552/// Small multi-thread runtime that lives for the whole process and runs
553/// every locked refresh (gate, flock poller, provider exchange, CAS save).
554/// Callers on any runtime just await the `JoinHandle`. If it cannot be
555/// started (thread spawn failure under resource exhaustion) the refresh
556/// fails with [`RefreshFailureKind::Other`] instead of aborting the process,
557/// and the next refresh tries to build it again (only success is kept, so a
558/// long-lived daemon recovers once resources free up).
559fn 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    // Lives for the process, like the OnceLock it replaces.
583    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    // Seat pairing is re-checked on every load, including the re-load
603    // after a CAS `Replaced` (re-login mid-refresh): the replacement is
604    // a different seat and was never proven.
605    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/// The last token refresh exchange of one credential slot in this process
614/// (`synaps auth state`). `failure: None` = the provider issued a token.
615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616pub struct RefreshExchange {
617    pub at_ms: u64,
618    pub failure: Option<RefreshFailureKind>,
619    /// The token endpoint's HTTP status when it answered with a failure.
620    pub http_status: Option<u16>,
621}
622
623/// Process-wide, keyed by credential file + storage key; written only where
624/// an exchange with a token endpoint actually happened (a fresh-token read,
625/// a peer's rotation found under the lock or a refused forced refresh
626/// records nothing).
627static 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
649/// The last refresh exchange of `storage_key` in the credential file at
650/// `path`, in this process.
651pub 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
659/// Gate → per-credential cross-process lock → CAS save, plus the forced
660/// refresh rate limit. `refresh` is the provider exchange (injected so the
661/// whole production composition is testable without a network). Each
662/// exchange's outcome is recorded for `synaps auth state`.
663async 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    // Off the refresh runtime's two workers (the store lock is polled with
690    // a blocking sleep), with the long post-rotation deadline.
691    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
722/// Minimum interval between two *forced* (401-driven) refreshes of the same
723/// credential. If the provider rejects a token minted less than this long
724/// ago, a new token will not help (org / permission / header problem) and
725/// spending the refresh token again only widens the rotation race window.
726///
727/// Per account, across processes: every successful refresh stamps its time
728/// into that account's own lock file (`auth.json.<storage_key>.refresh.lock`)
729/// while holding the lock, and the check reads it under the same lock. A
730/// refresh or `synaps login` of account A therefore never cools down
731/// account B, in this process or any other. This is a rate limit only; the
732/// flock + CAS save are what prevent a double spend.
733pub(crate) const FORCED_REFRESH_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(60);
734
735/// Rate limit for forcing a refresh of a not-yet-expired token.
736/// `last_refresh_ms` is the epoch-ms stamp read from this account's lock
737/// file (any process's last successful refresh of THIS account). Refused
738/// only if that is less than [`FORCED_REFRESH_COOLDOWN`] ago; a missing,
739/// unparsable or future-dated stamp allows. Only consulted while storage
740/// still holds the rejected token, so an external `synaps login` that
741/// changes the token never hits it.
742fn 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
766/// Per-credential cross-process lock file, beside the credential file.
767/// Storage keys only contain `[a-z0-9._@-]`, so they are safe path fragments.
768fn 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
776/// Hard deadline for one provider refresh HTTP exchange while the
777/// per-credential lock is held. Bounds the lock hold time regardless of the
778/// caller's HTTP client configuration.
779const REFRESH_HTTP_DEADLINE: std::time::Duration = std::time::Duration::from_secs(60);
780
781/// Bounded wait for the cross-process refresh lock (another process may be
782/// mid-rotation). Strictly longer than `REFRESH_HTTP_DEADLINE` plus the
783/// persistence step, so a healthy peer always finishes before a waiter
784/// gives up; a wedged peer cannot hang this process forever.
785const REFRESH_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
786
787/// Deadline for the CAS save that follows a provider exchange. The
788/// provider may already have rotated (spent) the old refresh token, so
789/// giving up loses the login: wait as long as any refresh-lock waiter would.
790/// Login and static-key saves keep the short `STORE_LOCK_WAIT`.
791const REFRESH_SAVE_WAIT: std::time::Duration = REFRESH_LOCK_WAIT;
792
793/// Sidecar for a rotation whose save failed, beside the account's lock.
794fn pending_path(lock_path: &Path) -> PathBuf {
795    lock_path.with_extension("pending")
796}
797
798/// The save after a rotation failed. Park `fresh` in the 0600 sidecar
799/// (under the held refresh lock) and hand it out; the next locked load
800/// applies it. Without a lock, or if parking fails too, the rotation is lost.
801fn 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
831/// Guard holding the exclusive `flock`; dropping it releases the lock.
832///
833/// The lock file's content is this account's last-refresh stamp (epoch ms,
834/// plain ASCII), read and written only while the lock is held. Older
835/// clients open the same file with `truncate(false)` and never write it, so
836/// the stamp survives them.
837struct RefreshFileLock {
838    file: std::fs::File,
839    /// Lock file path: keys the in-process [`refresh_floor`].
840    path: PathBuf,
841}
842
843/// In-process last-refresh floor per refresh-lock path, recorded only when
844/// the on-disk stamp could not be written. Without it a failed stamp
845/// (ENOSPC, a read-only lock file) would silently switch the forced-refresh
846/// cooldown off and every 401 would rotate / re-mint again.
847fn 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    /// Last successful refresh of this account: the on-disk stamp (any
854    /// process), or this process's floor if that is later.
855    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    /// Record a successful refresh. Never fails a refresh that is already
874    /// persisted: if the stamp cannot be written, warn and keep the time
875    /// in the in-process floor instead.
876    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    /// Write `{expected_refresh, credential}` to the 0600 sidecar
895    /// (tmp + rename). Only called while holding this lock.
896    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    /// CAS a parked rotation into the store. Applied or stale (the slot was
915    /// re-logged-in or removed) deletes the sidecar. Otherwise the sidecar is
916    /// kept and this fails (with the parked credential, so the caller may
917    /// serve its access token), so the spent refresh token it replaced is
918    /// never used:
919    /// - store missing (moved aside): ActionRequired, never deleted;
920    /// - corrupt store: ActionRequired, keeping the repair text;
921    /// - anything else: Other, retry shortly.
922    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
983/// Acquire the per-credential refresh lock off the async runtime (flock
984/// blocks). Polls `try_lock_exclusive` so a wedged peer cannot hang this
985/// process forever.
986async 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        // Never truncate on open: the content is the refresh stamp, and
999        // must also survive older clients' opens of the same lock.
1000        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/// Generic single-flight refresh core. Separated from network/storage so the
1039/// concurrency, rotation, and failure invariants are directly testable.
1040///
1041/// Contract:
1042/// 1. Exactly one refresh runs at a time per gate; waiters re-`load` after the
1043///    gate and observe the rotated credential without a second refresh.
1044/// 2. `save` runs only after a successful refresh (failures persist nothing).
1045/// 3. The refreshed credential is persisted before the gate is released.
1046///
1047/// Production goes through [`ensure_fresh_gated_locked`] (same contract plus
1048/// the cross-process lock and CAS save); this variant remains as the
1049/// reference core for the concurrency tests.
1050#[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    // Re-read after entering the gate: a preceding waiter may have refreshed
1065    // and atomically persisted a rotated refresh token.
1066    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
1075/// Upper bound on load→refresh→CAS rounds when the slot keeps being replaced
1076/// underneath us (re-login storms). Two rounds cover the realistic case.
1077const MAX_CAS_ROUNDS: usize = 2;
1078
1079/// Single-flight refresh with an optional cross-process file lock and a
1080/// compare-and-swap save. Extends [`ensure_fresh_gated`]'s contract with:
1081/// 4. The cross-process lock is held for the whole load → refresh → save
1082///    cycle, so two processes never rotate the same refresh token.
1083/// 5. `save(old_refresh, fresh)` reports whether the slot still held
1084///    `old_refresh`. `Removed` is an error (nothing is resurrected);
1085///    `Replaced` re-loads the stored credential and, if it is expired,
1086///    refreshes *that* one — bounded — so the result is never a stale or
1087///    orphaned credential.
1088/// 6. Metadata (`accountId`) is carried over when the provider's refresh
1089///    response omits it.
1090#[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/// `rejected` is an access token the provider just refused (HTTP 401). If
1114/// the stored credential still carries it, a refresh is forced even though
1115/// its expiry is in the future (revoked-but-unexpired) — provided
1116/// `may_force` allows it (per-credential rate limit,
1117/// [`FORCED_REFRESH_COOLDOWN`]; it receives the last-refresh stamp read from
1118/// the held lock file, `None` without a lock or stamp). An expired
1119/// credential always refreshes without consulting `may_force`. Every
1120/// successful save stamps the lock file before the lock is released.
1121#[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/// [`ensure_gated_locked_policy`] plus `serve_parked`: while a parked
1145/// sidecar can't be applied, hand out its still-valid ACCESS token (never
1146/// its refresh token). Only for callers without a seat expectation: the
1147/// parked credential never went through `load`'s seat check.
1148#[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    // In-process waiters are bounded like cross-process ones.
1169    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    // A rotation an earlier save could not persist: apply it before
1182    // anything else, so its spent refresh token is never sent again.
1183    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        // Re-read under both locks: a preceding waiter (or another process)
1200        // may have refreshed and atomically persisted a rotated token.
1201        let creds = load()?;
1202        // A peer already rotated/replaced the rejected token: adopt it, do
1203        // not force another rotation. Otherwise refresh even if unexpired.
1204        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            // Forced (rejection-driven) refresh of an unexpired credential.
1211            may_force(file_lock.as_ref().and_then(RefreshFileLock::last_refresh_ms))?;
1212        }
1213        let old_refresh = creds.refresh.clone();
1214        // The provider exchange is deadline-bounded so the lock hold time is
1215        // bounded too (a slow-drip response cannot block every other process).
1216        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                // The file vanished mid-refresh (moved aside): park the only
1236                // live token rather than drop it.
1237                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                // A re-login replaced this slot mid-refresh: the rotation we
1252                // hold is orphaned. Loop: load the replacement and refresh it
1253                // only if it is actually expired.
1254                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    // ── Bug #2 atomicity regression suite ─────────────────────────────────────
1280    //
1281    // Root cause (pre-fix): ensure_fresh_token used seek(0)+set_len(0)+write_all
1282    // on the live auth.json file (token.rs ~139-168). A crash between truncate
1283    // and write completion zeroed the file. Because Anthropic rotates the
1284    // refresh token on every refresh, the zeroed-file state is unrecoverable.
1285    //
1286    // Fix: the write goes through save_provider_auth → save_provider_auth_at
1287    // (storage.rs), which does write-to-tmp + rename(2). rename(2) is atomic:
1288    // the file is either the old content or the new content, never empty.
1289    //
1290    // These tests exercise save_provider_auth_at directly (the same function
1291    // ensure_fresh_token now delegates to) and prove the atomicity contract.
1292
1293    /// After a successful save, auth.json must not be empty or zero-length.
1294    /// This would be violated by the old truncate-in-place path on a crash.
1295    #[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    /// The write must go through a .json.tmp file, not truncate-in-place.
1306    /// We prove this by checking that after a successful save the tmp file
1307    /// is gone (renamed → auth.json) and the final file is valid JSON.
1308    /// The tmp file should not persist after a successful write.
1309    #[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    /// The final file must be valid JSON with the correct credential.
1323    /// Truncate-in-place failure would leave `{}` or `null`; atomic rename
1324    /// either keeps old content or produces fully-written new content.
1325    #[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    /// Refresh write must preserve other providers (fixes #184 / AuthFile
1342    /// 2-field issue). Old code rebuilt `AuthFile { anthropic, openai_codex }`
1343    /// which drops any 3rd provider; new code does read-merge-write.
1344    #[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        // Simulate state after two prior logins
1350        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        // Simulate ensure_fresh_token writing the refreshed anthropic credential
1356        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    // ── Single-flight refresh invariants (concurrency / rotation / failure) ──
1376    //
1377    // These drive `ensure_fresh_gated` — the exact core `ensure_fresh_token`
1378    // (Anthropic) and `ensure_fresh_provider_token` (all providers) execute —
1379    // with injected load/refresh/save so no network or real auth.json is
1380    // involved.
1381
1382    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    /// Shared fake persistence: models auth.json for a single provider.
1397    #[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                    // Rotate: new refresh token derived from the one presented.
1425                    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    /// Concurrency: N concurrent callers with an expired credential perform
1444    /// exactly ONE network refresh; everyone observes the rotated result.
1445    #[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    /// Rotation: the persisted refresh token is the rotated one, and a later
1469    /// expiry cycle refreshes FROM the rotated token (never the stale one).
1470    #[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        // Force expiry again; the second cycle must present the rotated token.
1478        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    /// Freshness: an unexpired credential is returned without refresh or save.
1485    #[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        // A proxy echoing the request body must not leak the refresh token.
1507        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        // Multi-byte chars at the cap boundary never panic.
1512        let long = format!(r#"{{"error":"{}"}}"#, "é".repeat(500));
1513        assert_eq!(s(&long).chars().count(), 120);
1514        // The pre-sanitizer exchange path byte-sliced at 200, which panics
1515        // when a multi-byte char straddles the cut.
1516        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    /// Failure: a refresh error propagates and persists nothing — the stored
1521    /// credential is untouched so a later retry can still present it.
1522    #[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    // ── Account-aware refresh: gates, cross-process lock, CAS (G1) ───────────
1546
1547    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    /// Drives `ensure_fresh_gated_locked` against a real temp auth.json with a
1560    /// fake provider that rotates tokens and records which refresh token it
1561    /// was presented.
1562    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    /// Two accounts of one provider refresh independently and concurrently:
1599    /// each rotates exactly once from its own refresh token, and neither
1600    /// write disturbs the other slot (or the default slot).
1601    #[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    /// Cross-process rotation lock: while another "process" holds the
1634    /// per-credential flock, this refresh waits; a second account's lock is
1635    /// unaffected.
1636    #[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        // Foreign holder of account a's lock (separate fd, like another process).
1644        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        // Account b proceeds immediately.
1655        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        // Account a blocks until the holder releases.
1662        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        // Simulate the other process finishing its rotation before releasing.
1673        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    /// Removal during refresh: the stale rotation is never written back and
1684    /// the caller gets an error rather than a resurrected slot.
1685    #[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    /// Re-login during refresh: the newly stored credential wins; the
1708    /// orphaned rotation is dropped and the result is the fresh replacement.
1709    #[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    /// Re-login during refresh with an already-expired replacement: the
1732    /// replacement itself is refreshed (bounded) — never an expired result.
1733    #[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    /// The rotated credential inherits `accountId` when the provider's
1756    /// refresh response omits it (Codex header pairing must survive refresh).
1757    #[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    /// Seat-pinned vend: the slot must still hold the inspected seat. A
1780    /// re-login (different refresh material / account id) yields the typed
1781    /// `SeatChanged` error and no token; the check never needs the network
1782    /// for a fresh credential.
1783    #[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        // Same seat, rotated refresh token (another process refreshed): the
1798        // account id anchors the identity, so the vend still pairs.
1799        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        // Re-login with another seat under the same alias: refused.
1808        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        // Unchecked vend of the same slot still works (explicit selection).
1817        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        // Removed slot: the ordinary load-miss error, not a seat mismatch.
1825        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    /// Cancel safety: the caller of a refresh is dropped (Esc) while the
1837    /// provider exchange is in flight; the detached task still completes
1838    /// and persists the rotated token, so it is never lost.
1839    #[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        // Cancel the caller mid-exchange, as Esc does via `await_or_cancel`.
1874        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    /// A slow fake refresh of account `label` in `path`, as a 'static future
1895    /// handed to `run_detached` (owned path and key, like production).
1896    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    /// Review I1: subagent workers run on throwaway `current_thread`
1942    /// runtimes. Dropping one mid-exchange must neither kill the refresh
1943    /// (the rotated token is still saved) nor block on it.
1944    #[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); // the worker timed out / was cancelled mid-exchange
1963        assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed());
1964        wait_for_rotation(&path, "a", "a-r-rotated");
1965    }
1966
1967    /// Same, while the refresh is still waiting in the flock poller behind a
1968    /// peer process: dropping the worker returns promptly (the poller is not
1969    /// on the worker's blocking pool), and the refresh completes once the
1970    /// peer releases the lock.
1971    #[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    /// Ported from dev's `refresh_file_lock_excludes_second_holder_until_released`
2001    /// (#237/#238): a second holder of the same refresh lock (flock is per
2002    /// open file description, so this contends exactly like another process)
2003    /// times out as `LockBusy`, and the lock is acquirable again once the
2004    /// first holder releases it.
2005    #[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    /// The fresh-token fast path never takes the cross-process lock: a
2022    /// foreign holder does not delay a vend of an unexpired credential.
2023    #[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        // The default Anthropic slot shares the lock name used by pre-#215
2055        // builds (`auth.json.<provider>.refresh.lock`), so an older client
2056        // still running alongside serializes on the same file.
2057        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    /// Round-2 m-g: a failed runtime build is not cached; the next call
2111    /// builds again, and a success is kept.
2112    #[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    /// m4: a stamp that cannot be written (ENOSPC, read-only lock file)
2127    /// must not switch the cooldown off: the in-process floor keeps it.
2128    #[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        // Read-only handle: every stamp write fails.
2134        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        // Another account's lock never sees this floor.
2147        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    /// Round-2 I2: a corrupt store is not a login problem (login would fail
2157    /// on it too): the refresh says what to do, typed ActionRequired.
2158    #[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    /// m3: a refresh runtime that failed to start is a refresh error, not
2171    /// a process abort (release builds use `panic = "abort"`).
2172    #[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        // A started runtime still runs the task.
2183        let ok: Result<u8, RefreshFailure> =
2184            run_detached_on(refresh_runtime(), async { Ok(7) }).await;
2185        assert_eq!(ok.unwrap(), 7);
2186    }
2187
2188    /// I1 (round 2): the save after a rotation must not lose the rotated
2189    /// token. A failed save parks it in a 0600 sidecar under the account's
2190    /// refresh lock; the caller still gets the fresh token, and the next
2191    /// locked load applies the sidecar instead of spending the old refresh
2192    /// token again.
2193    #[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        // Next locked load: the sidecar is applied; the spent "r0" is never
2242        // sent again.
2243        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    /// Round 3: an expired stored credential, a parked sidecar and a save
2251    /// that keeps failing with `save_err`.
2252    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    /// R2-m3a: while the sidecar can't be applied, its still-valid access
2290    /// token is served (never its refresh token); the sidecar stays and the
2291    /// spent token is not sent.
2292    #[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        // Not served: seat-pinned callers, a parked token that was just
2303        // rejected, an expired one.
2304        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    /// R2-m3b: a corrupt store behind the sidecar is ActionRequired with the
2317    /// mv text, not "retry shortly".
2318    #[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    /// R2-m3c: after the user follows the mv advice, the store is missing.
2336    /// The sidecar (the only live refresh token) is kept and the user is
2337    /// told, never deleted as "removed".
2338    #[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    /// A store that vanishes between the provider exchange and the save
2350    /// parks the rotation instead of dropping it.
2351    #[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    /// A sidecar the store has moved past (re-login, removal) is dropped;
2373    /// one that can't be applied yet blocks the refresh instead of spending
2374    /// the old token.
2375    #[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        // Store moved on: Replaced -> sidecar deleted, nothing written.
2388        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        // Store still busy: error, sidecar kept for the next try.
2397        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    /// A store-lock timeout names the store lock (it is not another refresh
2410    /// of this account), and the refresh save waits far longer than a login
2411    /// or static-key save.
2412    #[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    // ── Forced-refresh rate limit (ported from #237/#238, now per account) ──
2488
2489    /// Single-credential fake store driven through the production policy.
2490    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    /// Revoked-but-unexpired: the stored credential still holds the exact
2539    /// access token the provider rejected -> refresh is forced.
2540    #[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    /// Disk already rotated (external `synaps login` / cron / earlier waiter):
2554    /// the rejected token no longer matches -> no refresh, disk creds returned.
2555    #[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    /// Concurrent 401s with the same rejected token: exactly one forced
2568    /// refresh; later waiters see the rotated token and skip.
2569    #[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    /// Rate limit: a forced refresh denied by `may_force` spends nothing.
2583    #[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    /// An EXPIRED credential refreshes even inside the cooldown: the rate
2605    /// limit applies only to forcing an unexpired token. It also refreshes
2606    /// when the caller's rejected token is no longer the stored one.
2607    #[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    /// auth.json seeded long ago. (The mtime no longer matters to the
2638    /// cooldown; the old timestamp keeps these fixtures neutral.)
2639    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    /// Production composition (gate + lock + CAS save + cooldown) with a
2653    /// fake provider exchange that mints a distinct access token per call.
2654    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    /// A permanent 401 stops spending refresh tokens: the first rejection
2675    /// forces one refresh; when the provider rejects the freshly minted
2676    /// token too, the cooldown refuses a second spend. A second account of
2677    /// the same provider has its own cooldown and still recovers.
2678    #[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        // Account b: a's refresh stamped only a's lock file.
2694        forced(&path, &b, "b-rejected", &calls).await.unwrap();
2695        assert_eq!(calls.load(Ordering::SeqCst), 2, "b has its own cooldown");
2696    }
2697
2698    /// `synaps auth state` sees each slot's last refresh exchange: its kind
2699    /// when the provider refused, none when it issued a token. A forced
2700    /// refresh refused by the cooldown is no exchange, and a slot's record
2701    /// is its own.
2702    #[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        // r2 item 8: the HTTP status the token endpoint answered is kept.
2720        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    /// What another synaps process leaves behind after refreshing `cred`:
2736    /// the refresh time stamped into that account's lock file.
2737    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    /// Another process refreshed account A seconds ago (stamp in A's lock
2746    /// file): a forced refresh of A is refused and spends nothing.
2747    #[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    /// A's stamp never cools down B in the same auth.json.
2761    #[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    /// A fresh auth.json write (e.g. `synaps login` of any account) with no
2774    /// refresh stamp no longer blocks a forced refresh.
2775    #[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        // Written "now", by something other than a refresh.
2781        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    /// Review I3's leak: another process force-refreshed A, then THIS process
2788    /// refreshed B (so the last auth.json write is ours). A 401 on A's new
2789    /// token must still be refused — the cooldown is A's own stamp, not a
2790    /// file-level "who wrote last" signal.
2791    #[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    /// The cooldown only gates FORCING an unexpired token: an expired one
2807    /// refreshes even right after another process stamped it.
2808    #[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    /// The stamp is advisory: unreadable or future-dated values allow.
2824    #[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    /// Our refresh writes the stamp, and an older client that opens and
2842    /// flocks the same lock file the pre-#215 way (`create`, `write`,
2843    /// `truncate(false)`, never writing it) leaves it intact.
2844    #[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        // Old client (origin/dev `lock_file_at`): open + flock + unlock.
2859        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        // ...so the cooldown it records still holds.
2870        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    /// Typed failures: callers can tell a cooldown and a transient provider
2876    /// failure from an unusable login without parsing text.
2877    #[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        // Storage/slot strings are credential failures.
2888        assert_eq!(RefreshFailure::from("No credentials".to_string()).kind, Credential);
2889    }
2890}
2891
2892/// Minimal loopback HTTP server for refresh-classification tests: answers
2893/// every request with one canned raw response, then closes the connection.
2894#[cfg(test)]
2895pub(crate) mod fake_http {
2896    use tokio::io::{AsyncReadExt, AsyncWriteExt};
2897
2898    /// `HTTP/1.1 <status>` response with a JSON content type and an exact
2899    /// `Content-Length`.
2900    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    /// Serve `raw` to every connection; returns `http://127.0.0.1:<port>`.
2922    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    /// A loopback URL nothing listens on (connection refused).
2946    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/// m1: refresh failures are classified where they are produced (status code
2955/// and error type), never by matching message text.
2956#[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        // A captive portal answering 200 with HTML: not a login problem.
2980        let portal = anthropic(response("200 OK", "<html>sign in to wifi</html>")).await;
2981        assert_eq!(portal.kind, Transient, "{portal}");
2982        // A body cut mid-transfer (declared 200 bytes, sent 10).
2983        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        // A real rejection stays a credential failure, sanitized.
2995        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        // No network at all.
3003        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    /// Kind follows the typed provider error, even when the text would
3009    /// have fooled the old substring matcher.
3010    #[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        // Billing, not the login: never "sign in again".
3025        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 = &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        // Round-2 m-i: a proxy echoing the request (refresh token included)
3080        // never reaches the error text.
3081        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}