use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use base64::{decode, encode};
use log::{debug, error, warn};
use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::PathBuf;
use std::time::SystemTime;
use crate::errors::{ConfigError, GmailApiError, GmailResult};
#[derive(Debug, Serialize, Deserialize)]
pub struct CachedToken {
pub access_token: String,
pub refresh_token: String,
pub expiry_timestamp: u64, }
#[derive(Debug, Clone)]
pub struct TokenCacheConfig {
pub enabled: bool,
pub cache_file_path: PathBuf,
pub encryption_key: Vec<u8>,
}
impl TokenCacheConfig {
pub fn from_env() -> Result<Self, ConfigError> {
let enabled = std::env::var("TOKEN_CACHE_ENABLED")
.map(|s| s.to_lowercase() == "true" || s == "1")
.unwrap_or(false);
if !enabled {
debug!("Token caching is disabled");
return Ok(Self {
enabled: false,
cache_file_path: default_cache_path(),
encryption_key: generate_encryption_key("default_unused_key"),
});
}
let cache_file_path = match std::env::var("TOKEN_CACHE_FILE") {
Ok(path) => PathBuf::from(path),
Err(_) => default_cache_path(),
};
let encryption_key = match std::env::var("TOKEN_CACHE_ENCRYPTION_KEY") {
Ok(key) => generate_encryption_key(&key),
Err(_) => {
warn!("TOKEN_CACHE_ENCRYPTION_KEY not found, using less secure device-derived key");
fallback_encryption_key()
}
};
debug!(
"Token cache configured to use file: {}",
cache_file_path.display()
);
Ok(Self {
enabled,
cache_file_path,
encryption_key,
})
}
}
#[derive(Clone)]
pub struct TokenCache {
config: TokenCacheConfig,
cipher: Aes256Gcm,
}
impl std::fmt::Debug for TokenCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenCache")
.field("config", &self.config)
.field("cipher", &"<AES-GCM cipher>")
.finish()
}
}
impl TokenCache {
pub fn new(config: TokenCacheConfig) -> Result<Self, GmailApiError> {
if !config.enabled {
debug!("Creating TokenCache (disabled)");
let cipher = match Aes256Gcm::new_from_slice(&config.encryption_key) {
Ok(cipher) => cipher,
Err(e) => {
error!("Failed to initialize encryption: {}", e);
return Err(GmailApiError::CacheError(format!(
"Failed to initialize encryption: {}",
e
)));
}
};
return Ok(Self { config, cipher });
}
debug!("Creating TokenCache (enabled)");
if let Some(parent) = config.cache_file_path.parent() {
if !parent.exists() {
debug!("Creating parent directory for token cache");
match fs::create_dir_all(parent) {
Ok(_) => {}
Err(e) => {
error!("Failed to create cache directory: {}", e);
return Err(GmailApiError::CacheError(format!(
"Failed to create cache directory: {}",
e
)));
}
}
}
}
let cipher = match Aes256Gcm::new_from_slice(&config.encryption_key) {
Ok(cipher) => cipher,
Err(e) => {
error!("Failed to initialize encryption: {}", e);
return Err(GmailApiError::CacheError(format!(
"Failed to initialize encryption: {}",
e
)));
}
};
Ok(Self { config, cipher })
}
pub fn save_token(
&self,
access_token: &str,
refresh_token: &str,
expiry: SystemTime,
) -> GmailResult<()> {
if !self.config.enabled {
debug!("Token caching disabled, not saving token");
return Ok(());
}
debug!("Saving token to cache");
let expiry_timestamp = match expiry.duration_since(SystemTime::UNIX_EPOCH) {
Ok(duration) => duration.as_secs(),
Err(e) => {
error!("Invalid expiry time: {}", e);
return Err(GmailApiError::CacheError(
"Invalid expiry timestamp".to_string(),
));
}
};
let token = CachedToken {
access_token: access_token.to_string(),
refresh_token: refresh_token.to_string(),
expiry_timestamp,
};
let token_json = match serde_json::to_string(&token) {
Ok(json) => json,
Err(e) => {
error!("Failed to serialize token: {}", e);
return Err(GmailApiError::CacheError(format!(
"Failed to serialize token: {}",
e
)));
}
};
let encrypted_data = self.encrypt_data(token_json.as_bytes())?;
let encrypted_b64 = encode(&encrypted_data);
match File::create(&self.config.cache_file_path) {
Ok(mut file) => {
if let Err(e) = file.write_all(encrypted_b64.as_bytes()) {
error!("Failed to write token cache: {}", e);
return Err(GmailApiError::CacheError(format!(
"Failed to write token cache: {}",
e
)));
}
}
Err(e) => {
error!("Failed to create token cache file: {}", e);
return Err(GmailApiError::CacheError(format!(
"Failed to create token cache file: {}",
e
)));
}
}
debug!("Token successfully cached to {}", self.config.cache_file_path.display());
Ok(())
}
pub fn load_token(&self) -> GmailResult<Option<CachedToken>> {
if !self.config.enabled {
debug!("Token caching disabled, not loading token");
return Ok(None);
}
if !self.config.cache_file_path.exists() {
debug!("Token cache file not found");
return Ok(None);
}
debug!("Loading token from cache");
let mut file = match File::open(&self.config.cache_file_path) {
Ok(file) => file,
Err(e) => {
error!("Failed to open token cache file: {}", e);
return Err(GmailApiError::CacheError(format!(
"Failed to open token cache file: {}",
e
)));
}
};
let mut encrypted_b64 = String::new();
if let Err(e) = file.read_to_string(&mut encrypted_b64) {
error!("Failed to read token cache: {}", e);
return Err(GmailApiError::CacheError(format!(
"Failed to read token cache: {}",
e
)));
}
let encrypted_data = match decode(&encrypted_b64) {
Ok(data) => data,
Err(e) => {
error!("Failed to decode cached token data: {}", e);
return Err(GmailApiError::CacheError(format!(
"Invalid token cache format: {}",
e
)));
}
};
let decrypted_data = match self.decrypt_data(&encrypted_data) {
Ok(data) => data,
Err(e) => {
warn!("Failed to decrypt token cache: {}", e);
if let Err(e) = fs::remove_file(&self.config.cache_file_path) {
debug!("Failed to delete corrupt token cache: {}", e);
}
return Ok(None);
}
};
match serde_json::from_slice::<CachedToken>(&decrypted_data) {
Ok(token) => {
debug!("Successfully loaded token from cache");
let now = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
Ok(duration) => duration.as_secs(),
Err(_) => {
error!("System time error when checking token expiry");
return Ok(Some(token)); }
};
if token.expiry_timestamp <= now {
debug!("Cached token has expired, will need refreshing");
}
Ok(Some(token))
}
Err(e) => {
error!("Failed to deserialize cached token: {}", e);
if let Err(e) = fs::remove_file(&self.config.cache_file_path) {
debug!("Failed to delete corrupt token cache: {}", e);
}
Ok(None)
}
}
}
pub fn is_token_valid(&self, token: &CachedToken) -> bool {
let now = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
Ok(duration) => duration.as_secs(),
Err(_) => {
error!("System time error when checking token validity");
return false;
}
};
token.expiry_timestamp > now + 300
}
pub fn clear_cache(&self) -> GmailResult<()> {
if !self.config.enabled {
return Ok(());
}
if self.config.cache_file_path.exists() {
debug!("Clearing token cache");
match fs::remove_file(&self.config.cache_file_path) {
Ok(_) => {
debug!("Token cache cleared successfully");
Ok(())
}
Err(e) => {
error!("Failed to clear token cache: {}", e);
Err(GmailApiError::CacheError(format!(
"Failed to clear token cache: {}",
e
)))
}
}
} else {
debug!("No token cache to clear");
Ok(())
}
}
fn encrypt_data(&self, data: &[u8]) -> GmailResult<Vec<u8>> {
let nonce_value = rand::random::<[u8; 12]>();
let nonce = Nonce::from_slice(&nonce_value);
let ciphertext = match self.cipher.encrypt(nonce, data) {
Ok(ciphertext) => ciphertext,
Err(e) => {
error!("Encryption failed: {}", e);
return Err(GmailApiError::CacheError(format!("Encryption failed: {}", e)));
}
};
let mut result = nonce_value.to_vec();
result.extend_from_slice(&ciphertext);
Ok(result)
}
fn decrypt_data(&self, data: &[u8]) -> GmailResult<Vec<u8>> {
if data.len() < 12 {
return Err(GmailApiError::CacheError(
"Invalid encrypted data: too short".to_string(),
));
}
let nonce = Nonce::from_slice(&data[0..12]);
let ciphertext = &data[12..];
match self.cipher.decrypt(nonce, ciphertext) {
Ok(plaintext) => Ok(plaintext),
Err(e) => {
error!("Decryption failed: {}", e);
Err(GmailApiError::CacheError(format!("Decryption failed: {}", e)))
}
}
}
}
fn generate_encryption_key(secret: &str) -> Vec<u8> {
let mut key = Vec::with_capacity(32); let source = secret.as_bytes().to_vec();
if source.len() < 32 {
while key.len() < 32 {
key.extend_from_slice(&source);
}
key.truncate(32);
} else if source.len() > 32 {
key.extend_from_slice(&source[0..32]);
} else {
key = source;
}
key
}
fn fallback_encryption_key() -> Vec<u8> {
let hostname = match std::process::Command::new("hostname").output() {
Ok(output) => String::from_utf8_lossy(&output.stdout).to_string(),
Err(_) => "unknown-host".to_string(),
};
let username = match std::env::var("USER") {
Ok(user) => user,
Err(_) => "unknown-user".to_string(),
};
let combined = format!("gmail-mcp-rs-{}-{}", hostname, username);
generate_encryption_key(&combined)
}
fn default_cache_path() -> PathBuf {
let mut path = match dirs::cache_dir() {
Some(cache_dir) => cache_dir,
None => {
if let Some(temp_dir) = std::env::temp_dir().to_str() {
PathBuf::from(temp_dir)
} else {
PathBuf::from("/tmp") }
}
};
path.push("gmail-mcp-rs");
path.push("token-cache.dat");
path
}