@@ -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 } ) ,
0 commit comments