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 Self::store_token(provider, token)
114 }
115
116 pub fn store_token(provider: &str, token: oxi_sdk::TokenBundle) -> Result<()> {
120 if let Err(e) = oxi_sdk::save_token(provider, &token) {
122 if is_legacy_auth_error(&e) {
126 tracing::info!("auth.json has legacy format, migrating to TokenBundle");
127 migrate_legacy_auth_store(provider, &token)?;
128 } else {
129 return Err(e.into());
130 }
131 }
132
133 tracing::info!(provider = %provider, "credential stored to oxios auth store");
134 Ok(())
135 }
136
137 pub fn delete(key: &str) -> Result<()> {
143 let _ = oxi_sdk::remove_token(key);
145
146 if let Ok(path) = shared_auth_json_path()
148 && path.exists()
149 && let Ok(raw) = std::fs::read_to_string(&path)
150 && let Ok(mut map) =
151 serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw)
152 && map.remove(key).is_some()
153 {
154 std::fs::write(&path, serde_json::to_string_pretty(&map)?)?;
155 tracing::info!(key = %key, "Credential deleted from shared oxi-cli auth store");
156 }
157 Ok(())
158 }
159
160 pub fn resolve_secret(key: &str, env_var: &str) -> Option<(String, CredentialSource)> {
167 if let Ok(val) = std::env::var(env_var)
169 && !val.is_empty()
170 {
171 return Some((val, CredentialSource::EnvVar));
172 }
173 if let Ok(Some(token)) = oxi_sdk::load_token(key)
175 && !token.access_token.is_empty()
176 {
177 return Some((token.access_token, CredentialSource::OxiAuthStore));
178 }
179 if let Some(val) = load_from_shared_store(key) {
181 return Some((val, CredentialSource::OxiAuthStore));
182 }
183 None
184 }
185
186 pub fn provider_from_model(model_id: &str) -> Option<&str> {
190 if model_id.is_empty() {
191 return None;
192 }
193 model_id.split_once('/').map(|(p, _)| p)
194 }
195}
196
197#[derive(serde::Deserialize)]
201struct LegacyEntry {
202 #[allow(dead_code)]
203 r#type: String,
204 key: String,
205}
206
207fn extract_credential(provider: &str, raw: &str) -> Option<String> {
212 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(raw).ok()?;
213 let entry = map.get(provider)?;
214 if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(entry.clone())
216 && !bundle.access_token.is_empty()
217 {
218 return Some(bundle.access_token);
219 }
220 if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(entry.clone())
222 && !legacy.key.is_empty()
223 {
224 return Some(legacy.key);
225 }
226 None
227}
228
229fn load_from_shared_store(provider: &str) -> Option<String> {
234 let path = match shared_auth_json_path() {
235 Ok(p) => p,
236 Err(_) => return None,
237 };
238 let raw = match std::fs::read_to_string(&path) {
239 Ok(s) => s,
240 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
241 Err(e) => {
242 tracing::warn!(
243 provider = %provider,
244 path = %path.display(),
245 error = %e,
246 "shared auth.json exists but could not be read; skipping",
247 );
248 return None;
249 }
250 };
251 extract_credential(provider, &raw)
252}
253
254fn is_legacy_auth_error(err: &oxi_sdk::OAuthError) -> bool {
256 matches!(err, oxi_sdk::OAuthError::Json(_))
257}
258
259fn migrate_legacy_auth_store(provider: &str, new_token: &oxi_sdk::TokenBundle) -> Result<()> {
262 let path = shared_auth_json_path()?;
263 let raw = std::fs::read_to_string(&path)?;
264
265 let entries: serde_json::Map<String, serde_json::Value> =
267 serde_json::from_str(&raw).unwrap_or_default();
268
269 let mut migrated = HashMap::new();
270
271 for (key, value) in &entries {
272 if key == provider {
273 continue; }
275
276 if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(value.clone()) {
278 migrated.insert(key.clone(), bundle);
279 continue;
280 }
281
282 if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(value.clone()) {
284 migrated.insert(
285 key.clone(),
286 oxi_sdk::TokenBundle {
287 access_token: legacy.key,
288 refresh_token: None,
289 token_type: "Bearer".to_string(),
290 obtained_at: chrono::Utc::now(),
291 expires_in: 0,
292 scope: None,
293 },
294 );
295 continue;
296 }
297
298 tracing::warn!(provider = %key, "skipping unparseable auth.json entry during migration");
299 }
300
301 migrated.insert(provider.to_string(), new_token.clone());
303
304 let store = oxi_sdk::AuthStore { tokens: migrated };
306 oxi_sdk::save_auth_store(&store)?;
307 Ok(())
308}
309
310fn shared_auth_json_path() -> Result<PathBuf> {
317 let home = std::env::var("HOME")
318 .or_else(|_| std::env::var("USERPROFILE"))
319 .map_err(|_| anyhow::anyhow!("Cannot determine home directory"))?;
320 Ok(PathBuf::from(home).join(".oxi").join("auth.json"))
321}
322
323pub fn discover_auth_store_providers() -> Result<Vec<String>> {
330 let mut providers: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
331 let keep = |k: &str| k != "version" && !k.starts_with('_');
332
333 if let Ok(store) = oxi_sdk::load_auth_store() {
335 for k in store.tokens.keys() {
336 if keep(k) {
337 providers.insert(k.clone());
338 }
339 }
340 }
341 if let Ok(path) = shared_auth_json_path()
343 && let Ok(raw) = std::fs::read_to_string(&path)
344 && let Ok(map) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw)
345 {
346 for k in map.keys() {
347 if keep(k) {
348 providers.insert(k.clone());
349 }
350 }
351 }
352 Ok(providers.into_iter().collect())
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn test_provider_from_model() {
361 assert_eq!(
362 CredentialStore::provider_from_model("anthropic/claude-sonnet-4-20250514"),
363 Some("anthropic")
364 );
365 assert_eq!(
366 CredentialStore::provider_from_model("openai/gpt-4o"),
367 Some("openai")
368 );
369 assert_eq!(CredentialStore::provider_from_model("bare-model"), None);
370 assert_eq!(CredentialStore::provider_from_model(""), None);
371 }
372
373 #[test]
374 fn test_config_key_takes_priority() {
375 let result = CredentialStore::resolve("anthropic", Some("sk-test-config-key"));
377 assert!(result.is_some());
378 let (key, source) = result.unwrap();
379 assert_eq!(key, "sk-test-config-key");
380 assert!(matches!(source, CredentialSource::Config));
381 }
382
383 #[test]
384 fn test_empty_config_key_skipped() {
385 let _ = CredentialStore::resolve("anthropic", Some(""));
388 }
389
390 #[test]
391 fn test_none_config_key_skipped() {
392 let _ = CredentialStore::resolve("anthropic", None); }
394
395 #[test]
396 fn extract_credential_roundtrips_token_bundle() {
397 let bundle = oxi_sdk::TokenBundle {
399 access_token: "sk-bundle".into(),
400 refresh_token: None,
401 token_type: "Bearer".into(),
402 obtained_at: chrono::Utc::now(),
403 expires_in: 0,
404 scope: None,
405 };
406 let mut map = serde_json::Map::new();
407 map.insert("openai".into(), serde_json::to_value(&bundle).unwrap());
408 let raw = serde_json::to_string(&map).unwrap();
409 assert_eq!(
410 extract_credential("openai", &raw).as_deref(),
411 Some("sk-bundle")
412 );
413 }
414
415 #[test]
416 fn extract_credential_reads_legacy_shape() {
417 let raw = r#"{"anthropic":{"type":"api_key","key":"sk-legacy"}}"#;
418 assert_eq!(
419 extract_credential("anthropic", raw).as_deref(),
420 Some("sk-legacy")
421 );
422 }
423
424 #[test]
425 fn extract_credential_absent_provider_is_none() {
426 let raw = r#"{"openai":{"type":"api_key","key":"sk-x"}}"#;
427 assert!(extract_credential("anthropic", raw).is_none());
428 }
429
430 #[test]
431 fn extract_credential_empty_token_is_none() {
432 let raw = r#"{"openai":{"type":"api_key","key":""}}"#;
433 assert!(extract_credential("openai", raw).is_none());
434 }
435}