PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / trunk
Yoast SEO – Advanced SEO with real-time guidance and built-in AI vtrunk
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 trunk, at src/myyoast-client/application/myyoast-client.php

499 lines 18.9 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\Redirect_URI_Provider_Interface;
18 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Site_URL_Provider_Interface;
19 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Token_Storage_Interface;
20 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\User_Token_Storage_Interface;
21 use Yoast\WP\SEO\MyYoast_Client\Domain\Exceptions\Invalid_Resource_Exception;
22 use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response;
23 use Yoast\WP\SEO\MyYoast_Client\Domain\Registered_Client;
24 use Yoast\WP\SEO\MyYoast_Client\Domain\Resource_Indicator;
25 use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set;
26 use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Type_Hint;
27 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
28 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
29 use YoastSEO_Vendor\Psr\Log\NullLogger;
30
31 /**
32 * Primary facade for the MyYoast OAuth client.
33 *
34 * Orchestrates registration, token lifecycle, and authenticated requests.
35 * This is the main entry point for consuming code.
36 *
37 * @makePublic
38 */
39 class MyYoast_Client implements LoggerAwareInterface {
40 use LoggerAwareTrait;
41
42 private const REFRESH_LOCK_TTL_IN_SECONDS = 30;
43
44 /**
45 * The client registration port.
46 *
47 * @var Client_Registration_Interface
48 */
49 private $client_registration;
50
51 /**
52 * The authorization code handler.
53 *
54 * @var Authorization_Code_Handler
55 */
56 private $auth_code_handler;
57
58 /**
59 * The OAuth grant handler.
60 *
61 * @var OAuth_Grant_Handler
62 */
63 private $grant_handler;
64
65 /**
66 * The token revocation handler.
67 *
68 * @var Token_Revocation_Handler
69 */
70 private $revocation_handler;
71
72 /**
73 * The OAuth server client port.
74 *
75 * @var OAuth_Server_Client_Interface
76 */
77 private $http_client;
78
79 /**
80 * The lock helper.
81 *
82 * @var Lock_Helper
83 */
84 private $lock_helper;
85
86 /**
87 * The site-level token storage port.
88 *
89 * @var Token_Storage_Interface
90 */
91 private $token_storage;
92
93 /**
94 * The user-level token storage port.
95 *
96 * @var User_Token_Storage_Interface
97 */
98 private $user_token_storage;
99
100 /**
101 * The site URL provider port.
102 *
103 * @var Site_URL_Provider_Interface
104 */
105 private $site_url_provider;
106
107 /**
108 * The redirect URI provider port.
109 *
110 * @var Redirect_URI_Provider_Interface
111 */
112 private $redirect_uri_provider;
113
114 /**
115 * MyYoast_Client constructor.
116 *
117 * @param Client_Registration_Interface $client_registration The client registration port.
118 * @param Authorization_Code_Handler $auth_code_handler The authorization code handler.
119 * @param OAuth_Grant_Handler $grant_handler The OAuth grant handler.
120 * @param Token_Revocation_Handler $revocation_handler The token revocation handler.
121 * @param OAuth_Server_Client_Interface $http_client The OAuth server client port.
122 * @param Lock_Helper $lock_helper The lock helper.
123 * @param Token_Storage_Interface $token_storage The site-level token storage port.
124 * @param User_Token_Storage_Interface $user_token_storage The user-level token storage port.
125 * @param Site_URL_Provider_Interface $site_url_provider The site URL provider port.
126 * @param Redirect_URI_Provider_Interface $redirect_uri_provider The redirect URI provider port.
127 */
128 public function __construct(
129 Client_Registration_Interface $client_registration,
130 Authorization_Code_Handler $auth_code_handler,
131 OAuth_Grant_Handler $grant_handler,
132 Token_Revocation_Handler $revocation_handler,
133 OAuth_Server_Client_Interface $http_client,
134 Lock_Helper $lock_helper,
135 Token_Storage_Interface $token_storage,
136 User_Token_Storage_Interface $user_token_storage,
137 Site_URL_Provider_Interface $site_url_provider,
138 Redirect_URI_Provider_Interface $redirect_uri_provider
139 ) {
140 $this->client_registration = $client_registration;
141 $this->auth_code_handler = $auth_code_handler;
142 $this->grant_handler = $grant_handler;
143 $this->revocation_handler = $revocation_handler;
144 $this->http_client = $http_client;
145 $this->lock_helper = $lock_helper;
146 $this->token_storage = $token_storage;
147 $this->user_token_storage = $user_token_storage;
148 $this->site_url_provider = $site_url_provider;
149 $this->redirect_uri_provider = $redirect_uri_provider;
150 $this->logger = new NullLogger();
151 }
152
153 /**
154 * Ensures the plugin is registered as an OAuth client with the provider's redirect URIs.
155 *
156 * @return Registered_Client The registered client.
157 *
158 * @throws Registration_Failed_Exception If registration fails.
159 */
160 public function ensure_registered(): Registered_Client {
161 return $this->client_registration->ensure_registered( $this->redirect_uri_provider->get_redirect_uris() );
162 }
163
164 /**
165 * Returns the stored registered client, or null if not registered.
166 *
167 * The returned client exposes per-URI verification state via Registered_Client::is_uri_validated().
168 *
169 * @return Registered_Client|null The registered client, or null if not registered.
170 */
171 public function get_registered_client(): ?Registered_Client {
172 return $this->client_registration->get_registered_client();
173 }
174
175 /**
176 * Whether the plugin is registered as an OAuth client.
177 *
178 * @return bool
179 */
180 public function is_registered(): bool {
181 return $this->client_registration->get_registered_client() !== null;
182 }
183
184 /**
185 * Refreshes the local registration status against the server.
186 *
187 * Reads the current client registration (RFC 7592 GET) to confirm it is
188 * still live; the underlying read self-heals by forgetting the local
189 * registration when the server reports it gone.
190 *
191 * @return array<string, string|string[]> The registration metadata.
192 *
193 * @throws Registration_Failed_Exception If the read fails.
194 */
195 public function refresh_registration_status(): array {
196 return $this->client_registration->read_registration();
197 }
198
199 /**
200 * Deletes the client registration from the server and clears local data.
201 *
202 * @return bool True if deleted or not registered, false on network failure.
203 */
204 public function deregister(): bool {
205 return $this->client_registration->deregister();
206 }
207
208 /**
209 * Rotates the registration key pair.
210 *
211 * @return Registered_Client The updated credentials.
212 *
213 * @throws Registration_Failed_Exception If the rotation fails.
214 */
215 public function rotate_registration_keys(): Registered_Client {
216 return $this->client_registration->rotate_registration_keys();
217 }
218
219 /**
220 * Rotates the DPoP key pair.
221 *
222 * @return void
223 */
224 public function rotate_dpop_keys(): void {
225 $this->client_registration->rotate_dpop_keys();
226 }
227
228 /**
229 * Builds the authorization URL for the user authorization flow.
230 *
231 * @param int $user_id The WordPress user ID.
232 * @param string[] $scopes The scopes to request.
233 * @param string|null $resource_indicator The RFC 8707 resource indicator the issued token should be bound to.
234 * @param string|null $return_url The URL to return the user to after authorization completes.
235 *
236 * @return string The authorization URL.
237 *
238 * @throws Authorization_Flow_Exception If registration, discovery, or parameter validation fails.
239 * @throws Invalid_Resource_Exception If the resource indicator is malformed.
240 */
241 public function get_authorization_url( int $user_id, array $scopes = [], ?string $resource_indicator = null, ?string $return_url = null ): string {
242 return $this->auth_code_handler->get_authorization_url( $user_id, $scopes, new Resource_Indicator( $resource_indicator ), $return_url );
243 }
244
245 /**
246 * Exchanges an authorization code for tokens and stores them for the user.
247 *
248 * @param int $user_id The WordPress user ID.
249 * @param string $code The authorization code.
250 * @param string $state The state parameter from the callback.
251 *
252 * @return Token_Set The obtained tokens.
253 *
254 * @throws Token_Request_Failed_Exception If the exchange fails.
255 * @throws Token_Storage_Exception If encrypting the token set for storage fails.
256 */
257 public function exchange_authorization_code( int $user_id, string $code, string $state ): Token_Set {
258 $token_set = $this->auth_code_handler->exchange_code( $user_id, $code, $state );
259 $this->user_token_storage->store( $user_id, $token_set );
260 return $token_set;
261 }
262
263 // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- Token_Storage_Exception is thrown by an injected service, not directly here.
264
265 /**
266 * Returns a valid site-level access token (client_credentials).
267 *
268 * @param string[] $scopes The service:* scopes to request.
269 * @param string|null $resource_indicator The RFC 8707 resource indicator the token should be bound to, or null for the default resource.
270 *
271 * @return Token_Set The site-level token set.
272 *
273 * @throws Invalid_Resource_Exception If the resource indicator is malformed.
274 * @throws Token_Request_Failed_Exception If the site is not registered or the token request fails. May also throw Token_Storage_Exception when encrypting the token set for storage fails.
275 */
276 public function get_site_token( array $scopes = [], ?string $resource_indicator = null ): Token_Set {
277 $indicator = new Resource_Indicator( $resource_indicator );
278
279 $cached = $this->token_storage->get( $indicator );
280 if ( $cached !== null && ! $cached->is_expired() && $cached->has_scopes( $scopes ) ) {
281 $this->logger->debug( 'MyYoast site token: cache hit; reusing cached token (scopes: {scopes}).', [ 'scopes' => \implode( ' ', $scopes ) ] );
282 return $cached;
283 }
284 $this->logger->debug( 'MyYoast site token: cache miss; preparing to request a fresh token (scopes: {scopes}).', [ 'scopes' => \implode( ' ', $scopes ) ] );
285
286 if ( ! $this->is_registered() ) {
287 $this->logger->debug( 'MyYoast site token: site not registered; refusing to request a token. Run the connect flow first.' );
288 throw new Token_Request_Failed_Exception( 'not_registered', 'Site is not registered with MyYoast; complete the connect flow first.' );
289 }
290 $this->logger->debug( 'MyYoast site token: site already registered; proceeding with token request.' );
291
292 $grant = new Client_Credentials_Grant( $scopes, $this->site_url_provider->get() );
293 $token_set = $this->grant_handler->request_token( $grant, $indicator );
294 $this->token_storage->store( $token_set );
295
296 $this->logger->debug( 'MyYoast site token: fresh token issued and cached.' );
297
298 return $token_set;
299 }
300
301 // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
302
303 /**
304 * Returns a valid user-level access token, auto-refreshing if expired.
305 *
306 * @param int $user_id The WordPress user ID.
307 * @param string[] $required_scopes Optional scopes required for the token; if provided, no token will be returned unless it has at least these scopes.
308 * This is to avoid refreshing a token that would trigger an immediate re-authorization due to missing scopes.
309 * @param string|null $resource_indicator The RFC 8707 resource indicator the token should be bound to, or null for the default resource.
310 *
311 * @return Token_Set|null The user token set, or null if the user hasn't authorized.
312 *
313 * @throws Invalid_Resource_Exception If the resource indicator is malformed.
314 */
315 public function get_user_token( int $user_id, array $required_scopes = [], ?string $resource_indicator = null ): ?Token_Set {
316 $indicator = new Resource_Indicator( $resource_indicator );
317 $token_set = $this->user_token_storage->get( $user_id, $indicator );
318 if ( $token_set === null ) {
319 return null;
320 }
321
322 if ( ! $token_set->has_scopes( $required_scopes ) ) {
323 // Required scopes are missing, treat as if no token is available.
324 return null;
325 }
326
327 if ( ! $token_set->is_expired() ) {
328 return $token_set;
329 }
330
331 $refresh_token = $token_set->get_refresh_token();
332 if ( $refresh_token === null ) {
333 return null;
334 }
335
336 // Refresh must keep the audience binding (RFC 8707 §2); pull it from the stored token.
337 $bound_resource = $token_set->get_resource_indicator();
338
339 // If the client was just re-registered, this refresh will fail with invalid_grant.
340 // error_count is 0 on first attempt; after one invalid_grant it becomes 1.
341 // On the second consecutive invalid_grant (error_count >= 1), clear and give up.
342 $grant = new Refresh_Token_Grant( $refresh_token );
343 $lock_key = 'wpseo_myyoast_refresh:' . \hash( 'sha256', $refresh_token );
344 try {
345 $new_token_set = $this->lock_helper->execute(
346 $lock_key,
347 function () use ( $grant, $bound_resource ) {
348 return $this->grant_handler->request_token( $grant, $bound_resource );
349 },
350 self::REFRESH_LOCK_TTL_IN_SECONDS,
351 );
352 } catch ( Lock_Timeout_Exception $e ) {
353 // Concurrent refresh in progress, treat as transient failure.
354 $this->logger->debug( 'Skipping token refresh for user {user_id}: concurrent refresh in progress.', [ 'user_id' => $user_id ] );
355 return null;
356 } catch ( Token_Request_Failed_Exception $e ) {
357 if ( $e->get_error_code() === 'invalid_grant' ) {
358 if ( $token_set->get_error_count() >= 1 ) {
359 $this->logger->warning( 'Repeated invalid_grant for user {user_id}, clearing stored tokens.', [ 'user_id' => $user_id ] );
360 $this->user_token_storage->delete( $user_id, $indicator );
361 return null;
362 }
363
364 try {
365 $this->user_token_storage->store( $user_id, $token_set->with_incremented_error_count() );
366 } catch ( Token_Storage_Exception $e ) {
367 // Failure to persist error count is non-critical; token will be retried next request.
368 $this->logger->warning( 'Failed to persist token error count: {error}', [ 'error' => $e->getMessage() ] );
369 }
370 }
371
372 return null;
373 }
374 try {
375 $this->user_token_storage->store( $user_id, $new_token_set );
376 } catch ( Token_Storage_Exception $e ) {
377 // Next request will re-refresh from the old stored token.
378 $this->logger->warning( 'Failed to persist refreshed token: {error}', [ 'error' => $e->getMessage() ] );
379 }
380 return $new_token_set;
381 }
382
383 /**
384 * Whether the given user has authorized with MyYoast for a resource.
385 *
386 * @param int $user_id The WordPress user ID.
387 * @param string|null $resource_indicator The RFC 8707 resource indicator, or null for the default resource.
388 *
389 * @return bool
390 *
391 * @throws Invalid_Resource_Exception If the resource indicator is malformed.
392 */
393 public function has_user_token( int $user_id, ?string $resource_indicator = null ): bool {
394 return $this->user_token_storage->get( $user_id, new Resource_Indicator( $resource_indicator ) ) !== null;
395 }
396
397 /**
398 * Revokes the user's tokens for a resource and clears storage.
399 *
400 * @param int $user_id The WordPress user ID.
401 * @param string|null $resource_indicator The RFC 8707 resource indicator, or null for the default resource.
402 *
403 * @return void
404 *
405 * @throws Invalid_Resource_Exception If the resource indicator is malformed.
406 */
407 public function revoke_user_token( int $user_id, ?string $resource_indicator = null ): void {
408 $indicator = new Resource_Indicator( $resource_indicator );
409 $token_set = $this->user_token_storage->get( $user_id, $indicator );
410 if ( $token_set === null ) {
411 return;
412 }
413 // Assume tokens are opaque for forwards compatibility. This operation is noop for JWTs, as they are only revoked upon expiration.
414 $this->revocation_handler->revoke( $token_set->get_access_token(), Token_Type_Hint::ACCESS_TOKEN );
415 if ( $token_set->get_refresh_token() !== null ) {
416 $this->revocation_handler->revoke( $token_set->get_refresh_token(), Token_Type_Hint::REFRESH_TOKEN );
417 }
418
419 $this->user_token_storage->delete( $user_id, $indicator );
420 }
421
422 /**
423 * Revokes every user token across all resource buckets ("log out everywhere").
424 *
425 * @param int $user_id The WordPress user ID.
426 *
427 * @return void
428 */
429 public function revoke_all_user_tokens( int $user_id ): void {
430 $tokens = $this->user_token_storage->get_all( $user_id );
431 foreach ( $tokens as $token_set ) {
432 $this->revocation_handler->revoke( $token_set->get_access_token(), Token_Type_Hint::ACCESS_TOKEN );
433 if ( $token_set->get_refresh_token() !== null ) {
434 $this->revocation_handler->revoke( $token_set->get_refresh_token(), Token_Type_Hint::REFRESH_TOKEN );
435 }
436 $this->user_token_storage->delete( $user_id, $token_set->get_resource_indicator() );
437 }
438 }
439
440 /**
441 * Revokes a token at the authorization server.
442 *
443 * @param string $token The token to revoke.
444 * @param string $token_type_hint A Token_Type_Hint constant.
445 *
446 * @return bool True if the revocation request was sent.
447 */
448 public function revoke_token(
449 // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPNativeAttributeFound -- No-op on PHP < 8.2; redacts parameter from stack traces on PHP 8.2+.
450 #[SensitiveParameter]
451 string $token,
452 string $token_type_hint = Token_Type_Hint::REFRESH_TOKEN
453 ): bool {
454 return $this->revocation_handler->revoke( $token, $token_type_hint );
455 }
456
457 /**
458 * Clears the site-level token for a resource bucket.
459 *
460 * @param string|null $resource_indicator The RFC 8707 resource indicator, or null for the default resource.
461 *
462 * @return void
463 *
464 * @throws Invalid_Resource_Exception If the resource indicator is malformed.
465 */
466 public function clear_site_token( ?string $resource_indicator = null ): void {
467 $this->token_storage->delete( new Resource_Indicator( $resource_indicator ) );
468 }
469
470 /**
471 * Clears every site-level token across resource buckets.
472 *
473 * @return void
474 */
475 public function clear_all_site_tokens(): void {
476 $this->token_storage->delete_all();
477 }
478
479 /**
480 * Makes an authenticated DPoP-bound request to a resource server.
481 *
482 * @param string $method The HTTP method.
483 * @param string $url The resource URL.
484 * @param Token_Set $token_set The token set to use.
485 * @param array<string, string|int|bool> $options Additional request options.
486 *
487 * @return HTTP_Response The response.
488 */
489 public function authenticated_request( string $method, string $url, Token_Set $token_set, array $options = [] ): HTTP_Response {
490 return $this->http_client->authenticated_request(
491 $method,
492 $url,
493 $token_set->get_access_token(),
494 $token_set->get_token_type(),
495 $options,
496 );
497 }
498 }
499