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>
This commit is contained in:
parent
77eb607ccb
commit
81745f1375
2 changed files with 218 additions and 7 deletions
|
|
@ -1925,7 +1925,10 @@ impl AuthorizationManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
match serde_json::from_slice::<AuthorizationMetadata>(response.body()) {
|
match serde_json::from_slice::<AuthorizationMetadata>(response.body()) {
|
||||||
Ok(metadata) => Ok(Some(metadata)),
|
Ok(metadata) => {
|
||||||
|
Self::validate_authorization_metadata_issuer(discovery_url, &metadata)?;
|
||||||
|
Ok(Some(metadata))
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
debug!("Failed to parse metadata for {}: {}", discovery_url, err);
|
debug!("Failed to parse metadata for {}: {}", discovery_url, err);
|
||||||
Ok(None) // malformed JSON ⇒ try next candidate
|
Ok(None) // malformed JSON ⇒ try next candidate
|
||||||
|
|
@ -1933,6 +1936,78 @@ impl AuthorizationManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn expected_issuer_for_authorization_metadata_url(discovery_url: &Url) -> Option<String> {
|
||||||
|
let path = discovery_url.path();
|
||||||
|
let oauth_prefix = "/.well-known/oauth-authorization-server";
|
||||||
|
let oidc_prefix = "/.well-known/openid-configuration";
|
||||||
|
|
||||||
|
let issuer_path = if path == oauth_prefix || path == oidc_prefix {
|
||||||
|
""
|
||||||
|
} else if let Some(suffix) = path.strip_prefix(&format!("{oauth_prefix}/")) {
|
||||||
|
// RFC 8414 path-insertion form
|
||||||
|
suffix
|
||||||
|
} else if let Some(suffix) = path.strip_prefix(&format!("{oidc_prefix}/")) {
|
||||||
|
// MCP-required OpenID Connect path-insertion compatibility form
|
||||||
|
suffix
|
||||||
|
} else if let Some(prefix) = path.strip_suffix(oidc_prefix) {
|
||||||
|
// OpenID Connect path-appended form
|
||||||
|
prefix.trim_start_matches('/')
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut issuer = discovery_url.clone();
|
||||||
|
issuer.set_query(None);
|
||||||
|
issuer.set_fragment(None);
|
||||||
|
if issuer_path.is_empty() {
|
||||||
|
issuer.set_path("");
|
||||||
|
} else {
|
||||||
|
issuer.set_path(&format!("/{issuer_path}"));
|
||||||
|
}
|
||||||
|
Some(issuer.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn issuer_identifiers_match(received_issuer: &str, expected_issuer: &str) -> bool {
|
||||||
|
if received_issuer == expected_issuer {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let trim_root_slash = |issuer: &str| -> String {
|
||||||
|
issuer
|
||||||
|
.strip_suffix('/')
|
||||||
|
.filter(|without_slash| {
|
||||||
|
Url::parse(without_slash)
|
||||||
|
.map(|url| url.path().is_empty() || url.path() == "/")
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.unwrap_or(issuer)
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
trim_root_slash(received_issuer) == trim_root_slash(expected_issuer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_authorization_metadata_issuer(
|
||||||
|
discovery_url: &Url,
|
||||||
|
metadata: &AuthorizationMetadata,
|
||||||
|
) -> Result<(), AuthError> {
|
||||||
|
let Some(expected_issuer) =
|
||||||
|
Self::expected_issuer_for_authorization_metadata_url(discovery_url)
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(received_issuer) = metadata.issuer.as_deref() else {
|
||||||
|
return Err(AuthError::AuthorizationServerMissingIssuer { expected_issuer });
|
||||||
|
};
|
||||||
|
if !Self::issuer_identifiers_match(received_issuer, &expected_issuer) {
|
||||||
|
return Err(AuthError::AuthorizationServerMismatch {
|
||||||
|
expected_issuer,
|
||||||
|
received_issuer: received_issuer.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn discover_oauth_server_via_resource_metadata(
|
async fn discover_oauth_server_via_resource_metadata(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Option<AuthorizationMetadata>, AuthError> {
|
) -> Result<Option<AuthorizationMetadata>, AuthError> {
|
||||||
|
|
@ -3416,6 +3491,7 @@ mod tests {
|
||||||
http_response(
|
http_response(
|
||||||
200,
|
200,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
|
"issuer": "https://auth.example.com",
|
||||||
"authorization_endpoint": "https://auth.example.com/authorize",
|
"authorization_endpoint": "https://auth.example.com/authorize",
|
||||||
"token_endpoint": "https://auth.example.com/token"
|
"token_endpoint": "https://auth.example.com/token"
|
||||||
}),
|
}),
|
||||||
|
|
@ -3516,6 +3592,125 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorization_metadata_rejects_mismatched_issuer() {
|
||||||
|
let client = RecordingOAuthHttpClient::with_responses(vec![
|
||||||
|
empty_response(401),
|
||||||
|
http_response(
|
||||||
|
200,
|
||||||
|
serde_json::json!({
|
||||||
|
"resource": "https://mcp.example.com/",
|
||||||
|
"authorization_servers": ["https://auth.example.com/tenant1"]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http_response(
|
||||||
|
200,
|
||||||
|
serde_json::json!({
|
||||||
|
"resource": "https://mcp.example.com/",
|
||||||
|
"authorization_servers": ["https://auth.example.com/tenant1"]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http_response(
|
||||||
|
200,
|
||||||
|
serde_json::json!({
|
||||||
|
"issuer": "https://evil.example.com/tenant1",
|
||||||
|
"authorization_endpoint": "https://evil.example.com/tenant1/authorize",
|
||||||
|
"token_endpoint": "https://evil.example.com/tenant1/token"
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let manager = AuthorizationManager::new_with_oauth_http_client(
|
||||||
|
"https://mcp.example.com/",
|
||||||
|
Arc::new(client),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = manager.discover_metadata().await.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
error,
|
||||||
|
AuthError::AuthorizationServerMismatch {
|
||||||
|
ref expected_issuer,
|
||||||
|
ref received_issuer
|
||||||
|
} if expected_issuer == "https://auth.example.com/tenant1"
|
||||||
|
&& received_issuer == "https://evil.example.com/tenant1"
|
||||||
|
),
|
||||||
|
"expected authorization server issuer mismatch, got: {error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authorization_metadata_accepts_oidc_path_appended_issuer() {
|
||||||
|
let discovery_url =
|
||||||
|
Url::parse("https://auth.example.com/tenant1/.well-known/openid-configuration")
|
||||||
|
.unwrap();
|
||||||
|
let metadata = AuthorizationMetadata {
|
||||||
|
issuer: Some("https://auth.example.com/tenant1".to_string()),
|
||||||
|
authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(),
|
||||||
|
token_endpoint: "https://auth.example.com/tenant1/token".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authorization_metadata_allows_only_root_trailing_slash_equivalence() {
|
||||||
|
assert!(AuthorizationManager::issuer_identifiers_match(
|
||||||
|
"https://auth.example.com/",
|
||||||
|
"https://auth.example.com"
|
||||||
|
));
|
||||||
|
assert!(!AuthorizationManager::issuer_identifiers_match(
|
||||||
|
"https://auth.example.com/tenant1/",
|
||||||
|
"https://auth.example.com/tenant1"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authorization_metadata_accepts_oidc_path_inserted_issuer() {
|
||||||
|
let discovery_url =
|
||||||
|
Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1")
|
||||||
|
.unwrap();
|
||||||
|
let metadata = AuthorizationMetadata {
|
||||||
|
issuer: Some("https://auth.example.com/tenant1".to_string()),
|
||||||
|
authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(),
|
||||||
|
token_endpoint: "https://auth.example.com/tenant1/token".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authorization_metadata_rejects_missing_issuer_for_standard_discovery_url() {
|
||||||
|
let discovery_url =
|
||||||
|
Url::parse("https://auth.example.com/.well-known/openid-configuration/tenant1")
|
||||||
|
.unwrap();
|
||||||
|
let metadata = AuthorizationMetadata {
|
||||||
|
issuer: None,
|
||||||
|
authorization_endpoint: "https://auth.example.com/tenant1/authorize".to_string(),
|
||||||
|
token_endpoint: "https://auth.example.com/tenant1/token".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let error =
|
||||||
|
AuthorizationManager::validate_authorization_metadata_issuer(&discovery_url, &metadata)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
error,
|
||||||
|
AuthError::AuthorizationServerMissingIssuer { ref expected_issuer }
|
||||||
|
if expected_issuer == "https://auth.example.com/tenant1"
|
||||||
|
),
|
||||||
|
"expected missing issuer error, got: {error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn protected_resource_metadata_supports_custom_location_and_oidc_path_append() {
|
async fn protected_resource_metadata_supports_custom_location_and_oidc_path_append() {
|
||||||
let challenge = oauth2::http::Response::builder()
|
let challenge = oauth2::http::Response::builder()
|
||||||
|
|
@ -3721,6 +3916,7 @@ mod tests {
|
||||||
http_response(
|
http_response(
|
||||||
200,
|
200,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
|
"issuer": "https://auth.example.com",
|
||||||
"authorization_endpoint": "https://auth.example.com/authorize",
|
"authorization_endpoint": "https://auth.example.com/authorize",
|
||||||
"token_endpoint": "https://auth.example.com/token"
|
"token_endpoint": "https://auth.example.com/token"
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -37,14 +37,29 @@ async fn resource_metadata_handler(req: Request<Body>) -> Result<Response<Body>,
|
||||||
async fn auth_server_metadata_handler(req: Request<Body>) -> Result<Response<Body>, Infallible> {
|
async fn auth_server_metadata_handler(req: Request<Body>) -> Result<Response<Body>, Infallible> {
|
||||||
let host = req.headers().get("host").unwrap().to_str().unwrap();
|
let host = req.headers().get("host").unwrap().to_str().unwrap();
|
||||||
let base_url = format!("http://{}", host);
|
let base_url = format!("http://{}", host);
|
||||||
Ok(json_response(serde_json::json!({
|
Ok(auth_server_metadata_response(&base_url, &base_url))
|
||||||
"issuer": base_url,
|
}
|
||||||
"authorization_endpoint": format!("{}/authorize", base_url),
|
|
||||||
"token_endpoint": format!("{}/token", base_url),
|
async fn path_inserted_auth_server_metadata_handler(
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Result<Response<Body>, Infallible> {
|
||||||
|
let host = req.headers().get("host").unwrap().to_str().unwrap();
|
||||||
|
let base_url = format!("http://{}", host);
|
||||||
|
Ok(auth_server_metadata_response(
|
||||||
|
&format!("{}/mcp", base_url),
|
||||||
|
&base_url,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_server_metadata_response(issuer: &str, endpoint_base_url: &str) -> Response<Body> {
|
||||||
|
json_response(serde_json::json!({
|
||||||
|
"issuer": issuer,
|
||||||
|
"authorization_endpoint": format!("{}/authorize", endpoint_base_url),
|
||||||
|
"token_endpoint": format!("{}/token", endpoint_base_url),
|
||||||
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
|
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
|
||||||
"grant_types_supported": ["client_credentials"],
|
"grant_types_supported": ["client_credentials"],
|
||||||
"scopes_supported": ["read", "write"]
|
"scopes_supported": ["read", "write"]
|
||||||
})))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn token_handler(req: Request<Body>) -> Result<Response<Body>, Infallible> {
|
async fn token_handler(req: Request<Body>) -> Result<Response<Body>, Infallible> {
|
||||||
|
|
@ -144,7 +159,7 @@ async fn start_path_insert_metadata_server() -> (String, SocketAddr) {
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/.well-known/oauth-authorization-server/mcp",
|
"/.well-known/oauth-authorization-server/mcp",
|
||||||
get(auth_server_metadata_handler),
|
get(path_inserted_auth_server_metadata_handler),
|
||||||
)
|
)
|
||||||
.route("/token", post(token_handler));
|
.route("/token", post(token_handler));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue