Skip to main content

oxicode_sdk/ports/fs/
auth.rs

1//! File-based `AuthProvider` — single JSON file holding API keys and OAuth
2//! tokens per provider.
3
4use parking_lot::Mutex;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::future::Future;
8use std::path::PathBuf;
9use std::pin::Pin;
10
11use crate::SdkError;
12use crate::ports::{AuthProvider, OAuthToken};
13
14/// On-disk schema for `auth.json`.
15///
16/// ```json
17/// {
18///   "version": 1,
19///   "providers": {
20///     "anthropic": { "api_key": "sk-ant-..." },
21///     "openai":    { "api_key": "sk-..." },
22///     "google":    { "oauth": { "access_token": "...", "refresh_token": "..." } }
23///   }
24/// }
25/// ```
26#[derive(Debug, Default, Clone, Serialize, Deserialize)]
27struct AuthFile {
28    #[serde(default = "default_version")]
29    version: u32,
30    #[serde(default)]
31    providers: HashMap<String, ProviderEntry>,
32}
33
34fn default_version() -> u32 {
35    1
36}
37
38#[derive(Debug, Default, Clone, Serialize, Deserialize)]
39struct ProviderEntry {
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    api_key: Option<String>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    oauth: Option<OAuthToken>,
44}
45
46impl AuthFile {
47    fn load(path: &std::path::Path) -> Self {
48        if !path.exists() {
49            return Self::default();
50        }
51        match std::fs::read_to_string(path) {
52            Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
53            Err(_) => Self::default(),
54        }
55    }
56
57    fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
58        if let Some(parent) = path.parent() {
59            std::fs::create_dir_all(parent)?;
60        }
61        let tmp = path.with_extension("json.tmp");
62        // SAFETY: `AuthFile` derives `Serialize` with only plain fields; no
63        // custom serializer returns Err, so serialization cannot fail.
64        #[allow(clippy::expect_used)]
65        let text = serde_json::to_string_pretty(self).expect("serializable");
66        std::fs::write(&tmp, text)?;
67        std::fs::rename(&tmp, path)?;
68        Ok(())
69    }
70}
71
72/// File-based `AuthProvider`.
73///
74/// Concurrency: a single `Mutex` guards the in-memory cache. Read-heavy
75/// workloads should consider `Arc<AuthProvider>` + cloning; writes are
76/// rare (interactive login) so a coarse lock is fine.
77pub struct FileAuthProvider {
78    path: PathBuf,
79    state: Mutex<AuthFile>,
80}
81
82impl std::fmt::Debug for FileAuthProvider {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("FileAuthProvider")
85            .field("path", &self.path)
86            .finish()
87    }
88}
89
90impl FileAuthProvider {
91    /// Create a new provider that reads/writes `path`.
92    pub fn new(path: impl Into<PathBuf>) -> Self {
93        let path = path.into();
94        let state = AuthFile::load(&path);
95        Self {
96            path,
97            state: Mutex::new(state),
98        }
99    }
100
101    /// Resolve an API key with this priority:
102    /// 1. The given in-memory state (loaded from `path`)
103    /// 2. The `OXICODE_API_KEY_<UPPER>` env var
104    /// 3. The provider's standard env var (e.g. `ANTHROPIC_API_KEY`)
105    fn resolve_with(state: &AuthFile, provider: &str) -> Option<String> {
106        if let Some(k) = state
107            .providers
108            .get(provider)
109            .and_then(|e| e.api_key.clone())
110        {
111            return Some(k);
112        }
113        Self::env_fallback(provider)
114    }
115
116    /// Environment-variable fallback used after both the in-memory cache
117    /// and (for the sync path) a fresh disk read miss.
118    fn env_fallback(provider: &str) -> Option<String> {
119        let upper = provider.to_uppercase();
120        if let Ok(k) = std::env::var(format!("OXICODE_API_KEY_{upper}"))
121            && !k.is_empty()
122        {
123            return Some(k);
124        }
125        // Standard provider env vars (best-effort, optional).
126        let conventional = match provider {
127            "anthropic" => "ANTHROPIC_API_KEY",
128            "openai" => "OPENAI_API_KEY",
129            "google" | "gemini" => "GOOGLE_API_KEY",
130            "deepseek" => "DEEPSEEK_API_KEY",
131            _ => return None,
132        };
133        std::env::var(conventional).ok().filter(|s| !s.is_empty())
134    }
135
136    /// Resolve an API key against the in-memory boot-time cache.
137    ///
138    /// Use [`get_api_key_sync`](AuthProvider::get_api_key_sync) for the
139    /// dynamic path that re-reads `path` from disk — that's the one
140    /// `Oxicode::create_provider` consults, so it picks up writes from
141    /// external singletons (e.g. the CLI's `shared_auth_storage()`).
142    pub fn resolve_api_key(&self, provider: &str) -> Option<String> {
143        let state = self.state.lock();
144        Self::resolve_with(&state, provider)
145    }
146}
147
148impl AuthProvider for FileAuthProvider {
149    fn get_api_key(
150        &self,
151        provider: &str,
152    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, SdkError>> + Send + '_>> {
153        let result = self.resolve_api_key(provider);
154        Box::pin(async move { Ok(result) })
155    }
156
157    /// Sync fast-path — **re-reads `path` from disk** on every call so
158    /// external writers (e.g. the CLI's `shared_auth_storage()` singleton
159    /// at oxicode-cli/src/store/auth_storage.rs:1135, which maintains its own
160    /// independent in-memory cache) are reflected without restart. This is
161    /// the credential source `Oxicode::create_provider` consults at build /
162    /// `switch_model` / `refresh_credentials` time; per-call file I/O is
163    /// negligible on those paths. Issue #40.
164    fn get_api_key_sync(&self, provider: &str) -> Result<Option<String>, SdkError> {
165        let fresh = AuthFile::load(&self.path);
166        Ok(Self::resolve_with(&fresh, provider))
167    }
168
169    fn set_api_key(
170        &self,
171        provider: &str,
172        key: &str,
173    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
174        let mut s = self.state.lock();
175        s.providers.entry(provider.to_string()).or_default().api_key = Some(key.to_string());
176        let result = s.save(&self.path).map_err(|e| SdkError::Internal(e.into()));
177        Box::pin(async { result })
178    }
179
180    fn delete_api_key(
181        &self,
182        provider: &str,
183    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
184        let mut s = self.state.lock();
185        if let Some(entry) = s.providers.get_mut(provider) {
186            entry.api_key = None;
187            if entry.oauth.is_none() {
188                s.providers.remove(provider);
189            }
190        }
191        let result = s.save(&self.path).map_err(|e| SdkError::Internal(e.into()));
192        Box::pin(async { result })
193    }
194
195    fn get_oauth(
196        &self,
197        provider: &str,
198    ) -> Pin<Box<dyn Future<Output = Result<Option<OAuthToken>, SdkError>> + Send + '_>> {
199        let s = self.state.lock();
200        let result = s.providers.get(provider).and_then(|e| e.oauth.clone());
201        Box::pin(async move { Ok(result) })
202    }
203
204    fn set_oauth(
205        &self,
206        provider: &str,
207        token: OAuthToken,
208    ) -> Pin<Box<dyn Future<Output = Result<(), SdkError>> + Send + '_>> {
209        let mut s = self.state.lock();
210        s.providers.entry(provider.to_string()).or_default().oauth = Some(token);
211        let result = s.save(&self.path).map_err(|e| SdkError::Internal(e.into()));
212        Box::pin(async { result })
213    }
214
215    fn list_providers(
216        &self,
217    ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, SdkError>> + Send + '_>> {
218        let s = self.state.lock();
219        let result = s.providers.keys().cloned().collect();
220        Box::pin(async move { Ok(result) })
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use tempfile::TempDir;
228
229    #[tokio::test]
230    async fn set_then_get_api_key() {
231        let tmp = TempDir::new().unwrap();
232        let p = tmp.path().join("auth.json");
233        let auth = FileAuthProvider::new(&p);
234        auth.set_api_key("anthropic", "sk-ant-test").await.unwrap();
235        let got = auth.get_api_key("anthropic").await.unwrap();
236        assert_eq!(got.as_deref(), Some("sk-ant-test"));
237        assert!(p.exists());
238    }
239
240    #[tokio::test]
241    async fn delete_api_key_removes_entry() {
242        let tmp = TempDir::new().unwrap();
243        let p = tmp.path().join("auth.json");
244        let auth = FileAuthProvider::new(&p);
245        auth.set_api_key("openai", "k").await.unwrap();
246        auth.delete_api_key("openai").await.unwrap();
247        assert!(auth.get_api_key("openai").await.unwrap().is_none());
248    }
249
250    #[tokio::test]
251    async fn oauth_round_trip() {
252        let tmp = TempDir::new().unwrap();
253        let p = tmp.path().join("auth.json");
254        let auth = FileAuthProvider::new(&p);
255        let token = OAuthToken::bearer("ya29.test");
256        auth.set_oauth("google", token.clone()).await.unwrap();
257        let got = auth.get_oauth("google").await.unwrap().unwrap();
258        assert_eq!(got.access_token, "ya29.test");
259    }
260
261    #[tokio::test]
262    async fn env_var_fallback_when_no_file_entry() {
263        let tmp = TempDir::new().unwrap();
264        let p = tmp.path().join("auth.json");
265        let auth = FileAuthProvider::new(&p);
266        // No file entry. Check that resolve_api_key returns None for an
267        // unrecognised provider.
268        assert!(auth.resolve_api_key("nonexistent-xyz").is_none());
269    }
270
271    /// Regression (#40 advisor follow-up): `get_api_key_sync` MUST re-read
272    /// `path` from disk on every call. Without this, an external writer
273    /// updating `auth.json` (e.g. the CLI's `shared_auth_storage()`
274    /// singleton, which owns a separate in-memory cache) would not be
275    /// visible to `Oxicode::create_provider`, silently breaking mid-session
276    /// credential refresh from the TUI provider overlay.
277    #[test]
278    fn get_api_key_sync_re_reads_disk_after_external_write() {
279        let tmp = TempDir::new().unwrap();
280        let p = tmp.path().join("auth.json");
281        let auth = FileAuthProvider::new(&p);
282
283        // Initially empty.
284        assert_eq!(
285            auth.get_api_key_sync("anthropic").unwrap().as_deref(),
286            None,
287            "fresh provider should have no key"
288        );
289
290        // External write — simulate `shared_auth_storage().set_api_key(...)`
291        std::fs::write(
292            &p,
293            r#"{"version":1,"providers":{"anthropic":{"api_key":"sk-external"}}}"#,
294        )
295        .unwrap();
296
297        // Sync fast-path must observe the new key WITHOUT re-instantiating
298        // the FileAuthProvider — proves it re-reads `path` from disk.
299        assert_eq!(
300            auth.get_api_key_sync("anthropic").unwrap().as_deref(),
301            Some("sk-external"),
302            "get_api_key_sync must re-read path on every call; \
303             FileAuthProvider's own in-memory cache is still empty here"
304        );
305
306        // Sanity: the cache-only `resolve_api_key` does NOT observe the
307        // external write — proving the test is exercising the new path
308        // (not just the existing cache).
309        assert_eq!(
310            auth.resolve_api_key("anthropic").as_deref(),
311            None,
312            "cache-only resolve_api_key must stay stale; \
313             this assertion locks the dual-cache contract"
314        );
315    }
316}