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. `OXIOS_<PROVIDER>_API_KEY` env var (containers/K8s)
5//! 2. `config.toml` → `[engine].api_key` (explicit override)
6//! 3. oxios auth store (`~/.oxios/auth.json`, via `OXI_HOME`) — primary
7//! 4. shared oxi-cli store (`~/.oxi/auth.json`) — backward-compat read
8//!    fallback, so keys registered via the standalone `oxi` CLI are still
9//!    detected
10//! 5. oxi-ai env var fallback (CI/CD, containers)
11//!
12//! Writes (`store`/onboarding/`set_api_key`) always target the oxios auth store
13//! (`~/.oxios/auth.json`), keeping oxios's credentials isolated in its own
14//! product home. The shared `~/.oxi/auth.json` is treated as read-only here.
15//!
16//! Both the modern `TokenBundle` and the legacy `oxi-cli`
17//! (`{"type":"api_key","key":"..."}`) shapes are honored in either store.
18
19use anyhow::Result;
20use std::collections::HashMap;
21use std::path::PathBuf;
22
23/// Where a credential was found.
24#[derive(Debug, Clone)]
25pub enum CredentialSource {
26    /// From config.toml [engine].api_key
27    Config,
28    /// From an auth store (~/.oxios or ~/.oxi)
29    OxiAuthStore,
30    /// From environment variable
31    EnvVar,
32}
33
34/// Multi-source credential resolver.
35pub struct CredentialStore;
36
37impl CredentialStore {
38    /// Resolve the best available API key for a provider.
39    ///
40    /// Priority: OXIOS_<PROVIDER>_API_KEY env → config.toml → oxios auth store
41    /// (~/.oxios) → shared oxi-cli store (~/.oxi) → oxi-ai env fallback.
42    /// Environment variables take highest priority for container/K8s deployments.
43    pub fn resolve(provider: &str, config_key: Option<&str>) -> Option<(String, CredentialSource)> {
44        // 1. Explicit Oxios env var: OXIOS_<PROVIDER>_API_KEY (highest priority for containers)
45        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        // 2. config.toml explicit key
53        if let Some(key) = config_key
54            && !key.is_empty()
55        {
56            return Some((key.to_string(), CredentialSource::Config));
57        }
58
59        // 3. oxios auth store (~/.oxios/auth.json via OXI_HOME) — primary
60        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        // 4. Shared oxi-cli store (~/.oxi/auth.json) — backward-compat read.
67        if let Some(key) = load_from_shared_store(provider) {
68            return Some((key, CredentialSource::OxiAuthStore));
69        }
70
71        // 5. oxi-ai env var fallback
72        if let Some(key) = oxi_sdk::get_env_api_key(provider) {
73            return Some((key, CredentialSource::EnvVar));
74        }
75
76        // 6. Suffix fallback: subscription credentials may be stored under
77        //    "<provider>-coding-plan" (e.g. "zai-coding-plan"). Only auth
78        //    stores are consulted — env vars use the canonical name.
79        {
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    /// Check if any credential is available for a provider.
94    pub fn has_credential(provider: &str, config_key: Option<&str>) -> bool {
95        Self::resolve(provider, config_key).is_some()
96    }
97
98    /// Store an API key to oxios's auth store (`~/.oxios/auth.json`).
99    ///
100    /// Writes are isolated to the oxios product home (`OXI_HOME`); the shared
101    /// `~/.oxi/auth.json` (oxi CLI) is never written from here. If the oxios
102    /// auth store contains legacy entries that don't deserialize as
103    /// `TokenBundle`, they are auto-migrated before saving.
104    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
114        // Try the normal path first.
115        if let Err(e) = oxi_sdk::save_token(provider, &token) {
116            // If the auth store has legacy entries (e.g. `oxi-cli` wrote
117            // `{"type":"api_key","key":"..."}`), `save_token` fails because
118            // it can't deserialize them as `TokenBundle`.  Migrate and retry.
119            if is_legacy_auth_error(&e) {
120                tracing::info!("auth.json has legacy format, migrating to TokenBundle");
121                migrate_legacy_auth_store(provider, &token)?;
122            } else {
123                return Err(e.into());
124            }
125        }
126
127        tracing::info!(provider = %provider, "API key stored to oxios auth store");
128        Ok(())
129    }
130
131    /// Delete a credential from both auth stores.
132    ///
133    /// Removes the entry from the oxios store (`~/.oxios/auth.json`) and, if
134    /// present, from the shared oxi-cli store (`~/.oxi/auth.json`). No-op if
135    /// neither contains the key.
136    pub fn delete(key: &str) -> Result<()> {
137        // Primary: oxios store (OXI_HOME). Best-effort.
138        let _ = oxi_sdk::remove_token(key);
139
140        // Shared: oxi-cli store (~/.oxi/auth.json).
141        if let Ok(path) = shared_auth_json_path()
142            && path.exists()
143            && let Ok(raw) = std::fs::read_to_string(&path)
144            && let Ok(mut map) =
145                serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw)
146            && map.remove(key).is_some()
147        {
148            std::fs::write(&path, serde_json::to_string_pretty(&map)?)?;
149            tracing::info!(key = %key, "Credential deleted from shared oxi-cli auth store");
150        }
151        Ok(())
152    }
153
154    /// Resolve a non-provider secret (telegram token, email password, etc.).
155    ///
156    /// Unlike [`resolve`](Self::resolve) — which checks `OXIOS_<PROVIDER>_API_KEY`
157    /// and config.toml — this checks an explicit env var name first, then the
158    /// auth stores. Used by the `/api/secrets` endpoints for keys that are not
159    /// LLM provider credentials.
160    pub fn resolve_secret(key: &str, env_var: &str) -> Option<(String, CredentialSource)> {
161        // 1. Environment variable
162        if let Ok(val) = std::env::var(env_var)
163            && !val.is_empty()
164        {
165            return Some((val, CredentialSource::EnvVar));
166        }
167        // 2. oxios auth store (~/.oxios via OXI_HOME) — primary
168        if let Ok(Some(token)) = oxi_sdk::load_token(key)
169            && !token.access_token.is_empty()
170        {
171            return Some((token.access_token, CredentialSource::OxiAuthStore));
172        }
173        // 3. Shared oxi-cli store (~/.oxi/auth.json) — backward-compat read.
174        if let Some(val) = load_from_shared_store(key) {
175            return Some((val, CredentialSource::OxiAuthStore));
176        }
177        None
178    }
179
180    /// Extract the provider name from a model ID.
181    /// "anthropic/claude-sonnet-4-20250514" → "anthropic"
182    /// Returns `None` if the model ID is empty or has no provider prefix.
183    pub fn provider_from_model(model_id: &str) -> Option<&str> {
184        if model_id.is_empty() {
185            return None;
186        }
187        model_id.split_once('/').map(|(p, _)| p)
188    }
189}
190
191// ── Shared oxi-cli store (read-only fallback) ──────────────────────────────
192
193/// Legacy entry from `oxi-cli`: `{"type":"api_key","key":"..."}`.
194#[derive(serde::Deserialize)]
195struct LegacyEntry {
196    #[allow(dead_code)]
197    r#type: String,
198    key: String,
199}
200
201/// Parse an auth.json blob and extract a provider's access token, accepting
202/// both the modern `TokenBundle` shape and the legacy
203/// `{"type":"api_key","key":...}` shape. Returns `None` when the provider is
204/// absent or its entry parses to neither shape.
205fn extract_credential(provider: &str, raw: &str) -> Option<String> {
206    let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(raw).ok()?;
207    let entry = map.get(provider)?;
208    // Modern TokenBundle first.
209    if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(entry.clone())
210        && !bundle.access_token.is_empty()
211    {
212        return Some(bundle.access_token);
213    }
214    // Legacy `oxi-cli` shape.
215    if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(entry.clone())
216        && !legacy.key.is_empty()
217    {
218        return Some(legacy.key);
219    }
220    None
221}
222
223/// Load a credential from the shared oxi-cli store (`~/.oxi/auth.json`), which
224/// oxios treats as a read-only secondary source. Tries `TokenBundle` first,
225/// then the legacy `oxi-cli` shape. Returns `None` when the file or entry is
226/// absent.
227fn load_from_shared_store(provider: &str) -> Option<String> {
228    let path = match shared_auth_json_path() {
229        Ok(p) => p,
230        Err(_) => return None,
231    };
232    let raw = match std::fs::read_to_string(&path) {
233        Ok(s) => s,
234        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
235        Err(e) => {
236            tracing::warn!(
237                provider = %provider,
238                path = %path.display(),
239                error = %e,
240                "shared auth.json exists but could not be read; skipping",
241            );
242            return None;
243        }
244    };
245    extract_credential(provider, &raw)
246}
247
248/// Check if an error is caused by a legacy-format auth.json.
249fn is_legacy_auth_error(err: &oxi_sdk::OAuthError) -> bool {
250    matches!(err, oxi_sdk::OAuthError::Json(_))
251}
252
253/// Migrate a legacy auth.json to `TokenBundle` format, preserving entries that
254/// can be converted and writing the new token for `provider`.
255fn migrate_legacy_auth_store(provider: &str, new_token: &oxi_sdk::TokenBundle) -> Result<()> {
256    let path = shared_auth_json_path()?;
257    let raw = std::fs::read_to_string(&path)?;
258
259    // Parse as a flat JSON map.
260    let entries: serde_json::Map<String, serde_json::Value> =
261        serde_json::from_str(&raw).unwrap_or_default();
262
263    let mut migrated = HashMap::new();
264
265    for (key, value) in &entries {
266        if key == provider {
267            continue; // will be replaced with new_token below
268        }
269
270        // Try parsing as TokenBundle first.
271        if let Ok(bundle) = serde_json::from_value::<oxi_sdk::TokenBundle>(value.clone()) {
272            migrated.insert(key.clone(), bundle);
273            continue;
274        }
275
276        // Try parsing as legacy `{"type":"api_key","key":"..."}`.
277        if let Ok(legacy) = serde_json::from_value::<LegacyEntry>(value.clone()) {
278            migrated.insert(
279                key.clone(),
280                oxi_sdk::TokenBundle {
281                    access_token: legacy.key,
282                    refresh_token: None,
283                    token_type: "Bearer".to_string(),
284                    obtained_at: chrono::Utc::now(),
285                    expires_in: 0,
286                    scope: None,
287                },
288            );
289            continue;
290        }
291
292        tracing::warn!(provider = %key, "skipping unparseable auth.json entry during migration");
293    }
294
295    // Insert the new token.
296    migrated.insert(provider.to_string(), new_token.clone());
297
298    // Write back as proper AuthStore (under OXI_HOME → ~/.oxios/auth.json).
299    let store = oxi_sdk::AuthStore { tokens: migrated };
300    oxi_sdk::save_auth_store(&store)?;
301    Ok(())
302}
303
304/// Resolve the shared oxi-cli auth store path (`~/.oxi/auth.json`).
305///
306/// This is deliberately **independent of `OXI_HOME`**: it always points at the
307/// oxi CLI's home so oxios can read keys registered by the standalone `oxi`
308/// tool as a backward-compat source, even while oxios's own writes are isolated
309/// under `OXI_HOME` (`~/.oxios/auth.json`).
310fn shared_auth_json_path() -> Result<PathBuf> {
311    let home = std::env::var("HOME")
312        .or_else(|_| std::env::var("USERPROFILE"))
313        .map_err(|_| anyhow::anyhow!("Cannot determine home directory"))?;
314    Ok(PathBuf::from(home).join(".oxi").join("auth.json"))
315}
316
317/// Discover all provider names stored in either auth store.
318///
319/// Returns the union of provider IDs from the oxios store (`~/.oxios/auth.json`
320/// via `OXI_HOME`) and the shared oxi-cli store (`~/.oxi/auth.json`). Special
321/// keys like `"version"` are filtered out. Used by `OxiosEngine::from_config`
322/// to inject credentials for providers beyond the hardcoded known list.
323pub fn discover_auth_store_providers() -> Result<Vec<String>> {
324    let mut providers: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
325    let keep = |k: &str| k != "version" && !k.starts_with('_');
326
327    // Primary: oxios store (OXI_HOME).
328    if let Ok(store) = oxi_sdk::load_auth_store() {
329        for k in store.tokens.keys() {
330            if keep(k) {
331                providers.insert(k.clone());
332            }
333        }
334    }
335    // Shared: oxi-cli store (~/.oxi/auth.json) — top-level keys, any shape.
336    if let Ok(path) = shared_auth_json_path()
337        && let Ok(raw) = std::fs::read_to_string(&path)
338        && let Ok(map) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw)
339    {
340        for k in map.keys() {
341            if keep(k) {
342                providers.insert(k.clone());
343            }
344        }
345    }
346    Ok(providers.into_iter().collect())
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_provider_from_model() {
355        assert_eq!(
356            CredentialStore::provider_from_model("anthropic/claude-sonnet-4-20250514"),
357            Some("anthropic")
358        );
359        assert_eq!(
360            CredentialStore::provider_from_model("openai/gpt-4o"),
361            Some("openai")
362        );
363        assert_eq!(CredentialStore::provider_from_model("bare-model"), None);
364        assert_eq!(CredentialStore::provider_from_model(""), None);
365    }
366
367    #[test]
368    fn test_config_key_takes_priority() {
369        // If config_key is set, it's always returned (even if other sources exist)
370        let result = CredentialStore::resolve("anthropic", Some("sk-test-config-key"));
371        assert!(result.is_some());
372        let (key, source) = result.unwrap();
373        assert_eq!(key, "sk-test-config-key");
374        assert!(matches!(source, CredentialSource::Config));
375    }
376
377    #[test]
378    fn test_empty_config_key_skipped() {
379        // Empty string is treated as None — falls through to next source
380        // (result depends on whether auth.json or env vars exist)
381        let _ = CredentialStore::resolve("anthropic", Some(""));
382    }
383
384    #[test]
385    fn test_none_config_key_skipped() {
386        let _ = CredentialStore::resolve("anthropic", None); // depends on system state
387    }
388
389    #[test]
390    fn extract_credential_roundtrips_token_bundle() {
391        // Self-consistent: serialize a real TokenBundle, then read it back.
392        let bundle = oxi_sdk::TokenBundle {
393            access_token: "sk-bundle".into(),
394            refresh_token: None,
395            token_type: "Bearer".into(),
396            obtained_at: chrono::Utc::now(),
397            expires_in: 0,
398            scope: None,
399        };
400        let mut map = serde_json::Map::new();
401        map.insert("openai".into(), serde_json::to_value(&bundle).unwrap());
402        let raw = serde_json::to_string(&map).unwrap();
403        assert_eq!(
404            extract_credential("openai", &raw).as_deref(),
405            Some("sk-bundle")
406        );
407    }
408
409    #[test]
410    fn extract_credential_reads_legacy_shape() {
411        let raw = r#"{"anthropic":{"type":"api_key","key":"sk-legacy"}}"#;
412        assert_eq!(
413            extract_credential("anthropic", raw).as_deref(),
414            Some("sk-legacy")
415        );
416    }
417
418    #[test]
419    fn extract_credential_absent_provider_is_none() {
420        let raw = r#"{"openai":{"type":"api_key","key":"sk-x"}}"#;
421        assert!(extract_credential("anthropic", raw).is_none());
422    }
423
424    #[test]
425    fn extract_credential_empty_token_is_none() {
426        let raw = r#"{"openai":{"type":"api_key","key":""}}"#;
427        assert!(extract_credential("openai", raw).is_none());
428    }
429}