@@ -578,6 +578,38 @@ pub struct AuthorizationMetadata {
578578 pub additional_fields : HashMap < String , serde_json:: Value > ,
579579}
580580
581+ /// How [`AuthorizationMetadata`] was obtained during discovery.
582+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
583+ #[ non_exhaustive]
584+ pub enum AuthorizationMetadataSource {
585+ /// Discovered through RFC 9728 protected resource metadata.
586+ ProtectedResourceMetadata ,
587+ /// Discovered through RFC 8414 / OpenID Connect metadata at the server's
588+ /// base URL.
589+ AuthorizationServerMetadata ,
590+ /// Nothing was discovered; the endpoints were synthesized from the base
591+ /// URL (`/authorize`, `/token`, `/register`) for compatibility with the
592+ /// 2025-03-26 MCP spec's default-endpoint fallback. The server gave no
593+ /// evidence that it supports OAuth.
594+ LegacyEndpointFallback ,
595+ }
596+
597+ impl AuthorizationMetadataSource {
598+ /// Whether the metadata was actually published by the server, as opposed
599+ /// to synthesized by the client as a legacy compatibility fallback.
600+ pub fn is_discovered ( self ) -> bool {
601+ !matches ! ( self , Self :: LegacyEndpointFallback )
602+ }
603+ }
604+
605+ /// [`AuthorizationMetadata`] together with its discovery provenance.
606+ #[ derive( Debug , Clone ) ]
607+ #[ non_exhaustive]
608+ pub struct DiscoveredAuthorizationMetadata {
609+ pub metadata : AuthorizationMetadata ,
610+ pub source : AuthorizationMetadataSource ,
611+ }
612+
581613#[ derive( Debug , Clone , Deserialize ) ]
582614struct ResourceServerMetadata {
583615 resource : Option < String > ,
@@ -1321,17 +1353,45 @@ impl AuthorizationManager {
13211353 }
13221354
13231355 /// discover oauth2 metadata (per SEP-985: Protected Resource Metadata first, then direct OAuth)
1356+ ///
1357+ /// When no metadata can be discovered, this falls back to legacy default
1358+ /// endpoints derived from the base URL rather than returning an error, so
1359+ /// a successful result does not prove the server supports OAuth. Callers
1360+ /// that need to tell verified discovery apart from the synthesized
1361+ /// fallback should use [`Self::discover_metadata_with_source`] instead.
13241362 pub async fn discover_metadata ( & self ) -> Result < AuthorizationMetadata , AuthError > {
1363+ Ok ( self . discover_metadata_with_source ( ) . await ?. metadata )
1364+ }
1365+
1366+ /// Discover oauth2 metadata along with how it was obtained.
1367+ ///
1368+ /// Unlike [`Self::discover_metadata`], the returned
1369+ /// [`AuthorizationMetadataSource`] lets callers distinguish metadata the
1370+ /// server actually published from the legacy default-endpoint fallback
1371+ /// that is synthesized when discovery finds nothing
1372+ /// ([`AuthorizationMetadataSource::LegacyEndpointFallback`]).
1373+ pub async fn discover_metadata_with_source (
1374+ & self ,
1375+ ) -> Result < DiscoveredAuthorizationMetadata , AuthError > {
13251376 if let Some ( metadata) = self . discover_oauth_server_via_resource_metadata ( ) . await ? {
1326- return Ok ( metadata) ;
1377+ return Ok ( DiscoveredAuthorizationMetadata {
1378+ metadata,
1379+ source : AuthorizationMetadataSource :: ProtectedResourceMetadata ,
1380+ } ) ;
13271381 }
13281382
13291383 if let Some ( metadata) = self . try_discover_oauth_server ( & self . base_url ) . await ? {
1330- return Ok ( metadata) ;
1384+ return Ok ( DiscoveredAuthorizationMetadata {
1385+ metadata,
1386+ source : AuthorizationMetadataSource :: AuthorizationServerMetadata ,
1387+ } ) ;
13311388 }
13321389
13331390 debug ! ( "falling back to legacy OAuth endpoints derived from the base URL" ) ;
1334- Ok ( Self :: legacy_authorization_metadata ( & self . base_url ) )
1391+ Ok ( DiscoveredAuthorizationMetadata {
1392+ metadata : Self :: legacy_authorization_metadata ( & self . base_url ) ,
1393+ source : AuthorizationMetadataSource :: LegacyEndpointFallback ,
1394+ } )
13351395 }
13361396
13371397 fn legacy_authorization_metadata ( base_url : & Url ) -> AuthorizationMetadata {
@@ -3691,10 +3751,10 @@ mod tests {
36913751
36923752 use super :: {
36933753 AuthError , AuthorizationCallback , AuthorizationManager , AuthorizationMetadata ,
3694- AuthorizationRequest , AuthorizationSession , CredentialStore , InMemoryCredentialStore ,
3695- InMemoryStateStore , OAuthClientConfig , OAuthHttpClient , OAuthHttpClientError ,
3696- OAuthHttpClientFuture , OAuthHttpRedirectPolicy , OAuthHttpRequest , ScopeUpgradeConfig ,
3697- StateStore , StoredAuthorizationState , is_https_url,
3754+ AuthorizationMetadataSource , AuthorizationRequest , AuthorizationSession , CredentialStore ,
3755+ InMemoryCredentialStore , InMemoryStateStore , OAuthClientConfig , OAuthHttpClient ,
3756+ OAuthHttpClientError , OAuthHttpClientFuture , OAuthHttpRedirectPolicy , OAuthHttpRequest ,
3757+ ScopeUpgradeConfig , StateStore , StoredAuthorizationState , is_https_url,
36983758 } ;
36993759 use crate :: transport:: auth:: VendorExtraTokenFields ;
37003760
@@ -4120,6 +4180,138 @@ mod tests {
41204180 ) ;
41214181 }
41224182
4183+ #[ tokio:: test]
4184+ async fn discover_metadata_with_source_reports_legacy_fallback_when_nothing_is_discovered ( ) {
4185+ let client = RecordingOAuthHttpClient :: with_responses ( vec ! [
4186+ empty_response( 404 ) ,
4187+ empty_response( 404 ) ,
4188+ empty_response( 404 ) ,
4189+ empty_response( 404 ) ,
4190+ empty_response( 404 ) ,
4191+ ] ) ;
4192+ let manager = AuthorizationManager :: new_with_oauth_http_client (
4193+ "https://legacy.example.com/" ,
4194+ Arc :: new ( client) ,
4195+ )
4196+ . await
4197+ . unwrap ( ) ;
4198+
4199+ let discovered = manager. discover_metadata_with_source ( ) . await . unwrap ( ) ;
4200+
4201+ assert_eq ! (
4202+ (
4203+ discovered. source,
4204+ discovered. metadata. authorization_endpoint. as_str( ) ,
4205+ ) ,
4206+ (
4207+ AuthorizationMetadataSource :: LegacyEndpointFallback ,
4208+ "https://legacy.example.com/authorize" ,
4209+ )
4210+ ) ;
4211+ }
4212+
4213+ #[ tokio:: test]
4214+ async fn discover_metadata_with_source_reports_protected_resource_metadata ( ) {
4215+ let challenge = oauth2:: http:: Response :: builder ( )
4216+ . status ( 401 )
4217+ . header (
4218+ "www-authenticate" ,
4219+ r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""# ,
4220+ )
4221+ . body ( Vec :: new ( ) )
4222+ . unwrap ( ) ;
4223+ let client = RecordingOAuthHttpClient :: with_responses ( vec ! [
4224+ challenge,
4225+ http_response(
4226+ 200 ,
4227+ serde_json:: json!( {
4228+ "resource" : "https://mcp.example.com/mcp" ,
4229+ "authorization_servers" : [ "https://auth.example.com" ]
4230+ } ) ,
4231+ ) ,
4232+ http_response(
4233+ 200 ,
4234+ serde_json:: json!( {
4235+ "issuer" : "https://auth.example.com" ,
4236+ "authorization_endpoint" : "https://auth.example.com/authorize" ,
4237+ "token_endpoint" : "https://auth.example.com/token"
4238+ } ) ,
4239+ ) ,
4240+ ] ) ;
4241+ let manager = AuthorizationManager :: new_with_oauth_http_client (
4242+ "https://mcp.example.com/mcp" ,
4243+ Arc :: new ( client) ,
4244+ )
4245+ . await
4246+ . unwrap ( ) ;
4247+
4248+ let discovered = manager. discover_metadata_with_source ( ) . await . unwrap ( ) ;
4249+
4250+ assert_eq ! (
4251+ (
4252+ discovered. source,
4253+ discovered. metadata. token_endpoint. as_str( ) ,
4254+ ) ,
4255+ (
4256+ AuthorizationMetadataSource :: ProtectedResourceMetadata ,
4257+ "https://auth.example.com/token" ,
4258+ )
4259+ ) ;
4260+ }
4261+
4262+ #[ tokio:: test]
4263+ async fn discover_metadata_with_source_reports_authorization_server_metadata ( ) {
4264+ let client = RecordingOAuthHttpClient :: with_responses ( vec ! [
4265+ empty_response( 404 ) ,
4266+ empty_response( 404 ) ,
4267+ empty_response( 404 ) ,
4268+ http_response(
4269+ 200 ,
4270+ serde_json:: json!( {
4271+ "issuer" : "https://mcp.example.com" ,
4272+ "authorization_endpoint" : "https://mcp.example.com/oauth/authorize" ,
4273+ "token_endpoint" : "https://mcp.example.com/oauth/token"
4274+ } ) ,
4275+ ) ,
4276+ ] ) ;
4277+ let manager = AuthorizationManager :: new_with_oauth_http_client (
4278+ "https://mcp.example.com/" ,
4279+ Arc :: new ( client) ,
4280+ )
4281+ . await
4282+ . unwrap ( ) ;
4283+
4284+ let discovered = manager. discover_metadata_with_source ( ) . await . unwrap ( ) ;
4285+
4286+ assert_eq ! (
4287+ (
4288+ discovered. source,
4289+ discovered. metadata. token_endpoint. as_str( ) ,
4290+ ) ,
4291+ (
4292+ AuthorizationMetadataSource :: AuthorizationServerMetadata ,
4293+ "https://mcp.example.com/oauth/token" ,
4294+ )
4295+ ) ;
4296+ }
4297+
4298+ #[ rstest]
4299+ #[ case:: protected_resource_metadata(
4300+ AuthorizationMetadataSource :: ProtectedResourceMetadata ,
4301+ true
4302+ ) ]
4303+ #[ case:: authorization_server_metadata(
4304+ AuthorizationMetadataSource :: AuthorizationServerMetadata ,
4305+ true
4306+ ) ]
4307+ #[ case:: legacy_endpoint_fallback( AuthorizationMetadataSource :: LegacyEndpointFallback , false ) ]
4308+ fn is_discovered_is_false_only_for_the_legacy_fallback (
4309+ #[ case] source : AuthorizationMetadataSource ,
4310+ #[ case] expected : bool ,
4311+ ) {
4312+ assert_eq ! ( source. is_discovered( ) , expected) ;
4313+ }
4314+
41234315 fn preregistered_as_metadata_response ( ) -> HttpResponse {
41244316 http_response (
41254317 200 ,
0 commit comments