From ce0ae9300a0d5823c5a00123c86ef726ed733e89 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 17 Jul 2026 22:13:19 -0400 Subject: [PATCH] chore: refactor OAuth client authorization api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OAuthState::start_authorization` now accepts a declarative `AuthorizationRequest` describing the client's available identity material and selects the highest-priority mechanism the server supports: pre-registered client information → Client ID Metadata Documents (SEP-991) → Dynamic Client Registration. This replaces the three mechanism-specific entry points (`start_authorization`, `start_authorization_with_metadata_url`, `start_authorization_with_preregistered_client`), where callers previously had to know which mechanism to pick. `AuthorizationSession::new` is consolidated the same way, and the failure-recovery pattern from #994 (returning the manager alongside the error so `OAuthState` recovers to `Unauthorized` on transient failures) is now applied across all registration paths. Includes new `RecordingOAuthHttpClient`-based tests covering the priority matrix and recovery semantics, plus updated docs, examples, and conformance client. --- conformance/src/bin/client.rs | 47 +- crates/rmcp/src/transport.rs | 11 +- crates/rmcp/src/transport/auth.rs | 641 ++++++++++++++++------ docs/OAUTH_SUPPORT.md | 60 +- examples/clients/src/auth/oauth_client.rs | 13 +- 5 files changed, 559 insertions(+), 213 deletions(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 0183532fa..5c7c019df 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -4,7 +4,7 @@ use rmcp::{ service::RequestContext, transport::{ AuthClient, AuthorizationManager, StreamableHttpClientTransport, - auth::{AuthorizationCallback, OAuthState}, + auth::{AuthorizationCallback, AuthorizationRequest, OAuthState}, streamable_http_client::StreamableHttpClientTransportConfig, }, }; @@ -205,11 +205,10 @@ async fn perform_oauth_flow( // Discover + register + get auth URL oauth - .start_authorization_with_metadata_url( - &[], - REDIRECT_URI, - Some("conformance-client"), - Some(CIMD_CLIENT_METADATA_URL), + .start_authorization( + AuthorizationRequest::new(REDIRECT_URI) + .with_client_name("conformance-client") + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), ) .await?; @@ -254,10 +253,12 @@ async fn perform_oauth_flow_preregistered( ) -> anyhow::Result> { let mut oauth = OAuthState::new(server_url, None).await?; - let config = rmcp::transport::auth::OAuthClientConfig::new(client_id, REDIRECT_URI) - .with_client_secret(client_secret); oauth - .start_authorization_with_preregistered_client(config) + .start_authorization( + AuthorizationRequest::new(REDIRECT_URI) + .with_preregistered_client(client_id) + .with_client_secret(client_secret), + ) .await?; let auth_url = oauth.get_authorization_url().await?; @@ -313,11 +314,10 @@ async fn run_auth_scope_step_up_client( // First auth let mut oauth = OAuthState::new(server_url, None).await?; oauth - .start_authorization_with_metadata_url( - &[], - REDIRECT_URI, - Some("conformance-client"), - Some(CIMD_CLIENT_METADATA_URL), + .start_authorization( + AuthorizationRequest::new(REDIRECT_URI) + .with_client_name("conformance-client") + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), ) .await?; @@ -363,11 +363,11 @@ async fn run_auth_scope_step_up_client( let mut oauth2 = OAuthState::new(server_url, None).await?; oauth2 - .start_authorization_with_metadata_url( - SCOPE_STEP_UP_ESCALATED_SCOPES, - REDIRECT_URI, - Some("conformance-client"), - Some(CIMD_CLIENT_METADATA_URL), + .start_authorization( + AuthorizationRequest::new(REDIRECT_URI) + .with_scopes(SCOPE_STEP_UP_ESCALATED_SCOPES.iter().copied()) + .with_client_name("conformance-client") + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), ) .await?; let auth_url2 = oauth2.get_authorization_url().await?; @@ -413,11 +413,10 @@ async fn run_auth_scope_retry_limit_client( loop { let mut oauth = OAuthState::new(server_url, None).await?; oauth - .start_authorization_with_metadata_url( - &[], - REDIRECT_URI, - Some("conformance-client"), - Some(CIMD_CLIENT_METADATA_URL), + .start_authorization( + AuthorizationRequest::new(REDIRECT_URI) + .with_client_name("conformance-client") + .with_client_metadata_url(CIMD_CLIENT_METADATA_URL), ) .await?; let auth_url = oauth.get_authorization_url().await?; diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index 8cc48aa41..74a13945b 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -99,11 +99,12 @@ pub mod auth; pub use auth::JwtSigningAlgorithm; #[cfg(feature = "auth")] pub use auth::{ - AuthClient, AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient, - ClientCredentialsConfig, CredentialStore, EXTENSION_OAUTH_CLIENT_CREDENTIALS, - InMemoryCredentialStore, InMemoryStateStore, OAuthHttpClient, OAuthHttpClientError, - OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig, - StateStore, StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams, + AuthClient, AuthError, AuthorizationManager, AuthorizationRequest, AuthorizationSession, + AuthorizedHttpClient, ClientCredentialsConfig, CredentialStore, + EXTENSION_OAUTH_CLIENT_CREDENTIALS, InMemoryCredentialStore, InMemoryStateStore, + OAuthHttpClient, OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, + OAuthHttpRequest, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, StoredCredentials, + WWWAuthenticateParams, }; // #[cfg(feature = "transport-ws")] diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index fc52c7b89..27fa03b2d 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -638,6 +638,125 @@ impl OAuthClientConfig { } } +/// Declarative description of the client identity material available for an +/// authorization flow. +/// +/// The [MCP authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration) +/// recommends that clients obtain a client ID using the following priority +/// order. [`OAuthState::start_authorization`] and [`AuthorizationSession::new`] +/// apply it internally: +/// +/// 1. Pre-registered client information +/// ([`with_preregistered_client`](Self::with_preregistered_client)), when +/// the client already holds a `client_id` issued out of band +/// 2. Client ID Metadata Documents (SEP-991, +/// [`with_client_metadata_url`](Self::with_client_metadata_url)), when the +/// authorization server advertises `client_id_metadata_document_supported` +/// 3. Dynamic Client Registration as a fallback, when the authorization server +/// advertises a `registration_endpoint` +/// +/// Provide whichever identity material the client has available; the SDK +/// selects the highest-priority mechanism the server supports. +/// +/// ```rust,ignore +/// let request = AuthorizationRequest::new("http://localhost:8080/callback") +/// // pass no scopes to let the SDK auto-select from server metadata +/// .with_scopes(["mcp", "profile"]) +/// // used when the server supports CIMD and no pre-registered client is set +/// .with_client_metadata_url("https://example.com/client-metadata.json") +/// // used for dynamic client registration as a last resort +/// .with_client_name("My MCP Client"); +/// oauth_state.start_authorization(request).await?; +/// ``` +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct AuthorizationRequest { + /// Redirect URI for the authorization-code flow. + pub redirect_uri: String, + /// Scopes to request. When empty, the SDK selects scopes from the + /// server's `WWW-Authenticate` challenge, Protected Resource Metadata, + /// or authorization server metadata. + pub scopes: Vec, + /// Human-readable client name, used for Dynamic Client Registration. + pub client_name: Option, + /// Pre-registered client ID obtained from the authorization server out of + /// band. When set, registration is skipped entirely. + pub client_id: Option, + /// Client secret paired with the pre-registered [`client_id`](Self::client_id). + pub client_secret: Option, + /// HTTPS URL of a Client ID Metadata Document (SEP-991). Used when the + /// authorization server advertises `client_id_metadata_document_supported` + /// and no pre-registered client is configured. + pub client_metadata_url: Option, + /// OIDC Dynamic Client Registration `application_type` (SEP-837), + /// e.g. `"native"` or `"web"`. + pub application_type: Option, +} + +impl AuthorizationRequest { + /// Create a request for the given redirect URI. With no further identity + /// material, authorization falls back to Dynamic Client Registration. + pub fn new(redirect_uri: impl Into) -> Self { + Self { + redirect_uri: redirect_uri.into(), + scopes: Vec::new(), + client_name: None, + client_id: None, + client_secret: None, + client_metadata_url: None, + application_type: None, + } + } + + /// Set the scopes to request. When not set, the SDK auto-selects scopes + /// using its normal scope-selection policy. + pub fn with_scopes(mut self, scopes: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.scopes = scopes.into_iter().map(Into::into).collect(); + self + } + + /// Set the client name used for Dynamic Client Registration. + pub fn with_client_name(mut self, client_name: impl Into) -> Self { + self.client_name = Some(client_name.into()); + self + } + + /// Use a client ID that was pre-registered with the authorization server + /// out of band. This takes priority over every other mechanism. + /// + /// Pair with [`with_client_secret`](Self::with_client_secret) for + /// confidential clients. + pub fn with_preregistered_client(mut self, client_id: impl Into) -> Self { + self.client_id = Some(client_id.into()); + self + } + + /// Set the client secret paired with a pre-registered client ID. + pub fn with_client_secret(mut self, client_secret: impl Into) -> Self { + self.client_secret = Some(client_secret.into()); + self + } + + /// Set the HTTPS URL of a Client ID Metadata Document (SEP-991). Used when + /// the authorization server advertises support and no pre-registered + /// client is configured. + pub fn with_client_metadata_url(mut self, client_metadata_url: impl Into) -> Self { + self.client_metadata_url = Some(client_metadata_url.into()); + self + } + + /// Set the OIDC Dynamic Client Registration `application_type` (SEP-837), + /// e.g. `"native"` or `"web"`. + pub fn with_application_type(mut self, application_type: impl Into) -> Self { + self.application_type = Some(application_type.into()); + self + } +} + // add type aliases for oauth2 types type OAuthErrorResponse = oauth2::StandardErrorResponse; @@ -2874,16 +2993,35 @@ pub struct AuthorizationSession { } impl AuthorizationSession { - /// create new authorization session + /// Create a new authorization session, selecting a client registration + /// mechanism per the [MCP authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration) + /// priority order: + /// + /// 1. Pre-registered client information + /// ([`AuthorizationRequest::with_preregistered_client`]), when available + /// 2. Client ID Metadata Documents + /// ([`AuthorizationRequest::with_client_metadata_url`]), when the + /// authorization server advertises `client_id_metadata_document_supported` + /// 3. Dynamic Client Registration, when the authorization server + /// advertises a `registration_endpoint` + /// + /// The manager must already have discovered authorization server metadata. + /// If `request.scopes` is empty, scopes are selected using the SDK's + /// normal scope-selection policy. + /// + /// On failure, the manager is returned alongside the error so callers can + /// retry without losing the original configuration and stores. pub async fn new( mut auth_manager: AuthorizationManager, - scopes: &[&str], - redirect_uri: &str, - client_name: Option<&str>, - client_metadata_url: Option<&str>, - ) -> Result { - let metadata = auth_manager.metadata.as_ref(); - let supports_url_based_client_id = metadata + request: AuthorizationRequest, + ) -> Result { + let redirect_uri = request.redirect_uri.clone(); + let scopes = request.scopes.clone(); + let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); + + let supports_url_based_client_id = auth_manager + .metadata + .as_ref() .and_then(|m| { m.additional_fields .get("client_id_metadata_document_supported") @@ -2891,80 +3029,73 @@ impl AuthorizationSession { .and_then(|v| v.as_bool()) .unwrap_or(false); - let config = if supports_url_based_client_id { - if let Some(client_metadata_url) = client_metadata_url { - if !is_https_url(client_metadata_url) { - return Err(AuthError::RegistrationFailed(format!( + // 1. pre-registered client information takes priority over everything else + let config = if let Some(client_id) = &request.client_id { + OAuthClientConfig { + client_id: client_id.clone(), + client_secret: request.client_secret.clone(), + scopes: scopes.clone(), + redirect_uri: redirect_uri.clone(), + application_type: request.application_type.clone(), + } + // 2. CIMD (SEP-991), when the server advertises support and the client hosts a metadata document + } else if supports_url_based_client_id && request.client_metadata_url.is_some() { + let client_metadata_url = request.client_metadata_url.as_deref().unwrap(); + if !is_https_url(client_metadata_url) { + return Err(( + auth_manager, + AuthError::RegistrationFailed(format!( "client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {}", client_metadata_url - ))); - } - // SEP-991: URL-based Client IDs - use URL as client_id directly. - // SEP-837: match the hosted client-metadata.json application_type ("native") - OAuthClientConfig { - client_id: client_metadata_url.to_string(), - client_secret: None, - scopes: scopes.iter().map(|s| s.to_string()).collect(), - redirect_uri: redirect_uri.to_string(), - application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), - } - } else { - // Fallback to dynamic registration - auth_manager - .register_client(client_name.unwrap_or("MCP Client"), redirect_uri, scopes) - .await - .map_err(|e| { - AuthError::RegistrationFailed(format!("Dynamic registration failed: {}", e)) - })? + )), + )); + } + // SEP-991: URL-based Client IDs - use URL as client_id directly. + // SEP-837: match the hosted client-metadata.json application_type ("native") + OAuthClientConfig { + client_id: client_metadata_url.to_string(), + client_secret: None, + scopes: scopes.clone(), + redirect_uri: redirect_uri.clone(), + application_type: Some( + request + .application_type + .clone() + .unwrap_or_else(|| DEFAULT_APPLICATION_TYPE.to_string()), + ), } + // 3. fall back to dynamic client registration } else { - // Fallback to dynamic registration match auth_manager - .register_client(client_name.unwrap_or("MCP Client"), redirect_uri, scopes) + .register_client( + request.client_name.as_deref().unwrap_or("MCP Client"), + &redirect_uri, + &scope_refs, + ) .await { Ok(config) => config, Err(e) => { - return Err(AuthError::RegistrationFailed(format!( - "Dynamic registration failed: {}", - e - ))); + return Err(( + auth_manager, + AuthError::RegistrationFailed(format!( + "Dynamic registration failed: {}", + e + )), + )); } } }; // reset client config - auth_manager.configure_client(config)?; - let auth_url = auth_manager.get_authorization_url(scopes).await?; - - Ok(Self { - auth_manager, - auth_url, - redirect_uri: redirect_uri.to_string(), - }) - } - - /// 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. - /// - /// On failure, the manager is returned alongside the error so callers can - /// retry without losing the original configuration and stores. - pub async fn with_preregistered_client( - mut auth_manager: AuthorizationManager, - config: OAuthClientConfig, - ) -> Result { - let redirect_uri = config.redirect_uri.clone(); - let scopes = config.scopes.clone(); if let Err(e) = auth_manager.configure_client(config) { return Err((auth_manager, e)); } - let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); let auth_url = match auth_manager.get_authorization_url(&scope_refs).await { Ok(url) => url, Err(e) => return Err((auth_manager, e)), }; + Ok(Self { auth_manager, auth_url, @@ -3192,66 +3323,28 @@ impl OAuthState { } } - /// start authorization - pub async fn start_authorization( - &mut self, - scopes: &[&str], - redirect_uri: &str, - client_name: Option<&str>, - ) -> Result<(), AuthError> { - self.start_authorization_with_metadata_url(scopes, redirect_uri, client_name, None) - .await - } - - /// start authorization with optional client metadata URL (SEP-991) - pub async fn start_authorization_with_metadata_url( - &mut self, - scopes: &[&str], - redirect_uri: &str, - client_name: Option<&str>, - client_metadata_url: Option<&str>, - ) -> Result<(), AuthError> { - let placeholder = self.placeholder().await?; - if let OAuthState::Unauthorized(mut manager) = std::mem::replace(self, placeholder) { - debug!("start discovery"); - let metadata = manager.discover_metadata().await?; - manager.metadata = Some(metadata); - let selected_scopes: Vec = if scopes.is_empty() { - manager.select_scopes(None, &[]) - } else { - let mut s: Vec = scopes.iter().map(|s| s.to_string()).collect(); - manager.add_offline_access_if_supported(&mut s); - s - }; - let scope_refs: Vec<&str> = selected_scopes.iter().map(|s| s.as_str()).collect(); - debug!("start session"); - let session = AuthorizationSession::new( - manager, - &scope_refs, - redirect_uri, - client_name, - client_metadata_url, - ) - .await?; - *self = OAuthState::Session(session); - Ok(()) - } else { - Err(AuthError::InternalError( - "Already in session state".to_string(), - )) - } - } - - /// start authorization using pre-registered client credentials, - /// skipping dynamic client registration. + /// Start authorization. + /// + /// Selects a client registration mechanism from the identity material in + /// `request`, following the [MCP authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration) + /// priority order: /// - /// 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 - /// using the SDK's normal scope-selection policy. - pub async fn start_authorization_with_preregistered_client( + /// 1. Pre-registered client information + /// ([`AuthorizationRequest::with_preregistered_client`]), when available + /// 2. Client ID Metadata Documents + /// ([`AuthorizationRequest::with_client_metadata_url`]), when the + /// authorization server advertises `client_id_metadata_document_supported` + /// 3. Dynamic Client Registration, when the authorization server + /// advertises a `registration_endpoint` + /// + /// If `request.scopes` is empty, scopes are selected using the SDK's + /// normal scope-selection policy. + /// + /// On failure, the state returns to `Unauthorized` so callers can retry + /// without losing the original configuration and stores. + pub async fn start_authorization( &mut self, - mut config: OAuthClientConfig, + mut request: AuthorizationRequest, ) -> Result<(), AuthError> { let placeholder = self.placeholder().await?; let old = std::mem::replace(self, placeholder); @@ -3261,6 +3354,7 @@ impl OAuthState { "Already in session state".to_string(), )); }; + debug!("start discovery"); let metadata = match manager.discover_metadata().await { Ok(metadata) => metadata, Err(e) => { @@ -3269,12 +3363,13 @@ impl OAuthState { } }; manager.metadata = Some(metadata); - if config.scopes.is_empty() { - config.scopes = manager.select_scopes(None, &[]); + if request.scopes.is_empty() { + request.scopes = manager.select_scopes(None, &[]); } else { - manager.add_offline_access_if_supported(&mut config.scopes); + manager.add_offline_access_if_supported(&mut request.scopes); } - match AuthorizationSession::with_preregistered_client(manager, config).await { + debug!("start session"); + match AuthorizationSession::new(manager, request).await { Ok(session) => { *self = OAuthState::Session(session); Ok(()) @@ -3488,9 +3583,9 @@ mod tests { use super::{ AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata, - InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, OAuthHttpClientError, - OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig, - StateStore, StoredAuthorizationState, is_https_url, + AuthorizationRequest, InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, + OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, + ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, }; use crate::transport::auth::VendorExtraTokenFields; @@ -3972,17 +4067,11 @@ mod tests { .await .unwrap(); - let config = OAuthClientConfig { - client_id: "preregistered-client".to_string(), - client_secret: Some("secret".to_string()), - scopes: vec!["read".to_string()], - redirect_uri: "http://localhost:8080/callback".to_string(), - application_type: None, - }; - state - .start_authorization_with_preregistered_client(config) - .await - .unwrap(); + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_client_secret("secret") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); // the registration endpoint was advertised but must not be called let requests = client.requests(); @@ -4009,19 +4098,11 @@ mod tests { .await .unwrap(); - let config = OAuthClientConfig { - client_id: "preregistered-client".to_string(), - client_secret: None, - scopes: Vec::new(), - redirect_uri: "http://localhost:8080/callback".to_string(), - application_type: None, - }; - state - .start_authorization_with_preregistered_client(config) - .await - .unwrap(); + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client"); + state.start_authorization(request).await.unwrap(); - // empty config scopes fall back to the discovered scopes_supported + // empty request scopes fall back to the discovered scopes_supported let auth_url = state.get_authorization_url().await.unwrap(); let query = auth_url_query(&auth_url); assert_eq!(query.get("scope").unwrap(), "read write offline_access"); @@ -4037,17 +4118,10 @@ mod tests { .await .unwrap(); - let config = OAuthClientConfig { - client_id: "preregistered-client".to_string(), - client_secret: None, - scopes: vec!["read".to_string()], - redirect_uri: "http://localhost:8080/callback".to_string(), - application_type: None, - }; - state - .start_authorization_with_preregistered_client(config) - .await - .unwrap(); + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); // explicit scopes are preserved; offline_access is appended per SEP-2207 let auth_url = state.get_authorization_url().await.unwrap(); @@ -4084,15 +4158,11 @@ mod tests { .await .unwrap(); - let config = OAuthClientConfig { - client_id: "preregistered-client".to_string(), - client_secret: None, - scopes: vec!["read".to_string()], - redirect_uri: "http://localhost:8080/callback".to_string(), - application_type: None, - }; + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_scopes(["read"]); let err = state - .start_authorization_with_preregistered_client(config.clone()) + .start_authorization(request.clone()) .await .unwrap_err(); assert!(!matches!(err, AuthError::InternalError(_)), "{err:?}"); @@ -4107,10 +4177,239 @@ mod tests { .lock() .unwrap() .extend(preregistered_discovery_responses()); - state - .start_authorization_with_preregistered_client(config) - .await + state.start_authorization(request).await.unwrap(); + assert!(matches!(state, super::OAuthState::Session(_))); + } + + fn cimd_as_metadata_response() -> HttpResponse { + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "registration_endpoint": "https://auth.example.com/register", + "scopes_supported": ["read", "write", "offline_access"], + "client_id_metadata_document_supported": true + }), + ) + } + + /// discovery responses like [`preregistered_discovery_responses`] but the + /// authorization server advertises CIMD support. + fn cimd_discovery_responses() -> Vec { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) .unwrap(); + vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + cimd_as_metadata_response(), + ] + } + + #[tokio::test] + async fn preregistered_client_takes_priority_over_cimd() { + // server supports CIMD and the request carries both pre-registered + // credentials and a client metadata URL: pre-registration wins + let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_client_metadata_url("https://client.example.com/client-metadata.json") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + let requests = client.requests(); + assert!( + requests + .iter() + .all(|request| !request.uri.contains("/register")), + "registration endpoint should not be called: {requests:?}" + ); + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("client_id").unwrap(), "preregistered-client"); + } + + #[tokio::test] + async fn cimd_used_when_server_advertises_support() { + let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_metadata_url("https://client.example.com/client-metadata.json") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + // CIMD takes priority over the advertised registration endpoint + let requests = client.requests(); + assert!( + requests + .iter() + .all(|request| !request.uri.contains("/register")), + "registration endpoint should not be called: {requests:?}" + ); + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!( + query.get("client_id").unwrap(), + "https://client.example.com/client-metadata.json" + ); + } + + #[tokio::test] + async fn cimd_falls_back_to_dcr_when_server_lacks_support() { + // server does not advertise client_id_metadata_document_supported, so + // the client metadata URL is ignored and DCR is used instead + let mut responses = preregistered_discovery_responses(); + responses.push(http_response( + 201, + serde_json::json!({ + "client_id": "dcr-client", + "redirect_uris": ["http://localhost:8080/callback"] + }), + )); + let client = RecordingOAuthHttpClient::with_responses(responses); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_metadata_url("https://client.example.com/client-metadata.json") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + let requests = client.requests(); + assert!( + requests + .iter() + .any(|request| request.uri.contains("/register")), + "registration endpoint should be called: {requests:?}" + ); + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("client_id").unwrap(), "dcr-client"); + } + + #[tokio::test] + async fn dcr_used_when_no_identity_material_is_provided() { + let mut responses = preregistered_discovery_responses(); + responses.push(http_response( + 201, + serde_json::json!({ + "client_id": "dcr-client", + "redirect_uris": ["http://localhost:8080/callback"] + }), + )); + let client = RecordingOAuthHttpClient::with_responses(responses); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_name("test-client") + .with_scopes(["read"]); + state.start_authorization(request).await.unwrap(); + + let auth_url = state.get_authorization_url().await.unwrap(); + let query = auth_url_query(&auth_url); + assert_eq!(query.get("client_id").unwrap(), "dcr-client"); + assert!(matches!(state, super::OAuthState::Session(_))); + } + + #[tokio::test] + async fn cimd_rejects_non_https_client_metadata_url_and_recovers_state() { + let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses()); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_metadata_url("http://client.example.com/client-metadata.json") + .with_scopes(["read"]); + let err = state.start_authorization(request).await.unwrap_err(); + assert!(matches!(err, AuthError::RegistrationFailed(_)), "{err:?}"); + assert!( + matches!(state, super::OAuthState::Unauthorized(_)), + "state should return to Unauthorized after a registration failure" + ); + } + + #[tokio::test] + async fn dcr_recovers_unauthorized_state_after_registration_failure() { + // discovery succeeds, but the registration endpoint rejects the request + let mut responses = preregistered_discovery_responses(); + responses.push(http_response( + 400, + serde_json::json!({"error": "invalid_client_metadata"}), + )); + let client = RecordingOAuthHttpClient::with_responses(responses); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_client_name("test-client") + .with_scopes(["read"]); + let err = state + .start_authorization(request.clone()) + .await + .unwrap_err(); + assert!(matches!(err, AuthError::RegistrationFailed(_)), "{err:?}"); + assert!( + matches!(state, super::OAuthState::Unauthorized(_)), + "state should return to Unauthorized after a registration failure" + ); + + // retrying with the same state succeeds once the server accepts + // registration (discovery runs again on retry) + { + let mut responses = client.responses.lock().unwrap(); + responses.extend(preregistered_discovery_responses()); + responses.push_back(http_response( + 201, + serde_json::json!({ + "client_id": "dcr-client", + "redirect_uris": ["http://localhost:8080/callback"] + }), + )); + } + state.start_authorization(request).await.unwrap(); assert!(matches!(state, super::OAuthState::Session(_))); } diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index 16809a407..54cf7f75a 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -110,14 +110,29 @@ transport. ### 3. Start authorization with OAuthState -The `OAuthState` state machine manages the full authorization lifecycle. When no -scopes are provided, the SDK automatically selects scopes from the server's -WWW-Authenticate header, Protected Resource Metadata, or AS metadata. +The `OAuthState` state machine manages the full authorization lifecycle. +`start_authorization` accepts an `AuthorizationRequest` describing the client +identity material you have available, and selects a client registration +mechanism following the [spec's priority order](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration): + +1. **Pre-registered client information** (`with_preregistered_client`), when + the client already holds a `client_id` issued out of band +2. **Client ID Metadata Documents** (SEP-991, `with_client_metadata_url`), when + the authorization server advertises `client_id_metadata_document_supported` +3. **Dynamic Client Registration**, as a fallback when the authorization server + advertises a `registration_endpoint` + +When no scopes are provided, the SDK automatically selects scopes from the +server's WWW-Authenticate header, Protected Resource Metadata, or AS metadata. ```rust ignore -// start authorization - pass empty scopes to let the SDK auto-select +use rmcp::transport::auth::AuthorizationRequest; + +// start authorization - pass no scopes to let the SDK auto-select oauth_state - .start_authorization(&[], MCP_REDIRECT_URI, Some("My MCP Client")) + .start_authorization( + AuthorizationRequest::new(MCP_REDIRECT_URI).with_client_name("My MCP Client"), + ) .await .context("Failed to start authorization")?; ``` @@ -126,7 +141,40 @@ If you know the scopes you need, you can still pass them explicitly: ```rust ignore oauth_state - .start_authorization(&["mcp", "profile"], MCP_REDIRECT_URI, Some("My MCP Client")) + .start_authorization( + AuthorizationRequest::new(MCP_REDIRECT_URI) + .with_scopes(["mcp", "profile"]) + .with_client_name("My MCP Client"), + ) + .await + .context("Failed to start authorization")?; +``` + +If the client hosts a Client ID Metadata Document (SEP-991), pass its URL; the +SDK uses it when the server supports CIMD and falls back to dynamic +registration otherwise: + +```rust ignore +oauth_state + .start_authorization( + AuthorizationRequest::new(MCP_REDIRECT_URI) + .with_client_name("My MCP Client") + .with_client_metadata_url("https://example.com/client-metadata.json"), + ) + .await + .context("Failed to start authorization")?; +``` + +If the client was registered with the authorization server out of band, provide +the pre-registered credentials; they take priority over every other mechanism: + +```rust ignore +oauth_state + .start_authorization( + AuthorizationRequest::new(MCP_REDIRECT_URI) + .with_preregistered_client("my-client-id") + .with_client_secret("my-client-secret"), + ) .await .context("Failed to start authorization")?; ``` diff --git a/examples/clients/src/auth/oauth_client.rs b/examples/clients/src/auth/oauth_client.rs index ffd343428..58565fb72 100644 --- a/examples/clients/src/auth/oauth_client.rs +++ b/examples/clients/src/auth/oauth_client.rs @@ -12,7 +12,7 @@ use rmcp::{ model::ClientInfo, transport::{ StreamableHttpClientTransport, - auth::{AuthClient, OAuthState}, + auth::{AuthClient, AuthorizationRequest, OAuthState}, streamable_http_client::StreamableHttpClientTransportConfig, }, }; @@ -128,14 +128,13 @@ async fn main() -> Result<()> { .await .context("Failed to initialize oauth state machine")?; // use CIMD (SEP-991) with client metadata URL. - // passing empty scopes lets the SDK auto-select from the server's + // passing no scopes lets the SDK auto-select from the server's // WWW-Authenticate header, Protected Resource Metadata, or AS metadata. oauth_state - .start_authorization_with_metadata_url( - &[], - MCP_REDIRECT_URI, - Some("Test MCP Client"), - Some(&client_metadata_url), + .start_authorization( + AuthorizationRequest::new(MCP_REDIRECT_URI) + .with_client_name("Test MCP Client") + .with_client_metadata_url(&client_metadata_url), ) .await .context("Failed to start authorization")?;