settings = $settings; $this->http = $http; } public function register(): void { \add_action( 'admin_post_' . self::START_ACTION, [ $this, 'handleStart' ] ); \add_action( 'admin_post_' . self::CALLBACK_ACTION, [ $this, 'handleCallback' ] ); \add_action( 'admin_post_' . self::DISCONNECT_ACTION, [ $this, 'handleDisconnect' ] ); } public function handleStart(): void { if ( ! \current_user_can( 'manage_options' ) ) { \wp_die( \esc_html__( 'Permission denied.', 'agent001-support-assistant-for-woocommerce' ) ); } \check_admin_referer( self::START_ACTION ); if ( $this->settings->apiKeyIsLocked() ) { $this->redirectToSettings( [ 'connect_error' => 'locked' ] ); } try { $state = \bin2hex( \random_bytes( 32 ) ); } catch ( \Throwable $e ) { $state = \wp_generate_password( 64, false, false ); } \set_transient( self::STATE_TRANSIENT_PREFIX . $state, [ 'user_id' => \get_current_user_id() ], self::STATE_TTL ); $url = \add_query_arg( [ 'site' => \home_url(), 'return_to' => \admin_url( 'admin-post.php?action=' . self::CALLBACK_ACTION ), 'state' => $state, 'channel' => 'default', 'platform' => 'woocommerce', ], $this->settings->connectUrl() ); $allowedHost = \wp_parse_url( $this->settings->backendBaseUrl(), PHP_URL_HOST ); if ( is_string( $allowedHost ) && $allowedHost !== '' ) { \add_filter( 'allowed_redirect_hosts', static function ( array $hosts ) use ( $allowedHost ): array { $hosts[] = $allowedHost; return $hosts; } ); } \wp_safe_redirect( $url ); exit; } public function handleCallback(): void { if ( ! \current_user_can( 'manage_options' ) ) { \wp_die( \esc_html__( 'Permission denied.', 'agent001-support-assistant-for-woocommerce' ) ); } // CSRF protection on this OAuth callback uses a one-time state token // rather than wp_verify_nonce, because the request comes back via the // remote authorisation server (https://agent001.ai/connect) which has // no way to relay a WP nonce. We generated the state token in // handleStart() with random_bytes(32), stored it in a transient bound // to the current user, and we verify both presence and user-binding // below before accepting the authorization code. The transient is // deleted on first use (single-shot), and the lookup is constant-time // because keys are HMAC-hashed by WP. // // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth state token, validated against single-use transient below. $state = isset( $_GET['state'] ) && is_string( $_GET['state'] ) ? \sanitize_text_field( \wp_unslash( (string) $_GET['state'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth authorization code, exchanged server-to-server below. $code = isset( $_GET['code'] ) && is_string( $_GET['code'] ) ? \sanitize_text_field( \wp_unslash( (string) $_GET['code'] ) ) : ''; if ( $state === '' || $code === '' ) { $this->redirectToSettings( [ 'connect_error' => 'invalid' ] ); } $transientKey = self::STATE_TRANSIENT_PREFIX . $state; $stored = \get_transient( $transientKey ); if ( $stored === false ) { $this->redirectToSettings( [ 'connect_error' => 'state' ] ); } \delete_transient( $transientKey ); $expectedUserId = is_array( $stored ) && isset( $stored['user_id'] ) ? (int) $stored['user_id'] : 0; if ( $expectedUserId === 0 || $expectedUserId !== \get_current_user_id() ) { $this->redirectToSettings( [ 'connect_error' => 'state' ] ); } $connection = $this->exchangeCode( $code ); if ( $connection === null ) { $this->redirectToSettings( [ 'connect_error' => 'exchange' ] ); } $this->settings->setApiKey( $connection['api_key'] ); $this->settings->setAccountName( $connection['account_name'] ); $this->settings->setConnectedSite( \home_url() ); $this->settings->setSelectedAgentIds( [] ); $this->settings->setSelectedAgentEmbedToken( '' ); $this->settings->setWidgetKey( '' ); $this->settings->setSelectedAgentActive( true ); \delete_transient( AgentSelectionController::AGENTS_TRANSIENT ); \update_option( Onboarding::OPTION, 'active', false ); $this->redirectToSettings( [ 'connected' => '1' ] ); } public function handleDisconnect(): void { if ( ! \current_user_can( 'manage_options' ) ) { \wp_die( \esc_html__( 'Permission denied.', 'agent001-support-assistant-for-woocommerce' ) ); } \check_admin_referer( self::DISCONNECT_ACTION ); if ( $this->settings->apiKeyIsLocked() ) { $this->redirectToSettings( [ 'connect_error' => 'locked' ] ); } $this->settings->setApiKey( '' ); $this->settings->setAccountName( '' ); $this->settings->setConnectedSite( '' ); $this->settings->setSelectedAgentIds( [] ); $this->settings->setSelectedAgentEmbedToken( '' ); $this->settings->setWidgetKey( '' ); \delete_transient( AgentSelectionController::AGENTS_TRANSIENT ); $this->redirectToSettings( [ 'disconnected' => '1' ] ); } /** * @return array{api_key: string, account_name: string}|null */ private function exchangeCode( string $code ): ?array { $body = \wp_json_encode( [ 'code' => $code ] ); if ( $body === false ) { return null; } $headers = [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => 'Agent001/' . ( \defined( 'AGENT001_VERSION' ) ? \AGENT001_VERSION : 'dev' ), ]; $result = $this->http->postJson( $this->settings->exchangeUrl(), $headers, $body ); if ( ! $result->isSuccess() ) { return null; } $decoded = json_decode( $result->body(), true ); if ( ! is_array( $decoded ) || empty( $decoded['api_key'] ) || ! is_string( $decoded['api_key'] ) ) { return null; } $accountName = isset( $decoded['account_name'] ) && is_string( $decoded['account_name'] ) ? \sanitize_text_field( $decoded['account_name'] ) : ''; return [ 'api_key' => $decoded['api_key'], 'account_name' => $accountName, ]; } /** * @param array $extra * @return never */ private function redirectToSettings( array $extra ) { $url = \add_query_arg( array_merge( [ 'page' => SettingsPage::PAGE_SLUG ], $extra ), \admin_url( 'admin.php' ) ); \wp_safe_redirect( $url ); exit; } }