@@ -542,6 +542,7 @@ pub struct AuthorizationMetadata {
542542
543543#[ derive( Debug , Clone , Deserialize ) ]
544544struct 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