Jake Vanderwerf
2026-09-18 a6413da67a4579866f0fd0323ddb23a7b6b9654a
inc/integrations/OAuth.php
@@ -10,8 +10,8 @@
trait OAuth
{
   use _Base, Admin;
   protected bool $hasOAuth = true;
   use _Base, Admin, ServiceMap;
   protected OAuthURLs $oauth;
   /**
    * Child classes must set this (e.g. 'https://api.example.com/v1/')
    */
@@ -20,35 +20,122 @@
   protected int $oauthTTL = 300;
   protected array $oathScopes = [];
   protected ?RateLimits $oauthLimiter;
   abstract protected function getOAuthRequestHeaders(): array;
   abstract protected function handleOAuthResponse(array|WP_Error $response, string $method, string $endpoint): array;
   protected function initializeOAuthRateLimiter():void
   {
      $key = $this->service_name;
      if (!is_null($this->userID)) {
         $key .= '_'.$this->userID;
      }
      $key .= '_oauth';
      $this->oauthLimiter = new RateLimits($key, ['s' => 1, 'm' => 10, 'h' => 100]);
   }
   /**
    * @return array Defaults to the base getRequestHeaders, but can optionally be overridden
    */
   protected function getOAuthRequestHeaders(): array
   {
      return $this->getRequestHeaders();
   }
   public function getOAuthBaseURL():?string
   {
      return $this->baseOAuthUrl;
   }
   /**
    * Default to the base handleResponse method, but extensions can modify accordingly
    * @param array|WP_Error $response
    * @param string $method
    * @param string $endpoint
    * @return array
    */
   protected function handleOAuthResponse(array|WP_Error $response, string $method, string $endpoint): array
   {
      return $this->handleResponse($response, $method, $endpoint);
   }
   abstract protected function refreshAccessToken(OAuthCredentials $credentials): array;
   function addOAuthActions():void
   {
      $actions = [
         [
            'name'      => 'Connect to Service',
            'label'     => 'Connect',
            'icon'      => 'plugs-connected',
            'action' => [$this, 'handleOAuthConnect']
         ],
         [
            'name'      => 'Disconnect from Service',
            'icon'      => 'plugs-disconnected',
            'action' => [$this, 'handleOAuthDisconnect']
         ],
         [
            'name'      => 'Refresh Token',
            'icon'      => 'arrows-clockwise',
            'action' => [$this, 'handleOAuthRefreshToken']
         ]
      ];
      foreach ($actions as $a) {
         $this->addAction($a['name'], $a['action'], $a['slug']??null, $a['icon']??null, $a['label']??null);
      if ($this->isSetUp()) {
         $actions = [
            [
               'name'      => 'Connect to Service',
               'label'     => 'Connect',
               'icon'      => 'plugs-connected',
               'action' => [$this, 'handleOAuthConnect']
            ],
            [
               'name'      => 'Disconnect from Service',
               'label'     => 'Disconnect',
               'icon'      => 'plugs-disconnected',
               'action' => [$this, 'handleOAuthDisconnect']
            ],
//       [
//          'name'      => 'Refresh Token',
//          'icon'      => 'arrows-clockwise',
//          'action' => [$this, 'handleOAuthRefreshToken']
//       ]
         ];
         foreach ($actions as $a) {
            $this->addAction($a['name'], $a['action'], $a['slug']??null, $a['icon']??null, $a['label']??null);
         }
      }
   }
      public function hasOAuthCredentials(?int $userID = null):bool
      {
         if ($userID !== $this->userID) {
            $this->switchUser($userID);
         }
         return $this->hasValidOAuth();
      }
      public function handleOAuthConnect():array
      {
         $url = $this->getOAuthUrl();
         if ($url) {
            return [
               'success'   => true,
               'redirect'  => $url,
            ];
         }
         return $this->response(false, 'Failed to generate OAuth URL');
      }
      public function handleOAuthDisconnect():array
      {
         $revoked = $this->revokeOAuth();
         return $this->response($revoked, $revoked ?
         'Successfully disconnected from '.$this->title :
         'Failed to disconnect from '.$this->title);
      }
      public function revokeOAuth():bool
      {
         if (empty($this->oauth->getRevoke())) {
            error_log('No revoke url set for '.$this->service_name);
            return false;
         }
         $credentials = $this->loadCredentials();
         if (empty($credentials['access_token'])) {
            error_log('Could not send revoke request, no access_token for '.$this->service_name);
         } else {
            $response = $this->postOAuthRequest($this->oauth->getRevoke(), [
               'body'   => [
                  'token'  => $credentials['access_token']
               ]
            ]);
            $empty = new OAuthCredentials([]);
            foreach ($empty->toArray() as $key => $value) {
               if (array_key_exists($key, $credentials)) {
                  unset($credentials[$key]);
               }
            }
            return Auth::getInstance()->storeCredentials($this->service_name, $credentials, $this->userID);
         }
         return false;
      }
   protected function registerOAuthCallbacks():void
   {
@@ -105,10 +192,6 @@
         return $this->handleOAuthResponse($error, $method, $endpoint);
      }
      if (!empty($this->requiredScopes) && !$credentials->hasScopes($this->requiredScopes)) {
         $error = new WP_Error('insufficient_scope', "Missing required scopes for {$this->service_name}");
         return $this->handleOAuthResponse($error, $method, $endpoint);
      }
      $url = rtrim($this->baseOAuthUrl, '/') . '/' . ltrim($endpoint, '/');
@@ -215,12 +298,14 @@
      }
      // Get integration instance
      $integration = JVB()->connect($this->service_name, $user_id);
      $integration = $this->getService($this->service_name);
      if (!$integration) {
      if (!$integration || !method_exists($integration, 'switchUser')) {
         wp_die('Invalid service: ' . esc_html($this->service_name), 'OAuth Error');
      }
      $integration->switchUser($user_id);
      // Exchange code for tokens
      $result = $integration->handleOAuthCode($code, $state);
@@ -246,20 +331,23 @@
      $credentials = $this->loadCredentials();
      $hasCredentials = $this->hasValidOAuth();
      $returnURL = $this->getReturnURL();
      if ($hasCredentials) {
      if (!$this->isSetUp()) {
         return '';
      }
      if (!$hasCredentials) {
         return sprintf(
         '<div class="oauth-connect">
            <h3>Connect to your %s account</h3>
            <a href="%s" class="btn jvb-oauth-connect" data-service="%s">%s Authorize Connection</a>',
            <a href="%s" class="btn jvb-oauth-connect" data-service="%s">%s Authorize Connection</a>
            </div>',
            $this->title,
            $this->getOAuthURL($returnURL),
            esc_attr($this->service_name),
            $this->outputIcon($this->icon)
         );
      }
      $form = sprintf(
         '<details open>
      } else {
         $form = sprintf(
            '<details open>
         <summary>Connected Account
            <div class="connection-status connected">
               <span class="status-indicator">●</span>
@@ -268,22 +356,23 @@
         </summary>
         <span class="label">OAuth Redirect URL:</span>
         <code>%s</code>',
         $this->getRedirectUri()
      );
      if (!empty($credentials['updated_at'])) {
         $form .= sprintf(
            '<div class="oauth-meta"><small>Token expires: %s</small></div>',
            array_key_exists('expires_at', $credentials) ?
            $this->getRedirectUri()
         );
         if (!empty($credentials['updated_at'])) {
            $form .= sprintf(
               '<div class="oauth-meta"><small>Token expires: %s</small></div>',
               array_key_exists('expires_at', $credentials) ?
                  human_time_diff($credentials['expires_at']) :
                  'Never'
         );
            );
         }
         //
         if (method_exists($this, 'renderOAuthConnectedOptions')) {
            $form .= $this->renderOAuthConnectedOptions();
         }
         $form .= '</details>';
         return $form;
      }
      //
      if (method_exists($this, 'renderOAuthConnectedOptions')) {
         $form .= $this->renderOAuthConnectedOptions();
      }
      $form .= '</details';
      return $form;
   }
   /**
@@ -296,11 +385,9 @@
   public function getOAuthURL(?string $return_url = null):string
   {
      if (!$this->hasOAuth) {
         return '';
      }
      $credentials = $this->loadCredentials();
      if (empty($credentials['authorize'])) {
      if (empty($this->oauth->getAuthorize())) {
         $this->logError('getOAuthUrl', 'OAuth authorize URL not configured');
         return '';
      }
@@ -308,7 +395,7 @@
         'client_id'    => $credentials['client_id']??$credentials['application_id']??$credentials['app_id']??'',
         'redirect_url' => $this->getRedirectUri(),
         'response_type'   => 'code',
         'scope'        => implode(' ', $credentials['scopes']??[])
         'scope'        => implode(' ', $this->oauthScopes??[])
      ];
      $state_key = wp_generate_password(32, false);
      $user_id = $this->userID??0;
@@ -338,14 +425,19 @@
         $params = $this->addOAuthParams($params);
      }
      return $credentials['authorize'] . '?' . http_build_query($params);
      return $this->oauth->getAuthorize() . '?' . http_build_query($params);
   }
   protected function getRedirectUri():string
   {
      if ($this->hasOAuth) {
         return admin_url('admin-ajax.php?action='.BASE . $this->service_name . '_oauth_callback');
      }
      return rest_url('jvb/v1/oauth/callback?service=' . $this->service_name);
      return admin_url('admin-ajax.php?action='.BASE . $this->service_name . '_oauth_callback');
   }
   /****************************************************
    * UTILITY
   ****************************************************/
   protected function getAccessToken():string
   {
      return $this->loadCredentials()['access_token']??'';
   }
}