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

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