| 1 |
<?php |
| 2 |
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure. |
| 3 |
|
| 4 |
namespace Yoast\WP\SEO\MyYoast_Client\Application; |
| 5 |
|
| 6 |
use Throwable; |
| 7 |
use Yoast\WP\SEO\Expiring_Store\Application\Expiring_Store; |
| 8 |
use Yoast\WP\SEO\Expiring_Store\Domain\Corrupted_Value_Exception; |
| 9 |
use Yoast\WP\SEO\Expiring_Store\Domain\Key_Not_Found_Exception; |
| 10 |
use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Request_Failed_Exception; |
| 11 |
use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface; |
| 12 |
use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait; |
| 13 |
use YoastSEO_Vendor\Psr\Log\NullLogger; |
| 14 |
|
| 15 |
/** |
| 16 |
* Callback-URL-agnostic orchestration of the OAuth authorization-code callback. |
| 17 |
* |
| 18 |
* Given the already-extracted callback parameters (user id, code, state, error) |
| 19 |
* it performs the use-case: discard the pending flow state on a provider error, |
| 20 |
* exchange the code for tokens, persist the outcome for one-shot surfacing on |
| 21 |
* the next page load, and report it in OAuth terms. It does no transport work — |
| 22 |
* no `$_GET`, no redirect — so any consumer (the admin-post endpoint, a REST |
| 23 |
* route, WP-CLI) can drive it and translate the returned Callback_Outcome onto |
| 24 |
* its own surface. |
| 25 |
* |
| 26 |
* The callback runs on a redirect away from the page the flow started on, so the |
| 27 |
* outcome has to survive until the next page load; it is kept per-user in the |
| 28 |
* same Expiring_Store the in-progress flow state uses and read back once via |
| 29 |
* {@see consume_outcome()}. |
| 30 |
*/ |
| 31 |
class OAuth_Callback_Handler implements LoggerAwareInterface { |
| 32 |
use LoggerAwareTrait; |
| 33 |
|
| 34 |
private const OUTCOME_KEY = 'myyoast_oauth_callback_outcome'; |
| 35 |
private const OUTCOME_TTL = \MINUTE_IN_SECONDS; |
| 36 |
|
| 37 |
/** |
| 38 |
* The MyYoast client facade. |
| 39 |
* |
| 40 |
* @var MyYoast_Client |
| 41 |
*/ |
| 42 |
private $myyoast_client; |
| 43 |
|
| 44 |
/** |
| 45 |
* The authorization code handler. |
| 46 |
* |
| 47 |
* @var Authorization_Code_Handler |
| 48 |
*/ |
| 49 |
private $auth_code_handler; |
| 50 |
|
| 51 |
/** |
| 52 |
* The expiring store the outcome is kept in. |
| 53 |
* |
| 54 |
* @var Expiring_Store |
| 55 |
*/ |
| 56 |
private $expiring_store; |
| 57 |
|
| 58 |
/** |
| 59 |
* OAuth_Callback_Handler constructor. |
| 60 |
* |
| 61 |
* @param MyYoast_Client $myyoast_client The MyYoast client facade. |
| 62 |
* @param Authorization_Code_Handler $auth_code_handler The authorization code handler. |
| 63 |
* @param Expiring_Store $expiring_store The expiring store. |
| 64 |
*/ |
| 65 |
public function __construct( |
| 66 |
MyYoast_Client $myyoast_client, |
| 67 |
Authorization_Code_Handler $auth_code_handler, |
| 68 |
Expiring_Store $expiring_store |
| 69 |
) { |
| 70 |
$this->myyoast_client = $myyoast_client; |
| 71 |
$this->auth_code_handler = $auth_code_handler; |
| 72 |
$this->expiring_store = $expiring_store; |
| 73 |
$this->logger = new NullLogger(); |
| 74 |
} |
| 75 |
|
| 76 |
/** |
| 77 |
* Handles an OAuth authorization-code callback. |
| 78 |
* |
| 79 |
* The outcome is persisted for the user (except for a no-op, which is not a |
| 80 |
* real callback) so a later page load can surface it once, and also returned |
| 81 |
* for the caller to act on immediately. |
| 82 |
* |
| 83 |
* @param int $user_id The WordPress user ID the flow belongs to. |
| 84 |
* @param string $code The authorization code from the callback (empty if absent). |
| 85 |
* @param string $state The state parameter from the callback (empty if absent). |
| 86 |
* @param string $error The provider error code from the callback (empty if none). |
| 87 |
* |
| 88 |
* @return Callback_Outcome The outcome of the callback. |
| 89 |
*/ |
| 90 |
public function handle( int $user_id, string $code, string $state, string $error ): Callback_Outcome { |
| 91 |
$outcome = $this->resolve( $user_id, $code, $state, $error ); |
| 92 |
|
| 93 |
if ( ! $outcome->is_no_op() && $user_id > 0 ) { |
| 94 |
$this->expiring_store->persist_for_user( |
| 95 |
self::OUTCOME_KEY, |
| 96 |
$outcome->to_array(), |
| 97 |
self::OUTCOME_TTL, |
| 98 |
$user_id, |
| 99 |
); |
| 100 |
} |
| 101 |
|
| 102 |
return $outcome; |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Reads and consumes the pending callback outcome for a user. |
| 107 |
* |
| 108 |
* Consumed-on-read so the outcome is surfaced exactly once. |
| 109 |
* |
| 110 |
* @param int $user_id The WordPress user ID. |
| 111 |
* |
| 112 |
* @return Callback_Outcome|null The outcome, or null when none is pending. |
| 113 |
*/ |
| 114 |
public function consume_outcome( int $user_id ): ?Callback_Outcome { |
| 115 |
if ( $user_id <= 0 ) { |
| 116 |
return null; |
| 117 |
} |
| 118 |
|
| 119 |
try { |
| 120 |
$stored = $this->expiring_store->get_for_user( self::OUTCOME_KEY, $user_id ); |
| 121 |
} catch ( Key_Not_Found_Exception |Corrupted_Value_Exception $e ) { |
| 122 |
return null; |
| 123 |
} |
| 124 |
|
| 125 |
$this->expiring_store->delete_for_user( self::OUTCOME_KEY, $user_id ); |
| 126 |
|
| 127 |
if ( ! \is_array( $stored ) ) { |
| 128 |
return null; |
| 129 |
} |
| 130 |
|
| 131 |
return Callback_Outcome::from_array( $stored ); |
| 132 |
} |
| 133 |
|
| 134 |
/** |
| 135 |
* Performs the callback orchestration and classifies the result. |
| 136 |
* |
| 137 |
* @param int $user_id The WordPress user ID the flow belongs to. |
| 138 |
* @param string $code The authorization code from the callback (empty if absent). |
| 139 |
* @param string $state The state parameter from the callback (empty if absent). |
| 140 |
* @param string $error The provider error code from the callback (empty if none). |
| 141 |
* |
| 142 |
* @return Callback_Outcome The outcome of the callback. |
| 143 |
*/ |
| 144 |
private function resolve( int $user_id, string $code, string $state, string $error ): Callback_Outcome { |
| 145 |
if ( $error !== '' ) { |
| 146 |
// The provider returned an error: drop the pending flow so it can't be resumed. |
| 147 |
$this->auth_code_handler->discard_flow_state( $user_id ); |
| 148 |
return Callback_Outcome::provider_error( $error ); |
| 149 |
} |
| 150 |
|
| 151 |
if ( $code === '' || $state === '' ) { |
| 152 |
// Stale bookmark or someone hitting the callback URL directly: not a real callback. |
| 153 |
return Callback_Outcome::no_op(); |
| 154 |
} |
| 155 |
|
| 156 |
try { |
| 157 |
$this->myyoast_client->exchange_authorization_code( $user_id, $code, $state ); |
| 158 |
} catch ( Token_Request_Failed_Exception $e ) { |
| 159 |
return Callback_Outcome::exchange_error( $e->get_error_code() ); |
| 160 |
} catch ( Throwable $e ) { |
| 161 |
$this->logger->error( |
| 162 |
'Unexpected error during MyYoast OAuth callback exchange for user {user_id}: {error}', |
| 163 |
[ |
| 164 |
'user_id' => $user_id, |
| 165 |
'error' => $e->getMessage(), |
| 166 |
], |
| 167 |
); |
| 168 |
// No OAuth response was produced, so there is no native error code to surface. |
| 169 |
return Callback_Outcome::exchange_error( null ); |
| 170 |
} |
| 171 |
|
| 172 |
return Callback_Outcome::success(); |
| 173 |
} |
| 174 |
} |
| 175 |
|