mcp: add bearer token verification seam
This commit is contained in:
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(®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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Уровни защиты операции:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user