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 / application / oauth-callback-handler.php

oauth-callback-handler.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.5, at src/myyoast-client/application/oauth-callback-handler.php

175 lines 5.9 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.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