Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 134 additions & 1 deletion crates/rmcp/src/transport/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1743,6 +1743,24 @@ impl AuthorizationManager {
};
self.validate_server_metadata("code")?;

let auth_method = match metadata
.additional_fields
.get("token_endpoint_auth_methods_supported")
{
None => "none",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An explicit "token_endpoint_auth_methods_supported": null currently fails registration, while omitting the field succeeds. Should null be treated like an omitted field?

Suggested change
None => "none",
None | Some(serde_json::Value::Null) => "none",

Some(methods) => ["none", "client_secret_basic", "client_secret_post"]
.into_iter()
.find(|method| {
methods.as_array().is_some_and(|methods| {
methods.iter().any(|value| value.as_str() == Some(*method))
})
})
.ok_or_else(|| {
AuthError::RegistrationFailed(
"No supported client authentication method".to_string(),
)
})?,
Comment on lines +1758 to +1762

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this change, a server advertising only methods the SDK can't do (e.g. private_key_jwt) still got a none registration attempt, and some of those accept public DCR anyway; failing before the POST turns them into a hard error. Would falling back to none and letting the server reject the request be the safer default here?

Suggested change
.ok_or_else(|| {
AuthError::RegistrationFailed(
"No supported client authentication method".to_string(),
)
})?,
.unwrap_or("none"),

};
Comment on lines +1746 to +1763

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

methods.as_array() is re-evaluated for every candidate inside find; resolving the slice once reads more directly and treats a non-array the same as an empty list.

Suggested change
let auth_method = match metadata
.additional_fields
.get("token_endpoint_auth_methods_supported")
{
None => "none",
Some(methods) => ["none", "client_secret_basic", "client_secret_post"]
.into_iter()
.find(|method| {
methods.as_array().is_some_and(|methods| {
methods.iter().any(|value| value.as_str() == Some(*method))
})
})
.ok_or_else(|| {
AuthError::RegistrationFailed(
"No supported client authentication method".to_string(),
)
})?,
};
let auth_method = match metadata
.additional_fields
.get("token_endpoint_auth_methods_supported")
{
None => "none",
Some(methods) => {
let methods = methods.as_array().map(Vec::as_slice).unwrap_or_default();
["none", "client_secret_basic", "client_secret_post"]
.into_iter()
.find(|method| methods.iter().any(|value| value.as_str() == Some(*method)))
.ok_or_else(|| {
AuthError::RegistrationFailed(
"No supported client authentication method".to_string(),
)
})?
}
};

