use super::*;
#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(super) struct ProtectedResourceMetadata {
pub(crate) resource: String,
#[serde(default)]
pub(crate) authorization_servers: Vec<String>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, Value>,
}
impl fmt::Debug for ProtectedResourceMetadata {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProtectedResourceMetadata")
.field("resource", &self.resource)
.field("authorization_servers", &self.authorization_servers)
.field("extra", &self.extra)
.finish()
}
}
#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) struct AuthorizationServerMetadata {
pub(crate) issuer: String,
pub(crate) authorization_endpoint: String,
pub(crate) token_endpoint: String,
#[serde(default)]
pub(crate) registration_endpoint: Option<String>,
#[serde(default)]
pub(crate) scopes_supported: Option<Vec<String>>,
#[serde(default)]
pub(crate) response_types_supported: Option<Vec<String>>,
#[serde(default)]
pub(crate) grant_types_supported: Option<Vec<String>>,
#[serde(default)]
pub(crate) code_challenge_methods_supported: Option<Vec<String>>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, Value>,
}
impl fmt::Debug for AuthorizationServerMetadata {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AuthorizationServerMetadata")
.field("issuer", &self.issuer)
.field("authorization_endpoint", &self.authorization_endpoint)
.field("token_endpoint", &self.token_endpoint)
.field("registration_endpoint", &self.registration_endpoint)
.field("scopes_supported", &self.scopes_supported)
.field("response_types_supported", &self.response_types_supported)
.field("grant_types_supported", &self.grant_types_supported)
.field(
"code_challenge_methods_supported",
&self.code_challenge_methods_supported,
)
.field("extra", &self.extra)
.finish()
}
}
#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(super) struct RegistrationResponse {
pub(crate) client_id: String,
#[serde(default)]
pub(crate) client_secret: Option<String>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, Value>,
}
impl fmt::Debug for RegistrationResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RegistrationResponse")
.field("client_id", &self.client_id)
.field(
"client_secret",
&self.client_secret.as_ref().map(|_| "[REDACTED]"),
)
.field("extra", &self.extra)
.finish()
}
}
pub(super) fn discover_protected_resource(
origin_url: &str,
client: &reqwest::blocking::Client,
) -> McpResult<Option<ProtectedResourceMetadata>> {
let mut first_error: Option<McpError> = None;
for metadata_url in protected_resource_metadata_urls(origin_url)? {
match fetch_protected_resource_metadata(client, metadata_url, origin_url) {
Ok(Some(metadata)) => return Ok(Some(metadata)),
Ok(None) => {}
Err(error) => {
if first_error.is_none() {
first_error = Some(error);
}
}
}
}
if let Some(metadata) = discover_protected_resource_from_www_authenticate(origin_url, client)? {
return Ok(Some(metadata));
}
if let Some(error) = first_error {
return Err(error);
}
Ok(None)
}
fn fetch_protected_resource_metadata(
client: &reqwest::blocking::Client,
metadata_url: reqwest::Url,
origin_url: &str,
) -> McpResult<Option<ProtectedResourceMetadata>> {
validate_oauth_endpoint("resource_metadata", metadata_url.as_str())?;
let response = client.get(metadata_url).send().map_err(|_| {
McpError::Transport(format!(
"network error during OAuth protected-resource discovery at {}",
sanitize_url(origin_url)
))
})?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !response.status().is_success() {
return Err(McpError::Transport(format!(
"OAuth protected-resource discovery failed at {} with HTTP {}",
sanitize_url(origin_url),
response.status()
)));
}
let text = read_oauth_success_text(response, "OAuth protected-resource metadata")?;
let metadata: ProtectedResourceMetadata = serde_json::from_str(&text).map_err(|_| {
McpError::Protocol {
code: -32700,
message: "OAuth protected-resource metadata is malformed JSON (expected resource and authorization_servers)".to_string(),
}
})?;
validate_protected_resource_metadata(&metadata)?;
Ok(Some(metadata))
}
fn discover_protected_resource_from_www_authenticate(
origin_url: &str,
client: &reqwest::blocking::Client,
) -> McpResult<Option<ProtectedResourceMetadata>> {
let response = match client.get(origin_url).send() {
Ok(response) => response,
Err(_) => return Ok(None),
};
if response.status() != reqwest::StatusCode::UNAUTHORIZED {
return Ok(None);
}
let Some(metadata_url) = response
.headers()
.get(reqwest::header::WWW_AUTHENTICATE)
.and_then(|value| value.to_str().ok())
.and_then(parse_www_authenticate_resource_metadata)
else {
return Ok(None);
};
let url = reqwest::Url::parse(&metadata_url).map_err(|_| {
McpError::Config("OAuth WWW-Authenticate resource_metadata URL is invalid".to_string())
})?;
fetch_protected_resource_metadata(client, url, origin_url)
}
pub(super) fn discover_authorization_server(
auth_server_url: &str,
client: &reqwest::blocking::Client,
) -> McpResult<AuthorizationServerMetadata> {
let mut saw_404 = false;
validate_oauth_endpoint("authorization_server", auth_server_url)?;
for metadata_url in authorization_server_metadata_urls(auth_server_url)? {
let response = client.get(metadata_url).send().map_err(|_| {
McpError::Transport(format!(
"could not discover OAuth authorization server at {}: network error",
sanitize_url(auth_server_url)
))
})?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
saw_404 = true;
continue;
}
if !response.status().is_success() {
return Err(McpError::Transport(format!(
"could not discover OAuth authorization server at {}: HTTP {}",
sanitize_url(auth_server_url),
response.status()
)));
}
let text = read_oauth_success_text(response, "OAuth authorization-server metadata")?;
let metadata: AuthorizationServerMetadata =
serde_json::from_str(&text).map_err(|_| McpError::Protocol {
code: -32700,
message: format!(
"OAuth authorization-server metadata at {} is malformed JSON",
sanitize_url(auth_server_url)
),
})?;
validate_authorization_server_metadata(&metadata)?;
return Ok(metadata);
}
let _ = saw_404;
Err(McpError::Config(format!(
"could not discover OAuth authorization server at {}",
sanitize_url(auth_server_url)
)))
}
pub(crate) fn parse_www_authenticate_resource_metadata(header: &str) -> Option<String> {
header.split(',').find_map(|part| {
let (key, value) = part.trim().split_once('=')?;
let key = key.split_whitespace().last().unwrap_or(key).trim();
if !key.eq_ignore_ascii_case("resource_metadata") {
return None;
}
Some(value.trim().trim_matches('"').to_string()).filter(|value| !value.is_empty())
})
}
pub(super) fn select_authorization_server(
configured: Option<&str>,
protected: Option<&ProtectedResourceMetadata>,
) -> McpResult<String> {
if let Some(configured) = configured {
validate_oauth_endpoint("authorization_server", configured)?;
if let Some(protected) = protected
&& !protected.authorization_servers.is_empty()
&& !protected
.authorization_servers
.iter()
.any(|server| server == configured)
{
return Err(McpError::Config(
"configured OAuth authorization_server is not listed in protected-resource metadata".to_string(),
));
}
return Ok(configured.to_string());
}
let server = protected
.and_then(|metadata| metadata.authorization_servers.first())
.ok_or_else(|| {
McpError::Config(
"MCP OAuth protected-resource metadata missing authorization_servers".to_string(),
)
})?;
validate_oauth_endpoint("authorization_server", server)?;
Ok(server.to_string())
}
pub(super) fn register_client(
registration_endpoint: &str,
redirect_uris: Vec<String>,
client: &reqwest::blocking::Client,
) -> McpResult<RegistrationResponse> {
validate_oauth_endpoint("registration_endpoint", registration_endpoint)?;
let body = serde_json::json!({
"redirect_uris": redirect_uris,
"client_name": HTTP_CLIENT_NAME,
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"code_challenge_method": "S256"
});
let response = client
.post(registration_endpoint)
.json(&body)
.send()
.map_err(|_| {
McpError::Transport("MCP OAuth dynamic client registration failed".to_string())
})?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(McpError::Config(
"server does not support dynamic client registration; configure client_id".to_string(),
));
}
if !response.status().is_success() {
return Err(McpError::Transport(format!(
"MCP OAuth dynamic client registration was rejected with HTTP {}",
response.status()
)));
}
let text = read_oauth_success_text(response, "MCP OAuth dynamic client registration")?;
let registered: RegistrationResponse =
serde_json::from_str(&text).map_err(|_| McpError::Protocol {
code: -32700,
message: "MCP OAuth dynamic client registration response is malformed JSON".to_string(),
})?;
if registered.client_id.trim().is_empty() {
return Err(McpError::Protocol {
code: -32602,
message: "MCP OAuth dynamic client registration response missing client_id".to_string(),
});
}
Ok(registered)
}
fn protected_resource_metadata_urls(origin_url: &str) -> McpResult<Vec<reqwest::Url>> {
let parsed = reqwest::Url::parse(origin_url)
.map_err(|_| McpError::Config("MCP OAuth server URL is invalid".to_string()))?;
let mut urls = Vec::new();
let mut root = parsed.clone();
root.set_path("/.well-known/oauth-protected-resource");
root.set_query(None);
root.set_fragment(None);
urls.push(root);
let endpoint_dir = endpoint_directory_path(parsed.path());
let endpoint_path = format!("{endpoint_dir}.well-known/oauth-protected-resource");
let mut endpoint = parsed;
endpoint.set_path(&endpoint_path);
endpoint.set_query(None);
endpoint.set_fragment(None);
if !urls.iter().any(|url| url == &endpoint) {
urls.push(endpoint);
}
Ok(urls)
}
fn authorization_server_metadata_urls(auth_server_url: &str) -> McpResult<Vec<reqwest::Url>> {
let parsed = reqwest::Url::parse(auth_server_url).map_err(|_| {
McpError::Config("MCP OAuth authorization server URL is invalid".to_string())
})?;
let mut urls = Vec::new();
let mut root = parsed.clone();
root.set_path("/.well-known/oauth-authorization-server");
root.set_query(None);
root.set_fragment(None);
urls.push(root);
let endpoint_dir = endpoint_directory_path(parsed.path());
let mut path_relative = parsed.clone();
path_relative.set_path(&format!(
"{endpoint_dir}.well-known/oauth-authorization-server"
));
path_relative.set_query(None);
path_relative.set_fragment(None);
if !urls.iter().any(|url| url == &path_relative) {
urls.push(path_relative);
}
let mut oidc = parsed;
oidc.set_path(&format!("{endpoint_dir}.well-known/openid-configuration"));
oidc.set_query(None);
oidc.set_fragment(None);
if !urls.iter().any(|url| url == &oidc) {
urls.push(oidc);
}
Ok(urls)
}
fn endpoint_directory_path(path: &str) -> String {
let trimmed = path.trim_end_matches('/');
match trimmed.rsplit_once('/') {
Some(("", _)) | None => "/".to_string(),
Some((parent, _)) => format!("{parent}/"),
}
}
fn validate_protected_resource_metadata(metadata: &ProtectedResourceMetadata) -> McpResult<()> {
if metadata.resource.trim().is_empty() {
return Err(McpError::Protocol {
code: -32602,
message: "MCP OAuth protected-resource metadata missing resource".to_string(),
});
}
Ok(())
}
pub(super) fn validate_authorization_server_metadata(
metadata: &AuthorizationServerMetadata,
) -> McpResult<()> {
if metadata.issuer.trim().is_empty() {
return Err(metadata_missing("issuer"));
}
if metadata.authorization_endpoint.trim().is_empty() {
return Err(metadata_missing("authorization_endpoint"));
}
if metadata.token_endpoint.trim().is_empty() {
return Err(metadata_missing("token_endpoint"));
}
validate_oauth_endpoint("authorization_endpoint", &metadata.authorization_endpoint)?;
validate_oauth_endpoint("token_endpoint", &metadata.token_endpoint)?;
if let Some(endpoint) = metadata.registration_endpoint.as_deref() {
validate_oauth_endpoint("registration_endpoint", endpoint)?;
}
if metadata
.code_challenge_methods_supported
.as_ref()
.is_some_and(|methods| !methods.iter().any(|method| method == "S256"))
{
return Err(McpError::Config(
"MCP OAuth authorization server does not support PKCE S256".to_string(),
));
}
Ok(())
}
pub(super) fn validate_oauth_endpoint(field: &str, endpoint: &str) -> McpResult<()> {
crate::config::validate_mcp_http_url_field("oauth", field, endpoint)
.map_err(|error| McpError::Config(format!("MCP OAuth {field} is not permitted: {error}")))
}
fn metadata_missing(field: &str) -> McpError {
McpError::Protocol {
code: -32602,
message: format!("MCP OAuth authorization-server metadata missing {field}"),
}
}
pub(super) fn sanitize_url(url: &str) -> String {
match reqwest::Url::parse(url) {
Ok(mut parsed) => {
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
parsed.set_query(None);
parsed.set_fragment(None);
let host = parsed.host_str().unwrap_or("<unknown>");
let port = parsed
.port()
.map(|port| format!(":{port}"))
.unwrap_or_default();
format!("{}://{}{}{}", parsed.scheme(), host, port, parsed.path())
}
Err(_) => "<invalid-url>".to_string(),
}
}