PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / gsc / GscOAuthTokenStore.php

GscOAuthTokenStore.php in 404 Solution trunk, at includes/gsc/GscOAuthTokenStore.php

303 lines 11.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 require_once __DIR__ . '/GscConfig.php';
7
8 /**
9 * Owns Google Search Console settings, OAuth URLs, token persistence, refresh,
10 * revocation, and OAuth error state.
11 */
12 class ABJ_404_Solution_GscOAuthTokenStore {
13 /** @return array{client_id: string, client_secret: string, site_url: string} */
14 public function getSettings(): array {
15 $raw = get_option(ABJ_404_Solution_GscConfig::OPTION_KEY, array());
16 if (!is_array($raw)) {
17 $raw = array();
18 }
19 return array(
20 'client_id' => isset($raw['client_id']) && is_string($raw['client_id']) ? $raw['client_id'] : '',
21 'client_secret' => isset($raw['client_secret']) && is_string($raw['client_secret']) ? $raw['client_secret'] : '',
22 'site_url' => isset($raw['site_url']) && is_string($raw['site_url']) ? $raw['site_url'] : home_url('/'),
23 );
24 }
25
26 /** @param array<string, mixed> $postData */
27 public function saveSettings(array $postData): string {
28 $clientId = isset($postData['gsc_client_id']) ? sanitize_text_field((string)(is_scalar($postData['gsc_client_id']) ? $postData['gsc_client_id'] : '')) : '';
29 $clientSecret = isset($postData['gsc_client_secret']) ? sanitize_text_field((string)(is_scalar($postData['gsc_client_secret']) ? $postData['gsc_client_secret'] : '')) : '';
30 $siteUrl = isset($postData['gsc_site_url']) ? esc_url_raw((string)(is_scalar($postData['gsc_site_url']) ? $postData['gsc_site_url'] : '')) : home_url('/');
31
32 update_option(ABJ_404_Solution_GscConfig::OPTION_KEY, array(
33 'client_id' => $clientId,
34 'client_secret' => $clientSecret,
35 'site_url' => $siteUrl,
36 ), false);
37
38 $this->clearLastOAuthError();
39 return '';
40 }
41
42 public function isCentralizedMode(): bool {
43 $s = $this->getSettings();
44 if ($s['client_id'] !== '' && $s['client_secret'] !== '') {
45 return false;
46 }
47 return true;
48 }
49
50 public function isConfigured(): bool {
51 if ($this->isCentralizedMode()) {
52 return true;
53 }
54 $s = $this->getSettings();
55 return $s['client_id'] !== '' && $s['client_secret'] !== '';
56 }
57
58 public function isAuthorized(): bool {
59 $token = $this->getStoredToken();
60 if ($token === false || $this->payloadString($token, 'access_token') === '') {
61 return false;
62 }
63 $expiresAt = $this->payloadInt($token, 'expires_at', 0);
64 if ($expiresAt > 0 && $expiresAt < abj_clock()->now()) {
65 return $this->refreshToken();
66 }
67 return true;
68 }
69 public function buildAuthUrl(): string {
70 if ($this->isCentralizedMode()) {
71 $nonce = wp_create_nonce('abj404_gsc_oauth');
72 $params = array(
73 'site_callback_url' => $this->getCallbackUrl(),
74 'nonce' => $nonce,
75 'callback_signing_secret' => $this->createCentralizedCallbackSecret($nonce),
76 'scope' => ABJ_404_Solution_GscConfig::SCOPE,
77 );
78 return ABJ_404_Solution_GscConfig::CENTRALIZED_AUTH_URL . '/authorize?' . http_build_query($params);
79 }
80
81 $s = $this->getSettings();
82 $params = array(
83 'client_id' => $s['client_id'],
84 'redirect_uri' => $this->getCallbackUrl(),
85 'response_type' => 'code',
86 'scope' => ABJ_404_Solution_GscConfig::SCOPE,
87 'access_type' => 'offline',
88 'prompt' => 'consent',
89 'state' => wp_create_nonce('abj404_gsc_oauth'),
90 );
91 return ABJ_404_Solution_GscConfig::OAUTH_AUTH_URL . '?' . http_build_query($params);
92 }
93 private function createCentralizedCallbackSecret(string $nonce): string {
94 $secret = function_exists('wp_generate_password')
95 ? wp_generate_password(64, false, false)
96 : bin2hex(random_bytes(32));
97
98 set_transient( // allow-cache-empty: OAuth callback signing secret is generated non-empty; storage is required for Worker HMAC verification.
99 ABJ_404_Solution_GscConfig::centralizedCallbackSecretTransientKey($nonce),
100 $secret,
101 ABJ_404_Solution_GscConfig::CENTRALIZED_CALLBACK_SECRET_TTL
102 );
103
104 return $secret;
105 }
106
107 public function getCallbackUrl(): string {
108 return admin_url('admin-ajax.php?action=abj404_gsc_oauth_callback');
109 }
110
111 public function storeCentralizedTokens(string $accessToken, string $refreshToken, int $expiresIn): void {
112 $token = array(
113 'access_token' => $accessToken,
114 'token_type' => 'Bearer',
115 'expires_at' => $expiresIn > 0 ? (abj_clock()->now() + $expiresIn - 60) : 0,
116 'refresh_token' => $refreshToken,
117 );
118 update_option(ABJ_404_Solution_GscConfig::TOKEN_OPTION_KEY, $token, false);
119 $this->clearLastOAuthError();
120 }
121
122 public function exchangeCodeForToken(string $code): string {
123 if ($this->isCentralizedMode()) {
124 return 'Code exchange is not used in centralized mode.';
125 }
126 $s = $this->getSettings();
127 $response = wp_remote_post(ABJ_404_Solution_GscConfig::OAUTH_TOKEN_URL, array(
128 'body' => array(
129 'code' => $code,
130 'client_id' => $s['client_id'],
131 'client_secret' => $s['client_secret'],
132 'redirect_uri' => $this->getCallbackUrl(),
133 'grant_type' => 'authorization_code',
134 ),
135 'timeout' => 15,
136 ));
137
138 if (is_wp_error($response)) {
139 return $response->get_error_message();
140 }
141
142 $body = $this->decodeJsonObject(wp_remote_retrieve_body($response));
143 if ($body === false || $this->payloadString($body, 'access_token') === '') {
144 $error = (is_array($body) && isset($body['error_description'])) ? $body['error_description'] : __('OAuth token exchange failed.', '404-solution');
145 return is_string($error) ? $error : __('OAuth token exchange failed.', '404-solution');
146 }
147
148 $token = array(
149 'access_token' => $this->payloadString($body, 'access_token'),
150 'token_type' => $this->payloadString($body, 'token_type', 'Bearer'),
151 'expires_at' => $this->expiresAtFromBody($body),
152 'refresh_token' => $this->payloadString($body, 'refresh_token'),
153 );
154 update_option(ABJ_404_Solution_GscConfig::TOKEN_OPTION_KEY, $token, false);
155 $this->clearLastOAuthError();
156 return '';
157 }
158
159 private function refreshToken(): bool {
160 $token = $this->getStoredToken();
161 if ($token === false || $this->payloadString($token, 'refresh_token') === '') {
162 return false;
163 }
164
165 if ($this->isCentralizedMode()) {
166 return $this->refreshTokenViaCentralized($token);
167 }
168
169 $s = $this->getSettings();
170 $response = wp_remote_post(ABJ_404_Solution_GscConfig::OAUTH_TOKEN_URL, array(
171 'body' => array(
172 'refresh_token' => $this->payloadString($token, 'refresh_token'),
173 'client_id' => $s['client_id'],
174 'client_secret' => $s['client_secret'],
175 'grant_type' => 'refresh_token',
176 ),
177 'timeout' => 15,
178 ));
179
180 if (is_wp_error($response)) {
181 return false;
182 }
183
184 $body = $this->decodeJsonObject(wp_remote_retrieve_body($response));
185 if ($body === false || $this->payloadString($body, 'access_token') === '') {
186 return false;
187 }
188
189 $token['access_token'] = $this->payloadString($body, 'access_token');
190 $token['expires_at'] = $this->expiresAtFromBody($body);
191 update_option(ABJ_404_Solution_GscConfig::TOKEN_OPTION_KEY, $token, false);
192 return true;
193 }
194
195 /** @param array<string, mixed> $token Current stored token array. */
196 private function refreshTokenViaCentralized(array $token): bool {
197 $response = wp_remote_post(ABJ_404_Solution_GscConfig::CENTRALIZED_AUTH_URL . '/refresh', array(
198 'headers' => array('Content-Type' => 'application/json'),
199 'body' => (string)wp_json_encode(array(
200 'refresh_token' => $this->payloadString($token, 'refresh_token'),
201 )),
202 'timeout' => 15,
203 ));
204
205 if (is_wp_error($response)) {
206 return false;
207 }
208
209 $body = $this->decodeJsonObject(wp_remote_retrieve_body($response));
210 if ($body === false || $this->payloadString($body, 'access_token') === '') {
211 return false;
212 }
213
214 $token['access_token'] = $this->payloadString($body, 'access_token');
215 $token['expires_at'] = $this->expiresAtFromBody($body);
216 update_option(ABJ_404_Solution_GscConfig::TOKEN_OPTION_KEY, $token, false);
217 return true;
218 }
219
220 public function revokeAuthorization(): void {
221 delete_option(ABJ_404_Solution_GscConfig::TOKEN_OPTION_KEY);
222 delete_option(ABJ_404_Solution_GscConfig::OPTION_KEY);
223 delete_transient(ABJ_404_Solution_GscConfig::TRANSIENT_KEY);
224 $this->clearLastOAuthError();
225 }
226
227 public function setLastOAuthError(string $message): void {
228 update_option(ABJ_404_Solution_GscConfig::ERROR_OPTION_KEY, $message, false);
229 }
230
231 public function getLastOAuthError(): string {
232 $v = get_option(ABJ_404_Solution_GscConfig::ERROR_OPTION_KEY, '');
233 return is_string($v) ? $v : '';
234 }
235
236 public function clearLastOAuthError(): void {
237 delete_option(ABJ_404_Solution_GscConfig::ERROR_OPTION_KEY);
238 }
239
240 public function getState(): string {
241 if (!$this->isConfigured()) {
242 return 'not_configured';
243 }
244 if ($this->isAuthorized()) {
245 return 'connected';
246 }
247 if ($this->getLastOAuthError() !== '') {
248 return 'error';
249 }
250 return 'configured_not_connected';
251 }
252
253 /** @return array<string, mixed>|false */
254 private function getStoredToken() {
255 $token = get_option(ABJ_404_Solution_GscConfig::TOKEN_OPTION_KEY, false);
256 if (!is_array($token)) {
257 return false;
258 }
259 return $this->normalizeStringKeyedArray($token);
260 }
261
262 /** @return array<string, mixed>|false */
263 private function decodeJsonObject(string $json) {
264 $decoded = json_decode($json, true);
265 if (!is_array($decoded)) {
266 return false;
267 }
268 return $this->normalizeStringKeyedArray($decoded);
269 }
270
271 /**
272 * @param array<mixed, mixed> $values
273 * @return array<string, mixed>
274 */
275 private function normalizeStringKeyedArray(array $values): array {
276 $normalized = array();
277 foreach ($values as $key => $value) {
278 if (is_string($key)) {
279 $normalized[$key] = $value;
280 }
281 }
282 return $normalized;
283 }
284
285 /** @param array<string, mixed> $payload */
286 private function payloadString(array $payload, string $key, string $default = ''): string {
287 $value = $payload[$key] ?? $default;
288 return is_scalar($value) ? (string)$value : $default;
289 }
290
291 /** @param array<string, mixed> $payload */
292 private function payloadInt(array $payload, string $key, int $default): int {
293 $value = $payload[$key] ?? null;
294 return is_numeric($value) ? (int)$value : $default;
295 }
296
297 /** @param array<string, mixed> $body */
298 private function expiresAtFromBody(array $body): int {
299 $expiresIn = $this->payloadInt($body, 'expires_in', 0);
300 return $expiresIn > 0 ? abj_clock()->now() + $expiresIn - 60 : 0;
301 }
302 }
303