@@ -1876,14 +1876,89 @@ impl AuthorizationManager {
18761876 }
18771877
18781878 match serde_json:: from_slice :: < AuthorizationMetadata > ( response. body ( ) ) {
1879- Ok ( metadata) => Ok ( Some ( metadata) ) ,
1879+ Ok ( metadata) => {
1880+ Self :: validate_authorization_metadata_issuer ( discovery_url, & metadata) ?;
1881+ Ok ( Some ( metadata) )
1882+ }
18801883 Err ( err) => {
18811884 debug ! ( "Failed to parse metadata for {}: {}" , discovery_url, err) ;
18821885 Ok ( None ) // malformed JSON ⇒ try next candidate
18831886 }
18841887 }
18851888 }
18861889
1890+ fn expected_issuer_for_authorization_metadata_url ( discovery_url : & Url ) -> Option < String > {
1891+ let path = discovery_url. path ( ) ;
1892+ let oauth_prefix = "/.well-known/oauth-authorization-server" ;
1893+ let oidc_prefix = "/.well-known/openid-configuration" ;
1894+
1895+ let issuer_path = if path == oauth_prefix || path == oidc_prefix {
1896+ ""
1897+ } else if let Some ( suffix) = path. strip_prefix ( & format ! ( "{oauth_prefix}/" ) ) {
1898+ // RFC 8414 path-insertion form
1899+ suffix
1900+ } else if let Some ( suffix) = path. strip_prefix ( & format ! ( "{oidc_prefix}/" ) ) {
1901+ // MCP-required OpenID Connect path-insertion compatibility form
1902+ suffix
1903+ } else if let Some ( prefix) = path. strip_suffix ( oidc_prefix) {
1904+ // OpenID Connect path-appended form
1905+ prefix. trim_start_matches ( '/' )
1906+ } else {
1907+ return None ;
1908+ } ;
1909+
1910+ let mut issuer = discovery_url. clone ( ) ;
1911+ issuer. set_query ( None ) ;
1912+ issuer. set_fragment ( None ) ;
1913+ if issuer_path. is_empty ( ) {
1914+ issuer. set_path ( "" ) ;
1915+ } else {
1916+ issuer. set_path ( & format ! ( "/{issuer_path}" ) ) ;
1917+ }
1918+ Some ( issuer. to_string ( ) )
1919+ }
1920+
1921+ fn issuer_identifiers_match ( received_issuer : & str , expected_issuer : & str ) -> bool {
1922+ if received_issuer == expected_issuer {
1923+ return true ;
1924+ }
1925+
1926+ let trim_root_slash = |issuer : & str | -> String {
1927+ issuer
1928+ . strip_suffix ( '/' )
1929+ . filter ( |without_slash| {
1930+ Url :: parse ( without_slash)
1931+ . map ( |url| url. path ( ) . is_empty ( ) || url. path ( ) == "/" )
1932+ . unwrap_or ( false )
1933+ } )
1934+ . unwrap_or ( issuer)
1935+ . to_string ( )
1936+ } ;
1937+
1938+ trim_root_slash ( received_issuer) == trim_root_slash ( expected_issuer)
1939+ }
1940+
1941+ fn validate_authorization_metadata_issuer (
1942+ discovery_url : & Url ,
1943+ metadata : & AuthorizationMetadata ,
1944+ ) -> Result < ( ) , AuthError > {
1945+ let Some ( expected_issuer) =
1946+ Self :: expected_issuer_for_authorization_metadata_url ( discovery_url)
1947+ else {
1948+ return Ok ( ( ) ) ;
1949+ } ;
1950+ let Some ( received_issuer) = metadata. issuer . as_deref ( ) else {
1951+ return Err ( AuthError :: AuthorizationServerMissingIssuer { expected_issuer } ) ;
1952+ } ;
1953+ if !Self :: issuer_identifiers_match ( received_issuer, & expected_issuer) {
1954+ return Err ( AuthError :: AuthorizationServerMismatch {
1955+ expected_issuer,
1956+ received_issuer : received_issuer. to_string ( ) ,
1957+ } ) ;
1958+ }
1959+ Ok ( ( ) )
1960+ }
1961+
18871962 async fn discover_oauth_server_via_resource_metadata (
18881963 & self ,
18891964 ) -> Result < Option < AuthorizationMetadata > , AuthError > {
@@ -3467,6 +3542,125 @@ mod tests {
34673542 ) ;
34683543 }
34693544
3545+ #[ tokio:: test]
3546+ async fn authorization_metadata_rejects_mismatched_issuer ( ) {
3547+ let client = RecordingOAuthHttpClient :: with_responses ( vec ! [
3548+ empty_response( 401 ) ,
3549+ http_response(
3550+ 200 ,
3551+ serde_json:: json!( {
3552+ "resource" : "https://mcp.example.com/" ,
3553+ "authorization_servers" : [ "https://auth.example.com/tenant1" ]
3554+ } ) ,
3555+ ) ,
3556+ http_response(
3557+ 200 ,
3558+ serde_json:: json!( {
3559+ "resource" : "https://mcp.example.com/" ,
3560+ "authorization_servers" : [ "https://auth.example.com/tenant1" ]
3561+ } ) ,
3562+ ) ,
3563+ http_response(
3564+ 200 ,
3565+ serde_json:: json!( {
3566+ "issuer" : "https://evil.example.com/tenant1" ,
3567+ "authorization_endpoint" : "https://evil.example.com/tenant1/authorize" ,
3568+ "token_endpoint" : "https://evil.example.com/tenant1/token"
3569+ } ) ,
3570+ ) ,
3571+ ] ) ;
3572+ let manager = AuthorizationManager :: new_with_oauth_http_client (
3573+ "https://mcp.example.com/" ,
3574+ Arc :: new ( client) ,
3575+ )
3576+ . await
3577+ . unwrap ( ) ;
3578+
3579+ let error = manager. discover_metadata ( ) . await . unwrap_err ( ) ;
3580+
3581+ assert ! (
3582+ matches!(
3583+ error,
3584+ AuthError :: AuthorizationServerMismatch {
3585+ ref expected_issuer,
3586+ ref received_issuer
3587+ } if expected_issuer == "https://auth.example.com/tenant1"
3588+ && received_issuer == "https://evil.example.com/tenant1"
3589+ ) ,
3590+ "expected authorization server issuer mismatch, got: {error:?}"
3591+ ) ;
3592+ }
3593+
3594+ #[ test]
3595+ fn authorization_metadata_accepts_oidc_path_appended_issuer ( ) {
3596+ let discovery_url =
3597+ Url :: parse ( "https://auth.example.com/tenant1/.well-known/openid-configuration" )
3598+ . unwrap ( ) ;
3599+ let metadata = AuthorizationMetadata {
3600+ issuer : Some ( "https://auth.example.com/tenant1" . to_string ( ) ) ,
3601+ authorization_endpoint : "https://auth.example.com/tenant1/authorize" . to_string ( ) ,
3602+ token_endpoint : "https://auth.example.com/tenant1/token" . to_string ( ) ,
3603+ ..Default :: default ( )
3604+ } ;
3605+
3606+ AuthorizationManager :: validate_authorization_metadata_issuer ( & discovery_url, & metadata)
3607+ . unwrap ( ) ;
3608+ }
3609+
3610+ #[ test]
3611+ fn authorization_metadata_allows_only_root_trailing_slash_equivalence ( ) {
3612+ assert ! ( AuthorizationManager :: issuer_identifiers_match(
3613+ "https://auth.example.com/" ,
3614+ "https://auth.example.com"
3615+ ) ) ;
3616+ assert ! ( !AuthorizationManager :: issuer_identifiers_match(
3617+ "https://auth.example.com/tenant1/" ,
3618+ "https://auth.example.com/tenant1"
3619+ ) ) ;
3620+ }
3621+
3622+ #[ test]
3623+ fn authorization_metadata_accepts_oidc_path_inserted_issuer ( ) {
3624+ let discovery_url =
3625+ Url :: parse ( "https://auth.example.com/.well-known/openid-configuration/tenant1" )
3626+ . unwrap ( ) ;
3627+ let metadata = AuthorizationMetadata {
3628+ issuer : Some ( "https://auth.example.com/tenant1" . to_string ( ) ) ,
3629+ authorization_endpoint : "https://auth.example.com/tenant1/authorize" . to_string ( ) ,
3630+ token_endpoint : "https://auth.example.com/tenant1/token" . to_string ( ) ,
3631+ ..Default :: default ( )
3632+ } ;
3633+
3634+ AuthorizationManager :: validate_authorization_metadata_issuer ( & discovery_url, & metadata)
3635+ . unwrap ( ) ;
3636+ }
3637+
3638+ #[ test]
3639+ fn authorization_metadata_rejects_missing_issuer_for_standard_discovery_url ( ) {
3640+ let discovery_url =
3641+ Url :: parse ( "https://auth.example.com/.well-known/openid-configuration/tenant1" )
3642+ . unwrap ( ) ;
3643+ let metadata = AuthorizationMetadata {
3644+ issuer : None ,
3645+ authorization_endpoint : "https://auth.example.com/tenant1/authorize" . to_string ( ) ,
3646+ token_endpoint : "https://auth.example.com/tenant1/token" . to_string ( ) ,
3647+ ..Default :: default ( )
3648+ } ;
3649+
3650+ let error =
3651+ AuthorizationManager :: validate_authorization_metadata_issuer ( & discovery_url, & metadata)
3652+ . unwrap_err ( ) ;
3653+
3654+ assert ! (
3655+ matches!(
3656+ error,
3657+ AuthError :: AuthorizationServerMissingIssuer { ref expected_issuer }
3658+ if expected_issuer == "https://auth.example.com/tenant1"
3659+ ) ,
3660+ "expected missing issuer error, got: {error:?}"
3661+ ) ;
3662+ }
3663+
34703664 #[ tokio:: test]
34713665 async fn protected_resource_metadata_supports_custom_location_and_oidc_path_append ( ) {
34723666 let challenge = oauth2:: http:: Response :: builder ( )
0 commit comments