PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.3
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.3
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 / config / oauth-client.php

oauth-client.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.3, at src/config/oauth-client.php

309 lines 8.9 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\Config;
4
5 use Exception;
6 use Yoast\WP\SEO\Exceptions\OAuth\Authentication_Failed_Exception;
7 use Yoast\WP\SEO\Exceptions\OAuth\Tokens\Empty_Property_Exception;
8 use Yoast\WP\SEO\Exceptions\OAuth\Tokens\Empty_Token_Exception;
9 use Yoast\WP\SEO\Exceptions\OAuth\Tokens\Failed_Storage_Exception;
10 use Yoast\WP\SEO\Helpers\Options_Helper;
11 use Yoast\WP\SEO\Values\OAuth\OAuth_Token;
12 use YoastSEO_Vendor\League\OAuth2\Client\Provider\Exception\IdentityProviderException;
13 use YoastSEO_Vendor\League\OAuth2\Client\Provider\GenericProvider;
14
15 /**
16 * Class OAuth_Client
17 */
18 abstract class OAuth_Client {
19
20 /**
21 * The option's key.
22 *
23 * @var string
24 */
25 protected $token_option = null;
26
27 /**
28 * The provider.
29 *
30 * @var Wincher_PKCE_Provider|GenericProvider
31 */
32 protected $provider;
33
34 /**
35 * The options helper.
36 *
37 * @var Options_Helper
38 */
39 protected $options_helper;
40
41 /**
42 * The token.
43 *
44 * @var OAuth_Token|null
45 */
46 protected $token = null;
47
48 /**
49 * OAuth_Client constructor.
50 *
51 * @param string $token_option The option's name to save the token as.
52 * @param Wincher_PKCE_Provider|GenericProvider $provider The provider.
53 * @param Options_Helper $options_helper The Options_Helper instance.
54 *
55 * @throws Empty_Property_Exception Exception thrown if a token property is empty.
56 */
57 public function __construct(
58 $token_option,
59 $provider,
60 Options_Helper $options_helper
61 ) {
62 $this->provider = $provider;
63 $this->token_option = $token_option;
64 $this->options_helper = $options_helper;
65
66 $tokens = $this->options_helper->get( $this->token_option );
67
68 if ( ! empty( $tokens ) ) {
69 $this->token = new OAuth_Token(
70 $tokens['access_token'],
71 $tokens['refresh_token'],
72 $tokens['expires'],
73 $tokens['has_expired'],
74 $tokens['created_at'],
75 ( $tokens['error_count'] ?? 0 ),
76 );
77 }
78 }
79
80 /**
81 * Requests the access token and refresh token based on the passed code.
82 *
83 * @param string $code The code to send.
84 *
85 * @return OAuth_Token The requested tokens.
86 *
87 * @throws Authentication_Failed_Exception Exception thrown if authentication has failed.
88 */
89 public function request_tokens( $code ) {
90 try {
91 $response = $this->provider
92 ->getAccessToken(
93 'authorization_code',
94 [
95 'code' => $code,
96 ],
97 );
98
99 $token = OAuth_Token::from_response( $response );
100
101 return $this->store_token( $token );
102 } catch ( Exception $exception ) {
103 throw new Authentication_Failed_Exception( $exception );
104 }
105 }
106
107 /**
108 * Performs an authenticated GET request to the desired URL.
109 *
110 * @param string $url The URL to send the request to.
111 * @param array $options The options to pass along to the request.
112 *
113 * @return mixed The parsed API response.
114 *
115 * @throws IdentityProviderException Exception thrown if there's something wrong with the identifying data.
116 * @throws Authentication_Failed_Exception Exception thrown if authentication has failed.
117 * @throws Empty_Token_Exception Exception thrown if the token is empty.
118 */
119 public function get( $url, $options = [] ) {
120 return $this->do_request( 'GET', $url, $options );
121 }
122
123 /**
124 * Performs an authenticated POST request to the desired URL.
125 *
126 * @param string $url The URL to send the request to.
127 * @param mixed $body The data to send along in the request's body.
128 * @param array $options The options to pass along to the request.
129 *
130 * @return mixed The parsed API response.
131 *
132 * @throws IdentityProviderException Exception thrown if there's something wrong with the identifying data.
133 * @throws Authentication_Failed_Exception Exception thrown if authentication has failed.
134 * @throws Empty_Token_Exception Exception thrown if the token is empty.
135 */
136 public function post( $url, $body, $options = [] ) {
137 $options['body'] = $body;
138
139 return $this->do_request( 'POST', $url, $options );
140 }
141
142 /**
143 * Performs an authenticated DELETE request to the desired URL.
144 *
145 * @param string $url The URL to send the request to.
146 * @param array $options The options to pass along to the request.
147 *
148 * @return mixed The parsed API response.
149 *
150 * @throws IdentityProviderException Exception thrown if there's something wrong with the identifying data.
151 * @throws Authentication_Failed_Exception Exception thrown if authentication has failed.
152 * @throws Empty_Token_Exception Exception thrown if the token is empty.
153 */
154 public function delete( $url, $options = [] ) {
155 return $this->do_request( 'DELETE', $url, $options );
156 }
157
158 /**
159 * Determines whether there are valid tokens available.
160 *
161 * @return bool Whether there are valid tokens.
162 */
163 public function has_valid_tokens() {
164 return ! empty( $this->token ) && $this->token->has_expired() === false;
165 }
166
167 /**
168 * Gets the stored tokens and refreshes them if they've expired.
169 *
170 * @return OAuth_Token The stored tokens.
171 *
172 * @throws Empty_Token_Exception Exception thrown if the token is empty.
173 */
174 public function get_tokens() {
175 if ( empty( $this->token ) ) {
176 throw new Empty_Token_Exception();
177 }
178
179 if ( $this->token->has_expired() ) {
180 $this->token = $this->refresh_tokens( $this->token );
181 }
182
183 return $this->token;
184 }
185
186 /**
187 * Stores the passed token.
188 *
189 * @param OAuth_Token $token The token to store.
190 *
191 * @return OAuth_Token The stored token.
192 *
193 * @throws Failed_Storage_Exception Exception thrown if storing of the token fails.
194 */
195 public function store_token( OAuth_Token $token ) {
196 $saved = $this->options_helper->set( $this->token_option, $token->to_array() );
197
198 if ( $saved === false ) {
199 throw new Failed_Storage_Exception();
200 }
201
202 return $token;
203 }
204
205 /**
206 * Clears the stored token from storage.
207 *
208 * @return bool The stored token.
209 *
210 * @throws Failed_Storage_Exception Exception thrown if clearing of the token fails.
211 */
212 public function clear_token() {
213 $saved = $this->options_helper->set( $this->token_option, [] );
214
215 if ( $saved === false ) {
216 throw new Failed_Storage_Exception();
217 }
218
219 return true;
220 }
221
222 /**
223 * Performs the specified request.
224 *
225 * @param string $method The HTTP method to use.
226 * @param string $url The URL to send the request to.
227 * @param array $options The options to pass along to the request.
228 *
229 * @return mixed The parsed API response.
230 *
231 * @throws IdentityProviderException Exception thrown if there's something wrong with the identifying data.
232 * @throws Authentication_Failed_Exception Exception thrown if authentication has failed.
233 * @throws Empty_Token_Exception Exception thrown if the token is empty.
234 */
235 protected function do_request( $method, $url, array $options ) {
236 $defaults = [
237 'headers' => $this->provider->getHeaders( $this->get_tokens()->access_token ),
238 ];
239
240 $options = \array_merge_recursive( $defaults, $options );
241
242 if ( \array_key_exists( 'params', $options ) ) {
243 $url .= '?' . \http_build_query( $options['params'] );
244 unset( $options['params'] );
245 }
246
247 $request = $this->provider
248 ->getAuthenticatedRequest( $method, $url, null, $options );
249
250 return $this->provider->getParsedResponse( $request );
251 }
252
253 /**
254 * Refreshes the outdated tokens.
255 *
256 * @param OAuth_Token $tokens The outdated tokens.
257 *
258 * @return OAuth_Token The refreshed tokens.
259 *
260 * @throws Authentication_Failed_Exception Exception thrown if authentication has failed.
261 */
262 protected function refresh_tokens( OAuth_Token $tokens ) {
263 // We do this dance with transients since we need to make sure we don't
264 // delete valid tokens because of a race condition when two calls are
265 // made simultaneously to this function and refresh token rotation is
266 // turned on in the OAuth server. This is not 100% safe, but should at
267 // least be much better than not having any lock at all.
268 $lock_name = \sprintf( 'lock:%s', $this->token_option );
269 $can_lock = \get_transient( $lock_name ) === false;
270 $has_lock = $can_lock && \set_transient( $lock_name, true, 30 );
271
272 try {
273 $new_tokens = $this->provider->getAccessToken(
274 'refresh_token',
275 [
276 'refresh_token' => $tokens->refresh_token,
277 ],
278 );
279
280 $token_obj = OAuth_Token::from_response( $new_tokens );
281
282 return $this->store_token( $token_obj );
283 } catch ( Exception $exception ) {
284 // If we tried to refresh but the refresh token is invalid, delete
285 // the tokens so that we don't try again. Only do this if we got the
286 // lock at the beginning of the call.
287 if ( $has_lock && $exception->getMessage() === 'invalid_grant' ) {
288 try {
289 // To protect from race conditions, only do this if we've
290 // seen an error before with the same token.
291 if ( $tokens->error_count >= 1 ) {
292 $this->clear_token();
293 }
294 else {
295 $tokens->error_count += 1;
296 $this->store_token( $tokens );
297 }
298 } catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
299 // Pass through.
300 }
301 }
302
303 throw new Authentication_Failed_Exception( $exception );
304 } finally {
305 \delete_transient( $lock_name );
306 }
307 }
308 }
309