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
← All changes | src/myyoast-client/application/myyoast-client.php +162 -75 27.7 → trunk View file →
@@ -13,13 +13,16 @@
13 13 use Yoast\WP\SEO\MyYoast_Client\Application\Grants\Client_Credentials_Grant;
14 14 use Yoast\WP\SEO\MyYoast_Client\Application\Grants\Refresh_Token_Grant;
15 15 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
16 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;
17 18 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Site_URL_Provider_Interface;
18 19 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Token_Storage_Interface;
19 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;
20 22 use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response;
21 23 use Yoast\WP\SEO\MyYoast_Client\Domain\Registered_Client;
24 +use Yoast\WP\SEO\MyYoast_Client\Domain\Resource_Indicator;
22 25 use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set;
23 26 use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Type_Hint;
24 27 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
25 28 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
@@ -101,19 +104,27 @@
101 104 */
102 105 private $site_url_provider;
103 106
104 107 /**
108 + * The redirect URI provider port.
109 + *
110 + * @var Redirect_URI_Provider_Interface
111 + */
112 + private $redirect_uri_provider;
113 +
114 + /**
105 115 * MyYoast_Client constructor.
106 116 *
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.
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.
116 127 */
117 128 public function __construct(
118 129 Client_Registration_Interface $client_registration,
119 130 Authorization_Code_Handler $auth_code_handler,
@@ -122,54 +133,67 @@
122 133 OAuth_Server_Client_Interface $http_client,
123 134 Lock_Helper $lock_helper,
124 135 Token_Storage_Interface $token_storage,
125 136 User_Token_Storage_Interface $user_token_storage,
126 - Site_URL_Provider_Interface $site_url_provider
137 + Site_URL_Provider_Interface $site_url_provider,
138 + Redirect_URI_Provider_Interface $redirect_uri_provider
127 139 ) {
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();
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();
138 151 }
139 152
140 153 /**
141 - * Ensures the plugin is registered as an OAuth client.
154 + * Ensures the plugin is registered as an OAuth client with the provider's redirect URIs.
142 155 *
143 - * @param string[] $redirect_uris The OAuth redirect URIs to register with.
144 - *
145 156 * @return Registered_Client The registered client.
146 157 *
147 158 * @throws Registration_Failed_Exception If registration fails.
148 159 */
149 - public function ensure_registered( array $redirect_uris = [] ): Registered_Client {
150 - return $this->client_registration->ensure_registered( $redirect_uris );
160 + public function ensure_registered(): Registered_Client {
161 + return $this->client_registration->ensure_registered( $this->redirect_uri_provider->get_redirect_uris() );
151 162 }
152 163
153 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 + /**
154 176 * Whether the plugin is registered as an OAuth client.
155 177 *
156 - * @param string[] $redirect_uris Optional redirect URIs to verify against the stored registration.
157 - *
158 178 * @return bool
159 179 */
160 - public function is_registered( array $redirect_uris = [] ): bool {
161 - return $this->client_registration->is_registered( $redirect_uris );
180 + public function is_registered(): bool {
181 + return $this->client_registration->get_registered_client() !== null;
162 182 }
163 183
164 184 /**
165 - * Reads the current client registration from the server.
185 + * Refreshes the local registration status against the server.
166 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 + *
167 191 * @return array<string, string|string[]> The registration metadata.
168 192 *
169 193 * @throws Registration_Failed_Exception If the read fails.
170 194 */
171 - public function verify_registration(): array {
195 + public function refresh_registration_status(): array {
172 196 return $this->client_registration->read_registration();
173 197 }
174 198
175 199 /**
@@ -203,19 +227,20 @@
203 227
204 228 /**
205 229 * Builds the authorization URL for the user authorization flow.
206 230 *
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.
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.
211 235 *
212 236 * @return string The authorization URL.
213 237 *
214 238 * @throws Authorization_Flow_Exception If registration, discovery, or parameter validation fails.
239 + * @throws Invalid_Resource_Exception If the resource indicator is malformed.
215 240 */
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 );
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 );
218 243 }
219 244
220 245 /**
221 246 * Exchanges an authorization code for tokens and stores them for the user.
@@ -234,42 +259,63 @@
234 259 $this->user_token_storage->store( $user_id, $token_set );
235 260 return $token_set;
236 261 }
237 262
263 + // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- Token_Storage_Exception is thrown by an injected service, not directly here.
264 +
238 265 /**
239 266 * Returns a valid site-level access token (client_credentials).
240 267 *
241 - * @param string[] $scopes The service:* scopes to request.
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.
242 270 *
243 271 * @return Token_Set The site-level token set.
244 272 *
245 - * @throws Token_Request_Failed_Exception If the token request fails.
246 - * @throws Token_Storage_Exception If encrypting the token set for storage fails.
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.
247 275 */
248 - public function get_site_token( array $scopes = [] ): Token_Set {
249 - $cached = $this->token_storage->get();
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 );
250 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 ) ] );
251 282 return $cached;
252 283 }
284 + $this->logger->debug( 'MyYoast site token: cache miss; preparing to request a fresh token (scopes: {scopes}).', [ 'scopes' => \implode( ' ', $scopes ) ] );
253 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 +
254 292 $grant = new Client_Credentials_Grant( $scopes, $this->site_url_provider->get() );
255 - $token_set = $this->grant_handler->request_token( $grant );
293 + $token_set = $this->grant_handler->request_token( $grant, $indicator );
256 294 $this->token_storage->store( $token_set );
257 295
296 + $this->logger->debug( 'MyYoast site token: fresh token issued and cached.' );
297 +
258 298 return $token_set;
259 299 }
260 300
301 + // phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
302 +
261 303 /**
262 304 * Returns a valid user-level access token, auto-refreshing if expired.
263 305 *
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.
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.
267 310 *
268 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.
269 314 */
270 - public function get_user_token( int $user_id, array $required_scopes = [] ): ?Token_Set {
271 - $token_set = $this->user_token_storage->get( $user_id );
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 );
272 318 if ( $token_set === null ) {
273 319 return null;
274 320 }
275 321
@@ -286,28 +332,24 @@
286 332 if ( $refresh_token === null ) {
287 333 return null;
288 334 }
289 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 );
290 344 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 345 $new_token_set = $this->lock_helper->execute(
297 346 $lock_key,
298 - function () use ( $grant ) {
299 - return $this->grant_handler->request_token( $grant );
347 + function () use ( $grant, $bound_resource ) {
348 + return $this->grant_handler->request_token( $grant, $bound_resource );
300 349 },
301 350 self::REFRESH_LOCK_TTL_IN_SECONDS,
302 351 );
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 352 } catch ( Lock_Timeout_Exception $e ) {
311 353 // Concurrent refresh in progress, treat as transient failure.
312 354 $this->logger->debug( 'Skipping token refresh for user {user_id}: concurrent refresh in progress.', [ 'user_id' => $user_id ] );
313 355 return null;
@@ -314,9 +356,9 @@
314 356 } catch ( Token_Request_Failed_Exception $e ) {
315 357 if ( $e->get_error_code() === 'invalid_grant' ) {
316 358 if ( $token_set->get_error_count() >= 1 ) {
317 359 $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 );
360 + $this->user_token_storage->delete( $user_id, $indicator );
319 361 return null;
320 362 }
321 363
322 364 try {
@@ -328,30 +370,44 @@
328 370 }
329 371
330 372 return null;
331 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;
332 381 }
333 382
334 383 /**
335 - * Whether the given user has authorized with MyYoast.
384 + * Whether the given user has authorized with MyYoast for a resource.
336 385 *
337 - * @param int $user_id The WordPress user ID.
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.
338 388 *
339 389 * @return bool
390 + *
391 + * @throws Invalid_Resource_Exception If the resource indicator is malformed.
340 392 */
341 - public function has_user_token( int $user_id ): bool {
342 - return $this->user_token_storage->get( $user_id ) !== null;
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;
343 395 }
344 396
345 397 /**
346 - * Revokes the user's tokens and clears storage.
398 + * Revokes the user's tokens for a resource and clears storage.
347 399 *
348 - * @param int $user_id The WordPress user ID.
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.
349 402 *
350 403 * @return void
404 + *
405 + * @throws Invalid_Resource_Exception If the resource indicator is malformed.
351 406 */
352 - public function revoke_user_token( int $user_id ): void {
353 - $token_set = $this->user_token_storage->get( $user_id );
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 );
354 410 if ( $token_set === null ) {
355 411 return;
356 412 }
357 413 // Assume tokens are opaque for forwards compatibility. This operation is noop for JWTs, as they are only revoked upon expiration.
@@ -359,12 +415,30 @@
359 415 if ( $token_set->get_refresh_token() !== null ) {
360 416 $this->revocation_handler->revoke( $token_set->get_refresh_token(), Token_Type_Hint::REFRESH_TOKEN );
361 417 }
362 418
363 - $this->user_token_storage->delete( $user_id );
419 + $this->user_token_storage->delete( $user_id, $indicator );
364 420 }
365 421
366 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 + /**
367 441 * Revokes a token at the authorization server.
368 442 *
369 443 * @param string $token The token to revoke.
370 444 * @param string $token_type_hint A Token_Type_Hint constant.
@@ -380,14 +454,27 @@
380 454 return $this->revocation_handler->revoke( $token, $token_type_hint );
381 455 }
382 456
383 457 /**
384 - * Clears the site-level token.
458 + * Clears the site-level token for a resource bucket.
385 459 *
460 + * @param string|null $resource_indicator The RFC 8707 resource indicator, or null for the default resource.
461 + *
386 462 * @return void
463 + *
464 + * @throws Invalid_Resource_Exception If the resource indicator is malformed.
387 465 */
388 - public function clear_site_token(): void {
389 - $this->token_storage->delete();
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();
390 477 }
391 478
392 479 /**
393 480 * Makes an authenticated DPoP-bound request to a resource server.