Skip to content

Commit 3a18ab9

Browse files
committed
refactor: make oauth discovery reactive
1 parent 14298b7 commit 3a18ab9

6 files changed

Lines changed: 581 additions & 241 deletions

File tree

conformance/src/bin/client.rs

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -195,25 +195,73 @@ 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.
199201
///
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`
202+
/// `None` (server accepted the unauthenticated connection, which is then
203+
/// closed cleanly) is a legitimate outcome, not an error: the scope-step-up
204+
/// and scope-retry-limit mocks allow unauthenticated `initialize` and only
205+
/// enforce authorization on tool calls.
206+
async fn initialize_challenge(
207+
server_url: &str,
208+
lifecycle: ClientLifecycleMode,
209+
) -> anyhow::Result<Option<String>> {
210+
let transport = StreamableHttpClientTransport::from_uri(server_url);
211+
match BasicClientHandler
212+
.serve_with_lifecycle(transport, lifecycle)
213+
.await
214+
{
215+
Ok(client) => {
216+
client.cancel().await.ok();
217+
Ok(None)
218+
}
219+
Err(error) => match error.auth_challenge() {
220+
Some(challenge) => Ok(Some(challenge.to_string())),
221+
None => Err(error.into()),
222+
},
223+
}
224+
}
225+
226+
fn with_optional_challenge(
227+
request: AuthorizationRequest,
228+
challenge: Option<String>,
229+
) -> AuthorizationRequest {
230+
match challenge {
231+
Some(challenge) => request.with_challenge(challenge),
232+
None => request,
233+
}
234+
}
235+
236+
/// Perform the headless OAuth authorization-code flow, reactively:
237+
///
238+
/// 1. Attempt the real connection; take the 401's WWW-Authenticate challenge
239+
/// 2. Discover from the challenge, register (or use CIMD), get auth URL
240+
/// 3. Fetch the auth URL with redirect:manual → extract code from Location header
241+
/// 4. Exchange code for token
242+
/// 5. Return an `AuthClient` wrapping `reqwest::Client`
204243
async fn perform_oauth_flow(
205244
server_url: &str,
206245
_ctx: &ConformanceContext,
207246
) -> anyhow::Result<AuthClient<reqwest::Client>> {
247+
// Always the discover lifecycle here (not `conformance_lifecycle()`):
248+
// this flow serves `run_auth_client`, whose 2026-07-28 auth mocks require
249+
// the per-request MCP-Protocol-Version negotiation.
250+
let challenge = initialize_challenge(
251+
server_url,
252+
ClientLifecycleMode::Discover {
253+
preferred_versions: preferred_protocol_versions(),
254+
},
255+
)
256+
.await?;
208257
let mut oauth = OAuthState::new(server_url, None).await?;
209258

210259
// Discover + register + get auth URL
260+
let request = AuthorizationRequest::new(REDIRECT_URI)
261+
.with_client_name("conformance-client")
262+
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL);
211263
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-
)
264+
.start_authorization(with_optional_challenge(request, challenge))
217265
.await?;
218266

219267
let auth_url = oauth.get_authorization_url().await?;
@@ -255,14 +303,14 @@ async fn perform_oauth_flow_preregistered(
255303
client_id: &str,
256304
client_secret: &str,
257305
) -> anyhow::Result<AuthClient<reqwest::Client>> {
306+
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
258307
let mut oauth = OAuthState::new(server_url, None).await?;
259308

309+
let request = AuthorizationRequest::new(REDIRECT_URI)
310+
.with_preregistered_client(client_id)
311+
.with_client_secret(client_secret);
260312
oauth
261-
.start_authorization(
262-
AuthorizationRequest::new(REDIRECT_URI)
263-
.with_preregistered_client(client_id)
264-
.with_client_secret(client_secret),
265-
)
313+
.start_authorization(with_optional_challenge(request, challenge))
266314
.await?;
267315

268316
let auth_url = oauth.get_authorization_url().await?;
@@ -325,14 +373,14 @@ async fn run_auth_scope_step_up_client(
325373
server_url: &str,
326374
_ctx: &ConformanceContext,
327375
) -> anyhow::Result<()> {
376+
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
328377
let mut oauth = OAuthState::new(server_url, None).await?;
378+
let request = AuthorizationRequest::new(REDIRECT_URI)
379+
.with_scopes(SCOPE_STEP_UP_INITIAL_SCOPES.iter().copied())
380+
.with_client_name("conformance-client")
381+
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL);
329382
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-
)
383+
.start_authorization(with_optional_challenge(request, challenge))
336384
.await?;
337385

338386
let auth_url = oauth.get_authorization_url().await?;
@@ -427,15 +475,15 @@ async fn run_auth_scope_retry_limit_client(
427475
) -> anyhow::Result<()> {
428476
let max_retries = 3u32;
429477
let mut attempt = 0u32;
478+
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
430479

431480
loop {
432481
let mut oauth = OAuthState::new(server_url, None).await?;
482+
let request = AuthorizationRequest::new(REDIRECT_URI)
483+
.with_client_name("conformance-client")
484+
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL);
433485
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-
)
486+
.start_authorization(with_optional_challenge(request, challenge.clone()))
439487
.await?;
440488
let auth_url = oauth.get_authorization_url().await?;
441489
let callback = headless_authorize(&auth_url).await?;
@@ -509,7 +557,10 @@ async fn migration_token(
509557
return Ok(manager.get_access_token().await?);
510558
}
511559

512-
let resolution = manager.resolve_metadata().await?;
560+
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
561+
let resolution = manager
562+
.resolve_metadata_from_challenge(challenge.as_deref())
563+
.await?;
513564
manager.set_metadata(resolution.metadata);
514565
manager
515566
.register_client("conformance-client", REDIRECT_URI, &[])
@@ -609,7 +660,10 @@ async fn run_client_credentials_basic(
609660
.unwrap_or("conformance-test-secret");
610661

611662
let mut manager = AuthorizationManager::new(server_url).await?;
612-
let resolution = manager.resolve_metadata().await?;
663+
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
664+
let resolution = manager
665+
.resolve_metadata_from_challenge(challenge.as_deref())
666+
.await?;
613667
let token_endpoint = resolution.metadata.token_endpoint.clone();
614668
manager.set_metadata(resolution.metadata);
615669

crates/rmcp/src/service/client.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,31 @@ impl ClientInitializeError {
8686
context: context.into(),
8787
}
8888
}
89+
90+
/// The `WWW-Authenticate` challenge from the 401/403 the transport hit
91+
/// during initialization, if that is why initialization failed.
92+
///
93+
/// This is the trigger of the reactive OAuth flow: feed the challenge to
94+
/// `AuthorizationRequest::with_challenge` to authorize, then reconnect.
95+
#[cfg(feature = "transport-streamable-http-client")]
96+
pub fn auth_challenge(&self) -> Option<&str> {
97+
use crate::transport::streamable_http_client::{AuthRequiredError, InsufficientScopeError};
98+
99+
let Self::TransportError { error, .. } = self else {
100+
return None;
101+
};
102+
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error.error.as_ref());
103+
while let Some(current) = source {
104+
if let Some(auth_required) = current.downcast_ref::<AuthRequiredError>() {
105+
return Some(&auth_required.www_authenticate_header);
106+
}
107+
if let Some(insufficient_scope) = current.downcast_ref::<InsufficientScopeError>() {
108+
return Some(&insufficient_scope.www_authenticate_header);
109+
}
110+
source = current.source();
111+
}
112+
None
113+
}
89114
}
90115

91116
/// Helper function to get the next message from the stream

0 commit comments

Comments
 (0)