PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.5
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.5
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 / user-interface / auth-command.php

auth-command.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.5, at src/myyoast-client/user-interface/auth-command.php

865 lines 26.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Yoast\WP\SEO\MyYoast_Client\User_Interface;
4
5 use Exception;
6 use WP_CLI;
7 use WP_CLI\ExitException;
8 use WP_CLI\Utils;
9 use Yoast\WP\SEO\Commands\Command_Interface;
10 use Yoast\WP\SEO\Conditionals\MyYoast_Connection_Conditional;
11 use Yoast\WP\SEO\Loadable_Interface;
12 use Yoast\WP\SEO\Main;
13 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Registration_Temporarily_Unavailable_Exception;
14 use Yoast\WP\SEO\MyYoast_Client\Application\MyYoast_Client;
15 use Yoast\WP\SEO\MyYoast_Client\Application\MyYoast_Client_Cleanup;
16 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
17 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Token_Storage_Interface;
18 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\User_Token_Storage_Interface;
19 use Yoast\WP\SEO\MyYoast_Client\Domain\Exceptions\Invalid_Resource_Exception;
20 use Yoast\WP\SEO\MyYoast_Client\Domain\Resource_Indicator;
21 use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set;
22 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Issuer_Config;
23
24 /**
25 * Manages the MyYoast OAuth client registration, tokens, and authorization.
26 *
27 * These commands are intended to be used with the global --user flag to set the
28 * WordPress user context. For example: wp yoast auth status --user=admin
29 */
30 final class Auth_Command implements Command_Interface, Loadable_Interface {
31
32 /**
33 * The MyYoast client facade.
34 *
35 * @var MyYoast_Client
36 */
37 private $myyoast_client;
38
39 /**
40 * The client registration port.
41 *
42 * @var Client_Registration_Interface
43 */
44 private $client_registration;
45
46 /**
47 * The issuer configuration.
48 *
49 * @var Issuer_Config
50 */
51 private $issuer_config;
52
53 /**
54 * The site-level token storage port.
55 *
56 * @var Token_Storage_Interface
57 */
58 private $token_storage;
59
60 /**
61 * The user-level token storage port.
62 *
63 * @var User_Token_Storage_Interface
64 */
65 private $user_token_storage;
66
67 /**
68 * The cleanup service.
69 *
70 * @var MyYoast_Client_Cleanup
71 */
72 private $cleanup;
73
74 /**
75 * Auth_Command constructor.
76 *
77 * @param MyYoast_Client $myyoast_client The MyYoast client facade.
78 * @param Client_Registration_Interface $client_registration The client registration port.
79 * @param Issuer_Config $issuer_config The issuer configuration.
80 * @param Token_Storage_Interface $token_storage The site-level token storage port.
81 * @param User_Token_Storage_Interface $user_token_storage The user-level token storage port.
82 * @param MyYoast_Client_Cleanup $cleanup The cleanup service.
83 */
84 public function __construct(
85 MyYoast_Client $myyoast_client,
86 Client_Registration_Interface $client_registration,
87 Issuer_Config $issuer_config,
88 Token_Storage_Interface $token_storage,
89 User_Token_Storage_Interface $user_token_storage,
90 MyYoast_Client_Cleanup $cleanup
91 ) {
92 $this->myyoast_client = $myyoast_client;
93 $this->client_registration = $client_registration;
94 $this->issuer_config = $issuer_config;
95 $this->token_storage = $token_storage;
96 $this->user_token_storage = $user_token_storage;
97 $this->cleanup = $cleanup;
98 }
99
100 /**
101 * Returns the namespace of this command.
102 *
103 * @return string
104 */
105 public static function get_namespace() {
106 return Main::WP_CLI_NAMESPACE . ' auth';
107 }
108
109 /**
110 * Returns the conditionals based on which this command should be registered.
111 *
112 * @return array<string> The array of conditionals.
113 */
114 public static function get_conditionals() {
115 return [ MyYoast_Connection_Conditional::class ];
116 }
117
118 /**
119 * Shows the current MyYoast OAuth client status.
120 *
121 * Displays issuer configuration, registration state, and token status
122 * without making any network calls. Use the global --user flag to check
123 * a specific user's token status.
124 *
125 * ## OPTIONS
126 *
127 * [--resource=<uri>]
128 * : Show status for a specific RFC 8707 resource indicator. Omit to target the default resource. Cannot be combined with --all-resources.
129 *
130 * [--all-resources]
131 * : Show status for every stored resource bucket. Cannot be combined with --resource.
132 *
133 * [--format=<format>]
134 * : Output format.
135 * ---
136 * default: table
137 * options:
138 * - table
139 * - json
140 * ---
141 *
142 * ## EXAMPLES
143 *
144 * wp yoast auth status
145 * wp yoast auth status --user=admin
146 * wp yoast auth status --resource=https://ai.yoa.st
147 * wp yoast auth status --all-resources
148 * wp yoast auth status --format=json
149 *
150 * @when after_wp_load
151 *
152 * @param array<int, string>|null $args The arguments.
153 * @param array<string, string>|null $assoc_args The associative arguments.
154 *
155 * @return void
156 */
157 public function status( $args = null, $assoc_args = null ): void {
158 $user_id = \get_current_user_id();
159
160 $issuer_url = $this->issuer_config->get_issuer_url();
161 $has_software = ( $this->issuer_config->get_software_statement() !== '' );
162 $has_iat = ( $this->issuer_config->get_initial_access_token() !== '' );
163 $is_registered = $this->myyoast_client->is_registered();
164 $client_id = null;
165 $registered_client = $this->client_registration->get_registered_client();
166
167 if ( $registered_client !== null ) {
168 $client_id = $registered_client->get_client_id();
169 }
170
171 $has_all = (bool) Utils\get_flag_value( $assoc_args, 'all-resources', false );
172 $resource = Utils\get_flag_value( $assoc_args, 'resource' );
173
174 if ( $has_all && $resource !== null && $resource !== '' ) {
175 WP_CLI::error( '--all-resources and --resource cannot be combined.' );
176 }
177
178 if ( $has_all ) {
179 $user_tokens = ( $user_id > 0 ) ? $this->user_token_storage->get_all( $user_id ) : [];
180 $site_tokens = $this->token_storage->get_all();
181 }
182 else {
183 try {
184 $resource_filter = new Resource_Indicator( ( $resource !== null && $resource !== '' ) ? (string) $resource : null );
185 }
186 catch ( Invalid_Resource_Exception $e ) {
187 WP_CLI::error( 'Invalid resource indicator: ' . $e->getMessage() );
188 return;
189 }
190
191 $user_tokens = ( $user_id > 0 ) ? \array_filter( [ $this->user_token_storage->get( $user_id, $resource_filter ) ] ) : [];
192 $site_tokens = \array_filter( [ $this->token_storage->get( $resource_filter ) ] );
193 }
194
195 $format = Utils\get_flag_value( $assoc_args, 'format', 'table' );
196
197 $data = [
198 'issuer_url' => $issuer_url,
199 'software_statement' => ( $has_software ) ? 'configured' : 'not configured',
200 'initial_access_token' => ( $has_iat ) ? 'configured' : 'not configured',
201 'registered' => ( $is_registered ) ? 'yes' : 'no',
202 'client_id' => ( $client_id ?? '-' ),
203 'user_id' => ( $user_id > 0 ) ? $user_id : 'none (use --user flag)',
204 'user_tokens' => $this->build_token_inventory( $user_tokens ),
205 'site_tokens' => $this->build_token_inventory( $site_tokens ),
206 ];
207
208 $this->output( $data, $format );
209 }
210
211 /**
212 * Registers the site as an OAuth client.
213 *
214 * Performs Dynamic Client Registration (RFC 7591) if the site is not
215 * already registered. Use --force to deregister and re-register.
216 *
217 * ## OPTIONS
218 *
219 * [--force]
220 * : Deregister first, then re-register.
221 *
222 * [--format=<format>]
223 * : Output format.
224 * ---
225 * default: table
226 * options:
227 * - table
228 * - json
229 * ---
230 *
231 * ## EXAMPLES
232 *
233 * wp yoast auth register
234 * wp yoast auth register --force
235 *
236 * @when after_wp_load
237 *
238 * @param array<int, string>|null $args The arguments.
239 * @param array<string, string>|null $assoc_args The associative arguments.
240 *
241 * @return void
242 *
243 * @throws ExitException When registration fails.
244 */
245 public function register( $args = null, $assoc_args = null ): void {
246 if ( Utils\get_flag_value( $assoc_args, 'force', false ) ) {
247 $this->myyoast_client->deregister();
248 WP_CLI::log( 'Deregistered existing client.' );
249 }
250
251 try {
252 $client = $this->myyoast_client->ensure_registered();
253 } catch ( Registration_Temporarily_Unavailable_Exception $e ) {
254 $retry_after = $e->get_retry_after_seconds();
255 $retry_hint = ( $retry_after !== null ) ? \sprintf( ' Try again in %d seconds.', $retry_after ) : ' Try again later.';
256 WP_CLI::error( 'Registration is temporarily unavailable.' . $retry_hint );
257 return;
258 } catch ( Exception $e ) {
259 WP_CLI::error( 'Registration failed: ' . $e->getMessage() );
260 return;
261 }
262
263 $this->output(
264 [
265 'client_id' => $client->get_client_id(),
266 'status' => 'registered',
267 ],
268 Utils\get_flag_value( $assoc_args, 'format', 'table' ),
269 );
270
271 WP_CLI::success( 'Client registered: ' . $client->get_client_id() );
272 }
273
274 /**
275 * Refreshes the client registration status against the server.
276 *
277 * Reads the current registration from the authorization server to
278 * confirm it is still valid and shows the registration metadata.
279 *
280 * ## OPTIONS
281 *
282 * [--format=<format>]
283 * : Output format.
284 * ---
285 * default: table
286 * options:
287 * - table
288 * - json
289 * ---
290 *
291 * ## EXAMPLES
292 *
293 * wp yoast auth refresh-status
294 * wp yoast auth refresh-status --format=json
295 *
296 * @subcommand refresh-status
297 *
298 * @when after_wp_load
299 *
300 * @param array<int, string>|null $args The arguments.
301 * @param array<string, string>|null $assoc_args The associative arguments.
302 *
303 * @return void
304 *
305 * @throws ExitException When the status refresh fails.
306 */
307 public function refresh_status( $args = null, $assoc_args = null ): void {
308 if ( ! $this->myyoast_client->is_registered() ) {
309 WP_CLI::error( 'Not registered. Run "wp yoast auth register" first.' );
310 }
311
312 try {
313 $metadata = $this->myyoast_client->refresh_registration_status();
314 } catch ( Exception $e ) {
315 WP_CLI::error( 'Status refresh failed: ' . $e->getMessage() );
316 return;
317 }
318
319 // Redact sensitive fields.
320 unset( $metadata['registration_access_token'] );
321
322 $this->output( $metadata, Utils\get_flag_value( $assoc_args, 'format', 'table' ) );
323
324 WP_CLI::success( 'Registration is valid.' );
325 }
326
327 /**
328 * Removes the OAuth client registration.
329 *
330 * Deletes the client registration from the authorization server and
331 * clears all local registration data and cached tokens.
332 *
333 * ## OPTIONS
334 *
335 * [--local-only]
336 * : Only delete local data without contacting the server.
337 *
338 * [--yes]
339 * : Skip confirmation prompt.
340 *
341 * ## EXAMPLES
342 *
343 * wp yoast auth deregister
344 * wp yoast auth deregister --local-only
345 * wp yoast auth deregister --yes
346 *
347 * @when after_wp_load
348 *
349 * @param array<int, string>|null $args The arguments.
350 * @param array<string, string>|null $assoc_args The associative arguments.
351 *
352 * @return void
353 */
354 public function deregister( $args = null, $assoc_args = null ): void {
355 if ( ! $this->myyoast_client->is_registered() ) {
356 WP_CLI::warning( 'Not registered. Nothing to do.' );
357 return;
358 }
359
360 WP_CLI::confirm( 'This will deregister this site from MyYoast and clear all cached tokens. Proceed?', $assoc_args );
361
362 if ( Utils\get_flag_value( $assoc_args, 'local-only', false ) ) {
363 $this->client_registration->delete_local_data();
364 $this->myyoast_client->clear_all_site_tokens();
365 WP_CLI::success( 'Local registration data cleared.' );
366 return;
367 }
368
369 $result = $this->myyoast_client->deregister();
370 $this->myyoast_client->clear_all_site_tokens();
371
372 if ( $result ) {
373 WP_CLI::success( 'Client deregistered.' );
374 }
375 else {
376 WP_CLI::warning( 'Server-side deregistration failed (network error). Local token was cleared but client credentials remain.' );
377 }
378 }
379
380 /**
381 * Resets all MyYoast OAuth client state on this site.
382 *
383 * Performs the same cleanup as plugin uninstall: best-effort server-side
384 * deregistration, then deletes all site/user tokens, registered client
385 * credentials, key pairs, and OIDC/JWKS/DPoP caches. Intended for
386 * development environments that need to start from a clean slate without
387 * uninstalling the plugin.
388 *
389 * ## OPTIONS
390 *
391 * [--yes]
392 * : Skip confirmation prompt.
393 *
394 * ## EXAMPLES
395 *
396 * wp yoast auth reset
397 * wp yoast auth reset --yes
398 *
399 * @when after_wp_load
400 *
401 * @param array<int, string>|null $args The arguments.
402 * @param array<string, string>|null $assoc_args The associative arguments.
403 *
404 * @return void
405 */
406 public function reset( $args = null, $assoc_args = null ): void {
407 WP_CLI::confirm( 'This will wipe all MyYoast OAuth client state on this site (registered client, site/user tokens, key pairs, OIDC/JWKS/DPoP caches). Proceed?', $assoc_args );
408
409 $this->cleanup->execute();
410
411 WP_CLI::success( 'MyYoast OAuth client state cleared.' );
412 }
413
414 /**
415 * Authorizes with MyYoast using the authorization code flow or client credentials.
416 *
417 * Without --site, starts the user authorization code flow:
418 * 1. Run without --code to get the authorization URL.
419 * 2. Visit the URL in a browser and authorize.
420 * 3. Copy the code and state from the callback URL.
421 * 4. Run again with --code and --state to exchange for tokens.
422 *
423 * With --site, performs a client_credentials grant for a site-level token.
424 *
425 * ## OPTIONS
426 *
427 * [--site]
428 * : Use client_credentials grant for a site-level token (no browser needed).
429 *
430 * [--scopes=<scopes>]
431 * : Comma-separated scopes to request.
432 *
433 * [--resource=<uri>]
434 * : RFC 8707 resource indicator to bind the token to (e.g. https://ai.yoa.st). Omit for the default resource.
435 *
436 * [--code=<code>]
437 * : Authorization code from the callback URL (user flow phase 2).
438 *
439 * [--state=<state>]
440 * : State parameter from the callback URL (user flow phase 2).
441 *
442 * [--url-only]
443 * : Only print the authorization URL without instructions (user flow phase 1).
444 *
445 * [--format=<format>]
446 * : Output format.
447 * ---
448 * default: table
449 * options:
450 * - table
451 * - json
452 * ---
453 *
454 * ## EXAMPLES
455 *
456 * # Site-level token (client_credentials):
457 * wp yoast auth authorize --site --scopes=service:analytics
458 *
459 * # Site-level token for a non-default resource:
460 * wp yoast auth authorize --site --resource=https://ai.yoa.st --scopes=service:ai:consume
461 *
462 * # User authorization code flow, phase 1 - get the URL:
463 * wp yoast auth authorize --user=admin --scopes=openid,profile
464 *
465 * # User authorization code flow, phase 2 - exchange the code:
466 * wp yoast auth authorize --user=admin --code=abc123 --state=xyz789
467 *
468 * @when after_wp_load
469 *
470 * @param array<int, string>|null $args The arguments.
471 * @param array<string, string>|null $assoc_args The associative arguments.
472 *
473 * @return void
474 *
475 * @throws ExitException When authorization fails.
476 */
477 public function authorize( $args = null, $assoc_args = null ): void {
478 $scopes = $this->parse_scopes( $assoc_args );
479 $format = Utils\get_flag_value( $assoc_args, 'format', 'table' );
480 $resource = Utils\get_flag_value( $assoc_args, 'resource' );
481 $resource_indicator = ( $resource !== null && $resource !== '' ) ? (string) $resource : null;
482
483 if ( Utils\get_flag_value( $assoc_args, 'site', false ) ) {
484 $this->authorize_site( $scopes, $resource_indicator, $format );
485 return;
486 }
487
488 $this->authorize_user( $assoc_args, $scopes, $resource_indicator, $format );
489 }
490
491 /**
492 * Revokes tokens for the current user and/or the site.
493 *
494 * Without --site, revokes the current user's tokens (requires --user flag).
495 * With --site, clears the cached site-level token.
496 * Both can be combined.
497 *
498 * ## OPTIONS
499 *
500 * [--site]
501 * : Clear the cached site-level token.
502 *
503 * [--resource=<uri>]
504 * : Limit revocation to a single RFC 8707 resource indicator. Omit to target the default resource.
505 *
506 * [--all-resources]
507 * : Revoke every stored token across all resource indicators. Cannot be combined with --resource.
508 *
509 * [--yes]
510 * : Skip confirmation prompt.
511 *
512 * ## EXAMPLES
513 *
514 * wp yoast auth revoke --user=admin
515 * wp yoast auth revoke --site
516 * wp yoast auth revoke --user=admin --site --yes
517 * wp yoast auth revoke --user=admin --resource=https://ai.yoa.st
518 * wp yoast auth revoke --user=admin --site --all-resources
519 *
520 * @when after_wp_load
521 *
522 * @param array<int, string>|null $args The arguments.
523 * @param array<string, string>|null $assoc_args The associative arguments.
524 *
525 * @return void
526 */
527 public function revoke( $args = null, $assoc_args = null ): void {
528 $user_id = \get_current_user_id();
529 $has_user = ( $user_id > 0 );
530 $has_site = (bool) Utils\get_flag_value( $assoc_args, 'site', false );
531 $has_all_resources = (bool) Utils\get_flag_value( $assoc_args, 'all-resources', false );
532 $resource = Utils\get_flag_value( $assoc_args, 'resource' );
533
534 if ( ! $has_site && ! $has_user ) {
535 WP_CLI::error( 'Specify --site and/or use the global --user flag.' );
536 }
537
538 if ( $has_all_resources && $resource !== null && $resource !== '' ) {
539 WP_CLI::error( '--all-resources and --resource cannot be combined.' );
540 }
541
542 $resource_indicator = null;
543 if ( $resource !== null && $resource !== '' ) {
544 $resource_indicator = (string) $resource;
545 try {
546 new Resource_Indicator( $resource_indicator );
547 }
548 catch ( Invalid_Resource_Exception $e ) {
549 WP_CLI::error( 'Invalid resource indicator: ' . $e->getMessage() );
550 return;
551 }
552 }
553
554 WP_CLI::confirm( 'This will revoke the specified tokens. Proceed?', $assoc_args );
555
556 try {
557 if ( $has_user ) {
558 if ( $has_all_resources ) {
559 $this->myyoast_client->revoke_all_user_tokens( $user_id );
560 WP_CLI::log( \sprintf( 'User %d tokens revoked across all resources.', $user_id ) );
561 }
562 else {
563 $this->myyoast_client->revoke_user_token( $user_id, $resource_indicator );
564 WP_CLI::log( \sprintf( 'User %d tokens revoked.', $user_id ) );
565 }
566 }
567
568 if ( $has_site ) {
569 if ( $has_all_resources ) {
570 $this->myyoast_client->clear_all_site_tokens();
571 WP_CLI::log( 'All site tokens cleared.' );
572 }
573 else {
574 $this->myyoast_client->clear_site_token( $resource_indicator );
575 WP_CLI::log( 'Site token cleared.' );
576 }
577 }
578 }
579 catch ( Exception $e ) {
580 WP_CLI::error( 'Revocation failed: ' . $e->getMessage() );
581 return;
582 }
583
584 WP_CLI::success( 'Done.' );
585 }
586
587 /**
588 * Rotates cryptographic key pairs.
589 *
590 * Rotates the registration key pair (server roundtrip) and/or the DPoP
591 * key pair (local only).
592 *
593 * ## OPTIONS
594 *
595 * [--registration]
596 * : Rotate the registration (private_key_jwt) key pair. Requires a server roundtrip.
597 *
598 * [--dpop]
599 * : Rotate the DPoP proof key pair (local only).
600 *
601 * [--all]
602 * : Rotate all key pairs.
603 *
604 * [--yes]
605 * : Skip confirmation prompt.
606 *
607 * ## EXAMPLES
608 *
609 * wp yoast auth rotate-keys --registration
610 * wp yoast auth rotate-keys --dpop
611 * wp yoast auth rotate-keys --all
612 *
613 * @when after_wp_load
614 *
615 * @param array<int, string>|null $args The arguments.
616 * @param array<string, string>|null $assoc_args The associative arguments.
617 *
618 * @return void
619 *
620 * @throws ExitException When key rotation fails.
621 */
622 public function rotate_keys( $args = null, $assoc_args = null ): void {
623 $rotate_all = (bool) Utils\get_flag_value( $assoc_args, 'all', false );
624 $rotate_registration = ( $rotate_all || (bool) Utils\get_flag_value( $assoc_args, 'registration', false ) );
625 $rotate_dpop = ( $rotate_all || (bool) Utils\get_flag_value( $assoc_args, 'dpop', false ) );
626
627 if ( ! $rotate_registration && ! $rotate_dpop ) {
628 WP_CLI::error( 'Specify --registration, --dpop, or --all.' );
629 }
630
631 WP_CLI::confirm( 'This will rotate the specified key pairs. Existing tokens may be invalidated. Proceed?', $assoc_args );
632
633 if ( $rotate_registration ) {
634 if ( ! $this->myyoast_client->is_registered() ) {
635 WP_CLI::error( 'Not registered. Run "wp yoast auth register" first.' );
636 }
637
638 try {
639 $client = $this->myyoast_client->rotate_registration_keys();
640 WP_CLI::log( 'Registration keys rotated. Client ID: ' . $client->get_client_id() );
641 } catch ( Exception $e ) {
642 WP_CLI::error( 'Registration key rotation failed: ' . $e->getMessage() );
643 return;
644 }
645 }
646
647 if ( $rotate_dpop ) {
648 $this->myyoast_client->rotate_dpop_keys();
649 WP_CLI::log( 'DPoP keys rotated.' );
650 }
651
652 WP_CLI::success( 'Key rotation complete.' );
653 }
654
655 /**
656 * Performs a client_credentials grant for a site-level token.
657 *
658 * @param string[] $scopes The scopes to request.
659 * @param string|null $resource_indicator The resource indicator (RFC 8707), or null for the default resource.
660 * @param string $format The output format.
661 *
662 * @return void
663 *
664 * @throws ExitException When the token request fails.
665 */
666 private function authorize_site( array $scopes, ?string $resource_indicator, string $format ): void {
667 try {
668 $token_set = $this->myyoast_client->get_site_token( $scopes, $resource_indicator );
669 } catch ( Exception $e ) {
670 WP_CLI::error( 'Site token request failed: ' . $e->getMessage() );
671 return;
672 }
673
674 $this->output( $this->build_token_info( $token_set ), $format );
675
676 WP_CLI::success( 'Site token obtained.' );
677 }
678
679 /**
680 * Handles the user authorization code flow.
681 *
682 * @param array<string, string> $assoc_args The associative arguments.
683 * @param string[] $scopes The scopes to request.
684 * @param string|null $resource_indicator The resource indicator (RFC 8707), or null for the default resource.
685 * @param string $format The output format.
686 *
687 * @return void
688 *
689 * @throws ExitException When authorization fails.
690 */
691 private function authorize_user( array $assoc_args, array $scopes, ?string $resource_indicator, string $format ): void {
692 $user_id = \get_current_user_id();
693 if ( $user_id <= 0 ) {
694 WP_CLI::error( 'User authorization requires the global --user flag.' );
695 }
696
697 $code = Utils\get_flag_value( $assoc_args, 'code' );
698 $state = Utils\get_flag_value( $assoc_args, 'state' );
699
700 // Phase 2: exchange the code. The resource was persisted in the flow state during phase 1.
701 if ( $code !== null && $state !== null ) {
702 try {
703 $token_set = $this->myyoast_client->exchange_authorization_code(
704 $user_id,
705 (string) $code,
706 (string) $state,
707 );
708 } catch ( Exception $e ) {
709 WP_CLI::error( 'Code exchange failed: ' . $e->getMessage() );
710 return;
711 }
712
713 $this->output( $this->build_token_info( $token_set ), $format );
714
715 WP_CLI::success( 'User authorized.' );
716 return;
717 }
718
719 if ( $code !== null || $state !== null ) {
720 WP_CLI::error( 'Both --code and --state are required for code exchange.' );
721 }
722
723 // Phase 1: generate the authorization URL. Registration is a prerequisite.
724 if ( ! $this->myyoast_client->is_registered() ) {
725 WP_CLI::error( 'Not registered. Run "wp yoast auth register" first.' );
726 }
727
728 try {
729 $url = $this->myyoast_client->get_authorization_url( $user_id, $scopes, $resource_indicator );
730 } catch ( Exception $e ) {
731 WP_CLI::error( 'Failed to generate authorization URL: ' . $e->getMessage() );
732 return;
733 }
734
735 if ( Utils\get_flag_value( $assoc_args, 'url-only', false ) ) {
736 WP_CLI::log( $url );
737 return;
738 }
739
740 WP_CLI::log( 'Visit this URL to authorize:' );
741 WP_CLI::log( '' );
742 WP_CLI::log( $url );
743 WP_CLI::log( '' );
744 WP_CLI::log( 'After authorizing, you will be redirected to a callback URL.' );
745 WP_CLI::log( 'If you need to manually complete authorization, copy the "code" and "state" parameters from the URL, then run:' );
746 WP_CLI::log( '' );
747 WP_CLI::log(
748 \sprintf(
749 ' wp yoast auth authorize --user=%s --code=<CODE> --state=<STATE>',
750 $user_id,
751 ),
752 );
753 }
754
755 /**
756 * Builds a display-safe token info array.
757 *
758 * @param Token_Set|null $token_set The token set, or null.
759 *
760 * @return array<string, string|int> The token info for display.
761 */
762 private function build_token_info( ?Token_Set $token_set ): array {
763 if ( $token_set === null ) {
764 return [
765 'resource' => '-',
766 'status' => 'none',
767 'expires' => '-',
768 'scopes' => '-',
769 'error_count' => '-',
770 ];
771 }
772
773 return [
774 'resource' => ( $token_set->get_resource_indicator()->is_default() ? '(default)' : $token_set->get_resource_indicator()->value() ),
775 'status' => ( $token_set->is_expired() ) ? 'expired' : 'valid',
776 'expires' => \gmdate( 'Y-m-d H:i:s', $token_set->get_expires_at() ) . ' UTC',
777 'scopes' => ( $token_set->get_scope() ?? '-' ),
778 'error_count' => $token_set->get_error_count(),
779 ];
780 }
781
782 /**
783 * Builds a display-safe inventory of tokens across resource buckets.
784 *
785 * @param Token_Set[] $token_sets The token sets.
786 *
787 * @return array<int, array<string, string|int>> The inventory.
788 */
789 private function build_token_inventory( array $token_sets ): array {
790 $inventory = [];
791 foreach ( $token_sets as $token_set ) {
792 $inventory[] = $this->build_token_info( $token_set );
793 }
794 return $inventory;
795 }
796
797 /**
798 * Parses the comma-separated scopes option.
799 *
800 * @param array<string, string>|null $assoc_args The associative arguments.
801 *
802 * @return string[] The parsed scopes.
803 */
804 private function parse_scopes( $assoc_args ): array {
805 $scopes = Utils\get_flag_value( $assoc_args, 'scopes', '' );
806 if ( $scopes === '' ) {
807 return [];
808 }
809
810 return \array_values( \array_filter( \array_map( 'trim', \explode( ',', (string) $scopes ) ) ) );
811 }
812
813 /**
814 * Flattens nested arrays for table display by JSON-encoding array values.
815 *
816 * @param array<string, string|string[]|bool> $data The data to flatten.
817 *
818 * @return array<string, string> The flattened data.
819 */
820 private function flatten_for_display( array $data ): array {
821 $result = [];
822 foreach ( $data as $key => $value ) {
823 if ( \is_array( $value ) ) {
824 // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- WP-CLI display output, not user-facing HTML.
825 $result[ $key ] = ( \wp_json_encode( $value ) ?? 'err' );
826 }
827 elseif ( \is_bool( $value ) ) {
828 $result[ $key ] = ( $value ) ? 'true' : 'false';
829 }
830 else {
831 $result[ $key ] = (string) $value;
832 }
833 }
834 return $result;
835 }
836
837 /**
838 * Outputs data in the requested format.
839 *
840 * @param array<string, string|int> $data The data to output.
841 * @param string $format The output format (table or json).
842 *
843 * @return void
844 */
845 private function output( array $data, string $format ): void {
846 if ( $format === 'json' ) {
847 // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.FoundWithAdditionalParams -- CLI output, not user-facing HTML.
848 $encoded = \wp_json_encode( $data, \JSON_PRETTY_PRINT );
849 WP_CLI::log( ( $encoded !== false ) ? $encoded : '{}' );
850 return;
851 }
852
853 $flat_data = $this->flatten_for_display( $data );
854 $items = [];
855 foreach ( $flat_data as $key => $value ) {
856 $items[] = [
857 'field' => $key,
858 'value' => $value,
859 ];
860 }
861
862 WP_CLI\Utils\format_items( 'table', $items, [ 'field', 'value' ] );
863 }
864 }
865