PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / trunk
Yoast SEO – Advanced SEO with real-time guidance and built-in AI vtrunk
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / deprecated / src / ai-authorization / application / token-manager.php

token-manager.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI trunk, at src/deprecated/src/ai-authorization/application/token-manager.php

418 lines 14.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Yoast\WP\SEO\AI_Authorization\Application;
4
5 use RuntimeException;
6 use WP_User;
7 use WPSEO_Utils;
8 use Yoast\WP\SEO\AI_Authorization\Infrastructure\Access_Token_User_Meta_Repository;
9 use Yoast\WP\SEO\AI_Authorization\Infrastructure\Code_Verifier_User_Meta_Repository;
10 use Yoast\WP\SEO\AI_Authorization\Infrastructure\Refresh_Token_User_Meta_Repository;
11 use Yoast\WP\SEO\AI_Generator\Infrastructure\WordPress_URLs;
12 use Yoast\WP\SEO\AI_HTTP_Request\Application\Request_Handler;
13 use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Bad_Request_Exception;
14 use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Forbidden_Exception;
15 use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception;
16 use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Not_Found_Exception;
17 use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Payment_Required_Exception;
18 use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Request_Timeout_Exception;
19 use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Service_Unavailable_Exception;
20 use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Too_Many_Requests_Exception;
21 use Yoast\WP\SEO\AI_HTTP_Request\Domain\Exceptions\Unauthorized_Exception;
22 use Yoast\WP\SEO\AI_HTTP_Request\Domain\Request;
23 use Yoast\WP\SEO\Helpers\User_Helper;
24
25 /**
26 * Class Token_Manager
27 * Handles the management of JWT tokens used in the authorization process.
28 *
29 * @deprecated 28.4
30 * @codeCoverageIgnore
31 *
32 * @makePublic
33 */
34 class Token_Manager implements Token_Manager_Interface {
35
36 /**
37 * The access token repository.
38 *
39 * @var Access_Token_User_Meta_Repository
40 */
41 private $access_token_repository;
42
43 /**
44 * The code verifier service.
45 *
46 * @var Code_Verifier_Handler
47 */
48 private $code_verifier;
49
50 /**
51 * The refresh token repository.
52 *
53 * @var Refresh_Token_User_Meta_Repository
54 */
55 private $refresh_token_repository;
56
57 /**
58 * The user helper.
59 *
60 * @var User_Helper
61 */
62 private $user_helper;
63
64 /**
65 * The code verifier repository.
66 *
67 * @var Code_Verifier_User_Meta_Repository
68 */
69 private $code_verifier_repository;
70
71 /**
72 * The URLs service.
73 *
74 * @var WordPress_URLs
75 */
76 private $urls;
77
78 /**
79 * The request handler.
80 *
81 * @var Request_Handler
82 */
83 private $request_handler;
84
85 /**
86 * Token_Manager constructor.
87 *
88 * @deprecated 28.4
89 * @codeCoverageIgnore
90 *
91 * @param Access_Token_User_Meta_Repository $access_token_repository The access token repository.
92 * @param Code_Verifier_Handler $code_verifier The code verifier service.
93 * @param Refresh_Token_User_Meta_Repository $refresh_token_repository The refresh token repository.
94 * @param User_Helper $user_helper The user helper.
95 * @param Request_Handler $request_handler The request handler.
96 * @param Code_Verifier_User_Meta_Repository $code_verifier_repository The code verifier repository.
97 * @param WordPress_URLs $urls The URLs service.
98 */
99 public function __construct(
100 Access_Token_User_Meta_Repository $access_token_repository,
101 Code_Verifier_Handler $code_verifier,
102 Refresh_Token_User_Meta_Repository $refresh_token_repository,
103 User_Helper $user_helper,
104 Request_Handler $request_handler,
105 Code_Verifier_User_Meta_Repository $code_verifier_repository,
106 WordPress_URLs $urls
107 ) {
108 \_deprecated_function( __METHOD__, 'Yoast SEO 28.4' );
109 $this->access_token_repository = $access_token_repository;
110 $this->code_verifier = $code_verifier;
111 $this->refresh_token_repository = $refresh_token_repository;
112 $this->user_helper = $user_helper;
113 $this->request_handler = $request_handler;
114 $this->code_verifier_repository = $code_verifier_repository;
115 $this->urls = $urls;
116 }
117
118 // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods.
119
120 /**
121 * Invalidates the access token.
122 *
123 * The locally stored JWTs are always cleared, even when the remote invalidation fails — the
124 * remote exception still propagates to the caller, but no credentials are left behind.
125 *
126 * @deprecated 28.4
127 * @codeCoverageIgnore
128 *
129 * @param int $user_id The user ID.
130 *
131 * @return void
132 *
133 * @throws Bad_Request_Exception Bad_Request_Exception.
134 * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
135 * @throws Not_Found_Exception Not_Found_Exception.
136 * @throws Payment_Required_Exception Payment_Required_Exception.
137 * @throws Request_Timeout_Exception Request_Timeout_Exception.
138 * @throws Service_Unavailable_Exception Service_Unavailable_Exception.
139 * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
140 * @throws RuntimeException Unable to retrieve the access token.
141 */
142 public function token_invalidate( int $user_id ): void {
143 \_deprecated_function( __METHOD__, 'Yoast SEO 28.4' );
144 try {
145 $access_jwt = $this->access_token_repository->get_token( $user_id );
146 } catch ( RuntimeException $e ) {
147 $access_jwt = '';
148 }
149
150 $request_headers = [
151 'Authorization' => "Bearer $access_jwt",
152 ];
153
154 try {
155 // The endpoint takes no request body; the user is identified by the access token.
156 $this->request_handler->handle(
157 new Request(
158 '/token/invalidate',
159 [],
160 $request_headers,
161 ),
162 );
163 } catch ( Unauthorized_Exception |Forbidden_Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Reason: Ignored on purpose.
164 // If the credentials in our request were already invalid, our job is done and we continue to remove the tokens client-side.
165 } finally {
166 // Always clear the local tokens, even when the remote invalidation fails with an exception
167 // that propagates: leaving credentials behind would contradict the intent of invalidating.
168 $this->clear_tokens( $user_id );
169 }
170 }
171
172 /**
173 * Clears the user meta tokens for a specific user.
174 *
175 * @deprecated 28.4
176 * @codeCoverageIgnore
177 *
178 * @param int $user_id The user id to delete this for.
179 *
180 * @return void
181 */
182 public function clear_tokens( int $user_id ): void {
183 \_deprecated_function( __METHOD__, 'Yoast SEO 28.4' );
184 $this->access_token_repository->delete_token( $user_id );
185 $this->refresh_token_repository->delete_token( $user_id );
186 }
187
188 /**
189 * Checks whether any JWT (access or refresh) is stored locally for the user.
190 *
191 * @deprecated 28.4
192 * @codeCoverageIgnore
193 *
194 * @param int $user_id The user ID.
195 *
196 * @return bool Whether a locally stored JWT exists.
197 */
198 public function has_local_tokens( int $user_id ): bool {
199 \_deprecated_function( __METHOD__, 'Yoast SEO 28.4' );
200 try {
201 $this->access_token_repository->get_token( $user_id );
202
203 return true;
204 } catch ( RuntimeException $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Reason: Ignored on purpose.
205 // No access token; fall through to the refresh token check.
206 }
207
208 try {
209 $this->refresh_token_repository->get_token( $user_id );
210
211 return true;
212 } catch ( RuntimeException $e ) {
213 return false;
214 }
215 }
216
217 /**
218 * Requests a new set of JWT tokens.
219 *
220 * Requests a new JWT access and refresh token for a user from the Yoast AI Service and stores it in the database
221 * under usermeta. The storing of the token happens in a HTTP callback that is triggered by this request.
222 *
223 * @deprecated 28.4
224 * @codeCoverageIgnore
225 *
226 * @param WP_User $user The WP user.
227 *
228 * @return void
229 *
230 * @throws Bad_Request_Exception Bad_Request_Exception.
231 * @throws Forbidden_Exception Forbidden_Exception.
232 * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
233 * @throws Not_Found_Exception Not_Found_Exception.
234 * @throws Payment_Required_Exception Payment_Required_Exception.
235 * @throws Request_Timeout_Exception Request_Timeout_Exception.
236 * @throws Service_Unavailable_Exception Service_Unavailable_Exception.
237 * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
238 * @throws Unauthorized_Exception Unauthorized_Exception.
239 */
240 public function token_request( WP_User $user ): void {
241 \_deprecated_function( __METHOD__, 'Yoast SEO 28.4' );
242 // Generate a code verifier and store it in the database.
243 $code_verifier = $this->code_verifier->generate( $user->user_email );
244 $this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() );
245
246 $callback_url = $this->urls->get_callback_url();
247 $refresh_callback_url = $this->urls->get_refresh_callback_url();
248
249 $request_body = [
250 'service' => 'openai',
251 'code_challenge' => \hash( 'sha256', $code_verifier->get_code() ),
252 'license_site_url' => WPSEO_Utils::get_home_url(),
253 'user_id' => (string) $user->ID,
254 'callback_url' => $callback_url,
255 'refresh_callback_url' => $refresh_callback_url,
256 ];
257
258 $this->request_handler->handle( new Request( '/token/request', $request_body ) );
259
260 // Store a per-user hash of the callback URL to detect future site URL changes.
261 $this->user_helper->update_meta( $user->ID, '_yoast_wpseo_ai_generator_callback_url_hash', \md5( $callback_url ) );
262
263 // The callback saves the metadata. Because that is in another session, we need to delete the current cache here. Or we may get the old token.
264 \wp_cache_delete( $user->ID, 'user_meta' );
265 }
266
267 /**
268 * Refreshes the JWT access token.
269 *
270 * Refreshes a stored JWT access token for a user with the Yoast AI Service and stores it in the database under
271 * usermeta. The storing of the token happens in a HTTP callback that is triggered by this request.
272 *
273 * @deprecated 28.4
274 * @codeCoverageIgnore
275 *
276 * @param WP_User $user The WP user.
277 *
278 * @return void
279 *
280 * @throws Bad_Request_Exception Bad_Request_Exception.
281 * @throws Forbidden_Exception Forbidden_Exception.
282 * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
283 * @throws Not_Found_Exception Not_Found_Exception.
284 * @throws Payment_Required_Exception Payment_Required_Exception.
285 * @throws Request_Timeout_Exception Request_Timeout_Exception.
286 * @throws Service_Unavailable_Exception Service_Unavailable_Exception.
287 * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
288 * @throws Unauthorized_Exception Unauthorized_Exception.
289 * @throws RuntimeException Unable to retrieve the refresh token.
290 */
291 public function token_refresh( WP_User $user ): void {
292 \_deprecated_function( __METHOD__, 'Yoast SEO 28.4' );
293 $refresh_jwt = $this->refresh_token_repository->get_token( $user->ID );
294
295 // Generate a code verifier and store it in the database.
296 $code_verifier = $this->code_verifier->generate( $user->user_email );
297 $this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() );
298
299 $request_body = [
300 'code_challenge' => \hash( 'sha256', $code_verifier->get_code() ),
301 ];
302 $request_headers = [
303 'Authorization' => "Bearer $refresh_jwt",
304 ];
305
306 $this->request_handler->handle( new Request( '/token/refresh', $request_body, $request_headers ) );
307
308 // The callback saves the metadata. Because that is in another session, we need to delete the current cache here. Or we may get the old token.
309 \wp_cache_delete( $user->ID, 'user_meta' );
310 }
311
312 /**
313 * Checks whether the token has expired.
314 *
315 * @deprecated 28.4
316 * @codeCoverageIgnore
317 *
318 * @param string $jwt The JWT.
319 *
320 * @return bool Whether the token has expired.
321 */
322 public function has_token_expired( string $jwt ): bool {
323 \_deprecated_function( __METHOD__, 'Yoast SEO 28.4' );
324 $parts = \explode( '.', $jwt );
325 if ( \count( $parts ) !== 3 ) {
326 // Headers, payload and signature parts are not detected.
327 return true;
328 }
329
330 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Reason: Decoding the payload of the JWT.
331 $payload = \base64_decode( $parts[1] );
332 $json = \json_decode( $payload );
333 if ( $json === null || ! isset( $json->exp ) ) {
334 return true;
335 }
336
337 // Ensure exp is a valid numeric value.
338 if ( ! \is_numeric( $json->exp ) ) {
339 return true;
340 }
341
342 return $json->exp < \time();
343 }
344
345 /**
346 * Retrieves the access token.
347 *
348 * @deprecated 28.4
349 * @codeCoverageIgnore
350 *
351 * @param WP_User $user The WP user.
352 *
353 * @return string The access token.
354 *
355 * @throws Bad_Request_Exception Bad_Request_Exception.
356 * @throws Forbidden_Exception Forbidden_Exception.
357 * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
358 * @throws Not_Found_Exception Not_Found_Exception.
359 * @throws Payment_Required_Exception Payment_Required_Exception.
360 * @throws Request_Timeout_Exception Request_Timeout_Exception.
361 * @throws Service_Unavailable_Exception Service_Unavailable_Exception.
362 * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
363 * @throws Unauthorized_Exception Unauthorized_Exception.
364 * @throws RuntimeException Unable to retrieve the access or refresh token.
365 */
366 public function get_or_request_access_token( WP_User $user ): string {
367 \_deprecated_function( __METHOD__, 'Yoast SEO 28.4' );
368 // If the site URL has changed since callback URLs were registered, delete stale tokens.
369 if ( $this->have_callback_urls_changed( $user ) ) {
370 $this->clear_tokens( $user->ID );
371 }
372
373 $access_jwt = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt', true );
374 if ( ! \is_string( $access_jwt ) || $access_jwt === '' ) {
375 $this->token_request( $user );
376 $access_jwt = $this->access_token_repository->get_token( $user->ID );
377 }
378 elseif ( $this->has_token_expired( $access_jwt ) ) {
379 try {
380 $this->token_refresh( $user );
381 } catch ( Unauthorized_Exception $exception ) {
382 $this->token_request( $user );
383 }
384 $access_jwt = $this->access_token_repository->get_token( $user->ID );
385 }
386
387 return $access_jwt;
388 }
389
390 // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
391
392 /**
393 * Checks whether the callback URLs have changed since the last token request.
394 *
395 * Detects site URL changes (e.g., migrating from a staging URL to a production domain)
396 * that would leave stale callback URLs registered with the Yoast AI service.
397 * Uses a per-user hash so each user independently detects the change and re-registers.
398 * The hash is immune to wp search-replace operations.
399 *
400 * When no hash is stored (first run after upgrade), returns true to force a fresh
401 * token_request(). This ensures existing sites with stale callback URLs self-heal
402 * without manual intervention.
403 *
404 * @param WP_User $user The current user.
405 *
406 * @return bool Whether the callback URLs may have changed.
407 */
408 private function have_callback_urls_changed( WP_User $user ): bool {
409 $registered_hash = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_callback_url_hash', true );
410
411 if ( ! \is_string( $registered_hash ) || $registered_hash === '' ) {
412 return true;
413 }
414
415 return $registered_hash !== \md5( $this->urls->get_callback_url() );
416 }
417 }
418