| 1 |
<?php |
| 2 |
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded |
| 3 |
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure. |
| 4 |
|
| 5 |
namespace Yoast\WP\SEO\MyYoast_Client\User_Interface; |
| 6 |
|
| 7 |
use Yoast\WP\SEO\Conditionals\MyYoast_Connection_Conditional; |
| 8 |
use Yoast\WP\SEO\General\User_Interface\General_Page_Integration; |
| 9 |
use Yoast\WP\SEO\Helpers\Redirect_Helper; |
| 10 |
use Yoast\WP\SEO\Integrations\Integration_Interface; |
| 11 |
use Yoast\WP\SEO\MyYoast_Client\Application\Authorization_Code_Handler; |
| 12 |
use Yoast\WP\SEO\MyYoast_Client\Application\OAuth_Callback_Handler; |
| 13 |
|
| 14 |
/** |
| 15 |
* Handles the OAuth authorization-code callback redirect. |
| 16 |
* |
| 17 |
* Registers a dedicated callback endpoint on `admin-post.php` (reachable for |
| 18 |
* any logged-in user, regardless of which admin page the flow started from) as |
| 19 |
* the site's OAuth redirect URI, hooks the matching `admin_post_*` action, |
| 20 |
* drives the callback handler (which exchanges the returning code and records |
| 21 |
* the outcome for one-shot surfacing), and redirects the user to the |
| 22 |
* `return_url` they were sent off from. |
| 23 |
* |
| 24 |
* This endpoint's URL is the canonical OAuth redirect URI: the redirect-URI |
| 25 |
* provider resolves it directly from `get_callback_url()`, so the callback |
| 26 |
* never depends on a specific admin page being loaded. |
| 27 |
*/ |
| 28 |
class OAuth_Callback_Integration implements Integration_Interface { |
| 29 |
|
| 30 |
public const CALLBACK_ACTION = 'yoast_myyoast_oauth_callback'; |
| 31 |
|
| 32 |
/** |
| 33 |
* The callback handler — performs the callback-URL-agnostic orchestration. |
| 34 |
* |
| 35 |
* @var OAuth_Callback_Handler |
| 36 |
*/ |
| 37 |
private $callback_handler; |
| 38 |
|
| 39 |
/** |
| 40 |
* The authorization code handler — used to read the stored return URL. |
| 41 |
* |
| 42 |
* @var Authorization_Code_Handler |
| 43 |
*/ |
| 44 |
private $auth_code_handler; |
| 45 |
|
| 46 |
/** |
| 47 |
* The redirect helper — kept behind an injectable seam to keep the `exit` |
| 48 |
* out of the unit tests. |
| 49 |
* |
| 50 |
* @var Redirect_Helper |
| 51 |
*/ |
| 52 |
private $redirect_helper; |
| 53 |
|
| 54 |
/** |
| 55 |
* Constructor. |
| 56 |
* |
| 57 |
* @param OAuth_Callback_Handler $callback_handler The callback handler. |
| 58 |
* @param Authorization_Code_Handler $auth_code_handler The authorization code handler. |
| 59 |
* @param Redirect_Helper $redirect_helper The redirect helper. |
| 60 |
*/ |
| 61 |
public function __construct( |
| 62 |
OAuth_Callback_Handler $callback_handler, |
| 63 |
Authorization_Code_Handler $auth_code_handler, |
| 64 |
Redirect_Helper $redirect_helper |
| 65 |
) { |
| 66 |
$this->callback_handler = $callback_handler; |
| 67 |
$this->auth_code_handler = $auth_code_handler; |
| 68 |
$this->redirect_helper = $redirect_helper; |
| 69 |
} |
| 70 |
|
| 71 |
/** |
| 72 |
* Returns the conditionals on which this integration should be loaded. |
| 73 |
* |
| 74 |
* @return array<string> |
| 75 |
*/ |
| 76 |
public static function get_conditionals() { |
| 77 |
return [ MyYoast_Connection_Conditional::class ]; |
| 78 |
} |
| 79 |
|
| 80 |
/** |
| 81 |
* Registers the callback endpoint and points the site's OAuth redirect URI at it. |
| 82 |
* |
| 83 |
* @return void |
| 84 |
*/ |
| 85 |
public function register_hooks() { |
| 86 |
\add_action( 'admin_post_' . self::CALLBACK_ACTION, [ $this, 'handle' ] ); |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* Returns this site's dedicated OAuth callback endpoint URL. |
| 91 |
* |
| 92 |
* @return string The callback URL. |
| 93 |
*/ |
| 94 |
public static function get_callback_url(): string { |
| 95 |
return \get_admin_url( null, 'admin-post.php?action=' . self::CALLBACK_ACTION ); |
| 96 |
} |
| 97 |
|
| 98 |
/** |
| 99 |
* Handles the OAuth callback request. |
| 100 |
* |
| 101 |
* @return void |
| 102 |
*/ |
| 103 |
public function handle(): void { |
| 104 |
$user_id = \get_current_user_id(); |
| 105 |
$return_url = $this->resolve_return_url( $user_id ); |
| 106 |
|
| 107 |
// admin_post_* (no _nopriv variant) only fires for logged-in users, so $user_id should |
| 108 |
// always be > 0 here. Defensive check in case the hook is dispatched manually. |
| 109 |
if ( $user_id <= 0 ) { |
| 110 |
$this->redirect_helper->do_safe_redirect( $return_url ); |
| 111 |
return; |
| 112 |
} |
| 113 |
|
| 114 |
// The handler records the outcome for the next page load to surface; this |
| 115 |
// endpoint only needs to send the browser back where the flow started. |
| 116 |
$this->callback_handler->handle( |
| 117 |
$user_id, |
| 118 |
$this->read_query_arg( 'code' ), |
| 119 |
$this->read_query_arg( 'state' ), |
| 120 |
$this->read_query_arg( 'error' ), |
| 121 |
); |
| 122 |
|
| 123 |
$this->redirect_helper->do_safe_redirect( $return_url ); |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* Resolves the URL to send the browser back to after the callback runs. |
| 128 |
* |
| 129 |
* Falls back to the integrations page when no return URL is stored |
| 130 |
* (stale bookmark, no pending flow). |
| 131 |
* |
| 132 |
* @param int $user_id The WordPress user ID. |
| 133 |
* |
| 134 |
* @return string The return URL. |
| 135 |
*/ |
| 136 |
private function resolve_return_url( int $user_id ): string { |
| 137 |
$fallback = \admin_url( 'admin.php?page=' . General_Page_Integration::PAGE ); |
| 138 |
|
| 139 |
if ( $user_id > 0 ) { |
| 140 |
$stored = $this->auth_code_handler->get_return_url( $user_id ); |
| 141 |
if ( \is_string( $stored ) && $stored !== '' ) { |
| 142 |
// Defense in depth: the stored URL is only ever written as an |
| 143 |
// admin_url by the management route, but validate it against the |
| 144 |
// site's own host before redirecting so a tampered store entry |
| 145 |
// can't become an open redirect. |
| 146 |
return \wp_validate_redirect( $stored, $fallback ); |
| 147 |
} |
| 148 |
} |
| 149 |
|
| 150 |
return $fallback; |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* Reads a query argument, returning an empty string when missing. |
| 155 |
* |
| 156 |
* @param string $name The query argument name. |
| 157 |
* |
| 158 |
* @return string The sanitized value. |
| 159 |
*/ |
| 160 |
private function read_query_arg( string $name ): string { |
| 161 |
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- CSRF defense is OAuth `state` validated inside exchange_code. |
| 162 |
if ( ! isset( $_GET[ $name ] ) || ! \is_string( $_GET[ $name ] ) ) { |
| 163 |
return ''; |
| 164 |
} |
| 165 |
return \sanitize_text_field( \wp_unslash( $_GET[ $name ] ) ); |
| 166 |
// phpcs:enable WordPress.Security.NonceVerification.Recommended |
| 167 |
} |
| 168 |
} |
| 169 |
|