mcp: add bearer token verification seam

This commit is contained in:
a.tolmachev
2026-05-03 18:43:48 +00:00
parent ffcce360fc
commit 46f8e6cbeb
5 changed files with 178 additions and 13 deletions
+1 -1
View File
@@ -41,8 +41,8 @@ Progress:
- done: - done:
- stable DTO и Community stub endpoints для `POST /mcp-auth/v1/token` и `POST /mcp-auth/v1/token/one-time` - 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` - structured `403 forbidden` contract с `edition`, `machine_access_mode` и `upgrade_required`
- verification seam в `mcp-server` для bearer-token credentials без private Community implementation
- pending: - pending:
- abstraction для token verification в `mcp-server`
- capability-gated UI/API surface для premium machine access - capability-gated UI/API surface для premium machine access
- final separation of Community `standard` flow from commercial `elevated/strict` - final separation of Community `standard` flow from commercial `elevated/strict`
+46 -11
View File
@@ -41,6 +41,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::info; use tracing::info;
use crate::{ use crate::{
auth::{SharedMachineCredentialVerifier, VerifiedMachineCredential},
catalog::PublishedToolCatalog, catalog::PublishedToolCatalog,
jsonrpc::{ jsonrpc::{
CURRENT_PROTOCOL_VERSION, DEFAULT_PROTOCOL_VERSION, is_notification, is_request, CURRENT_PROTOCOL_VERSION, DEFAULT_PROTOCOL_VERSION, is_notification, is_request,
@@ -70,6 +71,7 @@ pub struct AppState {
api_rate_limiter: RequestRateLimiter, api_rate_limiter: RequestRateLimiter,
secret_crypto: SecretCrypto, secret_crypto: SecretCrypto,
sessions: SharedSessionStore, sessions: SharedSessionStore,
credential_verifier: SharedMachineCredentialVerifier,
allowed_origins: AllowedOrigins, allowed_origins: AllowedOrigins,
} }
@@ -133,6 +135,7 @@ pub fn build_app(
runtime: RuntimeExecutor, runtime: RuntimeExecutor,
api_rate_limiter: RequestRateLimiter, api_rate_limiter: RequestRateLimiter,
sessions: SharedSessionStore, sessions: SharedSessionStore,
credential_verifier: SharedMachineCredentialVerifier,
) -> Router { ) -> Router {
let state = Arc::new(AppState { let state = Arc::new(AppState {
registry: registry.clone(), registry: registry.clone(),
@@ -141,6 +144,7 @@ pub fn build_app(
api_rate_limiter, api_rate_limiter,
secret_crypto, secret_crypto,
sessions, sessions,
credential_verifier,
allowed_origins: AllowedOrigins::new(public_base_url), allowed_origins: AllowedOrigins::new(public_base_url),
}); });
@@ -174,7 +178,7 @@ async fn mcp_get(
} }
if let Err(status) = 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(); return status.into_response();
} }
@@ -222,7 +226,7 @@ async fn mcp_delete(
headers: HeaderMap, headers: HeaderMap,
) -> Response { ) -> Response {
if let Err(status) = 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(); return status.into_response();
} }
@@ -321,7 +325,7 @@ async fn mcp_post(
Some("tools/call") => PlatformApiKeyScope::Write, Some("tools/call") => PlatformApiKeyScope::Write,
_ => PlatformApiKeyScope::Read, _ => 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); return with_request_id_header(status.into_response(), &transport_request_id);
} }
@@ -1803,13 +1807,44 @@ async fn require_initialized_session(
Ok(session) Ok(session)
} }
async fn require_platform_api_key( async fn require_machine_access(
state: &Arc<AppState>, state: &Arc<AppState>,
path: &AgentRoutePath, path: &AgentRoutePath,
headers: &HeaderMap, headers: &HeaderMap,
required_scope: PlatformApiKeyScope, required_scope: PlatformApiKeyScope,
) -> Result<(), StatusCode> { ) -> Result<VerifiedMachineCredential, StatusCode> {
let secret = bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?; 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<AppState>,
path: &AgentRoutePath,
token: &str,
) -> Result<VerifiedMachineCredential, StatusCode> {
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<AppState>,
path: &AgentRoutePath,
secret: &str,
) -> Result<Option<VerifiedMachineCredential>, StatusCode> {
let secret_hash = hash_access_secret(secret); let secret_hash = hash_access_secret(secret);
let Some(api_key) = state let Some(api_key) = state
.registry .registry
@@ -1821,13 +1856,9 @@ async fn require_platform_api_key(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
else { 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(); let used_at = OffsetDateTime::now_utc();
state state
.registry .registry
@@ -1835,7 +1866,11 @@ async fn require_platform_api_key(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .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> { fn bearer_token(headers: &HeaderMap) -> Option<&str> {
+42
View File
@@ -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<PlatformApiKeyScope>,
}
#[async_trait]
pub trait MachineCredentialVerifier: Send + Sync {
async fn verify_bearer_token(
&self,
workspace_slug: &str,
agent_slug: &str,
token: &str,
) -> Result<Option<VerifiedMachineCredential>, MachineCredentialVerifierError>;
}
pub type SharedMachineCredentialVerifier = Arc<dyn MachineCredentialVerifier>;
#[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<Option<VerifiedMachineCredential>, MachineCredentialVerifierError> {
Ok(None)
}
}
+81 -1
View File
@@ -1,4 +1,5 @@
mod app; mod app;
mod auth;
mod catalog; mod catalog;
mod jsonrpc; mod jsonrpc;
mod session; mod session;
@@ -13,7 +14,10 @@ use sqlx::postgres::PgConnectOptions;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing::info; use tracing::info;
use crate::{app::build_app, session::PostgresTransportSessionStore}; use crate::{
app::build_app, auth::CommunityMachineCredentialVerifier,
session::PostgresTransportSessionStore,
};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -56,6 +60,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
runtime, runtime,
RequestRateLimiter::new(api_rate_limit), RequestRateLimiter::new(api_rate_limit),
std::sync::Arc::new(session_store), std::sync::Arc::new(session_store),
std::sync::Arc::new(CommunityMachineCredentialVerifier),
); );
let listener = TcpListener::bind(socket_addr).await?; let listener = TcpListener::bind(socket_addr).await?;
@@ -162,6 +167,11 @@ mod tests {
use crate::{ use crate::{
app::build_app, app::build_app,
auth::{
CommunityMachineCredentialVerifier, MachineCredentialVerifier,
MachineCredentialVerifierError, SharedMachineCredentialVerifier,
VerifiedMachineCredential,
},
session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore}, session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore},
}; };
@@ -238,6 +248,7 @@ mod tests {
public_base_url, public_base_url,
rate_limit_config, rate_limit_config,
std::sync::Arc::new(InMemorySessionStore::default()), std::sync::Arc::new(InMemorySessionStore::default()),
std::sync::Arc::new(CommunityMachineCredentialVerifier),
) )
} }
@@ -247,6 +258,7 @@ mod tests {
public_base_url: Option<String>, public_base_url: Option<String>,
rate_limit_config: RequestRateLimitConfig, rate_limit_config: RequestRateLimitConfig,
sessions: SharedSessionStore, sessions: SharedSessionStore,
credential_verifier: SharedMachineCredentialVerifier,
) -> axum::Router { ) -> axum::Router {
build_app( build_app(
registry, registry,
@@ -256,9 +268,32 @@ mod tests {
RuntimeExecutor::new(), RuntimeExecutor::new(),
RequestRateLimiter::new(rate_limit_config), RequestRateLimiter::new(rate_limit_config),
sessions, 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<Option<VerifiedMachineCredential>, MachineCredentialVerifierError> {
if token == self.token {
return Ok(Some(self.credential.clone()));
}
Ok(None)
}
}
#[tokio::test] #[tokio::test]
async fn initializes_lists_and_calls_published_tool_via_mcp() { async fn initializes_lists_and_calls_published_tool_via_mcp() {
let registry = test_registry().await; let registry = test_registry().await;
@@ -950,6 +985,7 @@ mod tests {
Some("https://crank.example.com".to_owned()), Some("https://crank.example.com".to_owned()),
RequestRateLimitConfig::new(10_000, 10_000).unwrap(), RequestRateLimitConfig::new(10_000, 10_000).unwrap(),
session_store, session_store,
std::sync::Arc::new(CommunityMachineCredentialVerifier),
)) ))
.await; .await;
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@@ -1417,6 +1453,50 @@ mod tests {
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); 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(&registry, "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] #[tokio::test]
async fn rejects_initialize_without_platform_api_key() { async fn rejects_initialize_without_platform_api_key() {
let registry = test_registry().await; let registry = test_registry().await;
+8
View File
@@ -114,6 +114,14 @@
- Community-сборка должна работать полностью без private token services; - Community-сборка должна работать полностью без private token services;
- commercial token flows должны подключаться через capability-gated server-side integrations. - 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.
Уровни защиты операции: Уровни защиты операции: