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

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