Skip to content

Commit f230960

Browse files
committed
fix(auth): validate discovered metadata issuer
Validate authorization server metadata issuers against the issuer implied by standard discovery URLs before trusting advertised endpoints. fixes #983
1 parent 98bb263 commit f230960

2 files changed

Lines changed: 216 additions & 7 deletions

File tree

crates/rmcp/src/transport/auth.rs

Lines changed: 195 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1876,14 +1876,89 @@ impl AuthorizationManager {
18761876
}
18771877

18781878
match serde_json::from_slice::<AuthorizationMetadata>(response.body()) {
1879-
Ok(metadata) => Ok(Some(metadata)),
1879+
Ok(metadata) => {
1880+
Self::validate_authorization_metadata_issuer(discovery_url, &metadata)?;
1881+
Ok(Some(metadata))
1882+
}
18801883
Err(err) => {
18811884
debug!("Failed to parse metadata for {}: {}", discovery_url, err);
18821885
Ok(None) // malformed JSON ⇒ try next candidate
18831886
}
18841887
}
18851888
}
18861889

1890+
fn expected_issuer_for_authorization_metadata_url(discovery_url: &Url) -> Option<String> {
1891+
let path = discovery_url.path();
1892+
let oauth_prefix = "/.well-known/oauth-authorization-server";
1893+
let oidc_prefix = "/.well-known/openid-configuration";
1894+
1895+
let issuer_path = if path == oauth_prefix || path == oidc_prefix {
1896+
""
1897+
} else if let Some(suffix) = path.strip_prefix(&format!("{oauth_prefix}/")) {
1898+
// RFC 8414 path-insertion form
1899+
suffix
1900+
} else if let Some(suffix) = path.strip_prefix(&format!("{oidc_prefix}/")) {
1901+
// MCP-required OpenID Connect path-insertion compatibility form
1902+
suffix
1903+
} else if let Some(prefix) = path.strip_suffix(oidc_prefix) {
1904+
// OpenID Connect path-appended form
1905+
prefix.trim_start_matches('/')
1906+
} else {
1907+
return None;
1908+
};
1909+
1910+
let mut issuer = discovery_url.clone();
1911+
issuer.set_query(None);
1912+
issuer.set_fragment(None);
1913+
if issuer_path.is_empty() {
1914+
issuer.set_path("");
1915+
} else {
1916+
issuer.set_path(&format!("/{issuer_path}"));
1917+
}
1918+
Some(issuer.to_string())
1919+
}
1920+
1921+
fn issuer_identifiers_match(received_issuer: &str, expected_issuer: &str) -> bool {
1922+
if received_issuer == expected_issuer {
1923+
return true;
1924+
}
1925+
1926+
let trim_root_slash = |issuer: &str| -> String {
1927+
issuer
1928+
.strip_suffix('/')
1929+
.filter(|without_slash| {
1930+
Url::parse(without_slash)
1931+
.map(|url| url.path().is_empty() || url.path() == "/")
1932+
.unwrap_or(false)
1933+
})
1934+
.unwrap_or(issuer)
1935+
.to_string()
1936+
};
1937+
1938+
trim_root_slash(received_issuer) == trim_root_slash(expected_issuer)
1939+
}
1940+
1941+
fn validate_authorization_metadata_issuer(
1942+
discovery_url: &Url,
1943+
metadata: &AuthorizationMetadata,
1944+
) -> Result<(), AuthError> {
1945+
let Some(expected_issuer) =
1946+
Self::expected_issuer_for_authorization_metadata_url(discovery_url)
1947+
else {
1948+
return Ok(());
1949+
};
1950+
let Some(received_issuer) = metadata.issuer.as_deref() else {
1951+
return Err(AuthError::AuthorizationServerMissingIssuer { expected_issuer });
1952+
};
1953+
if !Self::issuer_identifiers_match(received_issuer, &expected_issuer) {
1954+
return Err(AuthError::AuthorizationServerMismatch {
1955+
expected_issuer,
1956+
received_issuer: received_issuer.to_string(),
1957+
});
1958+
}
1959+
Ok(())
1960+
}
1961+
18871962
async fn discover_oauth_server_via_resource_metadata(
18881963
&self,
18891964
) -> Result<Option<AuthorizationMetadata>, AuthError> {
@@ -3467,6 +3542,125 @@ mod tests {
34673542
);
34683543
}
34693544

