Skip to main content

opendev_http/
auth.rs

1//! Secure credential storage with restrictive file permissions.
2//!
3//! Credentials are stored in `~/.opendev/auth.json` with mode 0600
4//! (owner read/write only). Environment variables take precedence over
5//! stored credentials.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use tracing::{info, warn};
11
12use crate::models::HttpError;
13
14/// Map of provider names to their environment variable names.
15const ENV_VAR_MAP: &[(&str, &str)] = &[
16    ("openai", "OPENAI_API_KEY"),
17    ("anthropic", "ANTHROPIC_API_KEY"),
18    ("fireworks", "FIREWORKS_API_KEY"),
19    ("google", "GOOGLE_API_KEY"),
20    ("groq", "GROQ_API_KEY"),
21    ("mistral", "MISTRAL_API_KEY"),
22    ("deepinfra", "DEEPINFRA_API_KEY"),
23    ("openrouter", "OPENROUTER_API_KEY"),
24    ("azure", "AZURE_OPENAI_API_KEY"),
25];
26
27/// On-disk format for auth.json.
28#[derive(Debug, Default, Clone, Serialize, Deserialize)]
29struct AuthData {
30    #[serde(default)]
31    keys: HashMap<String, String>,
32    #[serde(default)]
33    tokens: HashMap<String, TokenEntry>,
34}
35
36/// A stored token with optional metadata.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38struct TokenEntry {
39    token: String,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    metadata: Option<serde_json::Value>,
42}
43
44/// Status of a provider's credential.
45#[derive(Debug, Clone)]
46pub struct ProviderStatus {
47    pub provider: String,
48    pub has_env_key: bool,
49    pub has_stored_key: bool,
50    pub env_var: String,
51}
52
53/// Secure credential store backed by a JSON file with 0600 permissions.
54///
55/// Environment variables always take precedence over stored values.
56pub struct CredentialStore {
57    path: PathBuf,
58    cache: Option<AuthData>,
59}
60
61impl CredentialStore {
62    /// Create a new credential store.
63    ///
64    /// If `auth_path` is `None`, defaults to `~/.opendev/auth.json`.
65    pub fn new(auth_path: Option<PathBuf>) -> Self {
66        let path = auth_path.unwrap_or_else(|| {
67            dirs::home_dir()
68                .unwrap_or_else(|| PathBuf::from("/tmp"))
69                .join(".opendev")
70                .join("auth.json")
71        });
72        Self { path, cache: None }
73    }
74
75    /// Get API key for a provider. Environment variable takes precedence.
76    pub fn get_key(&mut self, provider: &str) -> Option<String> {
77        let provider_lower = provider.to_lowercase();
78
79        // Check environment variable first
80        if let Some(env_var) = env_var_for_provider(&provider_lower)
81            && let Ok(val) = std::env::var(env_var)
82            && !val.is_empty()
83        {
84            return Some(val);
85        }
86
87        // Fall back to stored credential
88        let data = self.load();
89        data.keys.get(&provider_lower).cloned()
90    }
91
92    /// Store an API key for a provider.
93    pub fn set_key(&mut self, provider: &str, key: &str) -> Result<(), HttpError> {
94        let mut data = self.load().clone();
95        data.keys.insert(provider.to_lowercase(), key.to_string());
96        self.save(&data)?;
97        info!("Stored API key for {}", provider);
98        Ok(())
99    }
100
101    /// Remove a stored API key. Returns `true` if the key existed.
102    pub fn remove_key(&mut self, provider: &str) -> Result<bool, HttpError> {
103        let mut data = self.load().clone();
104        let removed = data.keys.remove(&provider.to_lowercase()).is_some();
105        if removed {
106            self.save(&data)?;
107        }
108        Ok(removed)
109    }
110
111    /// List all known providers with their credential status.
112    pub fn list_providers(&mut self) -> Vec<ProviderStatus> {
113        let data = self.load();
114        ENV_VAR_MAP
115            .iter()
116            .map(|&(provider, env_var)| {
117                let has_env = std::env::var(env_var)
118                    .map(|v| !v.is_empty())
119                    .unwrap_or(false);
120                let has_stored = data.keys.contains_key(provider);
121                ProviderStatus {
122                    provider: provider.to_string(),
123                    has_env_key: has_env,
124                    has_stored_key: has_stored,
125                    env_var: env_var.to_string(),
126                }
127            })
128            .collect()
129    }
130
131    /// Store an arbitrary token (e.g., OAuth token for MCP servers).
132    pub fn store_token(
133        &mut self,
134        name: &str,
135        token: &str,
136        metadata: Option<serde_json::Value>,
137    ) -> Result<(), HttpError> {
138        let mut data = self.load().clone();
139        data.tokens.insert(
140            name.to_string(),
141            TokenEntry {
142                token: token.to_string(),
143                metadata,
144            },
145        );
146        self.save(&data)
147    }
148
149    /// Retrieve a stored token.
150    pub fn get_token(&mut self, name: &str) -> Option<String> {
151        let data = self.load();
152        data.tokens.get(name).map(|e| e.token.clone())
153    }
154
155    /// Load credentials from file, caching the result.
156    fn load(&mut self) -> &AuthData {
157        if let Some(ref cached) = self.cache {
158            return cached;
159        }
160
161        let data = if self.path.exists() {
162            // Verify and tighten permissions
163            #[cfg(unix)]
164            self.check_permissions();
165
166            match std::fs::read_to_string(&self.path) {
167                Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
168                Err(e) => {
169                    warn!("Failed to load credentials from {:?}: {}", self.path, e);
170                    AuthData::default()
171                }
172            }
173        } else {
174            AuthData::default()
175        };
176
177        self.cache = Some(data);
178        // SAFETY: we just set self.cache to Some on the line above
179        self.cache.as_ref().expect("cache was just set to Some")
180    }
181
182    /// Save credentials with restrictive permissions.
183    fn save(&mut self, data: &AuthData) -> Result<(), HttpError> {
184        self.cache = Some(data.clone());
185
186        if let Some(parent) = self.path.parent() {
187            std::fs::create_dir_all(parent)?;
188        }
189
190        // Write to temp file, then rename (atomic)
191        let tmp_path = self.path.with_extension("tmp");
192        let json = serde_json::to_string_pretty(data)?;
193        std::fs::write(&tmp_path, &json)?;
194
195        // Set permissions before rename
196        #[cfg(unix)]
197        {
198            use std::os::unix::fs::PermissionsExt;
199            std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))?;
200        }
201
202        std::fs::rename(&tmp_path, &self.path)?;
203        Ok(())
204    }
205
206    /// Check and tighten file permissions on Unix.
207    #[cfg(unix)]
208    fn check_permissions(&self) {
209        use std::os::unix::fs::PermissionsExt;
210        if let Ok(meta) = std::fs::metadata(&self.path) {
211            let mode = meta.permissions().mode() & 0o777;
212            if mode & 0o077 != 0 {
213                warn!(
214                    "Credential file {:?} has loose permissions ({:o}). Tightening to 0600.",
215                    self.path, mode
216                );
217                let _ =
218                    std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600));
219            }
220        }
221    }
222
223    /// Get the path to the auth file.
224    pub fn path(&self) -> &Path {
225        &self.path
226    }
227}
228
229impl std::fmt::Debug for CredentialStore {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        f.debug_struct("CredentialStore")
232            .field("path", &self.path)
233            .finish()
234    }
235}
236
237/// Look up the environment variable name for a provider.
238fn env_var_for_provider(provider: &str) -> Option<&'static str> {
239    ENV_VAR_MAP
240        .iter()
241        .find(|&&(p, _)| p == provider)
242        .map(|&(_, v)| v)
243}
244
245#[cfg(test)]
246#[path = "auth_tests.rs"]
247mod tests;