PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 27.8
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v27.8
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 / myyoast-client / application / myyoast-client.php

myyoast-client.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 27.8, at src/myyoast-client/application/myyoast-client.php

412 lines 14.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
3 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4 namespace Yoast\WP\SEO\MyYoast_Client\Application;
5
6 use SensitiveParameter;
7 use Yoast\WP\SEO\Exceptions\Locking\Lock_Timeout_Exception;
8 use Yoast\WP\SEO\Helpers\Lock_Helper;
9 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Authorization_Flow_Exception;
10 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Failed_Exception;
11 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Request_Failed_Exception;
12 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Storage_Exception;
13 use Yoast\WP\SEO\MyYoast_Client\Application\Grants\Client_Credentials_Grant;
14 use Yoast\WP\SEO\MyYoast_Client\Application\Grants\Refresh_Token_Grant;
15 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
16 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\OAuth_Server_Client_Interface;
17 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Site_URL_Provider_Interface;
18 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Token_Storage_Interface;
19 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\User_Token_Storage_Interface;
20 use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response;
21 use Yoast\WP\SEO\MyYoast_Client\Domain\Registered_Client;
22 use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set;
23 use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Type_Hint;
24 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
25 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
26 use YoastSEO_Vendor\Psr\Log\NullLogger;
27
28 /**
29 * Primary facade for the MyYoast OAuth client.
30 *
31 * Orchestrates registration, token lifecycle, and authenticated requests.
32 * This is the main entry point for consuming code.
33 *
34 * @makePublic
35 */
36 class MyYoast_Client implements LoggerAwareInterface {
37 use LoggerAwareTrait;
38
39 private const REFRESH_LOCK_TTL_IN_SECONDS = 30;
40
41 /**
42 * The client registration port.
43 *
44 * @var Client_Registration_Interface
45 */
46 private $client_registration;
47
48 /**
49 * The authorization code handler.
50 *
51 * @var Authorization_Code_Handler
52 */
53 private $auth_code_handler;
54
55 /**
56 * The OAuth grant handler.
57 *
58 * @var OAuth_Grant_Handler
59 */
60 private $grant_handler;
61
62 /**
63 * The token revocation handler.
64 *
65 * @var Token_Revocation_Handler
66 */
67 private $revocation_handler;
68
69 /**
70 * The OAuth server client port.
71 *
72 * @var OAuth_Server_Client_Interface
73 */
74 private $http_client;
75
76 /**
77 * The lock helper.
78 *
79 * @var Lock_Helper
80 */
81 private $lock_helper;
82
83 /**
84 * The site-level token storage port.
85 *
86 * @var Token_Storage_Interface
87 */
88 private $token_storage;
89
90 /**
91 * The user-level token storage port.
92 *
93 * @var User_Token_Storage_Interface
94 */
95 private $user_token_storage;
96
97 /**
98 * The site URL provider port.
99 *
100 * @var Site_URL_Provider_Interface
101 */
102 private $site_url_provider;
103
104 /**
105 * MyYoast_Client constructor.
106 *
107 * @param Client_Registration_Interface $client_registration The client registration port.
108 * @param Authorization_Code_Handler $auth_code_handler The authorization code handler.
109 * @param OAuth_Grant_Handler $grant_handler The OAuth grant handler.
110 * @param Token_Revocation_Handler $revocation_handler The token revocation handler.
111 * @param OAuth_Server_Client_Interface $http_client The OAuth server client port.
112 * @param Lock_Helper $lock_helper The lock helper.
113 * @param Token_Storage_Interface $token_storage The site-level token storage port.
114 * @param User_Token_Storage_Interface $user_token_storage The user-level token storage port.
115 * @param Site_URL_Provider_Interface $site_url_provider The site URL provider port.
116 */
117 public function __construct(
118 Client_Registration_Interface $client_registration,
119 Authorization_Code_Handler $auth_code_handler,
120 OAuth_Grant_Handler $grant_handler,
121 Token_Revocation_Handler $revocation_handler,
122 OAuth_Server_Client_Interface $http_client,
123 Lock_Helper $lock_helper,
124 Token_Storage_Interface $token_storage,
125 User_Token_Storage_Interface $user_token_storage,
126 Site_URL_Provider_Interface $site_url_provider
127 ) {
128 $this->client_registration = $client_registration;
129 $this->auth_code_handler = $auth_code_handler;
130 $this->grant_handler = $grant_handler;
131 $this->revocation_handler = $revocation_handler;
132 $this->http_client = $http_client;
133 $this->lock_helper = $lock_helper;
134 $this->token_storage = $token_storage;
135 $this->user_token_storage = $user_token_storage;
136 $this->site_url_provider = $site_url_provider;
137 $this->logger = new NullLogger();
138 }
139
140 /**
141 * Ensures the plugin is registered as an OAuth client.
142 *
143 * @param string[] $redirect_uris The OAuth redirect URIs to register with.
144 *
145 * @return Registered_Client The registered client.
146 *
147 * @throws Registration_Failed_Exception If registration fails.
148 */
149 public function ensure_registered( array $redirect_uris = [] ): Registered_Client {
150 return $this->client_registration->ensure_registered( $redirect_uris );
151 }
152
153 /**
154 * Whether the plugin is registered as an OAuth client.
155 *
156 * @param string[] $redirect_uris Optional redirect URIs to verify against the stored registration.
157 *
158 * @return bool
159 */
160 public function is_registered( array $redirect_uris = [] ): bool {
161 return $this->client_registration->is_registered( $redirect_uris );
162 }
163
164 /**
165 * Reads the current client registration from the server.
166 *
167 * @return array<string, string|string[]> The registration metadata.
168 *
169 * @throws Registration_Failed_Exception If the read fails.
170 */
171 public function verify_registration(): array {
172 return $this->client_registration->read_registration();
173 }
174
175 /**
176 * Deletes the client registration from the server and clears local data.
177 *
178 * @return bool True if deleted or not registered, false on network failure.
179 */
180 public function deregister(): bool {
181 return $this->client_registration->deregister();
182 }
183
184 /**
185 * Rotates the registration key pair.
186 *
187 * @return Registered_Client The updated credentials.
188 *
189 * @throws Registration_Failed_Exception If the rotation fails.
190 */
191 public function rotate_registration_keys(): Registered_Client {
192 return $this->client_registration->rotate_registration_keys();
193 }
194
195 /**
196 * Rotates the DPoP key pair.
197 *
198 * @return void
199 */
200 public function rotate_dpop_keys(): void {
201 $this->client_registration->rotate_dpop_keys();
202 }
203
204 /**
205 * Builds the authorization URL for the user authorization flow.
206 *
207 * @param int $user_id The WordPress user ID.
208 * @param string $redirect_uri The callback redirect URI.
209 * @param string[] $scopes The scopes to request.
210 * @param string|null $return_url The URL to return the user to after authorization completes.
211 *
212 * @return string The authorization URL.
213 *
214 * @throws Authorization_Flow_Exception If registration, discovery, or parameter validation fails.
215 */
216 public function get_authorization_url( int $user_id, string $redirect_uri, array $scopes = [], ?string $return_url = null ): string {
217 return $this->auth_code_handler->get_authorization_url( $user_id, $redirect_uri, $scopes, $return_url );
218 }
219
220 /**
221 * Exchanges an authorization code for tokens and stores them for the user.
222 *
223 * @param int $user_id The WordPress user ID.
224 * @param string $code The authorization code.
225 * @param string $state The state parameter from the callback.
226 *
227 * @return Token_Set The obtained tokens.
228 *
229 * @throws Token_Request_Failed_Exception If the exchange fails.
230 * @throws Token_Storage_Exception If encrypting the token set for storage fails.
231 */
232 public function exchange_authorization_code( int $user_id, string $code, string $state ): Token_Set {
233 $token_set = $this->auth_code_handler->exchange_code( $user_id, $code, $state );
234 $this->user_token_storage->store( $user_id, $token_set );
235 return $token_set;
236 }
237
238 /**
239 * Returns a valid site-level access token (client_credentials).
240 *
241 * @param string[] $scopes The service:* scopes to request.
242 *
243 * @return Token_Set The site-level token set.
244 *
245 * @throws Token_Request_Failed_Exception If the token request fails.
246 * @throws Token_Storage_Exception If encrypting the token set for storage fails.
247 */
248 public function get_site_token( array $scopes = [] ): Token_Set {
249 $cached = $this->token_storage->get();
250 if ( $cached !== null && ! $cached->is_expired() && $cached->has_scopes( $scopes ) ) {
251 return $cached;
252 }
253
254 $grant = new Client_Credentials_Grant( $scopes, $this->site_url_provider->get() );
255 $token_set = $this->grant_handler->request_token( $grant );
256 $this->token_storage->store( $token_set );
257
258 return $token_set;
259 }
260
261 /**
262 * Returns a valid user-level access token, auto-refreshing if expired.
263 *
264 * @param int $user_id The WordPress user ID.
265 * @param string[] $required_scopes Optional scopes required for the token; if provided, no token will be returned unless it has at least these scopes.
266 * This is to avoid refreshing a token that would trigger an immediate re-authorization due to missing scopes.
267 *
268 * @return Token_Set|null The user token set, or null if the user hasn't authorized.
269 */
270 public function get_user_token( int $user_id, array $required_scopes = [] ): ?Token_Set {
271 $token_set = $this->user_token_storage->get( $user_id );
272 if ( $token_set === null ) {
273 return null;
274 }
275
276 if ( ! $token_set->has_scopes( $required_scopes ) ) {
277 // Required scopes are missing, treat as if no token is available.
278 return null;
279 }
280
281 if ( ! $token_set->is_expired() ) {
282 return $token_set;
283 }
284
285 $refresh_token = $token_set->get_refresh_token();
286 if ( $refresh_token === null ) {
287 return null;
288 }
289
290 try {
291 // If the client was just re-registered, this refresh will fail with invalid_grant.
292 // error_count is 0 on first attempt; after one invalid_grant it becomes 1.
293 // On the second consecutive invalid_grant (error_count >= 1), clear and give up.
294 $grant = new Refresh_Token_Grant( $refresh_token );
295 $lock_key = 'wpseo_myyoast_refresh:' . \hash( 'sha256', $refresh_token );
296 $new_token_set = $this->lock_helper->execute(
297 $lock_key,
298 function () use ( $grant ) {
299 return $this->grant_handler->request_token( $grant );
300 },
301 self::REFRESH_LOCK_TTL_IN_SECONDS,
302 );
303 try {
304 $this->user_token_storage->store( $user_id, $new_token_set );
305 } catch ( Token_Storage_Exception $e ) {
306 // Next request will re-refresh from the old stored token.
307 $this->logger->warning( 'Failed to persist refreshed token: {error}', [ 'error' => $e->getMessage() ] );
308 }
309 return $new_token_set;
310 } catch ( Lock_Timeout_Exception $e ) {
311 // Concurrent refresh in progress, treat as transient failure.
312 $this->logger->debug( 'Skipping token refresh for user {user_id}: concurrent refresh in progress.', [ 'user_id' => $user_id ] );
313 return null;
314 } catch ( Token_Request_Failed_Exception $e ) {
315 if ( $e->get_error_code() === 'invalid_grant' ) {
316 if ( $token_set->get_error_count() >= 1 ) {
317 $this->logger->warning( 'Repeated invalid_grant for user {user_id}, clearing stored tokens.', [ 'user_id' => $user_id ] );
318 $this->user_token_storage->delete( $user_id );
319 return null;
320 }
321
322 try {
323 $this->user_token_storage->store( $user_id, $token_set->with_incremented_error_count() );
324 } catch ( Token_Storage_Exception $e ) {
325 // Failure to persist error count is non-critical; token will be retried next request.
326 $this->logger->warning( 'Failed to persist token error count: {error}', [ 'error' => $e->getMessage() ] );
327 }
328 }
329
330 return null;
331 }
332 }
333
334 /**
335 * Whether the given user has authorized with MyYoast.
336 *
337 * @param int $user_id The WordPress user ID.
338 *
339 * @return bool
340 */
341 public function has_user_token( int $user_id ): bool {
342 return $this->user_token_storage->get( $user_id ) !== null;
343 }
344
345 /**
346 * Revokes the user's tokens and clears storage.
347 *
348 * @param int $user_id The WordPress user ID.
349 *
350 * @return void
351 */
352 public function revoke_user_token( int $user_id ): void {
353 $token_set = $this->user_token_storage->get( $user_id );
354 if ( $token_set === null ) {
355 return;
356 }
357 // Assume tokens are opaque for forwards compatibility. This operation is noop for JWTs, as they are only revoked upon expiration.
358 $this->revocation_handler->revoke( $token_set->get_access_token(), Token_Type_Hint::ACCESS_TOKEN );
359 if ( $token_set->get_refresh_token() !== null ) {
360 $this->revocation_handler->revoke( $token_set->get_refresh_token(), Token_Type_Hint::REFRESH_TOKEN );
361 }
362
363 $this->user_token_storage->delete( $user_id );
364 }
365
366 /**
367 * Revokes a token at the authorization server.
368 *
369 * @param string $token The token to revoke.
370 * @param string $token_type_hint A Token_Type_Hint constant.
371 *
372 * @return bool True if the revocation request was sent.
373 */
374 public function revoke_token(
375 // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPNativeAttributeFound -- No-op on PHP < 8.2; redacts parameter from stack traces on PHP 8.2+.
376 #[SensitiveParameter]
377 string $token,
378 string $token_type_hint = Token_Type_Hint::REFRESH_TOKEN
379 ): bool {
380 return $this->revocation_handler->revoke( $token, $token_type_hint );
381 }
382
383 /**
384 * Clears the site-level token.
385 *
386 * @return void
387 */
388 public function clear_site_token(): void {
389 $this->token_storage->delete();
390 }
391
392 /**
393 * Makes an authenticated DPoP-bound request to a resource server.
394 *
395 * @param string $method The HTTP method.
396 * @param string $url The resource URL.
397 * @param Token_Set $token_set The token set to use.
398 * @param array<string, string|int|bool> $options Additional request options.
399 *
400 * @return HTTP_Response The response.
401 */
402 public function authenticated_request( string $method, string $url, Token_Set $token_set, array $options = [] ): HTTP_Response {
403 return $this->http_client->authenticated_request(
404 $method,
405 $url,
406 $token_set->get_access_token(),
407 $token_set->get_token_type(),
408 $options,
409 );
410 }
411 }
412