use std::time::Duration;
use serde::Deserialize;
use crate::auth::oauth2;
use crate::net::http::{self, Url};
#[derive(Debug, Deserialize)]
struct ResourceMetadata {
#[serde(default)]
authorization_servers: Vec<String>,
}
pub fn discover_issuer(resource: &str, timeout: Duration) -> Option<String> {
let meta_url = challenge_metadata_url(resource, timeout)
.filter(|u| same_origin(resource, u))
.or_else(|| well_known_url(resource))?;
let meta: ResourceMetadata = get_json(&meta_url, timeout)?;
meta.authorization_servers
.into_iter()
.find(|s| !s.trim().is_empty())
}
fn challenge_metadata_url(resource: &str, timeout: Duration) -> Option<String> {
let url = Url::parse(resource).ok()?;
let mut stream = oauth2::connect(&url, timeout).ok()?;
let body = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
let resp = http::send(
stream.as_mut(),
&url.host_header(),
"POST",
&url.path,
&[
("Content-Type", "application/json"),
("Accept", "application/json"),
],
body,
)
.ok()?;
parse_resource_metadata(resp.header("www-authenticate")?)
}
fn parse_resource_metadata(header: &str) -> Option<String> {
let idx = header.find("resource_metadata")?;
let rest = header[idx + "resource_metadata".len()..].trim_start();
let rest = rest.strip_prefix('=')?.trim_start();
let val = if let Some(q) = rest.strip_prefix('"') {
q.split('"').next()?
} else {
rest.split([',', ' ', ';']).next()?
};
(!val.is_empty()).then(|| val.to_string())
}
fn well_known_url(resource: &str) -> Option<String> {
let url = Url::parse(resource).ok()?;
Some(format!(
"{}://{}/.well-known/oauth-protected-resource",
url.scheme,
url.host_header()
))
}
fn same_origin(base: &str, candidate: &str) -> bool {
let (Ok(b), Ok(c)) = (Url::parse(base), Url::parse(candidate)) else {
return false;
};
b.scheme.eq_ignore_ascii_case(&c.scheme)
&& b.host.eq_ignore_ascii_case(&c.host)
&& b.port == c.port
}
fn get_json<T: serde::de::DeserializeOwned>(url: &str, timeout: Duration) -> Option<T> {
let url = Url::parse(url).ok()?;
let mut stream = oauth2::connect(&url, timeout).ok()?;
let resp = http::send(
stream.as_mut(),
&url.host_header(),
"GET",
&url.path,
&[("Accept", "application/json")],
&[],
)
.ok()?;
if !resp.is_success() {
return None;
}
serde_json::from_slice(&resp.body).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_resource_metadata_reads_quoted_and_bare() {
let h = r#"Bearer resource_metadata="https://rs.example/.well-known/oauth-protected-resource", error="invalid_token""#;
assert_eq!(
parse_resource_metadata(h).as_deref(),
Some("https://rs.example/.well-known/oauth-protected-resource")
);
let h = "Bearer resource_metadata=https://rs.example/meta, error=invalid_token";
assert_eq!(
parse_resource_metadata(h).as_deref(),
Some("https://rs.example/meta")
);
assert_eq!(parse_resource_metadata("Bearer realm=\"x\""), None);
}
#[test]
fn a_cross_origin_metadata_url_is_not_honoured() {
let res = "https://mcp.example/mcp";
assert!(same_origin(res, "https://mcp.example/.well-known/x"));
assert!(same_origin(res, "https://MCP.EXAMPLE/other"));
assert!(!same_origin(
res,
"http://169.254.169.254/latest/meta-data/"
));
assert!(!same_origin(res, "https://evil.example/meta"));
assert!(!same_origin(res, "http://mcp.example/meta"));
assert!(!same_origin(res, "https://mcp.example:8443/meta"));
assert!(same_origin(
"http://127.0.0.1:8080/mcp",
"http://127.0.0.1:8080/.well-known/oauth-protected-resource"
));
assert!(!same_origin(
"http://127.0.0.1:8080/mcp",
"http://127.0.0.1:9090/x"
));
}
#[test]
fn well_known_url_is_origin_scoped() {
assert_eq!(
well_known_url("https://mcp.example/mcp").as_deref(),
Some("https://mcp.example/.well-known/oauth-protected-resource")
);
assert_eq!(
well_known_url("https://mcp.example:8443/a/b").as_deref(),
Some("https://mcp.example:8443/.well-known/oauth-protected-resource")
);
}
}