|
1 | 1 | use rmcp::{ |
2 | 2 | ClientHandler, ClientLifecycleMode, ClientServiceExt, ErrorData, RoleClient, ServiceExt, |
3 | 3 | model::*, |
4 | | - service::RequestContext, |
| 4 | + service::{ClientInitializeError, RequestContext}, |
5 | 5 | transport::{ |
6 | 6 | AuthClient, AuthorizationManager, StreamableHttpClientTransport, |
7 | 7 | auth::{ |
8 | 8 | AuthorizationCallback, AuthorizationRequest, ClientCredentialsConfig, |
9 | 9 | InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState, |
10 | 10 | }, |
11 | | - streamable_http_client::StreamableHttpClientTransportConfig, |
| 11 | + streamable_http_client::{StreamableHttpClientTransportConfig, StreamableHttpError}, |
12 | 12 | }, |
13 | 13 | }; |
14 | 14 | use serde_json::{Value, json}; |
@@ -195,26 +195,69 @@ const REDIRECT_URI: &str = "http://localhost:3000/callback"; |
195 | 195 | const SCOPE_STEP_UP_INITIAL_SCOPES: &[&str] = &["mcp:basic"]; |
196 | 196 | const SCOPE_STEP_UP_ESCALATED_SCOPES: &[&str] = &["mcp:basic", "mcp:write"]; |
197 | 197 |
|
198 | | -/// Perform the headless OAuth authorization-code flow. |
| 198 | +/// Attempt the real connection unauthenticated and return the server's |
| 199 | +/// `WWW-Authenticate` challenge from the 401 — the reactive discovery |
| 200 | +/// trigger. Returns `None` when the server accepts unauthenticated clients |
| 201 | +/// (the connection is closed cleanly in that case). |
| 202 | +async fn initialize_challenge( |
| 203 | + server_url: &str, |
| 204 | + lifecycle: ClientLifecycleMode, |
| 205 | +) -> anyhow::Result<Option<String>> { |
| 206 | + let transport = StreamableHttpClientTransport::from_uri(server_url); |
| 207 | + match BasicClientHandler |
| 208 | + .serve_with_lifecycle(transport, lifecycle) |
| 209 | + .await |
| 210 | + { |
| 211 | + Ok(client) => { |
| 212 | + client.cancel().await.ok(); |
| 213 | + Ok(None) |
| 214 | + } |
| 215 | + Err(error) => match auth_challenge_from_init_error(&error) { |
| 216 | + Some(challenge) => Ok(Some(challenge)), |
| 217 | + None => Err(error.into()), |
| 218 | + }, |
| 219 | + } |
| 220 | +} |
| 221 | + |
| 222 | +fn auth_challenge_from_init_error(error: &ClientInitializeError) -> Option<String> { |
| 223 | + let ClientInitializeError::TransportError { error, .. } = error else { |
| 224 | + return None; |
| 225 | + }; |
| 226 | + error |
| 227 | + .error |
| 228 | + .downcast_ref::<StreamableHttpError<reqwest::Error>>()? |
| 229 | + .auth_challenge() |
| 230 | + .map(str::to_string) |
| 231 | +} |
| 232 | + |
| 233 | +/// Perform the headless OAuth authorization-code flow, reactively: |
199 | 234 | /// |
200 | | -/// 1. Discover metadata, register (or use CIMD), get auth URL |
201 | | -/// 2. Fetch the auth URL with redirect:manual → extract code from Location header |
202 | | -/// 3. Exchange code for token |
203 | | -/// 4. Return an `AuthClient` wrapping `reqwest::Client` |
| 235 | +/// 1. Attempt the real connection; take the 401's WWW-Authenticate challenge |
| 236 | +/// 2. Discover from the challenge, register (or use CIMD), get auth URL |
| 237 | +/// 3. Fetch the auth URL with redirect:manual → extract code from Location header |
| 238 | +/// 4. Exchange code for token |
| 239 | +/// 5. Return an `AuthClient` wrapping `reqwest::Client` |
204 | 240 | async fn perform_oauth_flow( |
205 | 241 | server_url: &str, |
206 | 242 | _ctx: &ConformanceContext, |
207 | 243 | ) -> anyhow::Result<AuthClient<reqwest::Client>> { |
| 244 | + let challenge = initialize_challenge( |
| 245 | + server_url, |
| 246 | + ClientLifecycleMode::Discover { |
| 247 | + preferred_versions: preferred_protocol_versions(), |
| 248 | + }, |
| 249 | + ) |
| 250 | + .await?; |
208 | 251 | let mut oauth = OAuthState::new(server_url, None).await?; |
209 | 252 |
|
210 | 253 | // Discover + register + get auth URL |
211 | | - oauth |
212 | | - .start_authorization( |
213 | | - AuthorizationRequest::new(REDIRECT_URI) |
214 | | - .with_client_name("conformance-client") |
215 | | - .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), |
216 | | - ) |
217 | | - .await?; |
| 254 | + let mut request = AuthorizationRequest::new(REDIRECT_URI) |
| 255 | + .with_client_name("conformance-client") |
| 256 | + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL); |
| 257 | + if let Some(challenge) = challenge { |
| 258 | + request = request.with_challenge(challenge); |
| 259 | + } |
| 260 | + oauth.start_authorization(request).await?; |
218 | 261 |
|
219 | 262 | let auth_url = oauth.get_authorization_url().await?; |
220 | 263 | tracing::debug!("Authorization URL: {}", auth_url); |
@@ -255,15 +298,16 @@ async fn perform_oauth_flow_preregistered( |
255 | 298 | client_id: &str, |
256 | 299 | client_secret: &str, |
257 | 300 | ) -> anyhow::Result<AuthClient<reqwest::Client>> { |
| 301 | + let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?; |
258 | 302 | let mut oauth = OAuthState::new(server_url, None).await?; |
259 | 303 |
|
260 | | - oauth |
261 | | - .start_authorization( |
262 | | - AuthorizationRequest::new(REDIRECT_URI) |
263 | | - .with_preregistered_client(client_id) |
264 | | - .with_client_secret(client_secret), |
265 | | - ) |
266 | | - .await?; |
| 304 | + let mut request = AuthorizationRequest::new(REDIRECT_URI) |
| 305 | + .with_preregistered_client(client_id) |
| 306 | + .with_client_secret(client_secret); |
| 307 | + if let Some(challenge) = challenge { |
| 308 | + request = request.with_challenge(challenge); |
| 309 | + } |
| 310 | + oauth.start_authorization(request).await?; |
267 | 311 |
|
268 | 312 | let auth_url = oauth.get_authorization_url().await?; |
269 | 313 | let callback = headless_authorize(&auth_url).await?; |
@@ -325,15 +369,16 @@ async fn run_auth_scope_step_up_client( |
325 | 369 | server_url: &str, |
326 | 370 | _ctx: &ConformanceContext, |
327 | 371 | ) -> anyhow::Result<()> { |
| 372 | + let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?; |
328 | 373 | let mut oauth = OAuthState::new(server_url, None).await?; |
329 | | - oauth |
330 | | - .start_authorization( |
331 | | - AuthorizationRequest::new(REDIRECT_URI) |
332 | | - .with_scopes(SCOPE_STEP_UP_INITIAL_SCOPES.iter().copied()) |
333 | | - .with_client_name("conformance-client") |
334 | | - .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), |
335 | | - ) |
336 | | - .await?; |
| 374 | + let mut request = AuthorizationRequest::new(REDIRECT_URI) |
| 375 | + .with_scopes(SCOPE_STEP_UP_INITIAL_SCOPES.iter().copied()) |
| 376 | + .with_client_name("conformance-client") |
| 377 | + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL); |
| 378 | + if let Some(challenge) = challenge { |
| 379 | + request = request.with_challenge(challenge); |
| 380 | + } |
| 381 | + oauth.start_authorization(request).await?; |
337 | 382 |
|
338 | 383 | let auth_url = oauth.get_authorization_url().await?; |
339 | 384 | let callback = headless_authorize(&auth_url).await?; |
@@ -427,16 +472,17 @@ async fn run_auth_scope_retry_limit_client( |
427 | 472 | ) -> anyhow::Result<()> { |
428 | 473 | let max_retries = 3u32; |
429 | 474 | let mut attempt = 0u32; |
| 475 | + let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?; |
430 | 476 |
|
431 | 477 | loop { |
432 | 478 | let mut oauth = OAuthState::new(server_url, None).await?; |
433 | | - oauth |
434 | | - .start_authorization( |
435 | | - AuthorizationRequest::new(REDIRECT_URI) |
436 | | - .with_client_name("conformance-client") |
437 | | - .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), |
438 | | - ) |
439 | | - .await?; |
| 479 | + let mut request = AuthorizationRequest::new(REDIRECT_URI) |
| 480 | + .with_client_name("conformance-client") |
| 481 | + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL); |
| 482 | + if let Some(challenge) = &challenge { |
| 483 | + request = request.with_challenge(challenge.clone()); |
| 484 | + } |
| 485 | + oauth.start_authorization(request).await?; |
440 | 486 | let auth_url = oauth.get_authorization_url().await?; |
441 | 487 | let callback = headless_authorize(&auth_url).await?; |
442 | 488 | oauth |
@@ -509,7 +555,10 @@ async fn migration_token( |
509 | 555 | return Ok(manager.get_access_token().await?); |
510 | 556 | } |
511 | 557 |
|
512 | | - let resolution = manager.resolve_metadata().await?; |
| 558 | + let resolution = match initialize_challenge(server_url, conformance_lifecycle()).await? { |
| 559 | + Some(challenge) => manager.resolve_metadata_from_challenge(&challenge).await?, |
| 560 | + None => manager.resolve_metadata().await?, |
| 561 | + }; |
513 | 562 | manager.set_metadata(resolution.metadata); |
514 | 563 | manager |
515 | 564 | .register_client("conformance-client", REDIRECT_URI, &[]) |
@@ -609,7 +658,10 @@ async fn run_client_credentials_basic( |
609 | 658 | .unwrap_or("conformance-test-secret"); |
610 | 659 |
|
611 | 660 | let mut manager = AuthorizationManager::new(server_url).await?; |
612 | | - let resolution = manager.resolve_metadata().await?; |
| 661 | + let resolution = match initialize_challenge(server_url, conformance_lifecycle()).await? { |
| 662 | + Some(challenge) => manager.resolve_metadata_from_challenge(&challenge).await?, |
| 663 | + None => manager.resolve_metadata().await?, |
| 664 | + }; |
613 | 665 | let token_endpoint = resolution.metadata.token_endpoint.clone(); |
614 | 666 | manager.set_metadata(resolution.metadata); |
615 | 667 |
|
|
0 commit comments