fix(microsoft365): scope token cache, fail on unimplemented encryption, use config dir

- Include hash of (tenant_id, client_id, auth_flow) in token cache
  filename to prevent cross-account token reuse after config changes.
- Fail fast with a clear error when token_cache_encrypted is true,
  since encryption is not yet implemented (was silently storing
  plaintext).
- Use the config directory (parent of config.toml) instead of
  workspace_dir for token cache storage, keeping bearer tokens out
  of the project tree.
This commit is contained in:
Giulio V 2026-03-11 19:19:59 +01:00
parent 71b16357f3
commit c7f341885b
3 changed files with 36 additions and 13 deletions

View File

@ -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<Self> {
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.

View File

@ -33,16 +33,16 @@ impl Microsoft365Tool {
config: types::Microsoft365ResolvedConfig,
security: Arc<SecurityPolicy>,
zeroclaw_dir: &std::path::Path,
) -> Self {
) -> anyhow::Result<Self> {
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<String> {

View File

@ -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"