PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 27.9
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v27.9
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 27.9, at src/myyoast-client/application/authorization-code-handler.php

316 lines 12.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\Registration_Failed_Exception;
15 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception;
16 use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Request_Failed_Exception;
17 use Yoast\WP\SEO\MyYoast_Client\Application\Grants\Authorization_Code_Grant;
18 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
19 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Discovery_Interface;
20 use Yoast\WP\SEO\MyYoast_Client\Application\Ports\ID_Token_Validator_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 * Authorization_Code_Handler constructor.
78 *
79 * @param Discovery_Interface $discovery The discovery port.
80 * @param Client_Registration_Interface $client_registration The client registration port.
81 * @param OAuth_Grant_Handler $grant_handler The OAuth grant handler.
82 * @param ID_Token_Validator_Interface $id_token_validator The ID token validator port.
83 * @param Expiring_Store $expiring_store The expiring store.
84 */
85 public function __construct(
86 Discovery_Interface $discovery,
87 Client_Registration_Interface $client_registration,
88 OAuth_Grant_Handler $grant_handler,
89 ID_Token_Validator_Interface $id_token_validator,
90 Expiring_Store $expiring_store
91 ) {
92 $this->discovery = $discovery;
93 $this->client_registration = $client_registration;
94 $this->grant_handler = $grant_handler;
95 $this->id_token_validator = $id_token_validator;
96 $this->expiring_store = $expiring_store;
97 $this->logger = new NullLogger();
98 }
99
100 /**
101 * Builds the authorization URL for the user to visit.
102 *
103 * Generates PKCE challenge, state, and nonce, and stores them in the expiring store.
104 *
105 * @param int $user_id The WordPress user ID.
106 * @param string $redirect_uri The callback redirect URI.
107 * @param string[] $scopes The scopes to request.
108 * @param Resource_Indicator $resource_indicator The RFC 8707 resource indicator the issued token should be bound to.
109 * @param string|null $return_url The URL to return the user to after authorization completes.
110 *
111 * @return string The authorization URL to redirect the user to.
112 *
113 * @throws Authorization_Flow_Exception If any of the auth flow prerequisites (registration, discovery, random number generation, or state parameter validation) fails.
114 */
115 public function get_authorization_url( int $user_id, string $redirect_uri, array $scopes, Resource_Indicator $resource_indicator, ?string $return_url = null ): string {
116 if ( $user_id <= 0 ) {
117 throw new Authorization_Flow_Exception( 'invalid_user', 'A valid WordPress user ID is required to start the authorization flow.' );
118 }
119
120 try {
121 $registered_client = $this->client_registration->ensure_registered( [ $redirect_uri ] );
122 } catch ( Registration_Failed_Exception $e ) {
123 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
124 throw new Authorization_Flow_Exception( 'registration_failed', $e->getMessage(), 0, $e );
125 }
126
127 try {
128 $auth_endpoint = $this->discovery->get_document()->get_authorization_endpoint();
129 } catch ( Discovery_Failed_Exception |Server_Capability_Exception $e ) {
130 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
131 throw new Authorization_Flow_Exception( 'discovery_failed', $e->getMessage(), 0, $e );
132 }
133
134 $requests_openid = \in_array( 'openid', $scopes, true );
135
136 try {
137 $code_verifier = Base64url::encode( \random_bytes( 32 ) );
138 $code_challenge = Base64url::encode( \hash( 'sha256', $code_verifier, true ) );
139 // State = CSRF protection on the redirect (verified by us on callback).
140 $state = Base64url::encode( \random_bytes( 32 ) );
141 // Nonce = ID token replay protection per OIDC Core 1.0 Section 3.1.2.1
142 // (embedded in the ID token by the server, verified by us to ensure freshness).
143 // Only generated when openid scope is requested, as nonces are not permitted otherwise.
144 $nonce = ( $requests_openid ) ? Base64url::encode( \random_bytes( 16 ) ) : null;
145 } catch ( Exception $e ) {
146 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
147 throw new Authorization_Flow_Exception( 'random_failure', 'Failed to generate secure random values.', 0, $e );
148 }
149
150 try {
151 $flow_state = new Auth_Flow_State( $code_verifier, $state, $nonce, $redirect_uri, $return_url, $resource_indicator );
152 } catch ( InvalidArgumentException $e ) {
153 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
154 throw new Authorization_Flow_Exception( 'invalid_state', $e->getMessage(), 0, $e );
155 }
156
157 $this->expiring_store->persist_for_user(
158 self::CURRENT_AUTH_FLOW_STATE_KEY,
159 $flow_state->to_array(),
160 self::PKCE_TTL,
161 $user_id,
162 );
163
164 $params = [
165 'response_type' => 'code',
166 'client_id' => $registered_client->get_client_id(),
167 'redirect_uri' => $redirect_uri,
168 'scope' => \implode( ' ', $scopes ),
169 'code_challenge' => $code_challenge,
170 'code_challenge_method' => 'S256',
171 'state' => $state,
172 'prompt' => 'consent',
173 ];
174
175 if ( $nonce !== null ) {
176 $params['nonce'] = $nonce;
177 }
178
179 if ( ! $resource_indicator->is_default() ) {
180 $params['resource'] = $resource_indicator->value();
181 }
182
183 return $auth_endpoint . '?' . \http_build_query( $params, '', '&', \PHP_QUERY_RFC3986 );
184 }
185
186 /**
187 * Exchanges an authorization code for tokens.
188 *
189 * Validates the state parameter (CSRF), exchanges the code for tokens,
190 * and validates the ID token nonce (replay protection) if present.
191 *
192 * @param int $user_id The WordPress user ID.
193 * @param string $code The authorization code from the callback.
194 * @param string $state The state parameter from the callback.
195 *
196 * @return Token_Set The obtained tokens.
197 *
198 * @throws Registration_Failed_Exception|Token_Request_Failed_Exception If client registration or exchange fails.
199 */
200 public function exchange_code( int $user_id, string $code, string $state ): Token_Set {
201 if ( $user_id <= 0 ) {
202 throw new Token_Request_Failed_Exception( 'invalid_user', 'A valid WordPress user ID is required to exchange an authorization code.' );
203 }
204
205 $flow_state = $this->get_flow_state( $user_id );
206
207 // Validate state (CSRF protection).
208 if ( ! \hash_equals( $flow_state->get_state(), $state ) ) {
209 $this->logger->warning( 'Authorization code exchange failed: state parameter mismatch for user {user_id} (potential CSRF).', [ 'user_id' => $user_id ] );
210 $this->expiring_store->delete_for_user( self::CURRENT_AUTH_FLOW_STATE_KEY, $user_id );
211 throw new Token_Request_Failed_Exception( 'invalid_request', 'State parameter mismatch.' );
212 }
213
214 // Clean up the stored flow state.
215 $this->expiring_store->delete_for_user( self::CURRENT_AUTH_FLOW_STATE_KEY, $user_id );
216
217 $resource_indicator = $flow_state->get_resource_indicator();
218 $grant = new Authorization_Code_Grant( $code, $flow_state->get_redirect_uri(), $flow_state->get_code_verifier() );
219 $token_set = $this->grant_handler->request_token( $grant, $resource_indicator );
220
221 // Validate ID token nonce (replay protection) if an ID token was returned.
222 $this->validate_id_token_nonce( $token_set, $flow_state );
223
224 return $token_set;
225 }
226
227 /**
228 * Returns the stored return URL for a pending authorization flow.
229 *
230 * @param int $user_id The WordPress user ID.
231 *
232 * @return string|null The return URL, or null if not set or no pending flow.
233 */
234 public function get_return_url( int $user_id ): ?string {
235 try {
236 return $this->get_flow_state( $user_id )->get_return_url();
237 } catch ( Token_Request_Failed_Exception $e ) {
238 return null;
239 }
240 }
241
242 /**
243 * Validates the nonce claim in the ID token against the stored nonce.
244 *
245 * @param Token_Set $token_set The token set containing the ID token.
246 * @param Auth_Flow_State $flow_state The flow state containing the expected nonce.
247 *
248 * @return void
249 *
250 * @throws Token_Request_Failed_Exception If ID token nonce validation fails.
251 */
252 private function validate_id_token_nonce( Token_Set $token_set, Auth_Flow_State $flow_state ): void {
253 $id_token = $token_set->get_id_token();
254 if ( $id_token === null ) {
255 return;
256 }
257
258 $nonce = $flow_state->get_nonce();
259 if ( $nonce === null ) {
260 // No nonce was sent (openid scope not requested), skip ID token nonce validation.
261 return;
262 }
263
264 $registered_client = $this->client_registration->get_registered_client();
265 if ( $registered_client === null ) {
266 throw new Token_Request_Failed_Exception( 'client_not_registered', 'Client registration not found during ID token validation.' );
267 }
268
269 try {
270 $this->id_token_validator->validate( $id_token, $registered_client->get_client_id(), $nonce );
271 } catch ( ID_Token_Validation_Exception $e ) {
272 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
273 throw new Token_Request_Failed_Exception( 'invalid_id_token', $e->getMessage(), 0, $e );
274 } catch ( Discovery_Failed_Exception |Server_Capability_Exception $e ) {
275 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
276 throw new Token_Request_Failed_Exception( 'discovery_failed', $e->getMessage(), 0, $e );
277 }
278 }
279
280 /**
281 * Retrieves and validates the stored flow state for a user.
282 *
283 * @param int $user_id The WordPress user ID.
284 *
285 * @return Auth_Flow_State The stored flow state.
286 *
287 * @throws Token_Request_Failed_Exception If no pending authorization is found.
288 */
289 private function get_flow_state( int $user_id ): Auth_Flow_State {
290 try {
291 $stored = $this->expiring_store->get_for_user( self::CURRENT_AUTH_FLOW_STATE_KEY, $user_id );
292 } catch ( Key_Not_Found_Exception |Corrupted_Value_Exception $e ) {
293 $this->logger->warning( 'No pending authorization flow state found for user {user_id}.', [ 'user_id' => $user_id ] );
294 throw new Token_Request_Failed_Exception( 'invalid_request', 'No pending authorization found for this user.' );
295 }
296
297 if ( ! \is_array( $stored ) ) {
298 $this->logger->warning( 'Stored authorization flow state is not an array for user {user_id}.', [ 'user_id' => $user_id ] );
299 throw new Token_Request_Failed_Exception( 'invalid_request', 'No pending authorization found for this user.' );
300 }
301
302 try {
303 return Auth_Flow_State::from_array( $stored );
304 } catch ( InvalidArgumentException $e ) {
305 $this->logger->error(
306 'Stored authorization state is invalid for user {user_id}: {error}',
307 [
308 'user_id' => $user_id,
309 'error' => $e->getMessage(),
310 ],
311 );
312 throw new Token_Request_Failed_Exception( 'invalid_request', 'Stored authorization state is invalid.' );
313 }
314 }
315 }
316