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
6pub 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 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
63pub 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
102pub 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 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 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 save_provider_auth("anthropic", &new_creds)?;
193
194 Ok(new_creds)
195}
196
197pub 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 #[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 #[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 #[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 #[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 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 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}