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 / user-interface / oauth-callback-integration.php

oauth-callback-integration.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.2, at src/myyoast-client/user-interface/oauth-callback-integration.php

169 lines 5.4 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.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