Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
44.93% |
102 / 227 |
|
38.89% |
7 / 18 |
CRAP | |
0.00% |
0 / 1 |
| PayPal_OAuth | |
45.33% |
102 / 225 |
|
38.89% |
7 / 18 |
918.90 | |
0.00% |
0 / 1 |
| get_environment | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| set_environment | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| get_base_url | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| get_encryption_key | |
33.33% |
2 / 6 |
|
0.00% |
0 / 1 |
8.74 | |||
| encrypt | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
| decrypt | |
75.00% |
9 / 12 |
|
0.00% |
0 / 1 |
5.39 | |||
| store_credentials | |
88.89% |
16 / 18 |
|
0.00% |
0 / 1 |
5.03 | |||
| get_credentials | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
7.01 | |||
| has_credentials | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| delete_credentials | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| get_access_token | |
66.67% |
8 / 12 |
|
0.00% |
0 / 1 |
7.33 | |||
| request_access_token_with_lock | |
50.00% |
7 / 14 |
|
0.00% |
0 / 1 |
10.50 | |||
| request_access_token | |
10.00% |
6 / 60 |
|
0.00% |
0 / 1 |
82.90 | |||
| clear_cached_token | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| validate_credentials | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| validate_api_access | |
0.00% |
0 / 40 |
|
0.00% |
0 / 1 |
156 | |||
| get_connection_status | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
4.06 | |||
| disconnect | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * PayPal OAuth 2.0 client credentials authentication handler. |
| 4 | * |
| 5 | * Manages OAuth token exchange, credential storage, and token caching |
| 6 | * for the PayPal Pay Links & Buttons API integration. |
| 7 | * |
| 8 | * @package automattic/jetpack-paypal-payments |
| 9 | * @since 0.7.0 |
| 10 | */ |
| 11 | |
| 12 | namespace Automattic\Jetpack\PaypalPayments; |
| 13 | |
| 14 | use Automattic\Jetpack\Connection\Manager; |
| 15 | use Automattic\Jetpack\Status\Host; |
| 16 | |
| 17 | if ( ! defined( 'ABSPATH' ) ) { |
| 18 | exit; |
| 19 | } |
| 20 | |
| 21 | /** |
| 22 | * Class PayPal_OAuth |
| 23 | * |
| 24 | * Handles PayPal OAuth 2.0 client credentials grant flow. |
| 25 | * Stores encrypted credentials in wp_options and caches |
| 26 | * access tokens using WordPress transients. |
| 27 | */ |
| 28 | class PayPal_OAuth { |
| 29 | |
| 30 | /** |
| 31 | * Request-scoped cache for decrypted credentials. |
| 32 | * Prevents redundant sodium_crypto_secretbox_open calls within a single request. |
| 33 | * |
| 34 | * @var array|false|null Null = not yet fetched, false = no credentials, array = cached. |
| 35 | */ |
| 36 | private static $credentials_cache = null; |
| 37 | |
| 38 | /** |
| 39 | * Option key for storing encrypted PayPal client credentials. |
| 40 | * |
| 41 | * @var string |
| 42 | */ |
| 43 | const CREDENTIALS_OPTION_KEY = 'jetpack_paypal_payment_buttons_credentials'; |
| 44 | |
| 45 | /** |
| 46 | * Transient key for caching the OAuth access token. |
| 47 | * |
| 48 | * @var string |
| 49 | */ |
| 50 | const TOKEN_TRANSIENT_KEY = 'jetpack_paypal_payment_buttons_token'; |
| 51 | |
| 52 | /** |
| 53 | * Option key for the environment setting (sandbox or production). |
| 54 | * |
| 55 | * @var string |
| 56 | */ |
| 57 | const ENVIRONMENT_OPTION_KEY = 'jetpack_paypal_payment_buttons_environment'; |
| 58 | |
| 59 | /** |
| 60 | * PayPal sandbox API base URL. |
| 61 | * |
| 62 | * @var string |
| 63 | */ |
| 64 | const SANDBOX_BASE_URL = 'https://api-m.sandbox.paypal.com'; |
| 65 | |
| 66 | /** |
| 67 | * PayPal production API base URL. |
| 68 | * |
| 69 | * @var string |
| 70 | */ |
| 71 | const PRODUCTION_BASE_URL = 'https://api.paypal.com'; |
| 72 | |
| 73 | /** |
| 74 | * OAuth token endpoint path. |
| 75 | * |
| 76 | * @var string |
| 77 | */ |
| 78 | const TOKEN_ENDPOINT = '/v1/oauth2/token'; |
| 79 | |
| 80 | /** |
| 81 | * Buffer in seconds to subtract from token expiry for early refresh. |
| 82 | * Refreshes 5 minutes before actual expiry to prevent edge-case failures. |
| 83 | * |
| 84 | * @var int |
| 85 | */ |
| 86 | const TOKEN_EXPIRY_BUFFER = 300; |
| 87 | |
| 88 | /** |
| 89 | * Option key for storing the absolute token expiry timestamp. |
| 90 | * |
| 91 | * Provides a reliable expiry check independent of the transient cache, |
| 92 | * which can be evicted by object caches or plugin flushes. |
| 93 | * |
| 94 | * @var string |
| 95 | */ |
| 96 | const TOKEN_EXPIRES_AT_OPTION_KEY = 'jetpack_paypal_payment_buttons_token_expires_at'; |
| 97 | |
| 98 | /** |
| 99 | * Get the current PayPal API environment. |
| 100 | * |
| 101 | * @return string 'sandbox' or 'production'. Defaults to 'production'. |
| 102 | */ |
| 103 | public static function get_environment() { |
| 104 | return get_option( self::ENVIRONMENT_OPTION_KEY, 'production' ); |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Set the PayPal API environment. |
| 109 | * |
| 110 | * @param string $environment Either 'sandbox' or 'production'. |
| 111 | * @return bool True if the option was updated, false otherwise. |
| 112 | */ |
| 113 | public static function set_environment( $environment ) { |
| 114 | $environment = sanitize_text_field( $environment ); |
| 115 | |
| 116 | if ( ! in_array( $environment, array( 'sandbox', 'production' ), true ) ) { |
| 117 | return false; |
| 118 | } |
| 119 | |
| 120 | // Clear cached token when environment changes. |
| 121 | self::clear_cached_token(); |
| 122 | |
| 123 | return update_option( self::ENVIRONMENT_OPTION_KEY, $environment, false ); |
| 124 | } |
| 125 | |
| 126 | /** |
| 127 | * Get the PayPal API base URL for the current environment. |
| 128 | * |
| 129 | * @return string The base URL (no trailing slash). |
| 130 | */ |
| 131 | public static function get_base_url() { |
| 132 | return 'production' === self::get_environment() |
| 133 | ? self::PRODUCTION_BASE_URL |
| 134 | : self::SANDBOX_BASE_URL; |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * Derive a symmetric encryption key from AUTH_KEY. |
| 139 | * |
| 140 | * Uses sodium_crypto_generichash (BLAKE2b) to derive a fixed-length |
| 141 | * key suitable for sodium_crypto_secretbox from the WordPress AUTH_KEY |
| 142 | * constant defined in wp-config.php. |
| 143 | * |
| 144 | * @return string|\WP_Error Raw binary key of SODIUM_CRYPTO_SECRETBOX_KEYBYTES length, or WP_Error if AUTH_KEY is unusable. |
| 145 | */ |
| 146 | private static function get_encryption_key() { |
| 147 | if ( ! defined( 'AUTH_KEY' ) || '' === \AUTH_KEY || 'put your unique phrase here' === \AUTH_KEY ) { |
| 148 | return new \WP_Error( |
| 149 | 'weak_encryption_key', |
| 150 | __( 'Your site\'s AUTH_KEY is not configured. Please set unique security keys in wp-config.php before connecting PayPal. Visit https://api.wordpress.org/secret-key/1.1/salt/ to generate them.', 'jetpack-paypal-payments' ) |
| 151 | ); |
| 152 | } |
| 153 | |
| 154 | return sodium_crypto_generichash( \AUTH_KEY, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES ); |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Encrypt a plaintext string using sodium_crypto_secretbox. |
| 159 | * |
| 160 | * Returns a base64-encoded string containing the nonce prepended to the ciphertext. |
| 161 | * |
| 162 | * @param string $plaintext The string to encrypt. |
| 163 | * @return string|\WP_Error Base64-encoded nonce + ciphertext, or WP_Error if the encryption key is unavailable. |
| 164 | */ |
| 165 | public static function encrypt( $plaintext ) { |
| 166 | $key = self::get_encryption_key(); |
| 167 | if ( is_wp_error( $key ) ) { |
| 168 | return $key; |
| 169 | } |
| 170 | $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); |
| 171 | |
| 172 | $ciphertext = sodium_crypto_secretbox( $plaintext, $nonce, $key ); |
| 173 | |
| 174 | return base64_encode( $nonce . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding binary ciphertext for safe storage in wp_options. |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * Decrypt a string previously encrypted with self::encrypt(). |
| 179 | * |
| 180 | * @param string $encoded Base64-encoded nonce + ciphertext. |
| 181 | * @return string|false The decrypted plaintext, or false on failure. |
| 182 | */ |
| 183 | public static function decrypt( $encoded ) { |
| 184 | $decoded = base64_decode( $encoded, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding binary ciphertext from wp_options storage. |
| 185 | |
| 186 | if ( false === $decoded || strlen( $decoded ) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES ) { |
| 187 | return false; |
| 188 | } |
| 189 | |
| 190 | $nonce = substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); |
| 191 | $ciphertext = substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); |
| 192 | $key = self::get_encryption_key(); |
| 193 | if ( is_wp_error( $key ) ) { |
| 194 | return false; |
| 195 | } |
| 196 | |
| 197 | try { |
| 198 | $plaintext = sodium_crypto_secretbox_open( $ciphertext, $nonce, $key ); |
| 199 | } catch ( \SodiumException $e ) { |
| 200 | return false; |
| 201 | } |
| 202 | |
| 203 | return $plaintext; |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Store PayPal client credentials. |
| 208 | * |
| 209 | * Credentials are encrypted at rest using sodium_crypto_secretbox |
| 210 | * (XSalsa20-Poly1305 authenticated encryption) with a key derived |
| 211 | * from AUTH_KEY via BLAKE2b. Each storage operation generates a |
| 212 | * fresh random nonce. |
| 213 | * |
| 214 | * @param string $client_id The PayPal OAuth client ID. |
| 215 | * @param string $client_secret The PayPal OAuth client secret. |
| 216 | * @return bool|\WP_Error True on success, false on empty input, WP_Error on encryption failure. |
| 217 | */ |
| 218 | public static function store_credentials( $client_id, $client_secret ) { |
| 219 | // sanitize_text_field() is avoided here because it may strip valid OAuth |
| 220 | // credential characters (+, /, =). wp_strip_all_tags() leaves those |
| 221 | // intact while still removing markup, which a real PayPal credential |
| 222 | // never contains. |
| 223 | $client_id = trim( wp_strip_all_tags( wp_unslash( $client_id ) ) ); |
| 224 | $client_secret = trim( wp_strip_all_tags( wp_unslash( $client_secret ) ) ); |
| 225 | |
| 226 | if ( empty( $client_id ) || empty( $client_secret ) ) { |
| 227 | return false; |
| 228 | } |
| 229 | |
| 230 | $encrypted_id = self::encrypt( $client_id ); |
| 231 | $encrypted_secret = self::encrypt( $client_secret ); |
| 232 | |
| 233 | // If encryption failed due to weak AUTH_KEY, propagate the error. |
| 234 | if ( is_wp_error( $encrypted_id ) ) { |
| 235 | return $encrypted_id; |
| 236 | } |
| 237 | if ( is_wp_error( $encrypted_secret ) ) { |
| 238 | return $encrypted_secret; |
| 239 | } |
| 240 | |
| 241 | $credentials = array( |
| 242 | 'encrypted_client_id' => $encrypted_id, |
| 243 | 'encrypted_client_secret' => $encrypted_secret, |
| 244 | 'stored_at' => time(), |
| 245 | ); |
| 246 | |
| 247 | // Clear caches since credentials changed. |
| 248 | self::$credentials_cache = null; |
| 249 | self::clear_cached_token(); |
| 250 | |
| 251 | return update_option( self::CREDENTIALS_OPTION_KEY, $credentials, false ); |
| 252 | } |
| 253 | |
| 254 | /** |
| 255 | * Retrieve stored PayPal client credentials. |
| 256 | * |
| 257 | * Decrypts credentials from wp_options using sodium_crypto_secretbox. |
| 258 | * If decryption fails (corrupted data or AUTH_KEY changed), the stored |
| 259 | * credentials are deleted and false is returned. |
| 260 | * |
| 261 | * @return array|false Array with 'client_id' and 'client_secret' keys, or false if not set. |
| 262 | */ |
| 263 | public static function get_credentials() { |
| 264 | // Return from request-scoped cache if available. |
| 265 | if ( null !== self::$credentials_cache ) { |
| 266 | return self::$credentials_cache; |
| 267 | } |
| 268 | |
| 269 | $credentials = get_option( self::CREDENTIALS_OPTION_KEY, false ); |
| 270 | |
| 271 | if ( ! is_array( $credentials ) |
| 272 | || empty( $credentials['encrypted_client_id'] ) |
| 273 | || empty( $credentials['encrypted_client_secret'] ) |
| 274 | ) { |
| 275 | self::$credentials_cache = false; |
| 276 | return false; |
| 277 | } |
| 278 | |
| 279 | $client_id = self::decrypt( $credentials['encrypted_client_id'] ); |
| 280 | $client_secret = self::decrypt( $credentials['encrypted_client_secret'] ); |
| 281 | |
| 282 | if ( false === $client_id || false === $client_secret ) { |
| 283 | // Decryption failed — likely AUTH_KEY changed or data corrupted. Deleting |
| 284 | // is by design (WOOPTP-189); the log is the only trace it ever happened. |
| 285 | error_log( 'PayPal Payment Buttons: stored credentials could not be decrypted; deleting them. AUTH_KEY may have changed.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log |
| 286 | self::delete_credentials(); |
| 287 | return false; |
| 288 | } |
| 289 | |
| 290 | self::$credentials_cache = array( |
| 291 | 'client_id' => $client_id, |
| 292 | 'client_secret' => $client_secret, |
| 293 | ); |
| 294 | |
| 295 | return self::$credentials_cache; |
| 296 | } |
| 297 | |
| 298 | /** |
| 299 | * Check whether PayPal credentials are stored and valid. |
| 300 | * |
| 301 | * @return bool True if credentials exist and pass integrity check. |
| 302 | */ |
| 303 | public static function has_credentials() { |
| 304 | return false !== self::get_credentials(); |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * Delete stored PayPal credentials and cached token. |
| 309 | * |
| 310 | * @return bool True on success, false on failure. |
| 311 | */ |
| 312 | public static function delete_credentials() { |
| 313 | self::$credentials_cache = null; |
| 314 | self::clear_cached_token(); |
| 315 | return delete_option( self::CREDENTIALS_OPTION_KEY ); |
| 316 | } |
| 317 | |
| 318 | /** |
| 319 | * Get a valid OAuth access token. |
| 320 | * |
| 321 | * Returns a cached token if still valid, otherwise requests a new one |
| 322 | * from PayPal's OAuth endpoint using the client credentials grant. |
| 323 | * |
| 324 | * @return string|\WP_Error The access token string, or WP_Error on failure. |
| 325 | */ |
| 326 | public static function get_access_token() { |
| 327 | // Try cached token first (stored encrypted). |
| 328 | $cached_encrypted = get_transient( self::TOKEN_TRANSIENT_KEY ); |
| 329 | if ( false !== $cached_encrypted && is_string( $cached_encrypted ) ) { |
| 330 | // Double-check absolute expiry timestamp in case the transient |
| 331 | // survived an object-cache flush or clock drift. |
| 332 | $expires_at = get_option( self::TOKEN_EXPIRES_AT_OPTION_KEY, 0 ); |
| 333 | if ( $expires_at > 0 && time() >= $expires_at ) { |
| 334 | self::clear_cached_token(); |
| 335 | return self::request_access_token_with_lock(); |
| 336 | } |
| 337 | |
| 338 | $cached_token = self::decrypt( $cached_encrypted ); |
| 339 | if ( false === $cached_token ) { |
| 340 | // Decryption failed — request a fresh token. |
| 341 | self::clear_cached_token(); |
| 342 | return self::request_access_token_with_lock(); |
| 343 | } |
| 344 | |
| 345 | return $cached_token; |
| 346 | } |
| 347 | |
| 348 | // No valid cached token — request a new one. |
| 349 | return self::request_access_token_with_lock(); |
| 350 | } |
| 351 | |
| 352 | /** |
| 353 | * Request a new access token with a best-effort mutex to reduce concurrent refreshes. |
| 354 | * |
| 355 | * Uses wp_cache_add() which is atomic on persistent object caches (Redis, Memcached). |
| 356 | * On sites without a persistent cache, this is per-request only — duplicate refreshes |
| 357 | * may still occur, which is acceptable since the token endpoint is idempotent. |
| 358 | * |
| 359 | * @return string|\WP_Error The access token string, or WP_Error on failure. |
| 360 | */ |
| 361 | private static function request_access_token_with_lock() { |
| 362 | $lock_key = 'paypal_token_refresh_lock'; |
| 363 | $lock_timeout = 30; // seconds. |
| 364 | |
| 365 | // wp_cache_add returns false if key already exists (atomic on persistent caches). |
| 366 | $acquired = wp_cache_add( $lock_key, true, '', $lock_timeout ); |
| 367 | |
| 368 | if ( ! $acquired ) { |
| 369 | // Another process is refreshing. Wait briefly for the new token. |
| 370 | for ( $i = 0; $i < 4; $i++ ) { |
| 371 | usleep( 250000 ); // 0.25 seconds, max 1 second total. |
| 372 | $cached_encrypted = get_transient( self::TOKEN_TRANSIENT_KEY ); |
| 373 | if ( false !== $cached_encrypted && is_string( $cached_encrypted ) ) { |
| 374 | $token = self::decrypt( $cached_encrypted ); |
| 375 | if ( false !== $token ) { |
| 376 | return $token; |
| 377 | } |
| 378 | } |
| 379 | } |
| 380 | // Fallthrough: other process may have failed. Proceed with our own request. |
| 381 | } |
| 382 | |
| 383 | $result = self::request_access_token(); |
| 384 | |
| 385 | wp_cache_delete( $lock_key ); |
| 386 | |
| 387 | return $result; |
| 388 | } |
| 389 | |
| 390 | /** |
| 391 | * Request a new OAuth access token from PayPal. |
| 392 | * |
| 393 | * Uses the client credentials grant type with HTTP Basic authentication. |
| 394 | * |
| 395 | * @return string|\WP_Error The access token string, or WP_Error on failure. |
| 396 | */ |
| 397 | private static function request_access_token() { |
| 398 | $credentials = self::get_credentials(); |
| 399 | if ( false === $credentials ) { |
| 400 | return new \WP_Error( |
| 401 | 'paypal_no_credentials', |
| 402 | __( 'PayPal API credentials are not configured. Please connect your PayPal account.', 'jetpack-paypal-payments' ) |
| 403 | ); |
| 404 | } |
| 405 | |
| 406 | $url = self::get_base_url() . self::TOKEN_ENDPOINT; |
| 407 | |
| 408 | $response = wp_remote_post( |
| 409 | $url, |
| 410 | array( |
| 411 | 'timeout' => 30, |
| 412 | 'headers' => array( |
| 413 | 'Authorization' => 'Basic ' . base64_encode( $credentials['client_id'] . ':' . $credentials['client_secret'] ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required by PayPal OAuth spec. |
| 414 | 'Content-Type' => 'application/x-www-form-urlencoded', |
| 415 | 'Accept' => 'application/json', |
| 416 | ), |
| 417 | 'body' => 'grant_type=client_credentials', |
| 418 | ) |
| 419 | ); |
| 420 | |
| 421 | if ( is_wp_error( $response ) ) { |
| 422 | return new \WP_Error( |
| 423 | 'paypal_token_request_failed', |
| 424 | sprintf( |
| 425 | /* translators: %s: error message from the HTTP request */ |
| 426 | __( 'Failed to connect to PayPal: %s', 'jetpack-paypal-payments' ), |
| 427 | $response->get_error_message() |
| 428 | ) |
| 429 | ); |
| 430 | } |
| 431 | |
| 432 | $status_code = wp_remote_retrieve_response_code( $response ); |
| 433 | $body = wp_remote_retrieve_body( $response ); |
| 434 | $data = json_decode( $body, true ); |
| 435 | |
| 436 | if ( 200 !== $status_code ) { |
| 437 | $error_description = isset( $data['error_description'] ) |
| 438 | ? sanitize_text_field( $data['error_description'] ) |
| 439 | : __( 'Unknown error', 'jetpack-paypal-payments' ); |
| 440 | |
| 441 | $error_code = isset( $data['error'] ) |
| 442 | ? sanitize_text_field( $data['error'] ) |
| 443 | : 'paypal_token_error'; |
| 444 | |
| 445 | return new \WP_Error( |
| 446 | 'paypal_token_' . $error_code, |
| 447 | sprintf( |
| 448 | /* translators: 1: HTTP status code, 2: error description from PayPal */ |
| 449 | __( 'PayPal authentication failed (HTTP %1$d): %2$s', 'jetpack-paypal-payments' ), |
| 450 | $status_code, |
| 451 | $error_description |
| 452 | ), |
| 453 | array( 'status' => $status_code ) |
| 454 | ); |
| 455 | } |
| 456 | |
| 457 | if ( empty( $data['access_token'] ) ) { |
| 458 | return new \WP_Error( |
| 459 | 'paypal_token_missing', |
| 460 | __( 'PayPal returned a successful response but no access token was included.', 'jetpack-paypal-payments' ) |
| 461 | ); |
| 462 | } |
| 463 | |
| 464 | // Use trim() to preserve valid OAuth token characters that sanitize_text_field() may strip. |
| 465 | $access_token = trim( $data['access_token'] ); |
| 466 | $expires_in = isset( $data['expires_in'] ) ? absint( $data['expires_in'] ) : 0; |
| 467 | |
| 468 | // Cache the token encrypted with a buffer before expiry. |
| 469 | if ( $expires_in > self::TOKEN_EXPIRY_BUFFER ) { |
| 470 | $cache_duration = $expires_in - self::TOKEN_EXPIRY_BUFFER; |
| 471 | $encrypted_token = self::encrypt( $access_token ); |
| 472 | if ( ! is_wp_error( $encrypted_token ) ) { |
| 473 | set_transient( self::TOKEN_TRANSIENT_KEY, $encrypted_token, $cache_duration ); |
| 474 | } |
| 475 | |
| 476 | // Store absolute expiry timestamp as a fallback for object-cache eviction. |
| 477 | update_option( self::TOKEN_EXPIRES_AT_OPTION_KEY, time() + $cache_duration, false ); |
| 478 | } |
| 479 | |
| 480 | return $access_token; |
| 481 | } |
| 482 | |
| 483 | /** |
| 484 | * Clear the cached OAuth access token. |
| 485 | * |
| 486 | * @return bool True if the transient was deleted, false otherwise. |
| 487 | */ |
| 488 | public static function clear_cached_token() { |
| 489 | delete_option( self::TOKEN_EXPIRES_AT_OPTION_KEY ); |
| 490 | return delete_transient( self::TOKEN_TRANSIENT_KEY ); |
| 491 | } |
| 492 | |
| 493 | /** |
| 494 | * Validate stored credentials by attempting a token exchange. |
| 495 | * |
| 496 | * Useful for verifying that the merchant's client_id and secret |
| 497 | * are correct and that Payment Links & Buttons is enabled. |
| 498 | * |
| 499 | * @return true|\WP_Error True if credentials are valid, WP_Error otherwise. |
| 500 | */ |
| 501 | public static function validate_credentials() { |
| 502 | // Force a fresh token request (bypass cache). |
| 503 | self::clear_cached_token(); |
| 504 | |
| 505 | $token = self::request_access_token(); |
| 506 | |
| 507 | if ( is_wp_error( $token ) ) { |
| 508 | return $token; |
| 509 | } |
| 510 | |
| 511 | return true; |
| 512 | } |
| 513 | |
| 514 | /** |
| 515 | * Validate that the authenticated account has access to the Payment Links & Buttons API. |
| 516 | * |
| 517 | * Probes GET /v1/checkout/payment-resources?page_size=1 after a successful |
| 518 | * token exchange. A 403 means the merchant's app lacks the required scope. |
| 519 | * Transient server errors (5xx, timeouts) are treated as non-blocking so |
| 520 | * the connect flow is not disrupted by temporary PayPal outages. |
| 521 | * |
| 522 | * @return true|\WP_Error True if the account has API access, WP_Error on 403. |
| 523 | */ |
| 524 | public static function validate_api_access() { |
| 525 | $token = self::get_access_token(); |
| 526 | if ( is_wp_error( $token ) ) { |
| 527 | return $token; |
| 528 | } |
| 529 | |
| 530 | $url = self::get_base_url() . '/v1/checkout/payment-resources?page_size=1'; |
| 531 | |
| 532 | $response = wp_remote_get( |
| 533 | $url, |
| 534 | array( |
| 535 | 'timeout' => 15, |
| 536 | 'headers' => array( |
| 537 | 'Authorization' => 'Bearer ' . $token, |
| 538 | 'Content-Type' => 'application/json', |
| 539 | 'Accept' => 'application/json', |
| 540 | ), |
| 541 | ) |
| 542 | ); |
| 543 | |
| 544 | // Network-level failures are non-blocking. |
| 545 | if ( is_wp_error( $response ) ) { |
| 546 | return true; |
| 547 | } |
| 548 | |
| 549 | $status_code = wp_remote_retrieve_response_code( $response ); |
| 550 | |
| 551 | // 5xx / unexpected codes — treat as transient, don't block connect. |
| 552 | if ( $status_code >= 500 || 0 === $status_code ) { |
| 553 | return true; |
| 554 | } |
| 555 | |
| 556 | // 403 — the app lacks Payment Links & Buttons access. |
| 557 | if ( 403 === $status_code ) { |
| 558 | $message = __( |
| 559 | 'Your PayPal app does not have access to Payment Links & Buttons. In the PayPal Developer Dashboard, open your app settings and enable the "Payment Links & Buttons" feature, then try connecting again.', |
| 560 | 'jetpack-paypal-payments' |
| 561 | ); |
| 562 | |
| 563 | // PayPal's own diagnosis beats our guess; the debug_id is what their |
| 564 | // support and status tooling resolve. |
| 565 | $body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 566 | $name = isset( $body['name'] ) ? sanitize_text_field( $body['name'] ) : ''; |
| 567 | $debug_id = isset( $body['debug_id'] ) ? sanitize_text_field( $body['debug_id'] ) : ''; |
| 568 | if ( $name || $debug_id ) { |
| 569 | $message .= ' ' . sprintf( |
| 570 | /* translators: 1: PayPal's error name, 2: PayPal's debug ID. */ |
| 571 | __( 'PayPal reported: %1$s (debug ID %2$s).', 'jetpack-paypal-payments' ), |
| 572 | $name ? $name : __( 'unknown error', 'jetpack-paypal-payments' ), |
| 573 | $debug_id ? $debug_id : '—' |
| 574 | ); |
| 575 | } |
| 576 | |
| 577 | return new \WP_Error( |
| 578 | 'paypal_api_not_authorized', |
| 579 | $message, |
| 580 | array( 'status' => 403 ) |
| 581 | ); |
| 582 | } |
| 583 | |
| 584 | // 200, 204, or other success / client errors (400, 404) mean the API is reachable. |
| 585 | return true; |
| 586 | } |
| 587 | |
| 588 | /** |
| 589 | * Get the connection status for display in the block editor. |
| 590 | * |
| 591 | * @return array { |
| 592 | * Connection status information. |
| 593 | * |
| 594 | * @type bool $connected Whether credentials are stored. |
| 595 | * @type string $environment Current environment ('sandbox' or 'production'). |
| 596 | * @type string $onboarding_method How the merchant connected, when that is known. |
| 597 | * @type string $merchant_id The merchant's PayPal ID, when that is known. |
| 598 | * @type bool $partner_referrals_available Whether this site can start onboarding through WordPress.com. |
| 599 | * } |
| 600 | */ |
| 601 | public static function get_connection_status() { |
| 602 | $status = array( |
| 603 | 'connected' => self::has_credentials(), |
| 604 | 'environment' => self::get_environment(), |
| 605 | ); |
| 606 | |
| 607 | // Include onboarding method if connected via Partner Referrals. |
| 608 | $method = get_option( PayPal_Partner_Onboarding::ONBOARDING_METHOD_OPTION_KEY, '' ); |
| 609 | if ( ! empty( $method ) ) { |
| 610 | $status['onboarding_method'] = $method; |
| 611 | } |
| 612 | |
| 613 | $merchant_id = get_option( PayPal_Partner_Onboarding::MERCHANT_ID_OPTION_KEY, '' ); |
| 614 | if ( ! empty( $merchant_id ) ) { |
| 615 | $status['merchant_id'] = $merchant_id; |
| 616 | } |
| 617 | |
| 618 | // Partner Referrals goes through WordPress.com: on WordPress.com the call is |
| 619 | // local, anywhere else it needs a Jetpack connection to authenticate it. |
| 620 | $status['partner_referrals_available'] = ( new Host() )->is_wpcom_simple() |
| 621 | || ( new Manager() )->is_connected(); |
| 622 | |
| 623 | return $status; |
| 624 | } |
| 625 | |
| 626 | /** |
| 627 | * Clean up all PayPal OAuth data. |
| 628 | * |
| 629 | * Removes stored credentials, cached token, and environment setting. |
| 630 | * Used during plugin deactivation or full disconnect. |
| 631 | * |
| 632 | * @return void |
| 633 | */ |
| 634 | public static function disconnect() { |
| 635 | self::$credentials_cache = null; |
| 636 | delete_option( self::CREDENTIALS_OPTION_KEY ); |
| 637 | delete_option( self::ENVIRONMENT_OPTION_KEY ); |
| 638 | delete_option( self::TOKEN_EXPIRES_AT_OPTION_KEY ); |
| 639 | self::clear_cached_token(); |
| 640 | } |
| 641 | } |