use super::*;
use crate::persistence::{CrossProcessFileLock, atomic_write_with_permissions, sync_parent_dir};
#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) struct StoredToken {
pub(crate) client_id: String,
pub(crate) access_token: String,
#[serde(default)]
pub(crate) refresh_token: Option<String>,
#[serde(default)]
pub(crate) expires_at: Option<i64>,
#[serde(default)]
pub(crate) granted_scopes: Vec<String>,
#[serde(default)]
pub(crate) client_secret: Option<String>,
#[serde(default)]
pub(crate) authorization_server: Option<String>,
#[serde(default)]
pub(crate) issuer: Option<String>,
#[serde(default)]
pub(crate) token_endpoint: Option<String>,
#[serde(default)]
pub(crate) resource: Option<String>,
pub(crate) server_url: String,
pub(crate) token_received_at: i64,
}
impl fmt::Debug for StoredToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StoredToken")
.field("client_id", &self.client_id)
.field("access_token", &"[REDACTED]")
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
)
.field("expires_at", &self.expires_at)
.field("granted_scopes", &self.granted_scopes)
.field(
"client_secret",
&self.client_secret.as_ref().map(|_| "[REDACTED]"),
)
.field("authorization_server", &self.authorization_server)
.field("issuer", &self.issuer)
.field("token_endpoint", &self.token_endpoint)
.field("resource", &self.resource)
.field("server_url", &self.server_url)
.field("token_received_at", &self.token_received_at)
.finish()
}
}
fn validate_token_server_name(server_name: &str) -> McpResult<()> {
if server_name.is_empty()
|| server_name.contains("__")
|| !server_name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
{
return Err(McpError::Config(format!(
"MCP server name '{server_name}' is not a valid token store name"
)));
}
Ok(())
}
pub(super) fn token_file_path(mc_home: &Path, server_name: &str) -> McpResult<PathBuf> {
validate_token_server_name(server_name)?;
Ok(mc_home
.join("mcp-tokens")
.join(format!("{server_name}.json")))
}
pub(crate) fn read_token(mc_home: &Path, server_name: &str) -> McpResult<Option<StoredToken>> {
let path = token_file_path(mc_home, server_name)?;
let _lock = CrossProcessFileLock::acquire(&path).map_err(lock_error)?;
read_token_unlocked(mc_home, server_name)
}
fn read_token_unlocked(mc_home: &Path, server_name: &str) -> McpResult<Option<StoredToken>> {
let path = token_file_path(mc_home, server_name)?;
match read_token_file_text(&path, server_name)? {
Some(text) => serde_json::from_str(&text).map(Some).map_err(|_| {
McpError::Config(format!(
"MCP OAuth token file for server '{server_name}' is corrupt"
))
}),
None => Ok(None),
}
}
#[cfg(windows)]
fn validate_token_file_path_before_open(path: &Path) -> McpResult<()> {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 => {
Err(McpError::Config(
"MCP OAuth token file must be a regular private file; reparse-point token files are not allowed".to_string(),
))
}
Ok(_) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(McpError::Transport(format!(
"failed to stat MCP OAuth token file: {error}"
))),
}
}
#[cfg(not(windows))]
fn validate_token_file_path_before_open(_path: &Path) -> McpResult<()> {
Ok(())
}
#[cfg(unix)]
fn path_is_symlink(path: &Path) -> bool {
fs::symlink_metadata(path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
}
#[cfg(all(unix, target_os = "linux"))]
fn o_no_follow() -> i32 {
0x20000
}
#[cfg(all(unix, not(target_os = "linux")))]
fn o_no_follow() -> i32 {
0x100
}
#[cfg(unix)]
fn validate_open_token_file(file: &fs::File) -> McpResult<()> {
use std::os::unix::fs::PermissionsExt;
let metadata = file.metadata().map_err(|error| {
McpError::Transport(format!("failed to stat MCP OAuth token file: {error}"))
})?;
if !metadata.is_file() {
return Err(McpError::Config(
"MCP OAuth token file must be a regular private file".to_string(),
));
}
if metadata.permissions().mode() & 0o077 != 0 {
return Err(McpError::Config(
"MCP OAuth token file permissions must be private/owner-only (0600 or stricter)"
.to_string(),
));
}
Ok(())
}
#[cfg(not(unix))]
fn validate_open_token_file(_file: &fs::File) -> McpResult<()> {
Ok(())
}
fn read_token_file_text(path: &Path, server_name: &str) -> McpResult<Option<String>> {
validate_token_file_path_before_open(path)?;
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(o_no_follow());
}
let mut file = match options.open(path) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
#[cfg(unix)]
Err(error) if path_is_symlink(path) => {
let _ = error;
return Err(McpError::Config(
"MCP OAuth token file must be a regular private file; symlinked token files are not allowed".to_string(),
));
}
Err(error) => {
return Err(McpError::Transport(format!(
"failed to read MCP OAuth token file for server '{server_name}': {error}"
)));
}
};
validate_open_token_file(&file)?;
let mut text = String::new();
file.read_to_string(&mut text).map_err(|error| {
McpError::Transport(format!(
"failed to read MCP OAuth token file for server '{server_name}': {error}"
))
})?;
Ok(Some(text))
}
pub(crate) fn write_token(mc_home: &Path, server_name: &str, token: &StoredToken) -> McpResult<()> {
let path = token_file_path(mc_home, server_name)?;
let _lock = CrossProcessFileLock::acquire(&path).map_err(lock_error)?;
write_token_unlocked(mc_home, server_name, token)
}
fn write_token_unlocked(mc_home: &Path, server_name: &str, token: &StoredToken) -> McpResult<()> {
let path = token_file_path(mc_home, server_name)?;
let bytes = serde_json::to_vec_pretty(token).map_err(|_| {
McpError::Config(format!(
"failed to serialize MCP OAuth token for server '{server_name}'"
))
})?;
atomic_write_with_permissions(&path, &bytes, Some(TOKEN_FILE_MODE)).map_err(|error| {
McpError::Transport(format!(
"failed to write MCP OAuth token file for server '{server_name}': {error}"
))
})
}
pub(crate) fn delete_token(mc_home: &Path, server_name: &str) -> McpResult<()> {
let path = token_file_path(mc_home, server_name)?;
let _lock = CrossProcessFileLock::acquire(&path).map_err(lock_error)?;
match fs::remove_file(&path) {
Ok(()) => sync_parent_after_token_delete(&path, server_name),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(McpError::Transport(format!(
"failed to delete MCP OAuth token file for server '{server_name}': {error}"
))),
}
}
fn sync_parent_after_token_delete(path: &Path, server_name: &str) -> McpResult<()> {
let parent = path.parent().ok_or_else(|| {
McpError::Transport(format!(
"failed to sync parent directory after deleting MCP OAuth token file for server '{server_name}': token path has no parent"
))
})?;
sync_parent_dir(parent).map_err(|error| {
McpError::Transport(format!(
"failed to sync parent directory after deleting MCP OAuth token file for server '{server_name}': {error}"
))
})
}
pub(super) fn read_token_locked(
mc_home: &Path,
server_name: &str,
) -> McpResult<Option<StoredToken>> {
with_token_lock(mc_home, server_name, || {
read_token_unlocked(mc_home, server_name)
})
}
pub(super) fn write_token_if_unchanged(
mc_home: &Path,
server_name: &str,
expected: &StoredToken,
next: &StoredToken,
) -> McpResult<bool> {
with_token_lock(mc_home, server_name, || {
let current = read_token_unlocked(mc_home, server_name)?;
if current.as_ref() != Some(expected) {
return Ok(false);
}
write_token_unlocked(mc_home, server_name, next)?;
Ok(true)
})
}
fn with_token_lock<T>(
mc_home: &Path,
server_name: &str,
f: impl FnOnce() -> McpResult<T>,
) -> McpResult<T> {
let path = token_file_path(mc_home, server_name)?;
let _lock = CrossProcessFileLock::acquire(&path).map_err(lock_error)?;
f()
}
pub(super) fn validate_token_url(token: &StoredToken, current_url: &str) -> bool {
token.server_url == current_url
}
fn lock_error(error: anyhow::Error) -> McpError {
McpError::Transport(format!("failed to lock MCP OAuth token file: {error}"))
}