Skip to content

Commit a3c21ba

Browse files
committed
Merge remote-tracking branch 'upstream/main'
2 parents dc05f37 + c1a8b29 commit a3c21ba

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

crates/rmcp/src/transport/auth.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,7 @@ pub struct AuthorizationMetadata {
542542

543543
#[derive(Debug, Clone, Deserialize)]
544544
struct ResourceServerMetadata {
545+
resource: Option<String>,
545546
authorization_server: Option<String>,
546547
authorization_servers: Option<Vec<String>>,
547548
scopes_supported: Option<Vec<String>>,
@@ -1839,6 +1840,8 @@ impl AuthorizationManager {
18391840
return Ok(None);
18401841
};
18411842

1843+
self.validate_resource_metadata_resource(&resource_metadata)?;
1844+
18421845
// store scopes_supported from protected resource metadata for select_scopes()
18431846
if let Some(scopes) = resource_metadata.scopes_supported {
18441847
if !scopes.is_empty() {
@@ -1892,6 +1895,39 @@ impl AuthorizationManager {
18921895
Ok(None)
18931896
}
18941897

1898+
fn validate_resource_metadata_resource(
1899+
&self,
1900+
metadata: &ResourceServerMetadata,
1901+
) -> Result<(), AuthError> {
1902+
let Some(resource) = metadata.resource.as_deref() else {
1903+
return Err(AuthError::MetadataError(
1904+
"Protected resource metadata missing required resource field".to_string(),
1905+
));
1906+
};
1907+
1908+
if !Self::resource_identifiers_match(self.base_url.as_str(), resource) {
1909+
return Err(AuthError::MetadataError(format!(
1910+
"Protected resource metadata resource mismatch: expected '{}', got '{}'",
1911+
self.base_url, resource
1912+
)));
1913+
}
1914+
1915+
Ok(())
1916+
}
1917+
1918+
fn resource_identifiers_match(expected: &str, actual: &str) -> bool {
1919+
expected == actual
1920+
|| (Self::is_root_resource_identifier(expected)
1921+
&& actual == expected.trim_end_matches('/'))
1922+
|| (Self::is_root_resource_identifier(actual)
1923+
&& expected == actual.trim_end_matches('/'))
1924+
}
1925+
1926+
fn is_root_resource_identifier(value: &str) -> bool {
1927+
Url::parse(value)
1928+
.is_ok_and(|url| url.path() == "/" && url.query().is_none() && url.fragment().is_none())
1929+
}
1930+
18951931
async fn discover_resource_metadata_url(&self) -> Result<Option<Url>, AuthError> {
18961932
if let Ok(Some(resource_metadata_url)) =
18971933
self.fetch_resource_metadata_url(&self.base_url).await
@@ -3190,6 +3226,7 @@ mod tests {
31903226
http_response(
31913227
200,
31923228
serde_json::json!({
3229+
"resource": "https://mcp.example.com/mcp",
31933230
"authorization_servers": ["https://auth.example.com"]
31943231
}),
31953232
),
@@ -3315,6 +3352,7 @@ mod tests {
33153352
http_response(
33163353
200,
33173354
serde_json::json!({
3355+
"resource": "https://mcp.example.com/mcp",
33183356
"authorization_servers": [
33193357
"http://169.254.169.254/latest/meta-data/",
33203358
"https://auth.example.com"
@@ -3358,6 +3396,98 @@ mod tests {
33583396
);
33593397
}
33603398

3399+
#[tokio::test]
3400+
async fn protected_resource_discovery_rejects_mismatched_resource() {
3401+
let challenge = oauth2::http::Response::builder()
3402+
.status(401)
3403+
.header(
3404+
"www-authenticate",
3405+
r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#,
3406+
)
3407+
.body(Vec::new())
3408+
.unwrap();
3409+
let client = RecordingOAuthHttpClient::with_responses(vec![
3410+
challenge,
3411+
http_response(
3412+
200,
3413+
serde_json::json!({
3414+
"resource": "https://real.example.com/mcp",
3415+
"authorization_servers": ["https://auth.example.com"]
3416+
}),
3417+
),
3418+
]);
3419+
let manager = AuthorizationManager::new_with_oauth_http_client(
3420+
"https://mcp.example.com/mcp",
3421+
Arc::new(client.clone()),
3422+
)
3423+
.await
3424+
.unwrap();
3425+
3426+
let error = manager.discover_metadata().await.unwrap_err();
3427+
3428+
assert!(
3429+
matches!(error, AuthError::MetadataError(ref message) if message.contains("resource mismatch")),
3430+
"expected resource mismatch metadata error, got: {error:?}"
3431+
);
3432+
assert_eq!(client.requests().len(), 2);
3433+
}
3434+
3435+
#[tokio::test]
3436+
async fn protected_resource_discovery_rejects_missing_resource() {
3437+
let challenge = oauth2::http::Response::builder()
3438+
.status(401)
3439+
.header(
3440+
"www-authenticate",
3441+
r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#,
3442+
)
3443+
.body(Vec::new())
3444+
.unwrap();
3445+
let client = RecordingOAuthHttpClient::with_responses(vec![
3446+
challenge,
3447+
http_response(
3448+
200,
3449+
serde_json::json!({
3450+
"authorization_servers": ["https://auth.example.com"]
3451+
}),
3452+
),
3453+
]);
3454+
let manager = AuthorizationManager::new_with_oauth_http_client(
3455+
"https://mcp.example.com/mcp",
3456+
Arc::new(client.clone()),
3457+
)
3458+
.await
3459+
.unwrap();
3460+
3461+
let error = manager.discover_metadata().await.unwrap_err();
3462+
3463+
assert!(
3464+
matches!(error, AuthError::MetadataError(ref message) if message.contains("missing required resource")),
3465+
"expected missing resource metadata error, got: {error:?}"
3466+
);
3467+
assert_eq!(client.requests().len(), 2);
3468+
}
3469+
3470+
#[test]
3471+
fn resource_identifier_matching_allows_only_root_trailing_slash_difference() {
3472+
assert!(AuthorizationManager::resource_identifiers_match(
3473+
"https://mcp.example.com/",
3474+
"https://mcp.example.com"
3475+
));
3476+
assert!(AuthorizationManager::resource_identifiers_match(
3477+
"https://mcp.example.com",
3478+
"https://mcp.example.com/"
3479+
));
3480+
3481+
assert!(!AuthorizationManager::resource_identifiers_match(
3482+
"https://mcp.example.com/mcp",
3483+
"https://mcp.example.com/mcp/"
3484+
));
3485+
assert!(!AuthorizationManager::resource_identifiers_match(
3486+
"https://mcp.example.com/mcp",
3487+
"https://real.example.com/mcp"
3488+
));
3489+
}
3490+
33613491
#[tokio::test]
33623492
async fn custom_http_client_handles_registration_exchange_and_refresh() {
33633493
let client = RecordingOAuthHttpClient::with_responses(vec![

0 commit comments

Comments
 (0)