Skip to content

Commit 1b06076

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

6 files changed

Lines changed: 583 additions & 239 deletions

File tree

conformance/src/bin/client.rs

Lines changed: 78 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -195,25 +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 error.auth_challenge() {
216+
Some(challenge) => Ok(Some(challenge.to_string())),
217+
None => Err(error.into()),
218+
},
219+
}
220+
}
221+
222+
fn with_optional_challenge(
223+
request: AuthorizationRequest,
224+
challenge: Option<String>,
225+
) -> AuthorizationRequest {
226+
match challenge {
227+
Some(challenge) => request.with_challenge(challenge),
228+
None => request,
229+
}
230+
}
231+
232+
/// Perform the headless OAuth authorization-code flow, reactively:
199233
///
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`
234+
/// 1. Attempt the real connection; take the 401's WWW-Authenticate challenge
235+
/// 2. Discover from the challenge, register (or use CIMD), get auth URL
236+
/// 3. Fetch the auth URL with redirect:manual → extract code from Location header
237+
/// 4. Exchange code for token
238+
/// 5. Return an `AuthClient` wrapping `reqwest::Client`
204239
async fn perform_oauth_flow(
205240
server_url: &str,
206241
_ctx: &ConformanceContext,
207242
) -> anyhow::Result<AuthClient<reqwest::Client>> {
243+
// Always the discover lifecycle here (not `conformance_lifecycle()`):
244+
// this flow serves `run_auth_client`, whose 2026-07-28 auth mocks require
245+
// the per-request MCP-Protocol-Version negotiation.
246+
let challenge = initialize_challenge(
247+
server_url,
248+
ClientLifecycleMode::Discover {
249+
preferred_versions: preferred_protocol_versions(),
250+
},
251+
)
252+
.await?;
208253
let mut oauth = OAuthState::new(server_url, None).await?;
209254

210255
// Discover + register + get auth URL
256+
let request = AuthorizationRequest::new(REDIRECT_URI)
257+
.with_client_name("conformance-client")
258+
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL);
211259
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-
)
260+
.start_authorization(with_optional_challenge(request, challenge))
217261
.await?;
218262

219263
let auth_url = oauth.get_authorization_url().await?;
@@ -255,14 +299,14 @@ async fn perform_oauth_flow_preregistered(
255299
client_id: &str,
256300
client_secret: &str,
257301
) -> anyhow::Result<AuthClient<reqwest::Client>> {
302+
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
258303
let mut oauth = OAuthState::new(server_url, None).await?;
259304

305+
let request = AuthorizationRequest::new(REDIRECT_URI)
306+
.with_preregistered_client(client_id)
307+
.with_client_secret(client_secret);
260308
oauth
261-
.start_authorization(
262-
AuthorizationRequest::new(REDIRECT_URI)
263-
.with_preregistered_client(client_id)
264-
.with_client_secret(client_secret),
265-
)
309+
.start_authorization(with_optional_challenge(request, challenge))
266310
.await?;
267311

268312
let auth_url = oauth.get_authorization_url().await?;
@@ -325,14 +369,14 @@ 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?;
374+
let 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);
329378
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-
)
379+
.start_authorization(with_optional_challenge(request, challenge))
336380
.await?;
337381

338382
let auth_url = oauth.get_authorization_url().await?;
@@ -427,15 +471,15 @@ async fn run_auth_scope_retry_limit_client(
427471
) -> anyhow::Result<()> {
428472
let max_retries = 3u32;
429473
let mut attempt = 0u32;
474+
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
430475

431476
loop {
432477
let mut oauth = OAuthState::new(server_url, None).await?;
478+
let request = AuthorizationRequest::new(REDIRECT_URI)
479+
.with_client_name("conformance-client")
480+
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL);
433481
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-
)
482+
.start_authorization(with_optional_challenge(request, challenge.clone()))
439483
.await?;
440484
let auth_url = oauth.get_authorization_url().await?;
441485
let callback = headless_authorize(&auth_url).await?;
@@ -509,7 +553,10 @@ async fn migration_token(
509553
return Ok(manager.get_access_token().await?);
510554
}
511555

512-
let resolution = manager.resolve_metadata().await?;
556+
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
557+
let resolution = manager
558+
.resolve_metadata_from_challenge(challenge.as_deref())
559+
.await?;
513560
manager.set_metadata(resolution.metadata);
514561
manager
515562
.register_client("conformance-client", REDIRECT_URI, &[])
@@ -609,7 +656,10 @@ async fn run_client_credentials_basic(
609656
.unwrap_or("conformance-test-secret");
610657

611658
let mut manager = AuthorizationManager::new(server_url).await?;
612-
let resolution = manager.resolve_metadata().await?;
659+
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
660+
let resolution = manager
661+
.resolve_metadata_from_challenge(challenge.as_deref())
662+
.await?;
613663
let token_endpoint = resolution.metadata.token_endpoint.clone();
614664
manager.set_metadata(resolution.metadata);
615665

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)