| 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 |
|