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 ec6eee11..90619781 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -991,6 +991,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/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 84e15a32..2d5138fb 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,8 @@ import { usePendingClientId, useSetPendingClientId, } from '@/stores'; +import { RegisterApiKeyClientModal } from './RegisterApiKeyClientModal'; +import { ClientApiKeysSection } from './ClientApiKeysSection'; // Bundled icons for well-known AI clients. const CLIENT_ICON_ASSETS: Record = { @@ -124,6 +127,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 +287,21 @@ export default function ClientsPage() { } actions={ - +
+ + +
} /> @@ -409,6 +424,16 @@ export default function ClientsPage() { )} + {showRegister && ( + setShowRegister(false)} + onRegistered={(client) => { + success(`Registered "${client.clientName}" with an API key.`); + void refreshClients(); + }} + /> + )} + {ConfirmDialogElement} @@ -571,6 +596,14 @@ function SidePanel({

+ {client.registration_type === 'preregistered' && ( + + )} +
@@ -582,7 +615,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} +

+ )} + +
+ + +
+ + )} +
+ +
+ ); +} 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. */ 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 ( 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; 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;