use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
use crate::models::HttpError;
const ENV_VAR_MAP: &[(&str, &str)] = &[
("openai", "OPENAI_API_KEY"),
("anthropic", "ANTHROPIC_API_KEY"),
("fireworks", "FIREWORKS_API_KEY"),
("google", "GOOGLE_API_KEY"),
("groq", "GROQ_API_KEY"),
("mistral", "MISTRAL_API_KEY"),
("deepinfra", "DEEPINFRA_API_KEY"),
("openrouter", "OPENROUTER_API_KEY"),
("azure", "AZURE_OPENAI_API_KEY"),
];
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct AuthData {
#[serde(default)]
keys: HashMap<String, String>,
#[serde(default)]
tokens: HashMap<String, TokenEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TokenEntry {
token: String,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct ProviderStatus {
pub provider: String,
pub has_env_key: bool,
pub has_stored_key: bool,
pub env_var: String,
}
pub struct CredentialStore {
path: PathBuf,
cache: Option<AuthData>,
}
impl CredentialStore {
pub fn new(auth_path: Option<PathBuf>) -> Self {
let path = auth_path.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".opendev")
.join("auth.json")
});
Self { path, cache: None }
}
pub fn get_key(&mut self, provider: &str) -> Option<String> {
let provider_lower = provider.to_lowercase();
if let Some(env_var) = env_var_for_provider(&provider_lower)
&& let Ok(val) = std::env::var(env_var)
&& !val.is_empty()
{
return Some(val);
}
let data = self.load();
data.keys.get(&provider_lower).cloned()
}
pub fn set_key(&mut self, provider: &str, key: &str) -> Result<(), HttpError> {
let mut data = self.load().clone();
data.keys.insert(provider.to_lowercase(), key.to_string());
self.save(&data)?;
info!("Stored API key for {}", provider);
Ok(())
}
pub fn remove_key(&mut self, provider: &str) -> Result<bool, HttpError> {
let mut data = self.load().clone();
let removed = data.keys.remove(&provider.to_lowercase()).is_some();
if removed {
self.save(&data)?;
}
Ok(removed)
}
pub fn list_providers(&mut self) -> Vec<ProviderStatus> {
let data = self.load();
ENV_VAR_MAP
.iter()
.map(|&(provider, env_var)| {
let has_env = std::env::var(env_var)
.map(|v| !v.is_empty())
.unwrap_or(false);
let has_stored = data.keys.contains_key(provider);
ProviderStatus {
provider: provider.to_string(),
has_env_key: has_env,
has_stored_key: has_stored,
env_var: env_var.to_string(),
}
})
.collect()
}
pub fn store_token(
&mut self,
name: &str,
token: &str,
metadata: Option<serde_json::Value>,
) -> Result<(), HttpError> {
let mut data = self.load().clone();
data.tokens.insert(
name.to_string(),
TokenEntry {
token: token.to_string(),
metadata,
},
);
self.save(&data)
}
pub fn get_token(&mut self, name: &str) -> Option<String> {
let data = self.load();
data.tokens.get(name).map(|e| e.token.clone())
}
fn load(&mut self) -> &AuthData {
if let Some(ref cached) = self.cache {
return cached;
}
let data = if self.path.exists() {
#[cfg(unix)]
self.check_permissions();
match std::fs::read_to_string(&self.path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(e) => {
warn!("Failed to load credentials from {:?}: {}", self.path, e);
AuthData::default()
}
}
} else {
AuthData::default()
};
self.cache = Some(data);
self.cache.as_ref().expect("cache was just set to Some")
}
fn save(&mut self, data: &AuthData) -> Result<(), HttpError> {
self.cache = Some(data.clone());
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp_path = self.path.with_extension("tmp");
let json = serde_json::to_string_pretty(data)?;
std::fs::write(&tmp_path, &json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))?;
}
std::fs::rename(&tmp_path, &self.path)?;
Ok(())
}
#[cfg(unix)]
fn check_permissions(&self) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&self.path) {
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
warn!(
"Credential file {:?} has loose permissions ({:o}). Tightening to 0600.",
self.path, mode
);
let _ =
std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600));
}
}
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl std::fmt::Debug for CredentialStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CredentialStore")
.field("path", &self.path)
.finish()
}
}
fn env_var_for_provider(provider: &str) -> Option<&'static str> {
ENV_VAR_MAP
.iter()
.find(|&&(p, _)| p == provider)
.map(|&(_, v)| v)
}
#[cfg(test)]
#[path = "auth_tests.rs"]
mod tests;