| @@ -9,9 +9,8 @@ | ||
| 9 | 9 | use WPSEO_Utils; |
| 10 | 10 | use Yoast\WP\SEO\AI\Authorization\Infrastructure\Access_Token_User_Meta_Repository_Interface; |
| 11 | 11 | use Yoast\WP\SEO\AI\Authorization\Infrastructure\Code_Verifier_User_Meta_Repository; |
| 12 | 12 | use Yoast\WP\SEO\AI\Authorization\Infrastructure\Refresh_Token_User_Meta_Repository_Interface; |
| 13 | -use Yoast\WP\SEO\AI\Consent\Application\Consent_Handler; | |
| 14 | 13 | use Yoast\WP\SEO\AI\Generator\Infrastructure\WordPress_URLs; |
| 15 | 14 | use Yoast\WP\SEO\AI\HTTP_Request\Application\Request_Handler; |
| 16 | 15 | use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Bad_Request_Exception; |
| 17 | 16 | use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Forbidden_Exception; |
| @@ -47,15 +46,8 @@ | ||
| 47 | 46 | */ |
| 48 | 47 | private $code_verifier; |
| 49 | 48 | |
| 50 | 49 | /** |
| 51 | - * The consent handler. | |
| 52 | - * | |
| 53 | - * @var Consent_Handler | |
| 54 | - */ | |
| 55 | - private $consent_handler; | |
| 56 | - | |
| 57 | - /** | |
| 58 | 50 | * The refresh token repository. |
| 59 | 51 | * |
| 60 | 52 | * @var Refresh_Token_User_Meta_Repository_Interface |
| 61 | 53 | */ |
| @@ -93,9 +85,8 @@ | ||
| 93 | 85 | * Token_Manager constructor. |
| 94 | 86 | * |
| 95 | 87 | * @param Access_Token_User_Meta_Repository_Interface $access_token_repository The access token repository. |
| 96 | 88 | * @param Code_Verifier_Handler $code_verifier The code verifier service. |
| 97 | - * @param Consent_Handler $consent_handler The consent handler. | |
| 98 | 89 | * @param Refresh_Token_User_Meta_Repository_Interface $refresh_token_repository The refresh token repository. |
| 99 | 90 | * @param User_Helper $user_helper The user helper. |
| 100 | 91 | * @param Request_Handler $request_handler The request handler. |
| 101 | 92 | * @param Code_Verifier_User_Meta_Repository $code_verifier_repository The code verifier repository. |
| @@ -103,9 +94,8 @@ | ||
| 103 | 94 | */ |
| 104 | 95 | public function __construct( |
| 105 | 96 | Access_Token_User_Meta_Repository_Interface $access_token_repository, |
| 106 | 97 | Code_Verifier_Handler $code_verifier, |
| 107 | - Consent_Handler $consent_handler, | |
| 108 | 98 | Refresh_Token_User_Meta_Repository_Interface $refresh_token_repository, |
| 109 | 99 | User_Helper $user_helper, |
| 110 | 100 | Request_Handler $request_handler, |
| 111 | 101 | Code_Verifier_User_Meta_Repository $code_verifier_repository, |
| @@ -112,9 +102,8 @@ | ||
| 112 | 102 | WordPress_URLs $urls |
| 113 | 103 | ) { |
| 114 | 104 | $this->access_token_repository = $access_token_repository; |
| 115 | 105 | $this->code_verifier = $code_verifier; |
| 116 | - $this->consent_handler = $consent_handler; | |
| 117 | 106 | $this->refresh_token_repository = $refresh_token_repository; |
| 118 | 107 | $this->user_helper = $user_helper; |
| 119 | 108 | $this->request_handler = $request_handler; |
| 120 | 109 | $this->code_verifier_repository = $code_verifier_repository; |
| @@ -125,10 +114,15 @@ | ||
| 125 | 114 | |
| 126 | 115 | /** |
| 127 | 116 | * Invalidates the access token. |
| 128 | 117 | * |
| 129 | - * @param string $user_id The user ID. | |
| 118 | + * The locally stored JWTs are always cleared, even when the remote invalidation fails — the | |
| 119 | + * remote exception still propagates to the caller, but no credentials are left behind. | |
| 130 | 120 | * |
| 121 | + * @param int $user_id The user ID. | |
| 122 | + * | |
| 123 | + * @return void | |
| 124 | + * | |
| 131 | 125 | * @throws Bad_Request_Exception Bad_Request_Exception. |
| 132 | 126 | * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception. |
| 133 | 127 | * @throws Not_Found_Exception Not_Found_Exception. |
| 134 | 128 | * @throws Payment_Required_Exception Payment_Required_Exception. |
| @@ -135,11 +129,10 @@ | ||
| 135 | 129 | * @throws Request_Timeout_Exception Request_Timeout_Exception. |
| 136 | 130 | * @throws Service_Unavailable_Exception Service_Unavailable_Exception. |
| 137 | 131 | * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception. |
| 138 | 132 | * @throws RuntimeException Unable to retrieve the access token. |
| 139 | - * @return void | |
| 140 | 133 | */ |
| 141 | - public function token_invalidate( string $user_id ): void { | |
| 134 | + public function token_invalidate( int $user_id ): void { | |
| 142 | 135 | try { |
| 143 | 136 | $access_jwt = $this->access_token_repository->get_token( $user_id ); |
| 144 | 137 | } catch ( RuntimeException $e ) { |
| 145 | 138 | $access_jwt = ''; |
| @@ -144,43 +137,68 @@ | ||
| 144 | 137 | } catch ( RuntimeException $e ) { |
| 145 | 138 | $access_jwt = ''; |
| 146 | 139 | } |
| 147 | 140 | |
| 148 | - $request_body = [ | |
| 149 | - 'user_id' => (string) $user_id, | |
| 150 | - ]; | |
| 151 | 141 | $request_headers = [ |
| 152 | 142 | 'Authorization' => "Bearer $access_jwt", |
| 153 | 143 | ]; |
| 154 | 144 | |
| 155 | 145 | try { |
| 146 | + // The endpoint takes no request body; the user is identified by the access token. | |
| 156 | 147 | $this->request_handler->handle( |
| 157 | 148 | new Request( |
| 158 | 149 | '/token/invalidate', |
| 159 | - $request_body, | |
| 150 | + [], | |
| 160 | 151 | $request_headers, |
| 161 | 152 | ), |
| 162 | 153 | ); |
| 163 | 154 | } catch ( Unauthorized_Exception | Forbidden_Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Reason: Ignored on purpose. |
| 164 | 155 | // If the credentials in our request were already invalid, our job is done and we continue to remove the tokens client-side. |
| 156 | + } finally { | |
| 157 | + // Always clear the local tokens, even when the remote invalidation fails with an exception | |
| 158 | + // that propagates: leaving credentials behind would contradict the intent of invalidating. | |
| 159 | + $this->clear_tokens( $user_id ); | |
| 165 | 160 | } |
| 166 | - | |
| 167 | - $this->clear_tokens( $user_id ); | |
| 168 | 161 | } |
| 169 | 162 | |
| 170 | 163 | /** |
| 171 | 164 | * Clears the user meta tokens for a specific user. |
| 172 | 165 | * |
| 173 | - * @param string $user_id The user id to delete this for. | |
| 166 | + * @param int $user_id The user id to delete this for. | |
| 174 | 167 | * |
| 175 | 168 | * @return void |
| 176 | 169 | */ |
| 177 | - public function clear_tokens( string $user_id ): void { | |
| 170 | + public function clear_tokens( int $user_id ): void { | |
| 178 | 171 | $this->access_token_repository->delete_token( $user_id ); |
| 179 | 172 | $this->refresh_token_repository->delete_token( $user_id ); |
| 180 | 173 | } |
| 181 | 174 | |
| 182 | 175 | /** |
| 176 | + * Checks whether any JWT (access or refresh) is stored locally for the user. | |
| 177 | + * | |
| 178 | + * @param int $user_id The user ID. | |
| 179 | + * | |
| 180 | + * @return bool Whether a locally stored JWT exists. | |
| 181 | + */ | |
| 182 | + public function has_local_tokens( int $user_id ): bool { | |
| 183 | + try { | |
| 184 | + $this->access_token_repository->get_token( $user_id ); | |
| 185 | + | |
| 186 | + return true; | |
| 187 | + } catch ( RuntimeException $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Reason: Ignored on purpose. | |
| 188 | + // No access token; fall through to the refresh token check. | |
| 189 | + } | |
| 190 | + | |
| 191 | + try { | |
| 192 | + $this->refresh_token_repository->get_token( $user_id ); | |
| 193 | + | |
| 194 | + return true; | |
| 195 | + } catch ( RuntimeException $e ) { | |
| 196 | + return false; | |
| 197 | + } | |
| 198 | + } | |
| 199 | + | |
| 200 | + /** | |
| 183 | 201 | * Requests a new set of JWT tokens. |
| 184 | 202 | * |
| 185 | 203 | * Requests a new JWT access and refresh token for a user from the Yoast AI Service and stores it in the database |
| 186 | 204 | * under usermeta. The storing of the token happens in a HTTP callback that is triggered by this request. |
| @@ -186,8 +204,10 @@ | ||
| 186 | 204 | * under usermeta. The storing of the token happens in a HTTP callback that is triggered by this request. |
| 187 | 205 | * |
| 188 | 206 | * @param WP_User $user The WP user. |
| 189 | 207 | * |
| 208 | + * @return void | |
| 209 | + * | |
| 190 | 210 | * @throws Bad_Request_Exception Bad_Request_Exception. |
| 191 | 211 | * @throws Forbidden_Exception Forbidden_Exception. |
| 192 | 212 | * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception. |
| 193 | 213 | * @throws Not_Found_Exception Not_Found_Exception. |
| @@ -195,20 +215,10 @@ | ||
| 195 | 215 | * @throws Request_Timeout_Exception Request_Timeout_Exception. |
| 196 | 216 | * @throws Service_Unavailable_Exception Service_Unavailable_Exception. |
| 197 | 217 | * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception. |
| 198 | 218 | * @throws Unauthorized_Exception Unauthorized_Exception. |
| 199 | - * @return void | |
| 200 | 219 | */ |
| 201 | 220 | public function token_request( WP_User $user ): void { |
| 202 | - // Ensure the user has given consent. | |
| 203 | - if ( $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_consent', true ) !== '1' ) { | |
| 204 | - // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive. | |
| 205 | - $this->consent_handler->revoke_consent( $user->ID ); | |
| 206 | - throw new Forbidden_Exception( 'CONSENT_REVOKED', 403 ); | |
| 207 | - | |
| 208 | - // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped | |
| 209 | - } | |
| 210 | - | |
| 211 | 221 | // Generate a code verifier and store it in the database. |
| 212 | 222 | $code_verifier = $this->code_verifier->generate( $user->user_email ); |
| 213 | 223 | $this->code_verifier_repository->store_code_verifier( $user->ID, $code_verifier->get_code(), $code_verifier->get_created_at() ); |
| 214 | 224 | |
| @@ -240,8 +250,10 @@ | ||
| 240 | 250 | * usermeta. The storing of the token happens in a HTTP callback that is triggered by this request. |
| 241 | 251 | * |
| 242 | 252 | * @param WP_User $user The WP user. |
| 243 | 253 | * |
| 254 | + * @return void | |
| 255 | + * | |
| 244 | 256 | * @throws Bad_Request_Exception Bad_Request_Exception. |
| 245 | 257 | * @throws Forbidden_Exception Forbidden_Exception. |
| 246 | 258 | * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception. |
| 247 | 259 | * @throws Not_Found_Exception Not_Found_Exception. |
| @@ -250,9 +262,8 @@ | ||
| 250 | 262 | * @throws Service_Unavailable_Exception Service_Unavailable_Exception. |
| 251 | 263 | * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception. |
| 252 | 264 | * @throws Unauthorized_Exception Unauthorized_Exception. |
| 253 | 265 | * @throws RuntimeException Unable to retrieve the refresh token. |
| 254 | - * @return void | |
| 255 | 266 | */ |
| 256 | 267 | public function token_refresh( WP_User $user ): void { |
| 257 | 268 | $refresh_jwt = $this->refresh_token_repository->get_token( $user->ID ); |
| 258 | 269 | |
| @@ -306,8 +317,10 @@ | ||
| 306 | 317 | * Retrieves the access token. |
| 307 | 318 | * |
| 308 | 319 | * @param WP_User $user The WP user. |
| 309 | 320 | * |
| 321 | + * @return string The access token. | |
| 322 | + * | |
| 310 | 323 | * @throws Bad_Request_Exception Bad_Request_Exception. |
| 311 | 324 | * @throws Forbidden_Exception Forbidden_Exception. |
| 312 | 325 | * @throws Internal_Server_Error_Exception Internal_Server_Error_Exception. |
| 313 | 326 | * @throws Not_Found_Exception Not_Found_Exception. |
| @@ -316,15 +329,13 @@ | ||
| 316 | 329 | * @throws Service_Unavailable_Exception Service_Unavailable_Exception. |
| 317 | 330 | * @throws Too_Many_Requests_Exception Too_Many_Requests_Exception. |
| 318 | 331 | * @throws Unauthorized_Exception Unauthorized_Exception. |
| 319 | 332 | * @throws RuntimeException Unable to retrieve the access or refresh token. |
| 320 | - * @return string The access token. | |
| 321 | 333 | */ |
| 322 | 334 | public function get_or_request_access_token( WP_User $user ): string { |
| 323 | 335 | // If the site URL has changed since callback URLs were registered, delete stale tokens. |
| 324 | 336 | if ( $this->have_callback_urls_changed( $user ) ) { |
| 325 | - $this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt' ); | |
| 326 | - $this->user_helper->delete_meta( $user->ID, '_yoast_wpseo_ai_generator_refresh_jwt' ); | |
| 337 | + $this->clear_tokens( $user->ID ); | |
| 327 | 338 | } |
| 328 | 339 | |
| 329 | 340 | $access_jwt = $this->user_helper->get_meta( $user->ID, '_yoast_wpseo_ai_generator_access_jwt', true ); |
| 330 | 341 | if ( ! \is_string( $access_jwt ) || $access_jwt === '' ) { |
| @@ -335,14 +346,8 @@ | ||
| 335 | 346 | try { |
| 336 | 347 | $this->token_refresh( $user ); |
| 337 | 348 | } catch ( Unauthorized_Exception $exception ) { |
| 338 | 349 | $this->token_request( $user ); |
| 339 | - } catch ( Forbidden_Exception $exception ) { | |
| 340 | - // Follow the API in the consent being revoked (Use case: user sent an e-mail to revoke?). | |
| 341 | - // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- false positive. | |
| 342 | - $this->consent_handler->revoke_consent( $user->ID ); | |
| 343 | - throw new Forbidden_Exception( 'CONSENT_REVOKED', 403 ); | |
| 344 | - // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped | |
| 345 | 350 | } |
| 346 | 351 | $access_jwt = $this->access_token_repository->get_token( $user->ID ); |
| 347 | 352 | } |
| 348 | 353 | |