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 / user-interface / auth-command.php

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

857 lines 26.1 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\General\User_Interface\General_Page_Integration;
12 use Yoast\WP\SEO\Loadable_Interface;
13 use Yoast\WP\SEO\Main;
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 $redirect_uri = \get_admin_url( null, 'admin.php?page=' . General_Page_Integration::PAGE . '&yoast_myyoast_oauth_callback=1' );
253 $client = $this->myyoast_client->ensure_registered( [ $redirect_uri ] );
254 } catch ( Exception $e ) {
255 WP_CLI::error( 'Registration failed: ' . $e->getMessage() );
256 return;
257 }
258
259 $this->output(
260 [
261 'client_id' => $client->get_client_id(),
262 'status' => 'registered',
263 ],
264 Utils\get_flag_value( $assoc_args, 'format', 'table' ),
265 );
266
267 WP_CLI::success( 'Client registered: ' . $client->get_client_id() );
268 }
269
270 /**
271 * Verifies the client registration with the server.
272 *
273 * Reads the current registration from the authorization server to
274 * confirm it is still valid and shows the registration metadata.
275 *
276 * ## OPTIONS
277 *
278 * [--format=<format>]
279 * : Output format.
280 * ---
281 * default: table
282 * options:
283 * - table
284 * - json
285 * ---
286 *
287 * ## EXAMPLES
288 *
289 * wp yoast auth verify
290 * wp yoast auth verify --format=json
291 *
292 * @when after_wp_load
293 *
294 * @param array<int, string>|null $args The arguments.
295 * @param array<string, string>|null $assoc_args The associative arguments.
296 *
297 * @return void
298 *
299 * @throws ExitException When verification fails.
300 */
301 public function verify( $args = null, $assoc_args = null ): void {
302 if ( ! $this->myyoast_client->is_registered() ) {
303 WP_CLI::error( 'Not registered. Run "wp yoast auth register" first.' );
304 }
305
306 try {
307 $metadata = $this->myyoast_client->verify_registration();
308 } catch ( Exception $e ) {
309 WP_CLI::error( 'Verification failed: ' . $e->getMessage() );
310 return;
311 }
312
313 // Redact sensitive fields.
314 unset( $metadata['registration_access_token'] );
315
316 $this->output( $metadata, Utils\get_flag_value( $assoc_args, 'format', 'table' ) );
317
318 WP_CLI::success( 'Registration is valid.' );
319 }
320
321 /**
322 * Removes the OAuth client registration.
323 *
324 * Deletes the client registration from the authorization server and
325 * clears all local registration data and cached tokens.
326 *
327 * ## OPTIONS
328 *
329 * [--local-only]
330 * : Only delete local data without contacting the server.
331 *
332 * [--yes]
333 * : Skip confirmation prompt.
334 *
335 * ## EXAMPLES
336 *
337 * wp yoast auth deregister
338 * wp yoast auth deregister --local-only
339 * wp yoast auth deregister --yes
340 *
341 * @when after_wp_load
342 *
343 * @param array<int, string>|null $args The arguments.
344 * @param array<string, string>|null $assoc_args The associative arguments.
345 *
346 * @return void
347 */
348 public function deregister( $args = null, $assoc_args = null ): void {
349 if ( ! $this->myyoast_client->is_registered() ) {
350 WP_CLI::warning( 'Not registered. Nothing to do.' );
351 return;
352 }
353
354 WP_CLI::confirm( 'This will deregister this site from MyYoast and clear all cached tokens. Proceed?', $assoc_args );
355
356 if ( Utils\get_flag_value( $assoc_args, 'local-only', false ) ) {
357 $this->client_registration->delete_local_data();
358 $this->myyoast_client->clear_all_site_tokens();
359 WP_CLI::success( 'Local registration data cleared.' );
360 return;
361 }
362
363 $result = $this->myyoast_client->deregister();
364 $this->myyoast_client->clear_all_site_tokens();
365
366 if ( $result ) {
367 WP_CLI::success( 'Client deregistered.' );
368 }
369 else {
370 WP_CLI::warning( 'Server-side deregistration failed (network error). Local token was cleared but client credentials remain.' );
371 }
372 }
373
374 /**
375 * Resets all MyYoast OAuth client state on this site.
376 *
377 * Performs the same cleanup as plugin uninstall: best-effort server-side
378 * deregistration, then deletes all site/user tokens, registered client
379 * credentials, key pairs, and OIDC/JWKS/DPoP caches. Intended for
380 * development environments that need to start from a clean slate without
381 * uninstalling the plugin.
382 *
383 * ## OPTIONS
384 *
385 * [--yes]
386 * : Skip confirmation prompt.
387 *
388 * ## EXAMPLES
389 *
390 * wp yoast auth reset
391 * wp yoast auth reset --yes
392 *
393 * @when after_wp_load
394 *
395 * @param array<int, string>|null $args The arguments.
396 * @param array<string, string>|null $assoc_args The associative arguments.
397 *
398 * @return void
399 */
400 public function reset( $args = null, $assoc_args = null ): void {
401 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 );
402
403 $this->cleanup->execute();
404
405 WP_CLI::success( 'MyYoast OAuth client state cleared.' );
406 }
407
408 /**
409 * Authorizes with MyYoast using the authorization code flow or client credentials.
410 *
411 * Without --site, starts the user authorization code flow:
412 * 1. Run without --code to get the authorization URL.
413 * 2. Visit the URL in a browser and authorize.
414 * 3. Copy the code and state from the callback URL.
415 * 4. Run again with --code and --state to exchange for tokens.
416 *
417 * With --site, performs a client_credentials grant for a site-level token.
418 *
419 * ## OPTIONS
420 *
421 * [--site]
422 * : Use client_credentials grant for a site-level token (no browser needed).
423 *
424 * [--scopes=<scopes>]
425 * : Comma-separated scopes to request.
426 *
427 * [--resource=<uri>]
428 * : RFC 8707 resource indicator to bind the token to (e.g. https://ai.yoa.st). Omit for the default resource.
429 *
430 * [--code=<code>]
431 * : Authorization code from the callback URL (user flow phase 2).
432 *
433 * [--state=<state>]
434 * : State parameter from the callback URL (user flow phase 2).
435 *
436 * [--url-only]
437 * : Only print the authorization URL without instructions (user flow phase 1).
438 *
439 * [--format=<format>]
440 * : Output format.
441 * ---
442 * default: table
443 * options:
444 * - table
445 * - json
446 * ---
447 *
448 * ## EXAMPLES
449 *
450 * # Site-level token (client_credentials):
451 * wp yoast auth authorize --site --scopes=service:analytics
452 *
453 * # Site-level token for a non-default resource:
454 * wp yoast auth authorize --site --resource=https://ai.yoa.st --scopes=service:ai:consume
455 *
456 * # User authorization code flow, phase 1 - get the URL:
457 * wp yoast auth authorize --user=admin --scopes=openid,profile
458 *
459 * # User authorization code flow, phase 2 - exchange the code:
460 * wp yoast auth authorize --user=admin --code=abc123 --state=xyz789
461 *
462 * @when after_wp_load
463 *
464 * @param array<int, string>|null $args The arguments.
465 * @param array<string, string>|null $assoc_args The associative arguments.
466 *
467 * @return void
468 *
469 * @throws ExitException When authorization fails.
470 */
471 public function authorize( $args = null, $assoc_args = null ): void {
472 $scopes = $this->parse_scopes( $assoc_args );
473 $format = Utils\get_flag_value( $assoc_args, 'format', 'table' );
474 $resource = Utils\get_flag_value( $assoc_args, 'resource' );
475 $resource_indicator = ( $resource !== null && $resource !== '' ) ? (string) $resource : null;
476
477 if ( Utils\get_flag_value( $assoc_args, 'site', false ) ) {
478 $this->authorize_site( $scopes, $resource_indicator, $format );
479 return;
480 }
481
482 $this->authorize_user( $assoc_args, $scopes, $resource_indicator, $format );
483 }
484
485 /**
486 * Revokes tokens for the current user and/or the site.
487 *
488 * Without --site, revokes the current user's tokens (requires --user flag).
489 * With --site, clears the cached site-level token.
490 * Both can be combined.
491 *
492 * ## OPTIONS
493 *
494 * [--site]
495 * : Clear the cached site-level token.
496 *
497 * [--resource=<uri>]
498 * : Limit revocation to a single RFC 8707 resource indicator. Omit to target the default resource.
499 *
500 * [--all-resources]
501 * : Revoke every stored token across all resource indicators. Cannot be combined with --resource.
502 *
503 * [--yes]
504 * : Skip confirmation prompt.
505 *
506 * ## EXAMPLES
507 *
508 * wp yoast auth revoke --user=admin
509 * wp yoast auth revoke --site
510 * wp yoast auth revoke --user=admin --site --yes
511 * wp yoast auth revoke --user=admin --resource=https://ai.yoa.st
512 * wp yoast auth revoke --user=admin --site --all-resources
513 *
514 * @when after_wp_load
515 *
516 * @param array<int, string>|null $args The arguments.
517 * @param array<string, string>|null $assoc_args The associative arguments.
518 *
519 * @return void
520 */
521 public function revoke( $args = null, $assoc_args = null ): void {
522 $user_id = \get_current_user_id();
523 $has_user = ( $user_id > 0 );
524 $has_site = (bool) Utils\get_flag_value( $assoc_args, 'site', false );
525 $has_all_resources = (bool) Utils\get_flag_value( $assoc_args, 'all-resources', false );
526 $resource = Utils\get_flag_value( $assoc_args, 'resource' );
527
528 if ( ! $has_site && ! $has_user ) {
529 WP_CLI::error( 'Specify --site and/or use the global --user flag.' );
530 }
531
532 if ( $has_all_resources && $resource !== null && $resource !== '' ) {
533 WP_CLI::error( '--all-resources and --resource cannot be combined.' );
534 }
535
536 $resource_indicator = null;
537 if ( $resource !== null && $resource !== '' ) {
538 $resource_indicator = (string) $resource;
539 try {
540 new Resource_Indicator( $resource_indicator );
541 }
542 catch ( Invalid_Resource_Exception $e ) {
543 WP_CLI::error( 'Invalid resource indicator: ' . $e->getMessage() );
544 return;
545 }
546 }
547
548 WP_CLI::confirm( 'This will revoke the specified tokens. Proceed?', $assoc_args );
549
550 try {
551 if ( $has_user ) {
552 if ( $has_all_resources ) {
553 $this->myyoast_client->revoke_all_user_tokens( $user_id );
554 WP_CLI::log( \sprintf( 'User %d tokens revoked across all resources.', $user_id ) );
555 }
556 else {
557 $this->myyoast_client->revoke_user_token( $user_id, $resource_indicator );
558 WP_CLI::log( \sprintf( 'User %d tokens revoked.', $user_id ) );
559 }
560 }
561
562 if ( $has_site ) {
563 if ( $has_all_resources ) {
564 $this->myyoast_client->clear_all_site_tokens();
565 WP_CLI::log( 'All site tokens cleared.' );
566 }
567 else {
568 $this->myyoast_client->clear_site_token( $resource_indicator );
569 WP_CLI::log( 'Site token cleared.' );
570 }
571 }
572 }
573 catch ( Exception $e ) {
574 WP_CLI::error( 'Revocation failed: ' . $e->getMessage() );
575 return;
576 }
577
578 WP_CLI::success( 'Done.' );
579 }
580
581 /**
582 * Rotates cryptographic key pairs.
583 *
584 * Rotates the registration key pair (server roundtrip) and/or the DPoP
585 * key pair (local only).
586 *
587 * ## OPTIONS
588 *
589 * [--registration]
590 * : Rotate the registration (private_key_jwt) key pair. Requires a server roundtrip.
591 *
592 * [--dpop]
593 * : Rotate the DPoP proof key pair (local only).
594 *
595 * [--all]
596 * : Rotate all key pairs.
597 *
598 * [--yes]
599 * : Skip confirmation prompt.
600 *
601 * ## EXAMPLES
602 *
603 * wp yoast auth rotate-keys --registration
604 * wp yoast auth rotate-keys --dpop
605 * wp yoast auth rotate-keys --all
606 *
607 * @when after_wp_load
608 *
609 * @param array<int, string>|null $args The arguments.
610 * @param array<string, string>|null $assoc_args The associative arguments.
611 *
612 * @return void
613 *
614 * @throws ExitException When key rotation fails.
615 */
616 public function rotate_keys( $args = null, $assoc_args = null ): void {
617 $rotate_all = (bool) Utils\get_flag_value( $assoc_args, 'all', false );
618 $rotate_registration = ( $rotate_all || (bool) Utils\get_flag_value( $assoc_args, 'registration', false ) );
619 $rotate_dpop = ( $rotate_all || (bool) Utils\get_flag_value( $assoc_args, 'dpop', false ) );
620
621 if ( ! $rotate_registration && ! $rotate_dpop ) {
622 WP_CLI::error( 'Specify --registration, --dpop, or --all.' );
623 }
624
625 WP_CLI::confirm( 'This will rotate the specified key pairs. Existing tokens may be invalidated. Proceed?', $assoc_args );
626
627 if ( $rotate_registration ) {
628 if ( ! $this->myyoast_client->is_registered() ) {
629 WP_CLI::error( 'Not registered. Run "wp yoast auth register" first.' );
630 }
631
632 try {
633 $client = $this->myyoast_client->rotate_registration_keys();
634 WP_CLI::log( 'Registration keys rotated. Client ID: ' . $client->get_client_id() );
635 } catch ( Exception $e ) {
636 WP_CLI::error( 'Registration key rotation failed: ' . $e->getMessage() );
637 return;
638 }
639 }
640
641 if ( $rotate_dpop ) {
642 $this->myyoast_client->rotate_dpop_keys();
643 WP_CLI::log( 'DPoP keys rotated.' );
644 }
645
646 WP_CLI::success( 'Key rotation complete.' );
647 }
648
649 /**
650 * Performs a client_credentials grant for a site-level token.
651 *
652 * @param string[] $scopes The scopes to request.
653 * @param string|null $resource_indicator The resource indicator (RFC 8707), or null for the default resource.
654 * @param string $format The output format.
655 *
656 * @return void
657 *
658 * @throws ExitException When the token request fails.
659 */
660 private function authorize_site( array $scopes, ?string $resource_indicator, string $format ): void {
661 try {
662 $token_set = $this->myyoast_client->get_site_token( $scopes, $resource_indicator );
663 } catch ( Exception $e ) {
664 WP_CLI::error( 'Site token request failed: ' . $e->getMessage() );
665 return;
666 }
667
668 $this->output( $this->build_token_info( $token_set ), $format );
669
670 WP_CLI::success( 'Site token obtained.' );
671 }
672
673 /**
674 * Handles the user authorization code flow.
675 *
676 * @param array<string, string> $assoc_args The associative arguments.
677 * @param string[] $scopes The scopes to request.
678 * @param string|null $resource_indicator The resource indicator (RFC 8707), or null for the default resource.
679 * @param string $format The output format.
680 *
681 * @return void
682 *
683 * @throws ExitException When authorization fails.
684 */
685 private function authorize_user( array $assoc_args, array $scopes, ?string $resource_indicator, string $format ): void {
686 $user_id = \get_current_user_id();
687 if ( $user_id <= 0 ) {
688 WP_CLI::error( 'User authorization requires the global --user flag.' );
689 }
690
691 $code = Utils\get_flag_value( $assoc_args, 'code' );
692 $state = Utils\get_flag_value( $assoc_args, 'state' );
693
694 // Phase 2: exchange the code. The resource was persisted in the flow state during phase 1.
695 if ( $code !== null && $state !== null ) {
696 try {
697 $token_set = $this->myyoast_client->exchange_authorization_code(
698 $user_id,
699 (string) $code,
700 (string) $state,
701 );
702 } catch ( Exception $e ) {
703 WP_CLI::error( 'Code exchange failed: ' . $e->getMessage() );
704 return;
705 }
706
707 $this->output( $this->build_token_info( $token_set ), $format );
708
709 WP_CLI::success( 'User authorized.' );
710 return;
711 }
712
713 if ( $code !== null || $state !== null ) {
714 WP_CLI::error( 'Both --code and --state are required for code exchange.' );
715 }
716
717 // Phase 1: generate the authorization URL.
718 $redirect_uri = \get_admin_url( null, 'admin.php?page=' . General_Page_Integration::PAGE . '&yoast_myyoast_oauth_callback=1' );
719
720 try {
721 $url = $this->myyoast_client->get_authorization_url( $user_id, $redirect_uri, $scopes, $resource_indicator );
722 } catch ( Exception $e ) {
723 WP_CLI::error( 'Failed to generate authorization URL: ' . $e->getMessage() );
724 return;
725 }
726
727 if ( Utils\get_flag_value( $assoc_args, 'url-only', false ) ) {
728 WP_CLI::log( $url );
729 return;
730 }
731
732 WP_CLI::log( 'Visit this URL to authorize:' );
733 WP_CLI::log( '' );
734 WP_CLI::log( $url );
735 WP_CLI::log( '' );
736 WP_CLI::log( 'After authorizing, you will be redirected to a callback URL.' );
737 WP_CLI::log( 'If you need to manually complete authorization, copy the "code" and "state" parameters from the URL, then run:' );
738 WP_CLI::log( '' );
739 WP_CLI::log(
740 \sprintf(
741 ' wp yoast auth authorize --user=%s --code=<CODE> --state=<STATE>',
742 $user_id,
743 ),
744 );
745 }
746
747 /**
748 * Builds a display-safe token info array.
749 *
750 * @param Token_Set|null $token_set The token set, or null.
751 *
752 * @return array<string, string|int> The token info for display.
753 */
754 private function build_token_info( ?Token_Set $token_set ): array {
755 if ( $token_set === null ) {
756 return [
757 'resource' => '-',
758 'status' => 'none',
759 'expires' => '-',
760 'scopes' => '-',
761 'error_count' => '-',
762 ];
763 }
764
765 return [
766 'resource' => ( $token_set->get_resource_indicator()->is_default() ? '(default)' : $token_set->get_resource_indicator()->value() ),
767 'status' => ( $token_set->is_expired() ) ? 'expired' : 'valid',
768 'expires' => \gmdate( 'Y-m-d H:i:s', $token_set->get_expires_at() ) . ' UTC',
769 'scopes' => ( $token_set->get_scope() ?? '-' ),
770 'error_count' => $token_set->get_error_count(),
771 ];
772 }
773
774 /**
775 * Builds a display-safe inventory of tokens across resource buckets.
776 *
777 * @param Token_Set[] $token_sets The token sets.
778 *
779 * @return array<int, array<string, string|int>> The inventory.
780 */
781 private function build_token_inventory( array $token_sets ): array {
782 $inventory = [];
783 foreach ( $token_sets as $token_set ) {
784 $inventory[] = $this->build_token_info( $token_set );
785 }
786 return $inventory;
787 }
788
789 /**
790 * Parses the comma-separated scopes option.
791 *
792 * @param array<string, string>|null $assoc_args The associative arguments.
793 *
794 * @return string[] The parsed scopes.
795 */
796 private function parse_scopes( $assoc_args ): array {
797 $scopes = Utils\get_flag_value( $assoc_args, 'scopes', '' );
798 if ( $scopes === '' ) {
799 return [];
800 }
801
802 return \array_values( \array_filter( \array_map( 'trim', \explode( ',', (string) $scopes ) ) ) );
803 }
804
805 /**
806 * Flattens nested arrays for table display by JSON-encoding array values.
807 *
808 * @param array<string, string|string[]|bool> $data The data to flatten.
809 *
810 * @return array<string, string> The flattened data.
811 */
812 private function flatten_for_display( array $data ): array {
813 $result = [];
814 foreach ( $data as $key => $value ) {
815 if ( \is_array( $value ) ) {
816 // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- WP-CLI display output, not user-facing HTML.
817 $result[ $key ] = ( \wp_json_encode( $value ) ?? 'err' );
818 }
819 elseif ( \is_bool( $value ) ) {
820 $result[ $key ] = ( $value ) ? 'true' : 'false';
821 }
822 else {
823 $result[ $key ] = (string) $value;
824 }
825 }
826 return $result;
827 }
828
829 /**
830 * Outputs data in the requested format.
831 *
832 * @param array<string, string|int> $data The data to output.
833 * @param string $format The output format (table or json).
834 *
835 * @return void
836 */
837 private function output( array $data, string $format ): void {
838 if ( $format === 'json' ) {
839 // phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.FoundWithAdditionalParams -- CLI output, not user-facing HTML.
840 $encoded = \wp_json_encode( $data, \JSON_PRETTY_PRINT );
841 WP_CLI::log( ( $encoded !== false ) ? $encoded : '{}' );
842 return;
843 }
844
845 $flat_data = $this->flatten_for_display( $data );
846 $items = [];
847 foreach ( $flat_data as $key => $value ) {
848 $items[] = [
849 'field' => $key,
850 'value' => $value,
851 ];
852 }
853
854 WP_CLI\Utils\format_items( 'table', $items, [ 'field', 'value' ] );
855 }
856 }
857