pdftract/crates/pdftract-cli/src/mcp/auth.rs
jedarden 210c40de8c feat(pdftract-mcp): add MCP server implementation changes
Changes from Phase 6.7 child beads that were not committed earlier:

- Add subtle dependency for constant-time token comparison
- Add root directory for path-traversal protection in HTTP+SSE transport
- Update MCP server state to support --root flag
- Minor fixes and improvements across MCP modules

These changes support the 7 closed child beads:
- pdftract-5xq16: JSON-RPC 2.0 framing layer
- pdftract-67tm8: stdio transport
- pdftract-g0ro2: HTTP+SSE transport
- pdftract-24kut: transport mutual exclusion enforcement
- pdftract-1rami: tool catalog (10 tools)
- pdftract-6696g: path-traversal protection
- pdftract-zltqd: bearer-token auth

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 03:09:56 -04:00

201 lines
6.6 KiB
Rust

use anyhow::{Context, Result};
use secrecy::SecretString;
use std::env;
use std::fs;
use std::path::Path;
/// Exit code for usage errors (invalid flag combination)
pub const EXIT_USAGE_ERROR: u8 = 64;
/// Minimum recommended token length (bytes)
const MIN_TOKEN_LENGTH: usize = 32;
/// The source of the authentication token.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthSource {
/// Token from --auth-token-file PATH (recommended)
TokenFile,
/// Token from PDFTRACT_MCP_TOKEN environment variable
EnvVar,
/// Token from --auth-token VALUE (deprecated, requires PDFTRACT_INSECURE_CLI_TOKEN=1)
CliInsecure,
}
impl std::fmt::Display for AuthSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthSource::TokenFile => write!(f, "--auth-token-file"),
AuthSource::EnvVar => write!(f, "PDFTRACT_MCP_TOKEN env var"),
AuthSource::CliInsecure => write!(f, "--auth-token (insecure)"),
}
}
}
/// Resolves the MCP bearer token from multiple possible sources.
///
/// Priority order:
/// 1. `--auth-token-file PATH` (reads file, strips terminating newline) — RECOMMENDED
/// 2. `PDFTRACT_MCP_TOKEN` env var
/// 3. `--auth-token VALUE` (only if `PDFTRACT_INSECURE_CLI_TOKEN=1`) — DEPRECATED
/// 4. None
///
/// Returns the token (if any) and the source that provided it.
/// Tokens shorter than 32 characters emit a warning but are accepted
/// to avoid breaking existing deployments.
pub fn resolve_token(
token_file: Option<&Path>,
env_token: Option<String>,
cli_token: Option<String>,
) -> Result<Option<(SecretString, AuthSource)>> {
// Priority 1: --auth-token-file
if let Some(path) = token_file {
let token_content = fs::read_to_string(path)
.with_context(|| format!("Failed to read token file: {}", path.display()))?;
let token = token_content.trim_end().to_string();
check_token_length(&token);
return Ok(Some((SecretString::new(token.into()), AuthSource::TokenFile)));
}
// Priority 2: PDFTRACT_MCP_TOKEN env var
if let Some(token) = env_token {
if !token.is_empty() {
check_token_length(&token);
return Ok(Some((SecretString::new(token.into()), AuthSource::EnvVar)));
}
}
// Priority 3: --auth-token VALUE (only if PDFTRACT_INSECURE_CLI_TOKEN=1)
if let Some(token) = cli_token {
let insecure_allowed = env::var("PDFTRACT_INSECURE_CLI_TOKEN")
.ok()
.as_deref()
== Some("1");
if !insecure_allowed {
anyhow::bail!(
"The --auth-token VALUE flag is REJECTED for security reasons.\n\
Use --auth-token-file PATH (RECOMMENDED) or PDFTRACT_MCP_TOKEN env var instead.\n\
To use this insecure flag anyway, set PDFTRACT_INSECURE_CLI_TOKEN=1."
);
}
eprintln!(
"WARNING: Using --auth-token VALUE is INSECURE. The token is visible in process listings.\n\
Recommended: Use --auth-token-file PATH or PDFTRACT_MCP_TOKEN env var."
);
check_token_length(&token);
return Ok(Some((SecretString::new(token.into()), AuthSource::CliInsecure)));
}
// No token provided
Ok(None)
}
/// Emits a warning if the token is shorter than the recommended minimum length.
fn check_token_length(token: &str) {
if token.len() < MIN_TOKEN_LENGTH {
eprintln!(
"WARNING: Token length is {} bytes, which is below the recommended minimum of {} bytes. \
Consider using a longer token for better security.",
token.len(),
MIN_TOKEN_LENGTH
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
use std::fs::write;
use tempfile::NamedTempFile;
#[test]
fn test_resolve_token_priority_file_first() {
let temp_file = NamedTempFile::new().unwrap();
write(temp_file.path(), "file-token\n").unwrap();
let (token, source) = resolve_token(
Some(temp_file.path()),
Some("env-token".to_string()),
Some("cli-token".to_string()),
)
.unwrap()
.unwrap();
assert_eq!(token.expose_secret(), "file-token");
assert_eq!(source, AuthSource::TokenFile);
}
#[test]
fn test_resolve_token_priority_env_second() {
let (token, source) = resolve_token(
None,
Some("env-token".to_string()),
Some("cli-token".to_string()),
)
.unwrap()
.unwrap();
assert_eq!(token.expose_secret(), "env-token");
assert_eq!(source, AuthSource::EnvVar);
}
#[test]
fn test_resolve_token_rejects_cli_token_without_insecure_flag() {
let result = resolve_token(None, None, Some("cli-token".to_string()));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("REJECTED"));
}
#[test]
fn test_resolve_token_accepts_cli_token_with_insecure_flag() {
env::set_var("PDFTRACT_INSECURE_CLI_TOKEN", "1");
let (token, source) = resolve_token(None, None, Some("cli-token".to_string()))
.unwrap()
.unwrap();
env::remove_var("PDFTRACT_INSECURE_CLI_TOKEN");
assert_eq!(token.expose_secret(), "cli-token");
assert_eq!(source, AuthSource::CliInsecure);
}
#[test]
fn test_resolve_token_none() {
let token = resolve_token(None, None, None).unwrap();
assert!(token.is_none());
}
#[test]
fn test_resolve_token_empty_env_var() {
let token = resolve_token(None, Some("".to_string()), None).unwrap();
assert!(token.is_none());
}
#[test]
fn test_resolve_token_file_strips_newline() {
let temp_file = NamedTempFile::new().unwrap();
write(temp_file.path(), "token-with-newline\n").unwrap();
let (token, source) = resolve_token(Some(temp_file.path()), None, None)
.unwrap()
.unwrap();
assert_eq!(token.expose_secret(), "token-with-newline");
assert_eq!(source, AuthSource::TokenFile);
}
#[test]
fn test_resolve_token_short_token_warning() {
let temp_file = NamedTempFile::new().unwrap();
write(temp_file.path(), "short").unwrap();
// Should succeed but emit warning (captured in test output)
let (token, source) = resolve_token(Some(temp_file.path()), None, None)
.unwrap()
.unwrap();
assert_eq!(token.expose_secret(), "short");
assert_eq!(source, AuthSource::TokenFile);
}
}