let application_type = self.application_type.clone();
let registration_request = ClientRegistrationRequest {
client_name: name.to_string(),
Expand All @@ -1751,7 +1769,7 @@ impl AuthorizationManager {
"authorization_code".to_string(),
"refresh_token".to_string(),
],
token_endpoint_auth_method: "none".to_string(), // public client
token_endpoint_auth_method: auth_method.to_string(),
response_types: vec!["code".to_string()],
scope: if scopes.is_empty() {
None
Expand Down Expand Up @@ -1823,6 +1841,11 @@ impl AuthorizationManager {
scopes: scopes.iter().map(|s| s.to_string()).collect(),
application_type,
};
if auth_method != "none" && config.client_secret.is_none() {
return Err(AuthError::RegistrationFailed(
"Confidential registration returned no client secret".to_string(),
));
}
Comment on lines +1844 to +1848

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RFC 7591 §3.2.1 lets the server return a different token_endpoint_auth_method than the one requested, but the response's value is never read. Should the secret check (and the Basic/POST choice) follow what the server actually registered?

Suggested change
if auth_method != "none" && config.client_secret.is_none() {
return Err(AuthError::RegistrationFailed(
"Confidential registration returned no client secret".to_string(),
));
}
let registered_method = reg_response
.additional_fields
.get("token_endpoint_auth_method")
.and_then(|method| method.as_str())
.unwrap_or(auth_method);
if registered_method != "none" && config.client_secret.is_none() {
return Err(AuthError::RegistrationFailed(
"Confidential registration returned no client secret".to_string(),
));
}


self.configure_client(config.clone())?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configure_client re-derives Basic vs. POST from the metadata on its own, so the method sent at registration and the one used at the token endpoint only agree because the two preference orders happen to match. Would a shared helper that both call keep them from drifting apart?

+fn select_token_endpoint_auth_method(metadata: &AuthorizationMetadata) -> Option<&'static str> {
+    let Some(methods) = metadata.additional_fields.get("token_endpoint_auth_methods_supported") else {
+        return Some("none");
+    };
+    let methods = methods.as_array().map(Vec::as_slice).unwrap_or_default();
+    ["none", "client_secret_basic", "client_secret_post"]
+        .into_iter()
+        .find(|method| methods.iter().any(|value| value.as_str() == Some(*method)))
+}

Ok(config)
Expand Down Expand Up @@ -6144,6 +6167,116 @@ mod tests {
assert_eq!(body.get("application_type").unwrap(), "web");
}

async fn registration_fixture(
methods: Option<serde_json::Value>,
secret: Option<&str>,
) -> (AuthorizationManager, RecordingOAuthHttpClient) {
let client = RecordingOAuthHttpClient::with_responses(vec![http_response(
201,
serde_json::json!({
"client_id": "registered-client",
"client_secret": secret,
"redirect_uris": ["http://localhost/callback"]
}),
)]);
let mut manager = AuthorizationManager::new_with_oauth_http_client(
"https://mcp.example.com/mcp",
Arc::new(client.clone()),
)
.await
.unwrap();
let mut metadata = AuthorizationMetadata {
authorization_endpoint: "https://auth.example.com/authorize".to_string(),
token_endpoint: "https://auth.example.com/token".to_string(),
registration_endpoint: Some("https://auth.example.com/register".to_string()),
response_types_supported: Some(vec!["code".to_string()]),
code_challenge_methods_supported: Some(vec!["S256".to_string()]),
..Default::default()
};
if let Some(methods) = methods {
metadata
.additional_fields
.insert("token_endpoint_auth_methods_supported".to_string(), methods);
}
manager.set_metadata(metadata);
(manager, client)
}

#[rstest]
#[case::omitted(None, "none")]
#[case::public(
Some(serde_json::json!(["client_secret_post", "none", "client_secret_basic"])),
"none"
)]
#[case::basic(
Some(serde_json::json!(["client_secret_basic"])),
"client_secret_basic"
)]
#[case::post(
Some(serde_json::json!(["client_secret_post"])),
"client_secret_post"
)]
#[case::both(
Some(serde_json::json!(["client_secret_post", "client_secret_basic"])),
"client_secret_basic"
)]
#[tokio::test]
async fn registers_with_supported_authentication(
#[case] methods: Option<serde_json::Value>,
#[case] expected: &str,
) {
let secret = (expected != "none").then_some("registered-secret");
let (mut manager, client) = registration_fixture(methods, secret).await;
let config = manager
.register_client("test-client", "http://localhost/callback", &["tools"])
.await
.unwrap();
let requests = client.requests();
assert_eq!(requests.len(), 1);
let registration: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(registration["token_endpoint_auth_method"], expected);
assert_eq!(config.client_secret.as_deref(), secret);
if expected != "none" {
assert_eq!(
matches!(
manager.oauth_client.as_ref().unwrap().auth_type(),
AuthType::RequestBody
),
expected == "client_secret_post"
);
}
Comment on lines +6239 to +6247

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comparing a matches! result to a bool inside one assert_eq! is hard to read; naming the boolean first makes the intent clearer.

Suggested change
if expected != "none" {
assert_eq!(
matches!(
manager.oauth_client.as_ref().unwrap().auth_type(),
AuthType::RequestBody
),
expected == "client_secret_post"
);
}
if expected != "none" {
let uses_post = matches!(
manager.oauth_client.as_ref().unwrap().auth_type(),
AuthType::RequestBody
);
assert_eq!(uses_post, expected == "client_secret_post");
}

}

#[rstest]
#[case::unsupported(serde_json::json!(["private_key_jwt"]))]
#[case::empty(serde_json::json!([]))]
#[case::malformed(serde_json::json!("client_secret_post"))]
#[tokio::test]
async fn rejects_unsupported_registration_authentication(#[case] methods: serde_json::Value) {
let (mut manager, client) = registration_fixture(Some(methods), None).await;
let error = manager
.register_client("test-client", "http://localhost/callback", &[])
.await
.unwrap_err();
assert!(matches!(error, AuthError::RegistrationFailed(_)));
assert!(client.requests().is_empty());
}

#[rstest]
#[case::missing(None)]
#[case::empty(Some(""))]
#[tokio::test]
async fn rejects_missing_confidential_registration_secret(#[case] secret: Option<&str>) {
let (mut manager, client) =
registration_fixture(Some(serde_json::json!(["client_secret_post"])), secret).await;
let error = manager
.register_client("test-client", "http://localhost/callback", &[])
.await
.unwrap_err();
assert!(matches!(error, AuthError::RegistrationFailed(_)));
assert_eq!(client.requests().len(), 1);
}

#[tokio::test]
async fn cimd_rejects_non_https_client_metadata_url_and_recovers_state() {
let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses());
Expand Down
Loading