//! `/api/v1/mcp-tokens` — per-tenant API tokens for the MCP server. //! //! These are opaque static bearers issued via the dashboard (or a //! direct curl with a KC JWT) and copied into LLM clients (Claude //! Desktop / Cursor / ChatGPT). The MCP server hashes incoming bearers //! and looks them up in the cross-tenant `__admin.mcp_tokens` //! collection to derive the tenant_id for routing. //! //! The raw token is shown to the caller exactly once at creation; the //! database only ever stores the SHA-256 hash. Revocation is a soft //! delete (sets `revoked: true`) so the audit log keeps the record. use axum::extract::{Extension, Path}; use axum::http::StatusCode; use axum::Json; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use compliance_core::models::{McpToken, McpTokenView}; use compliance_core::tenant_ctx::TenantCtx; use mongodb::bson::doc; use rand::RngCore; use sha2::{Digest, Sha256}; use super::dto::{AgentExt, ApiResponse}; /// Mongo collection name inside the admin DB. const COLLECTION: &str = "mcp_tokens"; /// Token prefix the MCP server expects on every bearer. const TOKEN_PREFIX: &str = "mcpt_"; /// Bytes of randomness behind each token. 32 → ~256 bits. /// Encoded as URL-safe base64 without padding → 43 chars. /// Combined with `mcpt_` → 48-char tokens. const TOKEN_RAND_BYTES: usize = 32; #[derive(serde::Deserialize)] pub struct CreateMcpTokenRequest { pub name: String, } /// Returned exactly once at creation. The `token` field is gone from /// the listing endpoint — the user must save it now. #[derive(serde::Serialize)] pub struct CreateMcpTokenResponse { pub token: String, pub view: McpTokenView, } /// `POST /api/v1/mcp-tokens` — mint a new token for the caller's tenant. #[tracing::instrument(skip_all)] pub async fn create_mcp_token( Extension(agent): AgentExt, tenant: TenantCtx, Json(req): Json, ) -> Result, StatusCode> { if req.name.trim().is_empty() { return Err(StatusCode::BAD_REQUEST); } let raw = generate_token(); let token_hash = sha256_hex(&raw); let token_prefix: String = raw.chars().take(12).collect(); let mut token = McpToken { id: None, token_hash, token_prefix, tenant_id: tenant.0.tenant_id.clone(), name: req.name.trim().to_string(), created_by: tenant.0.user_id.clone(), created_at: chrono::Utc::now(), last_used_at: None, revoked: false, }; let col = agent.db_pool.admin_db().collection::(COLLECTION); let res = col.insert_one(&token).await.map_err(|e| { tracing::error!("Failed to insert MCP token: {e}"); StatusCode::INTERNAL_SERVER_ERROR })?; token.id = res.inserted_id.as_object_id(); Ok(Json(CreateMcpTokenResponse { view: McpTokenView::from(&token), token: raw, })) } /// `GET /api/v1/mcp-tokens` — list tokens for the caller's tenant. /// Hash is never returned; only metadata + the 12-char prefix so the /// user can identify which row is which. #[tracing::instrument(skip_all)] pub async fn list_mcp_tokens( Extension(agent): AgentExt, tenant: TenantCtx, ) -> Result>>, StatusCode> { let col = agent.db_pool.admin_db().collection::(COLLECTION); let mut cursor = col .find(doc! { "tenant_id": &tenant.0.tenant_id }) .sort(doc! { "created_at": -1 }) .await .map_err(|e| { tracing::error!("Failed to list MCP tokens: {e}"); StatusCode::INTERNAL_SERVER_ERROR })?; let mut out = Vec::new(); while cursor.advance().await.map_err(|e| { tracing::warn!("MCP tokens cursor advance failed: {e}"); StatusCode::INTERNAL_SERVER_ERROR })? { match cursor.deserialize_current() { Ok(t) => out.push(McpTokenView::from(&t)), Err(e) => tracing::warn!("Failed to deserialize MCP token: {e}"), } } Ok(Json(ApiResponse { data: out, total: None, page: None, })) } /// `DELETE /api/v1/mcp-tokens/{id}` — revoke (soft delete). /// Scoped to the caller's tenant: a user can't revoke another tenant's /// token even if they guess its id. #[tracing::instrument(skip_all, fields(id = %id))] pub async fn revoke_mcp_token( Extension(agent): AgentExt, tenant: TenantCtx, Path(id): Path, ) -> Result, StatusCode> { let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; let col = agent.db_pool.admin_db().collection::(COLLECTION); let result = col .update_one( doc! { "_id": oid, "tenant_id": &tenant.0.tenant_id }, doc! { "$set": { "revoked": true } }, ) .await .map_err(|e| { tracing::error!("Failed to revoke MCP token: {e}"); StatusCode::INTERNAL_SERVER_ERROR })?; if result.matched_count == 0 { return Err(StatusCode::NOT_FOUND); } Ok(Json(serde_json::json!({ "status": "revoked" }))) } /// 32 bytes random → URL-safe base64 → 43 chars, no padding. /// Prefixed with `mcpt_` so the MCP server can sniff the format /// before bothering with the DB lookup. fn generate_token() -> String { let mut bytes = [0u8; TOKEN_RAND_BYTES]; rand::rng().fill_bytes(&mut bytes); format!("{TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes)) } fn sha256_hex(s: &str) -> String { let mut h = Sha256::new(); h.update(s.as_bytes()); hex::encode(h.finalize()) } #[cfg(test)] mod tests { use super::*; #[test] fn generated_tokens_are_unique_and_prefixed() { let a = generate_token(); let b = generate_token(); assert_ne!(a, b); assert!(a.starts_with(TOKEN_PREFIX)); assert!(b.starts_with(TOKEN_PREFIX)); // 5 + 43 = 48 chars assert_eq!(a.len(), 5 + 43); } #[test] fn sha256_is_stable_and_64_hex() { let h = sha256_hex("mcpt_abc"); assert_eq!(h.len(), 64); assert!(h.chars().all(|c| c.is_ascii_hexdigit())); assert_eq!(sha256_hex("mcpt_abc"), h); } }