use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use axum::Json;
use axum::body::Bytes;
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde_json::{Value, json};
use super::PublishedTool;
use super::oauth::{AccessRefusal, Issuer, verify_access};
use crate::pipeline::nodes::basic::trigger::mcp_trigger;
use crate::platform::web::{AuthError, PlatformAppState, token_roles, verify_webhook_auth};
pub const MAX_FAILURES: u32 = 20;
pub const WINDOW: Duration = Duration::from_secs(60);
const SWEEP_AT: usize = 10_000;
#[derive(Default)]
pub struct FailureLimiter {
entries: Mutex<HashMap<String, (Instant, u32)>>,
}
impl FailureLimiter {
pub(super) fn blocked(&self, key: &str) -> Option<Duration> {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let (start, count) = entries.get(key)?;
let elapsed = start.elapsed();
(*count >= MAX_FAILURES && elapsed < WINDOW).then(|| WINDOW - elapsed)
}
pub(super) fn fail(&self, key: &str) {
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if entries.len() >= SWEEP_AT {
entries.retain(|_, (start, _)| start.elapsed() < WINDOW);
}
let entry = entries.entry(key.to_string()).or_insert((Instant::now(), 0));
if entry.0.elapsed() >= WINDOW {
*entry = (Instant::now(), 0);
}
entry.1 += 1;
}
}
pub(super) fn limit_key(owner: &str, project: &str, route: &str, client: &str) -> String {
format!("{owner}/{project}{route}|{client}")
}
pub(super) fn client_address(peer: Option<SocketAddr>, headers: &HeaderMap) -> String {
let proxied = peer.is_none_or(|p| is_proxy_address(p.ip()));
let forwarded = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.rsplit(',').next())
.and_then(|v| v.trim().parse::<IpAddr>().ok());
match (proxied, forwarded, peer) {
(true, Some(ip), _) => ip.to_string(),
(_, _, Some(peer)) => peer.ip().to_string(),
_ => "local".to_string(),
}
}
fn is_proxy_address(ip: IpAddr) -> bool {
match ip.to_canonical() {
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
IpAddr::V6(v6) => {
let first = v6.segments()[0];
v6.is_loopback() || (first & 0xfe00) == 0xfc00 || (first & 0xffc0) == 0xfe80
}
}
}
fn app_credentials(headers: &HeaderMap) -> HeaderMap {
let mut kept = HeaderMap::new();
for name in [header::AUTHORIZATION, header::HeaderName::from_static("x-api-key")] {
if let Some(value) = headers.get(&name) {
kept.insert(name, value.clone());
}
}
kept
}
fn uniform(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(json!({ "ok": false, "error": { "code": code, "message": message } }))).into_response()
}
fn unauthenticated() -> Response {
uniform(StatusCode::UNAUTHORIZED, "unauthenticated", "a valid credential for this MCP server is required")
}
fn forbidden() -> Response {
uniform(StatusCode::FORBIDDEN, "forbidden", "this credential may not use this MCP server")
}
pub(super) fn too_many(wait: Duration) -> Response {
let mut response = uniform(StatusCode::TOO_MANY_REQUESTS, "too_many_failures", "too many refused attempts; wait before trying again");
if let Ok(value) = HeaderValue::from_str(&wait.as_secs().max(1).to_string()) {
response.headers_mut().insert(header::RETRY_AFTER, value);
}
response
}
pub(super) struct Door<'a> {
pub state: &'a PlatformAppState,
pub owner: &'a str,
pub project: &'a str,
pub route: &'a str,
pub tools: &'a [PublishedTool],
pub headers: &'a HeaderMap,
pub body: &'a Bytes,
pub issuer: Option<&'a Issuer>,
pub limit: Option<&'a str>,
}
impl Door<'_> {
pub(super) fn check(&self) -> Result<(), Response> {
let (auth_type, credential, roles, _) = self.tools[0].spec.guard();
if auth_type == "none" {
return Ok(());
}
let verified = if auth_type == "oauth" {
self.oauth(&credential, &roles)
} else {
verify_webhook_auth(
&app_credentials(self.headers),
self.body,
&auth_type,
&credential,
&roles,
&self.state.platform.credentials,
self.owner,
self.project,
)
};
let (response, reason, counted) = match verified {
Ok(_) => return Ok(()),
Err(AuthError::Unauthenticated { message, .. }) => (self.challenge(unauthenticated()), message, true),
Err(AuthError::Forbidden { message, .. }) => (forbidden(), message, true),
Err(AuthError::Internal(message)) => (
uniform(StatusCode::INTERNAL_SERVER_ERROR, "internal", "this MCP server cannot check credentials; its owner can see why"),
message,
false,
),
};
if counted && let Some(key) = self.limit {
self.state.mcp_failures.fail(key);
}
self.record(&reason);
Err(response)
}
fn oauth(&self, credential: &str, roles: &[String]) -> Result<Option<Value>, AuthError> {
let Some(issuer) = self.issuer else {
return Err(AuthError::Internal("this oauth route cannot name its own address on this arrival".to_string()));
};
let token = self
.headers
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
.map(str::trim)
.filter(|t| !t.is_empty())
.ok_or_else(|| AuthError::Unauthenticated { message: "missing Authorization: Bearer <access token>".to_string(), redirect_url: None })?;
let stored = self
.state
.platform
.credentials
.get_project_credential(self.owner, self.project, credential)
.map_err(|e| AuthError::Internal(e.message))?
.filter(|c| c.kind == "jwt_signing_key")
.ok_or_else(|| AuthError::Internal(format!("--credential '{credential}' is not a jwt_signing_key of this project")))?;
let claims = verify_access(&stored.secret, token, &issuer.resource()).map_err(|refusal| match refusal {
AccessRefusal::Token(message) => AuthError::Unauthenticated { message, redirect_url: None },
AccessRefusal::Key(message) => AuthError::Internal(message),
})?;
if !roles.is_empty() {
let held = token_roles(claims.get("roles"));
if !roles.iter().any(|r| held.contains(r)) {
return Err(AuthError::Forbidden { message: format!("roles {held:?} are not permitted for this route"), redirect_url: None });
}
}
Ok(Some(claims))
}
fn challenge(&self, mut response: Response) -> Response {
let Some(issuer) = self.issuer.filter(|_| self.tools[0].spec.auth_type == "oauth") else {
return response;
};
let presented = self.headers.get(header::AUTHORIZATION).is_some();
let mut value = format!("Bearer resource_metadata=\"{}\", scope=\"mcp\"", issuer.protected_resource_url());
if presented {
value.push_str(", error=\"invalid_token\"");
}
if let Ok(value) = HeaderValue::from_str(&value) {
response.headers_mut().insert(header::WWW_AUTHENTICATE, value);
}
response
}
fn record(&self, reason: &str) {
let wanted = serde_json::from_slice::<Value>(self.body)
.ok()
.filter(|m| m["method"] == "tools/call")
.and_then(|m| m["params"]["name"].as_str().map(str::to_string));
let tool = wanted
.and_then(|name| self.tools.iter().find(|t| t.spec.tool_name == name))
.unwrap_or(&self.tools[0]);
let platform = &self.state.platform;
let file = &tool.compiled.file_rel_path;
let message = format!("refused at the door of MCP route {}: {reason}", self.route);
let entry = crate::platform::model::PipelineInvocationEntry {
run_id: uuid::Uuid::new_v4().simple().to_string(),
at: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64,
duration_ms: 0,
status: "error".to_string(),
trigger: "mcp".to_string(),
error: Some(message.clone()),
trace: vec![crate::pipeline::model::NodeTraceEntry {
node_id: tool.spec.node_id.clone(),
node_kind: mcp_trigger::NODE_KIND.to_string(),
config: None,
duration_ms: 0,
input: Value::Null,
output: Value::Null,
error: Some(message.clone()),
status: "refused".to_string(),
error_code: Some(mcp_trigger::AUTH_CODE.to_string()),
preview_snapshot: None,
}],
};
let cfg = platform.zebflow_cfg.read_or_default(self.owner, self.project).unwrap_or_default();
let retention = crate::platform::model::resolve_invocation_retention(&cfg, Some(&tool.compiled.graph));
platform.pipeline_hits.record_failure(self.owner, self.project, file, "mcp.door", mcp_trigger::AUTH_CODE, &message);
let _ = platform.data.log_pipeline_invocation(self.owner, self.project, file, &entry, retention.max_invocations, retention.max_age_secs);
let bounds = cfg.configs.pipelines.logging.error_group_bounds();
if let Err(e) = platform.data.record_pipeline_error(self.owner, self.project, file, &entry, bounds) {
eprintln!("warning: error group not recorded for {}/{}: {}", self.owner, self.project, e.message);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_client_runs_out_of_refusals_and_waits_out_the_window() {
let limiter = FailureLimiter::default();
for _ in 0..MAX_FAILURES {
assert!(limiter.blocked("r|a").is_none());
limiter.fail("r|a");
}
assert!(limiter.blocked("r|a").is_some(), "out of refusals");
assert!(limiter.blocked("r|b").is_none(), "another address is another count");
limiter.entries.lock().unwrap().get_mut("r|a").unwrap().0 = Instant::now() - WINDOW;
assert!(limiter.blocked("r|a").is_none());
limiter.fail("r|a");
assert_eq!(limiter.entries.lock().unwrap()["r|a"].1, 1);
}
#[test]
fn only_a_proxy_on_this_offices_network_names_the_client() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("198.51.100.7, 203.0.113.9"));
let remote: SocketAddr = "192.0.2.4:40000".parse().unwrap();
for proxy in ["127.0.0.1:40000", "10.0.0.7:40000", "172.20.1.2:40000", "192.168.1.5:40000", "169.254.3.4:40000", "[fd00::5]:40000", "[fe80::1]:40000", "[::1]:40000", "[::ffff:10.0.0.9]:40000"] {
let peer: SocketAddr = proxy.parse().unwrap();
assert_eq!(client_address(Some(peer), &headers), "203.0.113.9", "behind {proxy}");
}
let mut other = HeaderMap::new();
other.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.10"));
let ingress: SocketAddr = "10.0.0.7:40000".parse().unwrap();
assert_ne!(client_address(Some(ingress), &headers), client_address(Some(ingress), &other), "one guesser does not lock out the next client");
assert_eq!(client_address(Some(remote), &headers), "192.0.2.4", "a remote peer cannot name another address");
for public in ["172.32.0.1:40000", "11.0.0.1:40000", "[2001:db8::1]:40000"] {
let peer: SocketAddr = public.parse().unwrap();
assert_eq!(client_address(Some(peer), &headers), peer.ip().to_string(), "{public} is not a proxy");
}
assert_eq!(client_address(Some(remote), &HeaderMap::new()), "192.0.2.4");
assert_eq!(client_address(Some(ingress), &HeaderMap::new()), "10.0.0.7", "a proxy that adds no header is the client");
assert_eq!(client_address(None, &HeaderMap::new()), "local");
}
#[test]
fn the_verifier_never_sees_a_cookie() {
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, HeaderValue::from_static("zebflow_session=s"));
headers.insert("x-api-key", HeaderValue::from_static("k"));
headers.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer t"));
headers.insert("x-zebflow-mcp-session", HeaderValue::from_static("d"));
let kept = app_credentials(&headers);
assert!(kept.get(header::COOKIE).is_none());
assert_eq!(kept.len(), 2);
}
#[test]
fn missing_wrong_and_expired_answer_alike() {
let a = unauthenticated();
let b = unauthenticated();
assert_eq!(a.status(), StatusCode::UNAUTHORIZED);
assert_eq!(a.headers(), b.headers());
}
}