← All changes
|
src/myyoast-client/application/authorization-code-handler.php
+69
-29
27.8
→
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,9 +16,11 @@ | ||
| 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 | +use Yoast\WP\SEO\MyYoast_Client\Domain\Resource_Indicator; | |
| 22 | 23 | use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set; |
| 23 | 24 | use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Encoding\Base64url; |
| 24 | 25 | use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface; |
| 25 | 26 | use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait; |
| @@ -72,15 +73,23 @@ | ||
| 72 | 73 | */ |
| 73 | 74 | private $expiring_store; |
| 74 | 75 | |
| 75 | 76 | /** |
| 77 | + * The redirect URI provider port. | |
| 78 | + * | |
| 79 | + * @var Redirect_URI_Provider_Interface | |
| 80 | + */ | |
| 81 | + private $redirect_uri_provider; | |
| 82 | + | |
| 83 | + /** | |
| 76 | 84 | * Authorization_Code_Handler constructor. |
| 77 | 85 | * |
| 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. | |
| 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. | |
| 83 | 92 | */ |
| 84 | 93 | public function __construct( |
| 85 | 94 | Discovery_Interface $discovery, |
| 86 | 95 | Client_Registration_Interface $client_registration, |
| @@ -85,16 +94,18 @@ | ||
| 85 | 94 | Discovery_Interface $discovery, |
| 86 | 95 | Client_Registration_Interface $client_registration, |
| 87 | 96 | OAuth_Grant_Handler $grant_handler, |
| 88 | 97 | ID_Token_Validator_Interface $id_token_validator, |
| 89 | - Expiring_Store $expiring_store | |
| 98 | + Expiring_Store $expiring_store, | |
| 99 | + Redirect_URI_Provider_Interface $redirect_uri_provider | |
| 90 | 100 | ) { |
| 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(); | |
| 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(); | |
| 97 | 108 | } |
| 98 | 109 | |
| 99 | 110 | /** |
| 100 | 111 | * Builds the authorization URL for the user to visit. |
| @@ -100,29 +111,37 @@ | ||
| 100 | 111 | * Builds the authorization URL for the user to visit. |
| 101 | 112 | * |
| 102 | 113 | * Generates PKCE challenge, state, and nonce, and stores them in the expiring store. |
| 103 | 114 | * |
| 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. | |
| 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. | |
| 108 | 119 | * |
| 109 | 120 | * @return string The authorization URL to redirect the user to. |
| 110 | 121 | * |
| 111 | 122 | * @throws Authorization_Flow_Exception If any of the auth flow prerequisites (registration, discovery, random number generation, or state parameter validation) fails. |
| 112 | 123 | */ |
| 113 | - public function get_authorization_url( int $user_id, string $redirect_uri, array $scopes = [], ?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 { | |
| 114 | 125 | if ( $user_id <= 0 ) { |
| 115 | 126 | throw new Authorization_Flow_Exception( 'invalid_user', 'A valid WordPress user ID is required to start the authorization flow.' ); |
| 116 | 127 | } |
| 117 | 128 | |
| 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 ); | |
| 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.' ); | |
| 123 | 133 | } |
| 124 | 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 | + | |
| 125 | 144 | try { |
| 126 | 145 | $auth_endpoint = $this->discovery->get_document()->get_authorization_endpoint(); |
| 127 | 146 | } catch ( Discovery_Failed_Exception | Server_Capability_Exception $e ) { |
| 128 | 147 | // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. |
| @@ -145,9 +164,9 @@ | ||
| 145 | 164 | throw new Authorization_Flow_Exception( 'random_failure', 'Failed to generate secure random values.', 0, $e ); |
| 146 | 165 | } |
| 147 | 166 | |
| 148 | 167 | try { |
| 149 | - $flow_state = new Auth_Flow_State( $code_verifier, $state, $nonce, $redirect_uri, $return_url ); | |
| 168 | + $flow_state = new Auth_Flow_State( $code_verifier, $state, $nonce, $redirect_uri, $return_url, $resource_indicator ); | |
| 150 | 169 | } catch ( InvalidArgumentException $e ) { |
| 151 | 170 | // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. |
| 152 | 171 | throw new Authorization_Flow_Exception( 'invalid_state', $e->getMessage(), 0, $e ); |
| 153 | 172 | } |
| @@ -173,8 +192,12 @@ | ||
| 173 | 192 | if ( $nonce !== null ) { |
| 174 | 193 | $params['nonce'] = $nonce; |
| 175 | 194 | } |
| 176 | 195 | |
| 196 | + if ( ! $resource_indicator->is_default() ) { | |
| 197 | + $params['resource'] = $resource_indicator->value(); | |
| 198 | + } | |
| 199 | + | |
| 177 | 200 | return $auth_endpoint . '?' . \http_build_query( $params, '', '&', \PHP_QUERY_RFC3986 ); |
| 178 | 201 | } |
| 179 | 202 | |
| 180 | 203 | /** |
| @@ -188,9 +211,9 @@ | ||
| 188 | 211 | * @param string $state The state parameter from the callback. |
| 189 | 212 | * |
| 190 | 213 | * @return Token_Set The obtained tokens. |
| 191 | 214 | * |
| 192 | - * @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. | |
| 193 | 216 | */ |
| 194 | 217 | public function exchange_code( int $user_id, string $code, string $state ): Token_Set { |
| 195 | 218 | if ( $user_id <= 0 ) { |
| 196 | 219 | throw new Token_Request_Failed_Exception( 'invalid_user', 'A valid WordPress user ID is required to exchange an authorization code.' ); |
| @@ -200,21 +223,24 @@ | ||
| 200 | 223 | |
| 201 | 224 | // Validate state (CSRF protection). |
| 202 | 225 | if ( ! \hash_equals( $flow_state->get_state(), $state ) ) { |
| 203 | 226 | $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 ); | |
| 227 | + $this->discard_flow_state( $user_id ); | |
| 205 | 228 | throw new Token_Request_Failed_Exception( 'invalid_request', 'State parameter mismatch.' ); |
| 206 | 229 | } |
| 207 | 230 | |
| 208 | 231 | // Clean up the stored flow state. |
| 209 | - $this->expiring_store->delete_for_user( self::CURRENT_AUTH_FLOW_STATE_KEY, $user_id ); | |
| 232 | + $this->discard_flow_state( $user_id ); | |
| 210 | 233 | |
| 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 ); | |
| 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 ); | |
| 213 | 237 | |
| 214 | 238 | // Validate ID token nonce (replay protection) if an ID token was returned. |
| 215 | 239 | $this->validate_id_token_nonce( $token_set, $flow_state ); |
| 216 | 240 | |
| 241 | + $this->client_registration->mark_uri_validated( $flow_state->get_redirect_uri() ); | |
| 242 | + | |
| 217 | 243 | return $token_set; |
| 218 | 244 | } |
| 219 | 245 | |
| 220 | 246 | /** |
| @@ -229,8 +255,22 @@ | ||
| 229 | 255 | return $this->get_flow_state( $user_id )->get_return_url(); |
| 230 | 256 | } catch ( Token_Request_Failed_Exception $e ) { |
| 231 | 257 | return null; |
| 232 | 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 ); | |
| 233 | 273 | } |
| 234 | 274 | |
| 235 | 275 | /** |
| 236 | 276 | * Validates the nonce claim in the ID token against the stored nonce. |