PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 27.8
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v27.8
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 / ai / authorization / application / token-manager.php

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

380 lines 14.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4
5 namespace Yoast\WP\SEO\AI\Authorization\Application;
6
7 use RuntimeException;
8 use WP_User;
9 use WPSEO_Utils;
10 use Yoast\WP\SEO\AI\Authorization\Infrastructure\Access_Token_User_Meta_Repository_Interface;
11 use Yoast\WP\SEO\AI\Authorization\Infrastructure\Code_Verifier_User_Meta_Repository;
12 use Yoast\WP\SEO\AI\Authorization\Infrastructure\Refresh_Token_User_Meta_Repository_Interface;
13 use Yoast\WP\SEO\AI\Consent\Application\Consent_Handler;
14 use Yoast\WP\SEO\AI\Generator\Infrastructure\WordPress_URLs;
15 use Yoast\WP\SEO\AI\HTTP_Request\Application\Request_Handler;
16 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Bad_Request_Exception;
17 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Forbidden_Exception;
18 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Internal_Server_Error_Exception;
19 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Not_Found_Exception;
20 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Payment_Required_Exception;
21 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Request_Timeout_Exception;
22 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Service_Unavailable_Exception;
23 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Too_Many_Requests_Exception;
24 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Unauthorized_Exception;
25 use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request;
26 use Yoast\WP\SEO\Helpers\User_Helper;
27
28 /**
29 * Class Token_Manager
30 * Handles the management of JWT tokens used in the authorization process.
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_Interface
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 consent handler.
52 *
53 * @var Consent_Handler
54 */
55 private $consent_handler;
56
57 /**
58 * The refresh token repository.
59 *
60 * @var Refresh_Token_User_Meta_Repository_Interface
61 */
62 private $refresh_token_repository;
63
64 /**
65 * The user helper.
66 *
67 * @var User_Helper
68 */
69 private $user_helper;
70
71 /**
72 * The code verifier repository.
73 *
74 * @var Code_Verifier_User_Meta_Repository
75 */
76 private $code_verifier_repository;
77
78 /**
79 * The URLs service.
80 *
81 * @var WordPress_URLs
82 */
83 private $urls;
84
85 /**
86 * The request handler.
87 *
88 * @var Request_Handler
89 */
90 private $request_handler;
91
92 /**
93 * Token_Manager constructor.
94 *
95 * @param Access_Token_User_Meta_Repository_Interface $access_token_repository The access token repository.
96 * @param Code_Verifier_Handler $code_verifier The code verifier service.
97 * @param Consent_Handler $consent_handler The consent handler.
98 * @param Refresh_Token_User_Meta_Repository_Interface $refresh_token_repository The refresh token repository.
99 * @param User_Helper $user_helper The user helper.
100 * @param Request_Handler $request_handler The request handler.
101 * @param Code_Verifier_User_Meta_Repository $code_verifier_repository The code verifier repository.
102 * @param WordPress_URLs $urls The URLs service.
103 */
104 public function __construct(
105 Access_Token_User_Meta_Repository_Interface $access_token_repository,
106 Code_Verifier_Handler $code_verifier,
107 Consent_Handler $consent_handler,
108 Refresh_Token_User_Meta_Repository_Interface $refresh_token_repository,
109 User_Helper $user_helper,
110 Request_Handler $request_handler,
111 Code_Verifier_User_Meta_Repository $code_verifier_repository,
112 WordPress_URLs $urls
113 ) {
114 $this->access_token_repository = $access_token_repository;
115 $this->code_verifier = $code_verifier;
116 $this->consent_handler = $consent_handler;
117 $this->refresh_token_repository = $refresh_token_repository;
118 $this->user_helper = $user_helper;
119 $this->request_handler = $request_handler;
120 $this->code_verifier_repository = $code_verifier_repository;
121 $this->urls = $urls;
122 }
123
124 // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- PHPCS doesn't take into account exceptions thrown in called methods.
125
126 /**
127 * Invalidates the access token.
128 *
129 * @param string $user_id The user ID.
130 *
131 * @throws Bad_Request_Exception Bad_Request_Exception.
132 * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
133 * @throws Not_Found_Exception Not_Found_Exception.
134 * @throws Payment_Required_Exception Payment_Required_Exception.
135 * @throws Request_Timeout_Exception Request_Timeout_Exception.
136 * @throws Service_Unavailable_Exception Service_Unavailable_Exception.
137 * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
138 * @throws RuntimeException Unable to retrieve the access token.
139 * @return void
140 */
141 public function token_invalidate( string $user_id ): void {
142 try {
143 $access_jwt = $this->access_token_repository->get_token( $user_id );
144 } catch ( RuntimeException $e ) {
145 $access_jwt = '';
146 }
147
148 $request_body = [
149 'user_id' => (string) $user_id,
150 ];
151 $request_headers = [
152 'Authorization' => "Bearer $access_jwt",
153 ];
154
155 try {
156 $this->request_handler->handle(
157 new Request(
158 '/token/invalidate',
159 $request_body,
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 }
166
167 $this->clear_tokens( $user_id );
168 }
169
170 /**
171 * Clears the user meta tokens for a specific user.
172 *
173 * @param string $user_id The user id to delete this for.
174 *
175 * @return void
176 */
177 public function clear_tokens( string $user_id ): void {
178 $this->access_token_repository->delete_token( $user_id );
179 $this->refresh_token_repository->delete_token( $user_id );
180 }
181
182 /**
183 * Requests a new set of JWT tokens.
184 *
185 * Requests a new JWT access and refresh token for a user from the Yoast AI Service and stores it in the database
186 * under usermeta. The storing of the token happens in a HTTP callback that is triggered by this request.
187 *
188 * @param WP_User $user The WP user.
189 *
190 * @throws Bad_Request_Exception Bad_Request_Exception.
191 * @throws Forbidden_Exception Forbidden_Exception.
192 * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
193 * @throws Not_Found_Exception Not_Found_Exception.
194 * @throws Payment_Required_Exception Payment_Required_Exception.
195 * @throws Request_Timeout_Exception Request_Timeout_Exception.
196 * @throws Service_Unavailable_Exception Service_Unavailable_Exception.
197 * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
198 * @throws Unauthorized_Exception Unauthorized_Exception.
199 * @return void
200 */
201 public function token_request( WP_User $user ): void {
202 // Ensure the user has given consent.
203 if ( $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_consent', true ) !== '1' ) {
204 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive.
205 $this->consent_handler->revoke_consent( $user->ID );
206 throw new Forbidden_Exception( 'CONSENT_REVOKED', 403 );
207
208 // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
209 }
210
211 // Generate a code verifier and store it in the database.
212 $code_verifier = $this->code_verifier->generate( $user->user_email );
213 $this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() );
214
215 $callback_url = $this->urls->get_callback_url();
216 $refresh_callback_url = $this->urls->get_refresh_callback_url();
217
218 $request_body = [
219 'service' => 'openai',
220 'code_challenge' => \hash( 'sha256', $code_verifier->get_code() ),
221 'license_site_url' => WPSEO_Utils::get_home_url(),
222 'user_id' => (string) $user->ID,
223 'callback_url' => $callback_url,
224 'refresh_callback_url' => $refresh_callback_url,
225 ];
226
227 $this->request_handler->handle( new Request( '/token/request', $request_body ) );
228
229 // Store a per-user hash of the callback URL to detect future site URL changes.
230 $this->user_helper->update_meta( $user->ID, '_yoast_wpseo_ai_generator_callback_url_hash', \md5( $callback_url ) );
231
232 // 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.
233 \wp_cache_delete( $user->ID, 'user_meta' );
234 }
235
236 /**
237 * Refreshes the JWT access token.
238 *
239 * Refreshes a stored JWT access token for a user with the Yoast AI Service and stores it in the database under
240 * usermeta. The storing of the token happens in a HTTP callback that is triggered by this request.
241 *
242 * @param WP_User $user The WP user.
243 *
244 * @throws Bad_Request_Exception Bad_Request_Exception.
245 * @throws Forbidden_Exception Forbidden_Exception.
246 * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
247 * @throws Not_Found_Exception Not_Found_Exception.
248 * @throws Payment_Required_Exception Payment_Required_Exception.
249 * @throws Request_Timeout_Exception Request_Timeout_Exception.
250 * @throws Service_Unavailable_Exception Service_Unavailable_Exception.
251 * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
252 * @throws Unauthorized_Exception Unauthorized_Exception.
253 * @throws RuntimeException Unable to retrieve the refresh token.
254 * @return void
255 */
256 public function token_refresh( WP_User $user ): void {
257 $refresh_jwt = $this->refresh_token_repository->get_token( $user->ID );
258
259 // Generate a code verifier and store it in the database.
260 $code_verifier = $this->code_verifier->generate( $user->user_email );
261 $this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() );
262
263 $request_body = [
264 'code_challenge' => \hash( 'sha256', $code_verifier->get_code() ),
265 ];
266 $request_headers = [
267 'Authorization' => "Bearer $refresh_jwt",
268 ];
269
270 $this->request_handler->handle( new Request( '/token/refresh', $request_body, $request_headers ) );
271
272 // 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.
273 \wp_cache_delete( $user->ID, 'user_meta' );
274 }
275
276 /**
277 * Checks whether the token has expired.
278 *
279 * @param string $jwt The JWT.
280 *
281 * @return bool Whether the token has expired.
282 */
283 public function has_token_expired( string $jwt ): bool {
284 $parts = \explode( '.', $jwt );
285 if ( \count( $parts ) !== 3 ) {
286 // Headers, payload and signature parts are not detected.
287 return true;
288 }
289
290 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Reason: Decoding the payload of the JWT.
291 $payload = \base64_decode( $parts[1] );
292 $json = \json_decode( $payload );
293 if ( $json === null || ! isset( $json->exp ) ) {
294 return true;
295 }
296
297 // Ensure exp is a valid numeric value.
298 if ( ! \is_numeric( $json->exp ) ) {
299 return true;
300 }
301
302 return $json->exp < \time();
303 }
304
305 /**
306 * Retrieves the access token.
307 *
308 * @param WP_User $user The WP user.
309 *
310 * @throws Bad_Request_Exception Bad_Request_Exception.
311 * @throws Forbidden_Exception Forbidden_Exception.
312 * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
313 * @throws Not_Found_Exception Not_Found_Exception.
314 * @throws Payment_Required_Exception Payment_Required_Exception.
315 * @throws Request_Timeout_Exception Request_Timeout_Exception.
316 * @throws Service_Unavailable_Exception Service_Unavailable_Exception.
317 * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
318 * @throws Unauthorized_Exception Unauthorized_Exception.
319 * @throws RuntimeException Unable to retrieve the access or refresh token.
320 * @return string The access token.
321 */
322 public function get_or_request_access_token( WP_User $user ): string {
323 // If the site URL has changed since callback URLs were registered, delete stale tokens.
324 if ( $this->have_callback_urls_changed( $user ) ) {
325 $this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt' );
326 $this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_refresh_jwt' );
327 }
328
329 $access_jwt = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt', true );
330 if ( ! \is_string( $access_jwt ) || $access_jwt === '' ) {
331 $this->token_request( $user );
332 $access_jwt = $this->access_token_repository->get_token( $user->ID );
333 }
334 elseif ( $this->has_token_expired( $access_jwt ) ) {
335 try {
336 $this->token_refresh( $user );
337 } catch ( Unauthorized_Exception $exception ) {
338 $this->token_request( $user );
339 } catch ( Forbidden_Exception $exception ) {
340 // Follow the API in the consent being revoked (Use case: user sent an e-mail to revoke?).
341 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive.
342 $this->consent_handler->revoke_consent( $user->ID );
343 throw new Forbidden_Exception( 'CONSENT_REVOKED', 403 );
344 // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
345 }
346 $access_jwt = $this->access_token_repository->get_token( $user->ID );
347 }
348
349 return $access_jwt;
350 }
351
352 // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
353
354 /**
355 * Checks whether the callback URLs have changed since the last token request.
356 *
357 * Detects site URL changes (e.g., migrating from a staging URL to a production domain)
358 * that would leave stale callback URLs registered with the Yoast AI service.
359 * Uses a per-user hash so each user independently detects the change and re-registers.
360 * The hash is immune to wp search-replace operations.
361 *
362 * When no hash is stored (first run after upgrade), returns true to force a fresh
363 * token_request(). This ensures existing sites with stale callback URLs self-heal
364 * without manual intervention.
365 *
366 * @param WP_User $user The current user.
367 *
368 * @return bool Whether the callback URLs may have changed.
369 */
370 private function have_callback_urls_changed( WP_User $user ): bool {
371 $registered_hash = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_callback_url_hash', true );
372
373 if ( ! \is_string( $registered_hash ) || $registered_hash === '' ) {
374 return true;
375 }
376
377 return $registered_hash !== \md5( $this->urls->get_callback_url() );
378 }
379 }
380