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