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    /// Extract the provider name from a model ID.
109    /// "anthropic/claude-sonnet-4-20250514" → "anthropic"
110    /// Returns `None` if the model ID is empty or has no provider prefix.
111    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// ── Legacy auth.json migration ─────────────────────────────────────────────
120
121/// Legacy entry from `oxi-cli`: `{"type":"api_key","key":"..."}`.
122#[derive(serde::Deserialize)]
123struct LegacyEntry {
124    #[allow(dead_code)]
125    r#type: String,
126    key: String,
127}
128
129/// Try to load a legacy `oxi-cli` API key from auth.json.
130///
131/// Returns `Some(key)` if the provider entry exists in the legacy
132/// `{"type":"api_key","key":"..."}` format.
133fn try_load_legacy_key(provider: &str) -> Option<String> {
134    // Returns `None` for the benign cases (no auth.json, provider absent).
135    // Read/parse failures are logged at warn — a corrupt auth.json may signal
136    // tampering and should not be silently indistinguishable from "absent".
137    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
185/// Check if an error is caused by a legacy-format auth.json.
186fn is_legacy_auth_error(err: &oxi_sdk::OAuthError) -> bool {
187    matches!(err, oxi_sdk::OAuthError::Json(_))
188}
189
190/// Migrate a legacy auth.json to `TokenBundle` format, preserving entries that
191/// can be converted and writing the new token for `provider`.
192fn 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    // Parse as a flat JSON map.
197    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; // will be replaced with new_token below
205        }
206
207        // Try parsing as TokenBundle first.
208        if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(value.clone()) {
209            migrated.insert(key.clone(), bundle);
210            continue;
211        }
212
213        // Try parsing as legacy `{"type":"api_key","key":"..."}`.
214        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    // Insert the new token.
233    migrated.insert(provider.to_string(), new_token.clone());
234
235    // Write back as proper AuthStore.
236    let store = oxi_sdk::AuthStore { tokens: migrated };
237    oxi_sdk::save_auth_store(&store)?;
238    Ok(())
239}
240
241/// Resolve `~/.oxi/auth.json` path without depending on oxi_sdk's error type.
242fn 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
249/// Discover all provider names stored in `~/.oxi/auth.json`.
250///
251/// Returns a list of provider IDs (top-level keys in the JSON file).
252/// Special keys like `"version"` are filtered out. Used by `OxiosEngine::from_config`
253/// to ensure credentials from the auth store are always injected, even for
254/// providers not in the hardcoded known list.
255pub 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        // If config_key is set, it's always returned (even if other sources exist)
290        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        // Empty string is treated as None — falls through to next source
301        // (result depends on whether auth.json or env vars exist)
302        // Just verify it doesn't panic
303        let _ = result;
304    }
305
306    #[test]
307    fn test_none_config_key_skipped() {
308        let result = CredentialStore::resolve("anthropic", None);
309        let _ = result; // depends on system state
310    }
311}