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