'POST', 'callback' => array( $this, 'generate_app_password' ), 'permission_callback' => $admin_only_perm, 'args' => array( 'server_id' => array( 'required' => false, 'default' => 0, 'sanitize_callback' => 'absint', ), // F073+ — optional caller-supplied app-password name so the // wizard can label wizard-generated tokens (e.g. "Quick Edit - // Claude Desktop") distinct from tab-generated ones. Sanitised // to text; empty falls back to build_app_name(server_id). 'name' => array( 'required' => false, 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), // F099 — optional client slug, folded into the credential name // so the Tokens tab carries provenance for as long as the // credential exists (B76). Never trusted for authorisation. 'client' => array( 'required' => false, 'default' => '', 'sanitize_callback' => 'sanitize_key', ), ), ) ); register_rest_route( self::REST_NAMESPACE, '/list-app-passwords', array( 'methods' => 'GET', 'callback' => array( $this, 'list_app_passwords' ), 'permission_callback' => $admin_only_perm, ) ); } /** * POST /acrossai-mcp-manager/v1/generate-app-password * * Body: { server_id?: int } * * @param \WP_REST_Request $request Incoming REST request with `server_id` and optional `name` params. * @return \WP_REST_Response|\WP_Error */ public function generate_app_password( \WP_REST_Request $request ) { $blocker = $this->application_password_blocker(); if ( $blocker instanceof \WP_Error ) { return $blocker; } $server_id = (int) $request->get_param( 'server_id' ); // SEC-013-002 / FR-016 — the credential is always minted for the caller. // A request-supplied user id is never honoured, here or anywhere. $user_id = get_current_user_id(); $current_user = wp_get_current_user(); // D33 / FR-015 — every connection-time credential-issuance surface gates // on server access BEFORE issuing. Fails open when the Access Control // package is absent (D19), so a site without it keeps working. if ( $server_id > 0 && ! AcrossAI_MCP_Access_Control::instance()->user_has_server_access( $user_id, $server_id ) ) { /** * Fires when credential issuance is refused by Access Control. * * @param int $user_id The refused user. * @param string $server_slug Server slug, or '' when unresolved. * @param string $tool Unused for this surface; '' by contract. * @param string $context Distinct per surface — see D33. */ do_action( 'acrossai_mcp_access_control_denied', $user_id, $this->server_slug( $server_id ), '', 'app_password_generate_prompt' ); $this->record_issuance( $server_id, '', '', 'denied' ); return new \WP_Error( 'forbidden', __( 'You do not have access to this MCP server.', 'acrossai-mcp-manager' ), array( 'status' => 403 ) ); } // A credential for a server no client may connect to is a credential // that fails on first use. Refuse before minting rather than after. if ( \AcrossAI_MCP_Manager\Includes\Database\MCPServer\NonConnectableServers::is_excluded_id( $server_id ) ) { return new \WP_Error( 'acrossai_mcp_server_not_connectable', \AcrossAI_MCP_Manager\Includes\Database\MCPServer\NonConnectableServers::refusal_message(), array( 'status' => 400 ) ); } $app_name = $this->build_app_name( $server_id, (string) $request->get_param( 'client' ), (string) $request->get_param( 'name' ) ); $result = \WP_Application_Passwords::create_new_application_password( $user_id, array( 'name' => $app_name ) ); if ( is_wp_error( $result ) ) { // FR-022 — hand-authored message. Core's own error text is not // surfaced: it can name internals the operator cannot act on. return new \WP_Error( 'create_failed', __( 'The Application Password could not be created. Check that Application Passwords are enabled for your account.', 'acrossai-mcp-manager' ), array( 'status' => 500 ) ); } [ $password, $app_details ] = $result; $uuid = (string) $app_details['uuid']; // FR-027 / SC-C19 — record the issuance with the credential's UUID and // name ONLY. The password itself never reaches the recorder: key-name // redaction matches 24 patterns and a value outside them is stored as // sent, so this is enforced here rather than relied on downstream. $this->record_issuance( $server_id, $uuid, $app_name, 'success' ); $response = rest_ensure_response( array( 'success' => true, 'password' => $password, 'username' => $current_user->user_login, 'app_id' => $uuid, 'message' => __( 'Application Password created. Store it safely — it is shown only once.', 'acrossai-mcp-manager' ), ) ); // FR-017 — the body carries a plaintext credential; no cache between // here and the browser may retain it. return CacheHeaders::apply_to_rest_response( $response ); } /** * GET /acrossai-mcp-manager/v1/list-app-passwords * * @return \WP_REST_Response */ public function list_app_passwords(): \WP_REST_Response { if ( ! class_exists( 'WP_Application_Passwords' ) ) { return rest_ensure_response( array( 'success' => true, 'passwords' => array(), ) ); } $user_id = get_current_user_id(); $all = \WP_Application_Passwords::get_user_application_passwords( $user_id ); $prefix = self::APP_NAME_PREFIX; $passwords = array_values( array_filter( $all, static function ( $pwd ) use ( $prefix ) { return isset( $pwd['name'] ) && false !== strpos( (string) $pwd['name'], $prefix ); } ) ); return rest_ensure_response( array( 'success' => true, 'passwords' => $passwords, ) ); } // ───────────────────────────────────────────────────────────────────────── // Server-edit Tokens tab renderer. // ───────────────────────────────────────────────────────────────────────── /** * Render the Tokens tab body for one server. Called from Settings. * * Lists existing AcrossAI Application Passwords (filtered by name prefix) * + a button that POSTs to /generate-app-password via fetch(). * * @param int $server_id Server row ID being edited. */ public function render_for_server( int $server_id ): void { $user_id = get_current_user_id(); $has_api = class_exists( 'WP_Application_Passwords' ); $generate = esc_url( rest_url( self::REST_NAMESPACE . '/generate-app-password' ) ); $nonce = wp_create_nonce( 'wp_rest' ); echo '
'; esc_html_e( 'Application Passwords are not supported on this WordPress version.', 'acrossai-mcp-manager' ); echo '
'; esc_html_e( 'Generate an Application Password to grant an MCP client access to this server. The plaintext password is shown only once.', 'acrossai-mcp-manager' ); echo '
'; printf( '', (int) $server_id, esc_attr( $generate ), esc_attr( $nonce ), esc_html__( 'Generate New Application Password', 'acrossai-mcp-manager' ) ); echo '
' . esc_html__( 'No Application Passwords created by this plugin yet.', 'acrossai-mcp-manager' ) . '
'; } else { echo '| ' . esc_html__( 'Name', 'acrossai-mcp-manager' ) . ' | ' . esc_html__( 'Created', 'acrossai-mcp-manager' ) . ' | ' . esc_html__( 'Last Used', 'acrossai-mcp-manager' ) . ' |
|---|---|---|
| %s | %s | %s |