Skip to content

Commit 7d653d9

Browse files
committed
refactor: make oauth discovery reactive
1 parent 14298b7 commit 7d653d9

5 files changed

Lines changed: 538 additions & 229 deletions

File tree

conformance/src/bin/client.rs

Lines changed: 90 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
use rmcp::{
22
ClientHandler, ClientLifecycleMode, ClientServiceExt, ErrorData, RoleClient, ServiceExt,
33
model::*,
4-
service::RequestContext,
4+
service::{ClientInitializeError, RequestContext},
55
transport::{
66
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
77
auth::{
88
AuthorizationCallback, AuthorizationRequest, ClientCredentialsConfig,
99
InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState,
1010
},
11-
streamable_http_client::StreamableHttpClientTransportConfig,
11+
streamable_http_client::{StreamableHttpClientTransportConfig, StreamableHttpError},
1212
},
1313
};
1414
use serde_json::{Value, json};
@@ -195,26 +195,69 @@ const REDIRECT_URI: &str = "http://localhost:3000/callback";
195195
const SCOPE_STEP_UP_INITIAL_SCOPES: &[&str] = &["mcp:basic"];
196196
const SCOPE_STEP_UP_ESCALATED_SCOPES: &[&str] = &["mcp:basic", "mcp:write"];
197197

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:
199234
///
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`
204240
async fn perform_oauth_flow(
205241
server_url: &str,
206242
_ctx: &ConformanceContext,
207243
) -> 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?;
208251
let mut oauth = OAuthState::new(server_url, None).await?;
209252

210253
// 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?;
218261

219262
let auth_url = oauth.get_authorization_url().await?;
220263
tracing::debug!("Authorization URL: {}", auth_url);
@@ -255,15 +298,16 @@ async fn perform_oauth_flow_preregistered(
255298
client_id: &str,
256299
client_secret: &str,
257300
) -> anyhow::Result<AuthClient<reqwest::Client>> {
301+
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
258302
let mut oauth = OAuthState::new(server_url, None).await?;
259303

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?;
267311

268312
let auth_url = oauth.get_authorization_url().await?;
269313
let callback = headless_authorize(&auth_url).await?;
@@ -325,15 +369,16 @@ async fn run_auth_scope_step_up_client(
325369
server_url: &str,
326370
_ctx: &ConformanceContext,
327371
) -> anyhow::Result<()> {
372+
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
328373
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?;
337382

338383
let auth_url = oauth.get_authorization_url().await?;
339384
let callback = headless_authorize(&auth_url).await?;
@@ -427,16 +472,17 @@ async fn run_auth_scope_retry_limit_client(
427472
) -> anyhow::Result<()> {
428473
let max_retries = 3u32;
429474
let mut attempt = 0u32;
475+
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
430476

431477
loop {
432478
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?;
440486
let auth_url = oauth.get_authorization_url().await?;
441487
let callback = headless_authorize(&auth_url).await?;
442488
oauth
@@ -509,7 +555,10 @@ async fn migration_token(
509555
return Ok(manager.get_access_token().await?);
510556
}
511557

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+
};
513562
manager.set_metadata(resolution.metadata);
514563
manager
515564
.register_client("conformance-client", REDIRECT_URI, &[])
@@ -609,7 +658,10 @@ async fn run_client_credentials_basic(
609658
.unwrap_or("conformance-test-secret");
610659

611660
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+
};
613665
let token_endpoint = resolution.metadata.token_endpoint.clone();
614666
manager.set_metadata(resolution.metadata);
615667

0 commit comments

Comments
 (0)