oxios_kernel/
credential.rs1use anyhow::Result;
12use std::collections::HashMap;
13use std::path::PathBuf;
14
15#[derive(Debug, Clone)]
17pub enum CredentialSource {
18 Config,
20 OxiAuthStore,
22 EnvVar,
24}
25
26pub struct CredentialStore;
28
29impl CredentialStore {
30 pub fn resolve(provider: &str, config_key: Option<&str>) -> Option<(String, CredentialSource)> {
35 let env_var = format!("OXIOS_{}_API_KEY", provider.to_uppercase());
37 if let Ok(key) = std::env::var(&env_var)
38 && !key.is_empty()
39 {
40 return Some((key, CredentialSource::EnvVar));
41 }
42
43 if let Some(key) = config_key
45 && !key.is_empty()
46 {
47 return Some((key.to_string(), CredentialSource::Config));
48 }
49
50 if let Ok(Some(token)) = oxi_sdk::load_token(provider) {
54 if !token.access_token.is_empty() {
55 return Some((token.access_token, CredentialSource::OxiAuthStore));
56 }
57 } else if let Some(key) = try_load_legacy_key(provider) {
58 return Some((key, CredentialSource::OxiAuthStore));
59 }
60
61 if let Some(key) = oxi_sdk::get_env_api_key(provider) {
63 return Some((key, CredentialSource::EnvVar));
64 }
65
66 None
67 }
68
69 pub fn has_credential(provider: &str, config_key: Option<&str>) -> bool {
71 Self::resolve(provider, config_key).is_some()
72 }
73
74 pub fn store(provider: &str, api_key: &str) -> Result<()> {
82 let token = oxi_sdk::TokenBundle {
83 access_token: api_key.to_string(),
84 refresh_token: None,
85 token_type: "Bearer".to_string(),
86 obtained_at: chrono::Utc::now(),
87 expires_in: 0,
88 scope: None,
89 };
90
91 if let Err(e) = oxi_sdk::save_token(provider, &token) {
93 if is_legacy_auth_error(&e) {
97 tracing::info!("auth.json has legacy format, migrating to TokenBundle");
98 migrate_legacy_auth_store(provider, &token)?;
99 } else {
100 return Err(e.into());
101 }
102 }
103
104 tracing::info!(provider = %provider, "API key stored to oxi auth store");
105 Ok(())
106 }
107
108 pub fn delete(key: &str) -> Result<()> {
112 let path = auth_json_path()?;
113 if !path.exists() {
114 return Ok(());
115 }
116 let raw = std::fs::read_to_string(&path)?;
117 let mut map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&raw)?;
118 if map.remove(key).is_some() {
119 std::fs::write(&path, serde_json::to_string_pretty(&map)?)?;
120 tracing::info!(key = %key, "Credential deleted from oxi auth store");
121 }
122 Ok(())
123 }
124
125 pub fn resolve_secret(key: &str, env_var: &str) -> Option<(String, CredentialSource)> {
132 if let Ok(val) = std::env::var(env_var)
134 && !val.is_empty()
135 {
136 return Some((val, CredentialSource::EnvVar));
137 }
138 if let Ok(Some(token)) = oxi_sdk::load_token(key) {
140 if !token.access_token.is_empty() {
141 return Some((token.access_token, CredentialSource::OxiAuthStore));
142 }
143 } else if let Some(val) = try_load_legacy_key(key) {
144 return Some((val, CredentialSource::OxiAuthStore));
145 }
146 None
147 }
148
149 pub fn provider_from_model(model_id: &str) -> Option<&str> {
153 if model_id.is_empty() {
154 return None;
155 }
156 model_id.split_once('/').map(|(p, _)| p)
157 }
158}
159
160#[derive(serde::Deserialize)]
164struct LegacyEntry {
165 #[allow(dead_code)]
166 r#type: String,
167 key: String,
168}
169
170fn try_load_legacy_key(provider: &str) -> Option<String> {
175 let path = match auth_json_path() {
179 Ok(p) => p,
180 Err(_) => return None,
181 };
182 let raw = match std::fs::read_to_string(&path) {
183 Ok(s) => s,
184 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
185 Err(e) => {
186 tracing::warn!(
187 provider = %provider,
188 path = %path.display(),
189 error = %e,
190 "auth.json exists but could not be read; skipping legacy key",
191 );
192 return None;
193 }
194 };
195 let map: serde_json::Map<String, serde_json::Value> = match serde_json::from_str(&raw) {
196 Ok(m) => m,
197 Err(e) => {
198 tracing::warn!(
199 provider = %provider,
200 path = %path.display(),
201 error = %e,
202 "auth.json is not valid JSON; possible corruption or tampering",
203 );
204 return None;
205 }
206 };
207 let entry = map.get(provider)?;
208 let legacy: LegacyEntry = match serde_json::from_value(entry.clone()) {
209 Ok(l) => l,
210 Err(e) => {
211 tracing::warn!(
212 provider = %provider,
213 error = %e,
214 "auth.json entry for provider is not the legacy format; skipping",
215 );
216 return None;
217 }
218 };
219 if legacy.key.is_empty() {
220 None
221 } else {
222 Some(legacy.key)
223 }
224}
225
226fn is_legacy_auth_error(err: &oxi_sdk::OAuthError) -> bool {
228 matches!(err, oxi_sdk::OAuthError::Json(_))
229}
230
231fn migrate_legacy_auth_store(provider: &str, new_token: &oxi_sdk::TokenBundle) -> Result<()> {
234 let path = auth_json_path()?;
235 let raw = std::fs::read_to_string(&path)?;
236
237 let entries: serde_json::Map<String, serde_json::Value> =
239 serde_json::from_str(&raw).unwrap_or_default();
240
241 let mut migrated = HashMap::new();
242
243 for (key, value) in &entries {
244 if key == provider {
245 continue; }
247
248 if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(value.clone()) {
250 migrated.insert(key.clone(), bundle);
251 continue;
252 }
253
254 if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(value.clone()) {
256 migrated.insert(
257 key.clone(),
258 oxi_sdk::TokenBundle {
259 access_token: legacy.key,
260 refresh_token: None,
261 token_type: "Bearer".to_string(),
262 obtained_at: chrono::Utc::now(),
263 expires_in: 0,
264 scope: None,
265 },
266 );
267 continue;
268 }
269
270 tracing::warn!(provider = %key, "skipping unparseable auth.json entry during migration");
271 }
272
273 migrated.insert(provider.to_string(), new_token.clone());
275
276 let store = oxi_sdk::AuthStore { tokens: migrated };
278 oxi_sdk::save_auth_store(&store)?;
279 Ok(())
280}
281
282fn auth_json_path() -> Result<PathBuf> {
284 let home = std::env::var("HOME")
285 .or_else(|_| std::env::var("USERPROFILE"))
286 .map_err(|_| anyhow::anyhow!("Cannot determine home directory"))?;
287 Ok(PathBuf::from(home).join(".oxi").join("auth.json"))
288}
289
290pub fn discover_auth_store_providers() -> Result<Vec<String>> {
297 let path = auth_json_path()?;
298 if !path.exists() {
299 return Ok(vec![]);
300 }
301 let raw = std::fs::read_to_string(&path)?;
302 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&raw)?;
303 Ok(map
304 .keys()
305 .filter(|k| *k != "version" && !k.starts_with('_'))
306 .cloned()
307 .collect())
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn test_provider_from_model() {
316 assert_eq!(
317 CredentialStore::provider_from_model("anthropic/claude-sonnet-4-20250514"),
318 Some("anthropic")
319 );
320 assert_eq!(
321 CredentialStore::provider_from_model("openai/gpt-4o"),
322 Some("openai")
323 );
324 assert_eq!(CredentialStore::provider_from_model("bare-model"), None);
325 assert_eq!(CredentialStore::provider_from_model(""), None);
326 }
327
328 #[test]
329 fn test_config_key_takes_priority() {
330 let result = CredentialStore::resolve("anthropic", Some("sk-test-config-key"));
332 assert!(result.is_some());
333 let (key, source) = result.unwrap();
334 assert_eq!(key, "sk-test-config-key");
335 assert!(matches!(source, CredentialSource::Config));
336 }
337
338 #[test]
339 fn test_empty_config_key_skipped() {
340 let result = CredentialStore::resolve("anthropic", Some(""));
341 let _ = result;
345 }
346
347 #[test]
348 fn test_none_config_key_skipped() {
349 let result = CredentialStore::resolve("anthropic", None);
350 let _ = result; }
352}