diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 2eae2b220..dacc4d942 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, future::Future, net::{IpAddr, Ipv4Addr, Ipv6Addr}, pin::Pin, @@ -635,6 +635,18 @@ struct ResourceServerMetadata { scopes_supported: Option>, } +/// How a url that may hold protected resource metadata was arrived at, which +/// decides what a document that is not metadata means there. +#[derive(Debug, Clone, Copy)] +enum ResourceMetadataUrlOrigin { + /// The server named this url in the `resource_metadata` parameter of a + /// `WWW-Authenticate` challenge. + Advertised, + /// The url was derived from the base url, on the chance that the document is + /// published there. + WellKnownGuess, +} + /// Parameters extracted from WWW-Authenticate header #[derive(Debug, Clone, Default)] #[non_exhaustive] @@ -2462,26 +2474,48 @@ impl AuthorizationManager { async fn discover_oauth_server_via_resource_metadata( &self, ) -> Result, AuthError> { - let Some(resource_metadata_url) = self.discover_resource_metadata_url().await? else { + let Some((resource_metadata_url, resource_metadata)) = + self.discover_resource_metadata().await? + else { return Ok(None); }; - self.discover_oauth_server_from_resource_metadata_url(&resource_metadata_url) - .await + self.authorization_metadata_from_resource_metadata( + &resource_metadata_url, + resource_metadata, + ) + .await } + /// Read protected resource metadata from the url a `WWW-Authenticate` + /// challenge advertised. async fn discover_oauth_server_from_resource_metadata_url( &self, resource_metadata_url: &Url, ) -> Result, AuthError> { let Some(resource_metadata) = self - .fetch_resource_metadata_from_url(resource_metadata_url) + .fetch_resource_metadata_from_url( + resource_metadata_url, + ResourceMetadataUrlOrigin::Advertised, + ) .await? else { return Ok(None); }; - self.validate_resource_metadata_resource(&resource_metadata)?; + self.authorization_metadata_from_resource_metadata(resource_metadata_url, resource_metadata) + .await + } + /// Walk the authorization servers a protected resource metadata document + /// names, keeping the first one that answers with usable metadata. + /// + /// The document arrives here through `read_resource_metadata`, which is where + /// it is decided to be this resource's metadata at all. + async fn authorization_metadata_from_resource_metadata( + &self, + resource_metadata_url: &Url, + resource_metadata: ResourceServerMetadata, + ) -> Result, AuthError> { self.discovered_resource .write() .await @@ -2494,24 +2528,28 @@ impl AuthorizationManager { *self.resource_scopes.write().await = scopes; } - let mut candidates = Vec::new(); + // A server naming the same authorization server in both the singular draft + // field and the list would otherwise have each of that server's well-known + // forms requested twice. + let mut candidates: Vec = Vec::new(); + let mut push_candidate = |candidate: String| { + let candidate = candidate.trim(); + if !candidate.is_empty() && !candidates.iter().any(|kept| kept == candidate) { + candidates.push(candidate.to_string()); + } + }; if let Some(single) = resource_metadata.authorization_server { - candidates.push(single); + push_candidate(single); } - if let Some(list) = resource_metadata.authorization_servers { - candidates.extend(list); + for candidate in resource_metadata.authorization_servers.unwrap_or_default() { + push_candidate(candidate); } for candidate in candidates { - let candidate = candidate.trim(); - if candidate.is_empty() { - continue; - } - - let candidate_url = match Url::parse(candidate) { + let candidate_url = match Url::parse(&candidate) { Ok(url) => url, - Err(_) => match resource_metadata_url.join(candidate) { + Err(_) => match resource_metadata_url.join(&candidate) { Ok(url) => url, Err(e) => { debug!("Failed to resolve authorization server URL `{candidate}`: {e}"); @@ -2605,52 +2643,113 @@ impl AuthorizationManager { || expected_path.as_bytes().get(actual_path.len()) == Some(&b'/')) } - async fn discover_resource_metadata_url(&self) -> Result, AuthError> { - if let Some(resource_metadata_url) = - self.probe_resource_metadata_url(&self.base_url).await? - { - return Ok(Some(resource_metadata_url)); + /// Look for the protected resource metadata document, reading it where it is + /// found so that a candidate answering with something else only costs that + /// candidate. + async fn discover_resource_metadata( + &self, + ) -> Result, AuthError> { + // A url the resource points at can also be one of the candidates below. + let mut requested = HashSet::new(); + + if let Some(advertised_url) = self.probe_resource_endpoint_for_challenge().await? { + requested.insert(advertised_url.clone()); + if let Some(metadata) = self + .fetch_resource_metadata_from_url( + &advertised_url, + ResourceMetadataUrlOrigin::Advertised, + ) + .await? + { + return Ok(Some((advertised_url, metadata))); + } + // Nothing was published there. The candidates below are reached from the + // base url rather than from that pointer, so they are still worth trying. } - // If the primary URL doesn't use WWW-Authenticate, try oauth-protected-resource discovery. + // The other place the document can be is the well-known location. // https://www.rfc-editor.org/rfc/rfc9728.html#name-obtaining-protected-resourc for candidate_path in Self::well_known_paths(self.base_url.path(), "oauth-protected-resource") { - let mut discovery_url = self.base_url.clone(); - discovery_url.set_query(None); - discovery_url.set_fragment(None); - discovery_url.set_path(&candidate_path); - if let Some(resource_metadata_url) = - self.probe_resource_metadata_url(&discovery_url).await? - { - return Ok(Some(resource_metadata_url)); + let mut candidate_url = self.base_url.clone(); + candidate_url.set_query(None); + candidate_url.set_fragment(None); + candidate_url.set_path(&candidate_path); + + if !requested.insert(candidate_url.clone()) { + continue; + } + + let response = self + .discovery_get(&candidate_url) + .await + .map_err(|error| Self::discovery_failed(&candidate_url, error))?; + + match response.status() { + // The candidate url is the document itself, so read the body here + // instead of requesting the same url again. + StatusCode::OK => { + if let Some(metadata) = self.read_resource_metadata( + &candidate_url, + response.body(), + ResourceMetadataUrlOrigin::WellKnownGuess, + )? { + return Ok(Some((candidate_url, metadata))); + } + } + StatusCode::UNAUTHORIZED => { + let Some(advertised_url) = self + .extract_resource_metadata_url_from_www_authenticate(&response) + .await + else { + continue; + }; + if !requested.insert(advertised_url.clone()) { + continue; + } + if let Some(metadata) = self + .fetch_resource_metadata_from_url( + &advertised_url, + ResourceMetadataUrlOrigin::Advertised, + ) + .await? + { + return Ok(Some((advertised_url, metadata))); + } + } + status => debug!("resource metadata probe returned unexpected status: {status}"), } } Ok(None) } - /// Probe `url` with a GET, extracting the resource metadata url from a - /// 200 (the url itself is the metadata document) or from a 401's - /// WWW-Authenticate header value. + /// Probe the resource itself, looking only for a `WWW-Authenticate` challenge + /// that carries a `resource_metadata` pointer. + /// + /// A 200 here says nothing about metadata. RFC 9728 publishes the document at + /// the well-known URI and advertises it through the challenge parameter, so the + /// resource answering its own GET is not the document and must not end + /// discovery before the well-known candidates are tried. /// https://www.rfc-editor.org/rfc/rfc9728.html#name-use-of-www-authenticate-for - async fn probe_resource_metadata_url(&self, url: &Url) -> Result, AuthError> { + async fn probe_resource_endpoint_for_challenge(&self) -> Result, AuthError> { let response = self - .discovery_get(url) + .discovery_get(&self.base_url) .await - .map_err(|error| Self::discovery_failed(url, error))?; + .map_err(|error| Self::discovery_failed(&self.base_url, error))?; - match response.status() { - StatusCode::OK => Ok(Some(url.clone())), - StatusCode::UNAUTHORIZED => Ok(self + if response.status() == StatusCode::UNAUTHORIZED { + return Ok(self .extract_resource_metadata_url_from_www_authenticate(&response) - .await), - status => { - debug!("resource metadata probe returned unexpected status: {status}"); - Ok(None) - } + .await); } + + debug!( + "resource endpoint probe returned {}, no WWW-Authenticate pointer to follow", + response.status() + ); + Ok(None) } async fn extract_resource_metadata_url_from_www_authenticate( @@ -2676,6 +2775,7 @@ impl AuthorizationManager { async fn fetch_resource_metadata_from_url( &self, resource_metadata_url: &Url, + origin: ResourceMetadataUrlOrigin, ) -> Result, AuthError> { debug!( "resource metadata discovery url: {:?}", @@ -2694,13 +2794,69 @@ impl AuthorizationManager { return Ok(None); } - let metadata = match serde_json::from_slice::(response.body()) { + self.read_resource_metadata(resource_metadata_url, response.body(), origin) + } + + /// Read a response body as this resource's protected resource metadata. + /// + /// A body that is not that document rules out the url it came from, and where + /// that url came from decides whether ruling it out leaves anything to try. + fn read_resource_metadata( + &self, + resource_metadata_url: &Url, + body: &[u8], + origin: ResourceMetadataUrlOrigin, + ) -> Result, AuthError> { + let metadata = match serde_json::from_slice::(body) { Ok(metadata) => metadata, Err(e) => { debug!("failed to parse resource metadata as JSON: {}", e); return Ok(None); } }; + + // Every field of `ResourceServerMetadata` is optional, so an unrelated JSON + // object deserializes into an all-`None` value and then fails validation + // fatally. RFC 9728 requires `resource`, and MCP requires an authorization + // server reference, so a document carrying neither is not a protected + // resource metadata document. + if metadata.resource.is_none() + && metadata.authorization_server.is_none() + && metadata.authorization_servers.is_none() + { + return match origin { + // The server named this url, so there is nothing better to move on + // to: the alternatives all drop the resource binding the document + // was supposed to carry. Report it instead. + ResourceMetadataUrlOrigin::Advertised => Err(AuthError::MetadataError(format!( + "the server advertised {resource_metadata_url} as protected resource metadata, but the document carries neither `resource` nor an authorization server reference" + ))), + // Nothing advertised this url, so its answer only rules out this + // candidate. + ResourceMetadataUrlOrigin::WellKnownGuess => { + debug!( + "response at {resource_metadata_url} is not a protected resource metadata document" + ); + Ok(None) + } + }; + } + + // Carrying those fields only makes the body a metadata document; validation + // is what makes it this resource's. Both answers rule out the url the same + // way, so both are read the same way. + if let Err(error) = self.validate_resource_metadata_resource(&metadata) { + return match origin { + ResourceMetadataUrlOrigin::Advertised => Err(error), + ResourceMetadataUrlOrigin::WellKnownGuess => { + debug!( + "document at {resource_metadata_url} is not this resource's metadata: {error}" + ); + Ok(None) + } + }; + } + Ok(Some(metadata)) } @@ -4324,13 +4480,6 @@ mod tests { "authorization_servers": ["https://auth.example.com/tenant1"] }), ), - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/", - "authorization_servers": ["https://auth.example.com/tenant1"] - }), - ), http_response( 200, serde_json::json!({ @@ -4365,7 +4514,6 @@ mod tests { vec![ "https://mcp.example.com/", "https://mcp.example.com/.well-known/oauth-protected-resource", - "https://mcp.example.com/.well-known/oauth-protected-resource", "https://auth.example.com/.well-known/oauth-authorization-server/tenant1", ], ) @@ -4383,13 +4531,6 @@ mod tests { "authorization_servers": ["https://auth.example.com/tenant1/"] }), ), - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/", - "authorization_servers": ["https://auth.example.com/tenant1/"] - }), - ), http_response( 200, serde_json::json!({ @@ -4853,6 +4994,424 @@ mod tests { ); } + #[tokio::test] + async fn resolve_metadata_reaches_the_well_known_document_past_a_non_metadata_base_url() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // the well-known candidate carries the real document + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_servers": ["https://auth.example.com"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let recorder = client.clone(); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::ProtectedResourceMetadata, + "https://auth.example.com/token", + ) + ); + assert!( + recorder.requests().iter().any(|request| { + request.uri == "https://mcp.example.com/.well-known/oauth-protected-resource" + }), + "the well-known candidate was never probed: {:?}", + recorder.requests() + ); + } + + #[tokio::test] + async fn resolve_metadata_ignores_a_well_known_url_that_is_not_a_metadata_document() { + let health = || { + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ) + }; + let client = RecordingOAuthHttpClient::with_responses(vec![ + // the MCP endpoint answers GET with a health payload, not metadata + health(), + // so does the well-known candidate + health(), + http_response( + 200, + serde_json::json!({ + "issuer": "https://mcp.example.com", + "authorization_endpoint": "https://mcp.example.com/oauth/authorize", + "token_endpoint": "https://mcp.example.com/oauth/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::AuthorizationServerMetadata, + "https://mcp.example.com/oauth/token", + ) + ); + } + + #[tokio::test] + async fn resolve_metadata_tries_the_next_well_known_candidate_past_a_non_metadata_document() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // so does the first well-known candidate + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // the second candidate carries the real document + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let recorder = client.clone(); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::ProtectedResourceMetadata, + "https://auth.example.com/token", + ) + ); + let document_requests = recorder + .requests() + .iter() + .filter(|request| { + request.uri == "https://mcp.example.com/mcp/.well-known/oauth-protected-resource" + }) + .count(); + assert_eq!( + document_requests, + 1, + "the candidate holding the document should be requested exactly once: {:?}", + recorder.requests() + ); + } + + #[tokio::test] + async fn resolve_metadata_from_challenge_reports_an_advertised_url_without_metadata() { + let mut responses = vec![http_response(200, serde_json::json!({}))]; + // enough responses for the fallback to reach the legacy endpoints, so that + // treating the document as a soft failure would resolve rather than error + responses.extend(std::iter::repeat_with(|| empty_response(404)).take(8)); + let client = RecordingOAuthHttpClient::with_responses(responses); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + + let error = manager + .resolve_metadata_from_challenge(Some( + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + )) + .await + .unwrap_err(); + + assert_eq!( + error.to_string(), + "Metadata error: the server advertised https://mcp.example.com/.well-known/oauth-protected-resource as protected resource metadata, but the document carries neither `resource` nor an authorization server reference" + ); + } + + #[tokio::test] + async fn resolve_metadata_tries_the_next_well_known_candidate_past_an_unusable_document() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // a catch-all handler answers the first candidate with its own error + // shape, which carries a `resource` that only validation rejects + http_response( + 200, + serde_json::json!({ + "error": "not_found", + "resource": "/.well-known/oauth-protected-resource" + }), + ), + // the second candidate carries the real document + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let recorder = client.clone(); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::ProtectedResourceMetadata, + "https://auth.example.com/token", + ) + ); + assert!( + recorder.requests().iter().any(|request| { + request.uri == "https://mcp.example.com/mcp/.well-known/oauth-protected-resource" + }), + "the candidate after the rejected one was never probed: {:?}", + recorder.requests() + ); + } + + #[tokio::test] + async fn resolve_metadata_probes_the_candidates_past_an_advertised_url_that_is_not_served() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/prm""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + // the advertised url is not where the document is served + empty_response(404), + // neither is the first candidate + empty_response(404), + // the second candidate carries the document + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }), + ), + ]); + let recorder = client.clone(); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::ProtectedResourceMetadata, + "https://auth.example.com/token", + ) + ); + assert!( + recorder.requests().iter().any(|request| { + request.uri == "https://mcp.example.com/mcp/.well-known/oauth-protected-resource" + }), + "the candidates were skipped after the advertised url answered 404: {:?}", + recorder.requests() + ); + } + + #[tokio::test] + async fn resolve_metadata_requests_an_advertised_url_that_is_also_a_candidate_once() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let mut responses = vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // the first candidate points at the last candidate of the same run + challenge, + ]; + // the document is served nowhere, so the run walks every candidate and + // settles on the legacy endpoints + responses.extend(std::iter::repeat_with(|| empty_response(404)).take(10)); + let client = RecordingOAuthHttpClient::with_responses(responses); + let recorder = client.clone(); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + resolution.source, + AuthorizationMetadataSource::LegacyEndpointFallback + ); + let advertised_requests = recorder + .requests() + .iter() + .filter(|request| { + request.uri == "https://mcp.example.com/.well-known/oauth-protected-resource" + }) + .count(); + assert_eq!( + advertised_requests, + 1, + "the url the challenge named was requested again as a candidate: {:?}", + recorder.requests() + ); + } + + #[tokio::test] + async fn resolve_metadata_requests_an_authorization_server_named_twice_once() { + let mut responses = vec![ + // the MCP endpoint answers GET with a health payload, not metadata + http_response( + 200, + serde_json::json!({"status": "healthy", "message": "MCP server is running"}), + ), + // the document names the same authorization server in both the singular + // draft field and the list + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/", + "authorization_server": "https://auth.example.com", + "authorization_servers": ["https://auth.example.com"] + }), + ), + ]; + // the authorization server publishes no metadata, so every form of its + // discovery url is tried before the run settles on the legacy endpoints + responses.extend(std::iter::repeat_with(|| empty_response(404)).take(10)); + let client = RecordingOAuthHttpClient::with_responses(responses); + let recorder = client.clone(); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + resolution.source, + AuthorizationMetadataSource::LegacyEndpointFallback + ); + let discovery_requests = recorder + .requests() + .iter() + .filter(|request| { + request.uri == "https://auth.example.com/.well-known/oauth-authorization-server" + }) + .count(); + assert_eq!( + discovery_requests, + 1, + "the authorization server was walked once per field naming it: {:?}", + recorder.requests() + ); + } + #[rstest] #[case::protected_resource_metadata( AuthorizationMetadataSource::ProtectedResourceMetadata,