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 {
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 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
74pub 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
121pub 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
134fn 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
149pub 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
181pub(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 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 #[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 #[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 #[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 #[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 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 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 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 #[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 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 #[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 #[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 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 #[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 #[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}