PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 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 trunk, at includes/integrations/class-google-oauth-proxy.php

493 lines 17.2 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 // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- the OAuth proxy is an external host, which wp_safe_redirect() would refuse.
241 wp_redirect(add_query_arg(
242 ['action' => 'connect', 'state' => $state],
243 self::get_proxy_url()
244 ));
245 exit;
246 }
247
248 /**
249 * Handle the proxy's redirect back: verify, swap, store, return to admin.
250 *
251 * @return void
252 */
253 public function handle_callback(): void {
254 $flag = (string) get_query_var('thinkrank_google_auth');
255 if ('' === $flag) {
256 return;
257 }
258
259 // With the query-var return URL, a proxy that joins its params with '?'
260 // instead of '&' folds them into this var's value. Recover them so the
261 // flow still completes rather than failing as invalid_state.
262 $separator = strpos($flag, '?');
263 if (false !== $separator) {
264 parse_str(substr($flag, $separator + 1), $recovered);
265 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only merge; params are verified below.
266 $_GET = array_merge($recovered, $_GET);
267 }
268
269 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the
270 // nonce arrives as `thinkrank_g_state` and is verified below.
271 $nonce = isset($_GET['thinkrank_g_state']) ? sanitize_text_field(wp_unslash($_GET['thinkrank_g_state'])) : '';
272 $exchange_token = isset($_GET['thinkrank_g_exchange']) ? sanitize_text_field(wp_unslash($_GET['thinkrank_g_exchange'])) : '';
273 $error = isset($_GET['thinkrank_g_error']) ? sanitize_text_field(wp_unslash($_GET['thinkrank_g_error'])) : '';
274 // phpcs:enable WordPress.Security.NonceVerification.Recommended
275
276 $return_url = '';
277 if (!empty($nonce)) {
278 $stored = get_transient('thinkrank_google_oauth_' . $nonce);
279 if (is_string($stored) && !empty($stored)) {
280 $return_url = $stored;
281 }
282 delete_transient('thinkrank_google_oauth_' . $nonce);
283 }
284 if (empty($return_url)) {
285 $return_url = $this->default_return_url();
286 }
287
288 if (!empty($error)) {
289 $this->finish($return_url, ['thinkrank_google_error' => $error]);
290 }
291
292 if (empty($nonce) || !wp_verify_nonce($nonce, self::NONCE_ACTION)) {
293 $this->finish($return_url, ['thinkrank_google_error' => 'invalid_state']);
294 }
295
296 if (!current_user_can('manage_options')) {
297 $this->finish($return_url, ['thinkrank_google_error' => 'forbidden']);
298 }
299
300 if (empty($exchange_token)) {
301 $this->finish($return_url, ['thinkrank_google_error' => 'missing_token']);
302 }
303
304 $tokens = $this->exchange($exchange_token);
305 if (empty($tokens['access_token'])) {
306 $this->finish($return_url, ['thinkrank_google_error' => 'exchange_failed']);
307 }
308
309 $this->store_tokens($tokens);
310 $this->finish($return_url, ['thinkrank_google_connected' => '1']);
311 }
312
313 /**
314 * Swap the one-time exchange token for real credentials, server to server.
315 *
316 * @param string $exchange_token One-time token issued by the proxy.
317 * @return array Decoded proxy response, or an empty array on failure.
318 */
319 private function exchange(string $exchange_token): array {
320 $response = wp_remote_post(self::get_proxy_url(), [
321 'headers' => [
322 'Content-Type' => 'application/json',
323 'Accept' => 'application/json',
324 ],
325 'body' => wp_json_encode([
326 'action' => 'exchange',
327 'exchange_token' => $exchange_token,
328 'site' => home_url(),
329 ]),
330 'timeout' => 30,
331 ]);
332
333 if (is_wp_error($response)) {
334 return [];
335 }
336
337 $data = json_decode(wp_remote_retrieve_body($response), true);
338
339 return is_array($data) ? $data : [];
340 }
341
342 /**
343 * Persist the credentials returned by the proxy.
344 *
345 * @param array $tokens Proxy response.
346 * @return void
347 */
348 private function store_tokens(array $tokens): void {
349 $settings = [
350 'google_access_token' => $tokens['access_token'],
351 'google_token_created' => time(),
352 'google_token_expires_in' => (int) ($tokens['expires_in'] ?? 3600),
353 'google_account_connected' => true,
354 ];
355
356 if (!empty($tokens['refresh_token'])) {
357 $settings['google_refresh_token'] = $tokens['refresh_token'];
358 }
359
360 $this->settings_manager->update_settings($settings, 'integrations');
361
362 update_option('thinkrank_google_token_contract', self::TOKEN_CONTRACT_VERSION);
363 delete_option('thinkrank_google_reconnect_required');
364
365 // A fresh authorization may be a different Google account — the cached
366 // Search Console property list must not outlive the account that wrote it.
367 \ThinkRank\API\Integrations_Endpoint::purge_search_console_sites_cache();
368
369 // Settings memoizes reads, so a stale empty token from before the
370 // connect would otherwise trip the credential check on this request.
371 $this->settings_manager = new Settings_Manager();
372 }
373
374 /**
375 * Force sites connected under the old contract to re-authorize.
376 *
377 * Tokens obtained through the pre-proxy flow can't be refreshed against the
378 * new proxy contract, so they are cleared rather than left to fail silently
379 * on the next cron run.
380 *
381 * @return void
382 */
383 public function maybe_require_reconnect(): void {
384 $integrations = $this->settings_manager->get_settings('integrations');
385
386 // One-time migration off the pre-proxy token contract.
387 if (get_option('thinkrank_google_token_contract') !== self::TOKEN_CONTRACT_VERSION) {
388 if (!empty($integrations['google_account_connected']) || !empty($integrations['google_access_token'])) {
389 $this->clear_tokens('contract');
390 }
391
392 update_option('thinkrank_google_token_contract', self::TOKEN_CONTRACT_VERSION);
393
394 return;
395 }
396
397 // Credentials are encrypted with a key derived from wp_salt('auth').
398 // Rotating the salts — or restoring a database without the matching
399 // wp-config.php — leaves rows that can never be decrypted. Settings
400 // hands back an empty string in that case, so a connection that claims
401 // to be live but has no usable token is the signal. Without this the
402 // only symptom is Google returning 401 forever.
403 if (empty($integrations['google_account_connected'])) {
404 return;
405 }
406
407 if (empty($integrations['google_access_token']) || empty($integrations['google_refresh_token'])) {
408 $this->clear_tokens('credentials');
409 }
410 }
411
412 /**
413 * Drop credentials Google has refused for good.
414 *
415 * Called from the refresh path, which is static and has no instance to
416 * hand, so this is the public entry point onto clear_tokens().
417 *
418 * @return void
419 */
420 public static function mark_revoked(): void {
421 (new self())->clear_tokens('revoked');
422 }
423
424 /**
425 * Drop stored credentials and flag the site for re-authorization.
426 *
427 * @param string $reason Why the reconnect is needed — drives the notice copy.
428 * @return void
429 */
430 private function clear_tokens(string $reason): void {
431 $this->settings_manager->update_settings([
432 'google_access_token' => '',
433 'google_refresh_token' => '',
434 'google_token_expires_in' => '',
435 'google_token_created' => '',
436 'google_account_connected' => false,
437 ], 'integrations');
438
439 \ThinkRank\API\Integrations_Endpoint::purge_search_console_sites_cache();
440
441 update_option('thinkrank_google_reconnect_required', $reason);
442 }
443
444 /**
445 * Encode the state blob as URL-safe base64.
446 *
447 * @param array $payload State payload.
448 * @return string
449 */
450 private function encode_state(array $payload): string {
451 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- transport encoding, not obfuscation.
452 return rtrim(strtr(base64_encode((string) wp_json_encode($payload)), '+/', '-_'), '=');
453 }
454
455 /**
456 * Resolve and validate the admin URL to return to after the flow.
457 *
458 * @return string
459 */
460 private function resolve_return_url(): string {
461 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified by the caller.
462 $raw = isset($_GET['return']) ? rawurldecode(sanitize_text_field(wp_unslash($_GET['return']))) : '';
463
464 // Only ever return to this site's admin — never an attacker-supplied host.
465 if (!empty($raw) && 0 === strpos($raw, admin_url())) {
466 return $raw;
467 }
468
469 return $this->default_return_url();
470 }
471
472 /**
473 * The Google Services screen.
474 *
475 * @return string
476 */
477 private function default_return_url(): string {
478 return admin_url('admin.php?page=thinkrank-essential-seo&nav_section=integrations&nav_item=google-services');
479 }
480
481 /**
482 * Redirect back into the admin and stop.
483 *
484 * @param string $return_url Base URL.
485 * @param array $args Query args to append.
486 * @return void
487 */
488 private function finish(string $return_url, array $args): void {
489 wp_safe_redirect(add_query_arg($args, $return_url));
490 exit;
491 }
492 }
493