PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.28.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.28.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / integrations / class-google-oauth-proxy.php

class-google-oauth-proxy.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.28.0, at includes/integrations/class-google-oauth-proxy.php

492 lines 17.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Google OAuth Proxy Class
4 *
5 * Drives the whole Google connect flow through a ThinkRank-hosted proxy so the
6 * plugin never ships Google app credentials — not even the public client ID —
7 * and access/refresh tokens never travel through the browser.
8 *
9 * Round trip:
10 * 1. Admin clicks Connect → admin-post.php?action=thinkrank_google_connect
11 * 2. We redirect to the proxy with ?action=connect&state=<base64 {r,n}>
12 * 3. Proxy owns client_id/client_secret, runs the Google consent + code
13 * exchange, then redirects the browser back to state.r with a one-time
14 * exchange token (never the access token).
15 * 4. Our front-end callback route swaps that token server-to-server for the
16 * real tokens and stores them encrypted.
17 *
18 * @package ThinkRank\Integrations
19 * @since 1.20.0
20 */
21
22 declare(strict_types=1);
23
24 namespace ThinkRank\Integrations;
25
26 use ThinkRank\Core\Settings_Manager;
27
28 // Prevent direct access
29 if (!defined('ABSPATH')) {
30 exit;
31 }
32
33 /**
34 * Google OAuth Proxy Class
35 *
36 * Single Responsibility: own the browser-facing half of the Google OAuth flow.
37 * Token refresh lives in Analytics_Manager and calls this class only for the
38 * proxy URL.
39 *
40 * @since 1.20.0
41 */
42 class Google_OAuth_Proxy {
43
44 /**
45 * Default proxy endpoint. Single URL, dispatched by an `action` field:
46 * `connect` (GET, redirect), `exchange` (POST, JSON), `refresh` (POST, JSON).
47 */
48 private const DEFAULT_PROXY_URL = 'https://api.thinkrank.ai/v1/callback.php';
49
50 /**
51 * Nonce action guarding the whole flow (start → callback).
52 */
53 private const NONCE_ACTION = 'thinkrank_google_oauth';
54
55 /**
56 * Bumped whenever the rewrite rules change, to force a one-time flush.
57 */
58 private const REWRITE_VERSION = '1';
59
60 /**
61 * Bumped when the token contract changes in a way that invalidates stored
62 * credentials, forcing connected sites to re-authorize.
63 */
64 private const TOKEN_CONTRACT_VERSION = '2';
65
66 /**
67 * Settings Manager instance
68 *
69 * @var Settings_Manager
70 */
71 private Settings_Manager $settings_manager;
72
73 /**
74 * Constructor
75 *
76 * @param Settings_Manager|null $settings_manager Settings manager instance.
77 */
78 public function __construct(?Settings_Manager $settings_manager = null) {
79 $this->settings_manager = $settings_manager ?? new Settings_Manager();
80 }
81
82 /**
83 * Initialize hooks
84 *
85 * @return void
86 */
87 public function init(): void {
88 add_action('init', [$this, 'register_rewrites']);
89 add_action('update_option_permalink_structure', [$this, 'reset_rewrite_version']);
90 add_filter('query_vars', [$this, 'register_query_var']);
91 add_action('template_redirect', [$this, 'handle_callback']);
92 add_action('admin_post_thinkrank_google_connect', [$this, 'handle_connect']);
93 add_action('admin_init', [$this, 'maybe_require_reconnect']);
94 }
95
96 /**
97 * Resolve the proxy base URL.
98 *
99 * Overridable so staging installs can point at a test proxy without a
100 * plugin release.
101 *
102 * @return string Proxy URL without a trailing slash.
103 */
104 public static function get_proxy_url(): string {
105 $proxy = defined('THINKRANK_GOOGLE_OAUTH_PROXY')
106 ? THINKRANK_GOOGLE_OAUTH_PROXY
107 : self::DEFAULT_PROXY_URL;
108
109 return untrailingslashit(apply_filters('thinkrank_google_oauth_proxy_url', $proxy));
110 }
111
112 /**
113 * Register the front-end callback route.
114 *
115 * A front-end rewrite (rather than admin-ajax) keeps the return URL stable
116 * and free of query args, so the proxy can append its own params safely.
117 *
118 * WordPress only evaluates rewrite rules when a permalink structure is set,
119 * so on Plain-permalink sites the rule (and its flush) would be dead weight —
120 * get_redirect_uri() routes through the query var there instead.
121 *
122 * @return void
123 */
124 public function register_rewrites(): void {
125 if ($this->is_plain_permalinks()) {
126 return;
127 }
128
129 add_rewrite_rule('^thinkrank-google-auth/?$', 'index.php?thinkrank_google_auth=1', 'top');
130
131 if (get_option('thinkrank_google_rewrite_version') !== self::REWRITE_VERSION) {
132 flush_rewrite_rules(false);
133 update_option('thinkrank_google_rewrite_version', self::REWRITE_VERSION);
134 }
135 }
136
137 /**
138 * Drop the stored rewrite version when the permalink structure changes.
139 *
140 * Without this, a site switching from Plain to pretty permalinks would keep
141 * the already-current version marker and never re-register/flush our rule.
142 *
143 * @return void
144 */
145 public function reset_rewrite_version(): void {
146 delete_option('thinkrank_google_rewrite_version');
147 }
148
149 /**
150 * Whether the site runs on Plain permalinks (no rewrite rules evaluated).
151 *
152 * @return bool
153 */
154 private function is_plain_permalinks(): bool {
155 return '' === (string) get_option('permalink_structure');
156 }
157
158 /**
159 * Register the callback query var.
160 *
161 * @param array $vars Registered query vars.
162 * @return array
163 */
164 public function register_query_var(array $vars): array {
165 $vars[] = 'thinkrank_google_auth';
166 return $vars;
167 }
168
169 /**
170 * The URL the proxy redirects the browser back to.
171 *
172 * The pretty path only resolves through the registered rewrite rule, which
173 * WordPress skips entirely on Plain permalinks — so fall back to the raw
174 * query var there. The query var is registered either way, so the callback
175 * handler works unchanged.
176 *
177 * @return string
178 */
179 public function get_redirect_uri(): string {
180 if ($this->is_plain_permalinks()) {
181 return home_url('/?thinkrank_google_auth=1');
182 }
183
184 return home_url('/thinkrank-google-auth/');
185 }
186
187 /**
188 * Build the admin-facing "Connect with Google" URL.
189 *
190 * This points at admin-post.php, not at Google — the consent URL (client_id,
191 * scopes, redirect_uri) is assembled by the proxy, never by the plugin.
192 *
193 * @param string $return_url Admin URL to land on when the flow completes.
194 * @return string
195 */
196 public static function get_connect_url(string $return_url = ''): string {
197 // Built with add_query_arg rather than wp_nonce_url() because the latter
198 // HTML-escapes the ampersands, which breaks the URL once it is handed to
199 // JavaScript rather than printed into markup.
200 $args = [
201 'action' => 'thinkrank_google_connect',
202 '_wpnonce' => wp_create_nonce('thinkrank_google_connect'),
203 ];
204
205 if (!empty($return_url)) {
206 $args['return'] = rawurlencode($return_url);
207 }
208
209 return add_query_arg($args, admin_url('admin-post.php'));
210 }
211
212 /**
213 * Start the flow: stash the return URL, then bounce to the proxy.
214 *
215 * @return void
216 */
217 public function handle_connect(): void {
218 if (!current_user_can('manage_options')) {
219 wp_die(esc_html__('You do not have permission to connect a Google account.', 'thinkrank'), '', ['response' => 403]);
220 }
221
222 check_admin_referer('thinkrank_google_connect');
223
224 // The nonce doubles as the transient key, so only the admin who started
225 // the flow can finish it — and only within the nonce lifetime.
226 $nonce = wp_create_nonce(self::NONCE_ACTION);
227
228 set_transient(
229 'thinkrank_google_oauth_' . $nonce,
230 $this->resolve_return_url(),
231 15 * MINUTE_IN_SECONDS
232 );
233
234 $state = $this->encode_state([
235 'r' => $this->get_redirect_uri(),
236 'n' => $nonce,
237 ]);
238
239 // External host, so wp_safe_redirect() is not applicable here.
240 wp_redirect(add_query_arg(
241 ['action' => 'connect', 'state' => $state],
242 self::get_proxy_url()
243 ));
244 exit;
245 }
246
247 /**
248 * Handle the proxy's redirect back: verify, swap, store, return to admin.
249 *
250 * @return void
251 */
252 public function handle_callback(): void {
253 $flag = (string) get_query_var('thinkrank_google_auth');
254 if ('' === $flag) {
255 return;
256 }
257
258 // With the query-var return URL, a proxy that joins its params with '?'
259 // instead of '&' folds them into this var's value. Recover them so the
260 // flow still completes rather than failing as invalid_state.
261 $separator = strpos($flag, '?');
262 if (false !== $separator) {
263 parse_str(substr($flag, $separator + 1), $recovered);
264 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only merge; params are verified below.
265 $_GET = array_merge($recovered, $_GET);
266 }
267
268 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the
269 // nonce arrives as `thinkrank_g_state` and is verified below.
270 $nonce = isset($_GET['thinkrank_g_state']) ? sanitize_text_field(wp_unslash($_GET['thinkrank_g_state'])) : '';
271 $exchange_token = isset($_GET['thinkrank_g_exchange']) ? sanitize_text_field(wp_unslash($_GET['thinkrank_g_exchange'])) : '';
272 $error = isset($_GET['thinkrank_g_error']) ? sanitize_text_field(wp_unslash($_GET['thinkrank_g_error'])) : '';
273 // phpcs:enable WordPress.Security.NonceVerification.Recommended
274
275 $return_url = '';
276 if (!empty($nonce)) {
277 $stored = get_transient('thinkrank_google_oauth_' . $nonce);
278 if (is_string($stored) && !empty($stored)) {
279 $return_url = $stored;
280 }
281 delete_transient('thinkrank_google_oauth_' . $nonce);
282 }
283 if (empty($return_url)) {
284 $return_url = $this->default_return_url();
285 }
286
287 if (!empty($error)) {
288 $this->finish($return_url, ['thinkrank_google_error' => $error]);
289 }
290
291 if (empty($nonce) || !wp_verify_nonce($nonce, self::NONCE_ACTION)) {
292 $this->finish($return_url, ['thinkrank_google_error' => 'invalid_state']);
293 }
294
295 if (!current_user_can('manage_options')) {
296 $this->finish($return_url, ['thinkrank_google_error' => 'forbidden']);
297 }
298
299 if (empty($exchange_token)) {
300 $this->finish($return_url, ['thinkrank_google_error' => 'missing_token']);
301 }
302
303 $tokens = $this->exchange($exchange_token);
304 if (empty($tokens['access_token'])) {
305 $this->finish($return_url, ['thinkrank_google_error' => 'exchange_failed']);
306 }
307
308 $this->store_tokens($tokens);
309 $this->finish($return_url, ['thinkrank_google_connected' => '1']);
310 }
311
312 /**
313 * Swap the one-time exchange token for real credentials, server to server.
314 *
315 * @param string $exchange_token One-time token issued by the proxy.
316 * @return array Decoded proxy response, or an empty array on failure.
317 */
318 private function exchange(string $exchange_token): array {
319 $response = wp_remote_post(self::get_proxy_url(), [
320 'headers' => [
321 'Content-Type' => 'application/json',
322 'Accept' => 'application/json',
323 ],
324 'body' => wp_json_encode([
325 'action' => 'exchange',
326 'exchange_token' => $exchange_token,
327 'site' => home_url(),
328 ]),
329 'timeout' => 30,
330 ]);
331
332 if (is_wp_error($response)) {
333 return [];
334 }
335
336 $data = json_decode(wp_remote_retrieve_body($response), true);
337
338 return is_array($data) ? $data : [];
339 }
340
341 /**
342 * Persist the credentials returned by the proxy.
343 *
344 * @param array $tokens Proxy response.
345 * @return void
346 */
347 private function store_tokens(array $tokens): void {
348 $settings = [
349 'google_access_token' => $tokens['access_token'],
350 'google_token_created' => time(),
351 'google_token_expires_in' => (int) ($tokens['expires_in'] ?? 3600),
352 'google_account_connected' => true,
353 ];
354
355 if (!empty($tokens['refresh_token'])) {
356 $settings['google_refresh_token'] = $tokens['refresh_token'];
357 }
358
359 $this->settings_manager->update_settings($settings, 'integrations');
360
361 update_option('thinkrank_google_token_contract', self::TOKEN_CONTRACT_VERSION);
362 delete_option('thinkrank_google_reconnect_required');
363
364 // A fresh authorization may be a different Google account — the cached
365 // Search Console property list must not outlive the account that wrote it.
366 \ThinkRank\API\Integrations_Endpoint::purge_search_console_sites_cache();
367
368 // Settings memoizes reads, so a stale empty token from before the
369 // connect would otherwise trip the credential check on this request.
370 $this->settings_manager = new Settings_Manager();
371 }
372
373 /**
374 * Force sites connected under the old contract to re-authorize.
375 *
376 * Tokens obtained through the pre-proxy flow can't be refreshed against the
377 * new proxy contract, so they are cleared rather than left to fail silently
378 * on the next cron run.
379 *
380 * @return void
381 */
382 public function maybe_require_reconnect(): void {
383 $integrations = $this->settings_manager->get_settings('integrations');
384
385 // One-time migration off the pre-proxy token contract.
386 if (get_option('thinkrank_google_token_contract') !== self::TOKEN_CONTRACT_VERSION) {
387 if (!empty($integrations['google_account_connected']) || !empty($integrations['google_access_token'])) {
388 $this->clear_tokens('contract');
389 }
390
391 update_option('thinkrank_google_token_contract', self::TOKEN_CONTRACT_VERSION);
392
393 return;
394 }
395
396 // Credentials are encrypted with a key derived from wp_salt('auth').
397 // Rotating the salts — or restoring a database without the matching
398 // wp-config.php — leaves rows that can never be decrypted. Settings
399 // hands back an empty string in that case, so a connection that claims
400 // to be live but has no usable token is the signal. Without this the
401 // only symptom is Google returning 401 forever.
402 if (empty($integrations['google_account_connected'])) {
403 return;
404 }
405
406 if (empty($integrations['google_access_token']) || empty($integrations['google_refresh_token'])) {
407 $this->clear_tokens('credentials');
408 }
409 }
410
411 /**
412 * Drop credentials Google has refused for good.
413 *
414 * Called from the refresh path, which is static and has no instance to
415 * hand, so this is the public entry point onto clear_tokens().
416 *
417 * @return void
418 */
419 public static function mark_revoked(): void {
420 (new self())->clear_tokens('revoked');
421 }
422
423 /**
424 * Drop stored credentials and flag the site for re-authorization.
425 *
426 * @param string $reason Why the reconnect is needed — drives the notice copy.
427 * @return void
428 */
429 private function clear_tokens(string $reason): void {
430 $this->settings_manager->update_settings([
431 'google_access_token' => '',
432 'google_refresh_token' => '',
433 'google_token_expires_in' => '',
434 'google_token_created' => '',
435 'google_account_connected' => false,
436 ], 'integrations');
437
438 \ThinkRank\API\Integrations_Endpoint::purge_search_console_sites_cache();
439
440 update_option('thinkrank_google_reconnect_required', $reason);
441 }
442
443 /**
444 * Encode the state blob as URL-safe base64.
445 *
446 * @param array $payload State payload.
447 * @return string
448 */
449 private function encode_state(array $payload): string {
450 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- transport encoding, not obfuscation.
451 return rtrim(strtr(base64_encode((string) wp_json_encode($payload)), '+/', '-_'), '=');
452 }
453
454 /**
455 * Resolve and validate the admin URL to return to after the flow.
456 *
457 * @return string
458 */
459 private function resolve_return_url(): string {
460 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified by the caller.
461 $raw = isset($_GET['return']) ? rawurldecode(sanitize_text_field(wp_unslash($_GET['return']))) : '';
462
463 // Only ever return to this site's admin — never an attacker-supplied host.
464 if (!empty($raw) && 0 === strpos($raw, admin_url())) {
465 return $raw;
466 }
467
468 return $this->default_return_url();
469 }
470
471 /**
472 * The Google Services screen.
473 *
474 * @return string
475 */
476 private function default_return_url(): string {
477 return admin_url('admin.php?page=thinkrank-essential-seo&nav_section=integrations&nav_item=google-services');
478 }
479
480 /**
481 * Redirect back into the admin and stop.
482 *
483 * @param string $return_url Base URL.
484 * @param array $args Query args to append.
485 * @return void
486 */
487 private function finish(string $return_url, array $args): void {
488 wp_safe_redirect(add_query_arg($args, $return_url));
489 exit;
490 }
491 }
492