use std::convert::Infallible;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use bytes::Bytes;
use http::header::{self, HeaderMap, HeaderValue};
use http::{Method, Request, Response, StatusCode};
use serde_json::{Map, Value, json};
use super::http::HttpBody;
use crate::auth::{AuthContext, ScopeImplies, TokenVerifier};
const WELL_KNOWN: &str = "/.well-known/oauth-protected-resource";
const MAX_TOKEN_BYTES: usize = 16 * 1024;
const DEFAULT_VERIFY_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone)]
pub struct OAuth {
inner: Arc<Inner>,
}
struct Inner {
resource: String,
metadata_url: String,
metadata_paths: Vec<String>,
metadata_body: Bytes,
challenge_scope: Option<String>,
required_scopes: Vec<String>,
verifier: Box<dyn TokenVerifier>,
implies: Option<ScopeImplies>,
verify_timeout: Duration,
}
impl fmt::Debug for OAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OAuth")
.field("resource", &self.inner.resource)
.field("metadata_url", &self.inner.metadata_url)
.field("required_scopes", &self.inner.required_scopes)
.finish_non_exhaustive()
}
}
impl OAuth {
#[must_use]
pub fn builder(resource: impl Into<String>, verifier: impl TokenVerifier) -> OAuthBuilder {
OAuthBuilder {
resource: resource.into(),
verifier: Box::new(verifier),
authorization_servers: Vec::new(),
scopes_supported: Vec::new(),
required_scopes: Vec::new(),
implies: None,
resource_name: None,
verify_timeout: DEFAULT_VERIFY_TIMEOUT,
}
}
#[must_use]
pub fn resource(&self) -> &str {
&self.inner.resource
}
#[must_use]
pub fn metadata_url(&self) -> &str {
&self.inner.metadata_url
}
#[must_use]
pub fn metadata_paths(&self) -> &[String] {
&self.inner.metadata_paths
}
#[must_use]
pub fn metadata_service(&self) -> ProtectedResourceMetadata {
ProtectedResourceMetadata {
body: self.inner.metadata_body.clone(),
}
}
pub(crate) async fn authenticate(&self, headers: &HeaderMap) -> Result<AuthContext, Challenge> {
let mut values = headers.get_all(header::AUTHORIZATION).iter();
let Some(value) = values.next() else {
return Err(Challenge::Missing);
};
if values.next().is_some() {
return Err(Challenge::Malformed);
}
let Some((scheme, token)) = value.to_str().ok().and_then(|v| v.split_once(' ')) else {
return Err(Challenge::Missing);
};
if !scheme.eq_ignore_ascii_case("bearer") {
return Err(Challenge::Missing);
}
let token = token.trim_matches(' ');
if token.is_empty() || token.len() > MAX_TOKEN_BYTES || !is_b64token(token) {
return Err(Challenge::InvalidToken);
}
let verified =
tokio::time::timeout(self.inner.verify_timeout, self.inner.verifier.verify(token))
.await;
let principal = match verified {
Ok(Ok(principal)) => principal,
Ok(Err(error)) => {
tracing::debug!(reason = error.reason(), "rejected an access token");
return Err(Challenge::InvalidToken);
}
Err(_elapsed) => {
tracing::warn!(
"token verification took longer than {:?}\n = help: the verifier may be \
waiting on the authorization server; raise OAuthBuilder::verify_timeout(..) \
or check its availability",
self.inner.verify_timeout
);
return Err(Challenge::Unavailable);
}
};
let auth = AuthContext::new(principal, self.inner.implies);
let missing = auth.missing(&self.inner.required_scopes);
if !missing.is_empty() {
tracing::debug!(
?missing,
"an access token lacks the server's required scopes"
);
return Err(Challenge::InsufficientScope(
self.inner.required_scopes.join(" "),
));
}
Ok(auth)
}
pub(crate) fn www_authenticate(&self, challenge: &Challenge) -> Option<HeaderValue> {
let mut value = format!("Bearer resource_metadata=\"{}\"", self.inner.metadata_url);
match challenge {
Challenge::Missing | Challenge::Malformed | Challenge::Unavailable => {
if let Some(scope) = &self.inner.challenge_scope {
value.push_str(&format!(", scope=\"{scope}\""));
}
if matches!(challenge, Challenge::Malformed) {
value.push_str(", error=\"invalid_request\"");
}
}
Challenge::InvalidToken => {
if let Some(scope) = &self.inner.challenge_scope {
value.push_str(&format!(", scope=\"{scope}\""));
}
value.push_str(", error=\"invalid_token\"");
}
Challenge::InsufficientScope(scope) => {
value.push_str(&format!(
", scope=\"{scope}\", error=\"insufficient_scope\""
));
}
}
HeaderValue::from_str(&value).ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Challenge {
Missing,
Malformed,
InvalidToken,
InsufficientScope(String),
Unavailable,
}
impl Challenge {
pub(crate) fn status(&self) -> StatusCode {
match self {
Self::Missing | Self::InvalidToken => StatusCode::UNAUTHORIZED,
Self::Malformed => StatusCode::BAD_REQUEST,
Self::InsufficientScope(_) => StatusCode::FORBIDDEN,
Self::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
}
}
pub(crate) fn message(&self) -> &'static str {
match self {
Self::Missing => "Authorization required",
Self::Malformed => "Malformed Authorization header",
Self::InvalidToken => "Invalid or expired access token",
Self::InsufficientScope(_) => "Insufficient scope",
Self::Unavailable => "Authorization is temporarily unavailable; retry later.",
}
}
}
pub struct OAuthBuilder {
resource: String,
verifier: Box<dyn TokenVerifier>,
authorization_servers: Vec<String>,
scopes_supported: Vec<String>,
required_scopes: Vec<String>,
implies: Option<ScopeImplies>,
resource_name: Option<String>,
verify_timeout: Duration,
}
impl fmt::Debug for OAuthBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OAuthBuilder")
.field("resource", &self.resource)
.field("authorization_servers", &self.authorization_servers)
.finish_non_exhaustive()
}
}
impl OAuthBuilder {
#[must_use]
pub fn authorization_server(mut self, issuer: impl Into<String>) -> Self {
self.authorization_servers.push(issuer.into());
self
}
#[must_use]
pub fn 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
}
#[must_use]
pub fn required_scopes<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.required_scopes = scopes.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn scope_implies(mut self, implies: ScopeImplies) -> Self {
self.implies = Some(implies);
self
}
#[must_use]
pub fn resource_name(mut self, name: impl Into<String>) -> Self {
self.resource_name = Some(name.into());
self
}
#[must_use]
pub fn verify_timeout(mut self, timeout: Duration) -> Self {
self.verify_timeout = timeout;
self
}
pub fn build(self) -> Result<OAuth, OAuthError> {
let resource =
canonical_url(&self.resource).map_err(|reason| OAuthError::InvalidResource {
resource: self.resource.clone(),
reason,
})?;
if self.authorization_servers.is_empty() {
return Err(OAuthError::NoAuthorizationServer);
}
for issuer in &self.authorization_servers {
canonical_url(issuer).map_err(|reason| OAuthError::InvalidAuthorizationServer {
issuer: issuer.clone(),
reason,
})?;
}
for scope in self.scopes_supported.iter().chain(&self.required_scopes) {
if !crate::auth::is_valid_scope(scope) {
return Err(OAuthError::InvalidScope(scope.clone()));
}
if scope == "offline_access" {
return Err(OAuthError::OfflineAccess);
}
}
if self.verify_timeout.is_zero() {
return Err(OAuthError::InvalidTimeout);
}
let (origin, path) = split_origin(&resource);
let path = path.trim_end_matches('/');
let metadata_url = format!("{origin}{WELL_KNOWN}{path}");
let mut metadata_paths = vec![format!("{WELL_KNOWN}{path}")];
if !path.is_empty() {
metadata_paths.push(WELL_KNOWN.to_owned());
}
let mut metadata = Map::new();
metadata.insert("resource".to_owned(), Value::from(resource.clone()));
metadata.insert(
"authorization_servers".to_owned(),
json!(self.authorization_servers),
);
if !self.scopes_supported.is_empty() {
metadata.insert("scopes_supported".to_owned(), json!(self.scopes_supported));
}
metadata.insert("bearer_methods_supported".to_owned(), json!(["header"]));
if let Some(name) = self.resource_name {
metadata.insert("resource_name".to_owned(), Value::from(name));
}
let metadata_body = serde_json::to_vec(&Value::Object(metadata))
.map_err(|_| OAuthError::InvalidResource {
resource: resource.clone(),
reason: "could not serialize the metadata".to_owned(),
})?
.into();
let challenge = if self.required_scopes.is_empty() {
&self.scopes_supported
} else {
&self.required_scopes
};
let challenge_scope = (!challenge.is_empty()).then(|| challenge.join(" "));
Ok(OAuth {
inner: Arc::new(Inner {
resource,
metadata_url,
metadata_paths,
metadata_body,
challenge_scope,
required_scopes: self.required_scopes,
verifier: self.verifier,
implies: self.implies,
verify_timeout: self.verify_timeout,
}),
})
}
}
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum OAuthError {
#[error("invalid resource `{resource}`: {reason}")]
InvalidResource {
resource: String,
reason: String,
},
#[error("invalid authorization server `{issuer}`: {reason}")]
InvalidAuthorizationServer {
issuer: String,
reason: String,
},
#[error("no authorization server")]
NoAuthorizationServer,
#[error("invalid scope `{0}`")]
InvalidScope(String),
#[error("`offline_access` is not a resource scope")]
OfflineAccess,
#[error("the token verification timeout must be positive")]
InvalidTimeout,
}
impl OAuthError {
#[must_use]
pub fn help(&self) -> &'static str {
match self {
Self::InvalidResource { .. } => {
"use the URL clients connect to, such as `https://mcp.example.com/mcp`: https \
(http only on localhost), no fragment, no user name"
}
Self::InvalidAuthorizationServer { .. } => {
"use the issuer URL of the authorization server, such as \
`https://auth.example.com` (https; http only on localhost)"
}
Self::NoAuthorizationServer => {
"add the issuer that grants tokens for this server with \
`.authorization_server(\"https://auth.example.com\")`"
}
Self::InvalidScope(_) => {
"scopes are non-empty and use visible ASCII except `\"` and `\\`, without spaces"
}
Self::OfflineAccess => {
"refresh tokens are the client's concern: remove `offline_access` (the \
specification says servers should not list it)"
}
Self::InvalidTimeout => "use a timeout such as `Duration::from_secs(10)`",
}
}
}
impl fmt::Debug for OAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self}\n = help: {}", self.help())
}
}
#[derive(Clone, Debug)]
pub struct ProtectedResourceMetadata {
body: Bytes,
}
impl ProtectedResourceMetadata {
pub(crate) fn respond(&self, method: &Method) -> Response<HttpBody> {
let mut response = match *method {
Method::GET | Method::HEAD => {
let mut response = Response::new(HttpBody::full(self.body.clone()));
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=3600"),
);
response
}
Method::OPTIONS => {
let mut response = Response::new(HttpBody::full(Bytes::new()));
*response.status_mut() = StatusCode::NO_CONTENT;
response.headers_mut().insert(
header::ACCESS_CONTROL_ALLOW_METHODS,
HeaderValue::from_static("GET"),
);
response
}
_ => {
let mut response = Response::new(HttpBody::full(Bytes::new()));
*response.status_mut() = StatusCode::METHOD_NOT_ALLOWED;
response
.headers_mut()
.insert(header::ALLOW, HeaderValue::from_static("GET"));
response
}
};
response.headers_mut().insert(
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
);
response
}
}
impl<B> tower_service::Service<Request<B>> for ProtectedResourceMetadata {
type Response = Response<HttpBody>;
type Error = Infallible;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Infallible>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, request: Request<B>) -> Self::Future {
let response = self.respond(request.method());
Box::pin(async move { Ok(response) })
}
}
fn is_b64token(token: &str) -> bool {
let body = token.trim_end_matches('=');
!body.is_empty()
&& body.bytes().all(|b| {
b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'+' | b'/')
})
}
pub(crate) fn canonical_url(url: &str) -> Result<String, String> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| "missing scheme (expected `https://`)".to_owned())?;
let scheme = scheme.to_ascii_lowercase();
if url.contains('#') {
return Err("must not contain a fragment".to_owned());
}
if url
.chars()
.any(|c| c.is_whitespace() || c.is_control() || c == '"' || c == '\\')
{
return Err("contains spaces, quotes or control characters".to_owned());
}
let end = rest.find(['/', '?']).unwrap_or(rest.len());
let (authority, tail) = rest.split_at(end);
if authority.is_empty() {
return Err("missing host".to_owned());
}
if authority.contains('@') {
return Err("must not contain a user name".to_owned());
}
let authority = authority.to_ascii_lowercase();
let host = super::http::strip_port(&authority);
match scheme.as_str() {
"https" => {}
"http" if super::http::is_localhost(host) => {}
"http" => return Err("must use https (http is only allowed on localhost)".to_owned()),
_ => return Err("must use https".to_owned()),
}
Ok(format!("{scheme}://{authority}{tail}"))
}
fn split_origin(url: &str) -> (&str, &str) {
let after_scheme = url.find("://").map_or(0, |i| i + 3);
let path_start = url
.get(after_scheme..)
.and_then(|rest| rest.find(['/', '?']))
.map_or(url.len(), |i| i + after_scheme);
let (origin, rest) = url.split_at(path_start);
let path = rest.split('?').next().unwrap_or_default();
(origin, path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{Principal, TokenError};
fn verifier(token: &str) -> Result<Principal, TokenError> {
match token {
"good" => Ok(Principal::new("alice").with_scopes(["mcp"])),
"bare" => Ok(Principal::new("bob")),
_ => Err(TokenError::new("unknown token")),
}
}
fn oauth() -> OAuth {
OAuth::builder("https://MCP.example.com/mcp", verifier)
.authorization_server("https://auth.example.com")
.scopes_supported(["mcp"])
.required_scopes(["mcp"])
.build()
.unwrap()
}
fn headers(auth: &[&str]) -> HeaderMap {
let mut headers = HeaderMap::new();
for value in auth {
headers.append(header::AUTHORIZATION, HeaderValue::from_str(value).unwrap());
}
headers
}
#[test]
fn metadata_locations() {
let oauth = oauth();
assert_eq!(oauth.resource(), "https://mcp.example.com/mcp");
assert_eq!(
oauth.metadata_url(),
"https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
);
assert_eq!(
oauth.metadata_paths(),
[
"/.well-known/oauth-protected-resource/mcp",
"/.well-known/oauth-protected-resource"
]
);
let root = OAuth::builder("https://mcp.example.com", verifier)
.authorization_server("https://auth.example.com")
.build()
.unwrap();
assert_eq!(
root.metadata_paths(),
["/.well-known/oauth-protected-resource"]
);
let body: Value = serde_json::from_slice(&oauth.inner.metadata_body).unwrap();
assert_eq!(
body,
json!({
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["mcp"],
"bearer_methods_supported": ["header"],
})
);
}
#[test]
fn invalid_settings_are_rejected() {
let build = |resource: &str| {
OAuth::builder(resource, verifier)
.authorization_server("https://auth.example.com")
.build()
};
assert!(build("http://mcp.example.com/mcp").is_err());
assert!(build("mcp.example.com").is_err());
assert!(build("https://mcp.example.com/#x").is_err());
assert!(build("https://user@mcp.example.com").is_err());
assert!(build("http://localhost:8000/mcp").is_ok());
assert_eq!(
OAuth::builder("https://a.example", verifier)
.build()
.unwrap_err(),
OAuthError::NoAuthorizationServer
);
let bad_scope = OAuth::builder("https://a.example", verifier)
.authorization_server("https://auth.example.com")
.scopes_supported(["a b"])
.build();
assert!(matches!(bad_scope, Err(OAuthError::InvalidScope(_))));
let offline = OAuth::builder("https://a.example", verifier)
.authorization_server("https://auth.example.com")
.scopes_supported(["offline_access"])
.build();
assert_eq!(offline.unwrap_err(), OAuthError::OfflineAccess);
}
#[tokio::test]
async fn challenges() {
let oauth = oauth();
assert!(oauth.authenticate(&headers(&["Bearer good"])).await.is_ok());
assert!(oauth.authenticate(&headers(&["bearer good"])).await.is_ok());
assert_eq!(
oauth.authenticate(&headers(&[])).await.unwrap_err(),
Challenge::Missing
);
assert_eq!(
oauth
.authenticate(&headers(&["Basic Zm9vOmJhcg=="]))
.await
.unwrap_err(),
Challenge::Missing
);
assert_eq!(
oauth
.authenticate(&headers(&["Bearer good", "Bearer good"]))
.await
.unwrap_err(),
Challenge::Malformed
);
for bad in ["Bearer nope", "Bearer ", "Bearer a\"b", "Bearer =="] {
assert_eq!(
oauth.authenticate(&headers(&[bad])).await.unwrap_err(),
Challenge::InvalidToken,
"{bad}"
);
}
assert_eq!(
oauth
.authenticate(&headers(&["Bearer bare"]))
.await
.unwrap_err(),
Challenge::InsufficientScope("mcp".to_owned())
);
let value = |c: &Challenge| {
oauth
.www_authenticate(c)
.unwrap()
.to_str()
.unwrap()
.to_owned()
};
assert_eq!(
value(&Challenge::Missing),
"Bearer resource_metadata=\"https://mcp.example.com/.well-known/oauth-protected-resource/mcp\", scope=\"mcp\""
);
assert!(value(&Challenge::InvalidToken).ends_with("error=\"invalid_token\""));
assert!(
value(&Challenge::InsufficientScope("a b".to_owned()))
.ends_with("scope=\"a b\", error=\"insufficient_scope\"")
);
}
#[tokio::test]
async fn slow_verifiers_time_out() {
struct Slow;
impl TokenVerifier for Slow {
fn verify<'a>(&'a self, _: &'a str) -> crate::auth::VerifyFuture<'a> {
Box::pin(async {
tokio::time::sleep(Duration::from_secs(60)).await;
Err(TokenError::new("late"))
})
}
}
let oauth = OAuth::builder("https://a.example", Slow)
.authorization_server("https://auth.example.com")
.verify_timeout(Duration::from_millis(10))
.build()
.unwrap();
assert_eq!(
oauth
.authenticate(&headers(&["Bearer t"]))
.await
.unwrap_err(),
Challenge::Unavailable
);
}
#[test]
fn metadata_service_answers_get_only() {
let service = oauth().metadata_service();
let get = service.respond(&Method::GET);
assert_eq!(get.status(), StatusCode::OK);
assert_eq!(get.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*");
assert_eq!(
service.respond(&Method::POST).status(),
StatusCode::METHOD_NOT_ALLOWED
);
}
}