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 / infrastructure / registration / client-registration.php

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

572 lines 19.7 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.TooLong -- Needed in the folder structure.
3
4 namespace Yoast\WP\SEO\MyYoast_Client\Infrastructure\Registration;
5
6 use InvalidArgumentException;
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\Discovery_Failed_Exception;
10 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Failed_Exception;
11 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception;
12 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
13 use Yoast\WP\SEO\MyYoast_Client\Domain\Auth_Token_Type;
14 use Yoast\WP\SEO\MyYoast_Client\Domain\Registered_Client;
15 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption;
16 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption_Exception;
17 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Key_Pair_Manager;
18 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Http\HTTP_Client;
19 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Discovery_Client;
20 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Issuer_Config;
21 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
22 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
23 use YoastSEO_Vendor\Psr\Log\NullLogger;
24
25 /**
26 * Manages the full OAuth client registration lifecycle (RFC 7591 + RFC 7592).
27 *
28 * Handles initial Dynamic Client Registration, credential storage, key rotation,
29 * registration verification, and deregistration. Each WordPress site (or subsite
30 * in multisite) registers its own client_id. Uses lazy registration with a
31 * database-backed exclusive lock.
32 */
33 class Client_Registration implements Client_Registration_Interface, LoggerAwareInterface {
34 use LoggerAwareTrait;
35
36 private const OPTION_KEY_PREFIX = 'wpseo_myyoast_client_registration_';
37 private const ENCRYPTION_CONTEXT = 'yoast-myyoast-registration-credentials';
38 private const DCR_LOCK_TTL_IN_SECONDS = \MINUTE_IN_SECONDS;
39
40 /**
41 * The discovery client.
42 *
43 * @var Discovery_Client
44 */
45 private $discovery_client;
46
47 /**
48 * The key pair manager.
49 *
50 * @var Key_Pair_Manager
51 */
52 private $key_pair_manager;
53
54 /**
55 * The encryption service.
56 *
57 * @var Encryption
58 */
59 private $encryption;
60
61 /**
62 * The issuer configuration.
63 *
64 * @var Issuer_Config
65 */
66 private $issuer_config;
67
68 /**
69 * The lock helper.
70 *
71 * @var Lock_Helper
72 */
73 private $lock_helper;
74
75 /**
76 * The HTTP client.
77 *
78 * @var HTTP_Client
79 */
80 private $http_client;
81
82 /**
83 * In-memory cache for registered clients, keyed by option key (avoids repeated decryption within a single request).
84 *
85 * Each entry is Registered_Client|null (null = checked but not registered).
86 * Absence of a key means not yet loaded.
87 *
88 * @var array<string, Registered_Client|null>
89 */
90 private $cached_registered_clients = [];
91
92 /**
93 * Client_Registration constructor.
94 *
95 * @param Discovery_Client $discovery_client The discovery client.
96 * @param Key_Pair_Manager $key_pair_manager The key pair manager.
97 * @param Encryption $encryption The encryption service.
98 * @param Issuer_Config $issuer_config The issuer configuration.
99 * @param Lock_Helper $lock_helper The lock helper.
100 * @param HTTP_Client $http_client The HTTP client.
101 */
102 public function __construct(
103 Discovery_Client $discovery_client,
104 Key_Pair_Manager $key_pair_manager,
105 Encryption $encryption,
106 Issuer_Config $issuer_config,
107 Lock_Helper $lock_helper,
108 HTTP_Client $http_client
109 ) {
110 $this->discovery_client = $discovery_client;
111 $this->key_pair_manager = $key_pair_manager;
112 $this->encryption = $encryption;
113 $this->issuer_config = $issuer_config;
114 $this->lock_helper = $lock_helper;
115 $this->http_client = $http_client;
116 $this->logger = new NullLogger();
117 }
118
119 /**
120 * Registers the plugin as an OAuth client via DCR (RFC 7591).
121 *
122 * Uses a database-backed exclusive lock to prevent concurrent registrations.
123 *
124 * @param string[] $redirect_uris The OAuth redirect URIs to register.
125 *
126 * @return Registered_Client The registration result.
127 *
128 * @throws Registration_Failed_Exception If registration fails.
129 */
130 public function register( array $redirect_uris ): Registered_Client {
131 // Acquire lock and execute registration.
132 try {
133 return $this->lock_helper->execute(
134 'wpseo_myyoast_dcr_lock:' . $this->issuer_config->get_issuer_key() . ':' . \get_current_blog_id(),
135 function () use ( $redirect_uris ) {
136 return $this->do_register( $redirect_uris );
137 },
138 self::DCR_LOCK_TTL_IN_SECONDS,
139 );
140 } catch ( Lock_Timeout_Exception $e ) {
141 $this->logger->warning( 'DCR lock contention: another registration is already in progress.' );
142 throw new Registration_Failed_Exception( 'Another registration is already in progress.' );
143 }
144 }
145
146 /**
147 * Returns the stored registered client, or null if not registered.
148 *
149 * @return Registered_Client|null The registered client, or null if not registered.
150 */
151 public function get_registered_client(): ?Registered_Client {
152 $option_key = $this->get_option_key();
153
154 if ( \array_key_exists( $option_key, $this->cached_registered_clients ) ) {
155 return $this->cached_registered_clients[ $option_key ];
156 }
157
158 $stored = \get_option( $option_key, false );
159 if ( ! \is_array( $stored ) || empty( $stored['client_id'] ) || empty( $stored['encrypted_rat'] ) ) {
160 $this->cached_registered_clients[ $option_key ] = null;
161 return null;
162 }
163
164 try {
165 $rat = $this->encryption->decrypt( $stored['encrypted_rat'], self::ENCRYPTION_CONTEXT );
166 } catch ( Encryption_Exception $e ) {
167 $this->logger->error( 'Failed to decrypt registration access token, clearing registration: {error}', [ 'error' => $e->getMessage() ] );
168 $this->forget_registration();
169 return null;
170 }
171
172 try {
173 $this->cached_registered_clients[ $option_key ] = new Registered_Client(
174 $stored['client_id'],
175 $rat,
176 ( $stored['registration_client_uri'] ?? '' ),
177 ( $stored['metadata'] ?? [] ),
178 );
179 } catch ( InvalidArgumentException $e ) {
180 $this->logger->error( 'Stored registration data is invalid, clearing registration: {error}', [ 'error' => $e->getMessage() ] );
181 $this->forget_registration();
182 return null;
183 }
184
185 return $this->cached_registered_clients[ $option_key ];
186 }
187
188 /**
189 * Whether the plugin is registered as an OAuth client.
190 *
191 * When redirect URIs are provided, also verifies that all of them
192 * are included in the stored registration.
193 *
194 * @param string[] $redirect_uris Optional redirect URIs to verify against the stored registration.
195 *
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 * @return Registered_Client The client credentials.
219 *
220 * @throws Registration_Failed_Exception If registration fails.
221 */
222 public function ensure_registered( array $redirect_uris = [] ): Registered_Client {
223 if ( $this->is_registered( $redirect_uris ) ) {
224 return $this->get_registered_client();
225 }
226
227 // Registered with stale redirect URIs — deregister first.
228 if ( $this->get_registered_client() !== null ) {
229 $this->deregister();
230 }
231
232 if ( $redirect_uris === [] ) {
233 throw new Registration_Failed_Exception( 'At least one redirect URI is required for initial registration.' );
234 }
235
236 return $this->register( $redirect_uris );
237 }
238
239 /**
240 * Reads the current client registration from the server (RFC 7592 GET).
241 *
242 * @return array<string, string|string[]> The registration metadata.
243 *
244 * @throws Registration_Failed_Exception If the read fails.
245 */
246 public function read_registration(): array {
247 $registered_client = $this->get_registered_client();
248 if ( $registered_client === null ) {
249 throw new Registration_Failed_Exception( 'Not registered.' );
250 }
251
252 $result = $this->http_client->authenticated_request(
253 'GET',
254 $registered_client->get_registration_client_uri(),
255 $registered_client->get_registration_access_token(),
256 Auth_Token_Type::BEARER,
257 [
258 'timeout' => 10,
259 'headers' => [ 'Accept' => 'application/json' ],
260 ],
261 );
262
263 if ( $result->is_transport_failure() ) {
264 $error_message = (string) $result->get_body_value( 'error_description', '' );
265 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
266 throw new Registration_Failed_Exception( 'Failed to read registration: ' . $error_message );
267 }
268
269 if ( $result->get_status() === 401 || $result->get_status() === 404 ) {
270 $this->logger->warning( 'Registration is no longer valid (HTTP {status}), clearing local registration.', [ 'status' => $result->get_status() ] );
271 $this->forget_registration();
272 throw new Registration_Failed_Exception(
273 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
274 'Registration is no longer valid (HTTP ' . $result->get_status() . ').',
275 );
276 }
277
278 if ( ! $result->is_successful() ) {
279 $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
280 throw new Registration_Failed_Exception(
281 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
282 \sprintf( 'Registration read returned HTTP %d: %s', $result->get_status(), $error_message ),
283 );
284 }
285
286 $body = $result->get_body();
287 if ( ! \is_array( $body ) ) {
288 throw new Registration_Failed_Exception( 'Invalid response from registration endpoint.' );
289 }
290
291 return $body;
292 }
293
294 /**
295 * Rotates the registration key pair by updating the registration with a new JWKS (RFC 7592 PUT).
296 *
297 * @return Registered_Client The updated credentials (with new RAT).
298 *
299 * @throws Registration_Failed_Exception If the rotation fails.
300 */
301 public function rotate_registration_keys(): Registered_Client {
302 $registered_client = $this->get_registered_client();
303 if ( $registered_client === null ) {
304 throw new Registration_Failed_Exception( 'Not registered.' );
305 }
306
307 // Generate a new key pair in memory — only persist after server confirms.
308 $new_key_pair = $this->key_pair_manager->generate_key_pair();
309 $new_jwk = $this->key_pair_manager->get_public_key_jwk( $new_key_pair );
310
311 // Build the update request body from stored metadata with the new JWKS.
312 // Per RFC 7592 §2.2, server-assigned fields MUST NOT be included.
313 $request_body = $this->build_update_request_body( $registered_client->get_metadata() );
314 $request_body['jwks'] = [ 'keys' => [ $new_jwk ] ];
315 $request_body['software_statement'] = $this->issuer_config->get_software_statement();
316
317 // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for HTTP request body, not user-facing output.
318 $json = \wp_json_encode( $request_body );
319 if ( $json === false ) {
320 throw new Registration_Failed_Exception( 'Failed to JSON-encode registration request body.' );
321 }
322
323 $result = $this->http_client->authenticated_request(
324 'PUT',
325 $registered_client->get_registration_client_uri(),
326 $registered_client->get_registration_access_token(),
327 Auth_Token_Type::BEARER,
328 [
329 'headers' => [
330 'Content-Type' => 'application/json',
331 'Accept' => 'application/json',
332 ],
333 'body' => $json,
334 'timeout' => 15,
335 ],
336 );
337
338 if ( ! $result->is_successful() ) {
339 $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
340 throw new Registration_Failed_Exception(
341 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
342 \sprintf( 'Key rotation returned HTTP %d: %s', $result->get_status(), $error_message ),
343 );
344 }
345
346 $body = $result->get_body();
347 if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) {
348 throw new Registration_Failed_Exception( 'Key rotation returned invalid response.' );
349 }
350
351 // Server confirmed — now persist the new key pair locally.
352 $this->key_pair_manager->store_key_pair( Key_Pair_Manager::PURPOSE_REGISTRATION, $new_key_pair );
353
354 // Store the new RAT atomically.
355 return $this->store_credentials( $body );
356 }
357
358 /**
359 * Deletes the client registration from the server (RFC 7592 DELETE) and clears local data.
360 *
361 * @return bool True if deleted or already not registered, false on network failure.
362 */
363 public function deregister(): bool {
364 $credentials = $this->get_registered_client();
365 if ( $credentials === null ) {
366 return true;
367 }
368
369 $result = $this->http_client->authenticated_request(
370 'DELETE',
371 $credentials->get_registration_client_uri(),
372 $credentials->get_registration_access_token(),
373 Auth_Token_Type::BEARER,
374 [ 'timeout' => 10 ],
375 );
376
377 $this->forget_registration();
378
379 return ! $result->is_transport_failure();
380 }
381
382 /**
383 * Deletes the stored registration credentials.
384 *
385 * @return void
386 */
387 public function forget_registration(): void {
388 unset( $this->cached_registered_clients[ $this->get_option_key() ] );
389 \delete_option( $this->get_option_key() );
390 }
391
392 /**
393 * Deletes all local registration data (credentials, key pairs, caches).
394 *
395 * @return void
396 */
397 public function delete_local_data(): void {
398 $this->forget_registration();
399 $this->key_pair_manager->delete_key_pair( Key_Pair_Manager::PURPOSE_REGISTRATION );
400 $this->key_pair_manager->delete_key_pair( Key_Pair_Manager::PURPOSE_DPOP );
401 $this->discovery_client->invalidate_cache();
402 $suffix = $this->issuer_config->get_issuer_key();
403 \delete_transient( 'wpseo_myyoast_jwks_' . $suffix );
404 \delete_transient( 'wpseo_myyoast_dpop_nonce_' . $suffix );
405 }
406
407 /**
408 * Rotates the DPoP key pair (local only, no server coordination).
409 *
410 * @return void
411 */
412 public function rotate_dpop_keys(): void {
413 $this->key_pair_manager->rotate_key_pair( Key_Pair_Manager::PURPOSE_DPOP );
414 }
415
416 /**
417 * Stores the DCR response credentials securely.
418 *
419 * @param array<string, string|array<string>> $response_body The parsed DCR response body.
420 *
421 * @return Registered_Client The stored credentials.
422 */
423 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 );
429
430 // Strip the RAT from metadata — it is stored encrypted separately.
431 $metadata = $response_body;
432 unset( $metadata['registration_access_token'] );
433
434 \update_option(
435 $option_key,
436 [
437 'client_id' => $response_body['client_id'],
438 'encrypted_rat' => $encrypted_rat,
439 'registration_client_uri' => ( $response_body['registration_client_uri'] ?? '' ),
440 'metadata' => $metadata,
441 ],
442 false,
443 );
444
445 $this->cached_registered_clients[ $option_key ] = new Registered_Client(
446 $response_body['client_id'],
447 ( $response_body['registration_access_token'] ?? '' ),
448 ( $response_body['registration_client_uri'] ?? '' ),
449 $metadata,
450 );
451
452 return $this->cached_registered_clients[ $option_key ];
453 }
454
455 /**
456 * Returns the issuer-scoped option key for storing registration data.
457 *
458 * @return string The option key.
459 */
460 private function get_option_key(): string {
461 return self::OPTION_KEY_PREFIX . $this->issuer_config->get_issuer_key();
462 }
463
464 /**
465 * Performs the actual DCR registration request.
466 *
467 * @param string[] $redirect_uris The OAuth redirect URIs to register.
468 *
469 * @return Registered_Client The registration result.
470 *
471 * @throws Registration_Failed_Exception If registration fails.
472 */
473 private function do_register( array $redirect_uris ): Registered_Client {
474 try {
475 $registration_endpoint = $this->discovery_client->get_document()->get_registration_endpoint();
476 } catch ( Discovery_Failed_Exception |Server_Capability_Exception $e ) {
477 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
478 throw new Registration_Failed_Exception( 'OIDC discovery failed: ' . $e->getMessage(), 0, $e );
479 }
480
481 $software_statement = $this->issuer_config->get_software_statement();
482 $initial_access_token = $this->issuer_config->get_initial_access_token();
483
484 if ( $software_statement === '' || $initial_access_token === '' ) {
485 throw new Registration_Failed_Exception( 'Software statement and initial access token must be configured.' );
486 }
487
488 // Ensure a registration key pair exists.
489 $key_pair = $this->key_pair_manager->get_or_create_key_pair( Key_Pair_Manager::PURPOSE_REGISTRATION );
490 $public_jwk = $this->key_pair_manager->get_public_key_jwk( $key_pair );
491
492 $request_body = [
493 'software_statement' => $software_statement,
494 'redirect_uris' => $redirect_uris,
495 'grant_types' => [ 'authorization_code', 'refresh_token', 'client_credentials' ],
496 'token_endpoint_auth_method' => 'private_key_jwt',
497 'jwks' => [ 'keys' => [ $public_jwk ] ],
498 'dpop_bound_access_tokens' => true,
499 ];
500
501 // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for HTTP request body, not user-facing output.
502 $json = \wp_json_encode( $request_body );
503 if ( $json === false ) {
504 throw new Registration_Failed_Exception( 'Failed to JSON-encode DCR request body.' );
505 }
506
507 $result = $this->http_client->request(
508 'POST',
509 $registration_endpoint,
510 [
511 'headers' => [
512 'Authorization' => 'Bearer ' . $initial_access_token,
513 'Content-Type' => 'application/json',
514 'Accept' => 'application/json',
515 ],
516 'body' => $json,
517 'timeout' => 15,
518 ],
519 );
520
521 if ( $result->is_transport_failure() ) {
522 $error_message = (string) $result->get_body_value( 'error_description', '' );
523 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
524 throw new Registration_Failed_Exception( 'DCR request failed: ' . $error_message );
525 }
526
527 if ( $result->get_status() !== 201 ) {
528 $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
529 throw new Registration_Failed_Exception(
530 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
531 \sprintf( 'DCR returned HTTP %d: %s', $result->get_status(), $error_message ),
532 );
533 }
534
535 $body = $result->get_body();
536 if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) {
537 throw new Registration_Failed_Exception( 'DCR returned invalid response.' );
538 }
539
540 return $this->store_credentials( $body );
541 }
542
543 /**
544 * Strips server-assigned fields from metadata for a RFC 7592 PUT request.
545 *
546 * Per RFC 7592 §2.2, the update request body MUST NOT include fields
547 * that are assigned by the server (e.g. registration_client_uri,
548 * client_id_issued_at, client_secret, client_secret_expires_at).
549 * The software_statement is also stripped since a fresh one is provided.
550 *
551 * phpcs:disable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint -- OAuth metadata is an associative array with heterogeneous values.
552 *
553 * @param array<string, mixed> $metadata The stored client metadata.
554 *
555 * @return array<string, mixed> The metadata suitable for a PUT request body.
556 *
557 * phpcs:enable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint
558 */
559 private function build_update_request_body( array $metadata ): array {
560 unset(
561 $metadata['registration_access_token'],
562 $metadata['registration_client_uri'],
563 $metadata['client_id_issued_at'],
564 $metadata['client_secret'],
565 $metadata['client_secret_expires_at'],
566 $metadata['software_statement'],
567 );
568
569 return $metadata;
570 }
571 }
572