Skip to content

Commit d9a5560

Browse files
anara123Anar Azadaliyevclaude
authored
feat(auth): add token_endpoint_auth_method to OAuthClientConfig (#648)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat(auth): prefer basic auth when both methods supported and improve test assertions 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 <noreply@anthropic.com> * style(auth): apply cargo fmt formatting * style(auth): apply nightly cargo fmt import grouping * revert: undo .gitignore change --------- Co-authored-by: Anar Azadaliyev <anar.azadaliye@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bb534a7 commit d9a5560

1 file changed

Lines changed: 138 additions & 4 deletions

File tree

crates/rmcp/src/transport/auth.rs

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc, time::Duration};
22

33
use async_trait::async_trait;
44
use oauth2::{
5-
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields,
5+
AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields,
66
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope,
77
StandardTokenResponse, TokenResponse, TokenUrl,
88
basic::{BasicClient, BasicTokenType},
@@ -548,6 +548,23 @@ impl AuthorizationManager {
548548
client_builder = client_builder.set_client_secret(ClientSecret::new(secret));
549549
}
550550

551+
let uses_secret_post = metadata
552+
.additional_fields
553+
.get("token_endpoint_auth_methods_supported")
554+
.and_then(|v| v.as_array())
555+
.map(|arr| {
556+
let has_basic = arr
557+
.iter()
558+
.any(|m| m.as_str() == Some("client_secret_basic"));
559+
let has_post = arr.iter().any(|m| m.as_str() == Some("client_secret_post"));
560+
has_post && !has_basic
561+
})
562+
.unwrap_or(false);
563+
564+
if uses_secret_post {
565+
client_builder = client_builder.set_auth_type(AuthType::RequestBody);
566+
}
567+
551568
self.oauth_client = Some(client_builder);
552569
Ok(())
553570
}
@@ -1770,14 +1787,14 @@ impl OAuthState {
17701787

17711788
#[cfg(test)]
17721789
mod tests {
1773-
use std::sync::Arc;
1790+
use std::{collections::HashMap, sync::Arc};
17741791

1775-
use oauth2::{CsrfToken, PkceCodeVerifier};
1792+
use oauth2::{AuthType, CsrfToken, PkceCodeVerifier};
17761793
use url::Url;
17771794

17781795
use super::{
17791796
AuthError, AuthorizationManager, AuthorizationMetadata, InMemoryStateStore,
1780-
ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url,
1797+
OAuthClientConfig, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url,
17811798
};
17821799

17831800
// -- url helpers --
@@ -2263,6 +2280,123 @@ mod tests {
22632280
manager.set_state_store(TrackingStateStore::default());
22642281
}
22652282

2283+
/// Helper: create an AuthorizationManager with minimal metadata so
2284+
/// `configure_client` can be exercised without a live server.
2285+
async fn manager_with_metadata(
2286+
metadata_override: Option<AuthorizationMetadata>,
2287+
) -> AuthorizationManager {
2288+
let mut mgr = AuthorizationManager::new("http://localhost").await.unwrap();
2289+
mgr.set_metadata(metadata_override.unwrap_or(AuthorizationMetadata {
2290+
authorization_endpoint: "http://localhost/authorize".to_string(),
2291+
token_endpoint: "http://localhost/token".to_string(),
2292+
..Default::default()
2293+
}));
2294+
mgr
2295+
}
2296+
2297+
fn test_client_config() -> OAuthClientConfig {
2298+
OAuthClientConfig {
2299+
client_id: "my-client".to_string(),
2300+
client_secret: Some("my-secret".to_string()),
2301+
scopes: vec![],
2302+
redirect_uri: "http://localhost/callback".to_string(),
2303+
}
2304+
}
2305+
2306+
#[tokio::test]
2307+
async fn test_configure_client_uses_client_secret_post_from_metadata() {
2308+
let mut additional_fields = HashMap::new();
2309+
additional_fields.insert(
2310+
"token_endpoint_auth_methods_supported".to_string(),
2311+
serde_json::json!(["client_secret_post"]),
2312+
);
2313+
let meta = AuthorizationMetadata {
2314+
authorization_endpoint: "http://localhost/authorize".to_string(),
2315+
token_endpoint: "http://localhost/token".to_string(),
2316+
additional_fields,
2317+
..Default::default()
2318+
};
2319+
let mut mgr = manager_with_metadata(Some(meta)).await;
2320+
mgr.configure_client(test_client_config()).unwrap();
2321+
assert!(matches!(
2322+
mgr.oauth_client.as_ref().unwrap().auth_type(),
2323+
AuthType::RequestBody
2324+
));
2325+
}
2326+
2327+
#[tokio::test]
2328+
async fn test_configure_client_defaults_to_basic_auth() {
2329+
let mut mgr = manager_with_metadata(None).await;
2330+
mgr.configure_client(test_client_config()).unwrap();
2331+
assert!(matches!(
2332+
mgr.oauth_client.as_ref().unwrap().auth_type(),
2333+
AuthType::BasicAuth
2334+
));
2335+
}
2336+
2337+
#[tokio::test]
2338+
async fn test_configure_client_with_explicit_basic_in_metadata() {
2339+
let mut additional_fields = HashMap::new();
2340+
additional_fields.insert(
2341+
"token_endpoint_auth_methods_supported".to_string(),
2342+
serde_json::json!(["client_secret_basic"]),
2343+
);
2344+
let meta = AuthorizationMetadata {
2345+
authorization_endpoint: "http://localhost/authorize".to_string(),
2346+
token_endpoint: "http://localhost/token".to_string(),
2347+
additional_fields,
2348+
..Default::default()
2349+
};
2350+
let mut mgr = manager_with_metadata(Some(meta)).await;
2351+
mgr.configure_client(test_client_config()).unwrap();
2352+
assert!(matches!(
2353+
mgr.oauth_client.as_ref().unwrap().auth_type(),
2354+
AuthType::BasicAuth
2355+
));
2356+
}
2357+
2358+
#[tokio::test]
2359+
async fn test_configure_client_ignores_unsupported_auth_methods_in_metadata() {
2360+
let mut additional_fields = HashMap::new();
2361+
additional_fields.insert(
2362+
"token_endpoint_auth_methods_supported".to_string(),
2363+
serde_json::json!(["private_key_jwt"]),
2364+
);
2365+
let meta = AuthorizationMetadata {
2366+
authorization_endpoint: "http://localhost/authorize".to_string(),
2367+
token_endpoint: "http://localhost/token".to_string(),
2368+
additional_fields,
2369+
..Default::default()
2370+
};
2371+
let mut mgr = manager_with_metadata(Some(meta)).await;
2372+
// Unsupported method should fall through to default (basic auth)
2373+
mgr.configure_client(test_client_config()).unwrap();
2374+
assert!(matches!(
2375+
mgr.oauth_client.as_ref().unwrap().auth_type(),
2376+
AuthType::BasicAuth
2377+
));
2378+
}
2379+
2380+
#[tokio::test]
2381+
async fn test_configure_client_prefers_basic_when_both_methods_supported() {
2382+
let mut additional_fields = HashMap::new();
2383+
additional_fields.insert(
2384+
"token_endpoint_auth_methods_supported".to_string(),
2385+
serde_json::json!(["client_secret_post", "client_secret_basic"]),
2386+
);
2387+
let meta = AuthorizationMetadata {
2388+
authorization_endpoint: "http://localhost/authorize".to_string(),
2389+
token_endpoint: "http://localhost/token".to_string(),
2390+
additional_fields,
2391+
..Default::default()
2392+
};
2393+
let mut mgr = manager_with_metadata(Some(meta)).await;
2394+
mgr.configure_client(test_client_config()).unwrap();
2395+
assert!(matches!(
2396+
mgr.oauth_client.as_ref().unwrap().auth_type(),
2397+
AuthType::BasicAuth
2398+
));
2399+
}
22662400
// -- metadata deserialization --
22672401

22682402
#[test]

0 commit comments

Comments
 (0)