fix: Per RFC 9728, enable dynamic discovery of OAuth endpoints in MCP using path-aware discovery
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-07-17 13:28:55 -06:00
parent f5085a773a
commit 6dd1e59815
+193 -20
View File
@@ -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() {