From 3640dea441d5dd665ff81b3f6b94621f4cccab7f Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 18:30:31 +0800 Subject: [PATCH 1/6] feat(storage): inbound API-key credentials (migration 020 + repo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of API-key inbound auth (so headless/remote clients authenticate without the host-only OAuth consent deep link). Adds the inbound_client_api_keys table — SHA-256 hashes only, never plaintext; multiple keys per client for rotation — and InboundClientRepository methods create/validate/list/revoke (validation rejects revoked/expired keys and touches last_used_at), plus InboundApiKey + ApiKeyAuth types. Signed-off-by: Mohammod Al Amin Ashik --- crates/mcpmux-storage/src/database.rs | 5 + .../020_inbound_client_api_keys.sql | 25 ++++ .../repositories/inbound_client_repository.rs | 141 ++++++++++++++++++ crates/mcpmux-storage/src/repositories/mod.rs | 4 +- 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 crates/mcpmux-storage/src/migrations/020_inbound_client_api_keys.sql diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index 14a32c2a..93fc27d7 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -128,6 +128,11 @@ const MIGRATIONS: &[Migration] = &[ name: "space_base_dirs", sql: include_str!("migrations/019_space_base_dirs.sql"), }, + Migration { + version: 20, + name: "inbound_client_api_keys", + sql: include_str!("migrations/020_inbound_client_api_keys.sql"), + }, ]; /// SQLite database wrapper. diff --git a/crates/mcpmux-storage/src/migrations/020_inbound_client_api_keys.sql b/crates/mcpmux-storage/src/migrations/020_inbound_client_api_keys.sql new file mode 100644 index 00000000..4b686086 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/020_inbound_client_api_keys.sql @@ -0,0 +1,25 @@ +-- Migration 020: Inbound client API keys +-- +-- Long-lived, host-issued bearer credentials for manually-registered +-- (preregistered) inbound clients, so headless/remote clients can authenticate +-- WITHOUT the interactive OAuth consent flow (the mcpmux:// deep link only +-- works on the host). Keys are shown once at creation and stored only as a +-- SHA-256 hash — never in plaintext. Multiple keys per client allow rotation. + +CREATE TABLE IF NOT EXISTS inbound_client_api_keys ( + key_id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, -- SHA-256(presented key), hex + key_prefix TEXT NOT NULL, -- first chars (e.g. "mcpk_ab12") for UI display + label TEXT, -- optional user-facing name for the key + revoked INTEGER NOT NULL DEFAULT 0, + last_used_at TEXT, + expires_at TEXT, -- optional ISO-8601; NULL = no expiry + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (client_id) REFERENCES inbound_clients(client_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_client ON inbound_client_api_keys(client_id); +-- Lookups on auth validate by hash and only care about live keys. +CREATE INDEX IF NOT EXISTS idx_api_keys_hash_live ON inbound_client_api_keys(key_hash) WHERE revoked = 0; diff --git a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs index 44676fc6..f20b353a 100644 --- a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs +++ b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs @@ -160,6 +160,27 @@ pub struct TokenRecord { pub parent_token_id: Option, } +/// A stored API-key record. Never exposes the secret — only its display prefix. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InboundApiKey { + pub key_id: String, + pub client_id: String, + pub key_prefix: String, + pub label: Option, + pub revoked: bool, + pub last_used_at: Option, + pub expires_at: Option, + pub created_at: String, + pub updated_at: String, +} + +/// Identity resolved from a presented API key. +#[derive(Debug, Clone)] +pub struct ApiKeyAuth { + pub key_id: String, + pub client_id: String, +} + /// OAuth Repository with database persistence pub struct InboundClientRepository { db: Arc>, @@ -566,6 +587,126 @@ impl InboundClientRepository { hex::encode(hasher.finalize()) } + // ========================================================================= + // Inbound client API keys (long-lived, host-issued bearer credentials) + // ========================================================================= + + /// SHA-256 hex of an API key — the only form ever persisted. Same algorithm + /// as `hash_token`; named separately for intent. + pub fn hash_api_key(key: &str) -> String { + Self::hash_token(key) + } + + /// Persist a freshly-generated API key for a client. The caller generates + /// the random `plaintext` (shown to the user once) and a unique `key_id`; + /// only the SHA-256 hash + a display prefix are stored. + pub async fn create_api_key( + &self, + key_id: &str, + client_id: &str, + plaintext: &str, + key_prefix: &str, + label: Option<&str>, + expires_at: Option<&str>, + ) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + let hash = Self::hash_api_key(plaintext); + let db = self.db.lock().await; + let conn = db.connection(); + conn.execute( + "INSERT INTO inbound_client_api_keys + (key_id, client_id, key_hash, key_prefix, label, revoked, expires_at, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, ?7)", + params![key_id, client_id, hash, key_prefix, label, expires_at, now], + )?; + info!( + "[ApiKey] Created key {} for client {}", + key_prefix, client_id + ); + Ok(()) + } + + /// Validate a presented API key: look up a live (non-revoked, unexpired) key + /// by hash, touch `last_used_at`, and return the owning client. + pub async fn validate_api_key(&self, presented: &str) -> Result> { + let hash = Self::hash_api_key(presented); + let now = chrono::Utc::now().to_rfc3339(); + let db = self.db.lock().await; + let conn = db.connection(); + + let result = conn.query_row( + "SELECT key_id, client_id, expires_at + FROM inbound_client_api_keys + WHERE key_hash = ?1 AND revoked = 0", + params![hash], + |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, Option>(2)?, + )) + }, + ); + let (key_id, client_id, expires_at) = match result { + Ok(t) => t, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + + // ISO-8601 strings compare lexicographically; reject expired keys. + if let Some(exp) = expires_at.as_deref() { + if exp <= now.as_str() { + return Ok(None); + } + } + + conn.execute( + "UPDATE inbound_client_api_keys SET last_used_at = ?1 WHERE key_id = ?2", + params![now, key_id], + )?; + Ok(Some(ApiKeyAuth { key_id, client_id })) + } + + /// List a client's API keys (no secrets — prefix + metadata only). + pub async fn list_api_keys(&self, client_id: &str) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + let mut stmt = conn.prepare( + "SELECT key_id, client_id, key_prefix, label, revoked, last_used_at, expires_at, created_at, updated_at + FROM inbound_client_api_keys WHERE client_id = ?1 ORDER BY created_at DESC", + )?; + let rows = stmt.query_map(params![client_id], |r| { + Ok(InboundApiKey { + key_id: r.get(0)?, + client_id: r.get(1)?, + key_prefix: r.get(2)?, + label: r.get(3)?, + revoked: r.get::<_, i32>(4)? != 0, + last_used_at: r.get(5)?, + expires_at: r.get(6)?, + created_at: r.get(7)?, + updated_at: r.get(8)?, + }) + })?; + let mut keys = Vec::new(); + for k in rows { + keys.push(k?); + } + Ok(keys) + } + + /// Revoke a single API key (irreversible — it can never authenticate again). + pub async fn revoke_api_key(&self, key_id: &str) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + let db = self.db.lock().await; + let conn = db.connection(); + conn.execute( + "UPDATE inbound_client_api_keys SET revoked = 1, updated_at = ?1 WHERE key_id = ?2", + params![now, key_id], + )?; + Ok(()) + } + /// Save a token record pub async fn save_token(&self, record: &TokenRecord) -> Result<()> { let db = self.db.lock().await; diff --git a/crates/mcpmux-storage/src/repositories/mod.rs b/crates/mcpmux-storage/src/repositories/mod.rs index 9b1947db..fb6a87d4 100644 --- a/crates/mcpmux-storage/src/repositories/mod.rs +++ b/crates/mcpmux-storage/src/repositories/mod.rs @@ -17,8 +17,8 @@ pub use app_settings_repository::SqliteAppSettingsRepository; pub use credential_repository::SqliteCredentialRepository; pub use feature_set_repository::SqliteFeatureSetRepository; pub use inbound_client_repository::{ - AuthorizationCode, InboundClient, InboundClientRepository, RegistrationType, TokenRecord, - TokenType, + ApiKeyAuth, AuthorizationCode, InboundApiKey, InboundClient, InboundClientRepository, + RegistrationType, TokenRecord, TokenType, }; pub use inbound_mcp_client_repository::SqliteInboundMcpClientRepository; pub use installed_server_repository::SqliteInstalledServerRepository; From b0afeee96a7a1eea11c0d126e6ddae14df8278c1 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 18:34:50 +0800 Subject: [PATCH 2/6] feat(gateway): accept API keys in inbound auth middleware Phase 1. The auth chokepoint now resolves an identity from a valid JWT OR a valid API key (host-issued, validated via the inbound-client repo): when there is no valid JWT, a Bearer that matches a live key resolves to that key's client_id. Falls through to 401 (auth on) / anonymous (auth off) exactly as before. Lets headless/remote clients authenticate without the host-only OAuth consent deep link. Signed-off-by: Mohammod Al Amin Ashik --- .../src/mcp/oauth_middleware.rs | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs b/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs index 0a9be4da..561e0ade 100644 --- a/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs +++ b/crates/mcpmux-gateway/src/mcp/oauth_middleware.rs @@ -95,19 +95,45 @@ pub async fn mcp_oauth_middleware( None => None, }; - // Resolve (client_id, space_id) from the token, or — when auth is disabled - // — fall back to an anonymous identity on the default space. - let (client_id, space_id) = if let Some(claims) = claims { + // If there's no valid JWT, a presented Bearer may instead be a long-lived + // API key (host-issued, for headless/remote clients) — validate it directly + // to a client_id so a remote client can authenticate with no interactive + // consent (the OAuth consent deep link only works on the host). + let api_key_client_id = if claims.is_none() { + match token { + Some(tok) => match services + .dependencies + .inbound_client_repo + .validate_api_key(tok) + .await + { + Ok(result) => result.map(|auth| auth.client_id), + Err(e) => { + warn!(trace_id = %trace_id, "API key validation error: {}", e); + None + } + }, + None => None, + } + } else { + None + }; + + // Resolve (client_id, space_id) from the authenticated identity (JWT or API + // key); when auth is disabled, fall back to an anonymous identity on the + // default space. + let authed_client_id = claims.map(|c| c.client_id).or(api_key_client_id); + let (client_id, space_id) = if let Some(cid) = authed_client_id { match services .space_resolver_service - .resolve_space_for_client(&claims.client_id) + .resolve_space_for_client(&cid) .await { - Ok(id) => (claims.client_id, id), + Ok(id) => (cid, id), Err(e) => { warn!( trace_id = %trace_id, - client_id = %claims.client_id, + client_id = %cid, "Failed to resolve space: {}", e ); return ( From 7496e8f1ab23c766392cf33de372d4e3d0ebda8f Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 29 Jun 2026 10:38:33 +0800 Subject: [PATCH 3/6] feat: register API-key clients + key-management commands Phase 1. Host-side surface to mint/manage API keys: - Tauri commands: register_api_key_client (creates a Preregistered + approved client, issues a key shown once), create_client_api_key (rotation), list_client_api_keys, revoke_client_api_key. - TS wrappers + RegisteredApiKeyClient / ApiKeyInfo types. The generated key (mcpk_ + 256 bits) is returned once; only its SHA-256 hash is stored. A remote client then authenticates with `Authorization: Bearer `. The locked_space wiring (set_locked_space/get_locked_space repo methods, register_api_key_client's locked_space_id param, and the lockedSpaceId TS field) is deferred to P2, where migration 022 introduces the locked_space_id column (Strategy Y). P1 ships pure API-key auth with migration 020 only. Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src-tauri/src/commands/oauth.rs | 215 +++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 4 + apps/desktop/src/lib/api/gateway.ts | 95 ++++++-- 3 files changed, 292 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands/oauth.rs b/apps/desktop/src-tauri/src/commands/oauth.rs index b06737ec..0f45cd13 100644 --- a/apps/desktop/src-tauri/src/commands/oauth.rs +++ b/apps/desktop/src-tauri/src/commands/oauth.rs @@ -927,6 +927,221 @@ pub async fn delete_oauth_client( Ok(()) } +// ============================================================================= +// API-key clients (manually registered, host-issued credentials) +// +// A "preregistered", pre-approved inbound client authenticated by a long-lived +// API key. Unlike DCR clients it skips the browser-consent deep link, so +// headless/remote clients can connect with just the key — the secure path when +// the gateway is exposed over the network. +// ============================================================================= + +/// A newly-registered API-key client. `api_key` is returned ONCE at creation — +/// McpMux stores only its SHA-256 hash and can never show it again. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisteredApiKeyClient { + pub client_id: String, + pub client_name: String, + pub api_key: String, + pub key_prefix: String, +} + +/// API-key metadata for display (never includes the secret). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiKeyInfo { + pub key_id: String, + pub key_prefix: String, + pub label: Option, + pub revoked: bool, + pub last_used_at: Option, + pub created_at: String, +} + +/// Generate a strong API key: `mcpk_` + 256 bits of v4-UUID randomness. +/// Returns `(key_id, plaintext, key_prefix)`. Only the hash is ever stored. +fn generate_api_key() -> (String, String, String) { + let key_id = uuid::Uuid::new_v4().to_string(); + let secret = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + let plaintext = format!("mcpk_{secret}"); + let key_prefix: String = plaintext.chars().take(13).collect(); // "mcpk_" + 8 chars + (key_id, plaintext, key_prefix) +} + +/// Register a new pre-approved client authenticated by an API key. The returned +/// `api_key` is shown once and never stored. +#[tauri::command] +pub async fn register_api_key_client( + gateway_state: State<'_, Arc>>, + name: String, +) -> Result { + let app_state = gateway_state.read().await; + let Some(ref gw_state) = app_state.gateway_state else { + return Err("Gateway not running".to_string()); + }; + let state = gw_state.read().await; + let Some(repo) = state.inbound_client_repository() else { + return Err("Database not available".to_string()); + }; + + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Client name is required".to_string()); + } + + let now = chrono::Utc::now().to_rfc3339(); + let client_id = format!("mcp_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); + let client = mcpmux_storage::InboundClient { + client_id: client_id.clone(), + registration_type: mcpmux_storage::RegistrationType::Preregistered, + client_name: trimmed.to_string(), + client_alias: None, + redirect_uris: vec![], + grant_types: vec![], + response_types: vec![], + token_endpoint_auth_method: "none".to_string(), + scope: None, + approved: true, + logo_uri: None, + client_uri: None, + software_id: None, + software_version: None, + metadata_url: None, + metadata_cached_at: None, + metadata_cache_ttl: None, + last_seen: None, + created_at: now.clone(), + updated_at: now, + reports_roots: false, + roots_capability_known: false, + }; + repo.save_client(&client) + .await + .map_err(|e| format!("Failed to create client: {}", e))?; + + let (key_id, plaintext, key_prefix) = generate_api_key(); + repo.create_api_key(&key_id, &client_id, &plaintext, &key_prefix, None, None) + .await + .map_err(|e| format!("Failed to create API key: {}", e))?; + + info!( + "[OAuth] Registered API-key client {} ({})", + trimmed, client_id + ); + + Ok(RegisteredApiKeyClient { + client_id, + client_name: trimmed.to_string(), + api_key: plaintext, + key_prefix, + }) +} + +/// Issue an additional API key for an existing client (rotation). Returns the +/// new key plaintext once. +#[tauri::command] +pub async fn create_client_api_key( + gateway_state: State<'_, Arc>>, + client_id: String, + label: Option, +) -> Result { + let app_state = gateway_state.read().await; + let Some(ref gw_state) = app_state.gateway_state else { + return Err("Gateway not running".to_string()); + }; + let state = gw_state.read().await; + let Some(repo) = state.inbound_client_repository() else { + return Err("Database not available".to_string()); + }; + + let Some(client) = repo + .get_client(&client_id) + .await + .map_err(|e| format!("Failed to load client: {}", e))? + else { + return Err("Client not found".to_string()); + }; + + let (key_id, plaintext, key_prefix) = generate_api_key(); + repo.create_api_key( + &key_id, + &client_id, + &plaintext, + &key_prefix, + label.as_deref(), + None, + ) + .await + .map_err(|e| format!("Failed to create API key: {}", e))?; + + Ok(RegisteredApiKeyClient { + client_id, + client_name: client.client_name, + api_key: plaintext, + key_prefix, + }) +} + +/// List a client's API keys (metadata only — never the secret). +#[tauri::command] +pub async fn list_client_api_keys( + gateway_state: State<'_, Arc>>, + client_id: String, +) -> Result, String> { + let app_state = gateway_state.read().await; + let Some(ref gw_state) = app_state.gateway_state else { + return Err("Gateway not running".to_string()); + }; + let state = gw_state.read().await; + let Some(repo) = state.inbound_client_repository() else { + return Err("Database not available".to_string()); + }; + + let keys = repo + .list_api_keys(&client_id) + .await + .map_err(|e| format!("Failed to list API keys: {}", e))?; + + Ok(keys + .into_iter() + .map(|k| ApiKeyInfo { + key_id: k.key_id, + key_prefix: k.key_prefix, + label: k.label, + revoked: k.revoked, + last_used_at: k.last_used_at, + created_at: k.created_at, + }) + .collect()) +} + +/// Revoke a single API key (it can never authenticate again). +#[tauri::command] +pub async fn revoke_client_api_key( + gateway_state: State<'_, Arc>>, + key_id: String, +) -> Result<(), String> { + let app_state = gateway_state.read().await; + let Some(ref gw_state) = app_state.gateway_state else { + return Err("Gateway not running".to_string()); + }; + let state = gw_state.read().await; + let Some(repo) = state.inbound_client_repository() else { + return Err("Database not available".to_string()); + }; + + repo.revoke_api_key(&key_id) + .await + .map_err(|e| format!("Failed to revoke API key: {}", e))?; + info!("[OAuth] Revoked API key {}", key_id); + Ok(()) +} + /// Open a URL without flashing a terminal window (Windows-specific) #[cfg(target_os = "windows")] fn open_url_no_flash(url: &str) -> Result<(), String> { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5cde1a72..0b995284 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -984,6 +984,10 @@ pub fn run() { commands::approve_oauth_client, commands::update_oauth_client, commands::delete_oauth_client, + commands::register_api_key_client, + commands::create_client_api_key, + commands::list_client_api_keys, + commands::revoke_client_api_key, commands::open_url, // Per-client grants for the rootless fallback path commands::get_oauth_client_grants, diff --git a/apps/desktop/src/lib/api/gateway.ts b/apps/desktop/src/lib/api/gateway.ts index b7c2737b..da96fda8 100644 --- a/apps/desktop/src/lib/api/gateway.ts +++ b/apps/desktop/src/lib/api/gateway.ts @@ -151,10 +151,7 @@ export async function restartGateway(opts?: { /** * Export config for a client. */ -export async function exportConfig( - format: ExportFormat, - clientId?: string -): Promise { +export async function exportConfig(format: ExportFormat, clientId?: string): Promise { return invoke('export_config', { format, clientId }); } @@ -181,7 +178,11 @@ export async function connectServer(serverId: string): Promise { * @param spaceId - The space ID (required for proper space isolation) * @param logout - If true, also delete stored credentials (OAuth tokens) */ -export async function disconnectServer(serverId: string, spaceId: string, logout?: boolean): Promise { +export async function disconnectServer( + serverId: string, + spaceId: string, + logout?: boolean +): Promise { return invoke('disconnect_server', { serverId, spaceId, logout }); } @@ -199,13 +200,13 @@ export type RegistrationType = 'cimd' | 'dcr' | 'preregistered'; /** * Inbound client (unified OAuth + MCP model) - * + * * Represents apps connecting TO McpMux (e.g., Cursor, VS Code, Claude Desktop). * Supports three MCP registration approaches: * - CIMD: Client ID Metadata Documents (client_id is a URL) * - DCR: Dynamic Client Registration (server generates client_id) * - Preregistered: Server pre-configures client_id - * + * * Per RFC 7591, clients self-identify via metadata they provide. * Use `logo_uri`, `software_id`, and `client_name` for client identification. */ @@ -216,20 +217,20 @@ export interface OAuthClient { client_alias: string | null; redirect_uris: string[]; scope: string | null; - + // Approval status - true if user has explicitly approved this client approved: boolean; - + // RFC 7591 Client Metadata (use these for client identification) - logo_uri?: string | null; // URL for client's logo - client_uri?: string | null; // URL of client's homepage - software_id?: string | null; // Unique identifier (e.g., "com.cursor.app") - software_version?: string | null; // Client software version - + logo_uri?: string | null; // URL for client's logo + client_uri?: string | null; // URL of client's homepage + software_id?: string | null; // Unique identifier (e.g., "com.cursor.app") + software_version?: string | null; // Client software version + // CIMD-specific fields (only used when registration_type='cimd') - metadata_url?: string | null; // URL where metadata was fetched - metadata_cached_at?: string | null; // When we last fetched - metadata_cache_ttl?: number | null; // Cache duration in seconds + metadata_url?: string | null; // URL where metadata was fetched + metadata_cached_at?: string | null; // When we last fetched + metadata_cache_ttl?: number | null; // Cache duration in seconds last_seen: string | null; created_at: string; @@ -302,10 +303,7 @@ export async function deleteOAuthClient(clientId: string): Promise { * means the rootless fallback would deny — consumer should render the * "no defaults configured" empty state. */ -export async function getOAuthClientGrants( - clientId: string, - spaceId: string -): Promise { +export async function getOAuthClientGrants(clientId: string, spaceId: string): Promise { return invoke('get_oauth_client_grants', { clientId, spaceId }); } @@ -340,6 +338,59 @@ export async function revokeOAuthClientFeatureSet( }); } +// ============================================================================= +// API-key clients (manually registered, host-issued credentials) +// ============================================================================= +// +// A pre-approved inbound client authenticated by a long-lived API key. Skips +// the browser-consent deep link, so headless/remote clients can connect with +// just the key — the secure path when the gateway is exposed over the network. + +/** A newly-registered API-key client. `apiKey` is shown ONCE — store it now. */ +export interface RegisteredApiKeyClient { + clientId: string; + clientName: string; + /** The full key — shown once; afterwards only its hash is kept. */ + apiKey: string; + keyPrefix: string; +} + +/** API-key metadata for display (never the secret). */ +export interface ApiKeyInfo { + keyId: string; + keyPrefix: string; + label: string | null; + revoked: boolean; + lastUsedAt: string | null; + createdAt: string; +} + +/** + * Register a pre-approved client authenticated by an API key. The returned key + * is shown once and never retrievable again. + */ +export async function registerApiKeyClient(name: string): Promise { + return invoke('register_api_key_client', { name }); +} + +/** Issue an additional API key for an existing client (rotation). Shown once. */ +export async function createClientApiKey( + clientId: string, + label?: string | null +): Promise { + return invoke('create_client_api_key', { clientId, label: label ?? null }); +} + +/** List a client's API keys (metadata only — never the secret). */ +export async function listClientApiKeys(clientId: string): Promise { + return invoke('list_client_api_keys', { clientId }); +} + +/** Revoke an API key (it can never authenticate again). */ +export async function revokeClientApiKey(keyId: string): Promise { + return invoke('revoke_client_api_key', { keyId }); +} + /** * Result of bulk server connection. */ @@ -394,7 +445,7 @@ export async function refreshOAuthTokensOnStartup(): Promise { /** * Open a URL using the system's default handler. - * + * * This is needed for custom protocol URLs (like `cursor://`) that * the webview's opener plugin may not be allowed to open directly. */ From bc47fdd1ab13d1233a258b21be43775ff0997e89 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 29 Jun 2026 10:41:02 +0800 Subject: [PATCH 4/6] feat(ui): Register API-key client modal (Clients tab) Adds a modal to mint a pre-approved API-key client from the Clients tab: enter a name, generate a key shown once (mcpk_ + 256 bits), copy it, and an explainer of how the client authenticates with `Authorization: Bearer `. The "Lock to a Space" selector is deferred to P2 (Strategy Y) along with the rest of the locked_space wiring; P1 registers an unconfined API-key client. Signed-off-by: Mohammod Al Amin Ashik --- .../src/features/clients/ClientsPage.tsx | 36 ++- .../clients/RegisterApiKeyClientModal.tsx | 209 ++++++++++++++++++ 2 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index 84e15a32..c2c625c0 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -23,6 +23,7 @@ import { Check, Globe, ShieldOff, + KeyRound, } from 'lucide-react'; import { ConnectIDEs } from '@/components/ConnectIDEs'; import type { GatewayStatus, OAuthClient } from '@/lib/api/gateway'; @@ -55,6 +56,7 @@ import { usePendingClientId, useSetPendingClientId, } from '@/stores'; +import { RegisterApiKeyClientModal } from './RegisterApiKeyClientModal'; // Bundled icons for well-known AI clients. const CLIENT_ICON_ASSETS: Record = { @@ -124,6 +126,7 @@ export default function ClientsPage() { const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [selected, setSelected] = useState(null); + const [showRegister, setShowRegister] = useState(false); const [editAlias, setEditAlias] = useState(''); const [isSaving, setIsSaving] = useState(false); const [gatewayStatus, setGatewayStatus] = useState({ @@ -283,10 +286,21 @@ export default function ClientsPage() { } actions={ - +
+ + +
} /> @@ -409,6 +423,16 @@ export default function ClientsPage() { )} + {showRegister && ( + setShowRegister(false)} + onRegistered={(client) => { + success(`Registered "${client.clientName}" with an API key.`); + void refreshClients(); + }} + /> + )} + {ConfirmDialogElement} @@ -582,7 +606,9 @@ function SidePanel({ When this client reports a folder as an MCP root, mcpmux uses the matching Workspace binding to pick the Space and FeatureSet. If it doesn't report the folder reliably (e.g. Cursor), open the folder in Workspaces and{' '} - Connect apps to this folder{' '} + + Connect apps to this folder + {' '} to auto-write its config with a workspace header.

+
+ +
+ + {result ? 'API key created' : 'Register client (API key)'} + + + {result + ? 'Copy the key now — this is the only time it will be shown.' + : 'A pre-authorised client that connects with an API key instead of browser approval. Use this for headless, CI, or remote clients reaching the gateway over the network.'} + + + + + {result ? ( + <> +
+ +
+ + {result.apiKey} + + +
+
+ +
+ +

+ Store this key in your client now. McpMux keeps only a hash and{' '} + cannot show it again. If you lose it, revoke the key and create a + new one. +

+
+ +
+

+ How the client authenticates +

+ + Authorization: Bearer {result.keyPrefix}… + +
+ +
+ +
+ + ) : ( + <> +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !isSubmitting) void handleGenerate(); + }} + placeholder="e.g. CI runner, my-laptop, prod-bot" + className="w-full rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-3.5 py-2.5 text-sm transition-all focus:border-[rgb(var(--accent))] focus:outline-none focus:ring-2 focus:ring-[rgb(var(--accent))]/40" + /> +
+ +
+ +

+ The key is generated on this machine, shown once, and stored only as a SHA-256 + hash. The client then sends it as a Bearer token — no approval prompt needed. +

+
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+ + )} +
+ + + ); +} From 557653e35c71d9f6164f5597fcafd9c78a6760d9 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 19:58:10 +0800 Subject: [PATCH 5/6] feat(ui): manage a client's API keys in the side panel Phase 1. For preregistered (API-key) clients, the Apps side panel now lists the client's keys (prefix + last-used, never the secret) with per-key revoke and a "New key" rotation button that reveals the freshly-minted key once. Wired to list_client_api_keys / create_client_api_key / revoke_client_api_key. Signed-off-by: Mohammod Al Amin Ashik --- .../features/clients/ClientApiKeysSection.tsx | 180 ++++++++++++++++++ .../src/features/clients/ClientsPage.tsx | 9 + 2 files changed, 189 insertions(+) create mode 100644 apps/desktop/src/features/clients/ClientApiKeysSection.tsx diff --git a/apps/desktop/src/features/clients/ClientApiKeysSection.tsx b/apps/desktop/src/features/clients/ClientApiKeysSection.tsx new file mode 100644 index 00000000..dbea0c15 --- /dev/null +++ b/apps/desktop/src/features/clients/ClientApiKeysSection.tsx @@ -0,0 +1,180 @@ +/** + * API keys for a preregistered (API-key) client — rendered in the client side + * panel. Lists the client's keys (prefix + metadata, never the secret), and + * lets the user revoke a key or mint a new one (rotation). A freshly-minted key + * is shown ONCE inline. + */ + +import { useEffect, useState } from 'react'; +import { AlertTriangle, Check, Copy, Loader2, Plus, Trash2 } from 'lucide-react'; +import { Button } from '@mcpmux/ui'; +import { + createClientApiKey, + listClientApiKeys, + revokeClientApiKey, + type ApiKeyInfo, + type RegisteredApiKeyClient, +} from '@/lib/api/gateway'; + +interface ClientApiKeysSectionProps { + clientId: string; + onError: (title: string, body?: string) => void; + onSuccess: (title: string, body?: string) => void; +} + +export function ClientApiKeysSection({ clientId, onError, onSuccess }: ClientApiKeysSectionProps) { + const [keys, setKeys] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isCreating, setIsCreating] = useState(false); + const [revokingId, setRevokingId] = useState(null); + const [newKey, setNewKey] = useState(null); + const [copied, setCopied] = useState(false); + + const load = async () => { + setIsLoading(true); + try { + setKeys(await listClientApiKeys(clientId)); + } catch (e) { + onError('Failed to load API keys', e instanceof Error ? e.message : String(e)); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clientId]); + + const handleCreate = async () => { + setIsCreating(true); + try { + const issued = await createClientApiKey(clientId); + setNewKey(issued); + setCopied(false); + await load(); + } catch (e) { + onError('Failed to create key', e instanceof Error ? e.message : String(e)); + } finally { + setIsCreating(false); + } + }; + + const handleCopy = async () => { + if (!newKey) return; + try { + await navigator.clipboard.writeText(newKey.apiKey); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard can be unavailable; the field is selectable as a fallback. + } + }; + + const handleRevoke = async (keyId: string) => { + setRevokingId(keyId); + try { + await revokeClientApiKey(keyId); + onSuccess('Key revoked', 'It can no longer authenticate.'); + await load(); + } catch (e) { + onError('Failed to revoke key', e instanceof Error ? e.message : String(e)); + } finally { + setRevokingId(null); + } + }; + + const liveKeys = keys.filter((k) => !k.revoked); + + return ( +
+
+

+ API keys +

+ +
+ + {newKey && ( +
+
+ +

+ Copy this key now — it won't be shown again. +

+
+
+ + {newKey.apiKey} + + +
+
+ )} + + {isLoading ? ( +
+ +
+ ) : liveKeys.length === 0 ? ( +

+ No active keys. Create one so this client can authenticate. +

+ ) : ( +
    + {liveKeys.map((k) => ( +
  • +
    + {k.keyPrefix}… +

    + {k.lastUsedAt + ? `Last used ${new Date(k.lastUsedAt).toLocaleDateString()}` + : 'Never used'} +

    +
    + +
  • + ))} +
+ )} + +

+ This client authenticates with an API key as a Bearer token. Keys are stored hashed — revoke + a leaked one and mint a new key. +

+
+ ); +} diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index c2c625c0..2d5138fb 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -57,6 +57,7 @@ import { useSetPendingClientId, } from '@/stores'; import { RegisterApiKeyClientModal } from './RegisterApiKeyClientModal'; +import { ClientApiKeysSection } from './ClientApiKeysSection'; // Bundled icons for well-known AI clients. const CLIENT_ICON_ASSETS: Record = { @@ -595,6 +596,14 @@ function SidePanel({

