• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / wordpress-seo / 43af5d5bfdd474c464a1c88014ea4b82cd4038b1

14 Apr 2026 11:12AM UTC coverage: 53.441% (+0.04%) from 53.406%
43af5d5bfdd474c464a1c88014ea4b82cd4038b1

push

github

JorPV
Merge branch 'trunk' of github.com:Yoast/wordpress-seo into feature/content-planner

9182 of 16850 branches covered (54.49%)

Branch coverage included in aggregate %.

34882 of 65604 relevant lines covered (53.17%)

45852.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.58
/src/ai/authorization/application/token-manager.php
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(
2✔
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;
2✔
115
                $this->code_verifier            = $code_verifier;
2✔
116
                $this->consent_handler          = $consent_handler;
2✔
117
                $this->refresh_token_repository = $refresh_token_repository;
2✔
118
                $this->user_helper              = $user_helper;
2✔
119
                $this->request_handler          = $request_handler;
2✔
120
                $this->code_verifier_repository = $code_verifier_repository;
2✔
121
                $this->urls                     = $urls;
2✔
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 {
8✔
142
                try {
143
                        $access_jwt = $this->access_token_repository->get_token( $user_id );
8✔
144
                } catch ( RuntimeException $e ) {
2✔
145
                        $access_jwt = '';
2✔
146
                }
147

148
                $request_body    = [
8✔
149
                        'user_id' => (string) $user_id,
8✔
150
                ];
8✔
151
                $request_headers = [
8✔
152
                        'Authorization' => "Bearer $access_jwt",
8✔
153
                ];
8✔
154

155
                try {
156
                        $this->request_handler->handle(
8✔
157
                                new Request(
8✔
158
                                        '/token/invalidate',
8✔
159
                                        $request_body,
8✔
160
                                        $request_headers,
8✔
161
                                ),
8✔
162
                        );
8✔
163
                } catch ( Unauthorized_Exception | Forbidden_Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Reason: Ignored on purpose.
4✔
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 );
8✔
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 {
14✔
202
                // Ensure the user has given consent.
203
                if ( $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_consent', true ) !== '1' ) {
14✔
204
                        // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive.
205
                        $this->consent_handler->revoke_consent( $user->ID );
6✔
206
                        throw new Forbidden_Exception( 'CONSENT_REVOKED', 403 );
6✔
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 );
8✔
213
                $this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() );
8✔
214

215
                $callback_url         = $this->urls->get_callback_url();
8✔
216
                $refresh_callback_url = $this->urls->get_refresh_callback_url();
8✔
217

218
                $request_body = [
8✔
219
                        'service'              => 'openai',
8✔
220
                        'code_challenge'       => \hash( 'sha256', $code_verifier->get_code() ),
8✔
221
                        'license_site_url'     => WPSEO_Utils::get_home_url(),
8✔
222
                        'user_id'              => (string) $user->ID,
8✔
223
                        'callback_url'         => $callback_url,
8✔
224
                        'refresh_callback_url' => $refresh_callback_url,
8✔
225
                ];
8✔
226

227
                $this->request_handler->handle( new Request( '/token/request', $request_body ) );
8✔
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 ) );
8✔
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' );
8✔
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 {
22✔
257
                $refresh_jwt = $this->refresh_token_repository->get_token( $user->ID );
22✔
258

259
                // Generate a code verifier and store it in the database.
260
                $code_verifier = $this->code_verifier->generate( $user->user_email );
20✔
261
                $this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() );
20✔
262

263
                $request_body    = [
20✔
264
                        'code_challenge' => \hash( 'sha256', $code_verifier->get_code() ),
20✔
265
                ];
20✔
266
                $request_headers = [
20✔
267
                        'Authorization' => "Bearer $refresh_jwt",
20✔
268
                ];
20✔
269

270
                $this->request_handler->handle( new Request( '/token/refresh', $request_body, $request_headers ) );
20✔
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' );
2✔
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 {
34✔
284
                $parts = \explode( '.', $jwt );
34✔
285
                if ( \count( $parts ) !== 3 ) {
34✔
286
                        // Headers, payload and signature parts are not detected.
287
                        return true;
6✔
288
                }
289

290
                // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Reason: Decoding the payload of the JWT.
291
                $payload = \base64_decode( $parts[1] );
28✔
292
                $json    = \json_decode( $payload );
28✔
293
                if ( $json === null || ! isset( $json->exp ) ) {
28✔
294
                        return true;
12✔
295
                }
296

297
                // Ensure exp is a valid numeric value.
298
                if ( ! \is_numeric( $json->exp ) ) {
16✔
299
                        return true;
2✔
300
                }
301

302
                return $json->exp < \time();
14✔
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 {
36✔
323
                // If the site URL has changed since callback URLs were registered, delete stale tokens.
324
                if ( $this->have_callback_urls_changed( $user ) ) {
36✔
325
                        $this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt' );
4✔
326
                        $this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_refresh_jwt' );
4✔
327
                }
328

329
                $access_jwt = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt', true );
36✔
330
                if ( ! \is_string( $access_jwt ) || $access_jwt === '' ) {
36✔
331
                        $this->token_request( $user );
14✔
332
                        $access_jwt = $this->access_token_repository->get_token( $user->ID );
12✔
333
                }
334
                elseif ( $this->has_token_expired( $access_jwt ) ) {
22✔
335
                        try {
336
                                $this->token_refresh( $user );
18✔
337
                        } catch ( Unauthorized_Exception $exception ) {
16✔
338
                                $this->token_request( $user );
2✔
339
                        } catch ( Forbidden_Exception $exception ) {
14✔
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 );
2✔
343
                                throw new Forbidden_Exception( 'CONSENT_REVOKED', 403 );
2✔
344
                                // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
345
                        }
346
                        $access_jwt = $this->access_token_repository->get_token( $user->ID );
4✔
347
                }
348

349
                return $access_jwt;
18✔
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
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc