Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 20 additions & 34 deletions conformance/src/bin/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,50 +236,36 @@ async fn perform_oauth_flow(
Ok(AuthClient::new(reqwest::Client::default(), am))
}

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

// Configure with pre-registered credentials
let config = rmcp::transport::auth::OAuthClientConfig::new(client_id, REDIRECT_URI)
.with_client_secret(client_secret);
manager.configure_client(config)?;
oauth
.start_authorization_with_preregistered_client(config)
.await?;

let auth_url = oauth.get_authorization_url().await?;
let callback = headless_authorize(&auth_url).await?;
oauth
.handle_callback_with_issuer(
&callback.code,
&callback.csrf_token,
callback.issuer.as_deref(),
)
.await?;

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

// Headless redirect
let http = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let resp = http.get(&auth_url).send().await?;
let location = resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow::anyhow!("No Location header"))?;
let redirect_url = url::Url::parse(location)?;
let code = redirect_url
.query_pairs()
.find(|(k, _)| k == "code")
.map(|(_, v)| v.to_string())
.ok_or_else(|| anyhow::anyhow!("No code"))?;
let state = redirect_url
.query_pairs()
.find(|(k, _)| k == "state")
.map(|(_, v)| v.to_string())
.ok_or_else(|| anyhow::anyhow!("No state"))?;

manager.exchange_code_for_token(&code, &state).await?;

Ok(AuthClient::new(reqwest::Client::default(), manager))
Ok(AuthClient::new(reqwest::Client::default(), am))
}

/// Run the standard auth flow, then connect and exercise the server.
Expand Down
50 changes: 50 additions & 0 deletions crates/rmcp/src/transport/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2803,6 +2803,26 @@ impl AuthorizationSession {
})
}

/// create a session using pre-registered client credentials, skipping
/// dynamic client registration and URL-based client IDs.
///
/// The manager must already have discovered authorization server metadata.
pub async fn with_preregistered_client(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same impl currently exposes new and for_scope_upgrade. While with_* is valid Rust naming, the three constructors do not seem to communicate an obvious shared convention. I was just thinking we could clean up the API, since v3 allows us to make breaking changes.

mut auth_manager: AuthorizationManager,
config: OAuthClientConfig,
) -> Result<Self, AuthError> {
let redirect_uri = config.redirect_uri.clone();
let scopes = config.scopes.clone();
auth_manager.configure_client(config)?;
let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let auth_url = auth_manager.get_authorization_url(&scope_refs).await?;
Ok(Self {
auth_manager,
auth_url,
redirect_uri,
})
}

/// create session for scope upgrade flow (existing manager + pre-computed auth url)
pub fn for_scope_upgrade(
auth_manager: AuthorizationManager,
Expand Down Expand Up @@ -3073,6 +3093,36 @@ impl OAuthState {
}
}

/// start authorization using pre-registered client credentials,
/// skipping dynamic client registration.
///
/// Use this when the client was registered with the authorization server
/// out of band and already holds a `client_id` (and optionally a
/// `client_secret`). If `config.scopes` is empty, scopes are selected
/// from the discovered authorization server metadata.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

select_scopes prefers scopes from WWW-Authenticate and PRM before ARM, so this is narrower than the implementation.

Suggested change
/// from the discovered authorization server metadata.
/// using the SDK's normal scope-selection policy.

pub async fn start_authorization_with_preregistered_client(

@DaleSeo DaleSeo Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add targeted tests using the existing recording HTTP client to verify that this path skips the registration endpoint, handles default and explicit scopes, and etc? Those tests would isolate the new orchestration from the broader conformance harness. e.g.

#[tokio::test]
async fn custom_http_client_handles_registration_exchange_and_refresh() {

@DaleSeo DaleSeo Jul 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The draft authorization spec recommends the priority pre-registered client information → CIMD → DCR, but the current API exposes these choices through separate entry points. In particular, start_authorization() passes no metadata URL and therefore cannot take the preferred CIMD path, while start_authorization_with_metadata_url() is the method that actually implements CIMD with DCR fallback. This PR understandably extends that pattern with another mechanism-specific method, but we might want to consolidates these paths behind one start_authorization API. This is out of scope and non-blocking for this PR. Let me know what you think, and we can follow up on this separately.

&mut self,
mut config: OAuthClientConfig,
) -> Result<(), AuthError> {
let placeholder = self.placeholder().await?;
if let OAuthState::Unauthorized(mut manager) = std::mem::replace(self, placeholder) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

std::mem::replace installs the placeholder before discovery, client configuration, and authorization-URL generation. How should callers retry after a transient discovery/configuration failure without losing the original base URL, stores, and state?

let metadata = manager.discover_metadata().await?;
manager.metadata = Some(metadata);
if config.scopes.is_empty() {
config.scopes = manager.select_scopes(None, &[]);
} else {
manager.add_offline_access_if_supported(&mut config.scopes);
}
let session = AuthorizationSession::with_preregistered_client(manager, config).await?;
*self = OAuthState::Session(session);
Ok(())
} else {
Err(AuthError::InternalError(
"Already in session state".to_string(),
))
}
}

/// complete authorization
pub async fn complete_authorization(&mut self) -> Result<(), AuthError> {
let placeholder = self.placeholder().await?;
Expand Down