Our current apps no longer compatible

Topic summary

Main issue: A partner dashboard notice says some custom apps must be updated to meet requirements to load in admin.shopify.com by September 20, 2024.

Context: The apps are custom for a single store; the previous developer handled auth and has left. The notice doesn’t specify which apps are affected. The backend must remain in PHP.

Current implementation (from shared code):

  • PHP/CodeIgniter controller using a custom Shopify library.
  • OAuth flow builds an install URL with scopes (read/write: products, customers, orders, themes, content) and an escapeIframe view, then exchanges code for an access_token via POST to /admin/oauth/access_token.
  • Session-based access check (session access_token). Includes a custom signature check using md5 of secret + query string, URL handling forcing “/admin/”.

What’s being asked: Guidance on the current authentication flow required to remain compatible with admin.shopify.com and the best way to upgrade while keeping the PHP backend.

Outcomes/decisions: None yet; no responses providing specifics.

Status: Open and unresolved. Key unknowns remain (which apps are impacted; exact auth changes required). Code snippet is central to understanding the legacy flow.

Summarized with AI on December 20. AI used: gpt-5.

I develop apps for a single store, so basically custom apps. Our previous developer did the authentication needed for the apps to show in the Shopify Admin. But he has since left our company. These apps are some years old and we are now getting a message in our partners account (where we have the apps) that says the following:

Our records indicate that one or more of your apps have not been updated to meet Shopify’s requirements to load in admin.shopify.com, and will need to be updated by September 20, 2024 in order to continue working in Shopify.

But that is as detailed as it gets, there is no indication as to which apps are effected. We have a PHP backend for the apps, and need to retain that. Could you give me some direction as to what the current authentication flow is and how to use it.

Here is the code we are currently using. What would be the best way to upgrade.

load->model('admin/freesample_model', 'freesample_model');            
            $this->load->helper('array');
        } 
            
        public function access() {
            
            $shop = $this->config->item('shopify_store_url');

            if(isset($shop)) {
                $this->session->set_userdata($shop);
            }

            if(($this->session->userdata('access_token'))) {  
                $data['new_customers_count'] = $this->freesample_model->count_all_customers();
                $data['all_freesample_products'] = $this->freesample_model->count_all_products();          
                $data['all_freesample_orders'] = $this->freesample_model->count_all_orders();
                $data['view'] = 'dashboard';
                $this->load->view('layout',$data);
            } else {
                $this->auth($shop);
            }
        }

        public function auth($shop) {

            $data = array(
                'API_KEY' => $this->config->item('shopify_api_key'),
                'API_SECRET' => $this->config->item('shopify_secret'),
                'SHOP_DOMAIN' => $shop,
                'ACCESS_TOKEN' => ''
            );
            $this->load->library('Shopify' , $data); //load shopify library and pass values in constructor

            $scopes = array('read_products','write_products', 'read_customers', 'write_customers', 'read_orders', 'write_orders', 'read_themes', 'write_themes', 'read_content', 'write_content'); //what app can do
            $redirect_url = $this->config->item('redirect_url'); //redirect url specified in app setting at shopify
            $paramsforInstallURL = array(
                'scopes' => $scopes,
                'redirect' => $redirect_url
            );

            $permission_url = $this->shopify->installURL($paramsforInstallURL);
            
            $this->load->view('auth/escapeIframe', ['installUrl' => $permission_url]);
            
        }

        public function authCallback(){
            $code = $this->input->get('code');
            $shop = $this->input->get('shop');

            if(isset($code)){
                $data = array(
                'API_KEY' => $this->config->item('shopify_api_key'),
                'API_SECRET' => $this->config->item('shopify_secret'),
                'SHOP_DOMAIN' => $shop,
                'ACCESS_TOKEN' => ''
            );
                $this->load->library('Shopify' , $data); //load shopify library and pass values in constructor
            }

            $accessToken = $this->shopify->getAccessToken($code);
            $this->session->set_userdata(['shop' => $shop , 'access_token' => $accessToken]);

            redirect('https://'.$shop.'/admin/apps/vpa-my-admin');
        }

        
    }

Ans

