PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.2
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.2
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 28.2, at src/myyoast-client/infrastructure/registration/client-registration.php

756 lines 29.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.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\Rate_Limited_Exception;
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;
14 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception;
15 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
16 use Yoast\WP\SEO\MyYoast_Client\Domain\Auth_Token_Type;
17 use Yoast\WP\SEO\MyYoast_Client\Domain\HTTP_Response;
18 use Yoast\WP\SEO\MyYoast_Client\Domain\Registered_Client;
19 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption;
20 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Encryption_Exception;
21 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto\Key_Pair_Manager;
22 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Http\HTTP_Client;
23 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Discovery_Client;
24 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Issuer_Config;
25 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
26 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
27 use YoastSEO_Vendor\Psr\Log\NullLogger;
28
29 /**
30 * Manages the full OAuth client registration lifecycle (RFC 7591 + RFC 7592).
31 *
32 * Handles initial Dynamic Client Registration, credential storage, key rotation,
33 * registration verification, and deregistration. Each WordPress site (or subsite
34 * in multisite) registers its own client_id. Uses lazy registration with a
35 * database-backed exclusive lock.
36 */
37 class Client_Registration implements Client_Registration_Interface, LoggerAwareInterface {
38 use LoggerAwareTrait;
39
40 private const OPTION_KEY_PREFIX = 'wpseo_myyoast_client_registration_';
41 private const ENCRYPTION_CONTEXT = 'yoast-myyoast-registration-credentials';
42 private const DCR_LOCK_TTL_IN_SECONDS = \MINUTE_IN_SECONDS;
43
44 /**
45 * The discovery client.
46 *
47 * @var Discovery_Client
48 */
49 private $discovery_client;
50
51 /**
52 * The key pair manager.
53 *
54 * @var Key_Pair_Manager
55 */
56 private $key_pair_manager;
57
58 /**
59 * The encryption service.
60 *
61 * @var Encryption
62 */
63 private $encryption;
64
65 /**
66 * The issuer configuration.
67 *
68 * @var Issuer_Config
69 */
70 private $issuer_config;
71
72 /**
73 * The lock helper.
74 *
75 * @var Lock_Helper
76 */
77 private $lock_helper;
78
79 /**
80 * The HTTP client.
81 *
82 * @var HTTP_Client
83 */
84 private $http_client;
85
86 /**
87 * In-memory cache for registered clients, keyed by option key (avoids repeated decryption within a single request).
88 *
89 * Each entry is Registered_Client|null (null = checked but not registered).
90 * Absence of a key means not yet loaded.
91 *
92 * @var array<string, Registered_Client|null>
93 */
94 private $cached_registered_clients = [];
95
96 /**
97 * Client_Registration constructor.
98 *
99 * @param Discovery_Client $discovery_client The discovery client.
100 * @param Key_Pair_Manager $key_pair_manager The key pair manager.
101 * @param Encryption $encryption The encryption service.
102 * @param Issuer_Config $issuer_config The issuer configuration.
103 * @param Lock_Helper $lock_helper The lock helper.
104 * @param HTTP_Client $http_client The HTTP client.
105 */
106 public function __construct(
107 Discovery_Client $discovery_client,
108 Key_Pair_Manager $key_pair_manager,
109 Encryption $encryption,
110 Issuer_Config $issuer_config,
111 Lock_Helper $lock_helper,
112 HTTP_Client $http_client
113 ) {
114 $this->discovery_client = $discovery_client;
115 $this->key_pair_manager = $key_pair_manager;
116 $this->encryption = $encryption;
117 $this->issuer_config = $issuer_config;
118 $this->lock_helper = $lock_helper;
119 $this->http_client = $http_client;
120 $this->logger = new NullLogger();
121 }
122
123 /**
124 * Registers the plugin as an OAuth client via DCR (RFC 7591).
125 *
126 * Uses a database-backed exclusive lock to prevent concurrent registrations.
127 *
128 * @param string[] $redirect_uris The OAuth redirect URIs to register.
129 *
130 * @return Registered_Client The registration result.
131 *
132 * @throws Registration_Failed_Exception If registration fails.
133 */
134 public function register( array $redirect_uris ): Registered_Client {
135 // Acquire lock and execute registration.
136 try {
137 return $this->lock_helper->execute(
138 'wpseo_myyoast_dcr_lock:' . $this->issuer_config->get_issuer_key() . ':' . \get_current_blog_id(),
139 function () use ( $redirect_uris ) {
140 return $this->do_register( $redirect_uris );
141 },
142 self::DCR_LOCK_TTL_IN_SECONDS,
143 );
144 } catch ( Lock_Timeout_Exception $e ) {
145 $this->logger->warning( 'DCR lock contention: another registration is already in progress.' );
146 throw new Registration_Failed_Exception( 'Another registration is already in progress.' );
147 }
148 }
149
150 /**
151 * Returns the stored registered client, or null if not registered.
152 *
153 * @return Registered_Client|null The registered client, or null if not registered.
154 */
155 public function get_registered_client(): ?Registered_Client {
156 $option_key = $this->get_option_key();
157
158 if ( \array_key_exists( $option_key, $this->cached_registered_clients ) ) {
159 return $this->cached_registered_clients[ $option_key ];
160 }
161
162 $stored = \get_option( $option_key, false );
163 if ( ! \is_array( $stored ) || empty( $stored['client_id'] ) || empty( $stored['encrypted_rat'] ) ) {
164 $this->cached_registered_clients[ $option_key ] = null;
165 return null;
166 }
167
168 try {
169 $rat = $this->encryption->decrypt( $stored['encrypted_rat'], self::ENCRYPTION_CONTEXT );
170 } catch ( Encryption_Exception $e ) {
171 $this->logger->error( 'Failed to decrypt registration access token, clearing registration: {error}', [ 'error' => $e->getMessage() ] );
172 $this->forget_registration();
173 return null;
174 }
175
176 try {
177 $this->cached_registered_clients[ $option_key ] = new Registered_Client(
178 $stored['client_id'],
179 $rat,
180 ( $stored['registration_client_uri'] ?? '' ),
181 ( $stored['metadata'] ?? [] ),
182 ( $stored['validated_uris'] ?? [] ),
183 );
184 } catch ( InvalidArgumentException $e ) {
185 $this->logger->error( 'Stored registration data is invalid, clearing registration: {error}', [ 'error' => $e->getMessage() ] );
186 $this->forget_registration();
187 return null;
188 }
189
190 return $this->cached_registered_clients[ $option_key ];
191 }
192
193 /**
194 * Ensures the registration's redirect URIs exactly match the given set.
195 *
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.
199 *
200 * @param string[] $redirect_uris The exact set of OAuth redirect URIs the registration should have.
201 *
202 * @return Registered_Client The client credentials.
203 *
204 * @throws Registration_Failed_Exception If registration fails.
205 */
206 public function ensure_registered( array $redirect_uris ): Registered_Client {
207 $registered_client = $this->get_registered_client();
208
209 if ( $registered_client !== null && $registered_client->has_redirect_uris( $redirect_uris ) ) {
210 return $registered_client;
211 }
212
213 if ( $redirect_uris === [] ) {
214 throw new Registration_Failed_Exception( 'At least one redirect URI is required for initial registration.' );
215 }
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
223 return $this->register( $redirect_uris );
224 }
225
226 /**
227 * Reads the current client registration from the server (RFC 7592 GET).
228 *
229 * @return array<string, string|string[]> The registration metadata.
230 *
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.
234 */
235 public function read_registration(): array {
236 $registered_client = $this->get_registered_client();
237 if ( $registered_client === null ) {
238 throw new Registration_Failed_Exception( 'Not registered.' );
239 }
240
241 $result = $this->http_client->authenticated_request(
242 'GET',
243 $registered_client->get_registration_client_uri(),
244 $registered_client->get_registration_access_token(),
245 Auth_Token_Type::BEARER,
246 [
247 'timeout' => 10,
248 'headers' => [ 'Accept' => 'application/json' ],
249 ],
250 );
251
252 if ( $result->is_transport_failure() ) {
253 $error_message = (string) $result->get_body_value( 'error_description', '' );
254 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
255 throw new Registration_Failed_Exception( 'Failed to read registration: ' . $error_message );
256 }
257
258 if ( $result->get_status() === 401 || $result->get_status() === 404 ) {
259 $this->logger->warning( 'Registration is no longer valid (HTTP {status}), clearing local registration.', [ 'status' => $result->get_status() ] );
260 $this->forget_registration();
261 throw new Registration_Not_Found_Exception(
262 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
263 'Registration is no longer valid (HTTP ' . $result->get_status() . ').',
264 );
265 }
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
273 if ( ! $result->is_successful() ) {
274 $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
275 throw new Registration_Failed_Exception(
276 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
277 \sprintf( 'Registration read returned HTTP %d: %s', $result->get_status(), $error_message ),
278 );
279 }
280
281 $body = $result->get_body();
282 if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) {
283 throw new Registration_Failed_Exception( 'Invalid response from registration endpoint.' );
284 }
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
292 return $body;
293 }
294
295 /**
296 * Rotates the registration key pair by updating the registration with a new JWKS (RFC 7592 PUT).
297 *
298 * @return Registered_Client The updated credentials (with new RAT).
299 *
300 * @throws Registration_Failed_Exception If the rotation fails.
301 */
302 public function rotate_registration_keys(): Registered_Client {
303 $registered_client = $this->get_registered_client();
304 if ( $registered_client === null ) {
305 throw new Registration_Failed_Exception( 'Not registered.' );
306 }
307
308 // Generate a new key pair in memory — only persist after server confirms.
309 $new_key_pair = $this->key_pair_manager->generate_key_pair();
310 $new_jwk = $this->key_pair_manager->get_public_key_jwk( $new_key_pair );
311
312 // Build the update request body from stored metadata with the new JWKS.
313 // Per RFC 7592 §2.2, server-assigned fields MUST NOT be included.
314 $request_body = $this->build_update_request_body( $registered_client->get_metadata() );
315 $request_body['jwks'] = [ 'keys' => [ $new_jwk ] ];
316 $request_body['software_statement'] = $this->issuer_config->get_software_statement();
317
318 // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for HTTP request body, not user-facing output.
319 $json = \wp_json_encode( $request_body );
320 if ( $json === false ) {
321 throw new Registration_Failed_Exception( 'Failed to JSON-encode registration request body.' );
322 }
323
324 $result = $this->http_client->authenticated_request(
325 'PUT',
326 $registered_client->get_registration_client_uri(),
327 $registered_client->get_registration_access_token(),
328 Auth_Token_Type::BEARER,
329 [
330 'headers' => [
331 'Content-Type' => 'application/json',
332 'Accept' => 'application/json',
333 ],
334 'body' => $json,
335 'timeout' => 15,
336 ],
337 );
338
339 if ( ! $result->is_successful() ) {
340 $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
341 throw new Registration_Failed_Exception(
342 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
343 \sprintf( 'Key rotation returned HTTP %d: %s', $result->get_status(), $error_message ),
344 );
345 }
346
347 $body = $result->get_body();
348 if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) {
349 throw new Registration_Failed_Exception( 'Key rotation returned invalid response.' );
350 }
351
352 // Server confirmed — now persist the new key pair locally.
353 $this->key_pair_manager->store_key_pair( Key_Pair_Manager::PURPOSE_REGISTRATION, $new_key_pair );
354
355 // Store the new RAT atomically.
356 return $this->store_credentials( $body );
357 }
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 /**
437 * Deletes the client registration from the server (RFC 7592 DELETE) and clears local data.
438 *
439 * @return bool True if deleted or already not registered, false on network failure.
440 */
441 public function deregister(): bool {
442 $credentials = $this->get_registered_client();
443 if ( $credentials === null ) {
444 return true;
445 }
446
447 $result = $this->http_client->authenticated_request(
448 'DELETE',
449 $credentials->get_registration_client_uri(),
450 $credentials->get_registration_access_token(),
451 Auth_Token_Type::BEARER,
452 [ 'timeout' => 10 ],
453 );
454
455 $this->forget_registration();
456
457 return ! $result->is_transport_failure();
458 }
459
460 /**
461 * Deletes the stored registration credentials.
462 *
463 * @return void
464 */
465 public function forget_registration(): void {
466 unset( $this->cached_registered_clients[ $this->get_option_key() ] );
467 \delete_option( $this->get_option_key() );
468 }
469
470 /**
471 * Deletes all local registration data (credentials, key pairs, caches).
472 *
473 * @return void
474 */
475 public function delete_local_data(): void {
476 $this->forget_registration();
477 $this->key_pair_manager->delete_key_pair( Key_Pair_Manager::PURPOSE_REGISTRATION );
478 $this->key_pair_manager->delete_key_pair( Key_Pair_Manager::PURPOSE_DPOP );
479 $this->discovery_client->invalidate_cache();
480 $suffix = $this->issuer_config->get_issuer_key();
481 \delete_transient( 'wpseo_myyoast_jwks_' . $suffix );
482 \delete_transient( 'wpseo_myyoast_dpop_nonce_' . $suffix );
483 }
484
485 /**
486 * Rotates the DPoP key pair (local only, no server coordination).
487 *
488 * @return void
489 */
490 public function rotate_dpop_keys(): void {
491 $this->key_pair_manager->rotate_key_pair( Key_Pair_Manager::PURPOSE_DPOP );
492 }
493
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 /**
544 * Stores the DCR response credentials securely.
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 *
550 * @param array<string, string|array<string>> $response_body The parsed DCR response body.
551 *
552 * @return Registered_Client The stored credentials.
553 */
554 private function store_credentials( array $response_body ): Registered_Client {
555 $option_key = $this->get_option_key();
556 $existing = $this->get_registered_client();
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
572 // Strip the RAT from metadata — it is stored encrypted separately.
573 $metadata = $response_body;
574 unset( $metadata['registration_access_token'] );
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
588 \update_option(
589 $option_key,
590 [
591 'client_id' => $response_body['client_id'],
592 'encrypted_rat' => $encrypted_rat,
593 'registration_client_uri' => ( $response_body['registration_client_uri'] ?? '' ),
594 'metadata' => $metadata,
595 'validated_uris' => $validated_uris,
596 ],
597 false,
598 );
599
600 $this->cached_registered_clients[ $option_key ] = new Registered_Client(
601 $response_body['client_id'],
602 $rat,
603 ( $response_body['registration_client_uri'] ?? '' ),
604 $metadata,
605 $validated_uris,
606 );
607
608 return $this->cached_registered_clients[ $option_key ];
609 }
610
611 /**
612 * Returns the issuer-scoped option key for storing registration data.
613 *
614 * @return string The option key.
615 */
616 private function get_option_key(): string {
617 return self::OPTION_KEY_PREFIX . $this->issuer_config->get_issuer_key();
618 }
619
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 /**
633 * Performs the actual DCR registration request.
634 *
635 * @param string[] $redirect_uris The OAuth redirect URIs to register.
636 *
637 * @return Registered_Client The registration result.
638 *
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.
642 */
643 private function do_register( array $redirect_uris ): Registered_Client {
644 try {
645 $registration_endpoint = $this->discovery_client->get_document()->get_registration_endpoint();
646 } catch ( Discovery_Failed_Exception |Server_Capability_Exception $e ) {
647 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
648 throw new Registration_Failed_Exception( 'OIDC discovery failed: ' . $e->getMessage(), 0, $e );
649 }
650
651 $software_statement = $this->issuer_config->get_software_statement();
652 $initial_access_token = $this->issuer_config->get_initial_access_token();
653
654 if ( $software_statement === '' || $initial_access_token === '' ) {
655 throw new Registration_Failed_Exception( 'Software statement and initial access token must be configured.' );
656 }
657
658 // Ensure a registration key pair exists.
659 $key_pair = $this->key_pair_manager->get_or_create_key_pair( Key_Pair_Manager::PURPOSE_REGISTRATION );
660 $public_jwk = $this->key_pair_manager->get_public_key_jwk( $key_pair );
661
662 $request_body = [
663 'software_statement' => $software_statement,
664 'redirect_uris' => $redirect_uris,
665 'grant_types' => [ 'authorization_code', 'refresh_token', 'client_credentials' ],
666 'token_endpoint_auth_method' => 'private_key_jwt',
667 'jwks' => [ 'keys' => [ $public_jwk ] ],
668 'dpop_bound_access_tokens' => true,
669 ];
670
671 // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- Encoding for HTTP request body, not user-facing output.
672 $json = \wp_json_encode( $request_body );
673 if ( $json === false ) {
674 throw new Registration_Failed_Exception( 'Failed to JSON-encode DCR request body.' );
675 }
676
677 $result = $this->http_client->request(
678 'POST',
679 $registration_endpoint,
680 [
681 'headers' => [
682 'Authorization' => 'Bearer ' . $initial_access_token,
683 'Content-Type' => 'application/json',
684 'Accept' => 'application/json',
685 ],
686 'body' => $json,
687 'timeout' => 15,
688 ],
689 );
690
691 if ( $result->is_transport_failure() ) {
692 $error_message = (string) $result->get_body_value( 'error_description', '' );
693 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
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 ) );
709 }
710
711 if ( $result->get_status() !== 201 ) {
712 $error_message = (string) $result->get_body_value( 'error_description', $result->get_body_value( 'error', '' ) );
713 throw new Registration_Failed_Exception(
714 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
715 \sprintf( 'DCR returned HTTP %d: %s', $result->get_status(), $error_message ),
716 );
717 }
718
719 $body = $result->get_body();
720 if ( ! \is_array( $body ) || empty( $body['client_id'] ) ) {
721 throw new Registration_Failed_Exception( 'DCR returned invalid response.' );
722 }
723
724 return $this->store_credentials( $body );
725 }
726
727 /**
728 * Strips server-assigned fields from metadata for a RFC 7592 PUT request.
729 *
730 * Per RFC 7592 §2.2, the update request body MUST NOT include fields
731 * that are assigned by the server (e.g. registration_client_uri,
732 * client_id_issued_at, client_secret, client_secret_expires_at).
733 * The software_statement is also stripped since a fresh one is provided.
734 *
735 * phpcs:disable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint -- OAuth metadata is an associative array with heterogeneous values.
736 *
737 * @param array<string, mixed> $metadata The stored client metadata.
738 *
739 * @return array<string, mixed> The metadata suitable for a PUT request body.
740 *
741 * phpcs:enable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint
742 */
743 private function build_update_request_body( array $metadata ): array {
744 unset(
745 $metadata['registration_access_token'],
746 $metadata['registration_client_uri'],
747 $metadata['client_id_issued_at'],
748 $metadata['client_secret'],
749 $metadata['client_secret_expires_at'],
750 $metadata['software_statement'],
751 );
752
753 return $metadata;
754 }
755 }
756