@@ -2944,6 +2944,34 @@ impl AuthorizationSession {
29442944 } )
29452945 }
29462946
2947+ /// create a session using pre-registered client credentials, skipping
2948+ /// dynamic client registration and URL-based client IDs.
2949+ ///
2950+ /// The manager must already have discovered authorization server metadata.
2951+ ///
2952+ /// On failure, the manager is returned alongside the error so callers can
2953+ /// retry without losing the original configuration and stores.
2954+ pub async fn with_preregistered_client (
2955+ mut auth_manager : AuthorizationManager ,
2956+ config : OAuthClientConfig ,
2957+ ) -> Result < Self , ( AuthorizationManager , AuthError ) > {
2958+ let redirect_uri = config. redirect_uri . clone ( ) ;
2959+ let scopes = config. scopes . clone ( ) ;
2960+ if let Err ( e) = auth_manager. configure_client ( config) {
2961+ return Err ( ( auth_manager, e) ) ;
2962+ }
2963+ let scope_refs: Vec < & str > = scopes. iter ( ) . map ( |s| s. as_str ( ) ) . collect ( ) ;
2964+ let auth_url = match auth_manager. get_authorization_url ( & scope_refs) . await {
2965+ Ok ( url) => url,
2966+ Err ( e) => return Err ( ( auth_manager, e) ) ,
2967+ } ;
2968+ Ok ( Self {
2969+ auth_manager,
2970+ auth_url,
2971+ redirect_uri,
2972+ } )
2973+ }
2974+
29472975 /// create session for scope upgrade flow (existing manager + pre-computed auth url)
29482976 pub fn for_scope_upgrade (
29492977 auth_manager : AuthorizationManager ,
@@ -3214,6 +3242,50 @@ impl OAuthState {
32143242 }
32153243 }
32163244
3245+ /// start authorization using pre-registered client credentials,
3246+ /// skipping dynamic client registration.
3247+ ///
3248+ /// Use this when the client was registered with the authorization server
3249+ /// out of band and already holds a `client_id` (and optionally a
3250+ /// `client_secret`). If `config.scopes` is empty, scopes are selected
3251+ /// using the SDK's normal scope-selection policy.
3252+ pub async fn start_authorization_with_preregistered_client (
3253+ & mut self ,
3254+ mut config : OAuthClientConfig ,
3255+ ) -> Result < ( ) , AuthError > {
3256+ let placeholder = self . placeholder ( ) . await ?;
3257+ let old = std:: mem:: replace ( self , placeholder) ;
3258+ let OAuthState :: Unauthorized ( mut manager) = old else {
3259+ * self = old;
3260+ return Err ( AuthError :: InternalError (
3261+ "Already in session state" . to_string ( ) ,
3262+ ) ) ;
3263+ } ;
3264+ let metadata = match manager. discover_metadata ( ) . await {
3265+ Ok ( metadata) => metadata,
3266+ Err ( e) => {
3267+ * self = OAuthState :: Unauthorized ( manager) ;
3268+ return Err ( e) ;
3269+ }
3270+ } ;
3271+ manager. metadata = Some ( metadata) ;
3272+ if config. scopes . is_empty ( ) {
3273+ config. scopes = manager. select_scopes ( None , & [ ] ) ;
3274+ } else {
3275+ manager. add_offline_access_if_supported ( & mut config. scopes ) ;
3276+ }
3277+ match AuthorizationSession :: with_preregistered_client ( manager, config) . await {
3278+ Ok ( session) => {
3279+ * self = OAuthState :: Session ( session) ;
3280+ Ok ( ( ) )
3281+ }
3282+ Err ( ( manager, e) ) => {
3283+ * self = OAuthState :: Unauthorized ( manager) ;
3284+ Err ( e)
3285+ }
3286+ }
3287+ }
3288+
32173289 /// complete authorization
32183290 pub async fn complete_authorization ( & mut self ) -> Result < ( ) , AuthError > {
32193291 let placeholder = self . placeholder ( ) . await ?;
@@ -3844,6 +3916,204 @@ mod tests {
38443916 ) ;
38453917 }
38463918
3919+ fn preregistered_as_metadata_response ( ) -> HttpResponse {
3920+ http_response (
3921+ 200 ,
3922+ serde_json:: json!( {
3923+ "issuer" : "https://auth.example.com" ,
3924+ "authorization_endpoint" : "https://auth.example.com/authorize" ,
3925+ "token_endpoint" : "https://auth.example.com/token" ,
3926+ "registration_endpoint" : "https://auth.example.com/register" ,
3927+ "scopes_supported" : [ "read" , "write" , "offline_access" ]
3928+ } ) ,
3929+ )
3930+ }
3931+
3932+ /// discovery responses for the preregistered-client tests: a 401 challenge
3933+ /// pointing at protected resource metadata, the PRM document, then the
3934+ /// authorization server metadata.
3935+ fn preregistered_discovery_responses ( ) -> Vec < HttpResponse > {
3936+ let challenge = oauth2:: http:: Response :: builder ( )
3937+ . status ( 401 )
3938+ . header (
3939+ "www-authenticate" ,
3940+ r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""# ,
3941+ )
3942+ . body ( Vec :: new ( ) )
3943+ . unwrap ( ) ;
3944+ vec ! [
3945+ challenge,
3946+ http_response(
3947+ 200 ,
3948+ serde_json:: json!( {
3949+ "resource" : "https://mcp.example.com/mcp" ,
3950+ "authorization_servers" : [ "https://auth.example.com" ]
3951+ } ) ,
3952+ ) ,
3953+ preregistered_as_metadata_response( ) ,
3954+ ]
3955+ }
3956+
3957+ fn auth_url_query ( auth_url : & str ) -> HashMap < String , String > {
3958+ Url :: parse ( auth_url)
3959+ . unwrap ( )
3960+ . query_pairs ( )
3961+ . map ( |( k, v) | ( k. into_owned ( ) , v. into_owned ( ) ) )
3962+ . collect ( )
3963+ }
3964+
3965+ #[ tokio:: test]
3966+ async fn preregistered_client_skips_registration_endpoint ( ) {
3967+ let client = RecordingOAuthHttpClient :: with_responses ( preregistered_discovery_responses ( ) ) ;
3968+ let mut state = super :: OAuthState :: new_with_oauth_http_client (
3969+ "https://mcp.example.com/mcp" ,
3970+ Arc :: new ( client. clone ( ) ) ,
3971+ )
3972+ . await
3973+ . unwrap ( ) ;
3974+
3975+ let config = OAuthClientConfig {
3976+ client_id : "preregistered-client" . to_string ( ) ,
3977+ client_secret : Some ( "secret" . to_string ( ) ) ,
3978+ scopes : vec ! [ "read" . to_string( ) ] ,
3979+ redirect_uri : "http://localhost:8080/callback" . to_string ( ) ,
3980+ application_type : None ,
3981+ } ;
3982+ state
3983+ . start_authorization_with_preregistered_client ( config)
3984+ . await
3985+ . unwrap ( ) ;
3986+
3987+ // the registration endpoint was advertised but must not be called
3988+ let requests = client. requests ( ) ;
3989+ assert ! (
3990+ requests
3991+ . iter( )
3992+ . all( |request| !request. uri. contains( "/register" ) ) ,
3993+ "registration endpoint should not be called: {requests:?}"
3994+ ) ;
3995+
3996+ let auth_url = state. get_authorization_url ( ) . await . unwrap ( ) ;
3997+ let query = auth_url_query ( & auth_url) ;
3998+ assert_eq ! ( query. get( "client_id" ) . unwrap( ) , "preregistered-client" ) ;
3999+ assert ! ( matches!( state, super :: OAuthState :: Session ( _) ) ) ;
4000+ }
4001+
4002+ #[ tokio:: test]
4003+ async fn preregistered_client_selects_default_scopes_when_none_provided ( ) {
4004+ let client = RecordingOAuthHttpClient :: with_responses ( preregistered_discovery_responses ( ) ) ;
4005+ let mut state = super :: OAuthState :: new_with_oauth_http_client (
4006+ "https://mcp.example.com/mcp" ,
4007+ Arc :: new ( client. clone ( ) ) ,
4008+ )
4009+ . await
4010+ . unwrap ( ) ;
4011+
4012+ let config = OAuthClientConfig {
4013+ client_id : "preregistered-client" . to_string ( ) ,
4014+ client_secret : None ,
4015+ scopes : Vec :: new ( ) ,
4016+ redirect_uri : "http://localhost:8080/callback" . to_string ( ) ,
4017+ application_type : None ,
4018+ } ;
4019+ state
4020+ . start_authorization_with_preregistered_client ( config)
4021+ . await
4022+ . unwrap ( ) ;
4023+
4024+ // empty config scopes fall back to the discovered scopes_supported
4025+ let auth_url = state. get_authorization_url ( ) . await . unwrap ( ) ;
4026+ let query = auth_url_query ( & auth_url) ;
4027+ assert_eq ! ( query. get( "scope" ) . unwrap( ) , "read write offline_access" ) ;
4028+ }
4029+
4030+ #[ tokio:: test]
4031+ async fn preregistered_client_uses_explicit_scopes_and_adds_offline_access ( ) {
4032+ let client = RecordingOAuthHttpClient :: with_responses ( preregistered_discovery_responses ( ) ) ;
4033+ let mut state = super :: OAuthState :: new_with_oauth_http_client (
4034+ "https://mcp.example.com/mcp" ,
4035+ Arc :: new ( client. clone ( ) ) ,
4036+ )
4037+ . await
4038+ . unwrap ( ) ;
4039+
4040+ let config = OAuthClientConfig {
4041+ client_id : "preregistered-client" . to_string ( ) ,
4042+ client_secret : None ,
4043+ scopes : vec ! [ "read" . to_string( ) ] ,
4044+ redirect_uri : "http://localhost:8080/callback" . to_string ( ) ,
4045+ application_type : None ,
4046+ } ;
4047+ state
4048+ . start_authorization_with_preregistered_client ( config)
4049+ . await
4050+ . unwrap ( ) ;
4051+
4052+ // explicit scopes are preserved; offline_access is appended per SEP-2207
4053+ let auth_url = state. get_authorization_url ( ) . await . unwrap ( ) ;
4054+ let query = auth_url_query ( & auth_url) ;
4055+ assert_eq ! ( query. get( "scope" ) . unwrap( ) , "read offline_access" ) ;
4056+ }
4057+
4058+ #[ tokio:: test]
4059+ async fn preregistered_client_recovers_unauthorized_state_after_discovery_failure ( ) {
4060+ // first discovery attempt fails: protected resource metadata reports a
4061+ // mismatched resource identifier, which discover_metadata rejects
4062+ let challenge = oauth2:: http:: Response :: builder ( )
4063+ . status ( 401 )
4064+ . header (
4065+ "www-authenticate" ,
4066+ r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""# ,
4067+ )
4068+ . body ( Vec :: new ( ) )
4069+ . unwrap ( ) ;
4070+ let client = RecordingOAuthHttpClient :: with_responses ( vec ! [
4071+ challenge,
4072+ http_response(
4073+ 200 ,
4074+ serde_json:: json!( {
4075+ "resource" : "https://other.example.com/mcp" ,
4076+ "authorization_servers" : [ "https://auth.example.com" ]
4077+ } ) ,
4078+ ) ,
4079+ ] ) ;
4080+ let mut state = super :: OAuthState :: new_with_oauth_http_client (
4081+ "https://mcp.example.com/mcp" ,
4082+ Arc :: new ( client. clone ( ) ) ,
4083+ )
4084+ . await
4085+ . unwrap ( ) ;
4086+
4087+ let config = OAuthClientConfig {
4088+ client_id : "preregistered-client" . to_string ( ) ,
4089+ client_secret : None ,
4090+ scopes : vec ! [ "read" . to_string( ) ] ,
4091+ redirect_uri : "http://localhost:8080/callback" . to_string ( ) ,
4092+ application_type : None ,
4093+ } ;
4094+ let err = state
4095+ . start_authorization_with_preregistered_client ( config. clone ( ) )
4096+ . await
4097+ . unwrap_err ( ) ;
4098+ assert ! ( !matches!( err, AuthError :: InternalError ( _) ) , "{err:?}" ) ;
4099+ assert ! (
4100+ matches!( state, super :: OAuthState :: Unauthorized ( _) ) ,
4101+ "state should return to Unauthorized after a transient failure"
4102+ ) ;
4103+
4104+ // retrying with the same state succeeds once the server responds
4105+ client
4106+ . responses
4107+ . lock ( )
4108+ . unwrap ( )
4109+ . extend ( preregistered_discovery_responses ( ) ) ;
4110+ state
4111+ . start_authorization_with_preregistered_client ( config)
4112+ . await
4113+ . unwrap ( ) ;
4114+ assert ! ( matches!( state, super :: OAuthState :: Session ( _) ) ) ;
4115+ }
4116+
38474117 #[ tokio:: test]
38484118 async fn discovery_get_follows_same_origin_redirects ( ) {
38494119 let client = RecordingOAuthHttpClient :: with_responses ( vec ! [
0 commit comments