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

Yoast / wordpress-seo / 9594812bf110d67afe78816058eb15ec830063d2

15 Jul 2025 10:16AM UTC coverage: 52.595% (-1.0%) from 53.638%
9594812bf110d67afe78816058eb15ec830063d2

Pull #22432

github

web-flow
Merge branch 'trunk' into feature/ai-generator-in-free
Pull Request #22432: Merge feature branch into trunk

8341 of 15027 branches covered (55.51%)

Branch coverage included in aggregate %.

382 of 1769 new or added lines in 122 files covered. (21.59%)

2 existing lines in 2 files now uncovered.

30934 of 59648 relevant lines covered (51.86%)

40029.26 hits per line

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

0.0
/src/ai-authorization/application/token-manager.php
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
         */
NEW
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
        ) {
NEW
112
                $this->access_token_repository  = $access_token_repository;
×
NEW
113
                $this->code_verifier            = $code_verifier;
×
NEW
114
                $this->consent_handler          = $consent_handler;
×
NEW
115
                $this->refresh_token_repository = $refresh_token_repository;
×
NEW
116
                $this->user_helper              = $user_helper;
×
NEW
117
                $this->request_handler          = $request_handler;
×
NEW
118
                $this->code_verifier_repository = $code_verifier_repository;
×
NEW
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
         */
NEW
140
        public function token_invalidate( string $user_id ): void {
×
141
                try {
NEW
142
                        $access_jwt = $this->access_token_repository->get_token( $user_id );
×
NEW
143
                } catch ( RuntimeException $e ) {
×
NEW
144
                        $access_jwt = '';
×
145
                }
146

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

154
                try {
NEW
155
                        $this->request_handler->handle(
×
NEW
156
                                new Request(
×
NEW
157
                                        '/token/invalidate',
×
NEW
158
                                        $request_body,
×
NEW
159
                                        $request_headers
×
NEW
160
                                )
×
NEW
161
                        );
×
NEW
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.
NEW
167
                $this->user_helper->delete_meta( $user_id, '_yoast_wpseo_ai_generator_access_jwt' );
×
NEW
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
         */
NEW
191
        public function token_request( WP_User $user ): void {
×
192
                // Ensure the user has given consent.
NEW
193
                if ( $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_consent', true ) !== '1' ) {
×
194
                        // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive.
NEW
195
                        $this->consent_handler->revoke_consent( $user->ID );
×
NEW
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.
NEW
202
                $code_verifier = $this->code_verifier->generate( $user->user_email );
×
NEW
203
                $this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() );
×
204

NEW
205
                $request_body = [
×
NEW
206
                        'service'              => 'openai',
×
NEW
207
                        'code_challenge'       => \hash( 'sha256', $code_verifier->get_code() ),
×
NEW
208
                        'license_site_url'     => WPSEO_Utils::get_home_url(),
×
NEW
209
                        'user_id'              => (string) $user->ID,
×
NEW
210
                        'callback_url'         => $this->urls->get_callback_url(),
×
NEW
211
                        'refresh_callback_url' => $this->urls->get_refresh_callback_url(),
×
NEW
212
                ];
×
213

NEW
214
                $this->request_handler->handle( new Request( '/token/request', $request_body ) );
×
215

216
                // 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.
NEW
217
                \wp_cache_delete( $user->ID, 'user_meta' );
×
218
        }
219

220
        /**
221
         * Refreshes the JWT access token.
222
         *
223
         * Refreshes a stored JWT access token for a user with the Yoast AI Service and stores it in the database under
224
         * usermeta. The storing of the token happens in a HTTP callback that is triggered by this request.
225
         *
226
         * @param WP_User $user The WP user.
227
         *
228
         * @return void
229
         *
230
         * @throws Bad_Request_Exception Bad_Request_Exception.
231
         * @throws Forbidden_Exception Forbidden_Exception.
232
         * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
233
         * @throws Not_Found_Exception Not_Found_Exception.
234
         * @throws Payment_Required_Exception Payment_Required_Exception.
235
         * @throws Request_Timeout_Exception Request_Timeout_Exception.
236
         * @throws Service_Unavailable_Exception Service_Unavailable_Exception.
237
         * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
238
         * @throws Unauthorized_Exception Unauthorized_Exception.
239
         * @throws RuntimeException Unable to retrieve the refresh token.
240
         */
NEW
241
        public function token_refresh( WP_User $user ): void {
×
NEW
242
                $refresh_jwt = $this->refresh_token_repository->get_token( $user->ID );
×
243

244
                // Generate a code verifier and store it in the database.
NEW
245
                $code_verifier = $this->code_verifier->generate( $user->ID, $user->user_email );
×
NEW
246
                $this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() );
×
247

NEW
248
                $request_body    = [
×
NEW
249
                        'code_challenge' => \hash( 'sha256', $code_verifier->get_code() ),
×
NEW
250
                ];
×
NEW
251
                $request_headers = [
×
NEW
252
                        'Authorization' => "Bearer $refresh_jwt",
×
NEW
253
                ];
×
254

NEW
255
                $this->request_handler->handle( new Request( '/token/refresh', $request_body, $request_headers ) );
×
256

257
                // 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.
NEW
258
                \wp_cache_delete( $user->ID, 'user_meta' );
×
259
        }
260

261
        /**
262
         * Checks whether the token has expired.
263
         *
264
         * @param string $jwt The JWT.
265
         *
266
         * @return bool Whether the token has expired.
267
         */
NEW
268
        public function has_token_expired( string $jwt ): bool {
×
NEW
269
                $parts = \explode( '.', $jwt );
×
NEW
270
                if ( \count( $parts ) !== 3 ) {
×
271
                        // Headers, payload and signature parts are not detected.
NEW
272
                        return true;
×
273
                }
274

275
                // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Reason: Decoding the payload of the JWT.
NEW
276
                $payload = \base64_decode( $parts[1] );
×
NEW
277
                $json    = \json_decode( $payload );
×
NEW
278
                if ( $json === null || ! isset( $json->exp ) ) {
×
NEW
279
                        return true;
×
280
                }
281

NEW
282
                return $json->exp < \time();
×
283
        }
284

285
        /**
286
         * Retrieves the access token.
287
         *
288
         * @param WP_User $user The WP user.
289
         *
290
         * @return string The access token.
291
         *
292
         * @throws Bad_Request_Exception Bad_Request_Exception.
293
         * @throws Forbidden_Exception Forbidden_Exception.
294
         * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception.
295
         * @throws Not_Found_Exception Not_Found_Exception.
296
         * @throws Payment_Required_Exception Payment_Required_Exception.
297
         * @throws Request_Timeout_Exception Request_Timeout_Exception.
298
         * @throws Service_Unavailable_Exception Service_Unavailable_Exception.
299
         * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception.
300
         * @throws Unauthorized_Exception Unauthorized_Exception.
301
         * @throws RuntimeException Unable to retrieve the access or refresh token.
302
         */
NEW
303
        public function get_or_request_access_token( WP_User $user ): string {
×
NEW
304
                $access_jwt = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt', true );
×
NEW
305
                if ( ! \is_string( $access_jwt ) || $access_jwt === '' ) {
×
NEW
306
                        $this->token_request( $user );
×
NEW
307
                        $access_jwt = $this->access_token_repository->get_token( $user->ID );
×
308
                }
NEW
309
                elseif ( $this->has_token_expired( $access_jwt ) ) {
×
310
                        try {
NEW
311
                                $this->token_refresh( $user );
×
NEW
312
                        } catch ( Unauthorized_Exception $exception ) {
×
NEW
313
                                $this->token_request( $user );
×
NEW
314
                        } catch ( Forbidden_Exception $exception ) {
×
315
                                // Follow the API in the consent being revoked (Use case: user sent an e-mail to revoke?).
316
                                // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive.
NEW
317
                                $this->consent_handler->revoke_consent( $user->ID );
×
NEW
318
                                throw new Forbidden_Exception( 'CONSENT_REVOKED', 403 );
×
319
                                // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
320
                        }
NEW
321
                        $access_jwt = $this->access_token_repository->get_token( $user->ID );
×
322
                }
323

NEW
324
                return $access_jwt;
×
325
        }
326

327
        // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
328
}
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