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