diff --git a/src/tools/microsoft365/auth.rs b/src/tools/microsoft365/auth.rs index a7f1c0e59..9b5b2a9d4 100644 --- a/src/tools/microsoft365/auth.rs +++ b/src/tools/microsoft365/auth.rs @@ -1,6 +1,8 @@ use anyhow::Context; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; use std::path::PathBuf; /// Cached OAuth2 token state persisted to disk between runs. @@ -31,14 +33,30 @@ impl TokenCache { pub fn new( config: super::types::Microsoft365ResolvedConfig, zeroclaw_dir: &std::path::Path, - ) -> Self { - let cache_path = zeroclaw_dir.join("ms365_token_cache.json"); + ) -> anyhow::Result { + if config.token_cache_encrypted { + anyhow::bail!( + "microsoft365: token_cache_encrypted is enabled but encryption is not yet \ + implemented; refusing to store tokens in plaintext. Set token_cache_encrypted \ + to false or wait for encryption support." + ); + } + + // Scope cache file to (tenant_id, client_id, auth_flow) so config + // changes never reuse tokens from a different account/flow. + let mut hasher = DefaultHasher::new(); + config.tenant_id.hash(&mut hasher); + config.client_id.hash(&mut hasher); + config.auth_flow.hash(&mut hasher); + let fingerprint = format!("{:016x}", hasher.finish()); + + let cache_path = zeroclaw_dir.join(format!("ms365_token_cache_{fingerprint}.json")); let cached = Self::load_from_disk(&cache_path); - Self { + Ok(Self { inner: RwLock::new(cached), config, cache_path, - } + }) } /// Get a valid access token, refreshing or re-authenticating as needed. diff --git a/src/tools/microsoft365/mod.rs b/src/tools/microsoft365/mod.rs index 540cfc858..cfedf5cf3 100644 --- a/src/tools/microsoft365/mod.rs +++ b/src/tools/microsoft365/mod.rs @@ -33,16 +33,16 @@ impl Microsoft365Tool { config: types::Microsoft365ResolvedConfig, security: Arc, zeroclaw_dir: &std::path::Path, - ) -> Self { + ) -> anyhow::Result { let http_client = crate::config::build_runtime_proxy_client_with_timeouts("tool.microsoft365", 60, 10); - let token_cache = Arc::new(auth::TokenCache::new(config.clone(), zeroclaw_dir)); - Self { + let token_cache = Arc::new(auth::TokenCache::new(config.clone(), zeroclaw_dir)?); + Ok(Self { config, security, token_cache, http_client, - } + }) } async fn get_token(&self) -> anyhow::Result { diff --git a/src/tools/mod.rs b/src/tools/mod.rs index d57add30c..4fa2fcef5 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -387,11 +387,16 @@ pub fn all_tools_with_runtime( .unwrap_or("me") .to_string(), }; - tool_arcs.push(Arc::new(Microsoft365Tool::new( - resolved, - security.clone(), - workspace_dir, - ))); + // Store token cache in the config directory (next to config.toml), + // not the workspace directory, to keep bearer tokens out of the + // project tree. + let cache_dir = root_config.config_path.parent().unwrap_or(workspace_dir); + match Microsoft365Tool::new(resolved, security.clone(), cache_dir) { + Ok(tool) => tool_arcs.push(Arc::new(tool)), + Err(e) => { + tracing::error!("microsoft365: failed to initialize tool: {e}"); + } + } } else { tracing::warn!( "microsoft365: skipped registration because tenant_id or client_id is empty"