From faa593938136b66b31c7c6024c1422c46664efdb Mon Sep 17 00:00:00 2001 From: Anar Azadaliyev Date: Mon, 9 Feb 2026 10:03:48 +0200 Subject: [PATCH 1/4] feat(auth): add token_endpoint_auth_method to OAuthClientConfig Some OAuth providers (e.g. HubSpot) require client credentials to be sent as POST body parameters (client_secret_post) instead of via HTTP Basic Auth header. The oauth2 crate defaults to BasicAuth, and rmcp had no way to override this, causing TokenExchangeFailed errors. Add an optional `token_endpoint_auth_method` field to OAuthClientConfig that accepts "client_secret_post" (RequestBody) and "client_secret_basic" (BasicAuth). Unknown values are silently ignored, preserving the default. Co-Authored-By: Claude Opus 4.6 --- crates/rmcp/src/transport/auth.rs | 94 ++++++++++++++++++- .../servers/src/complex_auth_streamhttp.rs | 2 + 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index de2cf5e9..c0dd48f3 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use async_trait::async_trait; use oauth2::{ - AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, + AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, StandardTokenResponse, TokenResponse, TokenUrl, basic::{BasicClient, BasicTokenType}, @@ -268,6 +268,9 @@ pub struct OAuthClientConfig { pub client_secret: Option, pub scopes: Vec, pub redirect_uri: String, + /// Token endpoint authentication method. + /// Supported values: `"client_secret_basic"` (HTTP Basic Auth, default) and `"client_secret_post"` (credentials in POST body). + pub token_endpoint_auth_method: Option, } // add type aliases for oauth2 types @@ -482,6 +485,18 @@ impl AuthorizationManager { client_builder = client_builder.set_client_secret(ClientSecret::new(secret)); } + if let Some(method) = &config.token_endpoint_auth_method { + match method.as_str() { + "client_secret_post" => { + client_builder = client_builder.set_auth_type(AuthType::RequestBody); + } + "client_secret_basic" => { + client_builder = client_builder.set_auth_type(AuthType::BasicAuth); + } + _ => {} + } + } + self.oauth_client = Some(client_builder); Ok(()) } @@ -580,6 +595,7 @@ impl AuthorizationManager { client_secret: reg_response.client_secret.filter(|s| !s.is_empty()), redirect_uri: redirect_uri.to_string(), scopes: vec![], + token_endpoint_auth_method: None, }; self.configure_client(config.clone())?; @@ -594,6 +610,7 @@ impl AuthorizationManager { client_secret: None, scopes: vec![], redirect_uri: self.base_url.to_string(), + token_endpoint_auth_method: None, }; self.configure_client(config) } @@ -1127,6 +1144,7 @@ impl AuthorizationSession { client_secret: None, scopes: scopes.iter().map(|s| s.to_string()).collect(), redirect_uri: redirect_uri.to_string(), + token_endpoint_auth_method: None, } } else { // Fallback to dynamic registration @@ -1457,8 +1475,8 @@ mod tests { use url::Url; use super::{ - AuthError, AuthorizationManager, InMemoryStateStore, StateStore, StoredAuthorizationState, - is_https_url, + AuthError, AuthorizationManager, AuthorizationMetadata, InMemoryStateStore, + OAuthClientConfig, StateStore, StoredAuthorizationState, is_https_url, }; // SEP-991: URL-based Client IDs @@ -1876,4 +1894,74 @@ mod tests { let mut manager = AuthorizationManager::new("http://localhost").await.unwrap(); manager.set_state_store(TrackingStateStore::default()); } + + /// Helper: create an AuthorizationManager with minimal metadata so + /// `configure_client` can be exercised without a live server. + async fn manager_with_metadata() -> AuthorizationManager { + let mut mgr = AuthorizationManager::new("http://localhost").await.unwrap(); + mgr.set_metadata(AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + ..Default::default() + }); + mgr + } + + #[tokio::test] + async fn test_configure_client_with_client_secret_post() { + let mut mgr = manager_with_metadata().await; + let config = OAuthClientConfig { + client_id: "my-client".to_string(), + client_secret: Some("my-secret".to_string()), + scopes: vec![], + redirect_uri: "http://localhost/callback".to_string(), + token_endpoint_auth_method: Some("client_secret_post".to_string()), + }; + // configure_client should succeed and produce an oauth_client + mgr.configure_client(config).unwrap(); + assert!(mgr.oauth_client.is_some()); + } + + #[tokio::test] + async fn test_configure_client_with_client_secret_basic() { + let mut mgr = manager_with_metadata().await; + let config = OAuthClientConfig { + client_id: "my-client".to_string(), + client_secret: Some("my-secret".to_string()), + scopes: vec![], + redirect_uri: "http://localhost/callback".to_string(), + token_endpoint_auth_method: Some("client_secret_basic".to_string()), + }; + mgr.configure_client(config).unwrap(); + assert!(mgr.oauth_client.is_some()); + } + + #[tokio::test] + async fn test_configure_client_with_no_auth_method() { + let mut mgr = manager_with_metadata().await; + let config = OAuthClientConfig { + client_id: "my-client".to_string(), + client_secret: Some("my-secret".to_string()), + scopes: vec![], + redirect_uri: "http://localhost/callback".to_string(), + token_endpoint_auth_method: None, + }; + mgr.configure_client(config).unwrap(); + assert!(mgr.oauth_client.is_some()); + } + + #[tokio::test] + async fn test_configure_client_ignores_unknown_auth_method() { + let mut mgr = manager_with_metadata().await; + let config = OAuthClientConfig { + client_id: "my-client".to_string(), + client_secret: Some("my-secret".to_string()), + scopes: vec![], + redirect_uri: "http://localhost/callback".to_string(), + token_endpoint_auth_method: Some("private_key_jwt".to_string()), + }; + // Unknown method should not cause an error; it is silently ignored + mgr.configure_client(config).unwrap(); + assert!(mgr.oauth_client.is_some()); + } } diff --git a/examples/servers/src/complex_auth_streamhttp.rs b/examples/servers/src/complex_auth_streamhttp.rs index 33fa445c..66c41170 100644 --- a/examples/servers/src/complex_auth_streamhttp.rs +++ b/examples/servers/src/complex_auth_streamhttp.rs @@ -56,6 +56,7 @@ impl McpOAuthStore { client_secret: Some("mcp-client-secret".to_string()), scopes: vec!["profile".to_string(), "email".to_string()], redirect_uri: "http://localhost:8080/callback".to_string(), + token_endpoint_auth_method: None, }, ); @@ -564,6 +565,7 @@ async fn oauth_register( client_secret: Some(client_secret.clone()), redirect_uri: req.redirect_uris[0].clone(), scopes: vec![], + token_endpoint_auth_method: None, }; state From c8e21def7e48e80625f4d77bfed09688ba774ee3 Mon Sep 17 00:00:00 2001 From: Anar Azadaliyev Date: Mon, 9 Feb 2026 12:00:54 +0200 Subject: [PATCH 2/4] refactor(auth): derive token_endpoint_auth_method from server metadata Move auth method selection from per-client config to server's AuthorizationMetadata, which is the correct OAuth 2.0 approach. Servers like HubSpot advertise token_endpoint_auth_methods_supported in their metadata; reading it from there avoids manual configuration and prevents TokenExchangeFailed errors with non-BasicAuth providers. Co-Authored-By: Claude Opus 4.6 --- crates/rmcp/src/transport/auth.rs | 98 +++++++++---------- .../servers/src/complex_auth_streamhttp.rs | 2 - 2 files changed, 44 insertions(+), 56 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index c0dd48f3..f182f9ee 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -250,6 +250,7 @@ pub struct AuthorizationMetadata { pub jwks_uri: Option, pub scopes_supported: Option>, pub response_types_supported: Option>, + pub token_endpoint_auth_methods_supported: Option>, // allow additional fields #[serde(flatten)] pub additional_fields: HashMap, @@ -268,9 +269,6 @@ pub struct OAuthClientConfig { pub client_secret: Option, pub scopes: Vec, pub redirect_uri: String, - /// Token endpoint authentication method. - /// Supported values: `"client_secret_basic"` (HTTP Basic Auth, default) and `"client_secret_post"` (credentials in POST body). - pub token_endpoint_auth_method: Option, } // add type aliases for oauth2 types @@ -485,16 +483,11 @@ impl AuthorizationManager { client_builder = client_builder.set_client_secret(ClientSecret::new(secret)); } - if let Some(method) = &config.token_endpoint_auth_method { - match method.as_str() { - "client_secret_post" => { - client_builder = client_builder.set_auth_type(AuthType::RequestBody); - } - "client_secret_basic" => { - client_builder = client_builder.set_auth_type(AuthType::BasicAuth); - } - _ => {} + if let Some(methods) = &metadata.token_endpoint_auth_methods_supported { + if methods.iter().any(|m| m == "client_secret_post") { + client_builder = client_builder.set_auth_type(AuthType::RequestBody); } + // client_secret_basic is the oauth2 crate default — no action needed } self.oauth_client = Some(client_builder); @@ -595,7 +588,6 @@ impl AuthorizationManager { client_secret: reg_response.client_secret.filter(|s| !s.is_empty()), redirect_uri: redirect_uri.to_string(), scopes: vec![], - token_endpoint_auth_method: None, }; self.configure_client(config.clone())?; @@ -610,7 +602,6 @@ impl AuthorizationManager { client_secret: None, scopes: vec![], redirect_uri: self.base_url.to_string(), - token_endpoint_auth_method: None, }; self.configure_client(config) } @@ -1144,7 +1135,6 @@ impl AuthorizationSession { client_secret: None, scopes: scopes.iter().map(|s| s.to_string()).collect(), redirect_uri: redirect_uri.to_string(), - token_endpoint_auth_method: None, } } else { // Fallback to dynamic registration @@ -1897,71 +1887,71 @@ mod tests { /// Helper: create an AuthorizationManager with minimal metadata so /// `configure_client` can be exercised without a live server. - async fn manager_with_metadata() -> AuthorizationManager { + async fn manager_with_metadata( + metadata_override: Option, + ) -> AuthorizationManager { let mut mgr = AuthorizationManager::new("http://localhost").await.unwrap(); - mgr.set_metadata(AuthorizationMetadata { + mgr.set_metadata(metadata_override.unwrap_or(AuthorizationMetadata { authorization_endpoint: "http://localhost/authorize".to_string(), token_endpoint: "http://localhost/token".to_string(), ..Default::default() - }); + })); mgr } - #[tokio::test] - async fn test_configure_client_with_client_secret_post() { - let mut mgr = manager_with_metadata().await; - let config = OAuthClientConfig { + fn test_client_config() -> OAuthClientConfig { + OAuthClientConfig { client_id: "my-client".to_string(), client_secret: Some("my-secret".to_string()), scopes: vec![], redirect_uri: "http://localhost/callback".to_string(), - token_endpoint_auth_method: Some("client_secret_post".to_string()), + } + } + + #[tokio::test] + async fn test_configure_client_uses_client_secret_post_from_metadata() { + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + token_endpoint_auth_methods_supported: Some(vec!["client_secret_post".to_string()]), + ..Default::default() }; - // configure_client should succeed and produce an oauth_client - mgr.configure_client(config).unwrap(); + let mut mgr = manager_with_metadata(Some(meta)).await; + mgr.configure_client(test_client_config()).unwrap(); assert!(mgr.oauth_client.is_some()); } #[tokio::test] - async fn test_configure_client_with_client_secret_basic() { - let mut mgr = manager_with_metadata().await; - let config = OAuthClientConfig { - client_id: "my-client".to_string(), - client_secret: Some("my-secret".to_string()), - scopes: vec![], - redirect_uri: "http://localhost/callback".to_string(), - token_endpoint_auth_method: Some("client_secret_basic".to_string()), - }; - mgr.configure_client(config).unwrap(); + async fn test_configure_client_defaults_to_basic_auth() { + let mut mgr = manager_with_metadata(None).await; + mgr.configure_client(test_client_config()).unwrap(); assert!(mgr.oauth_client.is_some()); } #[tokio::test] - async fn test_configure_client_with_no_auth_method() { - let mut mgr = manager_with_metadata().await; - let config = OAuthClientConfig { - client_id: "my-client".to_string(), - client_secret: Some("my-secret".to_string()), - scopes: vec![], - redirect_uri: "http://localhost/callback".to_string(), - token_endpoint_auth_method: None, + async fn test_configure_client_with_explicit_basic_in_metadata() { + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + token_endpoint_auth_methods_supported: Some(vec!["client_secret_basic".to_string()]), + ..Default::default() }; - mgr.configure_client(config).unwrap(); + let mut mgr = manager_with_metadata(Some(meta)).await; + mgr.configure_client(test_client_config()).unwrap(); assert!(mgr.oauth_client.is_some()); } #[tokio::test] - async fn test_configure_client_ignores_unknown_auth_method() { - let mut mgr = manager_with_metadata().await; - let config = OAuthClientConfig { - client_id: "my-client".to_string(), - client_secret: Some("my-secret".to_string()), - scopes: vec![], - redirect_uri: "http://localhost/callback".to_string(), - token_endpoint_auth_method: Some("private_key_jwt".to_string()), + async fn test_configure_client_ignores_unsupported_auth_methods_in_metadata() { + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + token_endpoint_auth_methods_supported: Some(vec!["private_key_jwt".to_string()]), + ..Default::default() }; - // Unknown method should not cause an error; it is silently ignored - mgr.configure_client(config).unwrap(); + let mut mgr = manager_with_metadata(Some(meta)).await; + // Unsupported method should fall through to default (basic auth) + mgr.configure_client(test_client_config()).unwrap(); assert!(mgr.oauth_client.is_some()); } } diff --git a/examples/servers/src/complex_auth_streamhttp.rs b/examples/servers/src/complex_auth_streamhttp.rs index 66c41170..33fa445c 100644 --- a/examples/servers/src/complex_auth_streamhttp.rs +++ b/examples/servers/src/complex_auth_streamhttp.rs @@ -56,7 +56,6 @@ impl McpOAuthStore { client_secret: Some("mcp-client-secret".to_string()), scopes: vec!["profile".to_string(), "email".to_string()], redirect_uri: "http://localhost:8080/callback".to_string(), - token_endpoint_auth_method: None, }, ); @@ -565,7 +564,6 @@ async fn oauth_register( client_secret: Some(client_secret.clone()), redirect_uri: req.redirect_uris[0].clone(), scopes: vec![], - token_endpoint_auth_method: None, }; state From ceb4437a92a3247777455fc48a545c795ddc3620 Mon Sep 17 00:00:00 2001 From: Anar Azadaliyev Date: Mon, 9 Feb 2026 19:22:15 +0200 Subject: [PATCH 3/4] refactor(auth): read token_endpoint_auth_methods_supported from additional_fields Move token_endpoint_auth_methods_supported out of AuthorizationMetadata as an explicit field and read it from the serde(flatten) additional_fields HashMap instead. This avoids serializing `null` when the field is absent, which broke Zod validation in downstream consumers like MCP Inspector. Co-Authored-By: Claude Opus 4.6 --- crates/rmcp/src/transport/auth.rs | 47 ++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index f182f9ee..b74fdd59 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -250,7 +250,6 @@ pub struct AuthorizationMetadata { pub jwks_uri: Option, pub scopes_supported: Option>, pub response_types_supported: Option>, - pub token_endpoint_auth_methods_supported: Option>, // allow additional fields #[serde(flatten)] pub additional_fields: HashMap, @@ -483,11 +482,15 @@ impl AuthorizationManager { client_builder = client_builder.set_client_secret(ClientSecret::new(secret)); } - if let Some(methods) = &metadata.token_endpoint_auth_methods_supported { - if methods.iter().any(|m| m == "client_secret_post") { - client_builder = client_builder.set_auth_type(AuthType::RequestBody); - } - // client_secret_basic is the oauth2 crate default — no action needed + let uses_secret_post = metadata + .additional_fields + .get("token_endpoint_auth_methods_supported") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().any(|m| m.as_str() == Some("client_secret_post"))) + .unwrap_or(false); + + if uses_secret_post { + client_builder = client_builder.set_auth_type(AuthType::RequestBody); } self.oauth_client = Some(client_builder); @@ -1459,9 +1462,10 @@ impl OAuthState { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::sync::Arc; - use oauth2::{CsrfToken, PkceCodeVerifier}; + use oauth2::{AuthType, CsrfToken, PkceCodeVerifier}; use url::Url; use super::{ @@ -1910,48 +1914,63 @@ mod tests { #[tokio::test] async fn test_configure_client_uses_client_secret_post_from_metadata() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_post"]), + ); let meta = AuthorizationMetadata { authorization_endpoint: "http://localhost/authorize".to_string(), token_endpoint: "http://localhost/token".to_string(), - token_endpoint_auth_methods_supported: Some(vec!["client_secret_post".to_string()]), + additional_fields, ..Default::default() }; let mut mgr = manager_with_metadata(Some(meta)).await; mgr.configure_client(test_client_config()).unwrap(); - assert!(mgr.oauth_client.is_some()); + assert!(matches!(mgr.oauth_client.as_ref().unwrap().auth_type(), AuthType::RequestBody)); } #[tokio::test] async fn test_configure_client_defaults_to_basic_auth() { let mut mgr = manager_with_metadata(None).await; mgr.configure_client(test_client_config()).unwrap(); - assert!(mgr.oauth_client.is_some()); + assert!(matches!(mgr.oauth_client.as_ref().unwrap().auth_type(), AuthType::BasicAuth)); } #[tokio::test] async fn test_configure_client_with_explicit_basic_in_metadata() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_basic"]), + ); let meta = AuthorizationMetadata { authorization_endpoint: "http://localhost/authorize".to_string(), token_endpoint: "http://localhost/token".to_string(), - token_endpoint_auth_methods_supported: Some(vec!["client_secret_basic".to_string()]), + additional_fields, ..Default::default() }; let mut mgr = manager_with_metadata(Some(meta)).await; mgr.configure_client(test_client_config()).unwrap(); - assert!(mgr.oauth_client.is_some()); + assert!(matches!(mgr.oauth_client.as_ref().unwrap().auth_type(), AuthType::BasicAuth)); } #[tokio::test] async fn test_configure_client_ignores_unsupported_auth_methods_in_metadata() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["private_key_jwt"]), + ); let meta = AuthorizationMetadata { authorization_endpoint: "http://localhost/authorize".to_string(), token_endpoint: "http://localhost/token".to_string(), - token_endpoint_auth_methods_supported: Some(vec!["private_key_jwt".to_string()]), + additional_fields, ..Default::default() }; let mut mgr = manager_with_metadata(Some(meta)).await; // Unsupported method should fall through to default (basic auth) mgr.configure_client(test_client_config()).unwrap(); - assert!(mgr.oauth_client.is_some()); + assert!(matches!(mgr.oauth_client.as_ref().unwrap().auth_type(), AuthType::BasicAuth)); } } From 0e4a777a69d4a0775dc37b8f974a884b1c6e205a Mon Sep 17 00:00:00 2001 From: Anar Azadaliyev Date: Tue, 10 Feb 2026 11:30:12 +0200 Subject: [PATCH 4/4] feat(auth): prefer basic auth when both methods supported and improve test assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When token_endpoint_auth_methods_supported contains both client_secret_post and client_secret_basic, default to basic auth per RFC 6749 §2.3.1. Update configure_client tests to assert actual AuthType instead of is_some(). Co-Authored-By: Claude Opus 4.6 --- crates/rmcp/src/transport/auth.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index b74fdd59..e7366910 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -486,7 +486,11 @@ impl AuthorizationManager { .additional_fields .get("token_endpoint_auth_methods_supported") .and_then(|v| v.as_array()) - .map(|arr| arr.iter().any(|m| m.as_str() == Some("client_secret_post"))) + .map(|arr| { + let has_basic = arr.iter().any(|m| m.as_str() == Some("client_secret_basic")); + let has_post = arr.iter().any(|m| m.as_str() == Some("client_secret_post")); + has_post && !has_basic + }) .unwrap_or(false); if uses_secret_post { @@ -1973,4 +1977,22 @@ mod tests { mgr.configure_client(test_client_config()).unwrap(); assert!(matches!(mgr.oauth_client.as_ref().unwrap().auth_type(), AuthType::BasicAuth)); } + + #[tokio::test] + async fn test_configure_client_prefers_basic_when_both_methods_supported() { + let mut additional_fields = HashMap::new(); + additional_fields.insert( + "token_endpoint_auth_methods_supported".to_string(), + serde_json::json!(["client_secret_post", "client_secret_basic"]), + ); + let meta = AuthorizationMetadata { + authorization_endpoint: "http://localhost/authorize".to_string(), + token_endpoint: "http://localhost/token".to_string(), + additional_fields, + ..Default::default() + }; + let mut mgr = manager_with_metadata(Some(meta)).await; + mgr.configure_client(test_client_config()).unwrap(); + assert!(matches!(mgr.oauth_client.as_ref().unwrap().auth_type(), AuthType::BasicAuth)); + } }