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