oxicode_sdk/ports/fs/
auth.rs1use 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#[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 #[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
72pub 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 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 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 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 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 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 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 assert!(auth.resolve_api_key("nonexistent-xyz").is_none());
269 }
270
271 #[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 assert_eq!(
285 auth.get_api_key_sync("anthropic").unwrap().as_deref(),
286 None,
287 "fresh provider should have no key"
288 );
289
290 std::fs::write(
292 &p,
293 r#"{"version":1,"providers":{"anthropic":{"api_key":"sk-external"}}}"#,
294 )
295 .unwrap();
296
297 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 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}