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 / application / authorization-code-handler.php

authorization-code-handler.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.2, at src/myyoast-client/application/authorization-code-handler.php

349 lines 13.6 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 Exception;
7 use InvalidArgumentException;
8 use Yoast\WP\SEO\Expiring_Store\Application\Expiring_Store;
9 use Yoast\WP\SEO\Expiring_Store\Domain\Corrupted_Value_Exception;
10 use Yoast\WP\SEO\Expiring_Store\Domain\Key_Not_Found_Exception;
11 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Authorization_Flow_Exception;
12 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Discovery_Failed_Exception;
13 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\ID_Token_Validation_Exception;
14 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception;
15 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Request_Failed_Exception;
16 use Yoast\WP\SEO\MyYoast_Client\Application\Grants\Authorization_Code_Grant;
17 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
18 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Discovery_Interface;
19 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\ID_Token_Validator_Interface;
20 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Redirect_URI_Provider_Interface;
21 use Yoast\WP\SEO\MyYoast_Client\Domain\Auth_Flow_State;
22 use Yoast\WP\SEO\MyYoast_Client\Domain\Resource_Indicator;
23 use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set;
24 use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Encoding\Base64url;
25 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
26 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
27 use YoastSEO_Vendor\Psr\Log\NullLogger;
28
29 /**
30 * Manages the Authorization Code + PKCE flow.
31 *
32 * Builds the authorization URL, stores PKCE/state/nonce in the expiring store,
33 * and exchanges the authorization code for tokens via OAuth_Grant_Handler.
34 */
35 class Authorization_Code_Handler implements LoggerAwareInterface {
36 use LoggerAwareTrait;
37
38 private const CURRENT_AUTH_FLOW_STATE_KEY = 'myyoast_current_authorization_state';
39 private const PKCE_TTL = ( \MINUTE_IN_SECONDS * 10 );
40
41 /**
42 * The discovery port.
43 *
44 * @var Discovery_Interface
45 */
46 private $discovery;
47
48 /**
49 * The client registration port.
50 *
51 * @var Client_Registration_Interface
52 */
53 private $client_registration;
54
55 /**
56 * The OAuth grant handler.
57 *
58 * @var OAuth_Grant_Handler
59 */
60 private $grant_handler;
61
62 /**
63 * The ID token validator port.
64 *
65 * @var ID_Token_Validator_Interface
66 */
67 private $id_token_validator;
68
69 /**
70 * The expiring store.
71 *
72 * @var Expiring_Store
73 */
74 private $expiring_store;
75
76 /**
77 * The redirect URI provider port.
78 *
79 * @var Redirect_URI_Provider_Interface
80 */
81 private $redirect_uri_provider;
82
83 /**
84 * Authorization_Code_Handler constructor.
85 *
86 * @param Discovery_Interface $discovery The discovery port.
87 * @param Client_Registration_Interface $client_registration The client registration port.
88 * @param OAuth_Grant_Handler $grant_handler The OAuth grant handler.
89 * @param ID_Token_Validator_Interface $id_token_validator The ID token validator port.
90 * @param Expiring_Store $expiring_store The expiring store.
91 * @param Redirect_URI_Provider_Interface $redirect_uri_provider The redirect URI provider port.
92 */
93 public function __construct(
94 Discovery_Interface $discovery,
95 Client_Registration_Interface $client_registration,
96 OAuth_Grant_Handler $grant_handler,
97 ID_Token_Validator_Interface $id_token_validator,
98 Expiring_Store $expiring_store,
99 Redirect_URI_Provider_Interface $redirect_uri_provider
100 ) {
101 $this->discovery = $discovery;
102 $this->client_registration = $client_registration;
103 $this->grant_handler = $grant_handler;
104 $this->id_token_validator = $id_token_validator;
105 $this->expiring_store = $expiring_store;
106 $this->redirect_uri_provider = $redirect_uri_provider;
107 $this->logger = new NullLogger();
108 }
109
110 /**
111 * Builds the authorization URL for the user to visit.
112 *
113 * Generates PKCE challenge, state, and nonce, and stores them in the expiring store.
114 *
115 * @param int $user_id The WordPress user ID.
116 * @param string[] $scopes The scopes to request.
117 * @param Resource_Indicator $resource_indicator The RFC 8707 resource indicator the issued token should be bound to.
118 * @param string|null $return_url The URL to return the user to after authorization completes.
119 *
120 * @return string The authorization URL to redirect the user to.
121 *
122 * @throws Authorization_Flow_Exception If any of the auth flow prerequisites (registration, discovery, random number generation, or state parameter validation) fails.
123 */
124 public function get_authorization_url( int $user_id, array $scopes, Resource_Indicator $resource_indicator, ?string $return_url = null ): string {
125 if ( $user_id <= 0 ) {
126 throw new Authorization_Flow_Exception( 'invalid_user', 'A valid WordPress user ID is required to start the authorization flow.' );
127 }
128
129 // Registration is a prerequisite handled by the connect flow; this method never triggers DCR.
130 $registered_client = $this->client_registration->get_registered_client();
131 if ( $registered_client === null ) {
132 throw new Authorization_Flow_Exception( 'not_registered', 'Site is not registered with MyYoast; complete the registration first.' );
133 }
134
135 // Resolve which registered redirect URI to embed in this request (the server matches it exactly).
136 $redirect_uri = $this->redirect_uri_provider->get_authorization_redirect_uri(
137 $registered_client,
138 $user_id,
139 $scopes,
140 $resource_indicator,
141 $return_url,
142 );
143
144 try {
145 $auth_endpoint = $this->discovery->get_document()->get_authorization_endpoint();
146 } catch ( Discovery_Failed_Exception |Server_Capability_Exception $e ) {
147 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
148 throw new Authorization_Flow_Exception( 'discovery_failed', $e->getMessage(), 0, $e );
149 }
150
151 $requests_openid = \in_array( 'openid', $scopes, true );
152
153 try {
154 $code_verifier = Base64url::encode( \random_bytes( 32 ) );
155 $code_challenge = Base64url::encode( \hash( 'sha256', $code_verifier, true ) );
156 // State = CSRF protection on the redirect (verified by us on callback).
157 $state = Base64url::encode( \random_bytes( 32 ) );
158 // Nonce = ID token replay protection per OIDC Core 1.0 Section 3.1.2.1
159 // (embedded in the ID token by the server, verified by us to ensure freshness).
160 // Only generated when openid scope is requested, as nonces are not permitted otherwise.
161 $nonce = ( $requests_openid ) ? Base64url::encode( \random_bytes( 16 ) ) : null;
162 } catch ( Exception $e ) {
163 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
164 throw new Authorization_Flow_Exception( 'random_failure', 'Failed to generate secure random values.', 0, $e );
165 }
166
167 try {
168 $flow_state = new Auth_Flow_State( $code_verifier, $state, $nonce, $redirect_uri, $return_url, $resource_indicator );
169 } catch ( InvalidArgumentException $e ) {
170 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
171 throw new Authorization_Flow_Exception( 'invalid_state', $e->getMessage(), 0, $e );
172 }
173
174 $this->expiring_store->persist_for_user(
175 self::CURRENT_AUTH_FLOW_STATE_KEY,
176 $flow_state->to_array(),
177 self::PKCE_TTL,
178 $user_id,
179 );
180
181 $params = [
182 'response_type' => 'code',
183 'client_id' => $registered_client->get_client_id(),
184 'redirect_uri' => $redirect_uri,
185 'scope' => \implode( ' ', $scopes ),
186 'code_challenge' => $code_challenge,
187 'code_challenge_method' => 'S256',
188 'state' => $state,
189 'prompt' => 'consent',
190 ];
191
192 if ( $nonce !== null ) {
193 $params['nonce'] = $nonce;
194 }
195
196 if ( ! $resource_indicator->is_default() ) {
197 $params['resource'] = $resource_indicator->value();
198 }
199
200 return $auth_endpoint . '?' . \http_build_query( $params, '', '&', \PHP_QUERY_RFC3986 );
201 }
202
203 /**
204 * Exchanges an authorization code for tokens.
205 *
206 * Validates the state parameter (CSRF), exchanges the code for tokens,
207 * and validates the ID token nonce (replay protection) if present.
208 *
209 * @param int $user_id The WordPress user ID.
210 * @param string $code The authorization code from the callback.
211 * @param string $state The state parameter from the callback.
212 *
213 * @return Token_Set The obtained tokens.
214 *
215 * @throws Token_Request_Failed_Exception If the site is not registered or the exchange fails.
216 */
217 public function exchange_code( int $user_id, string $code, string $state ): Token_Set {
218 if ( $user_id <= 0 ) {
219 throw new Token_Request_Failed_Exception( 'invalid_user', 'A valid WordPress user ID is required to exchange an authorization code.' );
220 }
221
222 $flow_state = $this->get_flow_state( $user_id );
223
224 // Validate state (CSRF protection).
225 if ( ! \hash_equals( $flow_state->get_state(), $state ) ) {
226 $this->logger->warning( 'Authorization code exchange failed: state parameter mismatch for user {user_id} (potential CSRF).', [ 'user_id' => $user_id ] );
227 $this->discard_flow_state( $user_id );
228 throw new Token_Request_Failed_Exception( 'invalid_request', 'State parameter mismatch.' );
229 }
230
231 // Clean up the stored flow state.
232 $this->discard_flow_state( $user_id );
233
234 $resource_indicator = $flow_state->get_resource_indicator();
235 $grant = new Authorization_Code_Grant( $code, $flow_state->get_redirect_uri(), $flow_state->get_code_verifier() );
236 $token_set = $this->grant_handler->request_token( $grant, $resource_indicator );
237
238 // Validate ID token nonce (replay protection) if an ID token was returned.
239 $this->validate_id_token_nonce( $token_set, $flow_state );
240
241 $this->client_registration->mark_uri_validated( $flow_state->get_redirect_uri() );
242
243 return $token_set;
244 }
245
246 /**
247 * Returns the stored return URL for a pending authorization flow.
248 *
249 * @param int $user_id The WordPress user ID.
250 *
251 * @return string|null The return URL, or null if not set or no pending flow.
252 */
253 public function get_return_url( int $user_id ): ?string {
254 try {
255 return $this->get_flow_state( $user_id )->get_return_url();
256 } catch ( Token_Request_Failed_Exception $e ) {
257 return null;
258 }
259 }
260
261 /**
262 * Discards any pending authorization-flow state for a user.
263 *
264 * Used when the provider returns an error (e.g. the user denied consent) so a
265 * stale flow can't be resumed. A no-op when no flow is pending.
266 *
267 * @param int $user_id The WordPress user ID.
268 *
269 * @return void
270 */
271 public function discard_flow_state( int $user_id ): void {
272 $this->expiring_store->delete_for_user( self::CURRENT_AUTH_FLOW_STATE_KEY, $user_id );
273 }
274
275 /**
276 * Validates the nonce claim in the ID token against the stored nonce.
277 *
278 * @param Token_Set $token_set The token set containing the ID token.
279 * @param Auth_Flow_State $flow_state The flow state containing the expected nonce.
280 *
281 * @return void
282 *
283 * @throws Token_Request_Failed_Exception If ID token nonce validation fails.
284 */
285 private function validate_id_token_nonce( Token_Set $token_set, Auth_Flow_State $flow_state ): void {
286 $id_token = $token_set->get_id_token();
287 if ( $id_token === null ) {
288 return;
289 }
290
291 $nonce = $flow_state->get_nonce();
292 if ( $nonce === null ) {
293 // No nonce was sent (openid scope not requested), skip ID token nonce validation.
294 return;
295 }
296
297 $registered_client = $this->client_registration->get_registered_client();
298 if ( $registered_client === null ) {
299 throw new Token_Request_Failed_Exception( 'client_not_registered', 'Client registration not found during ID token validation.' );
300 }
301
302 try {
303 $this->id_token_validator->validate( $id_token, $registered_client->get_client_id(), $nonce );
304 } catch ( ID_Token_Validation_Exception $e ) {
305 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
306 throw new Token_Request_Failed_Exception( 'invalid_id_token', $e->getMessage(), 0, $e );
307 } catch ( Discovery_Failed_Exception |Server_Capability_Exception $e ) {
308 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
309 throw new Token_Request_Failed_Exception( 'discovery_failed', $e->getMessage(), 0, $e );
310 }
311 }
312
313 /**
314 * Retrieves and validates the stored flow state for a user.
315 *
316 * @param int $user_id The WordPress user ID.
317 *
318 * @return Auth_Flow_State The stored flow state.
319 *
320 * @throws Token_Request_Failed_Exception If no pending authorization is found.
321 */
322 private function get_flow_state( int $user_id ): Auth_Flow_State {
323 try {
324 $stored = $this->expiring_store->get_for_user( self::CURRENT_AUTH_FLOW_STATE_KEY, $user_id );
325 } catch ( Key_Not_Found_Exception |Corrupted_Value_Exception $e ) {
326 $this->logger->warning( 'No pending authorization flow state found for user {user_id}.', [ 'user_id' => $user_id ] );
327 throw new Token_Request_Failed_Exception( 'invalid_request', 'No pending authorization found for this user.' );
328 }
329
330 if ( ! \is_array( $stored ) ) {
331 $this->logger->warning( 'Stored authorization flow state is not an array for user {user_id}.', [ 'user_id' => $user_id ] );
332 throw new Token_Request_Failed_Exception( 'invalid_request', 'No pending authorization found for this user.' );
333 }
334
335 try {
336 return Auth_Flow_State::from_array( $stored );
337 } catch ( InvalidArgumentException $e ) {
338 $this->logger->error(
339 'Stored authorization state is invalid for user {user_id}: {error}',
340 [
341 'user_id' => $user_id,
342 'error' => $e->getMessage(),
343 ],
344 );
345 throw new Token_Request_Failed_Exception( 'invalid_request', 'Stored authorization state is invalid.' );
346 }
347 }
348 }
349