@@ -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" ) ]
9321147fn open_url_no_flash ( url : & str ) -> Result < ( ) , String > {
0 commit comments