mod access;
mod authorize;
mod cimd;
mod metadata;
mod register;
mod token;
pub(super) use access::{verify_access, AccessRefusal};
use axum::Json;
use axum::body::Bytes;
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header};
use axum::response::{IntoResponse, Response};
use serde_json::{Value, json};
use super::PlatformAppState;
use crate::platform::services::addressing::sections::{self, SectionTable};
use crate::platform::services::pipeline_runtime::McpTriggerSpec;
use crate::platform::web::ProjectHost;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::platform::web) enum Op {
Mcp,
Authorize,
Token,
Register,
ProtectedResource,
AuthorizationServer,
}
impl Op {
pub(super) fn word(self) -> Option<&'static str> {
Some(match self {
Op::Mcp => return None,
Op::Authorize => "authorize",
Op::Token => "token",
Op::Register => "register",
Op::ProtectedResource => "protected-resource",
Op::AuthorizationServer => "authorization-server",
})
}
pub(super) fn internal(self) -> bool {
matches!(self, Op::ProtectedResource | Op::AuthorizationServer)
}
}
pub(super) fn split_tail(tail: &str) -> (String, Op) {
let trimmed = tail.trim_end_matches('/');
let (route, word) = match trimmed.rsplit_once("_oauth/") {
Some((route, word)) if route.is_empty() || route.ends_with('/') => (route.trim_end_matches('/'), word),
_ => return (tail.to_string(), Op::Mcp),
};
let op = match word {
"authorize" => Op::Authorize,
"token" => Op::Token,
"register" => Op::Register,
"protected-resource" => Op::ProtectedResource,
"authorization-server" => Op::AuthorizationServer,
_ => return (tail.to_string(), Op::Mcp),
};
(route.to_string(), op)
}
pub fn well_known_split(path: &str) -> Option<(&'static str, &str)> {
for document in ["oauth-protected-resource", "oauth-authorization-server"] {
if let Some(inner) = path.strip_prefix("/.well-known/").and_then(|p| p.strip_prefix(document))
&& inner.starts_with('/')
&& inner.len() > 1
{
return Some((document, inner));
}
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(in crate::platform::web) struct Issuer {
origin: String,
path: String,
pages: String,
section: Option<(std::sync::Arc<SectionTable>, String)>,
}
impl Issuer {
pub(super) fn new(base: &str, route: &str, pages: &str) -> Option<Issuer> {
let url = reqwest::Url::parse(base).ok()?;
let host = url.host_str()?.to_ascii_lowercase();
let origin = match url.port() {
Some(port) => format!("{}://{host}:{port}", url.scheme()),
None => format!("{}://{host}", url.scheme()),
};
let mount = url.path().trim_end_matches('/');
let path = if route == "/" { mount.to_string() } else { format!("{mount}{route}") };
Some(Issuer { origin, path, pages: pages.trim_end_matches('/').to_string(), section: None })
}
pub(super) fn in_section(mut self, table: std::sync::Arc<SectionTable>, host: String) -> Issuer {
self.section = Some((table, host));
self
}
pub(super) fn resource(&self) -> String {
format!("{}{}", self.origin, self.path)
}
pub(super) fn endpoint(&self, name: &str) -> String {
format!("{}/_oauth/{name}", self.resource())
}
pub(super) fn protected_resource_url(&self) -> String {
format!("{}/.well-known/oauth-protected-resource{}", self.origin, self.path)
}
#[cfg(test)]
pub(super) fn authorization_server_url(&self) -> String {
format!("{}/.well-known/oauth-authorization-server{}", self.origin, self.path)
}
pub(super) fn login_url(&self, login: &str) -> Option<String> {
match &self.section {
None => Some(format!("{}{login}", self.pages)),
Some((table, host)) => {
let path = sections::pages_url(table, host, login)?;
if path.starts_with("http://") || path.starts_with("https://") {
return Some(path);
}
Some(format!("{}{path}", self.pages))
}
}
}
}
pub(super) fn arrival_base(
owner: &str,
project: &str,
host: Option<&ProjectHost>,
headers: &HeaderMap,
platform_base: Option<&str>,
) -> Option<(String, String)> {
if let Some(project_host) = host
&& let Some(mount) = &project_host.mount
{
let origin = if project_host.dev_host {
let port = headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.and_then(|h| h.rsplit_once(':'))
.map(|(_, port)| port)
.filter(|port| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()));
match port {
Some(port) => format!("http://{}:{port}", project_host.host),
None => format!("http://{}", project_host.host),
}
} else {
format!("https://{}", project_host.host)
};
return Some((format!("{origin}{mount}"), origin));
}
let base = platform_base?.trim().trim_end_matches('/');
if base.is_empty() {
return None;
}
Some((format!("{base}/mcp/{owner}/{project}"), format!("{base}/wh/{owner}/{project}")))
}
pub(super) fn platform_base() -> Option<String> {
std::env::var("ZEBFLOW_PLATFORM_BASE_URL").ok()
}
pub(super) struct Ctx<'a> {
pub state: &'a PlatformAppState,
pub owner: &'a str,
pub project: &'a str,
pub route: &'a str,
pub spec: &'a McpTriggerSpec,
pub issuer: &'a Issuer,
pub limit: Option<&'a str>,
}
impl Ctx<'_> {
pub(super) fn refused(&self) {
if let Some(key) = self.limit {
self.state.mcp_failures.fail(key);
}
}
}
pub(super) async fn dispatch(ctx: Ctx<'_>, op: Op, method: &Method, uri: &Uri, headers: &HeaderMap, body: &Bytes) -> Response {
if method == Method::OPTIONS && op != Op::Authorize {
return preflight();
}
match (op, method.as_str()) {
(Op::ProtectedResource, "GET") => metadata::protected_resource(&ctx),
(Op::AuthorizationServer, "GET") => metadata::authorization_server(&ctx),
(Op::Register, "POST") => register::register(&ctx, body),
(Op::Authorize, "GET") => authorize::authorize(&ctx, uri.query().unwrap_or_default()).await,
(Op::Token, "POST") => token::token(&ctx, headers, body),
_ => oauth_error(StatusCode::METHOD_NOT_ALLOWED, "invalid_request", "this endpoint does not answer that method"),
}
}
fn with_common_headers(mut response: Response) -> Response {
let headers = response.headers_mut();
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
response
}
pub(super) fn json_answer(status: StatusCode, value: Value) -> Response {
with_common_headers((status, Json(value)).into_response())
}
pub(super) fn oauth_error(status: StatusCode, error: &str, description: &str) -> Response {
json_answer(status, json!({ "error": error, "error_description": description }))
}
fn preflight() -> Response {
let mut response = with_common_headers(StatusCode::NO_CONTENT.into_response());
let headers = response.headers_mut();
headers.insert(header::ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("GET, POST, OPTIONS"));
headers.insert(
header::ACCESS_CONTROL_ALLOW_HEADERS,
HeaderValue::from_static("authorization, content-type, mcp-protocol-version"),
);
headers.insert(header::ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("600"));
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_tail_names_its_operation_and_a_route_keeps_its_path() {
assert_eq!(split_tail("library"), ("library".to_string(), Op::Mcp));
assert_eq!(split_tail("library/_oauth/token"), ("library".to_string(), Op::Token));
assert_eq!(split_tail("shelf/library/_oauth/authorize"), ("shelf/library".to_string(), Op::Authorize));
assert_eq!(split_tail("_oauth/register"), (String::new(), Op::Register));
assert_eq!(split_tail("library/_oauth/other"), ("library/_oauth/other".to_string(), Op::Mcp));
assert_eq!(split_tail("library_oauth/token"), ("library_oauth/token".to_string(), Op::Mcp));
}
#[test]
fn the_documents_sit_at_the_path_inserted_well_known_uris() {
let issuer = Issuer::new("http://Library.Site-A.localhost:10610/_mcp", "/library", "http://library.site-a.localhost:10610").unwrap();
assert_eq!(issuer.resource(), "http://library.site-a.localhost:10610/_mcp/library");
assert_eq!(issuer.protected_resource_url(), "http://library.site-a.localhost:10610/.well-known/oauth-protected-resource/_mcp/library");
assert_eq!(issuer.authorization_server_url(), "http://library.site-a.localhost:10610/.well-known/oauth-authorization-server/_mcp/library");
assert_eq!(issuer.endpoint("token"), "http://library.site-a.localhost:10610/_mcp/library/_oauth/token");
assert_eq!(issuer.login_url("/auth/login").as_deref(), Some("http://library.site-a.localhost:10610/auth/login"));
let root = Issuer::new("https://library.example/_mcp", "/", "https://library.example").unwrap();
assert_eq!(root.resource(), "https://library.example/_mcp");
assert_eq!(well_known_split("/.well-known/oauth-protected-resource/_mcp/library"), Some(("oauth-protected-resource", "/_mcp/library")));
assert_eq!(well_known_split("/.well-known/oauth-protected-resource"), None);
assert_eq!(well_known_split("/.well-known/oauth-protected-resourcex/a"), None);
}
#[test]
fn a_named_host_is_https_and_the_dev_host_keeps_its_port() {
let named = ProjectHost { host: "library.example".into(), owner: "site-a".into(), project: "demo".into(), dev_host: false, mount: Some("/_mcp".into()), root: None };
let mut headers = HeaderMap::new();
headers.insert(header::HOST, HeaderValue::from_static("library.example:8080"));
headers.insert("x-forwarded-proto", HeaderValue::from_static("http"));
assert_eq!(arrival_base("site-a", "demo", Some(&named), &headers, None).unwrap().0, "https://library.example/_mcp", "a forwarded header changes nothing");
let dev = ProjectHost { host: "demo.site-a.localhost".into(), dev_host: true, ..named };
headers.insert(header::HOST, HeaderValue::from_static("demo.site-a.localhost:10610"));
assert_eq!(arrival_base("site-a", "demo", Some(&dev), &headers, None).unwrap(), ("http://demo.site-a.localhost:10610/_mcp".into(), "http://demo.site-a.localhost:10610".into()));
assert_eq!(arrival_base("site-a", "demo", None, &headers, None), None);
let platform = arrival_base("site-a", "demo", None, &headers, Some("https://zebflow.example/")).unwrap();
assert_eq!(platform, ("https://zebflow.example/mcp/site-a/demo".into(), "https://zebflow.example/wh/site-a/demo".into()));
let issuer = Issuer::new(&platform.0, "/library", &platform.1).unwrap();
assert_eq!(issuer.resource(), "https://zebflow.example/mcp/site-a/demo/library");
assert_eq!(issuer.protected_resource_url(), "https://zebflow.example/.well-known/oauth-protected-resource/mcp/site-a/demo/library");
assert_eq!(issuer.login_url("/auth/login").as_deref(), Some("https://zebflow.example/wh/site-a/demo/auth/login"));
}
}