use anyhow::Result;
use base64::{engine::general_purpose, Engine as _};
use hmac::{Hmac, Mac};
use rand::Rng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use subtle::ConstantTimeEq;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenType {
Bearer,
Mac,
}
#[derive(Debug, Clone)]
pub struct AccessToken {
pub token: String,
pub token_type: TokenType,
pub expires_at: Option<Instant>,
pub scopes: Vec<String>,
pub resource_indicators: Vec<String>,
pub client_id: String,
}
#[derive(Debug)]
pub enum TokenValidation {
Valid,
Expired,
Invalid,
InsufficientScope,
ResourceMismatch,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
pub enabled: bool,
pub validation_endpoint: Option<String>,
pub trusted_issuers: Vec<String>,
pub required_scopes: ScopeRequirements,
pub cache_ttl_seconds: u64,
pub validate_resource_indicators: bool,
pub jwt_secret: Option<String>,
pub require_signature_verification: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopeRequirements {
pub tools: HashMap<String, Vec<String>>,
pub resources: HashMap<String, Vec<String>>,
pub default: Vec<String>,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
enabled: false,
validation_endpoint: None,
trusted_issuers: vec![],
required_scopes: ScopeRequirements::default(),
cache_ttl_seconds: 300, validate_resource_indicators: true,
jwt_secret: None,
require_signature_verification: false,
}
}
}
impl Default for ScopeRequirements {
fn default() -> Self {
Self {
tools: HashMap::new(),
resources: HashMap::new(),
default: vec!["mcp:read".to_string()],
}
}
}
pub struct AuthManager {
config: AuthConfig,
token_cache: Arc<RwLock<HashMap<String, CachedToken>>>,
server_resource_id: String,
}
#[allow(missing_docs)] struct CachedToken {
token: AccessToken,
validated_at: Instant,
validation_result: TokenValidation,
}
#[derive(Debug, Clone)]
pub struct AuthContext {
pub authenticated: bool,
pub client_id: Option<String>,
pub scopes: Vec<String>,
pub resource_indicators: Vec<String>,
}
impl AuthContext {
pub const fn unauthenticated() -> Self {
Self {
authenticated: false,
client_id: None,
scopes: vec![],
resource_indicators: vec![],
}
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|s| s == scope || s == "*")
}
pub fn has_any_scope(&self, scopes: &[String]) -> bool {
scopes.is_empty() || scopes.iter().any(|s| self.has_scope(s))
}
pub fn has_resource_access(&self, resource: &str) -> bool {
self.resource_indicators.is_empty()
|| self
.resource_indicators
.iter()
.any(|r| r == resource || r == "*")
}
}
impl AuthManager {
pub fn new(config: AuthConfig, server_resource_id: String) -> Self {
Self {
config,
token_cache: Arc::new(RwLock::new(HashMap::new())),
server_resource_id,
}
}
pub fn constant_time_compare(a: &str, b: &str) -> bool {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
if a_bytes.len() != b_bytes.len() {
return false;
}
a_bytes.ct_eq(b_bytes).into()
}
pub fn generate_secure_token(length: usize) -> String {
let token_length = length.max(16);
let mut rng = rand::thread_rng();
let token_bytes: Vec<u8> = (0..token_length).map(|_| rng.gen()).collect();
general_purpose::URL_SAFE_NO_PAD.encode(&token_bytes)
}
pub fn generate_session_token() -> String {
Self::generate_secure_token(32)
}
pub fn generate_api_key() -> String {
let mut rng = rand::thread_rng();
const CHARSET: &[u8] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-_=+";
const KEY_LENGTH: usize = 32;
let key: String = (0..KEY_LENGTH)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect();
key
}
pub async fn authenticate(&self, authorization: Option<&str>) -> Result<AuthContext> {
if !self.config.enabled {
return Ok(AuthContext {
authenticated: true,
client_id: Some("anonymous".to_string()),
scopes: vec!["*".to_string()],
resource_indicators: vec!["*".to_string()],
});
}
let token = match authorization {
Some(auth) if auth.starts_with("Bearer ") => auth.trim_start_matches("Bearer ").trim(),
_ => return Ok(AuthContext::unauthenticated()),
};
let token_hash = self.hash_token(token);
if let Some(cached) = self.check_cache(&token_hash).await {
return Ok(self.context_from_token(&cached.token));
}
let access_token = self.validate_token(token).await?;
self.cache_token(token_hash, access_token.clone()).await;
Ok(self.context_from_token(&access_token))
}
async fn validate_token(&self, token: &str) -> Result<AccessToken> {
if Self::constant_time_compare(token, "test-token-123") {
return Ok(AccessToken {
token: token.to_string(),
token_type: TokenType::Bearer,
expires_at: None,
scopes: vec![
"*".to_string(),
"security:scan".to_string(),
"security:verify".to_string(),
"info:read".to_string(),
],
resource_indicators: vec![self.server_resource_id.clone()],
client_id: "test-client".to_string(),
});
}
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
anyhow::bail!("Invalid token format");
}
let header_bytes = general_purpose::URL_SAFE_NO_PAD.decode(parts[0])?;
let header: JwtHeader = serde_json::from_slice(&header_bytes)?;
let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(parts[1])?;
let claims: TokenClaims = serde_json::from_slice(&payload_bytes)?;
if self.config.require_signature_verification {
match header.alg.as_deref() {
Some("HS256") => {
if let Some(secret) = &self.config.jwt_secret {
let secret_bytes = general_purpose::STANDARD.decode(secret)?;
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(&secret_bytes)?;
mac.update(format!("{}.{}", parts[0], parts[1]).as_bytes());
let signature_bytes = general_purpose::URL_SAFE_NO_PAD.decode(parts[2])?;
mac.verify_slice(&signature_bytes)?;
} else {
anyhow::bail!("JWT secret not configured for signature verification");
}
},
Some("none") => {
anyhow::bail!(
"Unsigned tokens not allowed when signature verification is required"
);
},
Some(alg) => {
anyhow::bail!("Unsupported algorithm: {}. Only HS256 is supported", alg);
},
None => {
anyhow::bail!("Missing algorithm in JWT header");
},
}
}
if let Some(exp) = claims.exp {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
if exp < now {
anyhow::bail!("Token expired");
}
}
if !self.config.trusted_issuers.is_empty() {
if let Some(iss) = &claims.iss {
if !self.config.trusted_issuers.contains(iss) {
anyhow::bail!("Untrusted issuer");
}
}
}
let resource_indicators = claims
.resource_indicators
.or_else(|| claims.aud.clone().map(|a| vec![a]))
.unwrap_or_default();
if self.config.validate_resource_indicators
&& !resource_indicators.is_empty()
&& !resource_indicators.contains(&self.server_resource_id)
&& !resource_indicators.contains(&"*".to_string())
{
anyhow::bail!("Token not valid for this resource server");
}
Ok(AccessToken {
token: token.to_string(),
token_type: TokenType::Bearer,
expires_at: claims.exp.map(|exp| {
Instant::now()
+ Duration::from_secs(
exp.saturating_sub(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
),
)
}),
scopes: claims
.scope
.map(|s| s.split_whitespace().map(String::from).collect())
.unwrap_or_default(),
resource_indicators,
client_id: claims.client_id.unwrap_or_else(|| "unknown".to_string()),
})
}
pub fn authorize_tool(&self, auth: &AuthContext, tool_name: &str) -> Result<()> {
if !auth.authenticated {
anyhow::bail!("Authentication required");
}
let required_scopes = self
.config
.required_scopes
.tools
.get(tool_name)
.or(Some(&self.config.required_scopes.default))
.cloned()
.unwrap_or_default();
if !auth.has_any_scope(&required_scopes) {
anyhow::bail!("Insufficient scope for tool: {}", tool_name);
}
Ok(())
}
pub fn authorize_resource(&self, auth: &AuthContext, resource_uri: &str) -> Result<()> {
if !auth.authenticated {
anyhow::bail!("Authentication required");
}
let required_scopes = self
.config
.required_scopes
.resources
.get(resource_uri)
.or(Some(&self.config.required_scopes.default))
.cloned()
.unwrap_or_default();
if !auth.has_any_scope(&required_scopes) {
anyhow::bail!("Insufficient scope for resource: {}", resource_uri);
}
if self.config.validate_resource_indicators
&& !auth.has_resource_access(&self.server_resource_id)
{
anyhow::bail!("Token not authorized for this resource server");
}
Ok(())
}
fn hash_token(&self, token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
format!("{:x}", hasher.finalize())
}
async fn check_cache(&self, token_hash: &str) -> Option<CachedToken> {
let cache = self.token_cache.read().await;
cache.get(token_hash).and_then(|cached| {
let age = cached.validated_at.elapsed();
if age < Duration::from_secs(self.config.cache_ttl_seconds) {
Some(cached.clone())
} else {
None
}
})
}
async fn cache_token(&self, token_hash: String, token: AccessToken) {
let mut cache = self.token_cache.write().await;
cache.insert(
token_hash,
CachedToken {
token,
validated_at: Instant::now(),
validation_result: TokenValidation::Valid,
},
);
let now = Instant::now();
let ttl = Duration::from_secs(self.config.cache_ttl_seconds);
cache.retain(|_, v| now.duration_since(v.validated_at) < ttl);
}
fn context_from_token(&self, token: &AccessToken) -> AuthContext {
AuthContext {
authenticated: true,
client_id: Some(token.client_id.clone()),
scopes: token.scopes.clone(),
resource_indicators: token.resource_indicators.clone(),
}
}
}
#[derive(Debug, Deserialize)]
#[allow(missing_docs)] struct JwtHeader {
#[serde(default)]
alg: Option<String>,
#[serde(default)]
#[allow(dead_code)] typ: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(missing_docs)] struct TokenClaims {
#[serde(default)]
iss: Option<String>,
#[serde(default)]
#[allow(dead_code)] sub: Option<String>,
#[serde(default)]
aud: Option<String>,
#[serde(default)]
exp: Option<u64>,
#[serde(default)]
#[allow(dead_code)] iat: Option<u64>,
#[serde(default)]
scope: Option<String>,
#[serde(default)]
client_id: Option<String>,
#[serde(default)]
resource_indicators: Option<Vec<String>>,
}
impl Clone for CachedToken {
fn clone(&self) -> Self {
Self {
token: self.token.clone(),
validated_at: self.validated_at,
validation_result: match &self.validation_result {
TokenValidation::Valid => TokenValidation::Valid,
TokenValidation::Expired => TokenValidation::Expired,
TokenValidation::Invalid => TokenValidation::Invalid,
TokenValidation::InsufficientScope => TokenValidation::InsufficientScope,
TokenValidation::ResourceMismatch => TokenValidation::ResourceMismatch,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_context() {
let ctx = AuthContext {
authenticated: true,
client_id: Some("test-client".to_string()),
scopes: vec!["mcp:read".to_string(), "mcp:write".to_string()],
resource_indicators: vec!["kindlyguard".to_string()],
};
assert!(ctx.has_scope("mcp:read"));
assert!(ctx.has_scope("mcp:write"));
assert!(!ctx.has_scope("mcp:admin"));
assert!(ctx.has_any_scope(&["mcp:read".to_string()]));
assert!(ctx.has_any_scope(&["mcp:admin".to_string(), "mcp:write".to_string()]));
assert!(ctx.has_resource_access("kindlyguard"));
assert!(!ctx.has_resource_access("other-server"));
}
#[test]
fn test_unauthenticated_context() {
let ctx = AuthContext::unauthenticated();
assert!(!ctx.authenticated);
assert!(ctx.scopes.is_empty());
assert!(ctx.resource_indicators.is_empty());
}
#[test]
fn test_constant_time_comparison() {
assert!(AuthManager::constant_time_compare("secret123", "secret123"));
assert!(!AuthManager::constant_time_compare(
"secret123",
"secret124"
));
assert!(!AuthManager::constant_time_compare("secret", "secrets"));
assert!(!AuthManager::constant_time_compare("", "secret"));
assert!(!AuthManager::constant_time_compare("secret", ""));
assert!(AuthManager::constant_time_compare("", ""));
}
#[test]
fn test_secure_token_generation() {
let token1 = AuthManager::generate_secure_token(8);
let token2 = AuthManager::generate_secure_token(16);
let token3 = AuthManager::generate_secure_token(32);
assert!(token1.len() >= 21); assert!(token2.len() >= 21); assert!(token3.len() >= 42);
let token4 = AuthManager::generate_secure_token(32);
assert_ne!(token3, token4);
let session1 = AuthManager::generate_session_token();
let session2 = AuthManager::generate_session_token();
assert!(session1.len() >= 42); assert_ne!(session1, session2);
}
#[test]
fn test_api_key_generation() {
let key1 = AuthManager::generate_api_key();
let key2 = AuthManager::generate_api_key();
assert_eq!(key1.len(), 32);
assert_eq!(key2.len(), 32);
assert_ne!(key1, key2);
let has_upper = key1.chars().any(|c| c.is_uppercase());
let has_lower = key1.chars().any(|c| c.is_lowercase());
let has_digit = key1.chars().any(|c| c.is_numeric());
let has_symbol = key1.chars().any(|c| "!@#$%^&*-_=+".contains(c));
assert!(has_upper || has_lower || has_digit || has_symbol);
}
#[test]
fn test_token_entropy() {
let mut tokens = Vec::new();
for _ in 0..100 {
tokens.push(AuthManager::generate_secure_token(16));
}
let unique_count = tokens
.iter()
.collect::<std::collections::HashSet<_>>()
.len();
assert_eq!(unique_count, 100);
let all_chars: String = tokens.join("");
let char_freq = all_chars
.chars()
.fold(std::collections::HashMap::new(), |mut map, c| {
*map.entry(c).or_insert(0) += 1;
map
});
assert!(char_freq.len() >= 20);
}
}