← All changes
|
src/myyoast-client/application/authorization-code-handler.php
+56
-23
27.9
→
28.5
View file →
| @@ -10,9 +10,8 @@ | ||
| 10 | 10 | use Yoast\WP\SEO\Expiring_Store\Domain\Key_Not_Found_Exception; |
| 11 | 11 | use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Authorization_Flow_Exception; |
| 12 | 12 | use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Discovery_Failed_Exception; |
| 13 | 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 | 14 | use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Server_Capability_Exception; |
| 16 | 15 | use Yoast\WP\SEO\MyYoast_Client\Application\Exceptions\Token_Request_Failed_Exception; |
| 17 | 16 | use Yoast\WP\SEO\MyYoast_Client\Application\Grants\Authorization_Code_Grant; |
| 18 | 17 | use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface; |
| @@ -17,8 +16,9 @@ | ||
| 17 | 16 | use Yoast\WP\SEO\MyYoast_Client\Application\Grants\Authorization_Code_Grant; |
| 18 | 17 | use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface; |
| 19 | 18 | use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Discovery_Interface; |
| 20 | 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 | 21 | use Yoast\WP\SEO\MyYoast_Client\Domain\Auth_Flow_State; |
| 22 | 22 | use Yoast\WP\SEO\MyYoast_Client\Domain\Resource_Indicator; |
| 23 | 23 | use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set; |
| 24 | 24 | use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Encoding\Base64url; |
| @@ -73,15 +73,23 @@ | ||
| 73 | 73 | */ |
| 74 | 74 | private $expiring_store; |
| 75 | 75 | |
| 76 | 76 | /** |
| 77 | + * The redirect URI provider port. | |
| 78 | + * | |
| 79 | + * @var Redirect_URI_Provider_Interface | |
| 80 | + */ | |
| 81 | + private $redirect_uri_provider; | |
| 82 | + | |
| 83 | + /** | |
| 77 | 84 | * Authorization_Code_Handler constructor. |
| 78 | 85 | * |
| 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. | |
| 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. | |
| 84 | 92 | */ |
| 85 | 93 | public function __construct( |
| 86 | 94 | Discovery_Interface $discovery, |
| 87 | 95 | Client_Registration_Interface $client_registration, |
| @@ -86,16 +94,18 @@ | ||
| 86 | 94 | Discovery_Interface $discovery, |
| 87 | 95 | Client_Registration_Interface $client_registration, |
| 88 | 96 | OAuth_Grant_Handler $grant_handler, |
| 89 | 97 | ID_Token_Validator_Interface $id_token_validator, |
| 90 | - Expiring_Store $expiring_store | |
| 98 | + Expiring_Store $expiring_store, | |
| 99 | + Redirect_URI_Provider_Interface $redirect_uri_provider | |
| 91 | 100 | ) { |
| 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(); | |
| 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(); | |
| 98 | 108 | } |
| 99 | 109 | |
| 100 | 110 | /** |
| 101 | 111 | * Builds the authorization URL for the user to visit. |
| @@ -102,9 +112,8 @@ | ||
| 102 | 112 | * |
| 103 | 113 | * Generates PKCE challenge, state, and nonce, and stores them in the expiring store. |
| 104 | 114 | * |
| 105 | 115 | * @param int $user_id The WordPress user ID. |
| 106 | - * @param string $redirect_uri The callback redirect URI. | |
| 107 | 116 | * @param string[] $scopes The scopes to request. |
| 108 | 117 | * @param Resource_Indicator $resource_indicator The RFC 8707 resource indicator the issued token should be bound to. |
| 109 | 118 | * @param string|null $return_url The URL to return the user to after authorization completes. |
| 110 | 119 | * |
| @@ -111,20 +120,28 @@ | ||
| 111 | 120 | * @return string The authorization URL to redirect the user to. |
| 112 | 121 | * |
| 113 | 122 | * @throws Authorization_Flow_Exception If any of the auth flow prerequisites (registration, discovery, random number generation, or state parameter validation) fails. |
| 114 | 123 | */ |
| 115 | - public function get_authorization_url( int $user_id, string $redirect_uri, array $scopes, Resource_Indicator $resource_indicator, ?string $return_url = null ): string { | |
| 124 | + public function get_authorization_url( int $user_id, array $scopes, Resource_Indicator $resource_indicator, ?string $return_url = null ): string { | |
| 116 | 125 | if ( $user_id <= 0 ) { |
| 117 | 126 | throw new Authorization_Flow_Exception( 'invalid_user', 'A valid WordPress user ID is required to start the authorization flow.' ); |
| 118 | 127 | } |
| 119 | 128 | |
| 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 ); | |
| 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.' ); | |
| 125 | 133 | } |
| 126 | 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 | + | |
| 127 | 144 | try { |
| 128 | 145 | $auth_endpoint = $this->discovery->get_document()->get_authorization_endpoint(); |
| 129 | 146 | } catch ( Discovery_Failed_Exception | Server_Capability_Exception $e ) { |
| 130 | 147 | // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. |
| @@ -194,9 +211,9 @@ | ||
| 194 | 211 | * @param string $state The state parameter from the callback. |
| 195 | 212 | * |
| 196 | 213 | * @return Token_Set The obtained tokens. |
| 197 | 214 | * |
| 198 | - * @throws Registration_Failed_Exception|Token_Request_Failed_Exception If client registration or exchange fails. | |
| 215 | + * @throws Token_Request_Failed_Exception If the site is not registered or the exchange fails. | |
| 199 | 216 | */ |
| 200 | 217 | public function exchange_code( int $user_id, string $code, string $state ): Token_Set { |
| 201 | 218 | if ( $user_id <= 0 ) { |
| 202 | 219 | throw new Token_Request_Failed_Exception( 'invalid_user', 'A valid WordPress user ID is required to exchange an authorization code.' ); |
| @@ -206,14 +223,14 @@ | ||
| 206 | 223 | |
| 207 | 224 | // Validate state (CSRF protection). |
| 208 | 225 | if ( ! \hash_equals( $flow_state->get_state(), $state ) ) { |
| 209 | 226 | $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 ); | |
| 227 | + $this->discard_flow_state( $user_id ); | |
| 211 | 228 | throw new Token_Request_Failed_Exception( 'invalid_request', 'State parameter mismatch.' ); |
| 212 | 229 | } |
| 213 | 230 | |
| 214 | 231 | // Clean up the stored flow state. |
| 215 | - $this->expiring_store->delete_for_user( self::CURRENT_AUTH_FLOW_STATE_KEY, $user_id ); | |
| 232 | + $this->discard_flow_state( $user_id ); | |
| 216 | 233 | |
| 217 | 234 | $resource_indicator = $flow_state->get_resource_indicator(); |
| 218 | 235 | $grant = new Authorization_Code_Grant( $code, $flow_state->get_redirect_uri(), $flow_state->get_code_verifier() ); |
| 219 | 236 | $token_set = $this->grant_handler->request_token( $grant, $resource_indicator ); |
| @@ -220,8 +237,10 @@ | ||
| 220 | 237 | |
| 221 | 238 | // Validate ID token nonce (replay protection) if an ID token was returned. |
| 222 | 239 | $this->validate_id_token_nonce( $token_set, $flow_state ); |
| 223 | 240 | |
| 241 | + $this->client_registration->mark_uri_validated( $flow_state->get_redirect_uri() ); | |
| 242 | + | |
| 224 | 243 | return $token_set; |
| 225 | 244 | } |
| 226 | 245 | |
| 227 | 246 | /** |
| @@ -236,8 +255,22 @@ | ||
| 236 | 255 | return $this->get_flow_state( $user_id )->get_return_url(); |
| 237 | 256 | } catch ( Token_Request_Failed_Exception $e ) { |
| 238 | 257 | return null; |
| 239 | 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 ); | |
| 240 | 273 | } |
| 241 | 274 | |
| 242 | 275 | /** |
| 243 | 276 | * Validates the nonce claim in the ID token against the stored nonce. |