RFC 9728 resource is used instead of base url when possible (#962)

* feat(transport)!: use declared OAuth resource

* fix(transport): validate OAuth resource paths

---------

Co-authored-by: Filip Konečný <filip.konecny@thermofisher.com>
This commit is contained in:
Filip Konečný 2026-07-28 21:08:25 +02:00 • committed by GitHub
commit b429fc1240
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1029,6 +1029,8 @@ pub struct AuthorizationManager {
www_auth_scopes: RwLock<Vec<String>>, www_auth_scopes: RwLock<Vec<String>>,
/// scopes_supported from protected resource metadata (RFC 9728) /// scopes_supported from protected resource metadata (RFC 9728)
resource_scopes: RwLock<Vec<String>>, resource_scopes: RwLock<Vec<String>>,
/// resource indicator from protected resource metadata, used for RFC 8707 `resource`
discovered_resource: RwLock<Option<String>>,
/// OIDC Dynamic Client Registration `application_type` (SEP-837) /// OIDC Dynamic Client Registration `application_type` (SEP-837)
application_type: Option<String>, application_type: Option<String>,
allow_missing_issuer: bool, allow_missing_issuer: bool,
@ -1276,6 +1278,7 @@ impl AuthorizationManager {
scope_upgrade_config: ScopeUpgradeConfig::default(), scope_upgrade_config: ScopeUpgradeConfig::default(),
www_auth_scopes: RwLock::new(Vec::new()), www_auth_scopes: RwLock::new(Vec::new()),
resource_scopes: RwLock::new(Vec::new()), resource_scopes: RwLock::new(Vec::new()),
discovered_resource: RwLock::new(None),
application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()),
allow_missing_issuer: false, allow_missing_issuer: false,
}; };
@ -1745,7 +1748,7 @@ impl AuthorizationManager {
let mut auth_request = oauth_client let mut auth_request = oauth_client
.authorize_url(CsrfToken::new_random) .authorize_url(CsrfToken::new_random)
.set_pkce_challenge(pkce_challenge) .set_pkce_challenge(pkce_challenge)
.add_extra_param("resource", self.base_url.to_string()); .add_extra_param("resource", self.oauth_resource().await);
// add request scopes // add request scopes
for scope in scopes { for scope in scopes {
@ -1783,6 +1786,14 @@ impl AuthorizationManager {
Ok(auth_url.to_string()) Ok(auth_url.to_string())
} }
async fn oauth_resource(&self) -> String {
self.discovered_resource
.read()
.await
.clone()
.unwrap_or_else(|| self.base_url.to_string())
}
/// get the current granted scopes /// get the current granted scopes
pub async fn get_current_scopes(&self) -> Vec<String> { pub async fn get_current_scopes(&self) -> Vec<String> {
self.current_scopes.read().await.clone() self.current_scopes.read().await.clone()
@ -2024,7 +2035,7 @@ impl AuthorizationManager {
let token_result = match oauth_client let token_result = match oauth_client
.exchange_code(AuthorizationCode::new(code.to_string())) .exchange_code(AuthorizationCode::new(code.to_string()))
.set_pkce_verifier(pkce_verifier) .set_pkce_verifier(pkce_verifier)
.add_extra_param("resource", self.base_url.to_string()) .add_extra_param("resource", self.oauth_resource().await)
.request_async(&OAuth2HttpClient { .request_async(&OAuth2HttpClient {
client: self.http_client.as_ref(), client: self.http_client.as_ref(),
redirect_policy: OAuthHttpRedirectPolicy::Stop, redirect_policy: OAuthHttpRedirectPolicy::Stop,
@ -2422,6 +2433,11 @@ impl AuthorizationManager {
self.validate_resource_metadata_resource(&resource_metadata)?; self.validate_resource_metadata_resource(&resource_metadata)?;
self.discovered_resource
.write()
.await
.replace(resource_metadata.resource.clone().unwrap_or_default());
// store scopes_supported from protected resource metadata for select_scopes() // store scopes_supported from protected resource metadata for select_scopes()
if let Some(scopes) = resource_metadata.scopes_supported if let Some(scopes) = resource_metadata.scopes_supported
&& !scopes.is_empty() && !scopes.is_empty()
@ -2485,9 +2501,21 @@ impl AuthorizationManager {
)); ));
}; };
if !Self::resource_identifiers_match(self.base_url.as_str(), resource) { let Ok(resource_url) = Url::parse(resource) else {
return Err(AuthError::MetadataError(
"Protected resource metadata resource field is not a valid URL".to_string(),
));
};
if resource_url.fragment().is_some() {
return Err(AuthError::MetadataError(
"Protected resource metadata resource does not permit fragment in URL as specified by RFC 8707".to_string()
));
}
if !Self::is_resource_identifier_valid(&self.base_url, &resource_url) {
return Err(AuthError::MetadataError(format!( return Err(AuthError::MetadataError(format!(
"Protected resource metadata resource mismatch: expected '{}', got '{}'", "Protected resource metadata resource mismatch: reference '{}', permitted '{}'",
self.base_url, resource self.base_url, resource
))); )));
} }
@ -2495,33 +2523,31 @@ impl AuthorizationManager {
Ok(()) Ok(())
} }
fn resource_identifiers_match(expected: &str, actual: &str) -> bool { fn is_resource_identifier_valid(expected: &Url, actual: &Url) -> bool {
expected == actual if expected == actual {
|| (Self::is_root_resource_identifier(expected) return true;
&& actual == expected.trim_end_matches('/')) }
|| (Self::is_root_resource_identifier(actual)
&& expected == actual.trim_end_matches('/'))
|| Self::root_resource_identifier_covers_path(actual, expected)
}
fn is_root_resource_identifier(value: &str) -> bool { if expected.scheme() != actual.scheme()
Url::parse(value) || expected.host_str() != actual.host_str()
.is_ok_and(|url| url.path() == "/" && url.query().is_none() && url.fragment().is_none()) || expected.port_or_known_default() != actual.port_or_known_default()
} {
fn root_resource_identifier_covers_path(root_resource: &str, path_resource: &str) -> bool {
let Ok(root_resource) = Url::parse(root_resource) else {
return false; return false;
}; }
let Ok(path_resource) = Url::parse(path_resource) else {
return false;
};
root_resource.path() == "/" let expected_path = expected.path();
&& root_resource.query().is_none() let actual_path = actual.path();
&& root_resource.fragment().is_none()
&& path_resource.path() != "/" // URL query part supported, even if it is discouraged in RFC 8707
&& Self::is_same_origin(&root_resource, &path_resource) if expected_path == actual_path && expected.query() == actual.query() {
return true;
}
expected_path.starts_with(actual_path)
&& expected.query().is_none()
&& actual.query().is_none()
&& (actual_path.ends_with('/')
|| expected_path.as_bytes().get(actual_path.len()) == Some(&b'/'))
} }
async fn discover_resource_metadata_url(&self) -> Result<Option<Url>, AuthError> { async fn discover_resource_metadata_url(&self) -> Result<Option<Url>, AuthError> {
@ -4075,7 +4101,7 @@ mod tests {
http_response( http_response(
200, 200,
serde_json::json!({ serde_json::json!({
"resource": "https://mcp.example.com/mcp", "resource": "https://mcp.example.com",
"authorization_servers": ["https://auth.example.com"] "authorization_servers": ["https://auth.example.com"]
}), }),
), ),
@ -4098,6 +4124,10 @@ mod tests {
let metadata = manager.resolve_metadata().await.unwrap().metadata; let metadata = manager.resolve_metadata().await.unwrap().metadata;
assert_eq!(metadata.token_endpoint, "https://auth.example.com/token"); assert_eq!(metadata.token_endpoint, "https://auth.example.com/token");
assert_eq!(
manager.discovered_resource.read().await.as_deref(),
Some("https://mcp.example.com")
);
assert_eq!( assert_eq!(
client.requests(), client.requests(),
vec![ vec![
@ -5325,35 +5355,55 @@ mod tests {
} }
#[test] #[test]
fn resource_identifier_matching_allows_only_root_trailing_slash_difference() { fn resource_identifier_matching_allows_matching_host_or_parent_path() {
assert!(AuthorizationManager::resource_identifiers_match( assert!(AuthorizationManager::is_resource_identifier_valid(
"https://mcp.example.com/", &Url::parse("https://mcp.example.com/").unwrap(),
"https://mcp.example.com" &Url::parse("https://mcp.example.com").unwrap()
)); ));
assert!(AuthorizationManager::resource_identifiers_match( assert!(AuthorizationManager::is_resource_identifier_valid(
"https://mcp.example.com", &Url::parse("https://mcp.example.com").unwrap(),
"https://mcp.example.com/" &Url::parse("https://mcp.example.com/").unwrap()
)); ));
assert!(AuthorizationManager::resource_identifiers_match( assert!(AuthorizationManager::is_resource_identifier_valid(
"https://mcp.example.com/mcp", &Url::parse("https://mcp.example.com/mcp").unwrap(),
"https://mcp.example.com" &Url::parse("https://mcp.example.com").unwrap()
));
assert!(AuthorizationManager::is_resource_identifier_valid(
&Url::parse("https://mcp.example.com/mcp/tools").unwrap(),
&Url::parse("https://mcp.example.com/mcp").unwrap()
));
assert!(AuthorizationManager::is_resource_identifier_valid(
&Url::parse("https://mcp.example.com/mcp?query=param").unwrap(),
&Url::parse("https://mcp.example.com/mcp?query=param").unwrap()
)); ));
assert!(!AuthorizationManager::resource_identifiers_match( assert!(!AuthorizationManager::is_resource_identifier_valid(
"https://mcp.example.com/mcp", &Url::parse("https://mcp.example.com/mcp").unwrap(),
"https://mcp.example.com/mcp/" &Url::parse("https://mcp.example.com/mcp/").unwrap()
)); ));
assert!(!AuthorizationManager::resource_identifiers_match( assert!(!AuthorizationManager::is_resource_identifier_valid(
"https://mcp.example.com/mcp", &Url::parse("https://mcp.example.com/mcp-tools").unwrap(),
"https://real.example.com/mcp" &Url::parse("https://mcp.example.com/mcp").unwrap()
)); ));
assert!(!AuthorizationManager::resource_identifiers_match( assert!(!AuthorizationManager::is_resource_identifier_valid(
"https://mcp.example.com/mcp", &Url::parse("https://mcp.example.com/mcp").unwrap(),
"https://real.example.com" &Url::parse("https://mcp.example.com/mcp-tools").unwrap()
)); ));
assert!(!AuthorizationManager::resource_identifiers_match( assert!(!AuthorizationManager::is_resource_identifier_valid(
"https://mcp.example.com/mcp", &Url::parse("https://mcp.example.com/mcp").unwrap(),
"https://mcp.example.com?resource=mcp" &Url::parse("https://mcp.example.com/mcp/tools").unwrap()
));
assert!(!AuthorizationManager::is_resource_identifier_valid(
&Url::parse("https://mcp.example.com/mcp").unwrap(),
&Url::parse("https://real.example.com/mcp").unwrap()
));
assert!(!AuthorizationManager::is_resource_identifier_valid(
&Url::parse("https://mcp.example.com/mcp").unwrap(),
&Url::parse("https://mcp.example.com/mcp?query=value1").unwrap()
));
assert!(!AuthorizationManager::is_resource_identifier_valid(
&Url::parse("https://mcp.example.com/mcp?query=value1").unwrap(),
&Url::parse("https://mcp.example.com/mcp?query=value2").unwrap()
)); ));
} }
@ -6313,6 +6363,64 @@ mod tests {
assert!(scope.contains("write")); assert!(scope.contains("write"));
} }
#[tokio::test]
async fn authorization_url_uses_discovered_resource() {
let base_url = "https://mcp.example.com/mcp";
let auth_endpoint = "https://auth.example.com/authorize";
let mut manager = AuthorizationManager::new(base_url).await.unwrap();
let metadata = AuthorizationMetadata {
authorization_endpoint: auth_endpoint.to_string(),
token_endpoint: "https://auth.example.com/token".to_string(),
registration_endpoint: None,
issuer: None,
jwks_uri: None,
scopes_supported: None,
response_types_supported: Some(vec!["code".to_string()]),
code_challenge_methods_supported: Some(vec!["S256".to_string()]),
additional_fields: std::collections::HashMap::new(),
};
manager.set_metadata(metadata);
manager.configure_client_id("test-client-id").unwrap();
*manager.discovered_resource.write().await = Some("https://mcp.example.com".to_string());
let auth_url = manager.get_authorization_url(&["read"]).await.unwrap();
let parsed = Url::parse(&auth_url).unwrap();
let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect();
assert_eq!(
params.get("resource").map(|v| v.as_ref()),
Some("https://mcp.example.com")
);
}
#[tokio::test]
async fn authorization_url_uses_default_resource_without_protected_resource_document() {
let base_url = "https://mcp.example.com/mcp";
let auth_endpoint = "https://auth.example.com/authorize";
let mut manager = AuthorizationManager::new(base_url).await.unwrap();
let metadata = AuthorizationMetadata {
authorization_endpoint: auth_endpoint.to_string(),
token_endpoint: "https://auth.example.com/token".to_string(),
registration_endpoint: None,
issuer: None,
jwks_uri: None,
scopes_supported: None,
response_types_supported: Some(vec!["code".to_string()]),
code_challenge_methods_supported: Some(vec!["S256".to_string()]),
additional_fields: std::collections::HashMap::new(),
};
manager.set_metadata(metadata);
manager.configure_client_id("test-client-id").unwrap();
let auth_url = manager.get_authorization_url(&["read"]).await.unwrap();
let parsed = Url::parse(&auth_url).unwrap();
let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect();
assert_eq!(params.get("resource").map(|v| v.as_ref()), Some(base_url));
}
#[test] #[test]
fn authorization_callback_parses_optional_issuer() { fn authorization_callback_parses_optional_issuer() {
let callback = AuthorizationCallback::from_redirect_url( let callback = AuthorizationCallback::from_redirect_url(