From 42fa8304ddb811b0f725f245130f70c0f5e86a6c Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 04 Nov 2025 06:12:02 +0000
Subject: [PATCH] =Refactored LoginManager to be more extensible and configurable, as well as an AjaxRateLimiter
---
inc/integrations/Integrations.php | 159 +++++++++++++++++++++++++++++++++++++++++++++++-----
1 files changed, 143 insertions(+), 16 deletions(-)
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index 76f0c56..e3ef859 100644
--- a/inc/integrations/Integrations.php
+++ b/inc/integrations/Integrations.php
@@ -35,6 +35,7 @@
protected array $apiEndpoints = []; // Valid endpoint paths for this service
protected string $apiVersion = ''; // API version string (e.g., 'v2', '2024-01-01')
+ protected int $refresh_interval = 0; //seconds before expiry to refresh tokens. 0 to disable
/**
* OAuth Configuration
@@ -166,7 +167,7 @@
{
$this->cacheName = $this->cacheName ?: $this->service_name;
$this->userID = $userID;
- $this->cache = new CacheManager('integrations_' . $this->cacheName, $this->ttl);
+ $this->cache = CacheManager::for('integrations_' . $this->cacheName, $this->ttl);
// Load error stats from cache
$this->loadErrorStats();
@@ -806,7 +807,9 @@
// Retry with backoff for server errors
if ($attempt < $this->maxRetries && !$this->isClientError($e)) {
- sleep($this->retryDelays[$attempt - 1] ?? 5);
+ $delay = pow(2, $attempt) * 1000000; // 2^attempt seconds in microseconds
+ $jitter = rand(0, $delay * 0.3); // Add 0-30% jitter
+ usleep($delay + $jitter);
} else {
break;
}
@@ -843,14 +846,14 @@
$this->logDebug("$method request to: $url: ".print_r($args, true));
- // Standard WordPress HTTP API
- // Use appropriate WordPress HTTP function
+ // Make the request
$response = match($method) {
'GET' => wp_remote_get($url, $args),
'POST' => wp_remote_post($url, $args),
'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
default => null
};
+
if (!$response) {
$this->logError("Unsupported HTTP method $method for $this->service_name");
return null;
@@ -864,9 +867,42 @@
$response_code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
+ // Handle 401 - try to refresh token and retry once
+ if ($response_code === 401 && $this->isOAuthService && !empty($this->credentials['refresh_token'])) {
+ // Avoid infinite retry loop - only retry once
+ static $retry_count = 0;
+
+ if ($retry_count === 0) {
+ $retry_count++;
+
+ $this->logDebug('Got 401, attempting token refresh...');
+ if ($this->refreshOAuthToken()) {
+ $this->logDebug('Token refreshed successfully, retrying request...');
+
+ // Rebuild request args with new token
+ $args = $this->buildRequestArgs($method, $data, $options);
+
+ // Retry the request
+ $response = match($method) {
+ 'GET' => wp_remote_get($url, $args),
+ 'POST' => wp_remote_post($url, $args),
+ 'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
+ default => null
+ };
+
+ if ($response && !is_wp_error($response)) {
+ $response_code = wp_remote_retrieve_response_code($response);
+ $body = wp_remote_retrieve_body($response);
+ }
+ }
+ $retry_count = 0; // Reset for next request
+ }
+ }
+
if ($response_code >= 400) {
$this->handleApiError($response_code, $body, $endpoint);
}
+
$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['raw_response' => $body];
@@ -901,7 +937,8 @@
$result = $this->makeRequest('GET', $endpoint, $params, $baseKey);
- if ($result && $ttl > 0) {
+ // Only cache successful responses (not WP_Error and not error objects)
+ if ($result && !is_wp_error($result) && !$this->isErrorResponse($result) && $ttl > 0) {
$this->cache->set($cacheKey, $result, $ttl);
}
@@ -909,6 +946,18 @@
}
/**
+ * Check if response contains an error
+ * Override in child classes for service-specific error detection
+ */
+ protected function isErrorResponse(array $response): bool
+ {
+ // Common error patterns across APIs
+ return isset($response['error'])
+ || isset($response['errors'])
+ || isset($response['error_description']);
+ }
+
+ /**
* POST request
*/
protected function postRequest(string $endpoint, array $data = [], ?string $baseKey = null): ?array
@@ -1341,10 +1390,21 @@
}
if (!empty($this->credentials)) {
- if ($this->isOAuthService && $this->hasOAuthCredentials() && !$this->isOAuthValid()) {
- $this->logDebug('OAuth token expired, attempting refresh');
- if (!$this->refreshOAuthToken()) {
- $this->logError('Failed to refresh OAuth token');
+ if ($this->isOAuthService && $this->hasOAuthCredentials()) {
+ // Check if token is expired first
+ if (!$this->isOAuthValid()) {
+ $this->logDebug('OAuth token expired, attempting refresh');
+ if (!$this->refreshOAuthToken()) {
+ $this->logError('Failed to refresh expired OAuth token');
+ }
+ }
+ // Check if we should proactively refresh (before expiry)
+ elseif ($this->shouldRefreshToken()) {
+ $this->logDebug('OAuth token should be refreshed proactively');
+ if (!$this->refreshOAuthToken()) {
+ $this->logError('Failed to proactively refresh OAuth token');
+ // Not critical - token is still valid
+ }
}
}
$this->initialize();
@@ -1744,12 +1804,7 @@
'redirect_uri' => $this->getRedirectUri()
];
- // Use a custom endpoint key for OAuth (not part of regular API)
- // We need to handle this specially since OAuth endpoints are different
$oauth_endpoint = $this->oauth['token'];
-
- // Make the request using the centralized method
- // This automatically includes rate limiting and error handling
$response = $this->makeOAuthRequest('POST', $oauth_endpoint, $request_data);
if (is_wp_error($response)) {
@@ -1762,10 +1817,13 @@
// Parse response
if (isset($response['access_token'])) {
+ $expires_in = $response['expires_in'] ?? 2592000; // 30 days default
+
return [
'access_token' => $response['access_token'],
'refresh_token' => $response['refresh_token'] ?? '',
- 'expires_in' => $response['expires_in'] ?? 2592000, // 30 days default
+ 'expires_in' => $expires_in,
+ 'expires_at' => time() + $expires_in, // Calculate expiry timestamp
'token_type' => $response['token_type'] ?? 'Bearer',
'merchant_id' => $response['merchant_id'] ?? '',
'scope' => $response['scope'] ?? ''
@@ -1786,6 +1844,75 @@
}
/**
+ * Check if token should be proactively refreshed
+ * Different from isOAuthValid() which checks if token is actually expired
+ */
+ protected function shouldRefreshToken(): bool
+ {
+ if (!$this->isOAuthService || $this->refresh_interval === 0) {
+ return false;
+ }
+
+ // If no expiry info, we can't proactively refresh
+ if (empty($this->credentials['expires_at'])) {
+ return false;
+ }
+
+ $expires_at = intval($this->credentials['expires_at']);
+ $time_until_expiry = $expires_at - time();
+
+ // Refresh if we're within the refresh interval window
+ return $time_until_expiry > 0 && $time_until_expiry <= $this->refresh_interval;
+ }
+ /**
+ * Get time until token refresh is recommended
+ * Useful for displaying in admin UI
+ */
+ public function getTimeUntilRefresh(): ?int
+ {
+ if ($this->refresh_interval === 0 || empty($this->credentials['expires_at'])) {
+ return null;
+ }
+
+ $expires_at = intval($this->credentials['expires_at']);
+ $refresh_at = $expires_at - $this->refresh_interval;
+ $time_until_refresh = $refresh_at - time();
+
+ return max(0, $time_until_refresh);
+ }
+
+ /**
+ * Get token freshness status
+ * Returns: 'fresh', 'should_refresh', 'expired', or 'no_expiry_info'
+ */
+ public function getTokenStatus(): string
+ {
+ if (!$this->isOAuthService) {
+ return 'not_oauth';
+ }
+
+ if (empty($this->credentials['access_token'])) {
+ return 'no_token';
+ }
+
+ if (empty($this->credentials['expires_at'])) {
+ return 'no_expiry_info';
+ }
+
+ $expires_at = intval($this->credentials['expires_at']);
+ $now = time();
+
+ if ($expires_at <= $now) {
+ return 'expired';
+ }
+
+ if ($this->shouldRefreshToken()) {
+ return 'should_refresh';
+ }
+
+ return 'fresh';
+ }
+ /**
* Refresh OAuth token
*/
protected function refreshOAuthToken(): bool
@@ -2981,7 +3108,7 @@
}
$credentials = $this->getCredentials();
$hasCredentials = $this->hasOAuthCredentials();
- $returnURL = (is_admin()) ? :get_the_permalink();
+ $returnURL = is_admin() ? admin_url('admin.php?page=jvb-integrations') : (get_the_permalink() ?: home_url());
?>
<details <?= $hasCredentials?' open':''?>>
--
Gitblit v1.10.0