3545+
#[tokio::test]
3546+
async fn authorization_metadata_rejects_mismatched_issuer() {
3547+
let client = RecordingOAuthHttpClient::with_responses(vec![
3548+
empty_response(401),
3549+
http_response(
3550+
200,
3551+
serde_json::json!({
3552+
"resource": "https://mcp.example.com/",
3553+
"authorization_servers": ["https://auth.example.com/tenant1"]
3554+
}),
3555+
),
3556+
http_response(
3557+
200,
3558+
serde_json::json!({
3559+
"resource": "https://mcp.example.com/",
3560+
"authorization_servers": ["https://auth.example.com/tenant1"]
3561+
}),
3562+
),
3563+
http_response(
3564+
200,
3565+
serde_json::json!({
3566+
"issuer": "https://evil.example.com/tenant1",
3567+
"authorization_endpoint": "https://evil.example.com/tenant1/authorize",
3568+
"token_endpoint": "https://evil.example.com/tenant1/token"
3569+
}),
3570+
),
3571+
]);
3572+
let manager = AuthorizationManager::new_with_oauth_http_client(
3573+
"https://mcp.example.com/",
3574+
Arc::new(client),
3575+
)
3576+
.await
3577+
.unwrap();
3578+
3579+
let error = manager.discover_metadata().await.unwrap_err();
3580+
3581+
assert!(
3582+
matches!(
3583+
error,
3584+
AuthError::AuthorizationServerMismatch {
3585+
ref expected_issuer,
3586+
ref received_issuer
3587+
} if expected_issuer == "https://auth.example.com/tenant1"
3588+
&& received_issuer == "https://evil.example.com/tenant1"
3589+
),
3590+
"expected authorization server issuer mismatch, got: {error:?}"
3591+
);
3592+
}
3593+
3594+
#[test]
3595+
fn authorization_metadata_accepts_oidc_path_appended_issuer() {
3596+
let discovery_url =
3597+
Url::parse("https://auth.example.com/tenant1/.well-known/openid-configuration")
3598+
.unwrap();
3599+
let metadata = AuthorizationMetadata {
3600+
issuer: Some("https://auth.example.com/tenant1".to_string()),
3601+
authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(),
3602+
token_endpoint: "https://auth.example.com/tenant1/token".to_string(),
3603+
..Default::default()
3604+
};
3605+
3606+
AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata)
3607+
.unwrap();
3608+
}
3609+
3610+
#[test]
3611+
fn authorization_metadata_allows_only_root_trailing_slash_equivalence() {
3612+
assert!(AuthorizationManager::issuer_identifiers_match(
3613+
"https://auth.example.com/",
3614+
"https://auth.example.com"
3615+
));
3616+
assert!(!AuthorizationManager::issuer_identifiers_match(
3617+
"https://auth.example.com/tenant1/",
3618+
"https://auth.example.com/tenant1"
3619+
));
3620+
}
3621+
3622+
#[test]
3623+
fn authorization_metadata_accepts_oidc_path_inserted_issuer() {
3624+
let discovery_url =
3625+
Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1")
3626+
.unwrap();
3627+
let metadata = AuthorizationMetadata {
3628+
issuer: Some("https://auth.example.com/tenant1".to_string()),
3629+
authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(),
3630+
token_endpoint: "https://auth.example.com/tenant1/token".to_string(),
3631+
..Default::default()
3632+
};
3633+
3634+
AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata)
3635+
.unwrap();
3636+
}
3637+
3638+
#[test]
3639+
fn authorization_metadata_rejects_missing_issuer_for_standard_discovery_url() {
3640+
let discovery_url =
3641+
Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1")
3642+
.unwrap();
3643+
let metadata = AuthorizationMetadata {
3644+
issuer: None,
3645+
authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(),
3646+
token_endpoint: "https://auth.example.com/tenant1/token".to_string(),
3647+
..Default::default()
3648+
};
3649+
3650+
let error =
3651+
AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata)
3652+
.unwrap_err();
3653+
3654+
assert!(
3655+
matches!(
3656+
error,
3657+
AuthError::AuthorizationServerMissingIssuer { ref expected_issuer }
3658+
if expected_issuer == "https://auth.example.com/tenant1"
3659+
),
3660+
"expected missing issuer error, got: {error:?}"
3661+
);
3662+
}
3663+
34703664
#[tokio::test]
34713665
async fn protected_resource_metadata_supports_custom_location_and_oidc_path_append() {
34723666
let challenge = oauth2::http::Response::builder()

crates/rmcp/tests/test_client_credentials.rs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,29 @@ async fn resource_metadata_handler(req: Request<Body>) -> Result<Response<Body>,
3737
async fn auth_server_metadata_handler(req: Request<Body>) -> Result<Response<Body>, Infallible> {
3838
let host = req.headers().get("host").unwrap().to_str().unwrap();
3939
let base_url = format!("http://{}", host);
40-
Ok(json_response(serde_json::json!({
41-
"issuer": base_url,
42-
"authorization_endpoint": format!("{}/authorize", base_url),
43-
"token_endpoint": format!("{}/token", base_url),
40+
Ok(auth_server_metadata_response(&base_url, &base_url))
41+
}
42+
43+
async fn path_inserted_auth_server_metadata_handler(
44+
req: Request<Body>,
45+
) -> Result<Response<Body>, Infallible> {
46+
let host = req.headers().get("host").unwrap().to_str().unwrap();
47+
let base_url = format!("http://{}", host);
48+
Ok(auth_server_metadata_response(
49+
&format!("{}/mcp", base_url),
50+
&base_url,
51+
))
52+
}
53+
54+
fn auth_server_metadata_response(issuer: &str, endpoint_base_url: &str) -> Response<Body> {
55+
json_response(serde_json::json!({
56+
"issuer": issuer,
57+
"authorization_endpoint": format!("{}/authorize", endpoint_base_url),
58+
"token_endpoint": format!("{}/token", endpoint_base_url),
4459
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
4560
"grant_types_supported": ["client_credentials"],
4661
"scopes_supported": ["read", "write"]
47-
})))
62+
}))
4863
}
4964

5065
async fn token_handler(req: Request<Body>) -> Result<Response<Body>, Infallible> {
@@ -144,7 +159,7 @@ async fn start_path_insert_metadata_server() -> (String, SocketAddr) {
144159
let app = Router::new()
145160
.route(
146161
"/.well-known/oauth-authorization-server/mcp",
147-
get(auth_server_metadata_handler),
162+
get(path_inserted_auth_server_metadata_handler),
148163
)
149164
.route("/token", post(token_handler));
150165

0 commit comments

Comments
 (0)