Skip to main content

entelix_auth_claude_code/
store.rs

1//! `CredentialStore` trait + the default `FileCredentialStore` impl.
2//!
3//! The trait keeps backend choice on the operator side. The default
4//! reads / writes the JSON file the `claude` CLI uses, but operators
5//! that ship credentials through a vault, an env-var-backed
6//! in-memory store, or a platform secret API (macOS Keychain, Linux
7//! Secret Service, Windows Credential Manager) implement
8//! [`CredentialStore`] directly.
9//!
10//! The store is async — every backend may incur IO or a syscall.
11
12use std::path::{Path, PathBuf};
13
14use async_trait::async_trait;
15
16use crate::credential::CredentialFile;
17use crate::error::{ClaudeCodeAuthError, ClaudeCodeAuthResult};
18
19/// Async credential persistence backend.
20#[async_trait]
21pub trait CredentialStore: Send + Sync + 'static {
22    /// Load the current credential envelope, or `None` if the
23    /// backend holds no credential yet.
24    async fn load(&self) -> ClaudeCodeAuthResult<Option<CredentialFile>>;
25    /// Persist a refreshed credential envelope. Backends that
26    /// cannot retain state (e.g. a read-only env var) return
27    /// [`ClaudeCodeAuthError::Io`] with a descriptive message —
28    /// the provider surfaces that to the operator.
29    async fn save(&self, file: &CredentialFile) -> ClaudeCodeAuthResult<()>;
30}
31
32/// File-backed [`CredentialStore`] at a caller-supplied path.
33///
34/// The path mirrors the on-disk shape the `claude` CLI writes
35/// (`~/.claude/.credentials.json` by convention); use
36/// [`FileCredentialStore::default_claude_path`] to resolve that
37/// location against the host's home directory.
38#[derive(Debug, Clone)]
39pub struct FileCredentialStore {
40    path: PathBuf,
41}
42
43impl FileCredentialStore {
44    /// Build a store rooted at the given file path.
45    #[must_use]
46    pub fn with_path(path: impl Into<PathBuf>) -> Self {
47        Self { path: path.into() }
48    }
49
50    /// The configured store path.
51    #[must_use]
52    pub fn path(&self) -> &Path {
53        &self.path
54    }
55
56    /// Resolve `~/.claude/.credentials.json` against the host's
57    /// home directory (`HOME` on Unix, `USERPROFILE` on Windows).
58    /// Returns [`ClaudeCodeAuthError::HomeUnresolved`] when neither
59    /// is set.
60    pub fn default_claude_path() -> ClaudeCodeAuthResult<PathBuf> {
61        let home = std::env::var_os("HOME")
62            .or_else(|| std::env::var_os("USERPROFILE"))
63            .ok_or(ClaudeCodeAuthError::HomeUnresolved)?;
64        let mut path = PathBuf::from(home);
65        path.push(".claude");
66        path.push(".credentials.json");
67        Ok(path)
68    }
69}
70
71#[async_trait]
72impl CredentialStore for FileCredentialStore {
73    async fn load(&self) -> ClaudeCodeAuthResult<Option<CredentialFile>> {
74        let path = self.path.clone();
75        let read = tokio::task::spawn_blocking(move || std::fs::read(&path))
76            .await
77            .map_err(|join_err| ClaudeCodeAuthError::Io {
78                path: self.path.display().to_string(),
79                source: std::io::Error::other(join_err.to_string()),
80            })?;
81        let bytes = match read {
82            Ok(bytes) => bytes,
83            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
84            Err(source) => {
85                return Err(ClaudeCodeAuthError::Io {
86                    path: self.path.display().to_string(),
87                    source,
88                });
89            }
90        };
91        let file: CredentialFile = serde_json::from_slice(&bytes).map_err(|source| {
92            ClaudeCodeAuthError::InvalidStorage {
93                path: self.path.display().to_string(),
94                source,
95            }
96        })?;
97        Ok(Some(file))
98    }
99
100    async fn save(&self, file: &CredentialFile) -> ClaudeCodeAuthResult<()> {
101        let path = self.path.clone();
102        let display = self.path.display().to_string();
103        let bytes = serde_json::to_vec_pretty(file).map_err(|source| {
104            ClaudeCodeAuthError::InvalidStorage {
105                path: display.clone(),
106                source,
107            }
108        })?;
109        let display_for_blocking = display.clone();
110        let write = tokio::task::spawn_blocking(move || atomic_write(&path, &bytes))
111            .await
112            .map_err(|join_err| ClaudeCodeAuthError::Io {
113                path: display_for_blocking.clone(),
114                source: std::io::Error::other(join_err.to_string()),
115            })?;
116        write.map_err(|source| ClaudeCodeAuthError::Io {
117            path: display,
118            source,
119        })
120    }
121}
122
123/// Write `bytes` to `path` atomically: create the parent directory,
124/// stream into a sibling `<file>.tmp`, then `rename` over the
125/// destination.
126///
127/// Atomicity: `rename(2)` is atomic on POSIX — readers either see
128/// the prior file or the complete new one, never a partial write.
129/// On Windows, `MoveFileExW`'s default flags reject overwriting an
130/// existing destination; the error propagates so the operator sees
131/// the failure rather than silent corruption (a Windows-specific
132/// `MOVEFILE_REPLACE_EXISTING` flag would need a `winapi`
133/// dependency we deliberately avoid in this minimal companion).
134fn atomic_write(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
135    if let Some(parent) = path.parent() {
136        std::fs::create_dir_all(parent)?;
137    }
138    let mut tmp_name = path
139        .file_name()
140        .ok_or_else(|| std::io::Error::other("destination path has no file name"))?
141        .to_owned();
142    tmp_name.push(".tmp");
143    let tmp_path = path.with_file_name(tmp_name);
144    // If a stale `.tmp` lingers from a prior crashed write, ignore
145    // the absence-error and proceed — the new write overwrites it.
146    let _ = std::fs::remove_file(&tmp_path);
147    std::fs::write(&tmp_path, bytes)?;
148    std::fs::rename(&tmp_path, path)
149}
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used)]
153mod tests {
154    use super::*;
155    use crate::credential::OAuthCredential;
156    use chrono::Utc;
157
158    fn tmp_path(name: &str) -> PathBuf {
159        let mut path = std::env::temp_dir();
160        path.push(format!(
161            "entelix-claude-code-{}-{}.json",
162            std::process::id(),
163            name
164        ));
165        path
166    }
167
168    #[tokio::test]
169    async fn load_returns_none_when_file_absent() {
170        let store = FileCredentialStore::with_path(tmp_path("absent"));
171        let loaded = store.load().await.unwrap();
172        assert!(loaded.is_none());
173    }
174
175    #[tokio::test]
176    async fn save_overwrites_existing_file_via_rename() {
177        // Atomic write must replace any prior credential file —
178        // the upstream `claude` CLI shares this path, and a stale
179        // refresh-token there would invalidate every subsequent
180        // session.
181        let path = tmp_path("overwrite");
182        let _ = std::fs::remove_file(&path);
183        let store = FileCredentialStore::with_path(&path);
184        let first = CredentialFile::with_oauth(OAuthCredential::new(
185            "first",
186            (Utc::now() + chrono::Duration::hours(1)).timestamp_millis(),
187        ));
188        store.save(&first).await.unwrap();
189        let second = CredentialFile::with_oauth(OAuthCredential::new(
190            "second",
191            (Utc::now() + chrono::Duration::hours(2)).timestamp_millis(),
192        ));
193        store.save(&second).await.unwrap();
194        let loaded = store.load().await.unwrap().unwrap();
195        assert_eq!(
196            loaded.claude_ai_oauth.unwrap().access_token,
197            "second",
198            "second save must replace first"
199        );
200        // No stale `.tmp` sibling should linger after a successful
201        // rename — verifies the cleanup contract.
202        let mut tmp_sibling = path.clone();
203        let mut tmp_name = path.file_name().unwrap().to_owned();
204        tmp_name.push(".tmp");
205        tmp_sibling.set_file_name(tmp_name);
206        assert!(
207            !tmp_sibling.exists(),
208            "rename must consume the .tmp staging file"
209        );
210        let _ = std::fs::remove_file(&path);
211    }
212
213    #[tokio::test]
214    async fn save_then_load_round_trips() {
215        let path = tmp_path("round_trip");
216        let _ = std::fs::remove_file(&path);
217        let store = FileCredentialStore::with_path(&path);
218        let envelope = CredentialFile::with_oauth(
219            OAuthCredential::new(
220                "tok",
221                (Utc::now() + chrono::Duration::hours(1)).timestamp_millis(),
222            )
223            .with_refresh_token("ref")
224            .with_subscription_type("pro")
225            .with_scopes(["user:inference"]),
226        );
227        store.save(&envelope).await.unwrap();
228        let loaded = store.load().await.unwrap().unwrap();
229        let oauth = loaded.claude_ai_oauth.unwrap();
230        assert_eq!(oauth.access_token, "tok");
231        assert_eq!(oauth.subscription_type.as_deref(), Some("pro"));
232        let _ = std::fs::remove_file(&path);
233    }
234
235    #[test]
236    fn default_path_resolves_from_environment() {
237        // Whatever HOME / USERPROFILE the test environment exposes,
238        // the helper either succeeds (typical case) or returns
239        // `HomeUnresolved` (CI sandboxes). Both paths are valid
240        // contract outcomes; the test asserts the success branch
241        // produces the documented suffix.
242        if let Ok(path) = FileCredentialStore::default_claude_path() {
243            assert!(
244                path.ends_with(".claude/.credentials.json")
245                    || path.ends_with(r".claude\.credentials.json"),
246                "unexpected path shape: {}",
247                path.display()
248            );
249        }
250    }
251}