+ {client.registration_type === 'preregistered' && ( + + )} +
From d2844bbcf541a61012195cf9c0fadf3db3c1343c Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sun, 28 Jun 2026 21:10:02 +0800 Subject: [PATCH 6/6] test(gateway): api-key auth path through the real middleware Boots /mcp behind the real mcp_oauth_middleware (auth required), pre-registers a Preregistered client + key over the same DB, and proves: a live Bearer key authenticates and injects the owning client id; an unknown key 401s; a revoked key 401s. Signed-off-by: Mohammod Al Amin Ashik --- .../tests/streamable_http/api_key_auth.rs | 249 ++++++++++++++++++ tests/rust/tests/streamable_http/mod.rs | 1 + 2 files changed, 250 insertions(+) create mode 100644 tests/rust/tests/streamable_http/api_key_auth.rs diff --git a/tests/rust/tests/streamable_http/api_key_auth.rs b/tests/rust/tests/streamable_http/api_key_auth.rs new file mode 100644 index 00000000..745648c2 --- /dev/null +++ b/tests/rust/tests/streamable_http/api_key_auth.rs @@ -0,0 +1,249 @@ +//! End-to-end proof that the gateway authenticates a request bearing a +//! host-issued **API key** through the REAL `mcp_oauth_middleware`: +//! - a live key in `Authorization: Bearer mcpk_…` is accepted (200) and the +//! middleware injects the owning client's id, +//! - an unknown key is rejected (401), +//! - a revoked key is rejected (401). +//! +//! This is the headless/remote auth path that needs no interactive consent — +//! the secure way to connect when the gateway is exposed over the network. + +use axum::{ + body::Body, + http::{Request, StatusCode}, + middleware, + response::{IntoResponse, Response}, + routing::post, + Router, +}; +use mcpmux_core::{DomainEvent, ServerDiscoveryService, ServerLogManager}; +use mcpmux_gateway::{ + mcp::mcp_oauth_middleware, + server::{DependenciesBuilder, GatewayDependencies, GatewayState, ServiceContainer}, +}; +use mcpmux_storage::{ + InboundClient, InboundClientRepository, RegistrationType, SqliteSpaceRepository, +}; +use std::sync::Arc; +use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use tests::db::TestDatabase; +use tests::mocks::*; + +/// Minimal `/mcp` handler that echoes the gateway-injected client id so the +/// test can confirm the middleware authenticated and assigned the right identity. +async fn echo_client_id(req: Request) -> Response { + let cid = req + .headers() + .get("x-mcpmux-client-id") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + (StatusCode::OK, cid).into_response() +} + +struct Harness { + url: String, + client_repo: Arc, + client_id: String, + api_key: String, + key_id: String, + ct: CancellationToken, +} + +impl Harness { + /// Boot a gateway exposing `/mcp` behind the REAL oauth middleware with auth + /// REQUIRED, and pre-register a Preregistered client + one API key over the + /// same database the middleware validates against. + async fn start() -> Self { + let ct = CancellationToken::new(); + let space_id = Uuid::new_v4(); + + let test_db = TestDatabase::in_memory(); + let database = Arc::new(tokio::sync::Mutex::new(test_db.db)); + + let space_repo = Arc::new(SqliteSpaceRepository::new(database.clone())); + let space = mcpmux_core::domain::Space { + id: space_id, + name: "Test Space".to_string(), + icon: None, + description: None, + is_default: true, + sort_order: 0, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }; + mcpmux_core::SpaceRepository::create(&*space_repo, &space) + .await + .expect("create space"); + mcpmux_core::SpaceRepository::set_default(&*space_repo, &space_id) + .await + .expect("set default"); + + // Register a Preregistered, approved client + an API key over the SAME + // database Arc the gateway's middleware reads from, so a write here is + // visible to the middleware's own repo instance. + let client_repo = Arc::new(InboundClientRepository::new(database.clone())); + let client_id = format!("mcp_{}", &Uuid::new_v4().simple().to_string()[..8]); + let now = chrono::Utc::now().to_rfc3339(); + let client = InboundClient { + client_id: client_id.clone(), + registration_type: RegistrationType::Preregistered, + client_name: "headless-bot".to_string(), + client_alias: None, + redirect_uris: vec![], + grant_types: vec![], + response_types: vec![], + token_endpoint_auth_method: "none".to_string(), + scope: None, + approved: true, + logo_uri: None, + client_uri: None, + software_id: None, + software_version: None, + metadata_url: None, + metadata_cached_at: None, + metadata_cache_ttl: None, + last_seen: None, + created_at: now.clone(), + updated_at: now, + reports_roots: false, + roots_capability_known: false, + }; + client_repo.save_client(&client).await.expect("save client"); + let key_id = Uuid::new_v4().to_string(); + let api_key = format!("mcpk_{}", Uuid::new_v4().simple()); + let prefix: String = api_key.chars().take(13).collect(); + client_repo + .create_api_key(&key_id, &client_id, &api_key, &prefix, None, None) + .await + .expect("create api key"); + + let deps = DependenciesBuilder::new() + .with_installed_server_repo(Arc::new(MockInstalledServerRepository::new())) + .with_credential_repo(Arc::new(MockCredentialRepository::new())) + .with_backend_oauth_repo(Arc::new(MockOutboundOAuthRepository::new())) + .with_feature_repo(Arc::new(MockServerFeatureRepository::new()) + as Arc) + .with_feature_set_repo(Arc::new(MockFeatureSetRepository::new()) + as Arc) + .with_server_discovery(Arc::new(ServerDiscoveryService::new( + std::path::PathBuf::from("test-data"), + std::path::PathBuf::from("test-spaces"), + ))) + .with_log_manager(Arc::new(ServerLogManager::new( + mcpmux_core::LogConfig::default(), + ))) + .with_database(database) + .build() + .expect("build dependencies"); + let deps = GatewayDependencies { + space_repo: space_repo as Arc, + ..deps + }; + + let (event_tx, _) = broadcast::channel::(64); + let mut gw_state = GatewayState::new(event_tx.clone()); + gw_state.set_base_url("http://127.0.0.1:0".to_string()); + gw_state.set_auth_disabled(false); // auth REQUIRED — the key must carry it + let gateway_state = Arc::new(tokio::sync::RwLock::new(gw_state)); + + let services = Arc::new(ServiceContainer::initialize( + &deps, + event_tx.clone(), + gateway_state, + )); + + let router = Router::new().route("/mcp", post(echo_client_id)).layer( + middleware::from_fn_with_state(services.clone(), mcp_oauth_middleware), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().unwrap().port(); + let ct_clone = ct.clone(); + tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async move { ct_clone.cancelled().await }) + .await + .unwrap(); + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + Self { + url: format!("http://127.0.0.1:{port}/mcp"), + client_repo, + client_id, + api_key, + key_id, + ct, + } + } + + async fn post_with_bearer(&self, token: &str) -> reqwest::Response { + reqwest::Client::new() + .post(&self.url) + .header("content-type", "application/json") + .header(reqwest::header::AUTHORIZATION, format!("Bearer {token}")) + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#) + .send() + .await + .expect("request") + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.ct.cancel(); + } +} + +#[tokio::test] +async fn api_key_authenticates_and_injects_client_id() { + let h = Harness::start().await; + let resp = h.post_with_bearer(&h.api_key).await; + assert_eq!( + resp.status(), + reqwest::StatusCode::OK, + "a live API key must authenticate against an auth-required gateway" + ); + let body = resp.text().await.unwrap(); + assert_eq!( + body, h.client_id, + "the middleware injects the key's owning client id" + ); +} + +#[tokio::test] +async fn unknown_api_key_is_rejected() { + let h = Harness::start().await; + let resp = h.post_with_bearer("mcpk_not_a_real_key").await; + assert_eq!( + resp.status(), + reqwest::StatusCode::UNAUTHORIZED, + "an unknown key must be rejected" + ); +} + +#[tokio::test] +async fn revoked_api_key_is_rejected() { + let h = Harness::start().await; + // Sanity: it authenticates before revocation. + assert_eq!( + h.post_with_bearer(&h.api_key).await.status(), + reqwest::StatusCode::OK + ); + h.client_repo + .revoke_api_key(&h.key_id) + .await + .expect("revoke"); + let resp = h.post_with_bearer(&h.api_key).await; + assert_eq!( + resp.status(), + reqwest::StatusCode::UNAUTHORIZED, + "a revoked key must be rejected" + ); +} diff --git a/tests/rust/tests/streamable_http/mod.rs b/tests/rust/tests/streamable_http/mod.rs index ea942c25..cdba3fc8 100644 --- a/tests/rust/tests/streamable_http/mod.rs +++ b/tests/rust/tests/streamable_http/mod.rs @@ -5,6 +5,7 @@ //! - Server-initiated notifications (list_changed via SSE) //! - Proper protocol negotiation +mod api_key_auth; mod auth_disable; mod auth_oauth_e2e; mod gateway_notifications;