1use std::path::PathBuf;
2
3use super::{AuthFile, OAuthCredentials};
4
5pub fn auth_file_path() -> PathBuf {
7 crate::config::resolve_read_path("auth.json")
8}
9
10pub fn load_auth() -> std::result::Result<Option<AuthFile>, String> {
12 let path = auth_file_path();
13 if !path.exists() {
14 return Ok(None);
15 }
16 let content = std::fs::read_to_string(&path)
17 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
18 let auth: AuthFile = serde_json::from_str(&content)
19 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
20 Ok(Some(auth))
21}
22
23pub fn load_provider_auth(provider: &str) -> std::result::Result<Option<OAuthCredentials>, String> {
25 let path = auth_file_path();
26 if !path.exists() {
27 return Ok(None);
28 }
29 let content = std::fs::read_to_string(&path)
30 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
31 let value: serde_json::Value = serde_json::from_str(&content)
32 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
33 let Some(raw) = value.get(provider) else {
34 return Ok(None);
35 };
36 let creds: OAuthCredentials = serde_json::from_value(raw.clone())
37 .map_err(|e| format!("Failed to parse {} credential: {}", provider, e))?;
38 Ok(Some(creds))
39}
40
41pub fn save_auth(creds: &OAuthCredentials) -> std::result::Result<(), String> {
43 save_provider_auth("anthropic", creds)
44}
45
46pub fn save_provider_auth(provider: &str, creds: &OAuthCredentials) -> std::result::Result<(), String> {
48 let path = crate::config::resolve_write_path("auth.json");
49 save_provider_auth_at(&path, provider, creds)
50}
51
52fn save_provider_auth_at(
56 path: &std::path::Path,
57 provider: &str,
58 creds: &OAuthCredentials,
59) -> std::result::Result<(), String> {
60 use fs4::fs_std::FileExt;
61
62 if let Some(parent) = path.parent() {
64 std::fs::create_dir_all(parent)
65 .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
66 }
67
68 let lock_path = path.with_extension("json.lock");
73 let lock_file = std::fs::OpenOptions::new()
74 .create(true)
75 .truncate(true)
76 .write(true)
77 .open(&lock_path)
78 .map_err(|e| format!("Failed to open lock file {}: {}", lock_path.display(), e))?;
79 FileExt::lock_exclusive(&lock_file)
80 .map_err(|e| format!("Failed to lock {}: {}", lock_path.display(), e))?;
81
82 let mut root = if path.exists() {
83 let content = std::fs::read_to_string(path)
84 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
85 match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&content) {
91 Ok(map) => map,
92 Err(e) => {
93 tracing::warn!(
94 path = %path.display(),
95 error = %e,
96 "auth.json could not be parsed as a JSON object; replacing with a fresh structure"
97 );
98 let ts = std::time::SystemTime::now()
100 .duration_since(std::time::UNIX_EPOCH)
101 .map(|d| d.as_secs())
102 .unwrap_or(0);
103 let backup = path.with_extension(format!("json.corrupt.{}", ts));
104 match std::fs::copy(path, &backup) {
105 Ok(_) => {
106 eprintln!(
107 "[warn] auth.json was corrupt and has been reset. Backup saved to: {}",
108 backup.display()
109 );
110 }
111 Err(copy_err) => {
112 eprintln!(
113 "[warn] auth.json was corrupt and has been reset, but backup failed: {}",
114 copy_err
115 );
116 }
117 }
118 serde_json::Map::new()
119 }
120 }
121 } else {
122 serde_json::Map::new()
123 };
124
125 root.insert(
126 provider.to_string(),
127 serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?,
128 );
129
130 let json = serde_json::to_string_pretty(&root)
131 .map_err(|e| format!("Failed to serialize auth: {}", e))?;
132
133 let tmp_path = path.with_extension("json.tmp");
138 {
139 use std::io::Write;
140 let mut file = std::fs::OpenOptions::new()
141 .write(true)
142 .create(true)
143 .truncate(true)
144 .open(&tmp_path)
145 .map_err(|e| format!("Failed to create {}: {}", tmp_path.display(), e))?;
146
147 #[cfg(unix)]
148 {
149 use std::os::unix::fs::PermissionsExt;
150 let perms = std::fs::Permissions::from_mode(0o600);
151 file.set_permissions(perms)
152 .map_err(|e| format!("Failed to set permissions on {}: {}", tmp_path.display(), e))?;
153 }
154
155 file.write_all(json.as_bytes())
156 .map_err(|e| format!("Failed to write {}: {}", tmp_path.display(), e))?;
157 file.sync_all()
158 .map_err(|e| format!("Failed to fsync {}: {}", tmp_path.display(), e))?;
159 }
160
161 std::fs::rename(&tmp_path, path)
162 .map_err(|e| format!("Failed to atomically replace {}: {}", path.display(), e))?;
163
164 Ok(())
165}
166
167#[cfg(test)]
171pub(super) fn save_provider_auth_at_test_hook(
172 path: &std::path::Path,
173 provider: &str,
174 creds: &OAuthCredentials,
175) -> std::result::Result<(), String> {
176 save_provider_auth_at(path, provider, creds)
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 fn fresh_creds() -> OAuthCredentials {
184 OAuthCredentials {
185 auth_type: "oauth".to_string(),
186 refresh: "r".to_string(),
187 access: "a".to_string(),
188 expires: 1,
189 account_id: None,
190 }
191 }
192
193 #[test]
194 fn save_provider_auth_at_creates_file_when_absent() {
195 let dir = tempfile::tempdir().expect("tempdir");
196 let path = dir.path().join("auth.json");
197 save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
198 assert!(path.exists());
199 let content = std::fs::read_to_string(&path).unwrap();
200 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
201 assert!(parsed.get("openai-codex").is_some());
202 }
203
204 #[test]
205 fn save_provider_auth_at_preserves_other_providers() {
206 let dir = tempfile::tempdir().expect("tempdir");
207 let path = dir.path().join("auth.json");
208 std::fs::write(
209 &path,
210 r#"{"anthropic":{"type":"oauth","refresh":"r2","access":"a2","expires":2}}"#,
211 )
212 .unwrap();
213 save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
214 let content = std::fs::read_to_string(&path).unwrap();
215 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
216 assert!(parsed.get("anthropic").is_some(), "must keep anthropic entry");
217 assert!(parsed.get("openai-codex").is_some());
218 }
219
220 #[test]
221 fn save_provider_auth_at_recovers_from_corrupt_file() {
222 let dir = tempfile::tempdir().expect("tempdir");
227 let path = dir.path().join("auth.json");
228 std::fs::write(&path, "this is not json {{{").unwrap();
229 save_provider_auth_at(&path, "openai-codex", &fresh_creds())
230 .expect("save must succeed even on corrupt input");
231 let content = std::fs::read_to_string(&path).unwrap();
232 let parsed: serde_json::Value = serde_json::from_str(&content)
233 .expect("file must now contain valid JSON");
234 assert!(parsed.get("openai-codex").is_some());
235 assert!(
236 parsed.get("anthropic").is_none(),
237 "corrupt fallback discards old (unrecoverable) entries"
238 );
239 }
240
241 #[test]
242 fn save_provider_auth_at_recovers_from_array_root() {
243 let dir = tempfile::tempdir().expect("tempdir");
246 let path = dir.path().join("auth.json");
247 std::fs::write(&path, "[1, 2, 3]").unwrap();
248 save_provider_auth_at(&path, "openai-codex", &fresh_creds())
249 .expect("save must succeed against non-object root");
250 let parsed: serde_json::Value =
251 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
252 assert!(parsed.is_object());
253 assert!(parsed.get("openai-codex").is_some());
254 }
255
256 #[test]
273 fn bug159_anthropic_login_preserves_existing_openai_codex() {
274 let dir = tempfile::tempdir().expect("tempdir");
275 let path = dir.path().join("auth.json");
276
277 let codex_creds = OAuthCredentials {
279 auth_type: "oauth".to_string(),
280 refresh: "codex-refresh".to_string(),
281 access: "codex-access".to_string(),
282 expires: 9_999_999_999_000,
283 account_id: Some("acct_codex_123".to_string()),
284 };
285 save_provider_auth_at(&path, "openai-codex", &codex_creds)
286 .expect("save openai-codex");
287
288 let anthropic_creds = OAuthCredentials {
290 auth_type: "oauth".to_string(),
291 refresh: "anth-refresh".to_string(),
292 access: "anth-access".to_string(),
293 expires: 9_999_999_999_000,
294 account_id: None,
295 };
296 save_provider_auth_at(&path, "anthropic", &anthropic_creds)
297 .expect("save anthropic");
298
299 let content = std::fs::read_to_string(&path).unwrap();
301 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
302
303 assert!(
304 parsed.get("anthropic").is_some(),
305 "anthropic credential must be present after login"
306 );
307 assert!(
308 parsed.get("openai-codex").is_some(),
309 "bug #159: openai-codex credential must NOT be wiped when logging in as anthropic"
310 );
311 assert_eq!(
312 parsed["openai-codex"]["refresh"].as_str(),
313 Some("codex-refresh"),
314 "openai-codex refresh token must be unchanged"
315 );
316 assert_eq!(
317 parsed["anthropic"]["refresh"].as_str(),
318 Some("anth-refresh"),
319 "anthropic refresh token must be correctly saved"
320 );
321 }
322
323 #[test]
326 fn bug159_openai_codex_login_preserves_existing_anthropic() {
327 let dir = tempfile::tempdir().expect("tempdir");
328 let path = dir.path().join("auth.json");
329
330 let anthropic_creds = OAuthCredentials {
332 auth_type: "oauth".to_string(),
333 refresh: "anth-refresh-first".to_string(),
334 access: "anth-access-first".to_string(),
335 expires: 9_999_999_999_000,
336 account_id: None,
337 };
338 save_provider_auth_at(&path, "anthropic", &anthropic_creds)
339 .expect("save anthropic");
340
341 let codex_creds = OAuthCredentials {
343 auth_type: "oauth".to_string(),
344 refresh: "codex-refresh-second".to_string(),
345 access: "codex-access-second".to_string(),
346 expires: 9_999_999_999_000,
347 account_id: Some("acct_456".to_string()),
348 };
349 save_provider_auth_at(&path, "openai-codex", &codex_creds)
350 .expect("save openai-codex");
351
352 let content = std::fs::read_to_string(&path).unwrap();
353 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
354
355 assert!(
356 parsed.get("anthropic").is_some(),
357 "anthropic credential must survive openai-codex login"
358 );
359 assert!(
360 parsed.get("openai-codex").is_some(),
361 "openai-codex credential must be present"
362 );
363 assert_eq!(
364 parsed["anthropic"]["refresh"].as_str(),
365 Some("anth-refresh-first"),
366 "anthropic refresh token must be unchanged"
367 );
368 }
369
370 #[test]
373 fn bug159_third_provider_login_preserves_both_existing_providers() {
374 let dir = tempfile::tempdir().expect("tempdir");
375 let path = dir.path().join("auth.json");
376
377 save_provider_auth_at(&path, "anthropic", &OAuthCredentials {
378 auth_type: "oauth".to_string(),
379 refresh: "anth-r".to_string(),
380 access: "anth-a".to_string(),
381 expires: 1,
382 account_id: None,
383 }).expect("save anthropic");
384
385 save_provider_auth_at(&path, "openai-codex", &OAuthCredentials {
386 auth_type: "oauth".to_string(),
387 refresh: "codex-r".to_string(),
388 access: "codex-a".to_string(),
389 expires: 1,
390 account_id: Some("acct_codex".to_string()),
391 }).expect("save openai-codex");
392
393 save_provider_auth_at(&path, "future-provider", &OAuthCredentials {
394 auth_type: "oauth".to_string(),
395 refresh: "future-r".to_string(),
396 access: "future-a".to_string(),
397 expires: 1,
398 account_id: None,
399 }).expect("save future-provider");
400
401 let content = std::fs::read_to_string(&path).unwrap();
402 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
403
404 assert!(parsed.get("anthropic").is_some(), "anthropic must survive");
405 assert!(parsed.get("openai-codex").is_some(), "openai-codex must survive");
406 assert!(parsed.get("future-provider").is_some(), "future-provider must be present");
407 }
408
409 #[test]
412 fn bug159_same_provider_login_upserts_in_place() {
413 let dir = tempfile::tempdir().expect("tempdir");
414 let path = dir.path().join("auth.json");
415
416 save_provider_auth_at(&path, "anthropic", &OAuthCredentials {
417 auth_type: "oauth".to_string(),
418 refresh: "old-refresh".to_string(),
419 access: "old-access".to_string(),
420 expires: 1,
421 account_id: None,
422 }).expect("initial save");
423
424 save_provider_auth_at(&path, "anthropic", &OAuthCredentials {
425 auth_type: "oauth".to_string(),
426 refresh: "new-refresh".to_string(),
427 access: "new-access".to_string(),
428 expires: 2,
429 account_id: None,
430 }).expect("upsert save");
431
432 let content = std::fs::read_to_string(&path).unwrap();
433 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
434 let obj = parsed.as_object().unwrap();
435
436 assert_eq!(obj.len(), 1, "only one anthropic entry — no duplicates");
437 assert_eq!(
438 parsed["anthropic"]["refresh"].as_str(),
439 Some("new-refresh"),
440 "credential must be updated to latest value"
441 );
442 }
443}