Skip to content

Commit 830c2ec

Browse files
committed
feat(auth): Phase 1 — API-key inbound auth
Autonomous decisions: - Omit upstream auto-map/Starter bootstrap on register — fork deny-by-default requires explicit binding/grant before any tools - Defer locked_space_id param and UI until Phase 3 — Phase 1 scope is auth-only with no routing changes Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent f786200 commit 830c2ec

13 files changed

Lines changed: 1155 additions & 10 deletions

File tree

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

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,230 @@ pub async fn delete_oauth_client(
665665
Ok(())
666666
}
667667

668+
// =============================================================================
669+
// API-key clients (manually registered, host-issued credentials)
670+
//
671+
// A "preregistered", pre-approved inbound client authenticated by a long-lived
672+
// API key. Unlike DCR clients it skips the browser-consent deep link, so
673+
// headless/remote clients can connect with just the key.
674+
// =============================================================================
675+
676+
/// A newly-registered API-key client. `api_key` is returned ONCE at creation —
677+
/// McpMux stores only its SHA-256 hash and can never show it again.
678+
#[derive(Debug, Serialize)]
679+
#[serde(rename_all = "camelCase")]
680+
pub struct RegisteredApiKeyClient {
681+
pub client_id: String,
682+
pub client_name: String,
683+
pub locked_space_id: Option<String>,
684+
pub api_key: String,
685+
pub key_prefix: String,
686+
}
687+
688+
/// API-key metadata for display (never includes the secret).
689+
#[derive(Debug, Serialize)]
690+
#[serde(rename_all = "camelCase")]
691+
pub struct ApiKeyInfo {
692+
pub key_id: String,
693+
pub key_prefix: String,
694+
pub label: Option<String>,
695+
pub revoked: bool,
696+
pub last_used_at: Option<String>,
697+
pub created_at: String,
698+
}
699+
700+
/// Generate a strong API key: `mcpk_` + 256 bits of v4-UUID randomness.
701+
/// Returns `(key_id, plaintext, key_prefix)`. Only the hash is ever stored.
702+
fn generate_api_key() -> (String, String, String) {
703+
let key_id = uuid::Uuid::new_v4().to_string();
704+
let secret = format!(
705+
"{}{}",
706+
uuid::Uuid::new_v4().simple(),
707+
uuid::Uuid::new_v4().simple()
708+
);
709+
let plaintext = format!("mcpk_{secret}");
710+
let key_prefix: String = plaintext.chars().take(13).collect();
711+
(key_id, plaintext, key_prefix)
712+
}
713+
714+
/// Register a new pre-approved client authenticated by an API key.
715+
/// The returned `api_key` is shown once and never stored.
716+
#[tauri::command]
717+
pub async fn register_api_key_client(
718+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
719+
name: String,
720+
) -> Result<RegisteredApiKeyClient, String> {
721+
let app_state = gateway_state.read().await;
722+
let Some(ref gw_state) = app_state.gateway_state else {
723+
return Err("Gateway not running".to_string());
724+
};
725+
let state = gw_state.read().await;
726+
let Some(repo) = state.inbound_client_repository() else {
727+
return Err("Database not available".to_string());
728+
};
729+
730+
let trimmed = name.trim();
731+
if trimmed.is_empty() {
732+
return Err("Client name is required".to_string());
733+
}
734+
735+
let now = chrono::Utc::now().to_rfc3339();
736+
let client_id = format!("mcp_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
737+
let client = mcpmux_storage::InboundClient {
738+
client_id: client_id.clone(),
739+
registration_type: mcpmux_storage::RegistrationType::Preregistered,
740+
client_name: trimmed.to_string(),
741+
client_alias: None,
742+
redirect_uris: vec![],
743+
grant_types: vec![],
744+
response_types: vec![],
745+
token_endpoint_auth_method: "none".to_string(),
746+
scope: None,
747+
approved: true,
748+
logo_uri: None,
749+
client_uri: None,
750+
software_id: None,
751+
software_version: None,
752+
metadata_url: None,
753+
metadata_cached_at: None,
754+
metadata_cache_ttl: None,
755+
last_seen: None,
756+
created_at: now.clone(),
757+
updated_at: now,
758+
reports_roots: false,
759+
roots_capability_known: false,
760+
machine_id: None,
761+
};
762+
repo.save_client(&client)
763+
.await
764+
.map_err(|e| format!("Failed to create client: {}", e))?;
765+
766+
let (key_id, plaintext, key_prefix) = generate_api_key();
767+
repo.create_api_key(&key_id, &client_id, &plaintext, &key_prefix, None, None)
768+
.await
769+
.map_err(|e| format!("Failed to create API key: {}", e))?;
770+
771+
info!(
772+
"[OAuth] Registered API-key client {} ({})",
773+
trimmed, client_id
774+
);
775+
776+
state.emit_domain_event(mcpmux_core::DomainEvent::ClientRegistered {
777+
client_id: client_id.clone(),
778+
client_name: trimmed.to_string(),
779+
registration_type: Some("preregistered".to_string()),
780+
});
781+
782+
Ok(RegisteredApiKeyClient {
783+
client_id,
784+
client_name: trimmed.to_string(),
785+
locked_space_id: None,
786+
api_key: plaintext,
787+
key_prefix,
788+
})
789+
}
790+
791+
/// Issue an additional API key for an existing client (rotation). Returns the
792+
/// new key plaintext once.
793+
#[tauri::command]
794+
pub async fn create_client_api_key(
795+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
796+
client_id: String,
797+
label: Option<String>,
798+
) -> Result<RegisteredApiKeyClient, String> {
799+
let app_state = gateway_state.read().await;
800+
let Some(ref gw_state) = app_state.gateway_state else {
801+
return Err("Gateway not running".to_string());
802+
};
803+
let state = gw_state.read().await;
804+
let Some(repo) = state.inbound_client_repository() else {
805+
return Err("Database not available".to_string());
806+
};
807+
808+
let Some(client) = repo
809+
.get_client(&client_id)
810+
.await
811+
.map_err(|e| format!("Failed to load client: {}", e))?
812+
else {
813+
return Err("Client not found".to_string());
814+
};
815+
816+
let (key_id, plaintext, key_prefix) = generate_api_key();
817+
repo.create_api_key(
818+
&key_id,
819+
&client_id,
820+
&plaintext,
821+
&key_prefix,
822+
label.as_deref(),
823+
None,
824+
)
825+
.await
826+
.map_err(|e| format!("Failed to create API key: {}", e))?;
827+
828+
Ok(RegisteredApiKeyClient {
829+
client_id,
830+
client_name: client.client_name,
831+
locked_space_id: None,
832+
api_key: plaintext,
833+
key_prefix,
834+
})
835+
}
836+
837+
/// List a client's API keys (metadata only — never the secret).
838+
#[tauri::command]
839+
pub async fn list_client_api_keys(
840+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
841+
client_id: String,
842+
) -> Result<Vec<ApiKeyInfo>, String> {
843+
let app_state = gateway_state.read().await;
844+
let Some(ref gw_state) = app_state.gateway_state else {
845+
return Err("Gateway not running".to_string());
846+
};
847+
let state = gw_state.read().await;
848+
let Some(repo) = state.inbound_client_repository() else {
849+
return Err("Database not available".to_string());
850+
};
851+
852+
let keys = repo
853+
.list_api_keys(&client_id)
854+
.await
855+
.map_err(|e| format!("Failed to list API keys: {}", e))?;
856+
857+
Ok(keys
858+
.into_iter()
859+
.map(|k| ApiKeyInfo {
860+
key_id: k.key_id,
861+
key_prefix: k.key_prefix,
862+
label: k.label,
863+
revoked: k.revoked,
864+
last_used_at: k.last_used_at,
865+
created_at: k.created_at,
866+
})
867+
.collect())
868+
}
869+
870+
/// Revoke a single API key (it can never authenticate again).
871+
#[tauri::command]
872+
pub async fn revoke_client_api_key(
873+
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
874+
key_id: String,
875+
) -> Result<(), String> {
876+
let app_state = gateway_state.read().await;
877+
let Some(ref gw_state) = app_state.gateway_state else {
878+
return Err("Gateway not running".to_string());
879+
};
880+
let state = gw_state.read().await;
881+
let Some(repo) = state.inbound_client_repository() else {
882+
return Err("Database not available".to_string());
883+
};
884+
885+
repo.revoke_api_key(&key_id)
886+
.await
887+
.map_err(|e| format!("Failed to revoke API key: {}", e))?;
888+
info!("[OAuth] Revoked API key {}", key_id);
889+
Ok(())
890+
}
891+
668892
/// Open a URL without flashing a terminal window (Windows-specific)
669893
#[cfg(target_os = "windows")]
670894
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
@@ -1101,6 +1101,10 @@ pub fn run() {
11011101
commands::approve_oauth_client,
11021102
commands::update_oauth_client,
11031103
commands::delete_oauth_client,
1104+
commands::register_api_key_client,
1105+
commands::create_client_api_key,
1106+
commands::list_client_api_keys,
1107+
commands::revoke_client_api_key,
11041108
commands::open_url,
11051109
// Per-client grants for the rootless fallback path
11061110
commands::get_oauth_client_grants,

0 commit comments

Comments
 (0)