Skip to main content

oxios_kernel/
credential.rs

1//! Multi-source credential resolution.
2//!
3//! Reads API keys from multiple sources with clear priority:
4//! 1. `config.toml` → `[engine].api_key` (explicit override)
5//! 2. `~/.oxi/auth.json` (shared with oxi CLI if installed)
6//! 3. oxi-ai env var fallback (CI/CD, containers)
7//!
8//! Handles legacy `oxi-cli` auth.json entries (`{"type":"api_key","key":"..."}`)
9//! by auto-migrating them to the `TokenBundle` format on first write.
10
11use anyhow::Result;
12use std::collections::HashMap;
13use std::path::PathBuf;
14
15/// Where a credential was found.
16#[derive(Debug, Clone)]
17pub enum CredentialSource {
18    /// From config.toml [engine].api_key
19    Config,
20    /// From ~/.oxi/auth.json (oxi CLI credential store)
21    OxiAuthStore,
22    /// From environment variable
23    EnvVar,
24}
25
26/// Multi-source credential resolver.
27pub struct CredentialStore;
28
29impl CredentialStore {
30    /// Resolve the best available API key for a provider.
31    ///
32    /// Priority: OXIOS_<PROVIDER>_API_KEY env → config.toml → oxi auth.json → oxi-ai env fallback
33    /// Environment variables take highest priority for container/K8s deployments.
34    pub fn resolve(provider: &str, config_key: Option<&str>) -> Option<(String, CredentialSource)> {
35        // 1. Explicit Oxios env var: OXIOS_<PROVIDER>_API_KEY (highest priority for containers)
36        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        // 2. config.toml explicit key
44        if let Some(key) = config_key
45            && !key.is_empty()
46        {
47            return Some((key.to_string(), CredentialSource::Config));
48        }
49
50        // 3. oxi auth store (~/.oxi/auth.json)
51        //    Try standard TokenBundle format first, then fall back to legacy
52        //    oxi-cli format (`{"type":"api_key","key":"..."}`).
53        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        // 4. oxi-ai env var fallback
62        if let Some(key) = oxi_sdk::get_env_api_key(provider) {
63            return Some((key, CredentialSource::EnvVar));
64        }
65
66        None
67    }
68
69    /// Check if any credential is available for a provider.
70    pub fn has_credential(provider: &str, config_key: Option<&str>) -> bool {
71        Self::resolve(provider, config_key).is_some()
72    }
73
74    /// Store an API key to oxi's auth store (~/.oxi/auth.json).
75    ///
76    /// This is called by the onboarding wizard. If oxi CLI is also
77    /// installed on this machine, it will pick up the same credential.
78    ///
79    /// If the auth store contains legacy entries from `oxi-cli` that don't
80    /// deserialize as `TokenBundle`, they are auto-migrated before saving.
81    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        // Try the normal path first.
92        if let Err(e) = oxi_sdk::save_token(provider, &token) {
93            // If the auth store has legacy entries (e.g. `oxi-cli` wrote
94            // `{"type":"api_key","key":"..."}`), `save_token` fails because
95            // it can't deserialize them as `TokenBundle`.  Migrate and retry.
96            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    /// Delete a credential from `~/.oxi/auth.json`.
109    ///
110    /// Removes the top-level key entry. No-op if the key or file doesn't exist.
111    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    /// Resolve a non-provider secret (telegram token, email password, etc.).
126    ///
127    /// Unlike [`resolve`](Self::resolve) — which checks `OXIOS_<PROVIDER>_API_KEY`
128    /// and config.toml — this checks an explicit env var name first, then the
129    /// auth store. Used by the `/api/secrets` endpoints for keys that are not
130    /// LLM provider credentials.
131    pub fn resolve_secret(key: &str, env_var: &str) -> Option<(String, CredentialSource)> {
132        // 1. Environment variable
133        if let Ok(val) = std::env::var(env_var)
134            && !val.is_empty()
135        {
136            return Some((val, CredentialSource::EnvVar));
137        }
138        // 2. Auth store (~/.oxi/auth.json)
139        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    /// Extract the provider name from a model ID.
150    /// "anthropic/claude-sonnet-4-20250514" → "anthropic"
151    /// Returns `None` if the model ID is empty or has no provider prefix.
152    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// ── Legacy auth.json migration ─────────────────────────────────────────────
161
162/// Legacy entry from `oxi-cli`: `{"type":"api_key","key":"..."}`.
163#[derive(serde::Deserialize)]
164struct LegacyEntry {
165    #[allow(dead_code)]
166    r#type: String,
167    key: String,
168}
169
170/// Try to load a legacy `oxi-cli` API key from auth.json.
171///
172/// Returns `Some(key)` if the provider entry exists in the legacy
173/// `{"type":"api_key","key":"..."}` format.
174fn try_load_legacy_key(provider: &str) -> Option<String> {
175    // Returns `None` for the benign cases (no auth.json, provider absent).
176    // Read/parse failures are logged at warn — a corrupt auth.json may signal
177    // tampering and should not be silently indistinguishable from "absent".
178    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
226/// Check if an error is caused by a legacy-format auth.json.
227fn is_legacy_auth_error(err: &oxi_sdk::OAuthError) -> bool {
228    matches!(err, oxi_sdk::OAuthError::Json(_))
229}
230
231/// Migrate a legacy auth.json to `TokenBundle` format, preserving entries that
232/// can be converted and writing the new token for `provider`.
233fn 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    // Parse as a flat JSON map.
238    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; // will be replaced with new_token below
246        }
247
248        // Try parsing as TokenBundle first.
249        if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(value.clone()) {
250            migrated.insert(key.clone(), bundle);
251            continue;
252        }
253
254        // Try parsing as legacy `{"type":"api_key","key":"..."}`.
255        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    // Insert the new token.
274    migrated.insert(provider.to_string(), new_token.clone());
275
276    // Write back as proper AuthStore.
277    let store = oxi_sdk::AuthStore { tokens: migrated };
278    oxi_sdk::save_auth_store(&store)?;
279    Ok(())
280}
281
282/// Resolve `~/.oxi/auth.json` path without depending on oxi_sdk's error type.
283fn 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
290/// Discover all provider names stored in `~/.oxi/auth.json`.
291///
292/// Returns a list of provider IDs (top-level keys in the JSON file).
293/// Special keys like `"version"` are filtered out. Used by `OxiosEngine::from_config`
294/// to ensure credentials from the auth store are always injected, even for
295/// providers not in the hardcoded known list.
296pub 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        // If config_key is set, it's always returned (even if other sources exist)
331        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        // Empty string is treated as None — falls through to next source
342        // (result depends on whether auth.json or env vars exist)
343        // Just verify it doesn't panic
344        let _ = result;
345    }
346
347    #[test]
348    fn test_none_config_key_skipped() {
349        let result = CredentialStore::resolve("anthropic", None);
350        let _ = result; // depends on system state
351    }
352}