use std::fmt;
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::pin::Pin;
use std::sync::Arc;
use turbomcp_core::auth::Principal;
#[derive(Debug, Clone)]
pub enum BearerRejection {
InvalidToken(String),
InsufficientScope {
required: Vec<String>,
},
}
impl fmt::Display for BearerRejection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidToken(reason) => write!(f, "invalid token: {reason}"),
Self::InsufficientScope { required } => {
write!(f, "insufficient scope: requires {}", required.join(" "))
}
}
}
}
pub type ValidationFuture<'a> =
Pin<Box<dyn Future<Output = Result<Principal, BearerRejection>> + Send + 'a>>;
pub trait BearerTokenValidator: Send + Sync + 'static {
fn validate<'a>(&'a self, token: &'a str) -> ValidationFuture<'a>;
}
pub struct HttpAuthorization {
resource: String,
authorization_servers: Vec<String>,
scopes_supported: Vec<String>,
validator: AssertUnwindSafe<Arc<dyn BearerTokenValidator>>,
}
impl Clone for HttpAuthorization {
fn clone(&self) -> Self {
Self {
resource: self.resource.clone(),
authorization_servers: self.authorization_servers.clone(),
scopes_supported: self.scopes_supported.clone(),
validator: AssertUnwindSafe(Arc::clone(&self.validator)),
}
}
}
impl fmt::Debug for HttpAuthorization {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HttpAuthorization")
.field("resource", &self.resource)
.field("authorization_servers", &self.authorization_servers)
.field("scopes_supported", &self.scopes_supported)
.finish_non_exhaustive()
}
}
impl HttpAuthorization {
pub fn new(
resource: impl Into<String>,
authorization_server: impl Into<String>,
validator: impl BearerTokenValidator,
) -> Self {
Self {
resource: resource.into(),
authorization_servers: vec![authorization_server.into()],
scopes_supported: Vec::new(),
validator: AssertUnwindSafe(Arc::new(validator)),
}
}
#[must_use]
pub fn with_authorization_server(mut self, authorization_server: impl Into<String>) -> Self {
self.authorization_servers.push(authorization_server.into());
self
}
#[must_use]
pub fn with_scopes_supported<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.scopes_supported = scopes.into_iter().map(Into::into).collect();
self
}
pub fn resource(&self) -> &str {
&self.resource
}
pub(crate) fn metadata_path(&self) -> String {
let path = self
.resource
.parse::<http::Uri>()
.map(|uri| uri.path().trim_end_matches('/').to_owned())
.unwrap_or_default();
format!("/.well-known/oauth-protected-resource{path}")
}
pub(crate) fn metadata_url(&self) -> String {
let origin = self
.resource
.parse::<http::Uri>()
.ok()
.and_then(|uri| {
Some(format!(
"{}://{}",
uri.scheme_str()?,
uri.authority()?.as_str()
))
})
.unwrap_or_default();
format!("{origin}{}", self.metadata_path())
}
pub(crate) fn metadata_document(&self) -> serde_json::Value {
let mut document = serde_json::json!({
"resource": self.resource,
"authorization_servers": self.authorization_servers,
"bearer_methods_supported": ["header"],
});
if !self.scopes_supported.is_empty() {
document["scopes_supported"] = serde_json::json!(self.scopes_supported);
}
document
}
pub(crate) async fn authorize(
&self,
authorization: Option<&str>,
) -> Result<Principal, BearerRejection> {
let token = authorization
.and_then(|value| {
let (scheme, token) = value.split_once(' ')?;
scheme.eq_ignore_ascii_case("bearer").then(|| token.trim())
})
.filter(|token| !token.is_empty())
.ok_or_else(|| BearerRejection::InvalidToken("no bearer token".into()))?;
self.validator.validate(token).await
}
pub(crate) fn challenge(&self, rejection: &BearerRejection, had_credentials: bool) -> String {
let mut challenge = format!(
"Bearer resource_metadata=\"{}\"",
quoted(&self.metadata_url())
);
match rejection {
BearerRejection::InvalidToken(reason) => {
if !self.scopes_supported.is_empty() {
challenge.push_str(&format!(
", scope=\"{}\"",
quoted(&self.scopes_supported.join(" "))
));
}
if had_credentials {
challenge.push_str(&format!(
", error=\"invalid_token\", error_description=\"{}\"",
quoted(reason)
));
}
}
BearerRejection::InsufficientScope { required } => {
challenge.push_str(&format!(
", error=\"insufficient_scope\", scope=\"{}\"",
quoted(&required.join(" "))
));
}
}
challenge
}
}
fn quoted(value: &str) -> String {
value
.chars()
.filter(|c| !c.is_control())
.flat_map(|c| match c {
'"' | '\\' => vec!['\\', c],
c => vec![c],
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
struct Fixed(Result<Principal, BearerRejection>);
impl BearerTokenValidator for Fixed {
fn validate<'a>(&'a self, _token: &'a str) -> ValidationFuture<'a> {
let outcome = self.0.clone();
Box::pin(async move { outcome })
}
}
fn auth(resource: &str) -> HttpAuthorization {
HttpAuthorization::new(
resource,
"https://auth.example.com",
Fixed(Ok(Principal::new("u"))),
)
}
#[test]
fn server_config_stays_unwind_safe() {
fn unwind_safe<T: std::panic::UnwindSafe + std::panic::RefUnwindSafe>() {}
unwind_safe::<HttpAuthorization>();
unwind_safe::<crate::ServerConfig>();
}
#[test]
fn metadata_is_path_inserted_for_a_server_with_a_path() {
let auth = auth("https://mcp.example.com/mcp");
assert_eq!(
auth.metadata_path(),
"/.well-known/oauth-protected-resource/mcp"
);
assert_eq!(
auth.metadata_url(),
"https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
);
let root = super::HttpAuthorization::new(
"https://mcp.example.com",
"https://auth.example.com",
Fixed(Ok(Principal::new("u"))),
);
assert_eq!(
root.metadata_path(),
"/.well-known/oauth-protected-resource"
);
}
#[test]
fn the_metadata_document_follows_rfc_9728() {
let document = auth("https://mcp.example.com/mcp")
.with_scopes_supported(["files:read"])
.metadata_document();
assert_eq!(document["resource"], "https://mcp.example.com/mcp");
assert_eq!(
document["authorization_servers"],
serde_json::json!(["https://auth.example.com"])
);
assert_eq!(document["scopes_supported"][0], "files:read");
}
#[tokio::test]
async fn only_the_authorization_header_carries_a_token() {
let auth = auth("https://mcp.example.com/mcp");
assert!(auth.authorize(None).await.is_err());
assert!(auth.authorize(Some("Basic abc")).await.is_err());
assert!(auth.authorize(Some("Bearer ")).await.is_err());
assert!(auth.authorize(Some("bearer abc")).await.is_ok());
}
#[test]
fn challenges_name_the_metadata_and_escape_their_values() {
let auth = auth("https://mcp.example.com/mcp");
let missing = auth.challenge(&BearerRejection::InvalidToken("none".into()), false);
assert_eq!(
missing,
"Bearer resource_metadata=\"https://mcp.example.com/.well-known/oauth-protected-resource/mcp\""
);
let invalid = auth.challenge(&BearerRejection::InvalidToken("bad \"sig\"\n".into()), true);
assert!(invalid.contains("error=\"invalid_token\""), "{invalid}");
assert!(invalid.contains("bad \\\"sig\\\""), "{invalid}");
assert!(!invalid.contains('\n'), "{invalid}");
let scope = auth.challenge(
&BearerRejection::InsufficientScope {
required: vec!["files:write".into()],
},
true,
);
assert!(scope.contains("error=\"insufficient_scope\""), "{scope}");
assert!(scope.contains("scope=\"files:write\""), "{scope}");
}
}