Skip to main content

agent_core/core/auth/
token.rs

1use reqwest::Client;
2
3use super::{is_token_expired, now_millis, OAuthCredentials, TokenResponse, CLIENT_ID, TOKEN_URL};
4use super::storage::{auth_file_path, load_provider_auth, save_provider_auth};
5
6/// Exchange an authorization code for access + refresh tokens.
7pub async fn exchange_code_for_tokens(
8    code: &str,
9    state: &str,
10    verifier: &str,
11    port: u16,
12) -> std::result::Result<OAuthCredentials, String> {
13    let redirect_uri = format!("http://localhost:{}/callback", port);
14
15    let body = serde_json::json!({
16        "grant_type": "authorization_code",
17        "client_id": CLIENT_ID,
18        "code": code,
19        "state": state,
20        "redirect_uri": redirect_uri,
21        "code_verifier": verifier,
22    });
23
24    let client = Client::builder()
25        .tls_built_in_webpki_certs(true)
26        .tls_built_in_native_certs(true)
27        .connect_timeout(std::time::Duration::from_secs(10))
28        .timeout(std::time::Duration::from_secs(30))
29        .build()
30        .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
31    let resp = client
32        .post(TOKEN_URL)
33        .header("Content-Type", "application/json")
34        .header("Accept", "application/json")
35        .json(&body)
36        .send()
37        .await
38        .map_err(|e| format!("Token exchange request failed: {}", e))?;
39
40    if !resp.status().is_success() {
41        let status = resp.status();
42        let text = resp.text().await.unwrap_or_default();
43        return Err(format!("Token exchange failed ({}): {}", status, text));
44    }
45
46    let token_resp: TokenResponse = resp
47        .json()
48        .await
49        .map_err(|e| format!("Failed to parse token response: {}", e))?;
50
51    // expires_in is seconds; store as epoch millis with 5-minute buffer (matches Pi/Claude Code)
52    let expires = now_millis() + (token_resp.expires_in * 1000) - (5 * 60 * 1000);
53
54    Ok(OAuthCredentials {
55        auth_type: "oauth".to_string(),
56        refresh: token_resp.refresh_token,
57        access: token_resp.access_token,
58        expires,
59        account_id: None,
60    })
61}
62
63/// Refresh an expired OAuth token.
64pub async fn refresh_token(client: &Client, refresh: &str) -> std::result::Result<OAuthCredentials, String> {
65    let body = serde_json::json!({
66        "grant_type": "refresh_token",
67        "client_id": CLIENT_ID,
68        "refresh_token": refresh,
69    });
70
71    let resp = client
72        .post(TOKEN_URL)
73        .header("Content-Type", "application/json")
74        .header("Accept", "application/json")
75        .json(&body)
76        .send()
77        .await
78        .map_err(|e| format!("Token refresh request failed: {}", e))?;
79
80    if !resp.status().is_success() {
81        let status = resp.status();
82        let text = resp.text().await.unwrap_or_default();
83        return Err(format!("Token refresh failed ({}): {}", status, text));
84    }
85
86    let token_resp: TokenResponse = resp
87        .json()
88        .await
89        .map_err(|e| format!("Failed to parse refresh response: {}", e))?;
90
91    let expires = now_millis() + (token_resp.expires_in * 1000) - (5 * 60 * 1000);
92
93    Ok(OAuthCredentials {
94        auth_type: "oauth".to_string(),
95        refresh: token_resp.refresh_token,
96        access: token_resp.access_token,
97        expires,
98        account_id: None,
99    })
100}
101
102/// Acquire an exclusive lock on auth.json, check token freshness, refresh if
103/// needed, and persist the result atomically. Returns the current (possibly
104/// refreshed) credentials.
105///
106/// # Atomicity fix
107///
108/// Previously used seek(0)+set_len(0)+write_all (truncate-in-place). A crash
109/// between `set_len(0)` and write completion zeroed auth.json. Because
110/// Anthropic rotates the refresh token on every refresh call, the zeroed file
111/// was unrecoverable — the new token existed only in memory.
112///
113/// Fix: delegate the write to `save_provider_auth("anthropic", ...)`, which
114/// calls `save_provider_auth_at` in storage.rs. That uses:
115///   sidecar `.json.lock` → write to `.json.tmp` → `rename(2)` (atomic on POSIX)
116/// A crash at any point leaves auth.json intact with the pre-refresh content.
117///
118/// The read phase still holds an fs4 exclusive lock on the live file to prevent
119/// two concurrent callers from both issuing a network refresh (which would
120/// rotate the refresh token twice, invalidating the first caller's result).
121/// The locks are compatible: read lock on auth.json, write lock on auth.json.lock.
122///
123/// # Task #184 (AuthFile hardcoded-2-field issue)
124///
125/// The old code rebuilt `AuthFile { anthropic, openai_codex }`, silently
126/// dropping any provider beyond those two. Routing through `save_provider_auth`
127/// gets the same read-merge-write as every other save site — all providers
128/// are preserved automatically.
129pub async fn ensure_fresh_token(client: &Client) -> std::result::Result<OAuthCredentials, String> {
130    use fs4::fs_std::FileExt;
131    use std::fs::OpenOptions;
132    use std::io::{Read, Seek, SeekFrom};
133
134    let path = auth_file_path();
135
136    if let Some(parent) = path.parent() {
137        std::fs::create_dir_all(parent)
138            .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
139    }
140
141    if !path.exists() {
142        return Err(format!(
143            "No credentials at {}. Run `login` to authenticate.",
144            path.display()
145        ));
146    }
147
148    // Open read-only; we no longer need write access on this handle because
149    // the write goes through save_provider_auth (tmp+rename).
150    let file = OpenOptions::new()
151        .read(true)
152        .open(&path)
153        .map_err(|e| format!("Failed to open {}: {}", path.display(), e))?;
154
155    let mut file = tokio::task::spawn_blocking(move || -> std::result::Result<std::fs::File, String> {
156        FileExt::lock_exclusive(&file)
157            .map_err(|e| format!("Failed to lock auth.json: {}", e))?;
158        Ok(file)
159    })
160    .await
161    .map_err(|e| format!("Lock task failed: {}", e))??;
162
163    file.seek(SeekFrom::Start(0))
164        .map_err(|e| format!("Failed to seek auth.json: {}", e))?;
165    let mut content = String::new();
166    file.read_to_string(&mut content)
167        .map_err(|e| format!("Failed to read auth.json: {}", e))?;
168    // Explicit drop: release the exclusive lock before the async refresh
169    // network call so we don't hold the lock across I/O we don't control.
170    drop(file);
171
172    let root: serde_json::Value = serde_json::from_str(&content)
173        .map_err(|e| format!("Failed to parse auth.json: {}", e))?;
174    let anthropic_raw = root
175        .get("anthropic")
176        .ok_or_else(|| "No anthropic credential in auth.json. Run `login`.".to_string())?;
177    let creds: OAuthCredentials = serde_json::from_value(anthropic_raw.clone())
178        .map_err(|e| format!("Failed to parse anthropic credential: {}", e))?;
179
180    if !is_token_expired(&creds) {
181        return Ok(creds);
182    }
183
184    let new_creds = refresh_token(client, &creds.refresh).await?;
185
186    // Atomic write via save_provider_auth → save_provider_auth_at:
187    //   1. acquire sidecar auth.json.lock
188    //   2. read-merge all providers from current auth.json
189    //   3. write to auth.json.tmp
190    //   4. fsync + rename(auth.json.tmp → auth.json)   ← atomic
191    // Also naturally preserves all providers beyond "anthropic" (fixes #184).
192    save_provider_auth("anthropic", &new_creds)?;
193
194    Ok(new_creds)
195}
196
197/// Ensure a non-Anthropic OAuth provider has a fresh token.
198pub async fn ensure_fresh_provider_token(
199    client: &Client,
200    provider: &str,
201) -> std::result::Result<OAuthCredentials, String> {
202    let Some(creds) = load_provider_auth(provider)? else {
203        return Err(format!("No credentials for {}. Run `synaps login`.", provider));
204    };
205
206    if !is_token_expired(&creds) {
207        return Ok(creds);
208    }
209
210    let fresh = match provider {
211        "openai-codex" => super::openai_codex::refresh_token(client, &creds.refresh).await?,
212        other => return Err(format!("No refresh handler for OAuth provider {}", other)),
213    };
214    save_provider_auth(provider, &fresh)?;
215    Ok(fresh)
216}
217
218#[cfg(test)]
219mod tests {
220    use super::super::storage::save_provider_auth_at_test_hook;
221    use super::super::OAuthCredentials;
222
223    fn fresh_creds(refresh: &str) -> OAuthCredentials {
224        OAuthCredentials {
225            auth_type: "oauth".to_string(),
226            refresh: refresh.to_string(),
227            access: "access".to_string(),
228            expires: crate::epoch_millis() + 3_600_000,
229            account_id: None,
230        }
231    }
232
233    // ── Bug #2 atomicity regression suite ─────────────────────────────────────
234    //
235    // Root cause (pre-fix): ensure_fresh_token used seek(0)+set_len(0)+write_all
236    // on the live auth.json file (token.rs ~139-168). A crash between truncate
237    // and write completion zeroed the file. Because Anthropic rotates the
238    // refresh token on every refresh, the zeroed-file state is unrecoverable.
239    //
240    // Fix: the write goes through save_provider_auth → save_provider_auth_at
241    // (storage.rs), which does write-to-tmp + rename(2). rename(2) is atomic:
242    // the file is either the old content or the new content, never empty.
243    //
244    // These tests exercise save_provider_auth_at directly (the same function
245    // ensure_fresh_token now delegates to) and prove the atomicity contract.
246
247    /// After a successful save, auth.json must not be empty or zero-length.
248    /// This would be violated by the old truncate-in-place path on a crash.
249    #[test]
250    fn bug2_write_produces_non_empty_file() {
251        let dir = tempfile::tempdir().expect("tempdir");
252        let path = dir.path().join("auth.json");
253        save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok1"))
254            .expect("save must succeed");
255        let meta = std::fs::metadata(&path).expect("file must exist");
256        assert!(meta.len() > 0, "auth.json must not be empty after save");
257    }
258
259    /// The write must go through a .json.tmp file, not truncate-in-place.
260    /// We prove this by checking that after a successful save the tmp file
261    /// is gone (renamed → auth.json) and the final file is valid JSON.
262    /// The tmp file should not persist after a successful write.
263    #[test]
264    fn bug2_tmp_file_cleaned_up_after_successful_write() {
265        let dir = tempfile::tempdir().expect("tempdir");
266        let path = dir.path().join("auth.json");
267        save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok2"))
268            .expect("save must succeed");
269        let tmp = path.with_extension("json.tmp");
270        assert!(
271            !tmp.exists(),
272            "auth.json.tmp must not persist after a successful atomic rename"
273        );
274    }
275
276    /// The final file must be valid JSON with the correct credential.
277    /// Truncate-in-place failure would leave `{}` or `null`; atomic rename
278    /// either keeps old content or produces fully-written new content.
279    #[test]
280    fn bug2_final_file_is_valid_json_with_correct_credential() {
281        let dir = tempfile::tempdir().expect("tempdir");
282        let path = dir.path().join("auth.json");
283        save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok3"))
284            .expect("save must succeed");
285        let content = std::fs::read_to_string(&path).expect("must be readable");
286        let parsed: serde_json::Value =
287            serde_json::from_str(&content).expect("must be valid JSON after atomic write");
288        assert_eq!(
289            parsed["anthropic"]["refresh"].as_str(),
290            Some("tok3"),
291            "refresh token must be persisted correctly"
292        );
293    }
294
295    /// Refresh write must preserve other providers (fixes #184 / AuthFile
296    /// 2-field issue). Old code rebuilt `AuthFile { anthropic, openai_codex }`
297    /// which drops any 3rd provider; new code does read-merge-write.
298    #[test]
299    fn bug2_refresh_write_preserves_other_providers() {
300        let dir = tempfile::tempdir().expect("tempdir");
301        let path = dir.path().join("auth.json");
302
303        // Simulate state after two prior logins
304        save_provider_auth_at_test_hook(&path, "openai-codex", &fresh_creds("codex-tok"))
305            .expect("save codex");
306        save_provider_auth_at_test_hook(&path, "future-provider", &fresh_creds("future-tok"))
307            .expect("save future");
308
309        // Simulate ensure_fresh_token writing the refreshed anthropic credential
310        save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("anth-new"))
311            .expect("save anthropic after refresh");
312
313        let content = std::fs::read_to_string(&path).expect("readable");
314        let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid JSON");
315
316        assert_eq!(parsed["anthropic"]["refresh"].as_str(), Some("anth-new"));
317        assert_eq!(
318            parsed["openai-codex"]["refresh"].as_str(),
319            Some("codex-tok"),
320            "openai-codex must survive the anthropic refresh write"
321        );
322        assert_eq!(
323            parsed["future-provider"]["refresh"].as_str(),
324            Some("future-tok"),
325            "future-provider must survive (fixes #184 — 3rd provider not dropped)"
326        );
327    }
328}