<?php
|
namespace JVBase\integrations;
|
|
use JetBrains\PhpStorm\NoReturn;
|
use WP_Error;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
trait OAuth
|
{
|
use _Base, Admin;
|
protected bool $hasOAuth = true;
|
protected OAuthURLs $oauth;
|
/**
|
* Child classes must set this (e.g. 'https://api.example.com/v1/')
|
*/
|
protected ?string $baseOAuthUrl = null;
|
protected ?string $oauthRedirectUrl = null;
|
protected int $oauthTTL = 300;
|
protected array $oathScopes = [];
|
|
|
/**
|
* @return array Defaults to the base getRequestHeaders, but can optionally be overridden
|
*/
|
protected function getOAuthRequestHeaders(): array
|
{
|
return $this->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(
|
'<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>',
|
$this->title,
|
$this->getOAuthURL($returnURL),
|
esc_attr($this->service_name),
|
$this->outputIcon($this->icon)
|
);
|
}
|
$form = sprintf(
|
'<details open>
|
<summary>Connected Account
|
<div class="connection-status connected">
|
<span class="status-indicator">●</span>
|
<span>Connected</span>
|
</div>
|
</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) ?
|
human_time_diff($credentials['expires_at']) :
|
'Never'
|
);
|
}
|
//
|
if (method_exists($this, 'renderOAuthConnectedOptions')) {
|
$form .= $this->renderOAuthConnectedOptions();
|
}
|
$form .= '</details';
|
return $form;
|
}
|
|
/**
|
* @return string Outputs additional information after the basic OAuth connection
|
*/
|
protected function renderOAuthConnectedOptions():string
|
{
|
return '';
|
}
|
|
public function getOAuthURL(?string $return_url = null):string
|
{
|
if (!$this->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);
|
}
|
}
|