diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 3c0b5583..a858430b 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1409,14 +1409,16 @@ impl AuthorizationManager { push_candidate("/.well-known/oauth-authorization-server".to_string()); push_candidate("/.well-known/openid-configuration".to_string()); } else { - // Path components present: follow spec priority order + // Path components present: prefer OAuth discovery before OpenID Connect fallbacks. // 1. OAuth 2.0 with path insertion push_candidate(format!("/.well-known/oauth-authorization-server/{trimmed}")); - // 2. OpenID Connect with path insertion + // 2. OAuth 2.0 with path appending + push_candidate(format!("/{trimmed}/.well-known/oauth-authorization-server")); + // 3. OpenID Connect with path insertion push_candidate(format!("/.well-known/openid-configuration/{trimmed}")); - // 3. OpenID Connect with path appending + // 4. OpenID Connect with path appending push_candidate(format!("/{trimmed}/.well-known/openid-configuration")); - // 4. Canonical OAuth fallback (without path suffix) + // 5. Canonical OAuth fallback (without path suffix) push_candidate("/.well-known/oauth-authorization-server".to_string()); } @@ -2684,61 +2686,73 @@ mod tests { let base_url = Url::parse("https://auth.example.com/tenant1").unwrap(); let urls = AuthorizationManager::generate_discovery_urls(&base_url); - assert_eq!(urls.len(), 4); + assert_eq!(urls.len(), 5); assert_eq!( urls[0].as_str(), "https://auth.example.com/.well-known/oauth-authorization-server/tenant1" ); assert_eq!( urls[1].as_str(), - "https://auth.example.com/.well-known/openid-configuration/tenant1" + "https://auth.example.com/tenant1/.well-known/oauth-authorization-server" ); assert_eq!( urls[2].as_str(), - "https://auth.example.com/tenant1/.well-known/openid-configuration" + "https://auth.example.com/.well-known/openid-configuration/tenant1" ); assert_eq!( urls[3].as_str(), + "https://auth.example.com/tenant1/.well-known/openid-configuration" + ); + assert_eq!( + urls[4].as_str(), "https://auth.example.com/.well-known/oauth-authorization-server" ); let base_url = Url::parse("https://auth.example.com/v1/mcp/").unwrap(); let urls = AuthorizationManager::generate_discovery_urls(&base_url); - assert_eq!(urls.len(), 4); + assert_eq!(urls.len(), 5); assert_eq!( urls[0].as_str(), "https://auth.example.com/.well-known/oauth-authorization-server/v1/mcp" ); assert_eq!( urls[1].as_str(), - "https://auth.example.com/.well-known/openid-configuration/v1/mcp" + "https://auth.example.com/v1/mcp/.well-known/oauth-authorization-server" ); assert_eq!( urls[2].as_str(), - "https://auth.example.com/v1/mcp/.well-known/openid-configuration" + "https://auth.example.com/.well-known/openid-configuration/v1/mcp" ); assert_eq!( urls[3].as_str(), + "https://auth.example.com/v1/mcp/.well-known/openid-configuration" + ); + assert_eq!( + urls[4].as_str(), "https://auth.example.com/.well-known/oauth-authorization-server" ); let base_url = Url::parse("https://auth.example.com/tenant1/subtenant").unwrap(); let urls = AuthorizationManager::generate_discovery_urls(&base_url); - assert_eq!(urls.len(), 4); + assert_eq!(urls.len(), 5); assert_eq!( urls[0].as_str(), "https://auth.example.com/.well-known/oauth-authorization-server/tenant1/subtenant" ); assert_eq!( urls[1].as_str(), - "https://auth.example.com/.well-known/openid-configuration/tenant1/subtenant" + "https://auth.example.com/tenant1/subtenant/.well-known/oauth-authorization-server" ); assert_eq!( urls[2].as_str(), - "https://auth.example.com/tenant1/subtenant/.well-known/openid-configuration" + "https://auth.example.com/.well-known/openid-configuration/tenant1/subtenant" ); assert_eq!( urls[3].as_str(), + "https://auth.example.com/tenant1/subtenant/.well-known/openid-configuration" + ); + assert_eq!( + urls[4].as_str(), "https://auth.example.com/.well-known/oauth-authorization-server" ); } diff --git a/crates/rmcp/tests/test_client_credentials.rs b/crates/rmcp/tests/test_client_credentials.rs index a90b8b0e..bfb3c5a2 100644 --- a/crates/rmcp/tests/test_client_credentials.rs +++ b/crates/rmcp/tests/test_client_credentials.rs @@ -136,6 +136,25 @@ async fn start_mock_server() -> (String, SocketAddr) { (base_url, addr) } +async fn start_path_append_metadata_server() -> (String, SocketAddr) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}", addr); + + let app = Router::new() + .route( + "/mcp/.well-known/oauth-authorization-server", + get(auth_server_metadata_handler), + ) + .route("/token", post(token_handler)); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + (base_url, addr) +} + #[tokio::test] async fn test_client_credentials_flow_client_secret() { let (base_url, _addr) = start_mock_server().await; @@ -162,6 +181,33 @@ async fn test_client_credentials_flow_client_secret() { assert_eq!(token, "m2m-access-token-12345"); } +#[tokio::test] +async fn test_client_credentials_discovers_path_append_oauth_metadata() { + let (base_url, _addr) = start_path_append_metadata_server().await; + let resource_url = format!("{base_url}/mcp"); + + let mut oauth_state = OAuthState::new(&resource_url, None).await.unwrap(); + + let config = ClientCredentialsConfig::ClientSecret { + client_id: "test-m2m-client".to_string(), + client_secret: "test-m2m-secret".to_string(), + scopes: vec!["read".to_string()], + resource: Some(resource_url), + }; + + oauth_state + .authenticate_client_credentials(config) + .await + .unwrap(); + + let manager = oauth_state + .into_authorization_manager() + .expect("Should be in Authorized state"); + + let token = manager.get_access_token().await.unwrap(); + assert_eq!(token, "m2m-access-token-12345"); +} + #[tokio::test] async fn test_client_credentials_invalid_secret() { let (base_url, _addr) = start_mock_server().await;