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    memory: Option<Arc<dyn RefusalMemory>>,
420) -> std::result::Result<OAuthCredentials, SeatVendError> {
421    let path = auth_file_path();
422    ensure_credential_checked_at(client, cred, &path, Some(expected), None, endpoints, memory)
423        .await
424        .map_err(|f| {
425            if f.message() == SEAT_CHANGED {
426                SeatVendError::SeatChanged
427            } else {
428                SeatVendError::Other(f)
429            }
430        })
431}
432
433/// Path-explicit core of [`ensure_fresh_credential`] (tests use temp dirs).
434pub(crate) async fn ensure_fresh_credential_at(
435    client: &Client,
436    cred: &CredentialRef,
437    path: &Path,
438) -> std::result::Result<OAuthCredentials, String> {
439    ensure_fresh_credential_checked_at(client, cred, path, None).await
440}
441
442pub(crate) async fn ensure_fresh_credential_checked_at(
443    client: &Client,
444    cred: &CredentialRef,
445    path: &Path,
446    expected_seat: Option<&SeatIdentity>,
447) -> std::result::Result<OAuthCredentials, String> {
448    ensure_credential_checked_at(
449        client,
450        cred,
451        path,
452        expected_seat,
453        None,
454        &RefreshEndpoints::default(),
455        None,
456    )
457    .await
458    .map_err(String::from)
459}
460
461/// Recovery is broker-owned: no refresh credentials cross into the runtime.
462/// Typed so the caller can tell a cooldown / busy lock / network failure
463/// from an unusable login.
464pub(super) async fn recover_rejected_credential(
465    client: &Client,
466    cred: &CredentialRef,
467    rejected: &str,
468    endpoints: &RefreshEndpoints,
469) -> std::result::Result<OAuthCredentials, RefreshFailure> {
470    ensure_credential_checked_at(
471        client,
472        cred,
473        &auth_file_path(),
474        None,
475        Some(rejected),
476        endpoints,
477        None,
478    )
479    .await
480}
481
482/// [`ensure_fresh_credential`] through `endpoints` (the broker's test
483/// seams), with the slot's refusal `memory` if the caller keeps one.
484pub(super) async fn ensure_fresh_credential_via(
485    client: &Client,
486    cred: &CredentialRef,
487    endpoints: &RefreshEndpoints,
488    memory: Option<Arc<dyn RefusalMemory>>,
489) -> std::result::Result<OAuthCredentials, RefreshFailure> {
490    ensure_credential_checked_at(client, cred, &auth_file_path(), None, None, endpoints, memory)
491        .await
492}
493
494async fn ensure_credential_checked_at(
495    client: &Client,
496    cred: &CredentialRef,
497    path: &Path,
498    expected_seat: Option<&SeatIdentity>,
499    rejected: Option<&str>,
500    endpoints: &RefreshEndpoints,
501    memory: Option<Arc<dyn RefusalMemory>>,
502) -> std::result::Result<OAuthCredentials, RefreshFailure> {
503    // Cheap pre-check without gate, file lock or task: the common
504    // fresh-token path stays a single file read. The locked core re-loads
505    // and re-decides under both locks.
506    let creds = load_checked(path, cred, expected_seat)?;
507    let rejected_now = rejected.is_some_and(|r| !r.is_empty() && r == creds.access);
508    if !rejected_now && !is_token_expired(&creds) {
509        return Ok(creds);
510    }
511    let (client, cred, path) = (client.clone(), cred.clone(), path.to_path_buf());
512    let (seat, rejected) = (expected_seat.cloned(), rejected.map(str::to_string));
513    let endpoints = endpoints.clone();
514    run_detached(async move {
515        let provider = cred.provider;
516        let (client, endpoints) = (&client, &endpoints);
517        refresh_credential_locked(
518            &cred,
519            &path,
520            seat.as_ref(),
521            rejected.as_deref(),
522            memory.as_deref(),
523            |refresh: String| async move {
524                super::provider::refresh_via(client, provider, &refresh, endpoints).await
525            },
526        )
527        .await
528    })
529    .await
530}
531
532/// Run the locked refresh DETACHED from the caller, on the process-lifetime
533/// [`refresh_runtime`]. If the caller is cancelled (Esc mid-request, a
534/// dropped turn) or its whole runtime is dropped (a subagent worker's
535/// throwaway `current_thread` runtime on timeout/cancel) after the provider
536/// has rotated the refresh token but before the CAS save, the refresh still
537/// completes and persists it — dropping a rotated refresh token would log
538/// the user out. The lock poller's `spawn_blocking` also lives on that
539/// runtime, so tearing down a caller's runtime never waits on it.
540async fn run_detached<T, E, F>(fut: F) -> std::result::Result<T, E>
541where
542    F: std::future::Future<Output = std::result::Result<T, E>> + Send + 'static,
543    T: Send + 'static,
544    E: From<RefreshFailure> + Send + 'static,
545{
546    run_detached_on(refresh_runtime(), fut).await
547}
548
549async fn run_detached_on<T, E, F>(
550    runtime: std::result::Result<&'static tokio::runtime::Runtime, RefreshFailure>,
551    fut: F,
552) -> std::result::Result<T, E>
553where
554    F: std::future::Future<Output = std::result::Result<T, E>> + Send + 'static,
555    T: Send + 'static,
556    E: From<RefreshFailure> + Send + 'static,
557{
558    runtime.map_err(E::from)?.spawn(fut).await.map_err(|e| {
559        E::from(RefreshFailure::new(
560            RefreshFailureKind::Other,
561            format!("credential refresh task failed: {e}"),
562        ))
563    })?
564}
565
566/// Small multi-thread runtime that lives for the whole process and runs
567/// every locked refresh (gate, flock poller, provider exchange, CAS save).
568/// Callers on any runtime just await the `JoinHandle`. If it cannot be
569/// started (thread spawn failure under resource exhaustion) the refresh
570/// fails with [`RefreshFailureKind::Other`] instead of aborting the process,
571/// and the next refresh tries to build it again (only success is kept, so a
572/// long-lived daemon recovers once resources free up).
573fn refresh_runtime() -> std::result::Result<&'static tokio::runtime::Runtime, RefreshFailure> {
574    static RT: std::sync::Mutex<Option<&'static tokio::runtime::Runtime>> =
575        std::sync::Mutex::new(None);
576    runtime_from(&RT, || {
577        tokio::runtime::Builder::new_multi_thread()
578            .worker_threads(2)
579            .thread_name("synaps-auth-refresh")
580            .enable_all()
581            .build()
582    })
583}
584
585fn runtime_from(
586    slot: &std::sync::Mutex<Option<&'static tokio::runtime::Runtime>>,
587    build: impl FnOnce() -> std::io::Result<tokio::runtime::Runtime>,
588) -> std::result::Result<&'static tokio::runtime::Runtime, RefreshFailure> {
589    let mut slot = slot.lock().unwrap_or_else(|e| e.into_inner());
590    if let Some(runtime) = *slot {
591        return Ok(runtime);
592    }
593    let runtime = build().map_err(|e| {
594        RefreshFailure::other(format!("could not start the auth refresh runtime: {e}"))
595    })?;
596    // Lives for the process, like the OnceLock it replaces.
597    let runtime: &'static tokio::runtime::Runtime = Box::leak(Box::new(runtime));
598    *slot = Some(runtime);
599    Ok(runtime)
600}
601
602fn load_checked(
603    path: &Path,
604    cred: &CredentialRef,
605    expected_seat: Option<&SeatIdentity>,
606) -> std::result::Result<OAuthCredentials, RefreshFailure> {
607    let creds = load_provider_auth_typed_at(path, &cred.storage_key())?.ok_or_else(|| {
608        RefreshFailure::credential(format!(
609            "No credentials for {} at {}. Run `synaps login --provider {} --account {}`.",
610            cred,
611            path.display(),
612            cred.provider,
613            cred.account
614        ))
615    })?;
616    // Seat pairing is re-checked on every load, including the re-load
617    // after a CAS `Replaced` (re-login mid-refresh): the replacement is
618    // a different seat and was never proven.
619    if let Some(expected) = expected_seat {
620        if SeatIdentity::of(cred.provider, &creds) != *expected {
621            return Err(RefreshFailure::credential(SEAT_CHANGED));
622        }
623    }
624    Ok(creds)
625}
626
627/// The last token refresh exchange of one credential slot in this process
628/// (`synaps auth state`). `failure: None` = the provider issued a token.
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub struct RefreshExchange {
631    pub at_ms: u64,
632    pub failure: Option<RefreshFailureKind>,
633    /// The token endpoint's HTTP status when it answered with a failure.
634    pub http_status: Option<u16>,
635}
636
637/// Process-wide, keyed by credential file + storage key; written only where
638/// an exchange with a token endpoint actually happened (a fresh-token read,
639/// a peer's rotation found under the lock or a refused forced refresh
640/// records nothing).
641static REFRESH_EXCHANGES: std::sync::Mutex<std::collections::BTreeMap<String, RefreshExchange>> =
642    std::sync::Mutex::new(std::collections::BTreeMap::new());
643
644fn refresh_exchange_key(path: &Path, storage_key: &str) -> String {
645    format!("{}|{storage_key}", path.display())
646}
647
648fn record_refresh_exchange(path: &Path, storage_key: &str, failure: Option<&RefreshFailure>) {
649    let at_ms = crate::epoch_millis();
650    REFRESH_EXCHANGES
651        .lock()
652        .unwrap_or_else(|e| e.into_inner())
653        .insert(
654            refresh_exchange_key(path, storage_key),
655            RefreshExchange {
656                at_ms,
657                failure: failure.map(|f| f.kind),
658                http_status: failure.and_then(RefreshFailure::http_status),
659            },
660        );
661}
662
663/// The last refresh exchange of `storage_key` in the credential file at
664/// `path`, in this process.
665pub fn last_refresh_exchange(path: &Path, storage_key: &str) -> Option<RefreshExchange> {
666    REFRESH_EXCHANGES
667        .lock()
668        .unwrap_or_else(|e| e.into_inner())
669        .get(&refresh_exchange_key(path, storage_key))
670        .copied()
671}
672
673/// A slot's memory of refused refreshes (the broker's refused-refresh
674/// hold), consulted and written by the gated refresh under the slot's gate
675/// and lock, after the stored login is re-read. So the check, the exchange
676/// and the record are one serialised decision per slot: a waiter sees the
677/// refusal its predecessor just got and sends nothing, and the record is
678/// of the login that was actually refused. Both calls run inside the
679/// detached refresh task, so a cancelled caller cannot skip the record.
680pub(super) trait RefusalMemory: Send + Sync {
681    /// The held refusal of `creds` (the login re-read under the gate), if
682    /// any; `None` lets the exchange go ahead.
683    fn held(&self, creds: &OAuthCredentials) -> Option<RefreshFailure>;
684    /// The token endpoint exchange of `creds` failed with `failure`. Only
685    /// the exchange's own outcome arrives here: a load, lock or save
686    /// failure (the local credential store) never does.
687    fn refused(&self, creds: &OAuthCredentials, failure: &RefreshFailure);
688}
689
690/// Gate → per-credential cross-process lock → CAS save, plus the forced
691/// refresh rate limit. `refresh` is the provider exchange (injected so the
692/// whole production composition is testable without a network). Each
693/// exchange's outcome is recorded for `synaps auth state`, and `memory`
694/// (if any) is consulted and told under the gate ([`RefusalMemory`]).
695async fn refresh_credential_locked<Refresh, RFut, RErr>(
696    cred: &CredentialRef,
697    path: &Path,
698    expected_seat: Option<&SeatIdentity>,
699    rejected: Option<&str>,
700    memory: Option<&dyn RefusalMemory>,
701    refresh: Refresh,
702) -> std::result::Result<OAuthCredentials, RefreshFailure>
703where
704    Refresh: Fn(String) -> RFut,
705    RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, RErr>>,
706    RErr: Into<RefreshFailure>,
707{
708    let key = cred.storage_key();
709    let refresh = |old_refresh: String| {
710        let exchange = refresh(old_refresh);
711        let key = &key;
712        async move {
713            let result: std::result::Result<OAuthCredentials, RefreshFailure> =
714                exchange.await.map_err(Into::into);
715            record_refresh_exchange(path, key, result.as_ref().err());
716            result
717        }
718    };
719    let gate_key = format!("{}|{}", path.display(), key);
720    let lock_path = refresh_lock_path(path, &key);
721    let load = || load_checked(path, cred, expected_seat);
722    // Off the refresh runtime's two workers (the store lock is polled with
723    // a blocking sleep), with the long post-rotation deadline.
724    let save = |old_refresh: &str, fresh: &OAuthCredentials| {
725        let (path, key) = (path.to_path_buf(), key.clone());
726        let (old_refresh, fresh) = (old_refresh.to_string(), fresh.clone());
727        async move {
728            tokio::task::spawn_blocking(move || {
729                save_provider_auth_if_refresh_matches_typed_at(
730                    &path,
731                    &key,
732                    &old_refresh,
733                    &fresh,
734                    REFRESH_SAVE_WAIT,
735                )
736            })
737            .await
738            .map_err(|e| RefreshFailure::other(format!("credential save task failed: {e}")))?
739            .map_err(RefreshFailure::from)
740        }
741    };
742    ensure_gated_locked_policy_with(
743        refresh_gate(&gate_key),
744        Some(lock_path),
745        load,
746        refresh,
747        save,
748        rejected,
749        |last_refresh_ms| forced_refresh_allowed(cred, last_refresh_ms, crate::epoch_millis()),
750        expected_seat.is_none(),
751        memory,
752    )
753    .await
754}
755
756/// Minimum interval between two *forced* (401-driven) refreshes of the same
757/// credential. If the provider rejects a token minted less than this long
758/// ago, a new token will not help (org / permission / header problem) and
759/// spending the refresh token again only widens the rotation race window.
760///
761/// Per account, across processes: every successful refresh stamps its time
762/// into that account's own lock file (`auth.json.<storage_key>.refresh.lock`)
763/// while holding the lock, and the check reads it under the same lock. A
764/// refresh or `synaps login` of account A therefore never cools down
765/// account B, in this process or any other. This is a rate limit only; the
766/// flock + CAS save are what prevent a double spend.
767pub(crate) const FORCED_REFRESH_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(60);
768
769/// Rate limit for forcing a refresh of a not-yet-expired token.
770/// `last_refresh_ms` is the epoch-ms stamp read from this account's lock
771/// file (any process's last successful refresh of THIS account). Refused
772/// only if that is less than [`FORCED_REFRESH_COOLDOWN`] ago; a missing,
773/// unparsable or future-dated stamp allows. Only consulted while storage
774/// still holds the rejected token, so an external `synaps login` that
775/// changes the token never hits it.
776fn forced_refresh_allowed(
777    cred: &CredentialRef,
778    last_refresh_ms: Option<u64>,
779    now_ms: u64,
780) -> std::result::Result<(), RefreshFailure> {
781    let cooldown_ms = FORCED_REFRESH_COOLDOWN.as_millis() as u64;
782    match last_refresh_ms.filter(|t| *t <= now_ms).map(|t| now_ms - t) {
783        Some(age_ms) if age_ms < cooldown_ms => {
784            let retry_in_secs = (cooldown_ms - age_ms).div_ceil(1000);
785            Err(RefreshFailure::new(
786                RefreshFailureKind::Cooldown { retry_in_secs },
787                format!(
788                    "{} rejected a token refreshed {}s ago; not refreshing again for {}s \
789                     (likely an account or permission issue, not a stale token)",
790                    cred,
791                    age_ms / 1000,
792                    retry_in_secs
793                ),
794            ))
795        }
796        _ => Ok(()),
797    }
798}
799
800/// Per-credential cross-process lock file, beside the credential file.
801/// Storage keys only contain `[a-z0-9._@-]`, so they are safe path fragments.
802fn refresh_lock_path(auth_path: &Path, storage_key: &str) -> PathBuf {
803    let file_name = auth_path
804        .file_name()
805        .and_then(|n| n.to_str())
806        .unwrap_or("auth.json");
807    auth_path.with_file_name(format!("{file_name}.{storage_key}.refresh.lock"))
808}
809
810/// Hard deadline for one provider refresh HTTP exchange while the
811/// per-credential lock is held. Bounds the lock hold time regardless of the
812/// caller's HTTP client configuration.
813const REFRESH_HTTP_DEADLINE: std::time::Duration = std::time::Duration::from_secs(60);
814
815/// Bounded wait for the cross-process refresh lock (another process may be
816/// mid-rotation). Strictly longer than `REFRESH_HTTP_DEADLINE` plus the
817/// persistence step, so a healthy peer always finishes before a waiter
818/// gives up; a wedged peer cannot hang this process forever.
819const REFRESH_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
820
821/// Deadline for the CAS save that follows a provider exchange. The
822/// provider may already have rotated (spent) the old refresh token, so
823/// giving up loses the login: wait as long as any refresh-lock waiter would.
824/// Login and static-key saves keep the short `STORE_LOCK_WAIT`.
825const REFRESH_SAVE_WAIT: std::time::Duration = REFRESH_LOCK_WAIT;
826
827/// Sidecar for a rotation whose save failed, beside the account's lock.
828fn pending_path(lock_path: &Path) -> PathBuf {
829    lock_path.with_extension("pending")
830}
831
832/// The save after a rotation failed. Park `fresh` in the 0600 sidecar
833/// (under the held refresh lock) and hand it out; the next locked load
834/// applies it. Without a lock, or if parking fails too, the rotation is lost.
835fn park_unsaved_rotation(
836    lock: Option<&RefreshFileLock>,
837    old_refresh: &str,
838    fresh: OAuthCredentials,
839    err: RefreshFailure,
840) -> std::result::Result<OAuthCredentials, RefreshFailure> {
841    let Some(lock) = lock else { return Err(err) };
842    match lock.park_pending(old_refresh, &fresh) {
843        Ok(()) => {
844            tracing::warn!(
845                lock = %lock.path.display(),
846                error = %err,
847                "refreshed login could not be saved; parked it beside the refresh lock, \
848                 it will be applied on next use"
849            );
850            Ok(fresh)
851        }
852        Err(e) => {
853            tracing::error!(
854                lock = %lock.path.display(),
855                save_error = %err,
856                park_error = %e,
857                "refreshed login could NOT be saved: if the provider rotated the refresh \
858                 token, this account is now logged out and needs `synaps login`"
859            );
860            Err(err)
861        }
862    }
863}
864
865/// Guard holding the exclusive `flock`; dropping it releases the lock.
866///
867/// The lock file's content is this account's last-refresh stamp (epoch ms,
868/// plain ASCII), read and written only while the lock is held. Older
869/// clients open the same file with `truncate(false)` and never write it, so
870/// the stamp survives them.
871struct RefreshFileLock {
872    file: std::fs::File,
873    /// Lock file path: keys the in-process [`refresh_floor`].
874    path: PathBuf,
875}
876
877/// In-process last-refresh floor per refresh-lock path, recorded only when
878/// the on-disk stamp could not be written. Without it a failed stamp
879/// (ENOSPC, a read-only lock file) would silently switch the forced-refresh
880/// cooldown off and every 401 would rotate / re-mint again.
881fn refresh_floor() -> &'static std::sync::Mutex<HashMap<PathBuf, u64>> {
882    static FLOOR: OnceLock<std::sync::Mutex<HashMap<PathBuf, u64>>> = OnceLock::new();
883    FLOOR.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
884}
885
886impl RefreshFileLock {
887    /// Last successful refresh of this account: the on-disk stamp (any
888    /// process), or this process's floor if that is later.
889    fn last_refresh_ms(&self) -> Option<u64> {
890        let floor = refresh_floor()
891            .lock()
892            .unwrap_or_else(|e| e.into_inner())
893            .get(&self.path)
894            .copied();
895        self.stamped_ms().max(floor)
896    }
897
898    fn stamped_ms(&self) -> Option<u64> {
899        use std::io::{Read, Seek};
900        let mut file = &self.file;
901        file.rewind().ok()?;
902        let mut text = String::new();
903        file.take(32).read_to_string(&mut text).ok()?;
904        text.trim().parse().ok()
905    }
906
907    /// Record a successful refresh. Never fails a refresh that is already
908    /// persisted: if the stamp cannot be written, warn and keep the time
909    /// in the in-process floor instead.
910    fn record_refreshed(&self, now_ms: u64) {
911        let mut floor = refresh_floor().lock().unwrap_or_else(|e| e.into_inner());
912        match self.stamp_refreshed(now_ms) {
913            Ok(()) => {
914                floor.remove(&self.path);
915            }
916            Err(e) => {
917                tracing::warn!(
918                    lock = %self.path.display(),
919                    error = %e,
920                    "could not stamp the refresh lock file; keeping the forced-refresh \
921                     cooldown in this process only"
922                );
923                floor.insert(self.path.clone(), now_ms);
924            }
925        }
926    }
927
928    /// Write `{expected_refresh, credential}` to the 0600 sidecar
929    /// (tmp + rename). Only called while holding this lock.
930    fn park_pending(&self, expected_refresh: &str, fresh: &OAuthCredentials) -> std::io::Result<()> {
931        use std::io::Write;
932        let body = serde_json::json!({ "expected_refresh": expected_refresh, "credential": fresh });
933        let path = pending_path(&self.path);
934        let tmp = path.with_extension("pending.tmp");
935        let mut open = std::fs::OpenOptions::new();
936        open.create(true).write(true).truncate(true);
937        #[cfg(unix)]
938        {
939            use std::os::unix::fs::OpenOptionsExt;
940            open.mode(0o600);
941        }
942        let mut file = open.open(&tmp)?;
943        file.write_all(body.to_string().as_bytes())?;
944        file.sync_all()?;
945        std::fs::rename(&tmp, &path)
946    }
947
948    /// CAS a parked rotation into the store. Applied or stale (the slot was
949    /// re-logged-in or removed) deletes the sidecar. Otherwise the sidecar is
950    /// kept and this fails (with the parked credential, so the caller may
951    /// serve its access token), so the spent refresh token it replaced is
952    /// never used:
953    /// - store missing (moved aside): ActionRequired, never deleted;
954    /// - corrupt store: ActionRequired, keeping the repair text;
955    /// - anything else: Other, retry shortly.
956    async fn apply_pending<S, SFut>(
957        &self,
958        save: &S,
959    ) -> std::result::Result<(), (RefreshFailure, Option<OAuthCredentials>)>
960    where
961        S: Fn(&str, &OAuthCredentials) -> SFut,
962        SFut: std::future::Future<Output = std::result::Result<CasOutcome, RefreshFailure>>,
963    {
964        let path = pending_path(&self.path);
965        let Ok(text) = std::fs::read_to_string(&path) else {
966            return Ok(());
967        };
968        let parked = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| {
969            let expected = v.get("expected_refresh")?.as_str()?.to_string();
970            let cred = serde_json::from_value::<OAuthCredentials>(v.get("credential")?.clone()).ok()?;
971            Some((expected, cred))
972        });
973        let Some((expected, cred)) = parked else {
974            let _ = std::fs::remove_file(&path);
975            return Ok(());
976        };
977        let failure = match save(&expected, &cred).await {
978            Ok(CasOutcome::Saved | CasOutcome::Replaced | CasOutcome::Removed) => {
979                let _ = std::fs::remove_file(&path);
980                return Ok(());
981            }
982            Ok(CasOutcome::StoreMissing) => RefreshFailure::new(
983                RefreshFailureKind::ActionRequired,
984                format!(
985                    "the credential store no longer exists; this account's refreshed login is \
986                     kept in {}. Put the store back (or repair it) to keep using it; logging in \
987                     again replaces it.",
988                    path.display()
989                ),
990            ),
991            Err(e) if e.kind == RefreshFailureKind::ActionRequired => RefreshFailure::new(
992                RefreshFailureKind::ActionRequired,
993                format!(
994                    "{} This account's refreshed login is kept in {} and is applied once the \
995                     store can be written.",
996                    e.message(),
997                    path.display()
998                ),
999            ),
1000            Err(e) => RefreshFailure::other(format!(
1001                "a refreshed login is waiting in {} and could not be applied yet ({e}); retry shortly",
1002                path.display()
1003            )),
1004        };
1005        Err((failure, Some(cred)))
1006    }
1007
1008    fn stamp_refreshed(&self, now_ms: u64) -> std::io::Result<()> {
1009        use std::io::{Seek, Write};
1010        let mut file = &self.file;
1011        file.set_len(0)?;
1012        file.rewind()?;
1013        file.write_all(now_ms.to_string().as_bytes())
1014    }
1015}
1016
1017/// Acquire the per-credential refresh lock off the async runtime (flock
1018/// blocks). Polls `try_lock_exclusive` so a wedged peer cannot hang this
1019/// process forever.
1020async fn acquire_refresh_lock(
1021    path: PathBuf,
1022    wait: std::time::Duration,
1023) -> std::result::Result<RefreshFileLock, RefreshFailure> {
1024    tokio::task::spawn_blocking(move || {
1025        use fs4::fs_std::FileExt;
1026        let other = |m: String| RefreshFailure::new(RefreshFailureKind::Other, m);
1027        if let Some(parent) = path.parent() {
1028            std::fs::create_dir_all(parent)
1029                .map_err(|e| other(format!("Failed to create {}: {}", parent.display(), e)))?;
1030        }
1031        let mut open = std::fs::OpenOptions::new();
1032        // Never truncate on open: the content is the refresh stamp, and
1033        // must also survive older clients' opens of the same lock.
1034        open.create(true).read(true).write(true).truncate(false);
1035        #[cfg(unix)]
1036        {
1037            use std::os::unix::fs::OpenOptionsExt;
1038            open.mode(0o600);
1039        }
1040        let file = open
1041            .open(&path)
1042            .map_err(|e| other(format!("Failed to open refresh lock {}: {}", path.display(), e)))?;
1043        let deadline = std::time::Instant::now() + wait;
1044        loop {
1045            match FileExt::try_lock_exclusive(&file) {
1046                Ok(true) => return Ok(RefreshFileLock { file, path }),
1047                Ok(false) => {}
1048                Err(e) => {
1049                    return Err(other(format!(
1050                        "Failed to lock refresh lock {}: {}",
1051                        path.display(),
1052                        e
1053                    )))
1054                }
1055            }
1056            if std::time::Instant::now() >= deadline {
1057                return Err(RefreshFailure::new(
1058                    RefreshFailureKind::LockBusy,
1059                    format!(
1060                        "timed out waiting for another process to finish refreshing ({})",
1061                        path.display()
1062                    ),
1063                ));
1064            }
1065            std::thread::sleep(std::time::Duration::from_millis(25));
1066        }
1067    })
1068    .await
1069    .map_err(|e| RefreshFailure::new(RefreshFailureKind::Other, format!("refresh lock task failed: {e}")))?
1070}
1071
1072/// Generic single-flight refresh core. Separated from network/storage so the
1073/// concurrency, rotation, and failure invariants are directly testable.
1074///
1075/// Contract:
1076/// 1. Exactly one refresh runs at a time per gate; waiters re-`load` after the
1077///    gate and observe the rotated credential without a second refresh.
1078/// 2. `save` runs only after a successful refresh (failures persist nothing).
1079/// 3. The refreshed credential is persisted before the gate is released.
1080///
1081/// Production goes through [`ensure_fresh_gated_locked`] (same contract plus
1082/// the cross-process lock and CAS save); this variant remains as the
1083/// reference core for the concurrency tests.
1084#[cfg(test)]
1085pub(crate) async fn ensure_fresh_gated<Load, Refresh, RFut, Save>(
1086    gate: Arc<Mutex<()>>,
1087    load: Load,
1088    refresh: Refresh,
1089    save: Save,
1090) -> std::result::Result<OAuthCredentials, String>
1091where
1092    Load: Fn() -> std::result::Result<OAuthCredentials, String>,
1093    Refresh: FnOnce(String) -> RFut,
1094    RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, String>>,
1095    Save: FnOnce(&OAuthCredentials) -> std::result::Result<(), String>,
1096{
1097    let _gate = gate.lock_owned().await;
1098    // Re-read after entering the gate: a preceding waiter may have refreshed
1099    // and atomically persisted a rotated refresh token.
1100    let creds = load()?;
1101    if !is_token_expired(&creds) {
1102        return Ok(creds);
1103    }
1104    let fresh = refresh(creds.refresh).await?;
1105    save(&fresh)?;
1106    Ok(fresh)
1107}
1108
1109/// Upper bound on load→refresh→CAS rounds when the slot keeps being replaced
1110/// underneath us (re-login storms). Two rounds cover the realistic case.
1111const MAX_CAS_ROUNDS: usize = 2;
1112
1113/// Single-flight refresh with an optional cross-process file lock and a
1114/// compare-and-swap save. Extends [`ensure_fresh_gated`]'s contract with:
1115/// 4. The cross-process lock is held for the whole load → refresh → save
1116///    cycle, so two processes never rotate the same refresh token.
1117/// 5. `save(old_refresh, fresh)` reports whether the slot still held
1118///    `old_refresh`. `Removed` is an error (nothing is resurrected);
1119///    `Replaced` re-loads the stored credential and, if it is expired,
1120///    refreshes *that* one — bounded — so the result is never a stale or
1121///    orphaned credential.
1122/// 6. Metadata (`accountId`) is carried over when the provider's refresh
1123///    response omits it.
1124#[cfg(test)]
1125pub(crate) async fn ensure_fresh_gated_locked<Load, Refresh, RFut, Save>(
1126    gate: Arc<Mutex<()>>,
1127    lock_path: Option<PathBuf>,
1128    load: Load,
1129    refresh: Refresh,
1130    save: Save,
1131) -> std::result::Result<OAuthCredentials, String>
1132where
1133    Load: Fn() -> std::result::Result<OAuthCredentials, String>,
1134    Refresh: Fn(String) -> RFut,
1135    RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, String>>,
1136    Save: Fn(&str, &OAuthCredentials) -> std::result::Result<CasOutcome, String>,
1137{
1138    let save = |old: &str, fresh: &OAuthCredentials| {
1139        std::future::ready(save(old, fresh).map_err(RefreshFailure::from))
1140    };
1141    let load = || load().map_err(RefreshFailure::from);
1142    ensure_gated_locked_policy(gate, lock_path, load, refresh, save, None, |_| Ok(()))
1143        .await
1144        .map_err(String::from)
1145}
1146
1147/// `rejected` is an access token the provider just refused (HTTP 401). If
1148/// the stored credential still carries it, a refresh is forced even though
1149/// its expiry is in the future (revoked-but-unexpired) — provided
1150/// `may_force` allows it (per-credential rate limit,
1151/// [`FORCED_REFRESH_COOLDOWN`]; it receives the last-refresh stamp read from
1152/// the held lock file, `None` without a lock or stamp). An expired
1153/// credential always refreshes without consulting `may_force`. Every
1154/// successful save stamps the lock file before the lock is released.
1155#[cfg(test)]
1156async fn ensure_gated_locked_policy<Load, Refresh, RFut, RErr, Save, SFut, MayForce>(
1157    gate: Arc<Mutex<()>>,
1158    lock_path: Option<PathBuf>,
1159    load: Load,
1160    refresh: Refresh,
1161    save: Save,
1162    rejected: Option<&str>,
1163    may_force: MayForce,
1164) -> std::result::Result<OAuthCredentials, RefreshFailure>
1165where
1166    Load: Fn() -> std::result::Result<OAuthCredentials, RefreshFailure>,
1167    Refresh: Fn(String) -> RFut,
1168    RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, RErr>>,
1169    RErr: Into<RefreshFailure>,
1170    Save: Fn(&str, &OAuthCredentials) -> SFut,
1171    SFut: std::future::Future<Output = std::result::Result<CasOutcome, RefreshFailure>>,
1172    MayForce: Fn(Option<u64>) -> std::result::Result<(), RefreshFailure>,
1173{
1174    ensure_gated_locked_policy_with(
1175        gate,
1176        lock_path,
1177        load,
1178        refresh,
1179        save,
1180        rejected,
1181        may_force,
1182        false,
1183        None,
1184    )
1185    .await
1186}
1187
1188/// [`ensure_gated_locked_policy`] plus `serve_parked`: while a parked
1189/// sidecar can't be applied, hand out its still-valid ACCESS token (never
1190/// its refresh token). Only for callers without a seat expectation: the
1191/// parked credential never went through `load`'s seat check. `memory`
1192/// ([`RefusalMemory`]) is asked before each exchange and told each
1193/// exchange failure, under both locks.
1194#[allow(clippy::too_many_arguments)]
1195async fn ensure_gated_locked_policy_with<Load, Refresh, RFut, RErr, Save, SFut, MayForce>(
1196    gate: Arc<Mutex<()>>,
1197    lock_path: Option<PathBuf>,
1198    load: Load,
1199    refresh: Refresh,
1200    save: Save,
1201    rejected: Option<&str>,
1202    may_force: MayForce,
1203    serve_parked: bool,
1204    memory: Option<&dyn RefusalMemory>,
1205) -> std::result::Result<OAuthCredentials, RefreshFailure>
1206where
1207    Load: Fn() -> std::result::Result<OAuthCredentials, RefreshFailure>,
1208    Refresh: Fn(String) -> RFut,
1209    RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, RErr>>,
1210    RErr: Into<RefreshFailure>,
1211    Save: Fn(&str, &OAuthCredentials) -> SFut,
1212    SFut: std::future::Future<Output = std::result::Result<CasOutcome, RefreshFailure>>,
1213    MayForce: Fn(Option<u64>) -> std::result::Result<(), RefreshFailure>,
1214{
1215    // In-process waiters are bounded like cross-process ones.
1216    let _gate = tokio::time::timeout(REFRESH_LOCK_WAIT, gate.lock_owned())
1217        .await
1218        .map_err(|_| {
1219            RefreshFailure::new(
1220                RefreshFailureKind::LockBusy,
1221                "timed out waiting for an in-flight token refresh",
1222            )
1223        })?;
1224    let file_lock = match lock_path {
1225        Some(path) => Some(acquire_refresh_lock(path, REFRESH_LOCK_WAIT).await?),
1226        None => None,
1227    };
1228    // A rotation an earlier save could not persist: apply it before
1229    // anything else, so its spent refresh token is never sent again.
1230    if let Some(lock) = &file_lock {
1231        if let Err((failure, parked)) = lock.apply_pending(&save).await {
1232            let usable = parked.filter(|p| {
1233                serve_parked
1234                    && !is_token_expired(p)
1235                    && !rejected.is_some_and(|r| !r.is_empty() && r == p.access)
1236            });
1237            let Some(mut parked) = usable else {
1238                return Err(failure);
1239            };
1240            tracing::warn!(error = %failure, "serving the parked access token until its refresh can be saved");
1241            parked.refresh.clear();
1242            return Ok(parked);
1243        }
1244    }
1245    for _ in 0..MAX_CAS_ROUNDS {
1246        // Re-read under both locks: a preceding waiter (or another process)
1247        // may have refreshed and atomically persisted a rotated token.
1248        let creds = load()?;
1249        // A peer already rotated/replaced the rejected token: adopt it, do
1250        // not force another rotation. Otherwise refresh even if unexpired.
1251        let expired = is_token_expired(&creds);
1252        let still_rejected = rejected.is_some_and(|r| !r.is_empty() && creds.access == r);
1253        if !expired && !still_rejected {
1254            return Ok(creds);
1255        }
1256        // A held refusal of exactly this login: no exchange. Checked here,
1257        // under both locks and after the re-read, so a waiter sees the
1258        // refusal its predecessor just got.
1259        if let Some(held) = memory.and_then(|m| m.held(&creds)) {
1260            return Err(held);
1261        }
1262        if !expired {
1263            // Forced (rejection-driven) refresh of an unexpired credential.
1264            may_force(file_lock.as_ref().and_then(RefreshFileLock::last_refresh_ms))?;
1265        }
1266        let old_refresh = creds.refresh.clone();
1267        // The provider exchange is deadline-bounded so the lock hold time is
1268        // bounded too (a slow-drip response cannot block every other process).
1269        let exchanged = tokio::time::timeout(REFRESH_HTTP_DEADLINE, refresh(old_refresh.clone()))
1270            .await
1271            .map_err(|_| RefreshFailure::new(RefreshFailureKind::Transient, "token refresh timed out"))?;
1272        let mut fresh = match exchanged {
1273            Ok(fresh) => fresh,
1274            Err(e) => {
1275                let failure: RefreshFailure = e.into();
1276                // The exchange's own answer, for the login it was about.
1277                if let Some(memory) = memory {
1278                    memory.refused(&creds, &failure);
1279                }
1280                return Err(failure);
1281            }
1282        };
1283        if fresh.account_id.is_none() {
1284            fresh.account_id = creds.account_id.clone();
1285        }
1286        let saved = match save(&old_refresh, &fresh).await {
1287            Ok(saved) => saved,
1288            Err(e) => return park_unsaved_rotation(file_lock.as_ref(), &old_refresh, fresh, e),
1289        };
1290        match saved {
1291            CasOutcome::Saved => {
1292                if let Some(lock) = &file_lock {
1293                    lock.record_refreshed(crate::epoch_millis());
1294                }
1295                return Ok(fresh);
1296            }
1297            CasOutcome::StoreMissing => {
1298                // The file vanished mid-refresh (moved aside): park the only
1299                // live token rather than drop it.
1300                let err = RefreshFailure::new(
1301                    RefreshFailureKind::ActionRequired,
1302                    "the credential store no longer exists",
1303                );
1304                return park_unsaved_rotation(file_lock.as_ref(), &old_refresh, fresh, err);
1305            }
1306            CasOutcome::Removed => {
1307                return Err(
1308                    "credential was removed while its token was being refreshed; run `synaps login`"
1309                        .to_string()
1310                        .into(),
1311                )
1312            }
1313            CasOutcome::Replaced => {
1314                // A re-login replaced this slot mid-refresh: the rotation we
1315                // hold is orphaned. Loop: load the replacement and refresh it
1316                // only if it is actually expired.
1317                continue;
1318            }
1319        }
1320    }
1321    Err(RefreshFailure::new(
1322        RefreshFailureKind::Other,
1323        "credential kept changing during refresh; retry",
1324    ))
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329    use super::super::storage::save_provider_auth_at_test_hook;
1330    use super::super::OAuthCredentials;
1331
1332    fn fresh_creds(refresh: &str) -> OAuthCredentials {
1333        OAuthCredentials {
1334            auth_type: "oauth".to_string(),
1335            refresh: refresh.to_string(),
1336            access: "access".to_string(),
1337            expires: crate::epoch_millis() + 3_600_000,
1338            account_id: None,
1339        }
1340    }
1341
1342    // ── Bug #2 atomicity regression suite ─────────────────────────────────────
1343    //
1344    // Root cause (pre-fix): ensure_fresh_token used seek(0)+set_len(0)+write_all
1345    // on the live auth.json file (token.rs ~139-168). A crash between truncate
1346    // and write completion zeroed the file. Because Anthropic rotates the
1347    // refresh token on every refresh, the zeroed-file state is unrecoverable.
1348    //
1349    // Fix: the write goes through save_provider_auth → save_provider_auth_at
1350    // (storage.rs), which does write-to-tmp + rename(2). rename(2) is atomic:
1351    // the file is either the old content or the new content, never empty.
1352    //
1353    // These tests exercise save_provider_auth_at directly (the same function
1354    // ensure_fresh_token now delegates to) and prove the atomicity contract.
1355
1356    /// After a successful save, auth.json must not be empty or zero-length.
1357    /// This would be violated by the old truncate-in-place path on a crash.
1358    #[test]
1359    fn bug2_write_produces_non_empty_file() {
1360        let dir = tempfile::tempdir().expect("tempdir");
1361        let path = dir.path().join("auth.json");
1362        save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok1"))
1363            .expect("save must succeed");
1364        let meta = std::fs::metadata(&path).expect("file must exist");
1365        assert!(meta.len() > 0, "auth.json must not be empty after save");
1366    }
1367
1368    /// The write must go through a .json.tmp file, not truncate-in-place.
1369    /// We prove this by checking that after a successful save the tmp file
1370    /// is gone (renamed → auth.json) and the final file is valid JSON.
1371    /// The tmp file should not persist after a successful write.
1372    #[test]
1373    fn bug2_tmp_file_cleaned_up_after_successful_write() {
1374        let dir = tempfile::tempdir().expect("tempdir");
1375        let path = dir.path().join("auth.json");
1376        save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok2"))
1377            .expect("save must succeed");
1378        let tmp = path.with_extension("json.tmp");
1379        assert!(
1380            !tmp.exists(),
1381            "auth.json.tmp must not persist after a successful atomic rename"
1382        );
1383    }
1384
1385    /// The final file must be valid JSON with the correct credential.
1386    /// Truncate-in-place failure would leave `{}` or `null`; atomic rename
1387    /// either keeps old content or produces fully-written new content.
1388    #[test]
1389    fn bug2_final_file_is_valid_json_with_correct_credential() {
1390        let dir = tempfile::tempdir().expect("tempdir");
1391        let path = dir.path().join("auth.json");
1392        save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok3"))
1393            .expect("save must succeed");
1394        let content = std::fs::read_to_string(&path).expect("must be readable");
1395        let parsed: serde_json::Value =
1396            serde_json::from_str(&content).expect("must be valid JSON after atomic write");
1397        assert_eq!(
1398            parsed["anthropic"]["refresh"].as_str(),
1399            Some("tok3"),
1400            "refresh token must be persisted correctly"
1401        );
1402    }
1403
1404    /// Refresh write must preserve other providers (fixes #184 / AuthFile
1405    /// 2-field issue). Old code rebuilt `AuthFile { anthropic, openai_codex }`
1406    /// which drops any 3rd provider; new code does read-merge-write.
1407    #[test]
1408    fn bug2_refresh_write_preserves_other_providers() {
1409        let dir = tempfile::tempdir().expect("tempdir");
1410        let path = dir.path().join("auth.json");
1411
1412        // Simulate state after two prior logins
1413        save_provider_auth_at_test_hook(&path, "openai-codex", &fresh_creds("codex-tok"))
1414            .expect("save codex");
1415        save_provider_auth_at_test_hook(&path, "future-provider", &fresh_creds("future-tok"))
1416            .expect("save future");
1417
1418        // Simulate ensure_fresh_token writing the refreshed anthropic credential
1419        save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("anth-new"))
1420            .expect("save anthropic after refresh");
1421
1422        let content = std::fs::read_to_string(&path).expect("readable");
1423        let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid JSON");
1424
1425        assert_eq!(parsed["anthropic"]["refresh"].as_str(), Some("anth-new"));
1426        assert_eq!(
1427            parsed["openai-codex"]["refresh"].as_str(),
1428            Some("codex-tok"),
1429            "openai-codex must survive the anthropic refresh write"
1430        );
1431        assert_eq!(
1432            parsed["future-provider"]["refresh"].as_str(),
1433            Some("future-tok"),
1434            "future-provider must survive (fixes #184 — 3rd provider not dropped)"
1435        );
1436    }
1437
1438    // ── Single-flight refresh invariants (concurrency / rotation / failure) ──
1439    //
1440    // These drive `ensure_fresh_gated` — the exact core `ensure_fresh_token`
1441    // (Anthropic) and `ensure_fresh_provider_token` (all providers) execute —
1442    // with injected load/refresh/save so no network or real auth.json is
1443    // involved.
1444
1445    use super::ensure_fresh_gated;
1446    use std::sync::atomic::{AtomicUsize, Ordering};
1447    use std::sync::{Arc, Mutex as StdMutex};
1448
1449    fn expired_creds(refresh: &str) -> OAuthCredentials {
1450        OAuthCredentials {
1451            auth_type: "oauth".to_string(),
1452            refresh: refresh.to_string(),
1453            access: "stale-access".to_string(),
1454            expires: 0,
1455            account_id: None,
1456        }
1457    }
1458
1459    /// Shared fake persistence: models auth.json for a single provider.
1460    #[derive(Clone)]
1461    struct FakeStore {
1462        creds: Arc<StdMutex<OAuthCredentials>>,
1463        refresh_calls: Arc<AtomicUsize>,
1464        save_calls: Arc<AtomicUsize>,
1465    }
1466
1467    impl FakeStore {
1468        fn new(initial: OAuthCredentials) -> Self {
1469            Self {
1470                creds: Arc::new(StdMutex::new(initial)),
1471                refresh_calls: Arc::new(AtomicUsize::new(0)),
1472                save_calls: Arc::new(AtomicUsize::new(0)),
1473            }
1474        }
1475
1476        async fn ensure(
1477            &self,
1478            gate: Arc<tokio::sync::Mutex<()>>,
1479        ) -> Result<OAuthCredentials, String> {
1480            let store = self.clone();
1481            let refresh_calls = self.refresh_calls.clone();
1482            ensure_fresh_gated(
1483                gate,
1484                move || Ok(store.creds.lock().unwrap().clone()),
1485                move |old_refresh| async move {
1486                    refresh_calls.fetch_add(1, Ordering::SeqCst);
1487                    // Rotate: new refresh token derived from the one presented.
1488                    Ok(OAuthCredentials {
1489                        auth_type: "oauth".to_string(),
1490                        refresh: format!("{old_refresh}-rotated"),
1491                        access: "fresh-access".to_string(),
1492                        expires: crate::epoch_millis() + 3_600_000,
1493                        account_id: None,
1494                    })
1495                },
1496                |c| {
1497                    self.save_calls.fetch_add(1, Ordering::SeqCst);
1498                    *self.creds.lock().unwrap() = c.clone();
1499                    Ok(())
1500                },
1501            )
1502            .await
1503        }
1504    }
1505
1506    /// Concurrency: N concurrent callers with an expired credential perform
1507    /// exactly ONE network refresh; everyone observes the rotated result.
1508    #[tokio::test]
1509    async fn anthropic_refresh_single_flight_under_concurrency() {
1510        let store = FakeStore::new(expired_creds("r0"));
1511        let gate = Arc::new(tokio::sync::Mutex::new(()));
1512        let mut handles = Vec::new();
1513        for _ in 0..16 {
1514            let store = store.clone();
1515            let gate = gate.clone();
1516            handles.push(tokio::spawn(async move { store.ensure(gate).await }));
1517        }
1518        for h in handles {
1519            let creds = h.await.unwrap().expect("refresh must succeed");
1520            assert_eq!(creds.access, "fresh-access");
1521            assert_eq!(creds.refresh, "r0-rotated");
1522        }
1523        assert_eq!(
1524            store.refresh_calls.load(Ordering::SeqCst),
1525            1,
1526            "exactly one caller may rotate the refresh token"
1527        );
1528        assert_eq!(store.save_calls.load(Ordering::SeqCst), 1);
1529    }
1530
1531    /// Rotation: the persisted refresh token is the rotated one, and a later
1532    /// expiry cycle refreshes FROM the rotated token (never the stale one).
1533    #[tokio::test]
1534    async fn refresh_rotation_chains_from_persisted_token() {
1535        let store = FakeStore::new(expired_creds("gen0"));
1536        let gate = Arc::new(tokio::sync::Mutex::new(()));
1537        store.ensure(gate.clone()).await.unwrap();
1538        assert_eq!(store.creds.lock().unwrap().refresh, "gen0-rotated");
1539
1540        // Force expiry again; the second cycle must present the rotated token.
1541        store.creds.lock().unwrap().expires = 0;
1542        let second = store.ensure(gate).await.unwrap();
1543        assert_eq!(second.refresh, "gen0-rotated-rotated");
1544        assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 2);
1545    }
1546
1547    /// Freshness: an unexpired credential is returned without refresh or save.
1548    #[tokio::test]
1549    async fn fresh_credential_short_circuits_without_refresh() {
1550        let store = FakeStore::new(fresh_creds("keep"));
1551        let gate = Arc::new(tokio::sync::Mutex::new(()));
1552        let creds = store.ensure(gate).await.unwrap();
1553        assert_eq!(creds.refresh, "keep");
1554        assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 0);
1555        assert_eq!(store.save_calls.load(Ordering::SeqCst), 0);
1556    }
1557
1558    #[test]
1559    fn oauth_error_body_is_sanitized_and_utf8_safe() {
1560        use super::sanitize_oauth_error_body as s;
1561        assert_eq!(
1562            s(r#"{"error":"invalid_grant","error_description":"Refresh token revoked"}"#),
1563            "invalid_grant: Refresh token revoked"
1564        );
1565        assert_eq!(
1566            s(r#"{"type":"error","error":{"type":"authentication_error","message":"bad"}}"#),
1567            "authentication_error: bad"
1568        );
1569        // A proxy echoing the request body must not leak the refresh token.
1570        let echoed = s(r#"{"grant_type":"refresh_token","refresh_token":"rt-SECRET"}"#);
1571        assert!(!echoed.contains("rt-SECRET"), "{echoed}");
1572        let html = format!("<html>{}</html>", "é".repeat(300));
1573        assert_eq!(s(&html), "<non-JSON error body omitted>");
1574        // Multi-byte chars at the cap boundary never panic.
1575        let long = format!(r#"{{"error":"{}"}}"#, "é".repeat(500));
1576        assert_eq!(s(&long).chars().count(), 120);
1577        // The pre-sanitizer exchange path byte-sliced at 200, which panics
1578        // when a multi-byte char straddles the cut.
1579        let straddle = format!("{}é{}", "a".repeat(199), "b".repeat(50));
1580        assert!(!straddle.is_char_boundary(200));
1581        assert_eq!(s(&straddle), "<non-JSON error body omitted>");
1582    }
1583    /// Failure: a refresh error propagates and persists nothing — the stored
1584    /// credential is untouched so a later retry can still present it.
1585    #[tokio::test]
1586    async fn refresh_failure_persists_nothing() {
1587        let saves = Arc::new(AtomicUsize::new(0));
1588        let saves2 = saves.clone();
1589        let err = ensure_fresh_gated(
1590            Arc::new(tokio::sync::Mutex::new(())),
1591            || Ok(expired_creds("r-fail")),
1592            |_r| async move { Err("provider 500".to_string()) },
1593            move |_c| {
1594                saves2.fetch_add(1, Ordering::SeqCst);
1595                Ok(())
1596            },
1597        )
1598        .await
1599        .unwrap_err();
1600        assert!(err.contains("provider 500"));
1601        assert_eq!(
1602            saves.load(Ordering::SeqCst),
1603            0,
1604            "failed refresh must not persist"
1605        );
1606    }
1607
1608    // ── Account-aware refresh: gates, cross-process lock, CAS (G1) ───────────
1609
1610    use super::super::account::{Account, CredentialRef};
1611    use super::super::provider::OAuthProviderId;
1612    use super::super::storage::{
1613        load_provider_auth_at, remove_key_at, save_provider_auth_at,
1614        save_provider_auth_if_refresh_matches_at,
1615    };
1616    use super::{ensure_fresh_gated_locked, refresh_lock_path, CasOutcome};
1617
1618    fn named(label: &str) -> CredentialRef {
1619        CredentialRef::new(OAuthProviderId::OpenAiCodex, Account::named(label).unwrap())
1620    }
1621
1622    /// Drives `ensure_fresh_gated_locked` against a real temp auth.json with a
1623    /// fake provider that rotates tokens and records which refresh token it
1624    /// was presented.
1625    async fn ensure_on_file(
1626        path: &std::path::Path,
1627        cred: &CredentialRef,
1628        presented: Arc<StdMutex<Vec<String>>>,
1629        delay: std::time::Duration,
1630    ) -> Result<OAuthCredentials, String> {
1631        let key = cred.storage_key();
1632        let gate = super::refresh_gate(&format!("{}|{}", path.display(), key));
1633        let lock = refresh_lock_path(path, &key);
1634        let load_path = path.to_path_buf();
1635        let load_key = key.clone();
1636        ensure_fresh_gated_locked(
1637            gate,
1638            Some(lock),
1639            move || {
1640                load_provider_auth_at(&load_path, &load_key)?.ok_or_else(|| "missing".to_string())
1641            },
1642            |old| {
1643                let presented = presented.clone();
1644                async move {
1645                    presented.lock().unwrap().push(old.clone());
1646                    tokio::time::sleep(delay).await;
1647                    Ok(OAuthCredentials {
1648                        auth_type: "oauth".into(),
1649                        refresh: format!("{old}-rotated"),
1650                        access: format!("{old}-access"),
1651                        expires: crate::epoch_millis() + 3_600_000,
1652                        account_id: None,
1653                    })
1654                }
1655            },
1656            |old, fresh| save_provider_auth_if_refresh_matches_at(path, &key, old, fresh),
1657        )
1658        .await
1659    }
1660
1661    /// Two accounts of one provider refresh independently and concurrently:
1662    /// each rotates exactly once from its own refresh token, and neither
1663    /// write disturbs the other slot (or the default slot).
1664    #[tokio::test]
1665    async fn two_accounts_refresh_independently_without_cross_talk() {
1666        let dir = tempfile::tempdir().unwrap();
1667        let path = dir.path().join("auth.json");
1668        save_provider_auth_at(&path, "openai-codex", &fresh_creds("default-r")).unwrap();
1669        save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1670        save_provider_auth_at(&path, "openai-codex@b", &expired_creds("b-r")).unwrap();
1671        let presented = Arc::new(StdMutex::new(Vec::new()));
1672        let mut handles = Vec::new();
1673        for label in ["a", "b", "a", "b", "a", "b"] {
1674            let path = path.clone();
1675            let presented = presented.clone();
1676            handles.push(tokio::spawn(async move {
1677                ensure_on_file(&path, &named(label), presented, std::time::Duration::from_millis(30))
1678                    .await
1679                    .unwrap()
1680            }));
1681        }
1682        for h in handles {
1683            h.await.unwrap();
1684        }
1685        let mut seen = presented.lock().unwrap().clone();
1686        seen.sort();
1687        assert_eq!(seen, vec!["a-r".to_string(), "b-r".to_string()], "one rotation per account");
1688        let a = load_provider_auth_at(&path, "openai-codex@a").unwrap().unwrap();
1689        let b = load_provider_auth_at(&path, "openai-codex@b").unwrap().unwrap();
1690        let d = load_provider_auth_at(&path, "openai-codex").unwrap().unwrap();
1691        assert_eq!(a.refresh, "a-r-rotated");
1692        assert_eq!(b.refresh, "b-r-rotated");
1693        assert_eq!(d.refresh, "default-r", "default slot untouched");
1694    }
1695
1696    /// Cross-process rotation lock: while another "process" holds the
1697    /// per-credential flock, this refresh waits; a second account's lock is
1698    /// unaffected.
1699    #[tokio::test]
1700    async fn refresh_waits_for_cross_process_lock_of_same_credential_only() {
1701        use fs4::fs_std::FileExt;
1702        let dir = tempfile::tempdir().unwrap();
1703        let path = dir.path().join("auth.json");
1704        save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1705        save_provider_auth_at(&path, "openai-codex@b", &expired_creds("b-r")).unwrap();
1706        // Foreign holder of account a's lock (separate fd, like another process).
1707        let lock_a = refresh_lock_path(&path, "openai-codex@a");
1708        let holder = std::fs::OpenOptions::new()
1709            .create(true)
1710            .write(true)
1711            .truncate(false)
1712            .open(&lock_a)
1713            .unwrap();
1714        FileExt::lock_exclusive(&holder).unwrap();
1715
1716        let presented = Arc::new(StdMutex::new(Vec::new()));
1717        // Account b proceeds immediately.
1718        let started = std::time::Instant::now();
1719        ensure_on_file(&path, &named("b"), presented.clone(), std::time::Duration::ZERO)
1720            .await
1721            .unwrap();
1722        assert!(started.elapsed() < std::time::Duration::from_secs(5));
1723
1724        // Account a blocks until the holder releases.
1725        let path_a = path.clone();
1726        let presented_a = presented.clone();
1727        let task = tokio::spawn(async move {
1728            ensure_on_file(&path_a, &named("a"), presented_a, std::time::Duration::ZERO).await
1729        });
1730        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1731        assert!(
1732            !presented.lock().unwrap().contains(&"a-r".to_string()),
1733            "must not rotate while another process holds the credential lock"
1734        );
1735        // Simulate the other process finishing its rotation before releasing.
1736        save_provider_auth_at(&path, "openai-codex@a", &fresh_creds("a-r-by-peer")).unwrap();
1737        FileExt::unlock(&holder).unwrap();
1738        let result = task.await.unwrap().unwrap();
1739        assert_eq!(result.refresh, "a-r-by-peer", "waiter re-reads the peer's rotation");
1740        assert!(
1741            !presented.lock().unwrap().contains(&"a-r".to_string()),
1742            "no second rotation of an already-rotated token"
1743        );
1744    }
1745
1746    /// Removal during refresh: the stale rotation is never written back and
1747    /// the caller gets an error rather than a resurrected slot.
1748    #[tokio::test]
1749    async fn removal_during_refresh_is_not_resurrected() {
1750        let dir = tempfile::tempdir().unwrap();
1751        let path = dir.path().join("auth.json");
1752        save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1753        save_provider_auth_at(&path, "openai-codex@b", &fresh_creds("b-r")).unwrap();
1754        let presented = Arc::new(StdMutex::new(Vec::new()));
1755        let path2 = path.clone();
1756        let task = tokio::spawn(async move {
1757            ensure_on_file(&path2, &named("a"), presented, std::time::Duration::from_millis(300)).await
1758        });
1759        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1760        assert!(remove_key_at(&path, "openai-codex@a").unwrap());
1761        let err = task.await.unwrap().unwrap_err();
1762        assert!(err.contains("removed"), "{err}");
1763        assert!(load_provider_auth_at(&path, "openai-codex@a").unwrap().is_none());
1764        assert_eq!(
1765            load_provider_auth_at(&path, "openai-codex@b").unwrap().unwrap().refresh,
1766            "b-r"
1767        );
1768    }
1769
1770    /// Re-login during refresh: the newly stored credential wins; the
1771    /// orphaned rotation is dropped and the result is the fresh replacement.
1772    #[tokio::test]
1773    async fn relogin_during_refresh_returns_fresh_replacement_not_stale_rotation() {
1774        let dir = tempfile::tempdir().unwrap();
1775        let path = dir.path().join("auth.json");
1776        save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1777        let presented = Arc::new(StdMutex::new(Vec::new()));
1778        let path2 = path.clone();
1779        let presented2 = presented.clone();
1780        let task = tokio::spawn(async move {
1781            ensure_on_file(&path2, &named("a"), presented2, std::time::Duration::from_millis(300)).await
1782        });
1783        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1784        save_provider_auth_at(&path, "openai-codex@a", &fresh_creds("relogin-r")).unwrap();
1785        let result = task.await.unwrap().unwrap();
1786        assert_eq!(result.refresh, "relogin-r");
1787        assert_eq!(
1788            load_provider_auth_at(&path, "openai-codex@a").unwrap().unwrap().refresh,
1789            "relogin-r"
1790        );
1791        assert_eq!(presented.lock().unwrap().as_slice(), ["a-r"], "no refresh of the fresh replacement");
1792    }
1793
1794    /// Re-login during refresh with an already-expired replacement: the
1795    /// replacement itself is refreshed (bounded) — never an expired result.
1796    #[tokio::test]
1797    async fn expired_replacement_is_refreshed_not_returned() {
1798        let dir = tempfile::tempdir().unwrap();
1799        let path = dir.path().join("auth.json");
1800        save_provider_auth_at(&path, "openai-codex@a", &expired_creds("a-r")).unwrap();
1801        let presented = Arc::new(StdMutex::new(Vec::new()));
1802        let path2 = path.clone();
1803        let presented2 = presented.clone();
1804        let task = tokio::spawn(async move {
1805            ensure_on_file(&path2, &named("a"), presented2, std::time::Duration::from_millis(300)).await
1806        });
1807        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1808        save_provider_auth_at(&path, "openai-codex@a", &expired_creds("relogin-expired")).unwrap();
1809        let result = task.await.unwrap().unwrap();
1810        assert!(!super::is_token_expired(&result));
1811        assert_eq!(result.refresh, "relogin-expired-rotated");
1812        assert_eq!(
1813            presented.lock().unwrap().as_slice(),
1814            ["a-r", "relogin-expired"]
1815        );
1816    }
1817
1818    /// The rotated credential inherits `accountId` when the provider's
1819    /// refresh response omits it (Codex header pairing must survive refresh).
1820    #[tokio::test]
1821    async fn refresh_preserves_account_id_when_response_omits_it() {
1822        let dir = tempfile::tempdir().unwrap();
1823        let path = dir.path().join("auth.json");
1824        let mut stored = expired_creds("a-r");
1825        stored.account_id = Some("acct-keep".into());
1826        save_provider_auth_at(&path, "openai-codex@a", &stored).unwrap();
1827        let result = ensure_on_file(
1828            &path,
1829            &named("a"),
1830            Arc::new(StdMutex::new(Vec::new())),
1831            std::time::Duration::ZERO,
1832        )
1833        .await
1834        .unwrap();
1835        assert_eq!(result.account_id.as_deref(), Some("acct-keep"));
1836        assert_eq!(
1837            load_provider_auth_at(&path, "openai-codex@a").unwrap().unwrap().account_id.as_deref(),
1838            Some("acct-keep")
1839        );
1840    }
1841
1842    /// Seat-pinned vend: the slot must still hold the inspected seat. A
1843    /// re-login (different refresh material / account id) yields the typed
1844    /// `SeatChanged` error and no token; the check never needs the network
1845    /// for a fresh credential.
1846    #[tokio::test]
1847    async fn seat_pinned_vend_refuses_a_replaced_slot() {
1848        use super::super::account::SeatIdentity;
1849        let dir = tempfile::tempdir().unwrap();
1850        let path = dir.path().join("auth.json");
1851        let mut stored = fresh_creds("a-r");
1852        stored.account_id = Some("acct-a".into());
1853        save_provider_auth_at(&path, "openai-codex@a", &stored).unwrap();
1854        let client = reqwest::Client::new();
1855        let seat = SeatIdentity::of(OAuthProviderId::OpenAiCodex, &stored);
1856        let ok = super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, Some(&seat))
1857            .await
1858            .unwrap();
1859        assert_eq!(ok.refresh, "a-r");
1860        // Same seat, rotated refresh token (another process refreshed): the
1861        // account id anchors the identity, so the vend still pairs.
1862        let mut rotated = fresh_creds("a-r2");
1863        rotated.account_id = Some("acct-a".into());
1864        save_provider_auth_at(&path, "openai-codex@a", &rotated).unwrap();
1865        assert!(
1866            super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, Some(&seat))
1867                .await
1868                .is_ok()
1869        );
1870        // Re-login with another seat under the same alias: refused.
1871        let mut other = fresh_creds("b-r");
1872        other.account_id = Some("acct-b".into());
1873        save_provider_auth_at(&path, "openai-codex@a", &other).unwrap();
1874        let err = super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, Some(&seat))
1875            .await
1876            .unwrap_err();
1877        assert_eq!(err, super::SEAT_CHANGED);
1878        assert!(!err.contains("b-r") && !err.contains("acct"));
1879        // Unchecked vend of the same slot still works (explicit selection).
1880        assert_eq!(
1881            super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, None)
1882                .await
1883                .unwrap()
1884                .refresh,
1885            "b-r"
1886        );
1887        // Removed slot: the ordinary load-miss error, not a seat mismatch.
1888        assert!(remove_key_at(&path, "openai-codex@a").unwrap());
1889        let err = super::ensure_fresh_credential_checked_at(&client, &named("a"), &path, Some(&seat))
1890            .await
1891            .unwrap_err();
1892        assert!(err.starts_with("No credentials for "), "{err}");
1893        assert_eq!(
1894            super::SeatVendError::SeatChanged.to_string(),
1895            super::SEAT_CHANGED
1896        );
1897    }
1898
1899    /// Cancel safety: the caller of a refresh is dropped (Esc) while the
1900    /// provider exchange is in flight; the detached task still completes
1901    /// and persists the rotated token, so it is never lost.
1902    #[tokio::test]
1903    async fn detached_refresh_persists_after_caller_is_cancelled() {
1904        let dir = tempfile::tempdir().unwrap();
1905        let path = dir.path().join("auth.json");
1906        let cred = named("a");
1907        let key = cred.storage_key();
1908        save_provider_auth_at(&path, &key, &expired_creds("a-r")).unwrap();
1909        let (task_path, task_key) = (path.clone(), key.clone());
1910        let caller = super::run_detached(async move {
1911            let gate = super::refresh_gate(&format!("{}|{}", task_path.display(), task_key));
1912            let lock = refresh_lock_path(&task_path, &task_key);
1913            ensure_fresh_gated_locked(
1914                gate,
1915                Some(lock),
1916                || {
1917                    load_provider_auth_at(&task_path, &task_key)?
1918                        .ok_or_else(|| "missing".to_string())
1919                },
1920                |old| async move {
1921                    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1922                    Ok(OAuthCredentials {
1923                        auth_type: "oauth".into(),
1924                        refresh: format!("{old}-rotated"),
1925                        access: format!("{old}-access"),
1926                        expires: crate::epoch_millis() + 3_600_000,
1927                        account_id: None,
1928                    })
1929                },
1930                |old, fresh| {
1931                    save_provider_auth_if_refresh_matches_at(&task_path, &task_key, old, fresh)
1932                },
1933            )
1934            .await
1935        });
1936        // Cancel the caller mid-exchange, as Esc does via `await_or_cancel`.
1937        assert!(
1938            tokio::time::timeout(std::time::Duration::from_millis(20), caller)
1939                .await
1940                .is_err(),
1941            "caller must still be waiting when cancelled"
1942        );
1943        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1944        loop {
1945            let stored = load_provider_auth_at(&path, &key).unwrap().unwrap();
1946            if stored.refresh == "a-r-rotated" {
1947                break;
1948            }
1949            assert!(
1950                std::time::Instant::now() < deadline,
1951                "rotated token was never persisted after cancel"
1952            );
1953            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1954        }
1955    }
1956
1957    /// A slow fake refresh of account `label` in `path`, as a 'static future
1958    /// handed to `run_detached` (owned path and key, like production).
1959    fn slow_refresh_future(
1960        path: std::path::PathBuf,
1961        label: &str,
1962        started: Arc<std::sync::atomic::AtomicBool>,
1963    ) -> impl std::future::Future<Output = Result<OAuthCredentials, String>> + Send + 'static {
1964        let key = named(label).storage_key();
1965        async move {
1966            let gate = super::refresh_gate(&format!("{}|{}", path.display(), key));
1967            let lock = refresh_lock_path(&path, &key);
1968            ensure_fresh_gated_locked(
1969                gate,
1970                Some(lock),
1971                || load_provider_auth_at(&path, &key)?.ok_or_else(|| "missing".to_string()),
1972                |old| {
1973                    let started = started.clone();
1974                    async move {
1975                        started.store(true, std::sync::atomic::Ordering::SeqCst);
1976                        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
1977                        Ok(OAuthCredentials {
1978                            auth_type: "oauth".into(),
1979                            refresh: format!("{old}-rotated"),
1980                            access: format!("{old}-access"),
1981                            expires: crate::epoch_millis() + 3_600_000,
1982                            account_id: None,
1983                        })
1984                    }
1985                },
1986                |old, fresh| save_provider_auth_if_refresh_matches_at(&path, &key, old, fresh),
1987            )
1988            .await
1989        }
1990    }
1991
1992    fn wait_for_rotation(path: &std::path::Path, label: &str, want: &str) {
1993        let key = named(label).storage_key();
1994        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1995        while load_provider_auth_at(path, &key).unwrap().unwrap().refresh != want {
1996            assert!(
1997                std::time::Instant::now() < deadline,
1998                "rotated token was never persisted after the caller's runtime died"
1999            );
2000            std::thread::sleep(std::time::Duration::from_millis(20));
2001        }
2002    }
2003
2004    /// Review I1: subagent workers run on throwaway `current_thread`
2005    /// runtimes. Dropping one mid-exchange must neither kill the refresh
2006    /// (the rotated token is still saved) nor block on it.
2007    #[test]
2008    fn refresh_survives_dropping_the_callers_runtime_mid_exchange() {
2009        let dir = tempfile::tempdir().unwrap();
2010        let path = dir.path().join("auth.json");
2011        save_provider_auth_at(&path, &named("a").storage_key(), &expired_creds("a-r")).unwrap();
2012        let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
2013        let worker = tokio::runtime::Builder::new_current_thread()
2014            .enable_all()
2015            .build()
2016            .unwrap();
2017        let fut = slow_refresh_future(path.clone(), "a", started.clone());
2018        worker.spawn(super::run_detached(fut));
2019        worker.block_on(async {
2020            while !started.load(std::sync::atomic::Ordering::SeqCst) {
2021                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2022            }
2023        });
2024        let t = std::time::Instant::now();
2025        drop(worker); // the worker timed out / was cancelled mid-exchange
2026        assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed());
2027        wait_for_rotation(&path, "a", "a-r-rotated");
2028    }
2029
2030    /// Same, while the refresh is still waiting in the flock poller behind a
2031    /// peer process: dropping the worker returns promptly (the poller is not
2032    /// on the worker's blocking pool), and the refresh completes once the
2033    /// peer releases the lock.
2034    #[test]
2035    fn dropping_the_callers_runtime_while_waiting_on_the_lock_is_prompt() {
2036        use fs4::fs_std::FileExt;
2037        let dir = tempfile::tempdir().unwrap();
2038        let path = dir.path().join("auth.json");
2039        let key = named("b").storage_key();
2040        save_provider_auth_at(&path, &key, &expired_creds("b-r")).unwrap();
2041        let holder = std::fs::OpenOptions::new()
2042            .create(true)
2043            .write(true)
2044            .truncate(false)
2045            .open(refresh_lock_path(&path, &key))
2046            .unwrap();
2047        FileExt::lock_exclusive(&holder).unwrap();
2048        let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
2049        let worker = tokio::runtime::Builder::new_current_thread()
2050            .enable_all()
2051            .build()
2052            .unwrap();
2053        worker.spawn(super::run_detached(slow_refresh_future(path.clone(), "b", started.clone())));
2054        worker.block_on(async { tokio::time::sleep(std::time::Duration::from_millis(100)).await });
2055        assert!(!started.load(std::sync::atomic::Ordering::SeqCst), "must wait for the peer");
2056        let t = std::time::Instant::now();
2057        drop(worker);
2058        assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed());
2059        drop(holder);
2060        wait_for_rotation(&path, "b", "b-r-rotated");
2061    }
2062
2063    /// Ported from dev's `refresh_file_lock_excludes_second_holder_until_released`
2064    /// (#237/#238): a second holder of the same refresh lock (flock is per
2065    /// open file description, so this contends exactly like another process)
2066    /// times out as `LockBusy`, and the lock is acquirable again once the
2067    /// first holder releases it.
2068    #[tokio::test]
2069    async fn refresh_file_lock_excludes_second_holder_until_released() {
2070        let dir = tempfile::tempdir().unwrap();
2071        let path = dir.path().join("auth.json.anthropic.refresh.lock");
2072        let short = std::time::Duration::from_millis(150);
2073        let held = super::acquire_refresh_lock(path.clone(), short).await.unwrap();
2074        let err = match super::acquire_refresh_lock(path.clone(), short).await {
2075            Ok(_) => panic!("second holder must time out"),
2076            Err(e) => e,
2077        };
2078        assert_eq!(err.kind, super::RefreshFailureKind::LockBusy);
2079        assert!(err.message().contains("timed out"), "{err}");
2080        drop(held);
2081        super::acquire_refresh_lock(path, short).await.unwrap();
2082    }
2083
2084    /// The fresh-token fast path never takes the cross-process lock: a
2085    /// foreign holder does not delay a vend of an unexpired credential.
2086    #[tokio::test]
2087    async fn fresh_credential_skips_the_refresh_lock() {
2088        use fs4::fs_std::FileExt;
2089        let dir = tempfile::tempdir().unwrap();
2090        let path = dir.path().join("auth.json");
2091        let cred = named("a");
2092        save_provider_auth_at(&path, &cred.storage_key(), &fresh_creds("a-r")).unwrap();
2093        let holder = std::fs::OpenOptions::new()
2094            .create(true)
2095            .write(true)
2096            .truncate(false)
2097            .open(refresh_lock_path(&path, &cred.storage_key()))
2098            .unwrap();
2099        FileExt::lock_exclusive(&holder).unwrap();
2100        let got = tokio::time::timeout(
2101            std::time::Duration::from_secs(5),
2102            super::ensure_fresh_credential_at(&reqwest::Client::new(), &cred, &path),
2103        )
2104        .await
2105        .expect("fresh vend must not wait on the refresh lock")
2106        .unwrap();
2107        assert_eq!(got.refresh, "a-r");
2108    }
2109
2110    #[test]
2111    fn refresh_lock_path_is_per_credential_and_beside_the_file() {
2112        let path = std::path::Path::new("/tmp/x/auth.json");
2113        assert_eq!(
2114            refresh_lock_path(path, "openai-codex@astra2"),
2115            std::path::PathBuf::from("/tmp/x/auth.json.openai-codex@astra2.refresh.lock")
2116        );
2117        // The default Anthropic slot shares the lock name used by pre-#215
2118        // builds (`auth.json.<provider>.refresh.lock`), so an older client
2119        // still running alongside serializes on the same file.
2120        assert_eq!(
2121            refresh_lock_path(path, "anthropic"),
2122            std::path::PathBuf::from("/tmp/x/auth.json.anthropic.refresh.lock")
2123        );
2124        assert_ne!(
2125            refresh_lock_path(path, "openai-codex"),
2126            refresh_lock_path(path, "openai-codex@astra2")
2127        );
2128        let _ = CasOutcome::Saved;
2129    }
2130}
2131
2132#[cfg(test)]
2133mod rejected_token_tests {
2134    use super::*;
2135    use std::sync::atomic::{AtomicUsize, Ordering};
2136    use std::sync::Mutex as StdMutex;
2137
2138    fn credential(access: &str) -> OAuthCredentials {
2139        OAuthCredentials {
2140            auth_type: "oauth".into(),
2141            access: access.into(),
2142            refresh: "r0".into(),
2143            expires: crate::epoch_millis() + 3_600_000,
2144            account_id: Some("account".into()),
2145        }
2146    }
2147    async fn recover(
2148        store: &StdMutex<OAuthCredentials>,
2149        gate: Arc<Mutex<()>>,
2150        calls: &AtomicUsize,
2151        lock: Option<PathBuf>,
2152        rejected: &str,
2153    ) -> Result<OAuthCredentials, String> {
2154        ensure_gated_locked_policy(
2155            gate,
2156            lock,
2157            || Ok(store.lock().unwrap().clone()),
2158            |_| async {
2159                calls.fetch_add(1, Ordering::SeqCst);
2160                tokio::task::yield_now().await;
2161                Ok::<_, String>(credential("new"))
2162            },
2163            |_, fresh| {
2164                *store.lock().unwrap() = fresh.clone();
2165                std::future::ready(Ok(CasOutcome::Saved))
2166            },
2167            Some(rejected),
2168            |_| Ok(()),
2169        )
2170        .await
2171        .map_err(String::from)
2172    }
2173    /// Round-2 m-g: a failed runtime build is not cached; the next call
2174    /// builds again, and a success is kept.
2175    #[test]
2176    fn refresh_runtime_build_is_retried_after_a_failure() {
2177        let slot = std::sync::Mutex::new(None);
2178        let err = runtime_from(&slot, || Err(std::io::Error::other("EAGAIN"))).unwrap_err();
2179        assert_eq!(err.kind, RefreshFailureKind::Other);
2180        assert!(err.message().contains("EAGAIN"), "{err}");
2181        let built = runtime_from(&slot, || {
2182            tokio::runtime::Builder::new_current_thread().build()
2183        })
2184        .expect("second attempt builds");
2185        let again = runtime_from(&slot, || panic!("must reuse the built runtime")).unwrap();
2186        assert!(std::ptr::eq(built, again));
2187    }
2188
2189    /// m4: a stamp that cannot be written (ENOSPC, read-only lock file)
2190    /// must not switch the cooldown off: the in-process floor keeps it.
2191    #[test]
2192    fn failed_stamp_write_keeps_an_in_process_cooldown_floor() {
2193        let dir = tempfile::tempdir().unwrap();
2194        let path = dir.path().join("auth.json.muse.refresh.lock");
2195        std::fs::write(&path, "").unwrap();
2196        // Read-only handle: every stamp write fails.
2197        let lock = RefreshFileLock {
2198            file: std::fs::File::open(&path).unwrap(),
2199            path: path.clone(),
2200        };
2201        let now = 5_000_000_000;
2202        lock.record_refreshed(now);
2203        assert_eq!(std::fs::read_to_string(&path).unwrap(), "", "stamp write failed");
2204        assert_eq!(lock.last_refresh_ms(), Some(now));
2205        let cred = CredentialRef::default_for(super::super::provider::OAuthProviderId::Muse);
2206        let refused = forced_refresh_allowed(&cred, lock.last_refresh_ms(), now + 5_000).unwrap_err();
2207        assert!(matches!(refused.kind, RefreshFailureKind::Cooldown { .. }), "{refused}");
2208
2209        // Another account's lock never sees this floor.
2210        let other_path = dir.path().join("auth.json.muse@work.refresh.lock");
2211        std::fs::write(&other_path, "").unwrap();
2212        let other = RefreshFileLock {
2213            file: std::fs::File::open(&other_path).unwrap(),
2214            path: other_path,
2215        };
2216        assert_eq!(other.last_refresh_ms(), None);
2217    }
2218
2219    /// Round-2 I2: a corrupt store is not a login problem (login would fail
2220    /// on it too): the refresh says what to do, typed ActionRequired.
2221    #[test]
2222    fn corrupt_store_load_is_action_required_not_credential() {
2223        let dir = tempfile::tempdir().unwrap();
2224        let path = dir.path().join("auth.json");
2225        std::fs::write(&path, "{oops").unwrap();
2226        let cred = CredentialRef::default_for(super::super::provider::OAuthProviderId::Anthropic);
2227        let err = load_checked(&path, &cred, None).unwrap_err();
2228        assert_eq!(err.kind, RefreshFailureKind::ActionRequired, "{err}");
2229        assert!(err.message().contains("mv '"), "{err}");
2230        assert!(!err.message().contains("synaps login"), "{err}");
2231    }
2232
2233    /// m3: a refresh runtime that failed to start is a refresh error, not
2234    /// a process abort (release builds use `panic = "abort"`).
2235    #[tokio::test]
2236    async fn refresh_runtime_start_failure_is_an_error() {
2237        let err = run_detached_on(
2238            Err(RefreshFailure::other("could not start the auth refresh runtime: boom")),
2239            async { Ok::<u8, RefreshFailure>(1) },
2240        )
2241        .await
2242        .unwrap_err();
2243        assert_eq!(err.kind, RefreshFailureKind::Other);
2244        assert!(err.message().contains("refresh runtime"), "{err}");
2245        // A started runtime still runs the task.
2246        let ok: Result<u8, RefreshFailure> =
2247            run_detached_on(refresh_runtime(), async { Ok(7) }).await;
2248        assert_eq!(ok.unwrap(), 7);
2249    }
2250
2251    /// I1 (round 2): the save after a rotation must not lose the rotated
2252    /// token. A failed save parks it in a 0600 sidecar under the account's
2253    /// refresh lock; the caller still gets the fresh token, and the next
2254    /// locked load applies the sidecar instead of spending the old refresh
2255    /// token again.
2256    #[tokio::test]
2257    async fn rotated_token_survives_a_failed_save() {
2258        let dir = tempfile::tempdir().unwrap();
2259        let lock = dir.path().join("auth.json.anthropic.refresh.lock");
2260        let mut stale = credential("old-access");
2261        stale.expires = 0;
2262        let store = StdMutex::new(stale);
2263        let refreshes = AtomicUsize::new(0);
2264        let fail_saves = AtomicUsize::new(1);
2265        let run = || {
2266            ensure_gated_locked_policy(
2267                Arc::new(Mutex::new(())),
2268                Some(lock.clone()),
2269                || Ok(store.lock().unwrap().clone()),
2270                |old| {
2271                    refreshes.fetch_add(1, Ordering::SeqCst);
2272                    let mut c = credential("rotated-access");
2273                    c.refresh = format!("{old}-rotated");
2274                    async move { Ok::<_, String>(c) }
2275                },
2276                |expected: &str, fresh: &OAuthCredentials| {
2277                    let out = if fail_saves.load(Ordering::SeqCst) > 0 {
2278                        fail_saves.fetch_sub(1, Ordering::SeqCst);
2279                        Err(StoreError::Busy("timed out waiting for auth.json.lock".into()).into())
2280                    } else if store.lock().unwrap().refresh == expected {
2281                        *store.lock().unwrap() = fresh.clone();
2282                        Ok(CasOutcome::Saved)
2283                    } else {
2284                        Ok(CasOutcome::Replaced)
2285                    };
2286                    std::future::ready(out)
2287                },
2288                None,
2289                |_| Ok(()),
2290            )
2291        };
2292        let first = run().await.expect("the rotated token is handed out");
2293        assert_eq!(first.refresh, "r0-rotated");
2294        assert_eq!(store.lock().unwrap().refresh, "r0", "save failed");
2295        let pending = pending_path(&lock);
2296        assert!(pending.exists());
2297        #[cfg(unix)]
2298        {
2299            use std::os::unix::fs::PermissionsExt;
2300            let mode = std::fs::metadata(&pending).unwrap().permissions().mode();
2301            assert_eq!(mode & 0o777, 0o600);
2302        }
2303
2304        // Next locked load: the sidecar is applied; the spent "r0" is never
2305        // sent again.
2306        let second = run().await.unwrap();
2307        assert_eq!(second.refresh, "r0-rotated");
2308        assert_eq!(store.lock().unwrap().refresh, "r0-rotated");
2309        assert_eq!(refreshes.load(Ordering::SeqCst), 1);
2310        assert!(!pending.exists());
2311    }
2312
2313    /// Round 3: an expired stored credential, a parked sidecar and a save
2314    /// that keeps failing with `save_err`.
2315    async fn blocked_apply(
2316        save_out: fn() -> Result<CasOutcome, RefreshFailure>,
2317        parked_expires: u64,
2318        rejected: Option<&str>,
2319        serve_parked: bool,
2320    ) -> (Result<OAuthCredentials, RefreshFailure>, bool, usize) {
2321        let dir = tempfile::tempdir().unwrap();
2322        let lock = dir.path().join("auth.json.anthropic.refresh.lock");
2323        let file_lock = RefreshFileLock {
2324            file: std::fs::File::create(&lock).unwrap(),
2325            path: lock.clone(),
2326        };
2327        let mut parked = credential("parked-access");
2328        parked.refresh = "parked-r".into();
2329        parked.expires = parked_expires;
2330        file_lock.park_pending("r0", &parked).unwrap();
2331        drop(file_lock);
2332        let mut stale = credential("old-access");
2333        stale.expires = 0;
2334        let refreshes = AtomicUsize::new(0);
2335        let out = ensure_gated_locked_policy_with(
2336            Arc::new(Mutex::new(())),
2337            Some(lock.clone()),
2338            || Ok(stale.clone()),
2339            |_| {
2340                refreshes.fetch_add(1, Ordering::SeqCst);
2341                async { Ok::<_, String>(credential("never")) }
2342            },
2343            |_: &str, _: &OAuthCredentials| std::future::ready(save_out()),
2344            rejected,
2345            |_| Ok(()),
2346            serve_parked,
2347            None,
2348        )
2349        .await;
2350        (out, pending_path(&lock).exists(), refreshes.load(Ordering::SeqCst))
2351    }
2352
2353    /// R2-m3a: while the sidecar can't be applied, its still-valid access
2354    /// token is served (never its refresh token); the sidecar stays and the
2355    /// spent token is not sent.
2356    #[tokio::test]
2357    async fn parked_access_token_is_served_while_the_sidecar_cannot_be_applied() {
2358        let busy = || Err(StoreError::Busy("timed out waiting for auth.json.lock".into()).into());
2359        let live = crate::epoch_millis() + 3_600_000;
2360        let (out, kept, refreshes) = blocked_apply(busy, live, None, true).await;
2361        let served = out.expect("parked access token served");
2362        assert_eq!(served.access, "parked-access");
2363        assert!(served.refresh.is_empty(), "the parked refresh token never leaves");
2364        assert!(kept);
2365        assert_eq!(refreshes, 0);
2366        // Not served: seat-pinned callers, a parked token that was just
2367        // rejected, an expired one.
2368        for (expires, rejected, serve) in [
2369            (live, None, false),
2370            (live, Some("parked-access"), true),
2371            (0, None, true),
2372        ] {
2373            let (out, kept, refreshes) = blocked_apply(busy, expires, rejected, serve).await;
2374            assert!(out.is_err());
2375            assert!(kept);
2376            assert_eq!(refreshes, 0);
2377        }
2378    }
2379
2380    /// R2-m3b: a corrupt store behind the sidecar is ActionRequired with the
2381    /// mv text, not "retry shortly".
2382    #[tokio::test]
2383    async fn sidecar_blocked_by_a_corrupt_store_is_action_required() {
2384        let corrupt = || {
2385            Err(StoreError::Corrupt(
2386                "/x/auth.json is not valid JSON; Move it aside with `mv '/x/auth.json' '/x/auth.json.broken-1'`"
2387                    .into(),
2388            )
2389            .into())
2390        };
2391        let (out, kept, _) = blocked_apply(corrupt, 0, None, false).await;
2392        let err = out.unwrap_err();
2393        assert_eq!(err.kind, RefreshFailureKind::ActionRequired, "{err}");
2394        assert!(err.message().contains("mv '/x/auth.json'"), "{err}");
2395        assert!(!err.message().contains("retry shortly"), "{err}");
2396        assert!(kept);
2397    }
2398
2399    /// R2-m3c: after the user follows the mv advice, the store is missing.
2400    /// The sidecar (the only live refresh token) is kept and the user is
2401    /// told, never deleted as "removed".
2402    #[tokio::test]
2403    async fn sidecar_is_kept_when_the_store_is_missing() {
2404        let (out, kept, refreshes) =
2405            blocked_apply(|| Ok(CasOutcome::StoreMissing), 0, None, false).await;
2406        let err = out.unwrap_err();
2407        assert_eq!(err.kind, RefreshFailureKind::ActionRequired, "{err}");
2408        assert!(err.message().contains("no longer exists"), "{err}");
2409        assert!(kept, "the only live token survives");
2410        assert_eq!(refreshes, 0);
2411    }
2412
2413    /// A refusal memory that holds every login it was told was refused.
2414    #[derive(Default)]
2415    struct Memory {
2416        told: StdMutex<Vec<(String, RefreshFailure)>>,
2417    }
2418
2419    impl Memory {
2420        /// (access token of the refused login, kind) for each record.
2421        fn told(&self) -> Vec<(String, RefreshFailureKind)> {
2422            let told = self.told.lock().unwrap();
2423            told.iter().map(|(access, f)| (access.clone(), f.kind)).collect()
2424        }
2425    }
2426
2427    impl RefusalMemory for Memory {
2428        fn held(&self, creds: &OAuthCredentials) -> Option<RefreshFailure> {
2429            let told = self.told.lock().unwrap();
2430            told.iter()
2431                .find(|(access, _)| *access == creds.access)
2432                .map(|(_, f)| f.clone())
2433        }
2434
2435        fn refused(&self, creds: &OAuthCredentials, failure: &RefreshFailure) {
2436            let record = (creds.access.clone(), failure.clone());
2437            self.told.lock().unwrap().push(record);
2438        }
2439    }
2440
2441    fn expired(access: &str) -> OAuthCredentials {
2442        let mut creds = credential(access);
2443        creds.expires = 0;
2444        creds
2445    }
2446
2447    /// One gated refresh of the login in `stored`; its exchange is refused
2448    /// (400) after a short delay. The first exchange replaces the stored
2449    /// login with `rotate_to` while it is in flight.
2450    async fn refused_refresh(
2451        gate: Arc<Mutex<()>>,
2452        stored: &StdMutex<OAuthCredentials>,
2453        rotate_to: Option<&OAuthCredentials>,
2454        exchanges: &AtomicUsize,
2455        memory: &Memory,
2456    ) -> Result<OAuthCredentials, RefreshFailure> {
2457        ensure_gated_locked_policy_with(
2458            gate,
2459            None,
2460            || Ok(stored.lock().unwrap().clone()),
2461            |_| {
2462                if exchanges.fetch_add(1, Ordering::SeqCst) == 0 {
2463                    if let Some(next) = rotate_to {
2464                        *stored.lock().unwrap() = next.clone();
2465                    }
2466                }
2467                async {
2468                    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2469                    Err::<OAuthCredentials, _>(RefreshFailure::status(400, "invalid_grant"))
2470                }
2471            },
2472            |_: &str, _: &OAuthCredentials| std::future::ready(Ok(CasOutcome::Saved)),
2473            None,
2474            |_| Ok(()),
2475            false,
2476            Some(memory as &dyn RefusalMemory),
2477        )
2478        .await
2479    }
2480
2481    /// s2-r1 blocker 1, at the gate: a second caller of the slot waits while
2482    /// the first one's exchange is refused, then re-reads the login under
2483    /// the gate, finds that refusal and sends nothing.
2484    #[tokio::test]
2485    async fn a_waiter_takes_its_predecessors_refusal_without_an_exchange() {
2486        let gate = Arc::new(Mutex::new(()));
2487        let stored = StdMutex::new(expired("old-access"));
2488        let (exchanges, memory) = (AtomicUsize::new(0), Memory::default());
2489        let (first, second) = tokio::join!(
2490            refused_refresh(gate.clone(), &stored, None, &exchanges, &memory),
2491            refused_refresh(gate.clone(), &stored, None, &exchanges, &memory),
2492        );
2493        assert_eq!(exchanges.load(Ordering::SeqCst), 1, "one exchange");
2494        for out in [first, second] {
2495            let err = out.unwrap_err();
2496            assert_eq!(err.kind, RefreshFailureKind::Credential, "{err}");
2497            assert_eq!(err.http_status(), Some(400), "{err}");
2498        }
2499        assert_eq!(
2500            memory.told(),
2501            vec![("old-access".to_string(), RefreshFailureKind::Credential)]
2502        );
2503    }
2504
2505    /// s2-r1 backlog 1: the record is of the login actually refused. The
2506    /// stored login changes while the first exchange is in flight; the
2507    /// waiter re-reads the new login, which nothing holds, so it is
2508    /// exchanged once and its refusal recorded for it; a third call is held.
2509    #[tokio::test]
2510    async fn the_refusal_is_recorded_for_the_login_read_under_the_gate() {
2511        let gate = Arc::new(Mutex::new(()));
2512        let stored = StdMutex::new(expired("old-access"));
2513        let rotated = expired("rotated-access");
2514        let (exchanges, memory) = (AtomicUsize::new(0), Memory::default());
2515        let (first, second) = tokio::join!(
2516            refused_refresh(gate.clone(), &stored, Some(&rotated), &exchanges, &memory),
2517            refused_refresh(gate.clone(), &stored, Some(&rotated), &exchanges, &memory),
2518        );
2519        first.unwrap_err();
2520        second.unwrap_err();
2521        assert_eq!(exchanges.load(Ordering::SeqCst), 2);
2522        let refused = RefreshFailureKind::Credential;
2523        assert_eq!(
2524            memory.told(),
2525            vec![
2526                ("old-access".to_string(), refused),
2527                ("rotated-access".to_string(), refused),
2528            ]
2529        );
2530        refused_refresh(gate, &stored, None, &exchanges, &memory)
2531            .await
2532            .unwrap_err();
2533        assert_eq!(exchanges.load(Ordering::SeqCst), 2, "the new login is held");
2534    }
2535
2536    /// s2-r1 backlog 2: only the exchange's own failure reaches the memory.
2537    /// A corrupt store at the re-read (ActionRequired) and a failed save
2538    /// after a good exchange (Credential) are local store failures, never
2539    /// recorded as refusals.
2540    #[tokio::test]
2541    async fn store_failures_never_reach_the_refusal_memory() {
2542        let memory = Memory::default();
2543        let corrupt = ensure_gated_locked_policy_with(
2544            Arc::new(Mutex::new(())),
2545            None,
2546            || {
2547                Err::<OAuthCredentials, RefreshFailure>(
2548                    StoreError::Corrupt("auth.json is not valid JSON".into()).into(),
2549                )
2550            },
2551            |_| async { Ok::<_, String>(credential("never")) },
2552            |_: &str, _: &OAuthCredentials| std::future::ready(Ok(CasOutcome::Saved)),
2553            None,
2554            |_| Ok(()),
2555            false,
2556            Some(&memory as &dyn RefusalMemory),
2557        )
2558        .await
2559        .unwrap_err();
2560        assert_eq!(corrupt.kind, RefreshFailureKind::ActionRequired, "{corrupt}");
2561
2562        let stale = expired("old-access");
2563        let unsaved = ensure_gated_locked_policy_with(
2564            Arc::new(Mutex::new(())),
2565            None,
2566            || Ok(stale.clone()),
2567            |_| async { Ok::<_, String>(credential("rotated")) },
2568            |_: &str, _: &OAuthCredentials| {
2569                std::future::ready(Err::<CasOutcome, RefreshFailure>(
2570                    StoreError::Failed("disk full".into()).into(),
2571                ))
2572            },
2573            None,
2574            |_| Ok(()),
2575            false,
2576            Some(&memory as &dyn RefusalMemory),
2577        )
2578        .await
2579        .unwrap_err();
2580        assert_eq!(unsaved.kind, RefreshFailureKind::Credential, "{unsaved}");
2581        assert!(memory.told().is_empty(), "{:?}", memory.told());
2582    }
2583
2584    /// A store that vanishes between the provider exchange and the save
2585    /// parks the rotation instead of dropping it.
2586    #[tokio::test]
2587    async fn rotation_is_parked_when_the_store_vanishes_mid_refresh() {
2588        let dir = tempfile::tempdir().unwrap();
2589        let lock = dir.path().join("auth.json.anthropic.refresh.lock");
2590        let mut stale = credential("old-access");
2591        stale.expires = 0;
2592        let fresh = ensure_gated_locked_policy(
2593            Arc::new(Mutex::new(())),
2594            Some(lock.clone()),
2595            || Ok(stale.clone()),
2596            |_| async { Ok::<_, String>(credential("rotated")) },
2597            |_: &str, _: &OAuthCredentials| std::future::ready(Ok(CasOutcome::StoreMissing)),
2598            None,
2599            |_| Ok(()),
2600        )
2601        .await
2602        .unwrap();
2603        assert_eq!(fresh.access, "rotated");
2604        assert!(pending_path(&lock).exists());
2605    }
2606
2607    /// A sidecar the store has moved past (re-login, removal) is dropped;
2608    /// one that can't be applied yet blocks the refresh instead of spending
2609    /// the old token.
2610    #[tokio::test]
2611    async fn stale_or_unapplied_sidecar_is_handled_under_the_lock() {
2612        let dir = tempfile::tempdir().unwrap();
2613        let lock = dir.path().join("auth.json.anthropic.refresh.lock");
2614        let file_lock = RefreshFileLock {
2615            file: std::fs::File::create(&lock).unwrap(),
2616            path: lock.clone(),
2617        };
2618        let mut parked = credential("parked");
2619        parked.refresh = "parked-r".into();
2620        file_lock.park_pending("r-before-relogin", &parked).unwrap();
2621
2622        // Store moved on: Replaced -> sidecar deleted, nothing written.
2623        file_lock
2624            .apply_pending(&|_: &str, _: &OAuthCredentials| {
2625                std::future::ready(Ok(CasOutcome::Replaced))
2626            })
2627            .await
2628            .unwrap();
2629        assert!(!pending_path(&lock).exists());
2630
2631        // Store still busy: error, sidecar kept for the next try.
2632        file_lock.park_pending("r0", &parked).unwrap();
2633        let err = file_lock
2634            .apply_pending(&|_: &str, _: &OAuthCredentials| {
2635                std::future::ready(Err(StoreError::Busy("busy".into()).into()))
2636            })
2637            .await
2638            .unwrap_err()
2639            .0;
2640        assert_eq!(err.kind, RefreshFailureKind::Other);
2641        assert!(pending_path(&lock).exists());
2642    }
2643
2644    /// A store-lock timeout names the store lock (it is not another refresh
2645    /// of this account), and the refresh save waits far longer than a login
2646    /// or static-key save.
2647    #[test]
2648    fn store_lock_timeout_is_described_honestly() {
2649        let busy: RefreshFailure =
2650            StoreError::Busy("timed out waiting for auth.json.lock".into()).into();
2651        assert_eq!(busy.kind, RefreshFailureKind::Other);
2652        assert!(busy.message().contains("auth.json.lock"));
2653        assert!(REFRESH_SAVE_WAIT >= REFRESH_LOCK_WAIT);
2654        assert!(REFRESH_SAVE_WAIT >= std::time::Duration::from_secs(120));
2655        assert_eq!(super::super::storage::STORE_LOCK_WAIT, std::time::Duration::from_secs(15));
2656    }
2657
2658    #[tokio::test]
2659    async fn changed_token_is_adopted_without_refresh() {
2660        let store = StdMutex::new(credential("peer-token"));
2661        let calls = AtomicUsize::new(0);
2662        let got = recover(&store, Arc::new(Mutex::new(())), &calls, None, "rejected")
2663            .await
2664            .unwrap();
2665        assert_eq!(got.access, "peer-token");
2666        assert_eq!(calls.load(Ordering::SeqCst), 0);
2667    }
2668    #[tokio::test]
2669    async fn unexpired_rejected_token_rotates_once_for_concurrent_waiters() {
2670        let store = StdMutex::new(credential("rejected"));
2671        let calls = AtomicUsize::new(0);
2672        let gate = Arc::new(Mutex::new(()));
2673        let (a, b) = tokio::join!(
2674            recover(&store, gate.clone(), &calls, None, "rejected"),
2675            recover(&store, gate, &calls, None, "rejected")
2676        );
2677        assert_eq!(a.unwrap().access, "new");
2678        assert_eq!(b.unwrap().access, "new");
2679        assert_eq!(calls.load(Ordering::SeqCst), 1);
2680    }
2681    #[tokio::test]
2682    async fn separate_process_gates_serialize_on_the_refresh_file_lock() {
2683        let dir = tempfile::tempdir().unwrap();
2684        let lock = dir.path().join("refresh.lock");
2685        let store = StdMutex::new(credential("rejected"));
2686        let calls = AtomicUsize::new(0);
2687        let (a, b) = tokio::join!(
2688            recover(
2689                &store,
2690                Arc::new(Mutex::new(())),
2691                &calls,
2692                Some(lock.clone()),
2693                "rejected"
2694            ),
2695            recover(
2696                &store,
2697                Arc::new(Mutex::new(())),
2698                &calls,
2699                Some(lock),
2700                "rejected"
2701            )
2702        );
2703        assert_eq!(a.unwrap().access, "new");
2704        assert_eq!(b.unwrap().access, "new");
2705        assert_eq!(calls.load(Ordering::SeqCst), 1);
2706    }
2707    #[tokio::test]
2708    async fn removed_slot_is_not_resurrected_by_forced_refresh() {
2709        let result = ensure_gated_locked_policy(
2710            Arc::new(Mutex::new(())),
2711            None,
2712            || Ok(credential("rejected")),
2713            |_| async { Ok::<_, String>(credential("new")) },
2714            |_, _| std::future::ready(Ok(CasOutcome::Removed)),
2715            Some("rejected"),
2716            |_| Ok(()),
2717        )
2718        .await;
2719        assert!(result.unwrap_err().to_string().contains("removed"));
2720    }
2721
2722    // ── Forced-refresh rate limit (ported from #237/#238, now per account) ──
2723
2724    /// Single-credential fake store driven through the production policy.
2725    struct FakeStore {
2726        creds: StdMutex<OAuthCredentials>,
2727        refresh_calls: AtomicUsize,
2728        save_calls: AtomicUsize,
2729    }
2730
2731    impl FakeStore {
2732        fn new(initial: OAuthCredentials) -> Self {
2733            Self {
2734                creds: StdMutex::new(initial),
2735                refresh_calls: AtomicUsize::new(0),
2736                save_calls: AtomicUsize::new(0),
2737            }
2738        }
2739
2740        async fn ensure_rejecting(
2741            &self,
2742            gate: Arc<Mutex<()>>,
2743            rejected: &str,
2744        ) -> Result<OAuthCredentials, String> {
2745            ensure_gated_locked_policy(
2746                gate,
2747                None,
2748                || Ok(self.creds.lock().unwrap().clone()),
2749                |old_refresh| async move {
2750                    self.refresh_calls.fetch_add(1, Ordering::SeqCst);
2751                    tokio::task::yield_now().await;
2752                    Ok::<_, String>(OAuthCredentials {
2753                        auth_type: "oauth".into(),
2754                        refresh: format!("{old_refresh}-rotated"),
2755                        access: "fresh-access".into(),
2756                        expires: crate::epoch_millis() + 3_600_000,
2757                        account_id: None,
2758                    })
2759                },
2760                |_, c| {
2761                    self.save_calls.fetch_add(1, Ordering::SeqCst);
2762                    *self.creds.lock().unwrap() = c.clone();
2763                    std::future::ready(Ok(CasOutcome::Saved))
2764                },
2765                Some(rejected),
2766                |_| Ok(()),
2767            )
2768            .await
2769            .map_err(String::from)
2770        }
2771    }
2772
2773    /// Revoked-but-unexpired: the stored credential still holds the exact
2774    /// access token the provider rejected -> refresh is forced.
2775    #[tokio::test]
2776    async fn rejected_unexpired_token_forces_refresh() {
2777        let store = FakeStore::new(credential("rejected"));
2778        let creds = store
2779            .ensure_rejecting(Arc::new(Mutex::new(())), "rejected")
2780            .await
2781            .unwrap();
2782        assert_eq!(creds.access, "fresh-access");
2783        assert_eq!(creds.refresh, "r0-rotated");
2784        assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 1);
2785        assert_eq!(store.save_calls.load(Ordering::SeqCst), 1);
2786    }
2787
2788    /// Disk already rotated (external `synaps login` / cron / earlier waiter):
2789    /// the rejected token no longer matches -> no refresh, disk creds returned.
2790    #[tokio::test]
2791    async fn rejected_token_differs_from_disk_uses_disk_without_refresh() {
2792        let store = FakeStore::new(credential("keep"));
2793        let creds = store
2794            .ensure_rejecting(Arc::new(Mutex::new(())), "some-old-token")
2795            .await
2796            .unwrap();
2797        assert_eq!(creds.access, "keep");
2798        assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 0);
2799        assert_eq!(store.save_calls.load(Ordering::SeqCst), 0);
2800    }
2801
2802    /// Concurrent 401s with the same rejected token: exactly one forced
2803    /// refresh; later waiters see the rotated token and skip.
2804    #[tokio::test]
2805    async fn concurrent_rejections_single_flight() {
2806        let store = FakeStore::new(credential("rejected"));
2807        let gate = Arc::new(Mutex::new(()));
2808        let waiters: Vec<_> = (0..8)
2809            .map(|_| store.ensure_rejecting(gate.clone(), "rejected"))
2810            .collect();
2811        for got in futures::future::join_all(waiters).await {
2812            assert_eq!(got.unwrap().access, "fresh-access");
2813        }
2814        assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 1);
2815    }
2816
2817    /// Rate limit: a forced refresh denied by `may_force` spends nothing.
2818    #[tokio::test]
2819    async fn forced_refresh_denied_by_cooldown_spends_nothing() {
2820        let refreshes = AtomicUsize::new(0);
2821        let err = ensure_gated_locked_policy(
2822            Arc::new(Mutex::new(())),
2823            None,
2824            || Ok(credential("rejected")),
2825            |_| async {
2826                refreshes.fetch_add(1, Ordering::SeqCst);
2827                Ok::<_, String>(credential("never"))
2828            },
2829            |_, _| std::future::ready(Ok(CasOutcome::Saved)),
2830            Some("rejected"),
2831            |_| Err("cooldown".to_string().into()),
2832        )
2833        .await
2834        .unwrap_err();
2835        assert_eq!(err.to_string(), "cooldown");
2836        assert_eq!(refreshes.load(Ordering::SeqCst), 0);
2837    }
2838
2839    /// An EXPIRED credential refreshes even inside the cooldown: the rate
2840    /// limit applies only to forcing an unexpired token. It also refreshes
2841    /// when the caller's rejected token is no longer the stored one.
2842    #[tokio::test]
2843    async fn expired_credential_ignores_force_cooldown() {
2844        for rejected in ["stale", "some-other-token"] {
2845            let creds = ensure_gated_locked_policy(
2846                Arc::new(Mutex::new(())),
2847                None,
2848                || {
2849                    let mut c = credential("stale");
2850                    c.expires = 0;
2851                    Ok(c)
2852                },
2853                |_| async { Ok::<_, String>(credential("new")) },
2854                |_, _| std::future::ready(Ok(CasOutcome::Saved)),
2855                Some(rejected),
2856                |_| Err("cooldown".to_string().into()),
2857            )
2858            .await
2859            .unwrap();
2860            assert_eq!(creds.access, "new", "rejected = {rejected}");
2861        }
2862    }
2863
2864    use super::super::account::Account;
2865    use super::super::provider::OAuthProviderId;
2866    use super::super::storage::save_provider_auth_at;
2867
2868    fn account(label: &str) -> CredentialRef {
2869        CredentialRef::new(OAuthProviderId::Anthropic, Account::named(label).unwrap())
2870    }
2871
2872    /// auth.json seeded long ago. (The mtime no longer matters to the
2873    /// cooldown; the old timestamp keeps these fixtures neutral.)
2874    fn seed_old(path: &Path, entries: &[(&CredentialRef, &str)]) {
2875        for (cred, access) in entries {
2876            save_provider_auth_at(path, &cred.storage_key(), &credential(access)).unwrap();
2877        }
2878        let old = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
2879        std::fs::File::options()
2880            .write(true)
2881            .open(path)
2882            .unwrap()
2883            .set_modified(old)
2884            .unwrap();
2885    }
2886
2887    /// Production composition (gate + lock + CAS save + cooldown) with a
2888    /// fake provider exchange that mints a distinct access token per call.
2889    async fn forced(
2890        path: &Path,
2891        cred: &CredentialRef,
2892        rejected: &str,
2893        calls: &AtomicUsize,
2894    ) -> Result<OAuthCredentials, String> {
2895        refresh_credential_locked(cred, path, None, Some(rejected), None, |old| async move {
2896            let n = calls.fetch_add(1, Ordering::SeqCst);
2897            Ok::<_, String>(OAuthCredentials {
2898                auth_type: "oauth".into(),
2899                refresh: format!("{old}-rotated"),
2900                access: format!("minted-{n}"),
2901                expires: crate::epoch_millis() + 3_600_000,
2902                account_id: None,
2903            })
2904        })
2905        .await
2906        .map_err(String::from)
2907    }
2908
2909    /// A permanent 401 stops spending refresh tokens: the first rejection
2910    /// forces one refresh; when the provider rejects the freshly minted
2911    /// token too, the cooldown refuses a second spend. A second account of
2912    /// the same provider has its own cooldown and still recovers.
2913    #[tokio::test]
2914    async fn forced_refresh_cooldown_is_per_account_and_stops_a_persistent_401() {
2915        let dir = tempfile::tempdir().unwrap();
2916        let path = dir.path().join("auth.json");
2917        let (a, b) = (account("a"), account("b"));
2918        seed_old(&path, &[(&a, "a-rejected"), (&b, "b-rejected")]);
2919        let calls = AtomicUsize::new(0);
2920
2921        let first = forced(&path, &a, "a-rejected", &calls).await.unwrap();
2922        assert_eq!(calls.load(Ordering::SeqCst), 1);
2923        let err = forced(&path, &a, &first.access, &calls).await.unwrap_err();
2924        assert!(err.contains("not refreshing again"), "{err}");
2925        assert!(!err.contains(&first.access), "error must not carry tokens: {err}");
2926        assert_eq!(calls.load(Ordering::SeqCst), 1, "cooldown spends nothing");
2927
2928        // Account b: a's refresh stamped only a's lock file.
2929        forced(&path, &b, "b-rejected", &calls).await.unwrap();
2930        assert_eq!(calls.load(Ordering::SeqCst), 2, "b has its own cooldown");
2931    }
2932
2933    /// `synaps auth state` sees each slot's last refresh exchange: its kind
2934    /// when the provider refused, none when it issued a token. A forced
2935    /// refresh refused by the cooldown is no exchange, and a slot's record
2936    /// is its own.
2937    #[tokio::test]
2938    async fn refresh_exchanges_are_recorded_per_slot() {
2939        let dir = tempfile::tempdir().unwrap();
2940        let path = dir.path().join("auth.json");
2941        let (a, b) = (account("a"), account("b"));
2942        seed_old(&path, &[(&a, "a-rejected"), (&b, "b-rejected")]);
2943        let key = a.storage_key();
2944        assert_eq!(last_refresh_exchange(&path, &key), None);
2945
2946        let refuse = |_old: String| async {
2947            Err::<OAuthCredentials, _>(RefreshFailure::status(503, "token endpoint answered 503"))
2948        };
2949        let err = refresh_credential_locked(&a, &path, None, Some("a-rejected"), None, refuse)
2950            .await
2951            .unwrap_err();
2952        assert_eq!(err.kind, RefreshFailureKind::Transient);
2953        let failed = last_refresh_exchange(&path, &key).expect("failed exchange recorded");
2954        assert_eq!(failed.failure, Some(RefreshFailureKind::Transient));
2955        // r2 item 8: the HTTP status the token endpoint answered is kept.
2956        assert_eq!(failed.http_status, Some(503));
2957
2958        let calls = AtomicUsize::new(0);
2959        let first = forced(&path, &a, "a-rejected", &calls).await.unwrap();
2960        let ok = last_refresh_exchange(&path, &key).expect("exchange recorded");
2961        assert_eq!(ok.failure, None);
2962        assert_eq!(ok.http_status, None);
2963        assert!(ok.at_ms >= failed.at_ms);
2964
2965        forced(&path, &a, &first.access, &calls).await.unwrap_err();
2966        assert_eq!(calls.load(Ordering::SeqCst), 1, "the cooldown refused it");
2967        assert_eq!(last_refresh_exchange(&path, &key), Some(ok));
2968        assert_eq!(last_refresh_exchange(&path, &b.storage_key()), None);
2969    }
2970
2971    /// What another synaps process leaves behind after refreshing `cred`:
2972    /// the refresh time stamped into that account's lock file.
2973    fn stamp_as_other_process(path: &Path, cred: &CredentialRef, stamp: &str) {
2974        std::fs::write(refresh_lock_path(path, &cred.storage_key()), stamp).unwrap();
2975    }
2976
2977    fn now_stamp() -> String {
2978        crate::epoch_millis().to_string()
2979    }
2980
2981    /// Another process refreshed account A seconds ago (stamp in A's lock
2982    /// file): a forced refresh of A is refused and spends nothing.
2983    #[tokio::test]
2984    async fn other_process_refresh_stamp_blocks_forced_refresh_of_that_account() {
2985        let dir = tempfile::tempdir().unwrap();
2986        let path = dir.path().join("auth.json");
2987        let a = account("a");
2988        seed_old(&path, &[(&a, "a-rejected")]);
2989        stamp_as_other_process(&path, &a, &now_stamp());
2990        let calls = AtomicUsize::new(0);
2991        let err = forced(&path, &a, "a-rejected", &calls).await.unwrap_err();
2992        assert!(err.contains("not refreshing again"), "{err}");
2993        assert_eq!(calls.load(Ordering::SeqCst), 0);
2994    }
2995
2996    /// A's stamp never cools down B in the same auth.json.
2997    #[tokio::test]
2998    async fn refresh_stamp_of_one_account_does_not_block_another() {
2999        let dir = tempfile::tempdir().unwrap();
3000        let path = dir.path().join("auth.json");
3001        let (a, b) = (account("a"), account("b"));
3002        seed_old(&path, &[(&a, "a-rejected"), (&b, "b-rejected")]);
3003        stamp_as_other_process(&path, &a, &now_stamp());
3004        let calls = AtomicUsize::new(0);
3005        forced(&path, &b, "b-rejected", &calls).await.unwrap();
3006        assert_eq!(calls.load(Ordering::SeqCst), 1);
3007    }
3008
3009    /// A fresh auth.json write (e.g. `synaps login` of any account) with no
3010    /// refresh stamp no longer blocks a forced refresh.
3011    #[tokio::test]
3012    async fn foreign_auth_file_write_without_a_stamp_does_not_block() {
3013        let dir = tempfile::tempdir().unwrap();
3014        let path = dir.path().join("auth.json");
3015        let a = account("a");
3016        // Written "now", by something other than a refresh.
3017        save_provider_auth_at(&path, &a.storage_key(), &credential("a-rejected")).unwrap();
3018        let calls = AtomicUsize::new(0);
3019        forced(&path, &a, "a-rejected", &calls).await.unwrap();
3020        assert_eq!(calls.load(Ordering::SeqCst), 1);
3021    }
3022
3023    /// Review I3's leak: another process force-refreshed A, then THIS process
3024    /// refreshed B (so the last auth.json write is ours). A 401 on A's new
3025    /// token must still be refused — the cooldown is A's own stamp, not a
3026    /// file-level "who wrote last" signal.
3027    #[tokio::test]
3028    async fn own_write_after_another_process_refreshed_a_does_not_hide_a_s_cooldown() {
3029        let dir = tempfile::tempdir().unwrap();
3030        let path = dir.path().join("auth.json");
3031        let (a, b) = (account("a"), account("b"));
3032        seed_old(&path, &[(&a, "a-new-from-peer"), (&b, "b-rejected")]);
3033        stamp_as_other_process(&path, &a, &now_stamp());
3034        let calls = AtomicUsize::new(0);
3035        forced(&path, &b, "b-rejected", &calls).await.unwrap();
3036        assert_eq!(calls.load(Ordering::SeqCst), 1, "b refreshed and wrote auth.json");
3037        let err = forced(&path, &a, "a-new-from-peer", &calls).await.unwrap_err();
3038        assert!(err.contains("not refreshing again"), "{err}");
3039        assert_eq!(calls.load(Ordering::SeqCst), 1, "a's refresh token not spent again");
3040    }
3041
3042    /// The cooldown only gates FORCING an unexpired token: an expired one
3043    /// refreshes even right after another process stamped it.
3044    #[tokio::test]
3045    async fn expired_token_refreshes_inside_the_stamp_cooldown() {
3046        let dir = tempfile::tempdir().unwrap();
3047        let path = dir.path().join("auth.json");
3048        let a = account("a");
3049        let mut expired = credential("a-old");
3050        expired.expires = 0;
3051        save_provider_auth_at(&path, &a.storage_key(), &expired).unwrap();
3052        stamp_as_other_process(&path, &a, &now_stamp());
3053        let calls = AtomicUsize::new(0);
3054        let got = forced(&path, &a, "a-old", &calls).await.unwrap();
3055        assert_eq!(got.access, "minted-0");
3056        assert_eq!(calls.load(Ordering::SeqCst), 1);
3057    }
3058
3059    /// The stamp is advisory: unreadable or future-dated values allow.
3060    #[tokio::test]
3061    async fn garbage_or_future_stamps_allow_a_forced_refresh() {
3062        let future = (crate::epoch_millis() + 3_600_000).to_string();
3063        for stamp in ["", "not-a-number", "-5", "12abc", future.as_str()] {
3064            let dir = tempfile::tempdir().unwrap();
3065            let path = dir.path().join("auth.json");
3066            let a = account("a");
3067            seed_old(&path, &[(&a, "a-rejected")]);
3068            stamp_as_other_process(&path, &a, stamp);
3069            let calls = AtomicUsize::new(0);
3070            forced(&path, &a, "a-rejected", &calls)
3071                .await
3072                .unwrap_or_else(|e| panic!("stamp {stamp:?}: {e}"));
3073            assert_eq!(calls.load(Ordering::SeqCst), 1, "stamp {stamp:?}");
3074        }
3075    }
3076
3077    /// Our refresh writes the stamp, and an older client that opens and
3078    /// flocks the same lock file the pre-#215 way (`create`, `write`,
3079    /// `truncate(false)`, never writing it) leaves it intact.
3080    #[tokio::test]
3081    async fn refresh_stamp_survives_an_old_client_style_lock_open() {
3082        use fs4::fs_std::FileExt;
3083        let dir = tempfile::tempdir().unwrap();
3084        let path = dir.path().join("auth.json");
3085        let a = account("a");
3086        seed_old(&path, &[(&a, "a-rejected")]);
3087        let calls = AtomicUsize::new(0);
3088        let first = forced(&path, &a, "a-rejected", &calls).await.unwrap();
3089        let lock = refresh_lock_path(&path, &a.storage_key());
3090        let stamp = std::fs::read_to_string(&lock).unwrap();
3091        let ms: u64 = stamp.parse().expect("plain ASCII epoch ms");
3092        assert!(crate::epoch_millis().saturating_sub(ms) < 60_000, "{stamp}");
3093
3094        // Old client (origin/dev `lock_file_at`): open + flock + unlock.
3095        let old = std::fs::OpenOptions::new()
3096            .create(true)
3097            .truncate(false)
3098            .write(true)
3099            .open(&lock)
3100            .unwrap();
3101        FileExt::lock_exclusive(&old).unwrap();
3102        drop(old);
3103        assert_eq!(std::fs::read_to_string(&lock).unwrap(), stamp);
3104
3105        // ...so the cooldown it records still holds.
3106        let err = forced(&path, &a, &first.access, &calls).await.unwrap_err();
3107        assert!(err.contains("not refreshing again"), "{err}");
3108        assert_eq!(calls.load(Ordering::SeqCst), 1);
3109    }
3110
3111    /// Typed failures: callers can tell a cooldown and a transient provider
3112    /// failure from an unusable login without parsing text.
3113    #[test]
3114    fn refresh_failures_are_typed() {
3115        let a = account("a");
3116        let now = 1_000_000_000;
3117        let f = forced_refresh_allowed(&a, Some(now - 12_000), now).unwrap_err();
3118        assert_eq!(f.kind, RefreshFailureKind::Cooldown { retry_in_secs: 48 });
3119        assert!(f.message().contains("not refreshing again for 48s"), "{f}");
3120        assert!(forced_refresh_allowed(&a, Some(now - 60_000), now).is_ok());
3121
3122        use RefreshFailureKind::Credential;
3123        // Storage/slot strings are credential failures.
3124        assert_eq!(RefreshFailure::from("No credentials".to_string()).kind, Credential);
3125    }
3126}
3127
3128/// Minimal loopback HTTP server for refresh-classification tests: answers
3129/// every request with one canned raw response, then closes the connection.
3130#[cfg(test)]
3131pub(crate) mod fake_http {
3132    use tokio::io::{AsyncReadExt, AsyncWriteExt};
3133
3134    /// `HTTP/1.1 <status>` response with a JSON content type and an exact
3135    /// `Content-Length`.
3136    pub(crate) fn response(status: &str, body: &str) -> String {
3137        format!(
3138            "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\
3139             Connection: close\r\n\r\n{body}",
3140            body.len()
3141        )
3142    }
3143
3144    fn request_complete(got: &[u8]) -> bool {
3145        let Some(end) = got.windows(4).position(|w| w == b"\r\n\r\n") else {
3146            return false;
3147        };
3148        let head = String::from_utf8_lossy(&got[..end]).to_ascii_lowercase();
3149        let len = head
3150            .lines()
3151            .find_map(|l| l.strip_prefix("content-length:"))
3152            .and_then(|v| v.trim().parse::<usize>().ok())
3153            .unwrap_or(0);
3154        got.len() >= end + 4 + len
3155    }
3156
3157    /// Serve `raw` to every connection; returns `http://127.0.0.1:<port>`.
3158    pub(crate) async fn serve(raw: String) -> String {
3159        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3160        let addr = listener.local_addr().unwrap();
3161        tokio::spawn(async move {
3162            while let Ok((mut sock, _)) = listener.accept().await {
3163                let raw = raw.clone();
3164                tokio::spawn(async move {
3165                    let mut got = Vec::new();
3166                    let mut buf = [0u8; 4096];
3167                    while !request_complete(&got) {
3168                        match sock.read(&mut buf).await {
3169                            Ok(0) | Err(_) => break,
3170                            Ok(n) => got.extend_from_slice(&buf[..n]),
3171                        }
3172                    }
3173                    let _ = sock.write_all(raw.as_bytes()).await;
3174                    let _ = sock.shutdown().await;
3175                });
3176            }
3177        });
3178        format!("http://{addr}")
3179    }
3180
3181    /// A loopback URL nothing listens on (connection refused).
3182    pub(crate) async fn dead_url() -> String {
3183        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3184        let addr = listener.local_addr().unwrap();
3185        drop(listener);
3186        format!("http://{addr}")
3187    }
3188}
3189
3190/// m1: refresh failures are classified where they are produced (status code
3191/// and error type), never by matching message text.
3192#[cfg(test)]
3193mod source_classification_tests {
3194    use super::fake_http::{dead_url, response, serve};
3195    use super::*;
3196    use RefreshFailureKind::{Credential, Transient};
3197
3198    #[test]
3199    fn status_kinds() {
3200        for s in [408, 425, 429, 500, 502, 503, 504] {
3201            assert_eq!(status_kind(s), Transient, "{s}");
3202        }
3203        for s in [400, 401, 403, 404] {
3204            assert_eq!(status_kind(s), Credential, "{s}");
3205        }
3206    }
3207
3208    async fn anthropic(raw: String) -> RefreshFailure {
3209        let url = format!("{}/v1/oauth/token", serve(raw).await);
3210        refresh_token_at(&Client::new(), &url, "rt").await.unwrap_err()
3211    }
3212
3213    #[tokio::test]
3214    async fn anthropic_refresh_failures_are_typed_at_the_source() {
3215        // A captive portal answering 200 with HTML: not a login problem.
3216        let portal = anthropic(response("200 OK", "<html>sign in to wifi</html>")).await;
3217        assert_eq!(portal.kind, Transient, "{portal}");
3218        // A body cut mid-transfer (declared 200 bytes, sent 10).
3219        let cut = anthropic(
3220            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 200\r\n\
3221             Connection: close\r\n\r\n{\"access_t"
3222                .to_string(),
3223        )
3224        .await;
3225        assert_eq!(cut.kind, Transient, "{cut}");
3226        let timeout = anthropic(response("408 Request Timeout", "{}")).await;
3227        assert_eq!(timeout.kind, Transient, "{timeout}");
3228        let busy = anthropic(response("503 Service Unavailable", "<html/>")).await;
3229        assert_eq!(busy.kind, Transient, "{busy}");
3230        // A real rejection stays a credential failure, sanitized.
3231        let revoked = anthropic(response(
3232            "400 Bad Request",
3233            r#"{"error":"invalid_grant","error_description":"revoked"}"#,
3234        ))
3235        .await;
3236        assert_eq!(revoked.kind, Credential, "{revoked}");
3237        assert!(revoked.message().contains("invalid_grant"), "{revoked}");
3238        // No network at all.
3239        let url = format!("{}/v1/oauth/token", dead_url().await);
3240        let down = refresh_token_at(&Client::new(), &url, "rt").await.unwrap_err();
3241        assert_eq!(down.kind, Transient, "{down}");
3242    }
3243
3244    /// Kind follows the typed provider error, even when the text would
3245    /// have fooled the old substring matcher.
3246    #[test]
3247    fn provider_errors_carry_their_kind() {
3248        use super::super::google_gemini::GeminiAuthError as G;
3249        assert_eq!(G::TokenRequestFailed(503).refresh_failure().kind, Transient);
3250        assert_eq!(G::TokenRequestFailed(400).refresh_failure().kind, Credential);
3251        assert_eq!(G::Transport.refresh_failure().kind, Transient);
3252        assert_eq!(G::EmptyRefreshToken.refresh_failure().kind, Credential);
3253
3254        use super::super::muse::MuseAuthError as M;
3255        assert_eq!(M::LoginRejected(401).refresh_failure().kind, Credential);
3256        assert_eq!(M::HttpStatus(503).refresh_failure().kind, Transient);
3257        assert_eq!(M::Transport.refresh_failure().kind, Transient);
3258        assert_eq!(M::InvalidMintResponse.refresh_failure().kind, Transient);
3259        assert_eq!(M::OnboardingRequired.refresh_failure().kind, Credential);
3260        // Billing, not the login: never "sign in again".
3261        assert_eq!(M::PaymentRequired.refresh_failure().kind, RefreshFailureKind::ActionRequired);
3262
3263        use super::super::kimi_code::KimiAuthError as K;
3264        assert_eq!(K::Unauthorized.refresh_failure().kind, Credential);
3265        assert_eq!(K::HttpStatus(502).refresh_failure().kind, Transient);
3266        assert_eq!(K::Transport.refresh_failure().kind, Transient);
3267
3268        use super::super::github_copilot::CopilotAuthError as C;
3269        assert_eq!(C::SessionMintRejected(401).refresh_failure().kind, Credential);
3270        assert_eq!(C::HttpStatus(500).refresh_failure().kind, Transient);
3271        assert_eq!(C::Transport.refresh_failure().kind, Transient);
3272    }
3273
3274    #[tokio::test]
3275    async fn gemini_503_is_transient_and_invalid_grant_is_credential() {
3276        use super::super::google_gemini::{refresh_with_registration_typed, GeminiRegistration};
3277        let reg = GeminiRegistration::test("client-id", None).unwrap();
3278        let run = |raw: String| {
3279            let reg = &reg;
3280            async move {
3281                let url = format!("{}/token", serve(raw).await);
3282                refresh_with_registration_typed(&Client::new(), &url, true, reg, "rt")
3283                    .await
3284                    .unwrap_err()
3285            }
3286        };
3287        let busy = run(response("503 Service Unavailable", "{}")).await;
3288        assert_eq!(busy.kind, Transient, "{busy}");
3289        assert!(busy.message().contains("(HTTP 503)"), "{busy}");
3290        let revoked = run(response("400 Bad Request", r#"{"error":"invalid_grant"}"#)).await;
3291        assert_eq!(revoked.kind, Credential, "{revoked}");
3292    }
3293
3294    #[tokio::test]
3295    async fn xai_discovery_failure_is_transient() {
3296        let url = format!("{}/.well-known/openid-configuration", dead_url().await);
3297        let err = super::super::xai::discover_at(&url).await.unwrap_err();
3298        assert_eq!(err.kind, Transient, "{err}");
3299        assert!(err.message().contains("discovery failed"), "{err}");
3300    }
3301
3302    #[tokio::test]
3303    async fn codex_refresh_failures_are_typed() {
3304        let run = |raw: String| async move {
3305            let url = format!("{}/oauth/token", serve(raw).await);
3306            super::super::openai_codex::token_request_at(&Client::new(), &url, &[("a", "b")])
3307                .await
3308                .unwrap_err()
3309        };
3310        assert_eq!(run(response("502 Bad Gateway", "")).await.kind, Transient);
3311        assert_eq!(run(response("200 OK", "<html/>")).await.kind, Transient);
3312        let revoked = run(response("401 Unauthorized", r#"{"error":"invalid_grant"}"#)).await;
3313        assert_eq!(revoked.kind, Credential);
3314        assert!(revoked.message().contains("invalid_grant"), "{revoked}");
3315        // Round-2 m-i: a proxy echoing the request (refresh token included)
3316        // never reaches the error text.
3317        let echoed = run(response(
3318            "400 Bad Request",
3319            "grant_type=refresh_token&refresh_token=SECRET-RT-123",
3320        ))
3321        .await;
3322        assert!(!echoed.message().contains("SECRET-RT-123"), "{echoed}");
3323        let nested = run(response(
3324            "400 Bad Request",
3325            r#"{"error":"invalid_request","error_description":"bad","echo":"SECRET-RT-123"}"#,
3326        ))
3327        .await;
3328        assert!(!nested.message().contains("SECRET-RT-123"), "{nested}");
3329        assert!(nested.message().contains("invalid_request: bad"), "{nested}");
3330    }
3331}