Skip to content

Commit 81745f1

Browse files
jamadeoalexhancock
andauthored
fix(auth): validate discovered metadata issuer (#996)
* 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 * test(auth): add required issuer to authorization-server mock metadata --------- Co-authored-by: Alex Hancock <alexhancock@block.xyz>
1 parent 77eb607 commit 81745f1

2 files changed

Lines changed: 218 additions & 7 deletions

File tree

crates/rmcp/src/transport/auth.rs

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1925,14 +1925,89 @@ impl AuthorizationManager {
19251925
}
19261926

19271927
match serde_json::from_slice::<AuthorizationMetadata>(response.body()) {
1928-
Ok(metadata) => Ok(Some(metadata)),
1928+
Ok(metadata) => {
1929+
Self::validate_authorization_metadata_issuer(discovery_url, &metadata)?;
1930+
Ok(Some(metadata))
1931+
}
19291932
Err(err) => {
19301933
debug!("Failed to parse metadata for {}: {}", discovery_url, err);
19311934
Ok(None) // malformed JSON ⇒ try next candidate
19321935
}
19331936
}
19341937
}
19351938

1939+
fn expected_issuer_for_authorization_metadata_url(discovery_url: &Url) -> Option<String> {
1940+
let path = discovery_url.path();
1941+
let oauth_prefix = "/.well-known/oauth-authorization-server";
1942+
let oidc_prefix = "/.well-known/openid-configuration";
1943+
1944+
let issuer_path = if path == oauth_prefix || path == oidc_prefix {
1945+
""
1946+
} else if let Some(suffix) = path.strip_prefix(&format!("{oauth_prefix}/")) {
1947+
// RFC 8414 path-insertion form
1948+
suffix
1949+
} else if let Some(suffix) = path.strip_prefix(&format!("{oidc_prefix}/")) {
1950+
// MCP-required OpenID Connect path-insertion compatibility form
1951+
suffix
1952+
} else if let Some(prefix) = path.strip_suffix(oidc_prefix) {
1953+
// OpenID Connect path-appended form
1954+
prefix.trim_start_matches('/')
1955+
} else {
1956+
return None;
1957+
};
1958+
1959+
let mut issuer = discovery_url.clone();
1960+
issuer.set_query(None);
1961+
issuer.set_fragment(None);
1962+
if issuer_path.is_empty() {
1963+
issuer.set_path("");
1964+
} else {
1965+
issuer.set_path(&format!("/{issuer_path}"));
1966+
}
1967+
Some(issuer.to_string())
1968+
}
1969+
1970+
fn issuer_identifiers_match(received_issuer: &str, expected_issuer: &str) -> bool {
1971+
if received_issuer == expected_issuer {
1972+
return true;
1973+
}
1974+
1975+
let trim_root_slash = |issuer: &str| -> String {
1976+
issuer
1977+
.strip_suffix('/')
1978+
.filter(|without_slash| {
1979+
Url::parse(without_slash)
1980+
.map(|url| url.path().is_empty() || url.path() == "/")
1981+
.unwrap_or(false)
1982+
})
1983+
.unwrap_or(issuer)
1984+
.to_string()
1985+
};
1986+
1987+
trim_root_slash(received_issuer) == trim_root_slash(expected_issuer)
1988+
}
1989+
1990+
fn validate_authorization_metadata_issuer(
1991+
discovery_url: &Url,
1992+
metadata: &AuthorizationMetadata,
1993+
) -> Result<(), AuthError> {
1994+
let Some(expected_issuer) =
1995+
Self::expected_issuer_for_authorization_metadata_url(discovery_url)
1996+
else {
1997+
return Ok(());
1998+
};
1999+
let Some(received_issuer) = metadata.issuer.as_deref() else {
2000+
return Err(AuthError::AuthorizationServerMissingIssuer { expected_issuer });
2001+
};
2002+
if !Self::issuer_identifiers_match(received_issuer, &expected_issuer) {
2003+
return Err(AuthError::AuthorizationServerMismatch {
2004+
expected_issuer,
2005+
received_issuer: received_issuer.to_string(),
2006+
});
2007+
}
2008+
Ok(())
2009+
}
2010+
19362011
async fn discover_oauth_server_via_resource_metadata(
19372012
&self,
19382013
) -> Result<Option<AuthorizationMetadata>, AuthError> {
@@ -3416,6 +3491,7 @@ mod tests {
34163491
http_response(
34173492
200,
34183493
serde_json::json!({
3494+
"issuer": "https://auth.example.com",
34193495
"authorization_endpoint": "https://auth.example.com/authorize",
34203496
"token_endpoint": "https://auth.example.com/token"
34213497
}),
@@ -3516,6 +3592,125 @@ mod tests {
35163592
);
35173593
}
35183594

3595+
#[tokio::test]
3596+
async fn authorization_metadata_rejects_mismatched_issuer() {
3597+
let client = RecordingOAuthHttpClient::with_responses(vec![
3598+
empty_response(401),
3599+
http_response(
3600+
200,
3601+
serde_json::json!({
3602+
"resource": "https://mcp.example.com/",
3603+
"authorization_servers": ["https://auth.example.com/tenant1"]
3604+
}),
3605+
),
3606+
http_response(
3607+
200,
3608+
serde_json::json!({
3609+
"resource": "https://mcp.example.com/",
3610+
"authorization_servers": ["https://auth.example.com/tenant1"]
3611+
}),
3612+
),
3613+
http_response(
3614+
200,
3615+
serde_json::json!({
3616+
"issuer": "https://evil.example.com/tenant1",
3617+
"authorization_endpoint": "https://evil.example.com/tenant1/authorize",
3618+
"token_endpoint": "https://evil.example.com/tenant1/token"
3619+
}),
3620+
),
3621+
]);
3622+
let manager = AuthorizationManager::new_with_oauth_http_client(
3623+
"https://mcp.example.com/",
3624+
Arc::new(client),
3625+
)
3626+
.await
3627+
.unwrap();
3628+
3629+
let error = manager.discover_metadata().await.unwrap_err();
3630+
3631+
assert!(
3632+
matches!(
3633+
error,
3634+
AuthError::AuthorizationServerMismatch {
3635+
ref expected_issuer,
3636+
ref received_issuer
3637+
} if expected_issuer == "https://auth.example.com/tenant1"
3638+
&& received_issuer == "https://evil.example.com/tenant1"
3639+
),
3640+
"expected authorization server issuer mismatch, got: {error:?}"
3641+
);
3642+
}
3643+
3644+
#[test]
3645+
fn authorization_metadata_accepts_oidc_path_appended_issuer() {
3646+
let discovery_url =
3647+
Url::parse("https://auth.example.com/tenant1/.well-known/openid-configuration")
3648+
.unwrap();
3649+
let metadata = AuthorizationMetadata {
3650+
issuer: Some("https://auth.example.com/tenant1".to_string()),
3651+
authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(),
3652+
token_endpoint: "https://auth.example.com/tenant1/token".to_string(),
3653+
..Default::default()
3654+
};
3655+
3656+
AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata)
3657+
.unwrap();
3658+
}
3659+
3660+
#[test]
3661+
fn authorization_metadata_allows_only_root_trailing_slash_equivalence() {
3662+
assert!(AuthorizationManager::issuer_identifiers_match(
3663+
"https://auth.example.com/",
3664+
"https://auth.example.com"
3665+
));
3666+
assert!(!AuthorizationManager::issuer_identifiers_match(
3667+
"https://auth.example.com/tenant1/",
3668+
"https://auth.example.com/tenant1"
3669+
));
3670+
}
3671+
3672+
#[test]
3673+
fn authorization_metadata_accepts_oidc_path_inserted_issuer() {
3674+
let discovery_url =
3675+
Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1")
3676+
.unwrap();
3677+
let metadata = AuthorizationMetadata {
3678+
issuer: Some("https://auth.example.com/tenant1".to_string()),
3679+
authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(),
3680+
token_endpoint: "https://auth.example.com/tenant1/token".to_string(),
3681+
..Default::default()
3682+
};
3683+
3684+
AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata)
3685+
.unwrap();
3686+
}
3687+
3688+
#[test]
3689+
fn authorization_metadata_rejects_missing_issuer_for_standard_discovery_url() {
3690+
let discovery_url =
3691+
Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1")
3692+
.unwrap();
3693+
let metadata = AuthorizationMetadata {
3694+
issuer: None,
3695+
authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(),
3696+
token_endpoint: "https://auth.example.com/tenant1/token".to_string(),
3697+
..Default::default()
3698+
};
3699+
3700+
let error =
3701+
AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata)
3702+
.unwrap_err();
3703+
3704+
assert!(
3705+
matches!(
3706+
error,
3707+
AuthError::AuthorizationServerMissingIssuer { ref expected_issuer }
3708+
if expected_issuer == "https://auth.example.com/tenant1"
3709+
),
3710+
"expected missing issuer error, got: {error:?}"
3711+
);
3712+
}
3713+
35193714
#[tokio::test]
35203715
async fn protected_resource_metadata_supports_custom_location_and_oidc_path_append() {
35213716
let challenge = oauth2::http::Response::builder()
@@ -3721,6 +3916,7 @@ mod tests {
37213916
http_response(
37223917
200,
37233918
serde_json::json!({
3919+
"issuer": "https://auth.example.com",
37243920
"authorization_endpoint": "https://auth.example.com/authorize",
37253921
"token_endpoint": "https://auth.example.com/token"
37263922
}),

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)