From 46f8e6cbebef20c26d9c094b89a80bb92c797a99 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Sun, 3 May 2026 18:43:48 +0000 Subject: [PATCH] mcp: add bearer token verification seam --- TASKS.md | 2 +- apps/mcp-server/src/app.rs | 57 +++++++++++++++++++++----- apps/mcp-server/src/auth.rs | 42 +++++++++++++++++++ apps/mcp-server/src/main.rs | 82 ++++++++++++++++++++++++++++++++++++- docs/mcp-interface.md | 8 ++++ 5 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 apps/mcp-server/src/auth.rs diff --git a/TASKS.md b/TASKS.md index f63f628..3d6b95c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -41,8 +41,8 @@ Progress: - done: - stable DTO и Community stub endpoints для `POST /mcp-auth/v1/token` и `POST /mcp-auth/v1/token/one-time` - structured `403 forbidden` contract с `edition`, `machine_access_mode` и `upgrade_required` + - verification seam в `mcp-server` для bearer-token credentials без private Community implementation - pending: - - abstraction для token verification в `mcp-server` - capability-gated UI/API surface для premium machine access - final separation of Community `standard` flow from commercial `elevated/strict` diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 49ab8e0..6d4f142 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -41,6 +41,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tracing::info; use crate::{ + auth::{SharedMachineCredentialVerifier, VerifiedMachineCredential}, catalog::PublishedToolCatalog, jsonrpc::{ CURRENT_PROTOCOL_VERSION, DEFAULT_PROTOCOL_VERSION, is_notification, is_request, @@ -70,6 +71,7 @@ pub struct AppState { api_rate_limiter: RequestRateLimiter, secret_crypto: SecretCrypto, sessions: SharedSessionStore, + credential_verifier: SharedMachineCredentialVerifier, allowed_origins: AllowedOrigins, } @@ -133,6 +135,7 @@ pub fn build_app( runtime: RuntimeExecutor, api_rate_limiter: RequestRateLimiter, sessions: SharedSessionStore, + credential_verifier: SharedMachineCredentialVerifier, ) -> Router { let state = Arc::new(AppState { registry: registry.clone(), @@ -141,6 +144,7 @@ pub fn build_app( api_rate_limiter, secret_crypto, sessions, + credential_verifier, allowed_origins: AllowedOrigins::new(public_base_url), }); @@ -174,7 +178,7 @@ async fn mcp_get( } if let Err(status) = - require_platform_api_key(&state, &path, &headers, PlatformApiKeyScope::Read).await + require_machine_access(&state, &path, &headers, PlatformApiKeyScope::Read).await { return status.into_response(); } @@ -222,7 +226,7 @@ async fn mcp_delete( headers: HeaderMap, ) -> Response { if let Err(status) = - require_platform_api_key(&state, &path, &headers, PlatformApiKeyScope::Read).await + require_machine_access(&state, &path, &headers, PlatformApiKeyScope::Read).await { return status.into_response(); } @@ -321,7 +325,7 @@ async fn mcp_post( Some("tools/call") => PlatformApiKeyScope::Write, _ => PlatformApiKeyScope::Read, }; - if let Err(status) = require_platform_api_key(&state, &path, &headers, required_scope).await { + if let Err(status) = require_machine_access(&state, &path, &headers, required_scope).await { return with_request_id_header(status.into_response(), &transport_request_id); } @@ -1803,13 +1807,44 @@ async fn require_initialized_session( Ok(session) } -async fn require_platform_api_key( +async fn require_machine_access( state: &Arc, path: &AgentRoutePath, headers: &HeaderMap, required_scope: PlatformApiKeyScope, -) -> Result<(), StatusCode> { +) -> Result { let secret = bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?; + let credential = resolve_machine_credential(state, path, secret).await?; + + if !allows_scope(&credential.scopes, required_scope) { + return Err(StatusCode::FORBIDDEN); + } + + Ok(credential) +} + +async fn resolve_machine_credential( + state: &Arc, + path: &AgentRoutePath, + token: &str, +) -> Result { + if let Some(credential) = verify_static_agent_key(state, path, token).await? { + return Ok(credential); + } + + state + .credential_verifier + .verify_bearer_token(&path.workspace_slug, &path.agent_slug, token) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED) +} + +async fn verify_static_agent_key( + state: &Arc, + path: &AgentRoutePath, + secret: &str, +) -> Result, StatusCode> { let secret_hash = hash_access_secret(secret); let Some(api_key) = state .registry @@ -1821,13 +1856,9 @@ async fn require_platform_api_key( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? else { - return Err(StatusCode::UNAUTHORIZED); + return Ok(None); }; - if !allows_scope(&api_key.api_key.scopes, required_scope) { - return Err(StatusCode::FORBIDDEN); - } - let used_at = OffsetDateTime::now_utc(); state .registry @@ -1835,7 +1866,11 @@ async fn require_platform_api_key( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(()) + Ok(Some(VerifiedMachineCredential { + machine_access_mode: crank_core::MachineAccessMode::StaticAgentKey, + max_security_level: crank_core::OperationSecurityLevel::Standard, + scopes: api_key.api_key.scopes, + })) } fn bearer_token(headers: &HeaderMap) -> Option<&str> { diff --git a/apps/mcp-server/src/auth.rs b/apps/mcp-server/src/auth.rs new file mode 100644 index 0000000..b0a82c0 --- /dev/null +++ b/apps/mcp-server/src/auth.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use crank_core::{MachineAccessMode, OperationSecurityLevel, PlatformApiKeyScope}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedMachineCredential { + pub machine_access_mode: MachineAccessMode, + pub max_security_level: OperationSecurityLevel, + pub scopes: Vec, +} + +#[async_trait] +pub trait MachineCredentialVerifier: Send + Sync { + async fn verify_bearer_token( + &self, + workspace_slug: &str, + agent_slug: &str, + token: &str, + ) -> Result, MachineCredentialVerifierError>; +} + +pub type SharedMachineCredentialVerifier = Arc; + +#[derive(Clone, Default)] +pub struct CommunityMachineCredentialVerifier; + +#[derive(Debug, thiserror::Error)] +#[error("machine credential verifier failed")] +pub struct MachineCredentialVerifierError; + +#[async_trait] +impl MachineCredentialVerifier for CommunityMachineCredentialVerifier { + async fn verify_bearer_token( + &self, + _workspace_slug: &str, + _agent_slug: &str, + _token: &str, + ) -> Result, MachineCredentialVerifierError> { + Ok(None) + } +} diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index e45af80..5e58c50 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod auth; mod catalog; mod jsonrpc; mod session; @@ -13,7 +14,10 @@ use sqlx::postgres::PgConnectOptions; use tokio::net::TcpListener; use tracing::info; -use crate::{app::build_app, session::PostgresTransportSessionStore}; +use crate::{ + app::build_app, auth::CommunityMachineCredentialVerifier, + session::PostgresTransportSessionStore, +}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -56,6 +60,7 @@ async fn main() -> Result<(), Box> { runtime, RequestRateLimiter::new(api_rate_limit), std::sync::Arc::new(session_store), + std::sync::Arc::new(CommunityMachineCredentialVerifier), ); let listener = TcpListener::bind(socket_addr).await?; @@ -162,6 +167,11 @@ mod tests { use crate::{ app::build_app, + auth::{ + CommunityMachineCredentialVerifier, MachineCredentialVerifier, + MachineCredentialVerifierError, SharedMachineCredentialVerifier, + VerifiedMachineCredential, + }, session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore}, }; @@ -238,6 +248,7 @@ mod tests { public_base_url, rate_limit_config, std::sync::Arc::new(InMemorySessionStore::default()), + std::sync::Arc::new(CommunityMachineCredentialVerifier), ) } @@ -247,6 +258,7 @@ mod tests { public_base_url: Option, rate_limit_config: RequestRateLimitConfig, sessions: SharedSessionStore, + credential_verifier: SharedMachineCredentialVerifier, ) -> axum::Router { build_app( registry, @@ -256,9 +268,32 @@ mod tests { RuntimeExecutor::new(), RequestRateLimiter::new(rate_limit_config), sessions, + credential_verifier, ) } + #[derive(Clone)] + struct StubMachineCredentialVerifier { + token: String, + credential: VerifiedMachineCredential, + } + + #[async_trait::async_trait] + impl MachineCredentialVerifier for StubMachineCredentialVerifier { + async fn verify_bearer_token( + &self, + _workspace_slug: &str, + _agent_slug: &str, + token: &str, + ) -> Result, MachineCredentialVerifierError> { + if token == self.token { + return Ok(Some(self.credential.clone())); + } + + Ok(None) + } + } + #[tokio::test] async fn initializes_lists_and_calls_published_tool_via_mcp() { let registry = test_registry().await; @@ -950,6 +985,7 @@ mod tests { Some("https://crank.example.com".to_owned()), RequestRateLimitConfig::new(10_000, 10_000).unwrap(), session_store, + std::sync::Arc::new(CommunityMachineCredentialVerifier), )) .await; let client = reqwest::Client::new(); @@ -1417,6 +1453,50 @@ mod tests { assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn accepts_initialize_with_verified_bearer_token_seam() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-token-seam", vec![]).await; + + let verifier = Arc::new(StubMachineCredentialVerifier { + token: "issued_token_demo".to_owned(), + credential: VerifiedMachineCredential { + machine_access_mode: crank_core::MachineAccessMode::ShortLivedToken, + max_security_level: crank_core::OperationSecurityLevel::Elevated, + scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + }, + }); + + let base_url = spawn_mcp_server(build_test_app_with_store( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + RequestRateLimitConfig::new(10_000, 10_000).unwrap(), + std::sync::Arc::new(InMemorySessionStore::default()), + verifier, + )) + .await; + let client = reqwest::Client::new(); + let response = client + .post(agent_mcp_url(&base_url, "sales-token-seam")) + .header(header::ACCEPT, "application/json, text/event-stream") + .header(header::AUTHORIZATION, "Bearer issued_token_demo") + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25" + } + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert!(response.headers().get("MCP-Session-Id").is_some()); + } + #[tokio::test] async fn rejects_initialize_without_platform_api_key() { let registry = test_registry().await; diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 9eb3bfa..4cad8c1 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -114,6 +114,14 @@ - Community-сборка должна работать полностью без private token services; - commercial token flows должны подключаться через capability-gated server-side integrations. +- public `mcp-server` должен содержать verification seam для bearer-token credentials, даже если Community по умолчанию использует только static agent key. + +Verification seam: + +- Community path сначала проверяет статический `agent key`; +- если статический ключ не подходит, `mcp-server` делегирует bearer-token verification в отдельный verifier interface; +- в open-source Community verifier по умолчанию не выдает commercial credentials; +- private short-lived и one-time token verification подключаются заменой verifier implementation без изменения MCP transport contract. Уровни защиты операции: