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

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