Skip to main content

agent_core/core/auth/
storage.rs

1use std::path::PathBuf;
2
3use super::{AuthFile, OAuthCredentials};
4
5/// Get the path to auth.json (~/.synaps-cli/auth.json).
6pub fn auth_file_path() -> PathBuf {
7    crate::config::resolve_read_path("auth.json")
8}
9
10/// Load credentials from auth.json.
11pub 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
23/// Load one provider's OAuth credential from auth.json.
24pub 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
41/// Save credentials to auth.json.
42pub fn save_auth(creds: &OAuthCredentials) -> std::result::Result<(), String> {
43    save_provider_auth("anthropic", creds)
44}
45
46/// Save one provider credential while preserving other auth.json entries.
47pub 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
52/// Path-explicit variant of `save_provider_auth`. Splits out the I/O so
53/// the corrupt-file fallback path can be unit-tested without touching the
54/// user's real `~/.synaps-cli/auth.json`.
55fn 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    // Ensure parent directory exists
63    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    // Hold an exclusive lock for the entire read-modify-write cycle.
69    // Without this, two concurrent `synaps login` processes can race:
70    // both read the same file, each adds their provider, second write
71    // silently drops the first's credential.
72    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        // Corrupt-file recovery: if the existing auth.json is not a JSON
86        // object (truncated write, manual edit error, swap-file detritus),
87        // log a warning and start fresh rather than refusing to save the
88        // new credential. The alternative is permanently locking the user
89        // out of `synaps login`.
90        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                // Back up the corrupt file so credentials are potentially recoverable.
99                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    // Atomic write: write to .tmp then rename. rename(2) is atomic on POSIX.
134    // This prevents a crash/kill between truncate and write from zeroing auth.json.
135    // Create with restrictive permissions from the start so credentials are never
136    // world-readable, even briefly.
137    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/// Test-only re-export of `save_provider_auth_at` so that `token::tests`
168/// can exercise the same function `ensure_fresh_token` now delegates to,
169/// without making the path-explicit variant part of the public API.
170#[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        // Pre-fix: a corrupt auth.json would lock the user out of
223        // `synaps login` entirely because save_provider_auth would fail
224        // to parse and bail. After fix: corrupt content is replaced with
225        // a fresh structure containing the new credential.
226        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        // auth.json was a JSON array (perhaps from a botched migration).
244        // Treat it as corrupt — same recovery as garbage input.
245        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    // ── Bug #159 regression suite ────────────────────────────────────────────
257    //
258    // Repro: `synaps login --provider openai-codex` (saves "openai-codex"),
259    // then `synaps login` (Anthropic OAuth, saves "anthropic") → the first
260    // provider's credential was wiped because save_auth() rebuilt AuthFile
261    // with only the new entry and did a whole-file overwrite.
262    //
263    // Root cause (pre-fix): src/core/auth/storage.rs @ 90f8f71 — save_auth()
264    // constructed `AuthFile { anthropic: creds }` (no read of existing file)
265    // then called `fs::write(path, json)`, discarding every other provider.
266    //
267    // All three multi-provider tests below would FAIL against that code.
268
269    /// Primary repro (bug #159): save openai-codex, then save anthropic via
270    /// the same write path `synaps login` (Anthropic OAuth) takes.
271    /// openai-codex MUST survive the second login.
272    #[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        // Step 1: user runs `synaps login --provider openai-codex`
278        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        // Step 2: user then runs `synaps login` (Anthropic Claude OAuth)
289        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        // Both providers must be present — bug #159 would wipe openai-codex here
300        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    /// Reverse of primary repro: anthropic first, then openai-codex login.
324    /// anthropic MUST survive.
325    #[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        // Step 1: anthropic already logged in
331        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        // Step 2: user runs `synaps login --provider openai-codex`
342        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    /// Three-provider scenario: saving a third provider preserves the first two.
371    /// Guards against regressions if/when more OAuth providers are added.
372    #[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    /// Upsert: saving the same provider twice updates the credential in place
410    /// without duplicating or corrupting the entry.
411    #[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}