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

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