fix: Per RFC 9728, enable dynamic discovery of OAuth endpoints in MCP using path-aware discovery
This commit is contained in:
+193
-20
@@ -14,6 +14,8 @@ use url::Url;
|
|||||||
struct ProtectedResourceMetadata {
|
struct ProtectedResourceMetadata {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
authorization_servers: Vec<String>,
|
authorization_servers: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
scopes_supported: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -187,46 +189,122 @@ async fn register_client(endpoint: &str, redirect_uri: &str) -> Result<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
|
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
|
||||||
let base = extract_base_url(server_url)?;
|
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
let mut tried: Vec<String> = Vec::new();
|
||||||
|
|
||||||
// RFC 9728: try protected resource metadata first; it points to the auth server
|
// RFC 9728 @ 5.1: an unauthenticated request should yield a 401 whose
|
||||||
let pr_url = format!("{base}/.well-known/oauth-protected-resource");
|
// WWW-Authenticate challenge advertises the protected resource metadata URL.
|
||||||
if let Ok(resp) = client.get(&pr_url).send().await
|
let mut pr_urls = Vec::new();
|
||||||
&& resp.status().is_success()
|
if let Some(url) = probe_resource_metadata_url(&client, server_url).await {
|
||||||
&& let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await
|
pr_urls.push(url);
|
||||||
&& let Some(auth_server) = pr.authorization_servers.first()
|
}
|
||||||
{
|
|
||||||
let as_url = format!("{auth_server}/.well-known/oauth-authorization-server");
|
// RFC 9728 @ 3.1: path-aware well-known URL, then root as legacy fallback.
|
||||||
|
pr_urls.extend(well_known_urls(server_url, "oauth-protected-resource")?);
|
||||||
|
pr_urls.dedup();
|
||||||
|
|
||||||
|
for pr_url in &pr_urls {
|
||||||
|
tried.push(pr_url.clone());
|
||||||
|
let Ok(resp) = client.get(pr_url).send().await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(issuer) = pr.authorization_servers.first() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// RFC 8414 @ 3.1: for issuers with a path component the well-known
|
||||||
|
// segment is inserted BEFORE the path (with the legacy appended form
|
||||||
|
// and root as fallbacks).
|
||||||
|
for as_url in well_known_urls(issuer, "oauth-authorization-server")? {
|
||||||
|
tried.push(as_url.clone());
|
||||||
if let Ok(resp) = client.get(&as_url).send().await
|
if let Ok(resp) = client.get(&as_url).send().await
|
||||||
&& resp.status().is_success()
|
&& resp.status().is_success()
|
||||||
&& let Ok(meta) = resp.json::<OAuthServerMetadata>().await
|
&& let Ok(mut meta) = resp.json::<OAuthServerMetadata>().await
|
||||||
{
|
{
|
||||||
|
// Some auth servers (e.g. GitHub) omit scopes_supported from
|
||||||
|
// their metadata; fall back to the resource's advertised scopes.
|
||||||
|
if meta.scopes_supported.is_empty() {
|
||||||
|
meta.scopes_supported = pr.scopes_supported.clone();
|
||||||
|
}
|
||||||
return Ok(meta);
|
return Ok(meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let as_url = format!("{base}/.well-known/oauth-authorization-server");
|
// Last resort: the MCP server itself may host authorization server metadata.
|
||||||
let resp = client
|
for as_url in well_known_urls(server_url, "oauth-authorization-server")? {
|
||||||
.get(&as_url)
|
tried.push(as_url.clone());
|
||||||
.send()
|
if let Ok(resp) = client.get(&as_url).send().await
|
||||||
.await
|
&& resp.status().is_success()
|
||||||
.with_context(|| format!("Failed to reach {as_url}"))?;
|
{
|
||||||
|
|
||||||
if resp.status().is_success() {
|
|
||||||
return resp
|
return resp
|
||||||
.json::<OAuthServerMetadata>()
|
.json::<OAuthServerMetadata>()
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
|
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"Could not discover OAuth metadata for '{server_url}'.\n\
|
"Could not discover OAuth metadata for '{server_url}'.\n\
|
||||||
Tried:\n {pr_url}\n {as_url}\n\
|
Tried:\n {}\n\
|
||||||
Ensure the server supports MCP OAuth discovery, or consult its documentation."
|
Ensure the server supports MCP OAuth discovery, or consult its documentation.",
|
||||||
|
tried.join("\n ")
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probes the MCP server with an unauthenticated request and extracts the
|
||||||
|
/// `resource_metadata` URL from the 401 `WWW-Authenticate` challenge (RFC 9728 @ 5.1).
|
||||||
|
async fn probe_resource_metadata_url(client: &Client, server_url: &str) -> Option<String> {
|
||||||
|
let resp = client.get(server_url).send().await.ok()?;
|
||||||
|
let header = resp.headers().get(reqwest::header::WWW_AUTHENTICATE)?;
|
||||||
|
|
||||||
|
parse_resource_metadata(header.to_str().ok()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the `resource_metadata` parameter value from a `WWW-Authenticate`
|
||||||
|
/// challenge, e.g. `Bearer error="...", resource_metadata="https://..."`.
|
||||||
|
fn parse_resource_metadata(challenge: &str) -> Option<String> {
|
||||||
|
let (_, rest) = challenge.split_once("resource_metadata=")?;
|
||||||
|
let rest = rest.trim_start();
|
||||||
|
let value = if let Some(stripped) = rest.strip_prefix('"') {
|
||||||
|
stripped.split('"').next()?
|
||||||
|
} else {
|
||||||
|
rest.split([',', ' ']).next()?
|
||||||
|
};
|
||||||
|
|
||||||
|
if value.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds candidate well-known metadata URLs for `url`, ordered by spec preference:
|
||||||
|
/// 1. Path-aware (RFC 8414 @ 3.1 / RFC 9728 @ 3.1): `{origin}/.well-known/{suffix}{path}`
|
||||||
|
/// 2. Legacy appended form: `{url}/.well-known/{suffix}`
|
||||||
|
/// 3. Root: `{origin}/.well-known/{suffix}`
|
||||||
|
///
|
||||||
|
/// URLs without a path component yield only the root form.
|
||||||
|
fn well_known_urls(url: &str, suffix: &str) -> Result<Vec<String>> {
|
||||||
|
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
||||||
|
let origin = extract_base_url(url)?;
|
||||||
|
let path = parsed.path().trim_end_matches('/');
|
||||||
|
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
if !path.is_empty() && path != "/" {
|
||||||
|
urls.push(format!("{origin}/.well-known/{suffix}{path}"));
|
||||||
|
urls.push(format!("{origin}{path}/.well-known/{suffix}"));
|
||||||
|
}
|
||||||
|
urls.push(format!("{origin}/.well-known/{suffix}"));
|
||||||
|
|
||||||
|
Ok(urls)
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_base_url(url: &str) -> Result<String> {
|
fn extract_base_url(url: &str) -> Result<String> {
|
||||||
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
||||||
let scheme = parsed.scheme();
|
let scheme = parsed.scheme();
|
||||||
@@ -296,6 +374,101 @@ mod tests {
|
|||||||
assert!(extract_base_url("not-a-url").is_err());
|
assert!(extract_base_url("not-a-url").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_known_urls_path_aware_first_for_url_with_path() {
|
||||||
|
let urls = well_known_urls(
|
||||||
|
"https://api.githubcopilot.com/mcp",
|
||||||
|
"oauth-protected-resource",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
urls,
|
||||||
|
vec![
|
||||||
|
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp",
|
||||||
|
"https://api.githubcopilot.com/mcp/.well-known/oauth-protected-resource",
|
||||||
|
"https://api.githubcopilot.com/.well-known/oauth-protected-resource",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_known_urls_inserts_before_issuer_path() {
|
||||||
|
let urls = well_known_urls(
|
||||||
|
"https://github.com/login/oauth",
|
||||||
|
"oauth-authorization-server",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
urls[0],
|
||||||
|
"https://github.com/.well-known/oauth-authorization-server/login/oauth"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_known_urls_root_only_for_url_without_path() {
|
||||||
|
let urls = well_known_urls("https://mcp.notion.com", "oauth-authorization-server").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
urls,
|
||||||
|
vec!["https://mcp.notion.com/.well-known/oauth-authorization-server"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn well_known_urls_ignores_trailing_slash() {
|
||||||
|
let urls = well_known_urls(
|
||||||
|
"https://api.githubcopilot.com/mcp/",
|
||||||
|
"oauth-protected-resource",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
urls[0],
|
||||||
|
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resource_metadata_extracts_quoted_url() {
|
||||||
|
let challenge = r#"Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp""#;
|
||||||
|
|
||||||
|
let url = parse_resource_metadata(challenge);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
Some(
|
||||||
|
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp"
|
||||||
|
.to_string()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resource_metadata_extracts_unquoted_url() {
|
||||||
|
let challenge = "Bearer resource_metadata=https://example.com/.well-known/oauth-protected-resource/mcp, error=\"invalid_token\"";
|
||||||
|
|
||||||
|
let url = parse_resource_metadata(challenge);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
Some("https://example.com/.well-known/oauth-protected-resource/mcp".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_resource_metadata_returns_none_when_absent() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_resource_metadata(r#"Bearer error="invalid_token""#),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_resource_metadata(r#"Bearer resource_metadata="""#),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn registered_client_id_roundtrip() {
|
fn registered_client_id_roundtrip() {
|
||||||
|
|||||||
Reference in New Issue
Block a user