Skip to main content

claude_codex/
auth.rs

1use anyhow::Result;
2use serde::{Serialize, de::DeserializeOwned};
3use serde_json::to_string_pretty;
4use std::fs::{self, File};
5use std::io::{self, Read, Write};
6use std::marker::PhantomData;
7#[cfg(unix)]
8use std::os::unix::fs::PermissionsExt;
9
10pub trait AuthStorage<T>: Send + Sync
11where
12    T: Serialize + DeserializeOwned + Send + Sync + Clone,
13{
14    fn load(&self) -> Result<Option<T>>;
15    fn save(&self, value: T) -> Result<()>;
16    fn clear(&self) -> Result<()>;
17    fn path(&self) -> String;
18}
19
20pub trait Keychain: Send + Sync {
21    fn read(&self, service: &str, account: &str) -> Result<Option<String>>;
22    fn write(&self, service: &str, account: &str, value: &str) -> Result<()>;
23    fn delete(&self, service: &str, account: &str) -> Result<()>;
24}
25
26#[derive(Default)]
27pub struct StubKeychain;
28
29impl Keychain for StubKeychain {
30    fn read(&self, _service: &str, _account: &str) -> Result<Option<String>> {
31        Ok(None)
32    }
33
34    fn write(&self, _service: &str, _account: &str, _value: &str) -> Result<()> {
35        Ok(())
36    }
37
38    fn delete(&self, _service: &str, _account: &str) -> Result<()> {
39        Ok(())
40    }
41}
42
43#[derive(Default, Clone, Copy)]
44pub struct SystemKeychain;
45
46#[cfg(target_os = "macos")]
47impl Keychain for SystemKeychain {
48    fn read(&self, service: &str, account: &str) -> Result<Option<String>> {
49        let output = run_security(&["find-generic-password", "-s", service, "-a", account, "-w"])?;
50        if output.status.success() {
51            let mut raw = String::from_utf8(output.stdout)
52                .map_err(|err| anyhow::anyhow!("Keychain value is not valid UTF-8: {err}"))?;
53            trim_one_trailing_newline(&mut raw);
54            return Ok(Some(raw));
55        }
56        let stderr = String::from_utf8_lossy(&output.stderr);
57        if stderr.contains("could not be found") || stderr.contains("specified item could not") {
58            return Ok(None);
59        }
60        Err(anyhow::anyhow!("Keychain read failed: {}", stderr.trim()))
61    }
62
63    fn write(&self, _service: &str, _account: &str, _value: &str) -> Result<()> {
64        anyhow::bail!("Keychain write is not available through non-interactive compatibility mode")
65    }
66
67    fn delete(&self, service: &str, account: &str) -> Result<()> {
68        let output = run_security(&["delete-generic-password", "-s", service, "-a", account])?;
69        if output.status.success() {
70            return Ok(());
71        }
72        let stderr = String::from_utf8_lossy(&output.stderr);
73        if stderr.contains("could not be found") || stderr.contains("specified item could not") {
74            return Ok(());
75        }
76        Err(anyhow::anyhow!("Keychain delete failed: {}", stderr.trim()))
77    }
78}
79
80#[cfg(target_os = "macos")]
81fn run_security(args: &[&str]) -> Result<std::process::Output> {
82    use std::process::{Command, Stdio};
83    use std::time::{Duration, Instant};
84
85    let mut child = Command::new("/usr/bin/security")
86        .args(args)
87        .stdin(Stdio::null())
88        .stdout(Stdio::piped())
89        .stderr(Stdio::piped())
90        .spawn()
91        .map_err(|err| anyhow::anyhow!("Failed to start /usr/bin/security: {err}"))?;
92    let start = Instant::now();
93    loop {
94        if child
95            .try_wait()
96            .map_err(|err| anyhow::anyhow!("Failed waiting for /usr/bin/security: {err}"))?
97            .is_some()
98        {
99            return child.wait_with_output().map_err(|err| {
100                anyhow::anyhow!("Failed collecting /usr/bin/security output: {err}")
101            });
102        }
103        if start.elapsed() >= Duration::from_secs(10) {
104            let _ = child.kill();
105            let _ = child.wait();
106            anyhow::bail!("Timed out reading macOS Keychain");
107        }
108        std::thread::sleep(Duration::from_millis(25));
109    }
110}
111
112#[cfg(target_os = "macos")]
113fn trim_one_trailing_newline(value: &mut String) {
114    if value.ends_with('\n') {
115        value.pop();
116        if value.ends_with('\r') {
117            value.pop();
118        }
119    }
120}
121
122#[cfg(not(target_os = "macos"))]
123impl Keychain for SystemKeychain {
124    fn read(&self, _service: &str, _account: &str) -> Result<Option<String>> {
125        Ok(None)
126    }
127
128    fn write(&self, _service: &str, _account: &str, _value: &str) -> Result<()> {
129        anyhow::bail!("Keychain storage is not available on this platform")
130    }
131
132    fn delete(&self, _service: &str, _account: &str) -> Result<()> {
133        Ok(())
134    }
135}
136
137pub struct FileAuthStore<T>
138where
139    T: Serialize + DeserializeOwned + Send + Sync + Clone,
140{
141    file: String,
142    legacy_file: String,
143    _marker: std::marker::PhantomData<T>,
144}
145
146impl<T> FileAuthStore<T>
147where
148    T: Serialize + DeserializeOwned + Send + Sync + Clone,
149{
150    pub fn new(file: String, legacy_file: String) -> Self {
151        Self {
152            file,
153            legacy_file,
154            _marker: Default::default(),
155        }
156    }
157}
158
159impl<T> AuthStorage<T> for FileAuthStore<T>
160where
161    T: Serialize + DeserializeOwned + Send + Sync + Clone,
162{
163    fn load(&self) -> Result<Option<T>> {
164        let parsed = load_auth_file::<T>(&self.file);
165        if parsed.is_some() {
166            return Ok(parsed);
167        }
168        if self.file == self.legacy_file {
169            return Ok(None);
170        }
171        Ok(load_auth_file::<T>(&self.legacy_file))
172    }
173
174    fn save(&self, value: T) -> Result<()> {
175        let path = std::path::Path::new(&self.file);
176        if let Some(dir) = path.parent() {
177            fs::create_dir_all(dir)?;
178            set_mode(dir, 0o700);
179        }
180        write_atomically(&self.file, &value)
181    }
182
183    fn clear(&self) -> Result<()> {
184        for path in [&self.file, &self.legacy_file] {
185            if let Err(err) = fs::remove_file(path)
186                && err.kind() != io::ErrorKind::NotFound
187            {
188                return Err(anyhow::Error::from(err));
189            }
190        }
191        Ok(())
192    }
193
194    fn path(&self) -> String {
195        self.file.clone()
196    }
197}
198
199pub struct KeychainFileAuthStore<T, K = SystemKeychain>
200where
201    T: Serialize + DeserializeOwned + Send + Sync + Clone,
202    K: Keychain,
203{
204    file_store: FileAuthStore<T>,
205    keychain: K,
206    service: String,
207    account: String,
208    use_keychain: bool,
209    keychain_path: String,
210    _marker: PhantomData<T>,
211}
212
213impl<T, K> KeychainFileAuthStore<T, K>
214where
215    T: Serialize + DeserializeOwned + Send + Sync + Clone,
216    K: Keychain,
217{
218    pub fn new(
219        file: String,
220        legacy_file: String,
221        service: impl Into<String>,
222        account: impl Into<String>,
223        use_keychain: bool,
224        keychain: K,
225    ) -> Self {
226        Self {
227            file_store: FileAuthStore::new(file, legacy_file),
228            keychain,
229            service: service.into(),
230            account: account.into(),
231            use_keychain,
232            keychain_path: "macOS Keychain".to_string(),
233            _marker: PhantomData,
234        }
235    }
236}
237
238impl<T, K> AuthStorage<T> for KeychainFileAuthStore<T, K>
239where
240    T: Serialize + DeserializeOwned + Send + Sync + Clone,
241    K: Keychain,
242{
243    fn load(&self) -> Result<Option<T>> {
244        if let Some(parsed) = self.file_store.load()? {
245            return Ok(Some(parsed));
246        }
247        if self.use_keychain
248            && let Some(raw) = self.keychain.read(&self.service, &self.account)?
249        {
250            return serde_json::from_str::<T>(&raw)
251                .map(Some)
252                .map_err(|err| anyhow::anyhow!("Failed to parse Keychain auth JSON: {err}"));
253        }
254        Ok(None)
255    }
256
257    fn save(&self, value: T) -> Result<()> {
258        if self.use_keychain {
259            let raw = serde_json::to_string(&value)?;
260            if self
261                .keychain
262                .write(&self.service, &self.account, &raw)
263                .is_ok()
264            {
265                return Ok(());
266            }
267            return self.file_store.save(value);
268        }
269        self.file_store.save(value)
270    }
271
272    fn clear(&self) -> Result<()> {
273        if self.use_keychain {
274            self.keychain.delete(&self.service, &self.account)?;
275        }
276        self.file_store.clear()
277    }
278
279    fn path(&self) -> String {
280        if self.use_keychain {
281            self.keychain_path.clone()
282        } else {
283            self.file_store.path()
284        }
285    }
286}
287
288pub fn load_auth_file<T: DeserializeOwned>(path: &str) -> Option<T> {
289    let mut file = File::open(path).ok()?;
290    let mut raw = String::new();
291    file.read_to_string(&mut raw).ok()?;
292    serde_json::from_str::<T>(&raw).ok()
293}
294
295pub fn load_auth_file_value(path: &std::path::Path) -> Option<serde_json::Value> {
296    let mut file = File::open(path).ok()?;
297    let mut raw = String::new();
298    file.read_to_string(&mut raw).ok()?;
299    serde_json::from_str::<serde_json::Value>(&raw).ok()
300}
301
302pub fn load_auth_file_with_legacy<T: DeserializeOwned>(
303    primary: &std::path::Path,
304    legacy: &std::path::Path,
305) -> Option<T> {
306    if let Some(value) = load_auth_file_value(primary) {
307        return serde_json::from_value(value).ok();
308    }
309    if primary == legacy {
310        None
311    } else {
312        load_auth_file_value(legacy).and_then(|value| serde_json::from_value(value).ok())
313    }
314}
315
316pub fn delete_auth_file(primary: &std::path::Path, legacy: &std::path::Path) -> io::Result<()> {
317    if let Err(err) = fs::remove_file(primary)
318        && err.kind() != io::ErrorKind::NotFound
319    {
320        return Err(err);
321    }
322    if primary != legacy
323        && let Err(err) = fs::remove_file(legacy)
324        && err.kind() != io::ErrorKind::NotFound
325    {
326        return Err(err);
327    }
328    Ok(())
329}
330
331pub fn write_atomically<T: Serialize>(path: &str, value: &T) -> Result<()> {
332    let dir = std::path::Path::new(path)
333        .parent()
334        .ok_or_else(|| anyhow::anyhow!("invalid auth path"))?;
335    fs::create_dir_all(dir)?;
336    set_mode(dir, 0o700);
337
338    let tmp = format!("{path}.tmp-{}", uuid::Uuid::new_v4());
339    #[cfg(unix)]
340    let mut out = {
341        use std::os::unix::fs::OpenOptionsExt;
342        std::fs::OpenOptions::new()
343            .write(true)
344            .create_new(true)
345            .mode(0o600)
346            .open(&tmp)?
347    };
348    #[cfg(not(unix))]
349    let mut out = std::fs::OpenOptions::new()
350        .write(true)
351        .create_new(true)
352        .open(&tmp)?;
353    out.write_all(to_string_pretty(value)?.as_bytes())?;
354    out.sync_all()?;
355    if let Err(err) = fs::rename(&tmp, path) {
356        let _ = fs::remove_file(&tmp);
357        return Err(err.into());
358    }
359    set_mode(std::path::Path::new(path), 0o600);
360    Ok(())
361}
362
363fn set_mode(path: &std::path::Path, mode: u32) {
364    #[cfg(unix)]
365    {
366        if let Ok(meta) = fs::metadata(path) {
367            let mut permissions = meta.permissions();
368            permissions.set_mode(mode);
369            let _ = fs::set_permissions(path, permissions);
370        }
371    }
372}
373
374pub struct InMemoryAuthStore<T>
375where
376    T: Serialize + DeserializeOwned + Send + Sync + Clone,
377{
378    inner: std::sync::Arc<std::sync::Mutex<Option<T>>>,
379}
380
381impl<T> Default for InMemoryAuthStore<T>
382where
383    T: Serialize + DeserializeOwned + Send + Sync + Clone,
384{
385    fn default() -> Self {
386        Self::new()
387    }
388}
389
390impl<T> InMemoryAuthStore<T>
391where
392    T: Serialize + DeserializeOwned + Send + Sync + Clone,
393{
394    pub fn new() -> Self {
395        Self {
396            inner: std::sync::Arc::new(std::sync::Mutex::new(None)),
397        }
398    }
399}
400
401impl<T> Clone for InMemoryAuthStore<T>
402where
403    T: Serialize + DeserializeOwned + Send + Sync + Clone,
404{
405    fn clone(&self) -> Self {
406        Self {
407            inner: self.inner.clone(),
408        }
409    }
410}
411
412impl<T> AuthStorage<T> for InMemoryAuthStore<T>
413where
414    T: Serialize + DeserializeOwned + Send + Sync + Clone,
415{
416    fn load(&self) -> Result<Option<T>> {
417        let inner = self
418            .inner
419            .lock()
420            .map_err(|err| anyhow::anyhow!(err.to_string()))?;
421        Ok(inner.clone())
422    }
423
424    fn save(&self, value: T) -> Result<()> {
425        let mut inner = self
426            .inner
427            .lock()
428            .map_err(|err| anyhow::anyhow!(err.to_string()))?;
429        *inner = Some(value);
430        Ok(())
431    }
432
433    fn clear(&self) -> Result<()> {
434        let mut inner = self
435            .inner
436            .lock()
437            .map_err(|err| anyhow::anyhow!(err.to_string()))?;
438        *inner = None;
439        Ok(())
440    }
441
442    fn path(&self) -> String {
443        "memory".to_string()
444    }
445}
446
447#[cfg(test)]
448pub fn fixture_store<T>() -> InMemoryAuthStore<T>
449where
450    T: Serialize + serde::de::DeserializeOwned + Send + Sync + Clone,
451{
452    InMemoryAuthStore::new()
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use serde_json::json;
459    use std::collections::HashMap;
460    use std::sync::{Arc, Mutex};
461
462    #[derive(Clone, Default)]
463    struct MockKeychain {
464        values: Arc<Mutex<HashMap<(String, String), String>>>,
465    }
466
467    impl MockKeychain {
468        fn set_raw(&self, service: &str, account: &str, value: serde_json::Value) {
469            self.values.lock().unwrap().insert(
470                (service.to_string(), account.to_string()),
471                value.to_string(),
472            );
473        }
474
475        fn raw(&self, service: &str, account: &str) -> Option<String> {
476            self.values
477                .lock()
478                .unwrap()
479                .get(&(service.to_string(), account.to_string()))
480                .cloned()
481        }
482    }
483
484    impl Keychain for MockKeychain {
485        fn read(&self, service: &str, account: &str) -> Result<Option<String>> {
486            Ok(self.raw(service, account))
487        }
488
489        fn write(&self, service: &str, account: &str, value: &str) -> Result<()> {
490            self.values.lock().unwrap().insert(
491                (service.to_string(), account.to_string()),
492                value.to_string(),
493            );
494            Ok(())
495        }
496
497        fn delete(&self, service: &str, account: &str) -> Result<()> {
498            self.values
499                .lock()
500                .unwrap()
501                .remove(&(service.to_string(), account.to_string()));
502            Ok(())
503        }
504    }
505
506    #[derive(Clone, Default)]
507    struct ReadOnlyKeychain(MockKeychain);
508
509    impl Keychain for ReadOnlyKeychain {
510        fn read(&self, service: &str, account: &str) -> Result<Option<String>> {
511            self.0.read(service, account)
512        }
513
514        fn write(&self, _service: &str, _account: &str, _value: &str) -> Result<()> {
515            anyhow::bail!("read-only")
516        }
517
518        fn delete(&self, service: &str, account: &str) -> Result<()> {
519            self.0.delete(service, account)
520        }
521    }
522
523    fn temp_auth_path(dir: &tempfile::TempDir, name: &str) -> String {
524        dir.path().join(name).to_string_lossy().to_string()
525    }
526
527    #[test]
528    fn keychain_file_store_loads_file_before_keychain() {
529        let temp = tempfile::TempDir::new().unwrap();
530        let file = temp_auth_path(&temp, "auth.json");
531        let legacy = temp_auth_path(&temp, "legacy.json");
532        write_atomically(&file, &json!({"source": "file"})).unwrap();
533
534        let keychain = MockKeychain::default();
535        keychain.set_raw("svc", "acct", json!({"source": "keychain"}));
536
537        let store: KeychainFileAuthStore<serde_json::Value, _> =
538            KeychainFileAuthStore::new(file, legacy, "svc", "acct", true, keychain);
539
540        let loaded = store.load().unwrap().unwrap();
541        assert_eq!(loaded["source"], json!("file"));
542        assert_eq!(store.path(), "macOS Keychain");
543    }
544
545    #[test]
546    fn keychain_file_store_falls_back_to_keychain_when_file_missing() {
547        let temp = tempfile::TempDir::new().unwrap();
548        let file = temp_auth_path(&temp, "auth.json");
549        let legacy = temp_auth_path(&temp, "legacy.json");
550        let keychain = MockKeychain::default();
551        keychain.set_raw("svc", "acct", json!({"source": "keychain"}));
552
553        let store: KeychainFileAuthStore<serde_json::Value, _> =
554            KeychainFileAuthStore::new(file, legacy, "svc", "acct", true, keychain);
555
556        let loaded = store.load().unwrap().unwrap();
557        assert_eq!(loaded["source"], json!("keychain"));
558    }
559
560    #[test]
561    fn keychain_file_store_saves_and_clears_keychain_when_enabled() {
562        let temp = tempfile::TempDir::new().unwrap();
563        let file = temp_auth_path(&temp, "auth.json");
564        let legacy = temp_auth_path(&temp, "legacy.json");
565        write_atomically(&file, &json!({"source": "file"})).unwrap();
566
567        let keychain = MockKeychain::default();
568        let store: KeychainFileAuthStore<serde_json::Value, _> =
569            KeychainFileAuthStore::new(file.clone(), legacy, "svc", "acct", true, keychain.clone());
570
571        store.save(json!({"source": "saved"})).unwrap();
572        let raw = keychain.raw("svc", "acct").unwrap();
573        assert_eq!(
574            serde_json::from_str::<serde_json::Value>(&raw).unwrap()["source"],
575            json!("saved")
576        );
577
578        store.clear().unwrap();
579        assert!(keychain.raw("svc", "acct").is_none());
580        assert!(!std::path::Path::new(&file).exists());
581    }
582
583    #[test]
584    fn keychain_file_store_falls_back_to_file_when_keychain_write_fails() {
585        let temp = tempfile::TempDir::new().unwrap();
586        let file = temp_auth_path(&temp, "auth.json");
587        let legacy = temp_auth_path(&temp, "legacy.json");
588        let store: KeychainFileAuthStore<serde_json::Value, _> = KeychainFileAuthStore::new(
589            file.clone(),
590            legacy,
591            "svc",
592            "acct",
593            true,
594            ReadOnlyKeychain::default(),
595        );
596
597        store.save(json!({"source": "file-fallback"})).unwrap();
598        assert_eq!(
599            store.load().unwrap().unwrap()["source"],
600            json!("file-fallback")
601        );
602        assert!(std::path::Path::new(&file).exists());
603    }
604
605    #[test]
606    fn keychain_file_store_uses_file_when_keychain_disabled() {
607        let temp = tempfile::TempDir::new().unwrap();
608        let file = temp_auth_path(&temp, "auth.json");
609        let legacy = temp_auth_path(&temp, "legacy.json");
610        let keychain = MockKeychain::default();
611        let store: KeychainFileAuthStore<serde_json::Value, _> = KeychainFileAuthStore::new(
612            file.clone(),
613            legacy,
614            "svc",
615            "acct",
616            false,
617            keychain.clone(),
618        );
619
620        store.save(json!({"source": "file"})).unwrap();
621        assert!(keychain.raw("svc", "acct").is_none());
622        assert_eq!(store.path(), file);
623        assert_eq!(store.load().unwrap().unwrap()["source"], json!("file"));
624    }
625}