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:
- 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`
+46 -11
View File
@@ -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<AppState>,
path: &AgentRoutePath,
headers: &HeaderMap,
required_scope: PlatformApiKeyScope,
) -> Result<(), StatusCode> {
) -> Result<VerifiedMachineCredential, StatusCode> {
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 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> {
+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 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<dyn std::error::Error>> {
@@ -56,6 +60,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<String>,
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<Option<VerifiedMachineCredential>, 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(&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]
async fn rejects_initialize_without_platform_api_key() {
let registry = test_registry().await;
+8
View File
@@ -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.
Уровни защиты операции: