Skip to content

Commit 4b5a9bc

Browse files
authored
feat: API-key inbound auth for headless/remote MCP clients (P1/3) (#201)
Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 6bd8220 commit 4b5a9bc

13 files changed

Lines changed: 1176 additions & 35 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
@@ -991,6 +991,10 @@ pub fn run() {
991991
commands::approve_oauth_client,
992992
commands::update_oauth_client,
993993
commands::delete_oauth_client,
994+
commands::register_api_key_client,
995+
commands::create_client_api_key,
996+
commands::list_client_api_keys,
997+
commands::revoke_client_api_key,
994998
commands::open_url,
995999
// Per-client grants for the rootless fallback path
9961000
commands::get_oauth_client_grants,

0 commit comments

Comments
 (0)