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 {
80 let alt = format!("{provider}-coding-plan");
81 if let Ok(Some(token)) = oxi_sdk::load_token(&alt)
82 && !token.access_token.is_empty()
83 {
84 return Some((token.access_token, CredentialSource::OxiAuthStore));
85 }
86 if let Some(key) = load_from_shared_store(&alt) {
87 return Some((key, CredentialSource::OxiAuthStore));
88 }
89 }
90 None
91 }
92
93 pub fn has_credential(provider: &str, config_key: Option<&str>) -> bool {
95 Self::resolve(provider, config_key).is_some()
96 }
97
98 pub fn store(provider: &str, api_key: &str) -> Result<()> {
105 let token = oxi_sdk::TokenBundle {
106 access_token: api_key.to_string(),
107 refresh_token: None,
108 token_type: "Bearer".to_string(),
109 obtained_at: chrono::Utc::now(),
110 expires_in: 0,
111 scope: None,
112 };
113
114 if let Err(e) = oxi_sdk::save_token(provider, &token) {
116 if is_legacy_auth_error(&e) {
120 tracing::info!("auth.json has legacy format, migrating to TokenBundle");
121 migrate_legacy_auth_store(provider, &token)?;
122 } else {
123 return Err(e.into());
124 }
125 }
126
127 tracing::info!(provider = %provider, "API key stored to oxios auth store");
128 Ok(())
129 }
130
131 pub fn delete(key: &str) -> Result<()> {
137 let _ = oxi_sdk::remove_token(key);
139
140 if let Ok(path) = shared_auth_json_path()
142 && path.exists()
143 && let Ok(raw) = std::fs::read_to_string(&path)
144 && let Ok(mut map) =
145 serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw)
146 && map.remove(key).is_some()
147 {
148 std::fs::write(&path, serde_json::to_string_pretty(&map)?)?;
149 tracing::info!(key = %key, "Credential deleted from shared oxi-cli auth store");
150 }
151 Ok(())
152 }
153
154 pub fn resolve_secret(key: &str, env_var: &str) -> Option<(String, CredentialSource)> {
161 if let Ok(val) = std::env::var(env_var)
163 && !val.is_empty()
164 {
165 return Some((val, CredentialSource::EnvVar));
166 }
167 if let Ok(Some(token)) = oxi_sdk::load_token(key)
169 && !token.access_token.is_empty()
170 {
171 return Some((token.access_token, CredentialSource::OxiAuthStore));
172 }
173 if let Some(val) = load_from_shared_store(key) {
175 return Some((val, CredentialSource::OxiAuthStore));
176 }
177 None
178 }
179
180 pub fn provider_from_model(model_id: &str) -> Option<&str> {
184 if model_id.is_empty() {
185 return None;
186 }
187 model_id.split_once('/').map(|(p, _)| p)
188 }
189}
190
191#[derive(serde::Deserialize)]
195struct LegacyEntry {
196 #[allow(dead_code)]
197 r#type: String,
198 key: String,
199}
200
201fn extract_credential(provider: &str, raw: &str) -> Option<String> {
206 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(raw).ok()?;
207 let entry = map.get(provider)?;
208 if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(entry.clone())
210 && !bundle.access_token.is_empty()
211 {
212 return Some(bundle.access_token);
213 }
214 if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(entry.clone())
216 && !legacy.key.is_empty()
217 {
218 return Some(legacy.key);
219 }
220 None
221}
222
223fn load_from_shared_store(provider: &str) -> Option<String> {
228 let path = match shared_auth_json_path() {
229 Ok(p) => p,
230 Err(_) => return None,
231 };
232 let raw = match std::fs::read_to_string(&path) {
233 Ok(s) => s,
234 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
235 Err(e) => {
236 tracing::warn!(
237 provider = %provider,
238 path = %path.display(),
239 error = %e,
240 "shared auth.json exists but could not be read; skipping",
241 );
242 return None;
243 }
244 };
245 extract_credential(provider, &raw)
246}
247
248fn is_legacy_auth_error(err: &oxi_sdk::OAuthError) -> bool {
250 matches!(err, oxi_sdk::OAuthError::Json(_))
251}
252
253fn migrate_legacy_auth_store(provider: &str, new_token: &oxi_sdk::TokenBundle) -> Result<()> {
256 let path = shared_auth_json_path()?;
257 let raw = std::fs::read_to_string(&path)?;
258
259 let entries: serde_json::Map<String, serde_json::Value> =
261 serde_json::from_str(&raw).unwrap_or_default();
262
263 let mut migrated = HashMap::new();
264
265 for (key, value) in &entries {
266 if key == provider {
267 continue; }
269
270 if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(value.clone()) {
272 migrated.insert(key.clone(), bundle);
273 continue;
274 }
275
276 if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(value.clone()) {
278 migrated.insert(
279 key.clone(),
280 oxi_sdk::TokenBundle {
281 access_token: legacy.key,
282 refresh_token: None,
283 token_type: "Bearer".to_string(),
284 obtained_at: chrono::Utc::now(),
285 expires_in: 0,
286 scope: None,
287 },
288 );
289 continue;
290 }
291
292 tracing::warn!(provider = %key, "skipping unparseable auth.json entry during migration");
293 }
294
295 migrated.insert(provider.to_string(), new_token.clone());
297
298 let store = oxi_sdk::AuthStore { tokens: migrated };
300 oxi_sdk::save_auth_store(&store)?;
301 Ok(())
302}
303
304fn shared_auth_json_path() -> Result<PathBuf> {
311 let home = std::env::var("HOME")
312 .or_else(|_| std::env::var("USERPROFILE"))
313 .map_err(|_| anyhow::anyhow!("Cannot determine home directory"))?;
314 Ok(PathBuf::from(home).join(".oxi").join("auth.json"))
315}
316
317pub fn discover_auth_store_providers() -> Result<Vec<String>> {
324 let mut providers: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
325 let keep = |k: &str| k != "version" && !k.starts_with('_');
326
327 if let Ok(store) = oxi_sdk::load_auth_store() {
329 for k in store.tokens.keys() {
330 if keep(k) {
331 providers.insert(k.clone());
332 }
333 }
334 }
335 if let Ok(path) = shared_auth_json_path()
337 && let Ok(raw) = std::fs::read_to_string(&path)
338 && let Ok(map) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw)
339 {
340 for k in map.keys() {
341 if keep(k) {
342 providers.insert(k.clone());
343 }
344 }
345 }
346 Ok(providers.into_iter().collect())
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn test_provider_from_model() {
355 assert_eq!(
356 CredentialStore::provider_from_model("anthropic/claude-sonnet-4-20250514"),
357 Some("anthropic")
358 );
359 assert_eq!(
360 CredentialStore::provider_from_model("openai/gpt-4o"),
361 Some("openai")
362 );
363 assert_eq!(CredentialStore::provider_from_model("bare-model"), None);
364 assert_eq!(CredentialStore::provider_from_model(""), None);
365 }
366
367 #[test]
368 fn test_config_key_takes_priority() {
369 let result = CredentialStore::resolve("anthropic", Some("sk-test-config-key"));
371 assert!(result.is_some());
372 let (key, source) = result.unwrap();
373 assert_eq!(key, "sk-test-config-key");
374 assert!(matches!(source, CredentialSource::Config));
375 }
376
377 #[test]
378 fn test_empty_config_key_skipped() {
379 let _ = CredentialStore::resolve("anthropic", Some(""));
382 }
383
384 #[test]
385 fn test_none_config_key_skipped() {
386 let _ = CredentialStore::resolve("anthropic", None); }
388
389 #[test]
390 fn extract_credential_roundtrips_token_bundle() {
391 let bundle = oxi_sdk::TokenBundle {
393 access_token: "sk-bundle".into(),
394 refresh_token: None,
395 token_type: "Bearer".into(),
396 obtained_at: chrono::Utc::now(),
397 expires_in: 0,
398 scope: None,
399 };
400 let mut map = serde_json::Map::new();
401 map.insert("openai".into(), serde_json::to_value(&bundle).unwrap());
402 let raw = serde_json::to_string(&map).unwrap();
403 assert_eq!(
404 extract_credential("openai", &raw).as_deref(),
405 Some("sk-bundle")
406 );
407 }
408
409 #[test]
410 fn extract_credential_reads_legacy_shape() {
411 let raw = r#"{"anthropic":{"type":"api_key","key":"sk-legacy"}}"#;
412 assert_eq!(
413 extract_credential("anthropic", raw).as_deref(),
414 Some("sk-legacy")
415 );
416 }
417
418 #[test]
419 fn extract_credential_absent_provider_is_none() {
420 let raw = r#"{"openai":{"type":"api_key","key":"sk-x"}}"#;
421 assert!(extract_credential("anthropic", raw).is_none());
422 }
423
424 #[test]
425 fn extract_credential_empty_token_is_none() {
426 let raw = r#"{"openai":{"type":"api_key","key":""}}"#;
427 assert!(extract_credential("openai", raw).is_none());
428 }
429}