@@ -578,6 +578,28 @@ pub struct AuthorizationMetadata {
578578 pub additional_fields : HashMap < String , serde_json:: Value > ,
579579}
580580
581+ /// Describes how authorization metadata was obtained during OAuth discovery.
582+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
583+ #[ non_exhaustive]
584+ pub enum AuthorizationMetadataSource {
585+ /// Protected resource metadata identified the authorization server.
586+ ProtectedResourceMetadata ,
587+ /// Authorization server metadata was discovered directly from the base URL.
588+ AuthorizationServerMetadata ,
589+ /// No metadata was discovered; endpoints were synthesized for legacy OAuth.
590+ LegacyEndpointFallback ,
591+ }
592+
593+ /// Authorization metadata together with the discovery mechanism that produced it.
594+ #[ derive( Debug , Clone ) ]
595+ #[ non_exhaustive]
596+ pub struct DiscoveredAuthorizationMetadata {
597+ /// The discovered authorization metadata or synthesized legacy endpoints.
598+ pub metadata : AuthorizationMetadata ,
599+ /// Whether the metadata was discovered or synthesized as a legacy fallback.
600+ pub source : AuthorizationMetadataSource ,
601+ }
602+
581603#[ derive( Debug , Clone , Deserialize ) ]
582604struct ResourceServerMetadata {
583605 resource : Option < String > ,
@@ -1320,18 +1342,44 @@ impl AuthorizationManager {
13201342 Ok ( ( ) )
13211343 }
13221344
1323- /// discover oauth2 metadata (per SEP-985: Protected Resource Metadata first, then direct OAuth)
1345+ /// Discover OAuth metadata, preserving legacy endpoint fallback compatibility.
1346+ ///
1347+ /// Use [`Self::discover_metadata_with_source`] when callers need to
1348+ /// distinguish discovered metadata from synthesized legacy endpoints.
13241349 pub async fn discover_metadata ( & self ) -> Result < AuthorizationMetadata , AuthError > {
1350+ self . discover_metadata_with_source ( )
1351+ . await
1352+ . map ( |discovered| discovered. metadata )
1353+ }
1354+
1355+ /// Discover OAuth metadata and identify the mechanism that produced it.
1356+ ///
1357+ /// Protected resource metadata is tried first, followed by direct
1358+ /// authorization server metadata discovery. If neither succeeds, legacy
1359+ /// endpoints are synthesized for compatibility and explicitly identified
1360+ /// as [`AuthorizationMetadataSource::LegacyEndpointFallback`].
1361+ pub async fn discover_metadata_with_source (
1362+ & self ,
1363+ ) -> Result < DiscoveredAuthorizationMetadata , AuthError > {
13251364 if let Some ( metadata) = self . discover_oauth_server_via_resource_metadata ( ) . await ? {
1326- return Ok ( metadata) ;
1365+ return Ok ( DiscoveredAuthorizationMetadata {
1366+ metadata,
1367+ source : AuthorizationMetadataSource :: ProtectedResourceMetadata ,
1368+ } ) ;
13271369 }
13281370
13291371 if let Some ( metadata) = self . try_discover_oauth_server ( & self . base_url ) . await ? {
1330- return Ok ( metadata) ;
1372+ return Ok ( DiscoveredAuthorizationMetadata {
1373+ metadata,
1374+ source : AuthorizationMetadataSource :: AuthorizationServerMetadata ,
1375+ } ) ;
13311376 }
13321377
13331378 debug ! ( "falling back to legacy OAuth endpoints derived from the base URL" ) ;
1334- Ok ( Self :: legacy_authorization_metadata ( & self . base_url ) )
1379+ Ok ( DiscoveredAuthorizationMetadata {
1380+ metadata : Self :: legacy_authorization_metadata ( & self . base_url ) ,
1381+ source : AuthorizationMetadataSource :: LegacyEndpointFallback ,
1382+ } )
13351383 }
13361384
13371385 fn legacy_authorization_metadata ( base_url : & Url ) -> AuthorizationMetadata {
@@ -3695,10 +3743,10 @@ mod tests {
36953743
36963744 use super :: {
36973745 AuthError , AuthorizationCallback , AuthorizationManager , AuthorizationMetadata ,
3698- AuthorizationRequest , AuthorizationSession , CredentialStore , InMemoryCredentialStore ,
3699- InMemoryStateStore , OAuthClientConfig , OAuthHttpClient , OAuthHttpClientError ,
3700- OAuthHttpClientFuture , OAuthHttpRedirectPolicy , OAuthHttpRequest , ScopeUpgradeConfig ,
3701- StateStore , StoredAuthorizationState , is_https_url,
3746+ AuthorizationMetadataSource , AuthorizationRequest , AuthorizationSession , CredentialStore ,
3747+ InMemoryCredentialStore , InMemoryStateStore , OAuthClientConfig , OAuthHttpClient ,
3748+ OAuthHttpClientError , OAuthHttpClientFuture , OAuthHttpRedirectPolicy , OAuthHttpRequest ,
3749+ ScopeUpgradeConfig , StateStore , StoredAuthorizationState , is_https_url,
37023750 } ;
37033751 use crate :: transport:: auth:: VendorExtraTokenFields ;
37043752
@@ -3830,6 +3878,97 @@ mod tests {
38303878 ) ;
38313879 }
38323880
3881+ #[ tokio:: test]
3882+ async fn discover_metadata_with_source_identifies_protected_resource_metadata ( ) {
3883+ let challenge = oauth2:: http:: Response :: builder ( )
3884+ . status ( 401 )
3885+ . header (
3886+ "www-authenticate" ,
3887+ r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""# ,
3888+ )
3889+ . body ( Vec :: new ( ) )
3890+ . unwrap ( ) ;
3891+ let client = RecordingOAuthHttpClient :: with_responses ( vec ! [
3892+ challenge,
3893+ http_response(
3894+ 200 ,
3895+ serde_json:: json!( {
3896+ "resource" : "https://mcp.example.com/mcp" ,
3897+ "authorization_servers" : [ "https://auth.example.com" ]
3898+ } ) ,
3899+ ) ,
3900+ http_response(
3901+ 200 ,
3902+ serde_json:: json!( {
3903+ "issuer" : "https://auth.example.com" ,
3904+ "authorization_endpoint" : "https://auth.example.com/authorize" ,
3905+ "token_endpoint" : "https://auth.example.com/token"
3906+ } ) ,
3907+ ) ,
3908+ ] ) ;
3909+ let manager = AuthorizationManager :: new_with_oauth_http_client (
3910+ "https://mcp.example.com/mcp" ,
3911+ Arc :: new ( client) ,
3912+ )
3913+ . await
3914+ . unwrap ( ) ;
3915+
3916+ let discovered = manager. discover_metadata_with_source ( ) . await . unwrap ( ) ;
3917+
3918+ assert_eq ! (
3919+ (
3920+ discovered. source,
3921+ discovered. metadata. issuer. as_deref( ) ,
3922+ discovered. metadata. token_endpoint. as_str( ) ,
3923+ ) ,
3924+ (
3925+ AuthorizationMetadataSource :: ProtectedResourceMetadata ,
3926+ Some ( "https://auth.example.com" ) ,
3927+ "https://auth.example.com/token" ,
3928+ )
3929+ ) ;
3930+ }
3931+
3932+ #[ tokio:: test]
3933+ async fn discover_metadata_with_source_identifies_authorization_server_metadata ( ) {
3934+ let client = RecordingOAuthHttpClient :: with_responses ( vec ! [
3935+ empty_response( 404 ) ,
3936+ empty_response( 404 ) ,
3937+ empty_response( 404 ) ,
3938+ http_response(
3939+ 200 ,
3940+ serde_json:: json!( {
3941+ "issuer" : "https://mcp.example.com" ,
3942+ "authorization_endpoint" : "https://mcp.example.com/authorize" ,
3943+ "token_endpoint" : "https://mcp.example.com/token"
3944+ } ) ,
3945+ ) ,
3946+ ] ) ;
3947+ let manager = AuthorizationManager :: new_with_oauth_http_client (
3948+ "https://mcp.example.com/" ,
3949+ Arc :: new ( client. clone ( ) ) ,
3950+ )
3951+ . await
3952+ . unwrap ( ) ;
3953+
3954+ let discovered = manager. discover_metadata_with_source ( ) . await . unwrap ( ) ;
3955+
3956+ assert_eq ! (
3957+ (
3958+ discovered. source,
3959+ discovered. metadata. issuer. as_deref( ) ,
3960+ discovered. metadata. token_endpoint. as_str( ) ,
3961+ client. requests( ) . len( ) ,
3962+ ) ,
3963+ (
3964+ AuthorizationMetadataSource :: AuthorizationServerMetadata ,
3965+ Some ( "https://mcp.example.com" ) ,
3966+ "https://mcp.example.com/token" ,
3967+ 4 ,
3968+ )
3969+ ) ;
3970+ }
3971+
38333972 #[ tokio:: test]
38343973 async fn protected_resource_metadata_supports_authorization_server_path_insertion ( ) {
38353974 let client = RecordingOAuthHttpClient :: with_responses ( vec ! [
@@ -4124,6 +4263,44 @@ mod tests {
41244263 ) ;
41254264 }
41264265
4266+ #[ tokio:: test]
4267+ async fn discover_metadata_with_source_identifies_legacy_endpoint_fallback ( ) {
4268+ let client = RecordingOAuthHttpClient :: with_responses ( vec ! [
4269+ empty_response( 404 ) ,
4270+ empty_response( 404 ) ,
4271+ empty_response( 404 ) ,
4272+ empty_response( 404 ) ,
4273+ empty_response( 404 ) ,
4274+ ] ) ;
4275+ let manager = AuthorizationManager :: new_with_oauth_http_client (
4276+ "https://public.example.com/" ,
4277+ Arc :: new ( client. clone ( ) ) ,
4278+ )
4279+ . await
4280+ . unwrap ( ) ;
4281+
4282+ let discovered = manager. discover_metadata_with_source ( ) . await . unwrap ( ) ;
4283+
4284+ assert_eq ! (
4285+ (
4286+ discovered. source,
4287+ discovered. metadata. authorization_endpoint. as_str( ) ,
4288+ discovered. metadata. token_endpoint. as_str( ) ,
4289+ discovered. metadata. registration_endpoint. as_deref( ) ,
4290+ discovered. metadata. issuer. as_deref( ) ,
4291+ client. requests( ) . len( ) ,
4292+ ) ,
4293+ (
4294+ AuthorizationMetadataSource :: LegacyEndpointFallback ,
4295+ "https://public.example.com/authorize" ,
4296+ "https://public.example.com/token" ,
4297+ Some ( "https://public.example.com/register" ) ,
4298+ None ,
4299+ 5 ,
4300+ )
4301+ ) ;
4302+ }
4303+
41274304 fn preregistered_as_metadata_response ( ) -> HttpResponse {
41284305 http_response (
41294306 200 ,
0 commit comments