setup($data);
			
        }
    }

    public function setup($data=array()) {
        foreach ($this->_KEYS as $key ) {
            if(array_key_exists($key,$data)){
				$this->_API[$key] = $this->verifySetup($key , $data[$key]);
				
            }
        }
    }

    private static function verifySetup($key='', $value=''){
        $value = trim($value);
        switch($key) {
            case 'SHOP_DOMAIN':
                  preg_match('/(https?:\/\/)?([a-zA-Z0-9\-\.])+/', $value, $matched);
                  return $matched[0];
                  break;
            default:
				  return $value;
				  
        }
    }
    
    public function installURL($data = array()) {
		// https://{shop}.myshopify.com/admin/oauth/authorize?client_id={api_key}&scope={scopes}&redirect_uri={redirect_uri}
		return 'https://' . $this->_API['SHOP_DOMAIN'] . '/admin/oauth/authorize?client_id=' . $this->_API['API_KEY'] . '&scope=' . implode(',', $data['scopes']) . (!empty($data['redirect']) ? '&redirect_uri=' . urlencode($data['redirect']) : '');
	}
    /**
	 * Verifies data returned by OAuth call
	 * @param array|string $data
	 *  bool
	 * @throws \Exception
	 */
	public function verifyRequest($data = NULL, $bypassTimeCheck = FALSE) {
		$da = array();
		if (is_string($data)) {
			$each = explode('&', $data);

			foreach($each as $e) {
				list($key, $val) = explode('=', $e);
				$da[$key] = $val;

			}
		} elseif (is_array($data)) {
			$da = $data;

		} else {
			throw new \Exception('Data passed to verifyRequest() needs to be an array or URL-encoded string of key/value pairs.');

		}
		// Timestamp check; 1 hour tolerance
		if (!$bypassTimeCheck) {
			if (($da['timestamp'] - time() > 3600)) {
				throw new \Exception('Timestamp is greater than 1 hour old. To bypass this check, pass TRUE as the second argument to verifyRequest().');

			}
		}

		if (array_key_exists('hmac', $da)) {
			// HMAC Validation
			$queryString = http_build_query(array('code' => $da['code'], 'shop' => $da['shop'], 'timestamp' => $da['timestamp']));
			$match = $da['hmac'];
			$calculated = hash_hmac('sha256', $queryString, $this->_API['API_SECRET']);

		} else {
			// MD5 Validation, to be removed
			$queryString = http_build_query(array('code' => $da['code'], 'shop' => $da['shop'], 'timestamp' => $da['timestamp']), NULL, '');
			$match = $da['signature'];
			$calculated = md5($this->_API['API_SECRET'] . $queryString);

		}
		return $calculated === $match;

	}

    /**
	 * Calls API and returns OAuth Access Token, which will be needed for all future requests
	 * @param string $code
	 *  mixed
	 * @throws \Exception
	 */
	public function getAccessToken($code = '') {
		$dta = array('client_id' => $this->_API['API_KEY'], 'client_secret' => $this->_API['API_SECRET'], 'code' => $code);
		$data = $this->call(['METHOD' => 'POST', 'URL' => 'https://' . $this->_API['SHOP_DOMAIN'] . '/admin/oauth/access_token', 'DATA' => $dta], FALSE);
		return $data->access_token;

	}

    /**
	 * Checks that data provided is in proper format
	 *  Checks for presence of /admin/ in URL
	 * @param array $userData
	 *  array
	 */
	private function setupUserData($userData = array()) {
		$returnable = array();
		foreach($userData as $key => $value) {
			switch($key) {
				case 'URL':
					// Remove shop domain
					$url = str_replace($this->_API['SHOP_DOMAIN'], '', $value);
					// Verify it contains /admin/
					if (strpos($url, '/admin/') !== 0) {
						$url = str_replace('//', '/', '/admin/' . preg_replace('/\/?admin\/?/', '', $url));
					}

					$returnable[$key] = $url;
					break;

				default:
					$returnable[$key] = $value;
			}
		}
		return $returnable;
	}

    /**
	 * Executes the actual cURL : based on $userData
	 * @param array $userData
	 *  mixed
	 * @throws \Exception
	 */
    public function call($userData = array(), $verifyData = TRUE) {		
	    if ($verifyData) {
		    foreach ($this->_KEYS as $k) {				
			    if ((!array_key_exists($k, $this->_API)) || (empty($this->_API[$k]))) {
					throw new \Exception($k . ' must be set.');
					
			    }
		    }
	    }

	    $defaults = array(
		    'CHARSET'       => 'UTF-8',
		    'METHOD'        => 'GET',
		    'URL'           => '/',
			'HEADERS'       => array(),
	        'DATA'          => array(),
	        'FAILONERROR'   => TRUE,
	        'RETURNARRAY'   => FALSE,
	        'ALLDATA'       => FALSE
	    );

	    if ($verifyData) {
			$request = $this->setupUserData(array_merge($defaults, $userData));
			
	    } else {
			$request = array_merge($defaults, $userData);
			
	    }

	    // Send & accept JSON data
	    $defaultHeaders = array();
	    $defaultHeaders[] = 'Content-Type: application/json; charset=' . $request['CHARSET'];
		$defaultHeaders[] = 'Accept: application/json';
		
	    if (array_key_exists('ACCESS_TOKEN', $this->_API)) {
			$defaultHeaders[] = 'X-Shopify-Access-Token: ' . $this->_API['ACCESS_TOKEN'];
			
	    }

        $headers = array_merge($defaultHeaders, $request['HEADERS']);

	    if ($verifyData) {
			$url = 'https://' . $this->_API['API_KEY'] . ':' . $this->_API['ACCESS_TOKEN'] . '@' . $this->_API['SHOP_DOMAIN'] . $request['URL'];
			
	    } else {
			$url = $request['URL'];
			
	    }

	    // cURL setup
        $ch = curl_init();
        $options = array(
            CURLOPT_RETURNTRANSFER  => TRUE,
            CURLOPT_URL             => $url,
            CURLOPT_HTTPHEADER      => $headers,
            CURLOPT_CUSTOMREQUEST   => strtoupper($request['METHOD']),
            CURLOPT_ENCODING        => '',
            CURLOPT_USERAGENT       => 'RocketCode Shopify API Wrapper',
            //CURLOPT_FAILONERROR     => $request['FAILONERROR'],
            //CURLOPT_VERBOSE         => $request['ALLDATA'],
            CURLOPT_HEADER          => 1
        );

	    // Checks if DATA is being sent
	    if (!empty($request['DATA'])) {
		    if (is_array($request['DATA'])) {
				$options[CURLOPT_POSTFIELDS] = json_encode($request['DATA']);
				
		    } else {
			    // Detect if already a JSON object
			    json_decode($request['DATA']);
			    if (json_last_error() == JSON_ERROR_NONE) {
					$options[CURLOPT_POSTFIELDS] = $request['DATA'];
					
			    } else {
					throw new \Exception('DATA malformed.');
					
			    }
		    }
	    }

        curl_setopt_array($ch, $options);

        $response = curl_exec($ch);
        $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);

        // Data returned
        $result = json_decode(substr($response, $headerSize), $request['RETURNARRAY']);

        // Headers
        $info = array_filter(array_map('trim', explode("\n", substr($response, 0, $headerSize))));

        foreach($info as $k => $header) {
	        if (strpos($header, 'HTTP/') > -1) {
                $_INFO['HTTP_CODE'] = $header;
				continue;
				
            }

            list($key, $val) = explode(':', $header);
			$_INFO[trim($key)] = trim($val);
			
        }

        // cURL Errors
        $_ERROR = array('NUMBER' => curl_errno($ch), 'MESSAGE' => curl_error($ch));

        curl_close($ch);

	    if ($_ERROR['NUMBER']) {
			throw new \Exception('ERROR #' . $_ERROR['NUMBER'] . ': ' . $_ERROR['MESSAGE']);
			
	    }

	    // Send back in format that user requested
	    if ($request['ALLDATA']) {
		    if ($request['RETURNARRAY']) {
			    $result['_ERROR'] = $_ERROR;
			    $result['_INFO'] = $_INFO;
		    } else {
			    $result->_ERROR = $_ERROR;
			    $result->_INFO = $_INFO;
		    }
			return $result;
			
	    } else {
			return $result;
			
	    }

    }
}