Skip to main content

agent_core/core/auth/
token.rs

1use std::collections::HashMap;
2use std::sync::{Arc, OnceLock};
3use tokio::sync::Mutex;
4
5use reqwest::Client;
6
7use super::storage::{auth_file_path, load_provider_auth, save_provider_auth};
8use super::{is_token_expired, now_millis, OAuthCredentials, TokenResponse, CLIENT_ID, TOKEN_URL};
9
10/// Exchange an authorization code for access + refresh tokens.
11pub async fn exchange_code_for_tokens(
12    code: &str,
13    state: &str,
14    verifier: &str,
15    port: u16,
16) -> std::result::Result<OAuthCredentials, String> {
17    let redirect_uri = format!("http://localhost:{}/callback", port);
18
19    let body = serde_json::json!({
20        "grant_type": "authorization_code",
21        "client_id": CLIENT_ID,
22        "code": code,
23        "state": state,
24        "redirect_uri": redirect_uri,
25        "code_verifier": verifier,
26    });
27
28    let client = Client::builder()
29        .tls_built_in_webpki_certs(true)
30        .tls_built_in_native_certs(true)
31        .connect_timeout(std::time::Duration::from_secs(10))
32        .timeout(std::time::Duration::from_secs(30))
33        .build()
34        .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
35    let resp = client
36        .post(TOKEN_URL)
37        .header("Content-Type", "application/json")
38        .header("Accept", "application/json")
39        .json(&body)
40        .send()
41        .await
42        .map_err(|e| format!("Token exchange request failed: {}", e))?;
43
44    if !resp.status().is_success() {
45        let status = resp.status();
46        // Truncate error body — server responses may contain sensitive data
47        // (tokens, internal errors). Status code alone is usually sufficient.
48        let text = resp.text().await.unwrap_or_default();
49        let truncated = if text.len() > 200 {
50            &text[..200]
51        } else {
52            &text
53        };
54        return Err(format!("Token exchange failed ({}): {}", status, truncated));
55    }
56
57    let token_resp: TokenResponse = resp
58        .json()
59        .await
60        .map_err(|e| format!("Failed to parse token response: {}", e))?;
61
62    // expires_in is seconds; store as epoch millis with 5-minute buffer (matches Pi/Claude Code)
63    let expires = now_millis() + (token_resp.expires_in * 1000) - (5 * 60 * 1000);
64
65    Ok(OAuthCredentials {
66        auth_type: "oauth".to_string(),
67        refresh: token_resp.refresh_token,
68        access: token_resp.access_token,
69        expires,
70        account_id: None,
71    })
72}
73
74/// Refresh an expired OAuth token.
75pub async fn refresh_token(
76    client: &Client,
77    refresh: &str,
78) -> std::result::Result<OAuthCredentials, String> {
79    let body = serde_json::json!({
80        "grant_type": "refresh_token",
81        "client_id": CLIENT_ID,
82        "refresh_token": refresh,
83    });
84
85    let resp = client
86        .post(TOKEN_URL)
87        .header("Content-Type", "application/json")
88        .header("Accept", "application/json")
89        .json(&body)
90        .send()
91        .await
92        .map_err(|e| format!("Token refresh request failed: {}", e))?;
93
94    if !resp.status().is_success() {
95        let status = resp.status();
96        let text = resp.text().await.unwrap_or_default();
97        let truncated = if text.len() > 200 {
98            &text[..200]
99        } else {
100            &text
101        };
102        return Err(format!("Token refresh failed ({}): {}", status, truncated));
103    }
104
105    let token_resp: TokenResponse = resp
106        .json()
107        .await
108        .map_err(|e| format!("Failed to parse refresh response: {}", e))?;
109
110    let expires = now_millis() + (token_resp.expires_in * 1000) - (5 * 60 * 1000);
111
112    Ok(OAuthCredentials {
113        auth_type: "oauth".to_string(),
114        refresh: token_resp.refresh_token,
115        access: token_resp.access_token,
116        expires,
117        account_id: None,
118    })
119}
120
121/// Acquire the Anthropic refresh gate, check token freshness, refresh if
122/// needed, and persist the result atomically. Returns the current (possibly
123/// refreshed) credentials.
124///
125/// Anthropic now flows through the exact same single-flight gate + atomic
126/// merge-persistence path as every other OAuth provider
127/// (`ensure_fresh_provider_token`), so concurrent async callers can never
128/// double-rotate the refresh token, and the write preserves every other
129/// provider entry (fixes #184) via `save_provider_auth` → tmp+rename(2).
130pub async fn ensure_fresh_token(client: &Client) -> std::result::Result<OAuthCredentials, String> {
131    ensure_fresh_provider_token(client, super::provider::OAuthProviderId::Anthropic).await
132}
133
134/// Process-wide, per-provider refresh gates. The file lock protects persistence;
135/// these gates additionally keep concurrent async callers in this process from
136/// rotating the same refresh token while the network request is in flight.
137fn refresh_gate(provider: &str) -> Arc<Mutex<()>> {
138    static GATES: OnceLock<std::sync::Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
139    let mut gates = GATES
140        .get_or_init(|| std::sync::Mutex::new(HashMap::new()))
141        .lock()
142        .expect("refresh gate registry poisoned");
143    gates
144        .entry(provider.to_string())
145        .or_insert_with(|| Arc::new(Mutex::new(())))
146        .clone()
147}
148
149/// Ensure an OAuth provider (including Anthropic) has a fresh token.
150///
151/// Single-flight: concurrent async callers for the same provider serialize on
152/// a per-provider gate; only the first performs the network refresh, later
153/// waiters re-read the persisted (rotated) credential.
154pub async fn ensure_fresh_provider_token<P>(
155    client: &Client,
156    provider: P,
157) -> std::result::Result<OAuthCredentials, String>
158where
159    P: TryInto<super::provider::OAuthProviderId>,
160    P::Error: std::fmt::Display,
161{
162    let provider = provider.try_into().map_err(|e| e.to_string())?;
163    let key = provider.as_str();
164    ensure_fresh_gated(
165        refresh_gate(key),
166        || {
167            load_provider_auth(key)?.ok_or_else(|| {
168                format!(
169                    "No credentials for {} at {}. Run `synaps login`.",
170                    provider,
171                    auth_file_path().display()
172                )
173            })
174        },
175        |refresh| async move { super::provider::refresh(client, provider, &refresh).await },
176        |creds| save_provider_auth(key, creds),
177    )
178    .await
179}
180
181/// Generic single-flight refresh core. Separated from network/storage so the
182/// concurrency, rotation, and failure invariants are directly testable.
183///
184/// Contract:
185/// 1. Exactly one refresh runs at a time per gate; waiters re-`load` after the
186///    gate and observe the rotated credential without a second refresh.
187/// 2. `save` runs only after a successful refresh (failures persist nothing).
188/// 3. The refreshed credential is persisted before the gate is released.
189pub(crate) async fn ensure_fresh_gated<Load, Refresh, RFut, Save>(
190    gate: Arc<Mutex<()>>,
191    load: Load,
192    refresh: Refresh,
193    save: Save,
194) -> std::result::Result<OAuthCredentials, String>
195where
196    Load: Fn() -> std::result::Result<OAuthCredentials, String>,
197    Refresh: FnOnce(String) -> RFut,
198    RFut: std::future::Future<Output = std::result::Result<OAuthCredentials, String>>,
199    Save: FnOnce(&OAuthCredentials) -> std::result::Result<(), String>,
200{
201    let _gate = gate.lock_owned().await;
202    // Re-read after entering the gate: a preceding waiter may have refreshed
203    // and atomically persisted a rotated refresh token.
204    let creds = load()?;
205    if !is_token_expired(&creds) {
206        return Ok(creds);
207    }
208    let fresh = refresh(creds.refresh).await?;
209    save(&fresh)?;
210    Ok(fresh)
211}
212
213#[cfg(test)]
214mod tests {
215    use super::super::storage::save_provider_auth_at_test_hook;
216    use super::super::OAuthCredentials;
217
218    fn fresh_creds(refresh: &str) -> OAuthCredentials {
219        OAuthCredentials {
220            auth_type: "oauth".to_string(),
221            refresh: refresh.to_string(),
222            access: "access".to_string(),
223            expires: crate::epoch_millis() + 3_600_000,
224            account_id: None,
225        }
226    }
227
228    // ── Bug #2 atomicity regression suite ─────────────────────────────────────
229    //
230    // Root cause (pre-fix): ensure_fresh_token used seek(0)+set_len(0)+write_all
231    // on the live auth.json file (token.rs ~139-168). A crash between truncate
232    // and write completion zeroed the file. Because Anthropic rotates the
233    // refresh token on every refresh, the zeroed-file state is unrecoverable.
234    //
235    // Fix: the write goes through save_provider_auth → save_provider_auth_at
236    // (storage.rs), which does write-to-tmp + rename(2). rename(2) is atomic:
237    // the file is either the old content or the new content, never empty.
238    //
239    // These tests exercise save_provider_auth_at directly (the same function
240    // ensure_fresh_token now delegates to) and prove the atomicity contract.
241
242    /// After a successful save, auth.json must not be empty or zero-length.
243    /// This would be violated by the old truncate-in-place path on a crash.
244    #[test]
245    fn bug2_write_produces_non_empty_file() {
246        let dir = tempfile::tempdir().expect("tempdir");
247        let path = dir.path().join("auth.json");
248        save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok1"))
249            .expect("save must succeed");
250        let meta = std::fs::metadata(&path).expect("file must exist");
251        assert!(meta.len() > 0, "auth.json must not be empty after save");
252    }
253
254    /// The write must go through a .json.tmp file, not truncate-in-place.
255    /// We prove this by checking that after a successful save the tmp file
256    /// is gone (renamed → auth.json) and the final file is valid JSON.
257    /// The tmp file should not persist after a successful write.
258    #[test]
259    fn bug2_tmp_file_cleaned_up_after_successful_write() {
260        let dir = tempfile::tempdir().expect("tempdir");
261        let path = dir.path().join("auth.json");
262        save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok2"))
263            .expect("save must succeed");
264        let tmp = path.with_extension("json.tmp");
265        assert!(
266            !tmp.exists(),
267            "auth.json.tmp must not persist after a successful atomic rename"
268        );
269    }
270
271    /// The final file must be valid JSON with the correct credential.
272    /// Truncate-in-place failure would leave `{}` or `null`; atomic rename
273    /// either keeps old content or produces fully-written new content.
274    #[test]
275    fn bug2_final_file_is_valid_json_with_correct_credential() {
276        let dir = tempfile::tempdir().expect("tempdir");
277        let path = dir.path().join("auth.json");
278        save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok3"))
279            .expect("save must succeed");
280        let content = std::fs::read_to_string(&path).expect("must be readable");
281        let parsed: serde_json::Value =
282            serde_json::from_str(&content).expect("must be valid JSON after atomic write");
283        assert_eq!(
284            parsed["anthropic"]["refresh"].as_str(),
285            Some("tok3"),
286            "refresh token must be persisted correctly"
287        );
288    }
289
290    /// Refresh write must preserve other providers (fixes #184 / AuthFile
291    /// 2-field issue). Old code rebuilt `AuthFile { anthropic, openai_codex }`
292    /// which drops any 3rd provider; new code does read-merge-write.
293    #[test]
294    fn bug2_refresh_write_preserves_other_providers() {
295        let dir = tempfile::tempdir().expect("tempdir");
296        let path = dir.path().join("auth.json");
297
298        // Simulate state after two prior logins
299        save_provider_auth_at_test_hook(&path, "openai-codex", &fresh_creds("codex-tok"))
300            .expect("save codex");
301        save_provider_auth_at_test_hook(&path, "future-provider", &fresh_creds("future-tok"))
302            .expect("save future");
303
304        // Simulate ensure_fresh_token writing the refreshed anthropic credential
305        save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("anth-new"))
306            .expect("save anthropic after refresh");
307
308        let content = std::fs::read_to_string(&path).expect("readable");
309        let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid JSON");
310
311        assert_eq!(parsed["anthropic"]["refresh"].as_str(), Some("anth-new"));
312        assert_eq!(
313            parsed["openai-codex"]["refresh"].as_str(),
314            Some("codex-tok"),
315            "openai-codex must survive the anthropic refresh write"
316        );
317        assert_eq!(
318            parsed["future-provider"]["refresh"].as_str(),
319            Some("future-tok"),
320            "future-provider must survive (fixes #184 — 3rd provider not dropped)"
321        );
322    }
323
324    // ── Single-flight refresh invariants (concurrency / rotation / failure) ──
325    //
326    // These drive `ensure_fresh_gated` — the exact core `ensure_fresh_token`
327    // (Anthropic) and `ensure_fresh_provider_token` (all providers) execute —
328    // with injected load/refresh/save so no network or real auth.json is
329    // involved.
330
331    use super::ensure_fresh_gated;
332    use std::sync::atomic::{AtomicUsize, Ordering};
333    use std::sync::{Arc, Mutex as StdMutex};
334
335    fn expired_creds(refresh: &str) -> OAuthCredentials {
336        OAuthCredentials {
337            auth_type: "oauth".to_string(),
338            refresh: refresh.to_string(),
339            access: "stale-access".to_string(),
340            expires: 0,
341            account_id: None,
342        }
343    }
344
345    /// Shared fake persistence: models auth.json for a single provider.
346    #[derive(Clone)]
347    struct FakeStore {
348        creds: Arc<StdMutex<OAuthCredentials>>,
349        refresh_calls: Arc<AtomicUsize>,
350        save_calls: Arc<AtomicUsize>,
351    }
352
353    impl FakeStore {
354        fn new(initial: OAuthCredentials) -> Self {
355            Self {
356                creds: Arc::new(StdMutex::new(initial)),
357                refresh_calls: Arc::new(AtomicUsize::new(0)),
358                save_calls: Arc::new(AtomicUsize::new(0)),
359            }
360        }
361
362        async fn ensure(
363            &self,
364            gate: Arc<tokio::sync::Mutex<()>>,
365        ) -> Result<OAuthCredentials, String> {
366            let store = self.clone();
367            let refresh_calls = self.refresh_calls.clone();
368            ensure_fresh_gated(
369                gate,
370                move || Ok(store.creds.lock().unwrap().clone()),
371                move |old_refresh| async move {
372                    refresh_calls.fetch_add(1, Ordering::SeqCst);
373                    // Rotate: new refresh token derived from the one presented.
374                    Ok(OAuthCredentials {
375                        auth_type: "oauth".to_string(),
376                        refresh: format!("{old_refresh}-rotated"),
377                        access: "fresh-access".to_string(),
378                        expires: crate::epoch_millis() + 3_600_000,
379                        account_id: None,
380                    })
381                },
382                |c| {
383                    self.save_calls.fetch_add(1, Ordering::SeqCst);
384                    *self.creds.lock().unwrap() = c.clone();
385                    Ok(())
386                },
387            )
388            .await
389        }
390    }
391
392    /// Concurrency: N concurrent callers with an expired credential perform
393    /// exactly ONE network refresh; everyone observes the rotated result.
394    #[tokio::test]
395    async fn anthropic_refresh_single_flight_under_concurrency() {
396        let store = FakeStore::new(expired_creds("r0"));
397        let gate = Arc::new(tokio::sync::Mutex::new(()));
398        let mut handles = Vec::new();
399        for _ in 0..16 {
400            let store = store.clone();
401            let gate = gate.clone();
402            handles.push(tokio::spawn(async move { store.ensure(gate).await }));
403        }
404        for h in handles {
405            let creds = h.await.unwrap().expect("refresh must succeed");
406            assert_eq!(creds.access, "fresh-access");
407            assert_eq!(creds.refresh, "r0-rotated");
408        }
409        assert_eq!(
410            store.refresh_calls.load(Ordering::SeqCst),
411            1,
412            "exactly one caller may rotate the refresh token"
413        );
414        assert_eq!(store.save_calls.load(Ordering::SeqCst), 1);
415    }
416
417    /// Rotation: the persisted refresh token is the rotated one, and a later
418    /// expiry cycle refreshes FROM the rotated token (never the stale one).
419    #[tokio::test]
420    async fn refresh_rotation_chains_from_persisted_token() {
421        let store = FakeStore::new(expired_creds("gen0"));
422        let gate = Arc::new(tokio::sync::Mutex::new(()));
423        store.ensure(gate.clone()).await.unwrap();
424        assert_eq!(store.creds.lock().unwrap().refresh, "gen0-rotated");
425
426        // Force expiry again; the second cycle must present the rotated token.
427        store.creds.lock().unwrap().expires = 0;
428        let second = store.ensure(gate).await.unwrap();
429        assert_eq!(second.refresh, "gen0-rotated-rotated");
430        assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 2);
431    }
432
433    /// Freshness: an unexpired credential is returned without refresh or save.
434    #[tokio::test]
435    async fn fresh_credential_short_circuits_without_refresh() {
436        let store = FakeStore::new(fresh_creds("keep"));
437        let gate = Arc::new(tokio::sync::Mutex::new(()));
438        let creds = store.ensure(gate).await.unwrap();
439        assert_eq!(creds.refresh, "keep");
440        assert_eq!(store.refresh_calls.load(Ordering::SeqCst), 0);
441        assert_eq!(store.save_calls.load(Ordering::SeqCst), 0);
442    }
443
444    /// Failure: a refresh error propagates and persists nothing — the stored
445    /// credential is untouched so a later retry can still present it.
446    #[tokio::test]
447    async fn refresh_failure_persists_nothing() {
448        let saves = Arc::new(AtomicUsize::new(0));
449        let saves2 = saves.clone();
450        let err = ensure_fresh_gated(
451            Arc::new(tokio::sync::Mutex::new(())),
452            || Ok(expired_creds("r-fail")),
453            |_r| async move { Err("provider 500".to_string()) },
454            move |_c| {
455                saves2.fetch_add(1, Ordering::SeqCst);
456                Ok(())
457            },
458        )
459        .await
460        .unwrap_err();
461        assert!(err.contains("provider 500"));
462        assert_eq!(
463            saves.load(Ordering::SeqCst),
464            0,
465            "failed refresh must not persist"
466        );
467    }
468}