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/infrastructure/registration/client-registration.php +227 -43 28.0 → trunk View file →
@@ -6,12 +6,16 @@
6 6 use InvalidArgumentException;
7 7 use Yoast\WP\SEO\Exceptions\Locking\Lock_Timeout_Exception;
8 8 use Yoast\WP\SEO\Helpers\Lock_Helper;
9 9 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Discovery_Failed_Exception;
10 +use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Rate_Limited_Exception;
10 11 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Failed_Exception;
12 +use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Not_Found_Exception;
13 +use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Temporarily_Unavailable_Exception;
11 14 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception;
12 15 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
13 16 use Yoast\WP\SEO\MyYoast_Client\Domain\Auth_Token_Type;
17 +use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response;
14 18 use Yoast\WP\SEO\MyYoast_Client\Domain\Registered_Client;
15 19 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption;
16 20 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption_Exception;
17 21 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Key_Pair_Manager;
@@ -174,8 +178,9 @@
174 178 $stored['client_id'],
175 179 $rat,
176 180 ( $stored['registration_client_uri'] ?? '' ),
177 181 ( $stored['metadata'] ?? [] ),
182 + ( $stored['validated_uris'] ?? [] ),
178 183 );
179 184 } catch ( InvalidArgumentException $e ) {
180 185 $this->logger->error( 'Stored registration data is invalid, clearing registration: {error}', [ 'error' => $e->getMessage() ] );
181 186 $this->forget_registration();
@@ -185,49 +190,25 @@
185 190 return $this->cached_registered_clients[ $option_key ];
186 191 }
187 192
188 193 /**
189 - * Whether the plugin is registered as an OAuth client.
194 + * Ensures the registration's redirect URIs exactly match the given set.
190 195 *
191 - * When redirect URIs are provided, also verifies that all of them
192 - * are included in the stored registration.
196 + * Performs DCR when not yet registered; when registered with a different set, updates the
197 + * registration in place via RFC 7592 (preserving the client_id, RAT, and key pair, and the
198 + * verification state of unchanged URIs) rather than re-registering.
193 199 *
194 - * @param string[] $redirect_uris Optional redirect URIs to verify against the stored registration.
200 + * @param string[] $redirect_uris The exact set of OAuth redirect URIs the registration should have.
195 201 *
196 - * @return bool
197 - */
198 - public function is_registered( array $redirect_uris = [] ): bool {
199 - $registered_client = $this->get_registered_client();
200 - if ( $registered_client === null ) {
201 - return false;
202 - }
203 -
204 - if ( $redirect_uris === [] ) {
205 - return true;
206 - }
207 -
208 - $stored_uris = ( $registered_client->get_metadata()['redirect_uris'] ?? [] );
209 -
210 - return \array_diff( $redirect_uris, $stored_uris ) === [];
211 - }
212 -
213 - /**
214 - * Ensures the plugin is registered, performing DCR if needed.
215 - *
216 - * @param string[] $redirect_uris The OAuth redirect URIs to register with.
217 - *
218 202 * @return Registered_Client The client credentials.
219 203 *
220 204 * @throws Registration_Failed_Exception If registration fails.
221 205 */
222 - public function ensure_registered( array $redirect_uris = [] ): Registered_Client {
223 - if ( $this->is_registered( $redirect_uris ) ) {
224 - return $this->get_registered_client();
225 - }
206 + public function ensure_registered( array $redirect_uris ): Registered_Client {
207 + $registered_client = $this->get_registered_client();
226 208
227 - // Registered with stale redirect URIs — deregister first.
228 - if ( $this->get_registered_client() !== null ) {
229 - $this->deregister();
209 + if ( $registered_client !== null && $registered_client->has_redirect_uris( $redirect_uris ) ) {
210 + return $registered_client;
230 211 }
231 212
232 213 if ( $redirect_uris === [] ) {
233 214 throw new Registration_Failed_Exception( 'At least one redirect URI is required for initial registration.' );
@@ -232,8 +213,14 @@
232 213 if ( $redirect_uris === [] ) {
233 214 throw new Registration_Failed_Exception( 'At least one redirect URI is required for initial registration.' );
234 215 }
235 216
217 + // Registered, but the redirect-URI set differs — update in place (RFC 7592 PUT) so the
218 + // client_id survives and unchanged URIs keep their verification state and tokens.
219 + if ( $registered_client !== null ) {
220 + return $this->update_redirect_uris( $redirect_uris );
221 + }
222 +
236 223 return $this->register( $redirect_uris );
237 224 }
238 225
239 226 /**
@@ -240,9 +227,11 @@
240 227 * Reads the current client registration from the server (RFC 7592 GET).
241 228 *
242 229 * @return array<string, string|string[]> The registration metadata.
243 230 *
244 - * @throws Registration_Failed_Exception If the read fails.
231 + * @throws Registration_Not_Found_Exception If the server reports the registration is gone (HTTP 401/404).
232 + * @throws Rate_Limited_Exception If the server rate-limited the request (HTTP 429).
233 + * @throws Registration_Failed_Exception If the read fails for any other reason.
245 234 */
246 235 public function read_registration(): array {
247 236 $registered_client = $this->get_registered_client();
248 237 if ( $registered_client === null ) {
@@ -268,14 +257,20 @@
268 257
269 258 if ( $result->get_status() === 401 || $result->get_status() === 404 ) {
270 259 $this->logger->warning( 'Registration is no longer valid (HTTP {status}), clearing local registration.', [ 'status' => $result->get_status() ] );
271 260 $this->forget_registration();
272 - throw new Registration_Failed_Exception(
261 + throw new Registration_Not_Found_Exception(
273 262 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
274 263 'Registration is no longer valid (HTTP ' . $result->get_status() . ').',
275 264 );
276 265 }
277 266
267 + if ( $result->get_status() === 429 ) {
268 + $this->logger->warning( 'Registration read was rate-limited (HTTP 429).' );
269 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
270 + throw new Rate_Limited_Exception( 'Registration read was rate-limited (HTTP 429).', $this->get_retry_after_seconds( $result ) );
271 + }
272 +
278 273 if ( ! $result->is_successful() ) {
279 274 $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
280 275 throw new Registration_Failed_Exception(
281 276 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
@@ -283,12 +278,18 @@
283 278 );
284 279 }
285 280
286 281 $body = $result->get_body();
287 - if ( ! \is_array( $body ) ) {
282 + if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) {
288 283 throw new Registration_Failed_Exception( 'Invalid response from registration endpoint.' );
289 284 }
290 285
286 + // The server is authoritative: heal local data that has drifted from it (for example when a
287 + // site migration rewrote the stored redirect URIs directly in the database, bypassing the
288 + // registration round-trip). The GET body carries no RAT, so store_credentials preserves the
289 + // stored one.
290 + $this->store_credentials( $body );
291 +
291 292 return $body;
292 293 }
293 294
294 295 /**
@@ -355,8 +356,85 @@
355 356 return $this->store_credentials( $body );
356 357 }
357 358
358 359 /**
360 + * Updates the registered redirect URIs in place (RFC 7592 PUT).
361 + *
362 + * Preserves the client_id, registration access token, and key pair. Verification state for
363 + * URIs that remain in the set is preserved; URIs no longer present are dropped.
364 + *
365 + * @param string[] $redirect_uris The new exact set of redirect URIs.
366 + *
367 + * @return Registered_Client The updated credentials.
368 + *
369 + * @throws Registration_Not_Found_Exception If the server reports the registration is gone (HTTP 401/404).
370 + * @throws Rate_Limited_Exception If the server rate-limited the request (HTTP 429).
371 + * @throws Registration_Failed_Exception If the update fails for any other reason.
372 + */
373 + private function update_redirect_uris( array $redirect_uris ): Registered_Client {
374 + $registered_client = $this->get_registered_client();
375 + if ( $registered_client === null ) {
376 + throw new Registration_Failed_Exception( 'Not registered.' );
377 + }
378 +
379 + // Per RFC 7592 §2.2, server-assigned fields MUST NOT be included; keep the existing key pair.
380 + $request_body = $this->build_update_request_body( $registered_client->get_metadata() );
381 + $request_body['redirect_uris'] = \array_values( $redirect_uris );
382 + $request_body['software_statement'] = $this->issuer_config->get_software_statement();
383 +
384 + // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for HTTP request body, not user-facing output.
385 + $json = \wp_json_encode( $request_body );
386 + if ( $json === false ) {
387 + throw new Registration_Failed_Exception( 'Failed to JSON-encode registration request body.' );
388 + }
389 +
390 + $result = $this->http_client->authenticated_request(
391 + 'PUT',
392 + $registered_client->get_registration_client_uri(),
393 + $registered_client->get_registration_access_token(),
394 + Auth_Token_Type::BEARER,
395 + [
396 + 'headers' => [
397 + 'Content-Type' => 'application/json',
398 + 'Accept' => 'application/json',
399 + ],
400 + 'body' => $json,
401 + 'timeout' => 15,
402 + ],
403 + );
404 +
405 + if ( $result->get_status() === 401 || $result->get_status() === 404 ) {
406 + $this->logger->warning( 'Registration is no longer valid on update (HTTP {status}), clearing local registration.', [ 'status' => $result->get_status() ] );
407 + $this->forget_registration();
408 + throw new Registration_Not_Found_Exception(
409 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
410 + 'Registration is no longer valid (HTTP ' . $result->get_status() . ').',
411 + );
412 + }
413 +
414 + if ( $result->get_status() === 429 ) {
415 + $this->logger->warning( 'Registration update was rate-limited (HTTP 429).' );
416 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
417 + throw new Rate_Limited_Exception( 'Registration update was rate-limited (HTTP 429).', $this->get_retry_after_seconds( $result ) );
418 + }
419 +
420 + if ( ! $result->is_successful() ) {
421 + $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
422 + throw new Registration_Failed_Exception(
423 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
424 + \sprintf( 'Redirect URI update returned HTTP %d: %s', $result->get_status(), $error_message ),
425 + );
426 + }
427 +
428 + $body = $result->get_body();
429 + if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) {
430 + throw new Registration_Failed_Exception( 'Redirect URI update returned invalid response.' );
431 + }
432 +
433 + return $this->store_credentials( $body );
434 + }
435 +
436 + /**
359 437 * Deletes the client registration from the server (RFC 7592 DELETE) and clears local data.
360 438 *
361 439 * @return bool True if deleted or already not registered, false on network failure.
362 440 */
@@ -413,25 +491,101 @@
413 491 $this->key_pair_manager->rotate_key_pair( Key_Pair_Manager::PURPOSE_DPOP );
414 492 }
415 493
416 494 /**
495 + * Whether the given redirect URI has completed the OAuth authorization-code flow on this site.
496 + *
497 + * The state lives on the stored registration: it is pruned to the current redirect-URI set
498 + * whenever those change, and invalidated when the client is deregistered.
499 + *
500 + * @param string $redirect_uri The redirect URI to check.
501 + *
502 + * @return bool
503 + */
504 + public function is_uri_validated( string $redirect_uri ): bool {
505 + $registered_client = $this->get_registered_client();
506 +
507 + return $registered_client !== null && $registered_client->is_uri_validated( $redirect_uri );
508 + }
509 +
510 + /**
511 + * Records that the given redirect URI has completed the authorization-code flow.
512 + *
513 + * No-op when the site is not registered or the URI was already recorded. Idempotent:
514 + * `update_option()` short-circuits when the stored value is unchanged.
515 + *
516 + * @param string $redirect_uri The redirect URI that completed the auth-code flow.
517 + *
518 + * @return void
519 + */
520 + public function mark_uri_validated( string $redirect_uri ): void {
521 + $registered_client = $this->get_registered_client();
522 + if ( $registered_client === null ) {
523 + return;
524 + }
525 +
526 + $validated_uris = $registered_client->get_validated_uris();
527 + if ( \in_array( $redirect_uri, $validated_uris, true ) ) {
528 + return;
529 + }
530 +
531 + $validated_uris[] = $redirect_uri;
532 +
533 + $option_key = $this->get_option_key();
534 + $stored = \get_option( $option_key, [] );
535 + if ( \is_array( $stored ) ) {
536 + $stored['validated_uris'] = $validated_uris;
537 + \update_option( $option_key, $stored, false );
538 + }
539 +
540 + $this->cached_registered_clients[ $option_key ] = $registered_client->with_validated_uris( $validated_uris );
541 + }
542 +
543 + /**
417 544 * Stores the DCR response credentials securely.
418 545 *
546 + * A registration read (RFC 7592 GET) response carries no registration access token; when the
547 + * body omits the RAT, the existing stored RAT is preserved rather than overwritten with an
548 + * empty value.
549 + *
419 550 * @param array<string, string|array<string>> $response_body The parsed DCR response body.
420 551 *
421 552 * @return Registered_Client The stored credentials.
422 553 */
423 554 private function store_credentials( array $response_body ): Registered_Client {
424 - $option_key = $this->get_option_key();
425 - $encrypted_rat = $this->encryption->encrypt(
426 - ( $response_body['registration_access_token'] ?? '' ),
427 - self::ENCRYPTION_CONTEXT,
428 - );
555 + $option_key = $this->get_option_key();
556 + $existing = $this->get_registered_client();
429 557
558 + // The RFC 7592 GET response never re-sends the RAT, so a missing key means "keep the stored
559 + // one" — encrypting the absent value would brick every future management call. Only a body
560 + // that explicitly carries a RAT (DCR / PUT) replaces it.
561 + if ( \array_key_exists( 'registration_access_token', $response_body ) ) {
562 + $rat = $response_body['registration_access_token'];
563 + $encrypted_rat = $this->encryption->encrypt( $rat, self::ENCRYPTION_CONTEXT );
564 + }
565 + else {
566 + // Reuse the already-decrypted RAT and its stored ciphertext rather than re-encrypting.
567 + $rat = ( $existing !== null ) ? $existing->get_registration_access_token() : '';
568 + $stored = \get_option( $option_key, [] );
569 + $encrypted_rat = ( \is_array( $stored ) ) ? ( $stored['encrypted_rat'] ?? '' ) : '';
570 + }
571 +
430 572 // Strip the RAT from metadata — it is stored encrypted separately.
431 573 $metadata = $response_body;
432 574 unset( $metadata['registration_access_token'] );
433 575
576 + // Preserve validation state across an in-place update or key rotation (same client_id), but
577 + // reset it for a fresh registration: a new client_id means the redirect URIs must be
578 + // re-validated from scratch. Always prune to the new redirect-URI set so a removed URI loses
579 + // its verification and an added one starts unverified.
580 + $validated_uris = [];
581 + if ( $existing !== null && $existing->get_client_id() === $response_body['client_id'] ) {
582 + $new_redirect_uris = ( $metadata['redirect_uris'] ?? [] );
583 + if ( \is_array( $new_redirect_uris ) ) {
584 + $validated_uris = \array_values( \array_intersect( $existing->get_validated_uris(), $new_redirect_uris ) );
585 + }
586 + }
587 +
434 588 \update_option(
435 589 $option_key,
436 590 [
437 591 'client_id' => $response_body['client_id'],
@@ -437,8 +591,9 @@
437 591 'client_id' => $response_body['client_id'],
438 592 'encrypted_rat' => $encrypted_rat,
439 593 'registration_client_uri' => ( $response_body['registration_client_uri'] ?? '' ),
440 594 'metadata' => $metadata,
595 + 'validated_uris' => $validated_uris,
441 596 ],
442 597 false,
443 598 );
444 599
@@ -443,11 +598,12 @@
443 598 );
444 599
445 600 $this->cached_registered_clients[ $option_key ] = new Registered_Client(
446 601 $response_body['client_id'],
447 - ( $response_body['registration_access_token'] ?? '' ),
602 + $rat,
448 603 ( $response_body['registration_client_uri'] ?? '' ),
449 604 $metadata,
605 + $validated_uris,
450 606 );
451 607
452 608 return $this->cached_registered_clients[ $option_key ];
453 609 }
@@ -461,8 +617,20 @@
461 617 return self::OPTION_KEY_PREFIX . $this->issuer_config->get_issuer_key();
462 618 }
463 619
464 620 /**
621 + * Extracts the `Retry-After` value (in seconds) from a 429 response, if any.
622 + *
623 + * @param HTTP_Response $result The 429 response.
624 + *
625 + * @return int|null Seconds until retry, or null when absent or unparseable.
626 + */
627 + private function get_retry_after_seconds( HTTP_Response $result ): ?int {
628 + $headers = $result->get_headers();
629 + return Rate_Limited_Exception::parse_retry_after( ( $headers['retry-after'] ?? null ) );
630 + }
631 +
632 + /**
465 633 * Performs the actual DCR registration request.
466 634 *
467 635 * @param string[] $redirect_uris The OAuth redirect URIs to register.
468 636 *
@@ -467,9 +635,11 @@
467 635 * @param string[] $redirect_uris The OAuth redirect URIs to register.
468 636 *
469 637 * @return Registered_Client The registration result.
470 638 *
471 - * @throws Registration_Failed_Exception If registration fails.
639 + * @throws Registration_Temporarily_Unavailable_Exception If the server temporarily refuses new registrations (HTTP 503).
640 + * @throws Rate_Limited_Exception If the server rate-limited the request (HTTP 429).
641 + * @throws Registration_Failed_Exception If registration fails for any other reason.
472 642 */
473 643 private function do_register( array $redirect_uris ): Registered_Client {
474 644 try {
475 645 $registration_endpoint = $this->discovery_client->get_document()->get_registration_endpoint();
@@ -521,8 +691,22 @@
521 691 if ( $result->is_transport_failure() ) {
522 692 $error_message = (string) $result->get_body_value( 'error_description', '' );
523 693 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
524 694 throw new Registration_Failed_Exception( 'DCR request failed: ' . $error_message );
695 + }
696 +
697 + // The server temporarily refuses new registrations (rollout brake engaged).
698 + // Surface it as a typed transient failure carrying the (display-only) retry hint.
699 + if ( $result->get_status() === 503 && $result->get_body_value( 'error' ) === 'temporarily_unavailable' ) {
700 + $error_message = (string) $result->get_body_value( 'error_description', 'Client registration is temporarily disabled.' );
701 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
702 + throw new Registration_Temporarily_Unavailable_Exception( $error_message, $this->get_retry_after_seconds( $result ) );
703 + }
704 +
705 + if ( $result->get_status() === 429 ) {
706 + $this->logger->warning( 'DCR was rate-limited (HTTP 429).' );
707 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
708 + throw new Rate_Limited_Exception( 'DCR was rate-limited (HTTP 429).', $this->get_retry_after_seconds( $result ) );
525 709 }
526 710
527 711 if ( $result->get_status() !== 201 ) {
528 712 $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );