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

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