diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 63a116ff4..ad0de4a17 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, InMemoryCredentialStore, OAuthState}, streamable_http_client::StreamableHttpClientTransportConfig, }, }; @@ -495,6 +495,66 @@ async fn run_auth_scope_retry_limit_client( Ok(()) } +async fn migration_token( + server_url: &str, + store: &InMemoryCredentialStore, +) -> anyhow::Result { + let mut manager = AuthorizationManager::new(server_url).await?; + manager.set_credential_store(store.clone()); + + if manager.initialize_from_store().await? { + return Ok(manager.get_access_token().await?); + } + + let metadata = manager.discover_metadata().await?; + manager.set_metadata(metadata); + manager + .register_client("conformance-client", REDIRECT_URI, &[]) + .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 callback = headless_authorize(&auth_url).await?; + manager + .exchange_code_for_token_with_issuer( + &callback.code, + &callback.csrf_token, + callback.issuer.as_deref(), + ) + .await?; + + Ok(manager.get_access_token().await?) +} + +async fn run_auth_server_migration_client( + server_url: &str, + _ctx: &ConformanceContext, +) -> anyhow::Result<()> { + let store = InMemoryCredentialStore::new(); + let http = reqwest::Client::new(); + let body = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}); + + let mut token = migration_token(server_url, &store).await?; + for _ in 0..3 { + let resp = http + .post(server_url) + .header( + "MCP-Protocol-Version", + conformance_protocol_version().as_str(), + ) + .bearer_auth(&token) + .json(&body) + .send() + .await?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + token = migration_token(server_url, &store).await?; + } + } + + Ok(()) +} + /// Auth flow with pre-registered credentials (from context). async fn run_auth_preregistered_client( server_url: &str, @@ -956,6 +1016,11 @@ async fn main() -> anyhow::Result<()> { // Auth - scope retry limit "auth/scope-retry-limit" => run_auth_scope_retry_limit_client(&server_url, &ctx).await?, + // Auth - authorization server migration (SEP-2352) + "auth/authorization-server-migration" => { + run_auth_server_migration_client(&server_url, &ctx).await? + } + // Auth - pre-registration "auth/pre-registration" => run_auth_preregistered_client(&server_url, &ctx).await?, diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 53cd9bdac..41a098be9 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -201,6 +201,8 @@ pub struct StoredCredentials { pub granted_scopes: Vec, #[serde(default)] pub token_received_at: Option, + #[serde(default)] + pub issuer: Option, } impl std::fmt::Debug for StoredCredentials { @@ -213,6 +215,7 @@ impl std::fmt::Debug for StoredCredentials { ) .field("granted_scopes", &self.granted_scopes) .field("token_received_at", &self.token_received_at) + .field("issuer", &self.issuer) .finish() } } @@ -230,8 +233,14 @@ impl StoredCredentials { token_response, granted_scopes, token_received_at, + issuer: None, } } + + pub fn with_issuer(mut self, issuer: Option) -> Self { + self.issuer = issuer; + self + } } /// Trait for storing and retrieving OAuth2 credentials @@ -1088,6 +1097,49 @@ impl AuthorizationManager { self.metadata = Some(metadata); } + if let (Some(stored_issuer), Some(current_issuer)) = + (stored.issuer.as_deref(), self.metadata_issuer().as_deref()) + { + // A CIMD client ID is the client's metadata URL, so it is + // portable across authorization servers and exempt here. + if stored_issuer != current_issuer { + if is_https_url(&stored.client_id) { + // A CIMD client ID is the client's metadata URL, so it is + // portable across authorization servers — but the tokens + // were minted by the previous AS and must not be reused. + tracing::warn!( + stored_issuer, + current_issuer, + "authorization server issuer changed; discarding tokens but keeping portable CIMD client ID" + ); + self.credential_store + .save( + StoredCredentials::new( + stored.client_id.clone(), + None, + vec![], + None, + ) + .with_issuer(self.metadata_issuer()), + ) + .await?; + self.configure_client_id(&stored.client_id)?; + return Ok(false); + } + + tracing::warn!( + stored_issuer, + current_issuer, + "authorization server issuer changed; clearing stored credentials bound to the previous issuer" + ); + self.credential_store.clear().await?; + return Err(AuthError::AuthorizationServerMismatch { + expected_issuer: stored_issuer.to_string(), + received_issuer: current_issuer.to_string(), + }); + } + } + self.configure_client_id(&stored.client_id)?; return Ok(true); } @@ -1703,6 +1755,7 @@ impl AuthorizationManager { token_response: Some(token_result.clone()), granted_scopes, token_received_at: Some(Self::now_epoch_secs()), + issuer: self.metadata_issuer(), }; self.credential_store.save(stored).await?; @@ -1716,6 +1769,10 @@ impl AuthorizationManager { .as_secs() } + fn metadata_issuer(&self) -> Option { + self.metadata.as_ref().and_then(|m| m.issuer.clone()) + } + /// Proactive refresh buffer: refresh tokens this many seconds before they expire /// to avoid races between token retrieval and the actual HTTP request. const REFRESH_BUFFER_SECS: u64 = 30; @@ -1838,6 +1895,7 @@ impl AuthorizationManager { token_response: Some(token_result.clone()), granted_scopes, token_received_at: Some(Self::now_epoch_secs()), + issuer: self.metadata_issuer(), }; self.credential_store.save(stored).await?; @@ -2658,6 +2716,7 @@ impl AuthorizationManager { token_response: Some(token_result.clone()), granted_scopes, token_received_at: Some(Self::now_epoch_secs()), + issuer: self.metadata_issuer(), }; self.credential_store.save(stored).await?; @@ -2780,6 +2839,7 @@ impl AuthorizationManager { token_response: Some(token_result.clone()), granted_scopes, token_received_at: Some(Self::now_epoch_secs()), + issuer: self.metadata_issuer(), }; self.credential_store.save(stored).await?; @@ -3142,17 +3202,18 @@ impl OAuthState { *manager.current_scopes.write().await = granted_scopes.clone(); + let metadata = manager.discover_metadata().await?; + manager.metadata = Some(metadata); + let stored = StoredCredentials { client_id: client_id.to_string(), token_response: Some(credentials), granted_scopes, token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: manager.metadata_issuer(), }; manager.credential_store.save(stored).await?; - let metadata = manager.discover_metadata().await?; - manager.metadata = Some(metadata); - manager.configure_client_id(client_id)?; *self = OAuthState::Authorized(manager); @@ -4617,6 +4678,7 @@ mod tests { token_response: Some(token_response), granted_scopes: vec![], token_received_at: None, + issuer: None, }; let debug_output = format!("{:?}", creds); @@ -5594,6 +5656,7 @@ mod tests { token_response: Some(make_token_response("my-access-token", Some(3600))), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -5611,6 +5674,7 @@ mod tests { token_response: Some(make_token_response("stale-token", Some(3600))), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -5629,6 +5693,7 @@ mod tests { token_response: Some(make_token_response("no-expiry-token", None)), granted_scopes: vec![], token_received_at: None, + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -5646,6 +5711,7 @@ mod tests { token_response: Some(make_token_response("almost-expired", Some(3600))), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs() - 3590), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -5664,6 +5730,7 @@ mod tests { token_response: Some(make_token_response("stale-token", Some(3600))), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -5965,6 +6032,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }) .await .unwrap(); @@ -5993,6 +6061,7 @@ mod tests { token_response: None, granted_scopes: vec![], token_received_at: None, + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6013,6 +6082,7 @@ mod tests { token_response: Some(make_token_response("old-token", Some(3600))), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6103,6 +6173,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }) .await .unwrap(); @@ -6308,6 +6379,7 @@ mod tests { )), granted_scopes: vec!["read".to_string(), "write".to_string()], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6345,6 +6417,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6382,6 +6455,7 @@ mod tests { )), granted_scopes: vec!["read".to_string()], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6419,6 +6493,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6457,6 +6532,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6520,6 +6596,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap();