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