From 4141fafe92371700960c90ffd20a520ee347c60e Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 16 Jul 2026 11:21:53 -0400 Subject: [PATCH] feat(auth): bind DCR client credentials to issuing authorization server (SEP-2352) --- conformance/src/bin/client.rs | 67 ++++++++++++++++++++++++++++++- crates/rmcp/src/transport/auth.rs | 37 +++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index cd4671f1..280d095a 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -4,7 +4,7 @@ use rmcp::{ service::{RequestContext, serve_directly}, 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, @@ -1023,6 +1083,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 b1f505ea..34cbc98a 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -200,6 +200,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 { @@ -212,6 +214,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() } } @@ -229,6 +232,7 @@ impl StoredCredentials { token_response, granted_scopes, token_received_at, + issuer: None, } } } @@ -1068,6 +1072,15 @@ impl AuthorizationManager { self.metadata = Some(metadata); } + if let (Some(stored_issuer), Some(current_issuer)) = + (stored.issuer.as_deref(), self.metadata_issuer().as_deref()) + { + if stored_issuer != current_issuer { + self.credential_store.clear().await?; + return Ok(false); + } + } + self.configure_client_id(&stored.client_id)?; return Ok(true); } @@ -1646,6 +1659,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?; @@ -1659,6 +1673,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; @@ -1772,6 +1790,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?; @@ -2517,6 +2536,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?; @@ -2639,6 +2659,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?; @@ -3006,6 +3027,7 @@ impl OAuthState { token_response: Some(credentials), granted_scopes, token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await?; @@ -4345,6 +4367,7 @@ mod tests { token_response: Some(token_response), granted_scopes: vec![], token_received_at: None, + issuer: None, }; let debug_output = format!("{:?}", creds); @@ -5224,6 +5247,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(); @@ -5241,6 +5265,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(); @@ -5259,6 +5284,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(); @@ -5276,6 +5302,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(); @@ -5294,6 +5321,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(); @@ -5577,6 +5605,7 @@ mod tests { token_response: None, granted_scopes: vec![], token_received_at: None, + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -5597,6 +5626,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(); @@ -5663,6 +5693,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }) .await .unwrap(); @@ -5868,6 +5899,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(); @@ -5905,6 +5937,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -5942,6 +5975,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(); @@ -5979,6 +6013,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6017,6 +6052,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap(); @@ -6080,6 +6116,7 @@ mod tests { )), granted_scopes: vec![], token_received_at: Some(AuthorizationManager::now_epoch_secs()), + issuer: None, }; manager.credential_store.save(stored).await.unwrap();