| 1 |
<?php |
| 2 |
|
| 3 |
namespace IvyForms\Services\GoogleSheets\Helpers; |
| 4 |
|
| 5 |
/** |
| 6 |
* Creates and validates one-time OAuth state tokens for Google Sheets connect flows. |
| 7 |
*/ |
| 8 |
class GoogleSheetsOAuthStateHelper |
| 9 |
{ |
| 10 |
private const TRANSIENT_PREFIX = 'ivyforms_gs_oauth_'; |
| 11 |
private const TTL_SECONDS = 900; |
| 12 |
|
| 13 |
public static function createState(): string |
| 14 |
{ |
| 15 |
$siteUrl = self::getSiteUrl(); |
| 16 |
$userId = get_current_user_id(); |
| 17 |
$nonce = wp_generate_password(32, false, false); |
| 18 |
$token = wp_hash($nonce . '|' . $userId . '|' . $siteUrl); |
| 19 |
|
| 20 |
set_transient( |
| 21 |
self::TRANSIENT_PREFIX . $token, |
| 22 |
[ |
| 23 |
'user_id' => $userId, |
| 24 |
'site' => $siteUrl, |
| 25 |
'nonce' => $nonce, |
| 26 |
], |
| 27 |
self::TTL_SECONDS |
| 28 |
); |
| 29 |
|
| 30 |
return $siteUrl . '|' . $token; |
| 31 |
} |
| 32 |
|
| 33 |
public static function validateState(string $state): bool |
| 34 |
{ |
| 35 |
$parsed = self::parseState($state); |
| 36 |
|
| 37 |
if ($parsed === null) { |
| 38 |
return false; |
| 39 |
} |
| 40 |
|
| 41 |
[$token, $payload] = $parsed; |
| 42 |
|
| 43 |
if (!self::isPayloadValid($payload) || !self::isUserAllowed($payload)) { |
| 44 |
return false; |
| 45 |
} |
| 46 |
|
| 47 |
delete_transient(self::TRANSIENT_PREFIX . $token); |
| 48 |
|
| 49 |
return true; |
| 50 |
} |
| 51 |
|
| 52 |
/** |
| 53 |
* @return array{0: string, 1: array<string, mixed>}|null |
| 54 |
*/ |
| 55 |
private static function parseState(string $state): ?array |
| 56 |
{ |
| 57 |
$state = trim($state); |
| 58 |
|
| 59 |
if ($state === '') { |
| 60 |
return null; |
| 61 |
} |
| 62 |
|
| 63 |
$parts = explode('|', $state, 2); |
| 64 |
|
| 65 |
if (count($parts) !== 2) { |
| 66 |
return null; |
| 67 |
} |
| 68 |
|
| 69 |
[$siteUrl, $token] = $parts; |
| 70 |
|
| 71 |
if ($siteUrl !== self::getSiteUrl() || $token === '') { |
| 72 |
return null; |
| 73 |
} |
| 74 |
|
| 75 |
$payload = get_transient(self::TRANSIENT_PREFIX . $token); |
| 76 |
|
| 77 |
if (!is_array($payload)) { |
| 78 |
return null; |
| 79 |
} |
| 80 |
|
| 81 |
return [$token, $payload]; |
| 82 |
} |
| 83 |
|
| 84 |
/** |
| 85 |
* @param array<string, mixed> $payload |
| 86 |
*/ |
| 87 |
private static function isPayloadValid(array $payload): bool |
| 88 |
{ |
| 89 |
return ($payload['site'] ?? '') === self::getSiteUrl(); |
| 90 |
} |
| 91 |
|
| 92 |
/** |
| 93 |
* @param array<string, mixed> $payload |
| 94 |
*/ |
| 95 |
private static function isUserAllowed(array $payload): bool |
| 96 |
{ |
| 97 |
$expectedUserId = (int) ($payload['user_id'] ?? 0); |
| 98 |
$currentUserId = get_current_user_id(); |
| 99 |
|
| 100 |
// The one-time transient is the primary CSRF guard for this public callback. |
| 101 |
// Cookie auth may be missing when the callback host differs from the admin host |
| 102 |
// (e.g. localhost vs ivy-cursor.test); only enforce user match when both are known. |
| 103 |
return !($expectedUserId > 0 && $currentUserId > 0 && $currentUserId !== $expectedUserId); |
| 104 |
} |
| 105 |
|
| 106 |
private static function getSiteUrl(): string |
| 107 |
{ |
| 108 |
if (defined('IVYFORMS_SITE_URL')) { |
| 109 |
return (string) IVYFORMS_SITE_URL; |
| 110 |
} |
| 111 |
|
| 112 |
return (string) home_url(); |
| 113 |
} |
| 114 |
} |
| 115 |
|