Skip to content

Commit 7496e8f

Browse files
committed
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 <key>`. 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 <maa.ashik00@gmail.com>
1 parent b0afeee commit 7496e8f

3 files changed

Lines changed: 292 additions & 22 deletions

File tree

apps/desktop/src-tauri/src/commands/oauth.rs

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,221 @@ pub async fn delete_oauth_client(
927927
Ok(())
928928
}
929929

930+
// =============================================================================
931+
// API-key clients (manually registered, host-issued credentials)
932+
//
933+
// A "preregistered", pre-approved inbound client authenticated by a long-lived
934+
// API key. Unlike DCR clients it skips the browser-consent deep link, so
935+
// headless/remote clients can connect with just the key — the secure path when
936+
// the gateway is exposed over the network.
937+
// =============================================================================
938+
939+
/// A newly-registered API-key client. `api_key` is returned ONCE at creation —
940+
/// McpMux stores only its SHA-256 hash and can never show it again.
941+
#[derive(Debug, Serialize)]
942+
#[serde(rename_all = "camelCase")]
943+
pub struct RegisteredApiKeyClient {
944+
pub client_id: String,
945+
pub client_name: String,
946+
pub api_key: String,
947+
pub key_prefix: String,
948+
}
949+
950+
/// API-key metadata for display (never includes the secret).
951+
#[derive(Debug, Serialize)]
952+
#[serde(rename_all = "camelCase")]
953+
pub struct ApiKeyInfo {
954+
pub key_id: String,
955+
pub key_prefix: String,
956+
pub label: Option<String>,
957+
pub revoked: bool,
958+
pub last_used_at: Option<String>,
959+
pub created_at: String,
960+
}
961+
962+
/// Generate a strong API key: `mcpk_` + 256 bits of v4-UUID randomness.
963+
/// Returns `(key_id, plaintext, key_prefix)`. Only the hash is ever stored.
964+
fn generate_api_key() -> (String, String, String) {
965+
let key_id = uuid::Uuid::new_v4().to_string();
966+
let secret = format!(
967+
"{}{}",
968+
uuid::Uuid::new_v4().simple(),
969+
uuid::Uuid::new_v4().simple()
970+
);
971+
let plaintext = format!("mcpk_{secret}");
972+
let key_prefix: String = plaintext.chars().take(13).collect(); // "mcpk_" + 8 chars
973+
(key_id, plaintext, key_prefix)
974+
}
975+
976+
/// Register a new pre-approved client authenticated by an API key. The returned
977+
/// `api_key` is shown once and never stored.
978+
#[tauri::command]
979+
pub async fn register_api_key_client(
980+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
981+
name: String,
982+
) -> Result<RegisteredApiKeyClient, String> {
983+
let app_state = gateway_state.read().await;
984+
let Some(ref gw_state) = app_state.gateway_state else {
985+
return Err("Gateway not running".to_string());
986+
};
987+
let state = gw_state.read().await;
988+
let Some(repo) = state.inbound_client_repository() else {
989+
return Err("Database not available".to_string());
990+
};
991+
992+
let trimmed = name.trim();
993+
if trimmed.is_empty() {
994+
return Err("Client name is required".to_string());
995+
}
996+
997+
let now = chrono::Utc::now().to_rfc3339();
998+
let client_id = format!("mcp_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
999+
let client = mcpmux_storage::InboundClient {
1000+
client_id: client_id.clone(),
1001+
registration_type: mcpmux_storage::RegistrationType::Preregistered,
1002+
client_name: trimmed.to_string(),
1003+
client_alias: None,
1004+
redirect_uris: vec![],
1005+
grant_types: vec![],
1006+
response_types: vec![],
1007+
token_endpoint_auth_method: "none".to_string(),
1008+
scope: None,
1009+
approved: true,
1010+
logo_uri: None,
1011+
client_uri: None,
1012+
software_id: None,
1013+
software_version: None,
1014+
metadata_url: None,
1015+
metadata_cached_at: None,
1016+
metadata_cache_ttl: None,
1017+
last_seen: None,
1018+
created_at: now.clone(),
1019+
updated_at: now,
1020+
reports_roots: false,
1021+
roots_capability_known: false,
1022+
};
1023+
repo.save_client(&client)
1024+
.await
1025+
.map_err(|e| format!("Failed to create client: {}", e))?;
1026+
1027+
let (key_id, plaintext, key_prefix) = generate_api_key();
1028+
repo.create_api_key(&key_id, &client_id, &plaintext, &key_prefix, None, None)
1029+
.await
1030+
.map_err(|e| format!("Failed to create API key: {}", e))?;
1031+
1032+
info!(
1033+
"[OAuth] Registered API-key client {} ({})",
1034+
trimmed, client_id
1035+
);
1036+
1037+
Ok(RegisteredApiKeyClient {
1038+
client_id,
1039+
client_name: trimmed.to_string(),
1040+
api_key: plaintext,
1041+
key_prefix,
1042+
})
1043+
}
1044+
1045+
/// Issue an additional API key for an existing client (rotation). Returns the
1046+
/// new key plaintext once.
1047+
#[tauri::command]
1048+
pub async fn create_client_api_key(
1049+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
1050+
client_id: String,
1051+
label: Option<String>,
1052+
) -> Result<RegisteredApiKeyClient, String> {
1053+
let app_state = gateway_state.read().await;
1054+
let Some(ref gw_state) = app_state.gateway_state else {
1055+
return Err("Gateway not running".to_string());
1056+
};
1057+
let state = gw_state.read().await;
1058+
let Some(repo) = state.inbound_client_repository() else {
1059+
return Err("Database not available".to_string());
1060+
};
1061+
1062+
let Some(client) = repo
1063+
.get_client(&client_id)
1064+
.await
1065+
.map_err(|e| format!("Failed to load client: {}", e))?
1066+
else {
1067+
return Err("Client not found".to_string());
1068+
};
1069+
1070+
let (key_id, plaintext, key_prefix) = generate_api_key();
1071+
repo.create_api_key(
1072+
&key_id,
1073+
&client_id,
1074+
&plaintext,
1075+
&key_prefix,
1076+
label.as_deref(),
1077+
None,
1078+
)
1079+
.await
1080+
.map_err(|e| format!("Failed to create API key: {}", e))?;
1081+
1082+
Ok(RegisteredApiKeyClient {
1083+
client_id,
1084+
client_name: client.client_name,
1085+
api_key: plaintext,
1086+
key_prefix,
1087+
})
1088+
}
1089+
1090+
/// List a client's API keys (metadata only — never the secret).
1091+
#[tauri::command]
1092+
pub async fn list_client_api_keys(
1093+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
1094+
client_id: String,
1095+
) -> Result<Vec<ApiKeyInfo>, String> {
1096+
let app_state = gateway_state.read().await;
1097+
let Some(ref gw_state) = app_state.gateway_state else {
1098+
return Err("Gateway not running".to_string());
1099+
};
1100+
let state = gw_state.read().await;
1101+
let Some(repo) = state.inbound_client_repository() else {
1102+
return Err("Database not available".to_string());
1103+
};
1104+
1105+
let keys = repo
1106+
.list_api_keys(&client_id)
1107+
.await
1108+
.map_err(|e| format!("Failed to list API keys: {}", e))?;
1109+
1110+
Ok(keys
1111+
.into_iter()
1112+
.map(|k| ApiKeyInfo {
1113+
key_id: k.key_id,
1114+
key_prefix: k.key_prefix,
1115+
label: k.label,
1116+
revoked: k.revoked,
1117+
last_used_at: k.last_used_at,
1118+
created_at: k.created_at,
1119+
})
1120+
.collect())
1121+
}
1122+
1123+
/// Revoke a single API key (it can never authenticate again).
1124+
#[tauri::command]
1125+
pub async fn revoke_client_api_key(
1126+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
1127+
key_id: String,
1128+
) -> Result<(), String> {
1129+
let app_state = gateway_state.read().await;
1130+
let Some(ref gw_state) = app_state.gateway_state else {
1131+
return Err("Gateway not running".to_string());
1132+
};
1133+
let state = gw_state.read().await;
1134+
let Some(repo) = state.inbound_client_repository() else {
1135+
return Err("Database not available".to_string());
1136+
};
1137+
1138+
repo.revoke_api_key(&key_id)
1139+
.await
1140+
.map_err(|e| format!("Failed to revoke API key: {}", e))?;
1141+
info!("[OAuth] Revoked API key {}", key_id);
1142+
Ok(())
1143+
}
1144+
9301145
/// Open a URL without flashing a terminal window (Windows-specific)
9311146
#[cfg(target_os = "windows")]
9321147
fn open_url_no_flash(url: &str) -> Result<(), String> {

apps/desktop/src-tauri/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,10 @@ pub fn run() {
984984
commands::approve_oauth_client,
985985
commands::update_oauth_client,
986986
commands::delete_oauth_client,
987+
commands::register_api_key_client,
988+
commands::create_client_api_key,
989+
commands::list_client_api_keys,
990+
commands::revoke_client_api_key,
987991
commands::open_url,
988992
// Per-client grants for the rootless fallback path
989993
commands::get_oauth_client_grants,

apps/desktop/src/lib/api/gateway.ts

Lines changed: 73 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,7 @@ export async function restartGateway(opts?: {
151151
/**
152152
* Export config for a client.
153153
*/
154-
export async function exportConfig(
155-
format: ExportFormat,
156-
clientId?: string
157-
): Promise<string> {
154+
export async function exportConfig(format: ExportFormat, clientId?: string): Promise<string> {
158155
return invoke('export_config', { format, clientId });
159156
}
160157

@@ -181,7 +178,11 @@ export async function connectServer(serverId: string): Promise<void> {
181178
* @param spaceId - The space ID (required for proper space isolation)
182179
* @param logout - If true, also delete stored credentials (OAuth tokens)
183180
*/
184-
export async function disconnectServer(serverId: string, spaceId: string, logout?: boolean): Promise<void> {
181+
export async function disconnectServer(
182+
serverId: string,
183+
spaceId: string,
184+
logout?: boolean
185+
): Promise<void> {
185186
return invoke('disconnect_server', { serverId, spaceId, logout });
186187
}
187188

@@ -199,13 +200,13 @@ export type RegistrationType = 'cimd' | 'dcr' | 'preregistered';
199200

200201
/**
201202
* Inbound client (unified OAuth + MCP model)
202-
*
203+
*
203204
* Represents apps connecting TO McpMux (e.g., Cursor, VS Code, Claude Desktop).
204205
* Supports three MCP registration approaches:
205206
* - CIMD: Client ID Metadata Documents (client_id is a URL)
206207
* - DCR: Dynamic Client Registration (server generates client_id)
207208
* - Preregistered: Server pre-configures client_id
208-
*
209+
*
209210
* Per RFC 7591, clients self-identify via metadata they provide.
210211
* Use `logo_uri`, `software_id`, and `client_name` for client identification.
211212
*/
@@ -216,20 +217,20 @@ export interface OAuthClient {
216217
client_alias: string | null;
217218
redirect_uris: string[];
218219
scope: string | null;
219-
220+
220221
// Approval status - true if user has explicitly approved this client
221222
approved: boolean;
222-
223+
223224
// RFC 7591 Client Metadata (use these for client identification)
224-
logo_uri?: string | null; // URL for client's logo
225-
client_uri?: string | null; // URL of client's homepage
226-
software_id?: string | null; // Unique identifier (e.g., "com.cursor.app")
227-
software_version?: string | null; // Client software version
228-
225+
logo_uri?: string | null; // URL for client's logo
226+
client_uri?: string | null; // URL of client's homepage
227+
software_id?: string | null; // Unique identifier (e.g., "com.cursor.app")
228+
software_version?: string | null; // Client software version
229+
229230
// CIMD-specific fields (only used when registration_type='cimd')
230-
metadata_url?: string | null; // URL where metadata was fetched
231-
metadata_cached_at?: string | null; // When we last fetched
232-
metadata_cache_ttl?: number | null; // Cache duration in seconds
231+
metadata_url?: string | null; // URL where metadata was fetched
232+
metadata_cached_at?: string | null; // When we last fetched
233+
metadata_cache_ttl?: number | null; // Cache duration in seconds
233234

234235
last_seen: string | null;
235236
created_at: string;
@@ -302,10 +303,7 @@ export async function deleteOAuthClient(clientId: string): Promise<void> {
302303
* means the rootless fallback would deny — consumer should render the
303304
* "no defaults configured" empty state.
304305
*/
305-
export async function getOAuthClientGrants(
306-
clientId: string,
307-
spaceId: string
308-
): Promise<string[]> {
306+
export async function getOAuthClientGrants(clientId: string, spaceId: string): Promise<string[]> {
309307
return invoke('get_oauth_client_grants', { clientId, spaceId });
310308
}
311309

@@ -340,6 +338,59 @@ export async function revokeOAuthClientFeatureSet(
340338
});
341339
}
342340

341+
// =============================================================================
342+
// API-key clients (manually registered, host-issued credentials)
343+
// =============================================================================
344+
//
345+
// A pre-approved inbound client authenticated by a long-lived API key. Skips
346+
// the browser-consent deep link, so headless/remote clients can connect with
347+
// just the key — the secure path when the gateway is exposed over the network.
348+
349+
/** A newly-registered API-key client. `apiKey` is shown ONCE — store it now. */
350+
export interface RegisteredApiKeyClient {
351+
clientId: string;
352+
clientName: string;
353+
/** The full key — shown once; afterwards only its hash is kept. */
354+
apiKey: string;
355+
keyPrefix: string;
356+
}
357+
358+
/** API-key metadata for display (never the secret). */
359+
export interface ApiKeyInfo {
360+
keyId: string;
361+
keyPrefix: string;
362+
label: string | null;
363+
revoked: boolean;
364+
lastUsedAt: string | null;
365+
createdAt: string;
366+
}
367+
368+
/**
369+
* Register a pre-approved client authenticated by an API key. The returned key
370+
* is shown once and never retrievable again.
371+
*/
372+
export async function registerApiKeyClient(name: string): Promise<RegisteredApiKeyClient> {
373+
return invoke('register_api_key_client', { name });
374+
}
375+
376+
/** Issue an additional API key for an existing client (rotation). Shown once. */
377+
export async function createClientApiKey(
378+
clientId: string,
379+
label?: string | null
380+
): Promise<RegisteredApiKeyClient> {
381+
return invoke('create_client_api_key', { clientId, label: label ?? null });
382+
}
383+
384+
/** List a client's API keys (metadata only — never the secret). */
385+
export async function listClientApiKeys(clientId: string): Promise<ApiKeyInfo[]> {
386+
return invoke('list_client_api_keys', { clientId });
387+
}
388+
389+
/** Revoke an API key (it can never authenticate again). */
390+
export async function revokeClientApiKey(keyId: string): Promise<void> {
391+
return invoke('revoke_client_api_key', { keyId });
392+
}
393+
343394
/**
344395
* Result of bulk server connection.
345396
*/
@@ -394,7 +445,7 @@ export async function refreshOAuthTokensOnStartup(): Promise<RefreshResult> {
394445

395446
/**
396447
* Open a URL using the system's default handler.
397-
*
448+
*
398449
* This is needed for custom protocol URLs (like `cursor://`) that
399450
* the webview's opener plugin may not be allowed to open directly.
400451
*/

0 commit comments

Comments
 (0)