getRequestHeaders(); } /** * 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); } } public function handleOAuthConnect():array { if (!$this->hasOAuth) { return $this->response(false, 'Not an OAuth service'); } $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 (!$this->hasOAuth) { return false; } if (empty($this->oauth['revoke'])) { 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['revoke'], [ '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 { add_action( 'wp_ajax_'.BASE.$this->service_name.'_oauth_callback', [$this, 'handleAjaxResponse'] ); add_action( 'wp_ajax_nopriv_'.BASE.$this->service_name.'_oauth_callback', [$this, 'handleAjaxResponse'] ); } protected function getOAuthRequest(string $endpoint, array $data = [], bool $force = false, ?int $ttl = null): array { $key = [$this->service_name, $this->userID, $endpoint, $data]; if (!$force) { $cached = $this->cache->get($key); if ($cached) { return $cached; } } $response = $this->makeOAuthRequest('GET', $endpoint, $data); if ($response && !is_wp_error($response)) { $ttl = is_int($ttl) ? $ttl : $this->oauthTTL; $this->cache->set($key, $response, $ttl); } return $response; } protected function postOAuthRequest(string $endpoint, array $data = []): array { return $this->makeOAuthRequest('POST', $endpoint, $data); } protected function putOAuthRequest(string $endpoint, array $data = []): array { return $this->makeOAuthRequest('PUT', $endpoint, $data); } protected function deleteOAuthRequest(string $endpoint, array $data = []): array { return $this->makeOAuthRequest('DELETE', $endpoint, $data); } protected function makeOAuthRequest(string $method, string $endpoint, array $data = []): array { if (is_null($this->baseOAuthUrl)) { error_log('Can not make request. No $baseOAuthUrl set for '.$this->service_name); return []; } if ($this->oauthLimiter && !$this->awaitOAuthRateLimit($endpoint)) { $error = new WP_Error('rate_limit_exceeded', "Rate limit exceeded for {$endpoint}"); return $this->handleOAuthResponse($error, $method, $endpoint); } $credentials = $this->ensureFreshToken(); if (!$credentials->isValid()) { $error = new WP_Error('invalid_credentials', "Missing or invalid OAuth credentials for {$this->service_name}"); return $this->handleOAuthResponse($error, $method, $endpoint); } $url = rtrim($this->baseOAuthUrl, '/') . '/' . ltrim($endpoint, '/'); $args = [ 'method' => $method, 'headers' => $this->getOAuthRequestHeaders(), 'timeout' => 15, ]; if (!empty($data)) { if (in_array($method, ['GET', 'DELETE'], true)) { $url = add_query_arg($data, $url); } else { $args['body'] = wp_json_encode($data); } } $response = wp_remote_request($url, $args); $this->oauthLimiter?->recordRequest($endpoint); return $this->handleOAuthResponse($response, $method, $endpoint); } /** * Checks stored credentials, refreshes via refreshAccessToken() if expired, * and persists the refreshed credentials back to Auth */ protected function ensureFreshToken(): OAuthCredentials { $auth = Auth::getInstance(); $credentials = new OAuthCredentials($auth->getCredentials($this->service_name, $this->userID)); if (!$credentials->isValid() || !$credentials->isExpired()) { return $credentials; } $refreshed = new OAuthCredentials($this->refreshAccessToken($credentials)); if ($refreshed->isValid()) { $auth->storeCredentials($this->service_name, $refreshed->toArray(), $this->userID); return $refreshed; } return $credentials; } public function hasValidOAuth():bool { $auth = Auth::getInstance(); $credentials = new OAuthCredentials($auth->getCredentials($this->service_name, $this->userID)); return $credentials->isValid(); } protected function awaitOAuthRateLimit(string $endpoint, int $maxAttempts = 3): bool { $attempts = 0; if (is_null($this->oauthLimiter)) { return false; } while (!$this->oauthLimiter->checkRateLimit($endpoint)) { $attempts++; if ($attempts >= $maxAttempts) { return false; } usleep(random_int(250_000, 750_000)); } return true; } /*************************************************************** * ACTIONS CALLBACKS ***************************************************************/ #[NoReturn] public function handleAjaxResponse():void { $code = $_GET['code']; $state = $_GET['state']; $state_parts = explode('|', $state); $state_key = $state_parts[0] ?? ''; $user_id = intval($state_parts[1] ?? 0); $user_id = ($user_id === 0) ? null : $user_id; $return_url = isset($state_parts[2]) ? base64_decode($state_parts[2]) : admin_url('admin.php?page=jvb-integrations'); $state_data = get_transient('oauth_state_' . $state_key); if (!$state_data || $state_data['service'] !== $this->service_name) { wp_die('Invalid state parameter', 'OAuth Error'); } // Delete the transient to prevent reuse delete_transient('oauth_state_' . $state_key); // Handle error from OAuth provider if (array_key_exists('error', $_GET)) { $error_description = $_GET['error_description'] ?? 'Authorization denied'; wp_redirect(add_query_arg([ 'error' => 'OAuth authorization denied: ' . $error_description ], $return_url)); exit; } // Get integration instance $integration = JVB()->connect($this->service_name, $user_id); if (!$integration) { wp_die('Invalid service: ' . esc_html($this->service_name), 'OAuth Error'); } // Exchange code for tokens $result = $integration->handleOAuthCode($code, $state); // Redirect back with result if ($result['success']) { wp_redirect(add_query_arg([ 'success' => 'Successfully connected to ' . $integration->title ], $return_url)); } else { // Handle failure $error_message = $result['message'] ?? 'Failed to complete OAuth authorization'; wp_redirect(add_query_arg([ 'error' => $error_message ], $return_url)); } exit; } protected function renderOAuthConnectionForm():string { $credentials = $this->loadCredentials(); $hasCredentials = $this->hasValidOAuth(); $returnURL = $this->getReturnURL(); if ($hasCredentials) { return sprintf( '

Connect to your %s account

%s Authorize Connection', $this->title, $this->getOAuthURL($returnURL), esc_attr($this->service_name), $this->outputIcon($this->icon) ); } $form = sprintf( '
Connected Account
Connected
OAuth Redirect URL: %s', $this->getRedirectUri() ); if (!empty($credentials['updated_at'])) { $form .= sprintf( '
Token expires: %s
', array_key_exists('expires_at', $credentials) ? human_time_diff($credentials['expires_at']) : 'Never' ); } // if (method_exists($this, 'renderOAuthConnectedOptions')) { $form .= $this->renderOAuthConnectedOptions(); } $form .= 'hasOAuth) { return ''; } $credentials = $this->loadCredentials(); if (empty($this->oauth['authorize'])) { $this->logError('getOAuthUrl', 'OAuth authorize URL not configured'); return ''; } $params = [ 'client_id' => $credentials['client_id']??$credentials['application_id']??$credentials['app_id']??'', 'redirect_url' => $this->getRedirectUri(), 'response_type' => 'code', 'scope' => implode(' ', $this->oauthScopes??[]) ]; $state_key = wp_generate_password(32, false); $user_id = $this->userID??0; //Store state data in transient, expiring in 10 minutes set_transient( BASE.'oauth_state_'.$state_key, [ 'service' => $this->service_name, 'user_id' => $user_id, 'created' => time() ], 600 ); $state_parts = [ $state_key, $user_id ]; $state_parts[] = base64_encode(is_null($return_url) ? $this->getReturnURL() : $return_url); $params['state'] = implode('|', $state_parts); //Allow extensions to modify params if (method_exists($this, 'addOAuthParams')) { $params = $this->addOAuthParams($params); } return $this->oauth['authorize'] . '?' . 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); } }