diff --git a/src/mcp/oauth.rs b/src/mcp/oauth.rs index e40e0c5..dbf3e40 100644 --- a/src/mcp/oauth.rs +++ b/src/mcp/oauth.rs @@ -14,6 +14,8 @@ use url::Url; struct ProtectedResourceMetadata { #[serde(default)] authorization_servers: Vec, + #[serde(default)] + scopes_supported: Vec, } #[derive(Debug, Deserialize)] @@ -187,46 +189,122 @@ async fn register_client(endpoint: &str, redirect_uri: &str) -> Result { } async fn discover_oauth_metadata(server_url: &str) -> Result { - let base = extract_base_url(server_url)?; let client = Client::new(); + let mut tried: Vec = Vec::new(); - // RFC 9728: try protected resource metadata first; it points to the auth server - let pr_url = format!("{base}/.well-known/oauth-protected-resource"); - if let Ok(resp) = client.get(&pr_url).send().await - && resp.status().is_success() - && let Ok(pr) = resp.json::().await - && let Some(auth_server) = pr.authorization_servers.first() - { - let as_url = format!("{auth_server}/.well-known/oauth-authorization-server"); - if let Ok(resp) = client.get(&as_url).send().await - && resp.status().is_success() - && let Ok(meta) = resp.json::().await - { - return Ok(meta); + // RFC 9728 @ 5.1: an unauthenticated request should yield a 401 whose + // WWW-Authenticate challenge advertises the protected resource metadata URL. + let mut pr_urls = Vec::new(); + if let Some(url) = probe_resource_metadata_url(&client, server_url).await { + pr_urls.push(url); + } + + // 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::().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 + && resp.status().is_success() + && let Ok(mut meta) = resp.json::().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); + } } } - let as_url = format!("{base}/.well-known/oauth-authorization-server"); - let resp = client - .get(&as_url) - .send() - .await - .with_context(|| format!("Failed to reach {as_url}"))?; - - if resp.status().is_success() { - return resp - .json::() - .await - .with_context(|| format!("Failed to parse OAuth metadata from {as_url}")); + // Last resort: the MCP server itself may host authorization server metadata. + for as_url in well_known_urls(server_url, "oauth-authorization-server")? { + tried.push(as_url.clone()); + if let Ok(resp) = client.get(&as_url).send().await + && resp.status().is_success() + { + return resp + .json::() + .await + .with_context(|| format!("Failed to parse OAuth metadata from {as_url}")); + } } Err(anyhow!( "Could not discover OAuth metadata for '{server_url}'.\n\ - Tried:\n {pr_url}\n {as_url}\n\ - Ensure the server supports MCP OAuth discovery, or consult its documentation." + Tried:\n {}\n\ + 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 { + 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 { + 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> { + 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 { let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?; let scheme = parsed.scheme(); @@ -296,6 +374,101 @@ mod tests { 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] #[serial] fn registered_client_id_roundtrip() {