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
10pub 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 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 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
70pub 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
113pub 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
126fn 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
141pub 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
173pub(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 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 #[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 #[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 #[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 #[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 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 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 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 #[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 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 #[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 #[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 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 #[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 #[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}