1use anyhow::Result;
20use std::collections::HashMap;
21use std::path::PathBuf;
22
23#[derive(Debug, Clone)]
25pub enum CredentialSource {
26 Config,
28 OxiAuthStore,
30 EnvVar,
32}
33
34pub struct CredentialStore;
36
37impl CredentialStore {
38 pub fn resolve(provider: &str, config_key: Option<&str>) -> Option<(String, CredentialSource)> {
44 let env_var = format!("OXIOS_{}_API_KEY", provider.to_uppercase());
46 if let Ok(key) = std::env::var(&env_var)
47 && !key.is_empty()
48 {
49 return Some((key, CredentialSource::EnvVar));
50 }
51
52 if let Some(key) = config_key
54 && !key.is_empty()
55 {
56 return Some((key.to_string(), CredentialSource::Config));
57 }
58
59 if let Ok(Some(token)) = oxi_sdk::load_token(provider)
61 && !token.access_token.is_empty()
62 {
63 return Some((token.access_token, CredentialSource::OxiAuthStore));
64 }
65
66 if let Some(key) = load_from_shared_store(provider) {
68 return Some((key, CredentialSource::OxiAuthStore));
69 }
70
71 if let Some(key) = oxi_sdk::get_env_api_key(provider) {
73 return Some((key, CredentialSource::EnvVar));
74 }
75
76 None
77 }
78
79 pub fn has_credential(provider: &str, config_key: Option<&str>) -> bool {
81 Self::resolve(provider, config_key).is_some()
82 }
83
84 pub fn store(provider: &str, api_key: &str) -> Result<()> {
91 let token = oxi_sdk::TokenBundle {
92 access_token: api_key.to_string(),
93 refresh_token: None,
94 token_type: "Bearer".to_string(),
95 obtained_at: chrono::Utc::now(),
96 expires_in: 0,
97 scope: None,
98 };
99
100 if let Err(e) = oxi_sdk::save_token(provider, &token) {
102 if is_legacy_auth_error(&e) {
106 tracing::info!("auth.json has legacy format, migrating to TokenBundle");
107 migrate_legacy_auth_store(provider, &token)?;
108 } else {
109 return Err(e.into());
110 }
111 }
112
113 tracing::info!(provider = %provider, "API key stored to oxios auth store");
114 Ok(())
115 }
116
117 pub fn delete(key: &str) -> Result<()> {
123 let _ = oxi_sdk::remove_token(key);
125
126 if let Ok(path) = shared_auth_json_path()
128 && path.exists()
129 && let Ok(raw) = std::fs::read_to_string(&path)
130 && let Ok(mut map) =
131 serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw)
132 && map.remove(key).is_some()
133 {
134 std::fs::write(&path, serde_json::to_string_pretty(&map)?)?;
135 tracing::info!(key = %key, "Credential deleted from shared oxi-cli auth store");
136 }
137 Ok(())
138 }
139
140 pub fn resolve_secret(key: &str, env_var: &str) -> Option<(String, CredentialSource)> {
147 if let Ok(val) = std::env::var(env_var)
149 && !val.is_empty()
150 {
151 return Some((val, CredentialSource::EnvVar));
152 }
153 if let Ok(Some(token)) = oxi_sdk::load_token(key)
155 && !token.access_token.is_empty()
156 {
157 return Some((token.access_token, CredentialSource::OxiAuthStore));
158 }
159 if let Some(val) = load_from_shared_store(key) {
161 return Some((val, CredentialSource::OxiAuthStore));
162 }
163 None
164 }
165
166 pub fn provider_from_model(model_id: &str) -> Option<&str> {
170 if model_id.is_empty() {
171 return None;
172 }
173 model_id.split_once('/').map(|(p, _)| p)
174 }
175}
176
177#[derive(serde::Deserialize)]
181struct LegacyEntry {
182 #[allow(dead_code)]
183 r#type: String,
184 key: String,
185}
186
187fn extract_credential(provider: &str, raw: &str) -> Option<String> {
192 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(raw).ok()?;
193 let entry = map.get(provider)?;
194 if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(entry.clone())
196 && !bundle.access_token.is_empty()
197 {
198 return Some(bundle.access_token);
199 }
200 if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(entry.clone())
202 && !legacy.key.is_empty()
203 {
204 return Some(legacy.key);
205 }
206 None
207}
208
209fn load_from_shared_store(provider: &str) -> Option<String> {
214 let path = match shared_auth_json_path() {
215 Ok(p) => p,
216 Err(_) => return None,
217 };
218 let raw = match std::fs::read_to_string(&path) {
219 Ok(s) => s,
220 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
221 Err(e) => {
222 tracing::warn!(
223 provider = %provider,
224 path = %path.display(),
225 error = %e,
226 "shared auth.json exists but could not be read; skipping",
227 );
228 return None;
229 }
230 };
231 extract_credential(provider, &raw)
232}
233
234fn is_legacy_auth_error(err: &oxi_sdk::OAuthError) -> bool {
236 matches!(err, oxi_sdk::OAuthError::Json(_))
237}
238
239fn migrate_legacy_auth_store(provider: &str, new_token: &oxi_sdk::TokenBundle) -> Result<()> {
242 let path = shared_auth_json_path()?;
243 let raw = std::fs::read_to_string(&path)?;
244
245 let entries: serde_json::Map<String, serde_json::Value> =
247 serde_json::from_str(&raw).unwrap_or_default();
248
249 let mut migrated = HashMap::new();
250
251 for (key, value) in &entries {
252 if key == provider {
253 continue; }
255
256 if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(value.clone()) {
258 migrated.insert(key.clone(), bundle);
259 continue;
260 }
261
262 if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(value.clone()) {
264 migrated.insert(
265 key.clone(),
266 oxi_sdk::TokenBundle {
267 access_token: legacy.key,
268 refresh_token: None,
269 token_type: "Bearer".to_string(),
270 obtained_at: chrono::Utc::now(),
271 expires_in: 0,
272 scope: None,
273 },
274 );
275 continue;
276 }
277
278 tracing::warn!(provider = %key, "skipping unparseable auth.json entry during migration");
279 }
280
281 migrated.insert(provider.to_string(), new_token.clone());
283
284 let store = oxi_sdk::AuthStore { tokens: migrated };
286 oxi_sdk::save_auth_store(&store)?;
287 Ok(())
288}
289
290fn shared_auth_json_path() -> Result<PathBuf> {
297 let home = std::env::var("HOME")
298 .or_else(|_| std::env::var("USERPROFILE"))
299 .map_err(|_| anyhow::anyhow!("Cannot determine home directory"))?;
300 Ok(PathBuf::from(home).join(".oxi").join("auth.json"))
301}
302
303pub fn discover_auth_store_providers() -> Result<Vec<String>> {
310 let mut providers: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
311 let keep = |k: &str| k != "version" && !k.starts_with('_');
312
313 if let Ok(store) = oxi_sdk::load_auth_store() {
315 for k in store.tokens.keys() {
316 if keep(k) {
317 providers.insert(k.clone());
318 }
319 }
320 }
321 if let Ok(path) = shared_auth_json_path()
323 && let Ok(raw) = std::fs::read_to_string(&path)
324 && let Ok(map) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw)
325 {
326 for k in map.keys() {
327 if keep(k) {
328 providers.insert(k.clone());
329 }
330 }
331 }
332 Ok(providers.into_iter().collect())
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn test_provider_from_model() {
341 assert_eq!(
342 CredentialStore::provider_from_model("anthropic/claude-sonnet-4-20250514"),
343 Some("anthropic")
344 );
345 assert_eq!(
346 CredentialStore::provider_from_model("openai/gpt-4o"),
347 Some("openai")
348 );
349 assert_eq!(CredentialStore::provider_from_model("bare-model"), None);
350 assert_eq!(CredentialStore::provider_from_model(""), None);
351 }
352
353 #[test]
354 fn test_config_key_takes_priority() {
355 let result = CredentialStore::resolve("anthropic", Some("sk-test-config-key"));
357 assert!(result.is_some());
358 let (key, source) = result.unwrap();
359 assert_eq!(key, "sk-test-config-key");
360 assert!(matches!(source, CredentialSource::Config));
361 }
362
363 #[test]
364 fn test_empty_config_key_skipped() {
365 let _ = CredentialStore::resolve("anthropic", Some(""));
368 }
369
370 #[test]
371 fn test_none_config_key_skipped() {
372 let _ = CredentialStore::resolve("anthropic", None); }
374
375 #[test]
376 fn extract_credential_roundtrips_token_bundle() {
377 let bundle = oxi_sdk::TokenBundle {
379 access_token: "sk-bundle".into(),
380 refresh_token: None,
381 token_type: "Bearer".into(),
382 obtained_at: chrono::Utc::now(),
383 expires_in: 0,
384 scope: None,
385 };
386 let mut map = serde_json::Map::new();
387 map.insert("openai".into(), serde_json::to_value(&bundle).unwrap());
388 let raw = serde_json::to_string(&map).unwrap();
389 assert_eq!(
390 extract_credential("openai", &raw).as_deref(),
391 Some("sk-bundle")
392 );
393 }
394
395 #[test]
396 fn extract_credential_reads_legacy_shape() {
397 let raw = r#"{"anthropic":{"type":"api_key","key":"sk-legacy"}}"#;
398 assert_eq!(
399 extract_credential("anthropic", raw).as_deref(),
400 Some("sk-legacy")
401 );
402 }
403
404 #[test]
405 fn extract_credential_absent_provider_is_none() {
406 let raw = r#"{"openai":{"type":"api_key","key":"sk-x"}}"#;
407 assert!(extract_credential("anthropic", raw).is_none());
408 }
409
410 #[test]
411 fn extract_credential_empty_token_is_none() {
412 let raw = r#"{"openai":{"type":"api_key","key":""}}"#;
413 assert!(extract_credential("openai", raw).is_none());
414 }
415}