use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::{Result, ZeptoError};
use crate::security::encryption::SecretEncryption;
use super::OAuthTokenSet;
pub struct TokenStore {
path: PathBuf,
encryption: SecretEncryption,
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct TokensFile {
tokens: HashMap<String, OAuthTokenSet>,
}
impl TokenStore {
pub fn new(encryption: SecretEncryption) -> Self {
let path = crate::config::Config::dir()
.join("auth")
.join("tokens.json.enc");
Self { path, encryption }
}
pub fn with_path(path: PathBuf, encryption: SecretEncryption) -> Self {
Self { path, encryption }
}
pub fn load(&self, provider: &str) -> Result<Option<OAuthTokenSet>> {
let file = self.load_file()?;
Ok(file.tokens.get(provider).cloned())
}
pub fn save(&self, tokens: &OAuthTokenSet) -> Result<()> {
let mut file = self.load_file()?;
file.tokens.insert(tokens.provider.clone(), tokens.clone());
self.save_file(&file)
}
pub fn delete(&self, provider: &str) -> Result<bool> {
let mut file = self.load_file()?;
let removed = file.tokens.remove(provider).is_some();
if removed {
self.save_file(&file)?;
}
Ok(removed)
}
pub fn list(&self) -> Result<Vec<(String, TokenSummary)>> {
let file = self.load_file()?;
let mut entries: Vec<(String, TokenSummary)> = file
.tokens
.iter()
.map(|(name, token)| {
(
name.clone(),
TokenSummary {
is_expired: token.is_expired(),
expires_in: token.expires_in_human(),
has_refresh_token: token.refresh_token.is_some(),
obtained_at: token.obtained_at,
},
)
})
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
Ok(entries)
}
pub fn exists(&self) -> bool {
self.path.exists()
}
fn load_file(&self) -> Result<TokensFile> {
if !self.path.exists() {
return Ok(TokensFile::default());
}
let encrypted = std::fs::read_to_string(&self.path).map_err(|e| {
ZeptoError::Config(format!(
"Failed to read token store at {:?}: {}",
self.path, e
))
})?;
if encrypted.trim().is_empty() {
return Ok(TokensFile::default());
}
let json = self.encryption.decrypt(encrypted.trim())?;
serde_json::from_str(&json)
.map_err(|e| ZeptoError::Config(format!("Failed to parse token store: {}", e)))
}
fn save_file(&self, file: &TokensFile) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
ZeptoError::Config(format!(
"Failed to create auth directory {:?}: {}",
parent, e
))
})?;
}
let json = serde_json::to_string_pretty(file)
.map_err(|e| ZeptoError::Config(format!("Failed to serialize tokens: {}", e)))?;
let encrypted = self.encryption.encrypt(&json)?;
std::fs::write(&self.path, &encrypted).map_err(|e| {
ZeptoError::Config(format!(
"Failed to write token store at {:?}: {}",
self.path, e
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct TokenSummary {
pub is_expired: bool,
pub expires_in: String,
pub has_refresh_token: bool,
pub obtained_at: i64,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_encryption() -> SecretEncryption {
SecretEncryption::from_raw_key(&[0x42u8; 32])
}
fn test_token(provider: &str) -> OAuthTokenSet {
OAuthTokenSet {
provider: provider.to_string(),
access_token: format!("access-token-{}", provider),
refresh_token: Some(format!("refresh-token-{}", provider)),
expires_at: Some(chrono::Utc::now().timestamp() + 28800),
token_type: "Bearer".to_string(),
scope: None,
obtained_at: chrono::Utc::now().timestamp(),
client_id: Some("test-client-id".to_string()),
}
}
#[test]
fn test_store_save_and_load() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tokens.json.enc");
let store = TokenStore::with_path(path, test_encryption());
assert!(store.load("anthropic").unwrap().is_none());
let token = test_token("anthropic");
store.save(&token).unwrap();
let loaded = store
.load("anthropic")
.unwrap()
.expect("token should exist");
assert_eq!(loaded.access_token, "access-token-anthropic");
assert_eq!(loaded.provider, "anthropic");
assert!(loaded.refresh_token.is_some());
}
#[test]
fn test_store_multiple_providers() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tokens.json.enc");
let store = TokenStore::with_path(path, test_encryption());
store.save(&test_token("anthropic")).unwrap();
store.save(&test_token("openai")).unwrap();
assert!(store.load("anthropic").unwrap().is_some());
assert!(store.load("openai").unwrap().is_some());
assert!(store.load("groq").unwrap().is_none());
}
#[test]
fn test_store_overwrite() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tokens.json.enc");
let store = TokenStore::with_path(path, test_encryption());
let mut token = test_token("anthropic");
store.save(&token).unwrap();
token.access_token = "updated-token".to_string();
store.save(&token).unwrap();
let loaded = store.load("anthropic").unwrap().unwrap();
assert_eq!(loaded.access_token, "updated-token");
}
#[test]
fn test_store_delete() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tokens.json.enc");
let store = TokenStore::with_path(path, test_encryption());
store.save(&test_token("anthropic")).unwrap();
assert!(store.load("anthropic").unwrap().is_some());
let removed = store.delete("anthropic").unwrap();
assert!(removed);
assert!(store.load("anthropic").unwrap().is_none());
let removed = store.delete("anthropic").unwrap();
assert!(!removed);
}
#[test]
fn test_store_list() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tokens.json.enc");
let store = TokenStore::with_path(path, test_encryption());
store.save(&test_token("anthropic")).unwrap();
store.save(&test_token("openai")).unwrap();
let list = store.list().unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].0, "anthropic");
assert_eq!(list[1].0, "openai");
assert!(!list[0].1.is_expired);
assert!(list[0].1.has_refresh_token);
}
#[test]
fn test_store_empty_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tokens.json.enc");
std::fs::create_dir_all(tmp.path()).unwrap();
std::fs::write(&path, "").unwrap();
let store = TokenStore::with_path(path, test_encryption());
assert!(store.load("anthropic").unwrap().is_none());
}
#[test]
fn test_store_wrong_key_fails() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tokens.json.enc");
let store1 = TokenStore::with_path(path.clone(), test_encryption());
store1.save(&test_token("anthropic")).unwrap();
let store2 = TokenStore::with_path(path, SecretEncryption::from_raw_key(&[0x99u8; 32]));
let result = store2.load("anthropic");
assert!(result.is_err());
}
#[test]
fn test_store_exists() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tokens.json.enc");
let store = TokenStore::with_path(path, test_encryption());
assert!(!store.exists());
store.save(&test_token("anthropic")).unwrap();
assert!(store.exists());
}
}