Skip to content

Commit 7d811c4

Browse files
authored
fix(auth): add an SDK path for pre-registered OAuth clients (#994)
... and exercise it in conformance tests
1 parent bc2c5f3 commit 7d811c4

2 files changed

Lines changed: 290 additions & 34 deletions

File tree

conformance/src/bin/client.rs

Lines changed: 20 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -245,50 +245,36 @@ async fn perform_oauth_flow(
245245
Ok(AuthClient::new(reqwest::Client::default(), am))
246246
}
247247

248-
/// Like `perform_oauth_flow` but uses pre-registered client credentials.
248+
/// Like `perform_oauth_flow` but uses pre-registered client credentials,
249+
/// exercising the SDK's high-level `OAuthState` path (no DCR).
249250
async fn perform_oauth_flow_preregistered(
250251
server_url: &str,
251252
client_id: &str,
252253
client_secret: &str,
253254
) -> anyhow::Result<AuthClient<reqwest::Client>> {
254-
let mut manager = AuthorizationManager::new(server_url).await?;
255-
let metadata = manager.discover_metadata().await?;
256-
manager.set_metadata(metadata);
255+
let mut oauth = OAuthState::new(server_url, None).await?;
257256

258-
// Configure with pre-registered credentials
259257
let config = rmcp::transport::auth::OAuthClientConfig::new(client_id, REDIRECT_URI)
260258
.with_client_secret(client_secret);
261-
manager.configure_client(config)?;
259+
oauth
260+
.start_authorization_with_preregistered_client(config)
261+
.await?;
262+
263+
let auth_url = oauth.get_authorization_url().await?;
264+
let callback = headless_authorize(&auth_url).await?;
265+
oauth
266+
.handle_callback_with_issuer(
267+
&callback.code,
268+
&callback.csrf_token,
269+
callback.issuer.as_deref(),
270+
)
271+
.await?;
262272

263-
let scopes = manager.select_scopes(None, &[]);
264-
let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
265-
let auth_url = manager.get_authorization_url(&scope_refs).await?;
273+
let am = oauth
274+
.into_authorization_manager()
275+
.ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?;
266276

267-
// Headless redirect
268-
let http = reqwest::Client::builder()
269-
.redirect(reqwest::redirect::Policy::none())
270-
.build()?;
271-
let resp = http.get(&auth_url).send().await?;
272-
let location = resp
273-
.headers()
274-
.get("location")
275-
.and_then(|v| v.to_str().ok())
276-
.ok_or_else(|| anyhow::anyhow!("No Location header"))?;
277-
let redirect_url = url::Url::parse(location)?;
278-
let code = redirect_url
279-
.query_pairs()
280-
.find(|(k, _)| k == "code")
281-
.map(|(_, v)| v.to_string())
282-
.ok_or_else(|| anyhow::anyhow!("No code"))?;
283-
let state = redirect_url
284-
.query_pairs()
285-
.find(|(k, _)| k == "state")
286-
.map(|(_, v)| v.to_string())
287-
.ok_or_else(|| anyhow::anyhow!("No state"))?;
288-
289-
manager.exchange_code_for_token(&code, &state).await?;
290-
291-
Ok(AuthClient::new(reqwest::Client::default(), manager))
277+
Ok(AuthClient::new(reqwest::Client::default(), am))
292278
}
293279

294280
/// Run the standard auth flow, then connect and exercise the server.

crates/rmcp/src/transport/auth.rs

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)