'GET', 'callback' => array( $this, 'handle_health' ), 'permission_callback' => '__return_true', ) ); register_rest_route( self::REST_NAMESPACE, '/auth/start', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_auth_start' ), 'permission_callback' => '__return_true', 'args' => array( 'server_id' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); register_rest_route( self::REST_NAMESPACE, '/auth/status', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_auth_status' ), 'permission_callback' => '__return_true', 'args' => array( 'code' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), 'server' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); register_rest_route( self::REST_NAMESPACE, '/servers', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_servers' ), 'permission_callback' => array( $this, 'verify_session_token' ), ) ); register_rest_route( self::REST_NAMESPACE, '/auth/exchange', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_auth_exchange' ), 'permission_callback' => '__return_true', ) ); } /** * `verify_session_token` permission callback for GET /servers. * * Reads `Authorization: Bearer ` (with REDIRECT_HTTP_AUTHORIZATION * fallback for Apache+CGI). Validates the session transient as a Q4 * `array{user_id, server_id}` payload. On success, sets current user AND * stashes `_bound_server_id` on the request for the endpoint body. * * @param WP_REST_Request $request Inbound REST request. * @return true|WP_Error */ public function verify_session_token( WP_REST_Request $request ) { $header = ''; if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) { $header = (string) $_SERVER['HTTP_AUTHORIZATION']; } elseif ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { $header = (string) $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; } if ( 0 !== stripos( $header, 'Bearer ' ) ) { return new WP_Error( 'rest_unauthorized', 'Missing Bearer token.', array( 'status' => 401 ) ); } $token = trim( substr( $header, 7 ) ); if ( '' === $token || strlen( $token ) > 64 ) { return new WP_Error( 'rest_unauthorized', 'Malformed Bearer token.', array( 'status' => 401 ) ); } // Per Q4 — payload is array{user_id: int, server_id: string}. $payload = get_transient( self::SESSION_TRANSIENT_PREFIX . $token ); if ( ! is_array( $payload ) || ! isset( $payload['user_id'], $payload['server_id'] ) || ! is_numeric( $payload['user_id'] ) ) { return new WP_Error( 'rest_unauthorized', 'Invalid or expired session token.', array( 'status' => 401 ) ); } wp_set_current_user( (int) $payload['user_id'] ); $request->set_param( '_bound_server_id', (string) $payload['server_id'] ); return true; } /** * Q2 Content-Type allow-list helper (per FR-015 / research R9). * * Returns `null` to continue; returns an `invalid_request` 400 response * to abort. Called as Step 0 of `handle_auth_start` and * `handle_auth_exchange`. * * @param WP_REST_Request $request Inbound REST request. */ private function check_content_type( WP_REST_Request $request ): ?WP_REST_Response { $ct_info = $request->get_content_type(); $value = strtolower( (string) ( $ct_info['value'] ?? '' ) ); if ( 'application/json' !== $value && 'application/x-www-form-urlencoded' !== $value ) { $resp = new WP_REST_Response( array( 'error' => 'invalid_request' ), 400 ); $resp->header( 'Cache-Control', 'no-store' ); return $resp; } return null; } /** * GET /health — plugin discovery + site_slug (FR-001). * * @param WP_REST_Request $request Inbound REST request (unused). */ public function handle_health( WP_REST_Request $request ): WP_REST_Response { unset( $request ); return new WP_REST_Response( array( 'plugin_installed' => true, 'plugin_active' => true, 'version' => defined( 'ACROSSAI_MCP_MANAGER_VERSION' ) ? (string) ACROSSAI_MCP_MANAGER_VERSION : '0.0.0', 'site_slug' => sanitize_title( get_bloginfo( 'name' ) ), ), 200 ); } /** * POST /auth/start — issue auth_code + auth_url (FR-002). * * @param WP_REST_Request $request Inbound REST request. */ public function handle_auth_start( WP_REST_Request $request ): WP_REST_Response { // Step 0 — Q2 Content-Type allow-list. $ct_err = $this->check_content_type( $request ); if ( null !== $ct_err ) { return $ct_err; } $server_id = sanitize_text_field( (string) $request->get_param( 'server_id' ) ); if ( '' === $server_id ) { return new WP_REST_Response( array( 'error' => 'invalid_request' ), 400 ); } try { $auth_code = bin2hex( random_bytes( 16 ) ); } catch ( \Throwable $e ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( '[acrossai-mcp] random_bytes failed in handle_auth_start: ' . $e->getMessage() ); return new WP_REST_Response( array( 'error' => 'server_error' ), 500 ); } $ok = set_transient( self::AUTH_TRANSIENT_PREFIX . $auth_code, array( 'server_id' => $server_id, 'status' => 'pending', 'user_id' => null, 'session_token' => null, 'created_at' => time(), ), self::AUTH_CODE_TTL ); if ( false === $ok ) { return new WP_REST_Response( array( 'error' => 'server_error' ), 500 ); } $auth_url = FrontendAuth::get_base_url() . '?' . http_build_query( array( 'action' => 'cli_auth', 'code' => $auth_code, 'server' => $server_id, ) ); return new WP_REST_Response( array( 'auth_code' => $auth_code, 'auth_url' => $auth_url, 'expires_in' => self::AUTH_CODE_TTL, ), 200 ); } /** * GET /auth/status — poll approval state (FR-003). * * Q4 oracle defense: server-id mismatch returns `{approved: false}`, * NOT 404 — indistinguishable from a real pending state. * * @param WP_REST_Request $request Inbound REST request. * @return WP_REST_Response|WP_Error */ public function handle_auth_status( WP_REST_Request $request ) { $code = sanitize_text_field( (string) $request->get_param( 'code' ) ); $server = sanitize_text_field( (string) $request->get_param( 'server' ) ); $payload = get_transient( self::AUTH_TRANSIENT_PREFIX . $code ); if ( false === $payload || ! is_array( $payload ) ) { return new WP_Error( 'auth_code_not_found', 'Authorization code not found.', array( 'status' => 404 ) ); } if ( 'approved' === ( $payload['status'] ?? '' ) && hash_equals( (string) ( $payload['server_id'] ?? '' ), $server ) ) { return new WP_REST_Response( array( 'approved' => true, 'token' => (string) ( $payload['session_token'] ?? '' ), ), 200 ); } return new WP_REST_Response( array( 'approved' => false ), 200 ); } /** * GET /servers — single-server inventory (Q4 binding). * * @param WP_REST_Request $request Inbound REST request (carries `_bound_server_id`). */ public function handle_servers( WP_REST_Request $request ): WP_REST_Response { $bound_server_id = (string) $request->get_param( '_bound_server_id' ); $rows = MCPServerQuery::instance()->query( array( 'server_slug' => $bound_server_id, 'is_enabled' => 1, 'number' => 1, ) ); if ( empty( $rows ) ) { return new WP_REST_Response( array( 'servers' => array() ), 200 ); } $row = $rows[0]; $user_id = get_current_user_id(); $ns = (string) $row->server_route_namespace; $route = (string) $row->server_route; // Feature 015 — Access Control v2 adoption. Route through the plugin-scoped // wrapper (never the vendor's v1-API ::instance() static, which does not // exist in v2). Fail-open when the vendor package is unavailable. $ac = \AcrossAI_MCP_Manager\Includes\AccessControl\AcrossAI_MCP_Access_Control::instance(); if ( $ac->is_available() ) { $manager = $ac->get_manager(); $slug = $ns . '/' . $route; if ( null !== $manager && ! $manager->user_has_access( $user_id, 'acrossai-mcp-manager', $slug ) ) { // FR-026 observability hook fires BEFORE the silent-empty-list return. do_action( 'acrossai_mcp_access_control_denied', $user_id, $slug, null, 'cli_servers' ); return new WP_REST_Response( array( 'servers' => array() ), 200 ); } } return new WP_REST_Response( array( 'servers' => array( array( 'id' => (int) $row->id, 'name' => (string) $row->server_name, 'description' => (string) $row->description, 'enabled' => (bool) $row->is_enabled, 'version' => (string) $row->server_version, 'namespace' => $ns, 'route' => $route, 'mcp_url' => rest_url( $ns . '/' . $route ), ), ), ), 200 ); } /** * POST /auth/exchange — issue Application Password (FR-005/006/007). * * 8-step validation chain + Step 0 Q2 Content-Type allow-list. * * @param WP_REST_Request $request Inbound REST request. */ public function handle_auth_exchange( WP_REST_Request $request ): WP_REST_Response { // Step 0 — Q2 Content-Type allow-list. $ct_err = $this->check_content_type( $request ); if ( null !== $ct_err ) { return $ct_err; } $code = sanitize_text_field( (string) $request->get_param( 'code' ) ); // Step 1 — read transient. $payload = get_transient( self::AUTH_TRANSIENT_PREFIX . $code ); if ( false === $payload || ! is_array( $payload ) ) { return $this->error( 'invalid_code', 400 ); } // Step 2 — status check. if ( 'approved' !== ( $payload['status'] ?? '' ) ) { return $this->error( 'not_approved', 400 ); } // Step 3 — user existence. $stored_user_id = (int) ( $payload['user_id'] ?? 0 ); $user = get_userdata( $stored_user_id ); if ( false === $user || ! ( $user instanceof \WP_User ) ) { return $this->error( 'invalid_user', 400 ); } // Step 4 — WP-Apps capability. if ( ! class_exists( 'WP_Application_Passwords' ) ) { // Audit row NOT written for this path. return $this->error( 'not_supported', 501 ); } // Step 5 — server_id present. $request_server_id = sanitize_title( (string) $request->get_param( 'server_id' ) ); if ( '' === $request_server_id ) { return $this->error( 'missing_server', 400 ); } // Step 6 — server_id matches transient (hash_equals). $stored_server_id = (string) ( $payload['server_id'] ?? '' ); if ( ! hash_equals( $stored_server_id, $request_server_id ) ) { // Transients NOT deleted — legitimate retry possible. return $this->error( 'server_mismatch', 400 ); } // Step 7 — server resolves to an enabled row. $server_rows = MCPServerQuery::instance()->query( array( 'server_slug' => $request_server_id, 'is_enabled' => 1, 'number' => 1, ) ); if ( empty( $server_rows ) ) { return $this->error( 'invalid_server', 403 ); } $resolved_server_slug = (string) $server_rows[0]->server_slug; // Step 8 (success path) — create + delete + audit + respond. try { // Q3 — uniqueness suffix from the first 8 hex chars of the raw code. $app_pwd_name = 'AcrossAI MCP Manager CLI - ' . $resolved_server_slug . ' - ' . substr( $code, 0, 8 ); $result = \WP_Application_Passwords::create_new_application_password( $stored_user_id, array( 'name' => $app_pwd_name ) ); } catch ( \Throwable $e ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( '[acrossai-mcp] WP-Apps create threw: ' . $e->getMessage() ); return $this->error( 'server_error', 500 ); } if ( is_wp_error( $result ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( '[acrossai-mcp] WP-Apps create returned WP_Error: ' . $result->get_error_message() ); return $this->error( 'server_error', 500 ); } // WP-Apps returns positional [raw_password, record_array]. $raw_password = isset( $result[0] ) ? (string) $result[0] : ''; $record = isset( $result[1] ) && is_array( $result[1] ) ? $result[1] : array(); $uuid = (string) ( $record['uuid'] ?? '' ); // Single-use enforcement — delete both transients. delete_transient( self::AUTH_TRANSIENT_PREFIX . $code ); $stored_session_token = (string) ( $payload['session_token'] ?? '' ); if ( '' !== $stored_session_token ) { delete_transient( self::SESSION_TRANSIENT_PREFIX . $stored_session_token ); } // Best-effort audit. Recorder::record_success( $stored_user_id, $request_server_id, hash( 'sha256', $code ), $uuid ); $resp = new WP_REST_Response( array( 'app_password' => $raw_password, 'username' => (string) $user->user_login, 'user_id' => (int) $stored_user_id, 'expires_in' => self::APP_PASSWORD_TTL_INFO, 'server_id' => $request_server_id, ), 200 ); $resp->header( 'Cache-Control', 'no-store' ); $resp->header( 'Pragma', 'no-cache' ); return $resp; } /** * Public static method called by FrontendAuth::handle_approve (FR-008). * * Reads the pending E1 transient, generates a session token, writes the * Q4-bound E2 transient as `array{user_id, server_id}`, calls Recorder. * * @param string $auth_code Raw authorization code from the CLI. * @param int $user_id Approving admin's user ID. * @return bool true on first valid call; false on absent transient or already-approved state. */ public static function approve_auth_code( string $auth_code, int $user_id ): bool { $payload = get_transient( self::AUTH_TRANSIENT_PREFIX . $auth_code ); if ( false === $payload || ! is_array( $payload ) ) { return false; } if ( 'pending' !== ( $payload['status'] ?? '' ) ) { return false; } try { $session_token = bin2hex( random_bytes( 16 ) ); } catch ( \Throwable $e ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( '[acrossai-mcp] random_bytes failed in approve_auth_code: ' . $e->getMessage() ); return false; } $payload['status'] = 'approved'; $payload['user_id'] = $user_id; $payload['session_token'] = $session_token; set_transient( self::AUTH_TRANSIENT_PREFIX . $auth_code, $payload, self::AUTH_CODE_TTL ); // Q4 — session token transient value is array{user_id, server_id}. set_transient( self::SESSION_TRANSIENT_PREFIX . $session_token, array( 'user_id' => (int) $user_id, 'server_id' => (string) ( $payload['server_id'] ?? '' ), ), self::SESSION_TOKEN_TTL ); Recorder::record_approved( $user_id, (string) ( $payload['server_id'] ?? '' ), hash( 'sha256', $auth_code ) ); return true; } /** * Read-only peek at the pending auth-code transient — returns the bound * server_id ONLY when the transient exists, is well-formed, and has * status === 'pending'. Returns null in every other case. * * Added 2026-06-30 to fix SEC-001 / S9 (consent-surface displayed-state * MUST come from the authoritative store, not the URL). Consumed by * FrontendAuth::handle_cli_auth() to source the displayed server slug. * * Pure stateless read — no transient writes, no audit-log writes, no * error_log, no exceptions. Idempotent: calling N times returns identical * values and changes no state. Applies B11 transient-defensive * triple-check (generalized). * * See contracts/cli-controller-peek-pending-server.md (Phase 7 spec) + * docs/memory/PROJECT_CONTEXT.md §S9 + docs/memory/BUGS.md §B11. * * @param string $auth_code Raw authorization code from the CLI's URL. * @return string|null Bound server_id for pending codes; null otherwise. */ public static function peek_pending_server( string $auth_code ): ?string { if ( '' === $auth_code ) { return null; } $payload = get_transient( self::AUTH_TRANSIENT_PREFIX . $auth_code ); if ( ! is_array( $payload ) ) { return null; } if ( ! isset( $payload['status'], $payload['server_id'] ) ) { return null; } if ( 'pending' !== $payload['status'] ) { return null; } if ( ! is_string( $payload['server_id'] ) || '' === $payload['server_id'] ) { return null; } return $payload['server_id']; } /** * Compose an `invalid_*` JSON error envelope. * * @param string $code Error code (e.g. `invalid_code`). * @param int $status HTTP status. */ private function error( string $code, int $status ): WP_REST_Response { $resp = new WP_REST_Response( array( 'error' => $code ), $status ); $resp->header( 'Cache-Control', 'no-store' ); return $resp; } }