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 provider_from_model(model_id: &str) -> Option<&str> {
112 if model_id.is_empty() {
113 return None;
114 }
115 model_id.split_once('/').map(|(p, _)| p)
116 }
117}
118
119#[derive(serde::Deserialize)]
123struct LegacyEntry {
124 #[allow(dead_code)]
125 r#type: String,
126 key: String,
127}
128
129fn try_load_legacy_key(provider: &str) -> Option<String> {
134 let path = match auth_json_path() {
138 Ok(p) => p,
139 Err(_) => return None,
140 };
141 let raw = match std::fs::read_to_string(&path) {
142 Ok(s) => s,
143 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
144 Err(e) => {
145 tracing::warn!(
146 provider = %provider,
147 path = %path.display(),
148 error = %e,
149 "auth.json exists but could not be read; skipping legacy key",
150 );
151 return None;
152 }
153 };
154 let map: serde_json::Map<String, serde_json::Value> = match serde_json::from_str(&raw) {
155 Ok(m) => m,
156 Err(e) => {
157 tracing::warn!(
158 provider = %provider,
159 path = %path.display(),
160 error = %e,
161 "auth.json is not valid JSON; possible corruption or tampering",
162 );
163 return None;
164 }
165 };
166 let entry = map.get(provider)?;
167 let legacy: LegacyEntry = match serde_json::from_value(entry.clone()) {
168 Ok(l) => l,
169 Err(e) => {
170 tracing::warn!(
171 provider = %provider,
172 error = %e,
173 "auth.json entry for provider is not the legacy format; skipping",
174 );
175 return None;
176 }
177 };
178 if legacy.key.is_empty() {
179 None
180 } else {
181 Some(legacy.key)
182 }
183}
184
185fn is_legacy_auth_error(err: &oxi_sdk::OAuthError) -> bool {
187 matches!(err, oxi_sdk::OAuthError::Json(_))
188}
189
190fn migrate_legacy_auth_store(provider: &str, new_token: &oxi_sdk::TokenBundle) -> Result<()> {
193 let path = auth_json_path()?;
194 let raw = std::fs::read_to_string(&path)?;
195
196 let entries: serde_json::Map<String, serde_json::Value> =
198 serde_json::from_str(&raw).unwrap_or_default();
199
200 let mut migrated = HashMap::new();
201
202 for (key, value) in &entries {
203 if key == provider {
204 continue; }
206
207 if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(value.clone()) {
209 migrated.insert(key.clone(), bundle);
210 continue;
211 }
212
213 if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(value.clone()) {
215 migrated.insert(
216 key.clone(),
217 oxi_sdk::TokenBundle {
218 access_token: legacy.key,
219 refresh_token: None,
220 token_type: "Bearer".to_string(),
221 obtained_at: chrono::Utc::now(),
222 expires_in: 0,
223 scope: None,
224 },
225 );
226 continue;
227 }
228
229 tracing::warn!(provider = %key, "skipping unparseable auth.json entry during migration");
230 }
231
232 migrated.insert(provider.to_string(), new_token.clone());
234
235 let store = oxi_sdk::AuthStore { tokens: migrated };
237 oxi_sdk::save_auth_store(&store)?;
238 Ok(())
239}
240
241fn auth_json_path() -> Result<PathBuf> {
243 let home = std::env::var("HOME")
244 .or_else(|_| std::env::var("USERPROFILE"))
245 .map_err(|_| anyhow::anyhow!("Cannot determine home directory"))?;
246 Ok(PathBuf::from(home).join(".oxi").join("auth.json"))
247}
248
249pub fn discover_auth_store_providers() -> Result<Vec<String>> {
256 let path = auth_json_path()?;
257 if !path.exists() {
258 return Ok(vec![]);
259 }
260 let raw = std::fs::read_to_string(&path)?;
261 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&raw)?;
262 Ok(map
263 .keys()
264 .filter(|k| *k != "version" && !k.starts_with('_'))
265 .cloned()
266 .collect())
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn test_provider_from_model() {
275 assert_eq!(
276 CredentialStore::provider_from_model("anthropic/claude-sonnet-4-20250514"),
277 Some("anthropic")
278 );
279 assert_eq!(
280 CredentialStore::provider_from_model("openai/gpt-4o"),
281 Some("openai")
282 );
283 assert_eq!(CredentialStore::provider_from_model("bare-model"), None);
284 assert_eq!(CredentialStore::provider_from_model(""), None);
285 }
286
287 #[test]
288 fn test_config_key_takes_priority() {
289 let result = CredentialStore::resolve("anthropic", Some("sk-test-config-key"));
291 assert!(result.is_some());
292 let (key, source) = result.unwrap();
293 assert_eq!(key, "sk-test-config-key");
294 assert!(matches!(source, CredentialSource::Config));
295 }
296
297 #[test]
298 fn test_empty_config_key_skipped() {
299 let result = CredentialStore::resolve("anthropic", Some(""));
300 let _ = result;
304 }
305
306 #[test]
307 fn test_none_config_key_skipped() {
308 let result = CredentialStore::resolve("anthropic", None);
309 let _ = result; }
311}