diff --git a/projects/plugins/jetpack/changelog/fix-premium-content-refresh-before-deny b/projects/plugins/jetpack/changelog/fix-premium-content-refresh-before-deny new file mode 100644 index 000000000000..f9e5b9c5bb60 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-premium-content-refresh-before-deny @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Paid Content block: refresh the subscription token on-demand when it contains a stale end_date, preventing lockout after a subscription renewal. diff --git a/projects/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-abstract-token-subscription-service.php b/projects/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-abstract-token-subscription-service.php index 36d653e5b2a8..dd5c8d821ce8 100644 --- a/projects/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-abstract-token-subscription-service.php +++ b/projects/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-abstract-token-subscription-service.php @@ -76,6 +76,103 @@ public function get_and_set_token_from_request() { return $this->token_from_cookie(); } + /** + * Attempt to refresh the current token against the WordPress.com refresh endpoint + * and, on success, persist the fresh token in the cookie and return the decoded + * payload. + * + * This is called when a subscriber has a JWT token whose subscription data is + * stale (e.g. the cookie contains an old end_date from before a Stripe renewal). + * The refresh endpoint accepts the existing token, re-queries billing, and + * returns a fresh token reflecting current subscription state. + * + * @return array|null Decoded fresh payload on success, null on any failure. + */ + protected function refresh_token_payload() { + $current_token = $this->get_and_set_token_from_request(); + if ( empty( $current_token ) ) { + return null; + } + + $fresh_token = $this->fetch_refreshed_token( $current_token ); + if ( empty( $fresh_token ) ) { + return null; + } + + $fresh_payload = $this->decode_token( $fresh_token ); + if ( empty( $fresh_payload ) ) { + return null; + } + + $this->set_token_cookie( $fresh_token ); + return $fresh_payload; + } + + /** + * POST the current token to the WordPress.com refresh endpoint and return a + * fresh JWT string, or null on any failure. + * + * Response handling: + * - 200 with jwt_token → return fresh token string. + * - 400 (iat expired) / 401 (bad signature) → deterministic failure: clear the + * cookie so the visitor is routed through the normal auth flow on the next + * page load, and return null. + * - Any other non-200 / WP_Error / network timeout → transient failure: leave + * the cookie alone so a temporary endpoint outage does not mass-log-out + * subscribers, and return null. + * + * @param string $current_token The token to present for refresh. + * @return string|null Fresh token string, or null on failure. + */ + protected function fetch_refreshed_token( $current_token ) { + $response = wp_remote_post( + self::REST_URL_ORIGIN . 'memberships/jwt/refresh', + array( + 'timeout' => 10, + 'headers' => array( 'Content-Type' => 'application/json' ), + 'body' => wp_json_encode( + array( + 'token' => $current_token, + 'site_id' => $this->get_site_id(), + ), + JSON_UNESCAPED_SLASHES + ), + ) + ); + + if ( is_wp_error( $response ) ) { + // Transient (network error / timeout). Leave cookie, deny. + return null; + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + + if ( 200 === $code ) { + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( is_array( $body ) && ! empty( $body['token'] ) && is_string( $body['token'] ) ) { + return $body['token']; + } + // Malformed 200 — treat as transient. + return null; + } + + if ( 400 === $code || 401 === $code ) { + // Deterministic failure — clear cookie so the subscriber re-authenticates. + self::clear_token_cookie(); + return null; + } + + // Any other status (5xx, 403, 404, etc.) — transient. Leave cookie, deny. + return null; + } + + /** + * Get the site ID for the current site. + * + * @return int + */ + abstract public function get_site_id(); + /** * Get the token payload . * @@ -156,6 +253,19 @@ public function visitor_can_view_content( $valid_plan_ids, $access_level ) { ); $subscriptions = (array) $payload['subscriptions']; $is_paid_subscriber = static::validate_subscriptions( $valid_plan_ids, $subscriptions ); + + // If the token's subscriptions do not satisfy the required plans (e.g. the token has a + // stale end_date from before a subscription renewal), attempt a single refresh against + // the WordPress.com refresh endpoint before denying access. + if ( ! $is_paid_subscriber && ! empty( $valid_plan_ids ) ) { + $fresh_payload = $this->refresh_token_payload(); + if ( ! empty( $fresh_payload ) ) { + $payload = $fresh_payload; + $is_blog_subscriber = isset( $payload['blog_sub'] ) && self::BLOG_SUB_ACTIVE === $payload['blog_sub']; + $subscriptions = isset( $payload['subscriptions'] ) ? (array) $payload['subscriptions'] : array(); + $is_paid_subscriber = static::validate_subscriptions( $valid_plan_ids, $subscriptions ); + } + } } $has_access = $this->user_has_access( $access_level, $is_blog_subscriber, $is_paid_subscriber, get_the_ID(), $subscriptions ); diff --git a/projects/plugins/jetpack/tests/php/extensions/blocks/premium-content/Jetpack_Premium_Content_Test.php b/projects/plugins/jetpack/tests/php/extensions/blocks/premium-content/Jetpack_Premium_Content_Test.php index a5d1dfc10b9e..eef5ca96e362 100644 --- a/projects/plugins/jetpack/tests/php/extensions/blocks/premium-content/Jetpack_Premium_Content_Test.php +++ b/projects/plugins/jetpack/tests/php/extensions/blocks/premium-content/Jetpack_Premium_Content_Test.php @@ -22,6 +22,14 @@ class Jetpack_Premium_Content_Test extends WP_UnitTestCase { protected $product_id = 1234; + /** + * Optional override for the refresh endpoint response. If null, refresh is blocked + * (simulates a transient 500). Tests can set this to control refresh behavior. + * + * @var array|null + */ + protected $refresh_response_override = null; + public function set_up() { parent::set_up(); Jetpack_Subscriptions::init(); @@ -32,6 +40,8 @@ function () { return new Test_Jetpack_Token_Subscription_Service(); } ); + // Block or mock refresh endpoint HTTP calls in tests. + add_filter( 'pre_http_request', array( $this, 'mock_refresh_endpoint' ), 10, 3 ); } public function tear_down() { @@ -39,9 +49,39 @@ public function tear_down() { remove_all_filters( 'earn_get_user_subscriptions_for_site_id' ); remove_all_filters( 'jetpack_is_connection_ready' ); remove_all_filters( PAYWALL_FILTER ); + remove_all_filters( 'pre_http_request' ); + $this->refresh_response_override = null; + unset( $_COOKIE['wp-jp-premium-content-session'] ); parent::tear_down(); } + /** + * Intercept HTTP calls to the refresh endpoint. Returns the override if set, + * otherwise a transient 500 so refresh fails and existing behavior is preserved. + * + * @param mixed $preempt Current preempt value. + * @param array $args Request args. + * @param string $url Request URL. + * @return mixed + */ + public function mock_refresh_endpoint( $preempt, $args, $url ) { + if ( false !== strpos( $url, 'memberships/jwt/refresh' ) ) { + if ( null !== $this->refresh_response_override ) { + return $this->refresh_response_override; + } + return array( + 'response' => array( + 'code' => 500, + 'message' => 'blocked in tests', + ), + 'body' => '', + 'headers' => array(), + 'cookies' => array(), + ); + } + return $preempt; + } + /** * Retrieves payload for JWT token * @@ -195,4 +235,198 @@ public function test_access_check_current_visitor_can_access_passing_plan_id() { $this->assertTrue( current_visitor_can_access( array( 'selectedPlanIds' => array( $plan_id ) ), array() ) ); $this->assertTrue( current_visitor_can_access( array(), (object) array( 'context' => array( 'premium-content/planIds' => array( $plan_id ) ) ) ) ); } + + /** + * Helper: build a mock 200 response from the refresh endpoint with a fresh JWT + * containing a subscription that expires in the future. + * + * @return array + */ + private function build_refresh_success_response() { + $service = subscription_service(); + $fresh_payload = array( + 'blog_sub' => 'active', + 'subscriptions' => array( + $this->product_id => array( + 'status' => 'active', + 'end_date' => time() + HOUR_IN_SECONDS, + 'product_id' => $this->product_id, + ), + ), + ); + $fresh_token = JWT::encode( $fresh_payload, $service->get_key() ); + return array( + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'body' => wp_json_encode( array( 'token' => $fresh_token ), JSON_UNESCAPED_SLASHES ), + 'headers' => array(), + 'cookies' => array(), + ); + } + + /** + * When the token contains a stale (expired) end_date and the refresh endpoint + * returns a fresh token with a valid subscription, access should be granted. + * This covers the core renewal lockout bug. + * + * @return void + */ + public function test_refresh_before_deny_grants_access_on_successful_refresh() { + $users_plans = $this->set_up_users_and_plans(); + $paid_subscriber_id = $users_plans[2]; + $plan_id = $users_plans[3]; + + wp_set_current_user( $paid_subscriber_id ); + // Stale token — end_date in the past. + $stale_payload = $this->get_payload( true, true, time() - HOUR_IN_SECONDS ); + $this->set_returned_token( $stale_payload ); + + // Refresh endpoint returns a fresh token with a valid future end_date. + $this->refresh_response_override = $this->build_refresh_success_response(); + + $this->assertTrue( current_visitor_can_access( array( 'selectedPlanIds' => array( $plan_id ) ), array() ) ); + } + + /** + * When the refresh endpoint returns 200 with a token whose subscriptions no + * longer include the required plan (e.g. the subscription was cancelled), + * access should be denied. + * + * @return void + */ + public function test_refresh_before_deny_denies_when_fresh_token_has_no_subscription() { + $users_plans = $this->set_up_users_and_plans(); + $paid_subscriber_id = $users_plans[2]; + $plan_id = $users_plans[3]; + + wp_set_current_user( $paid_subscriber_id ); + $stale_payload = $this->get_payload( true, true, time() - HOUR_IN_SECONDS ); + $this->set_returned_token( $stale_payload ); + + // Refresh returns a token with no subscriptions (cancelled). + $service = subscription_service(); + $empty_token = JWT::encode( + array( + 'blog_sub' => 'active', + 'subscriptions' => array(), + ), + $service->get_key() + ); + $this->refresh_response_override = array( + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'body' => wp_json_encode( array( 'token' => $empty_token ), JSON_UNESCAPED_SLASHES ), + 'headers' => array(), + 'cookies' => array(), + ); + + $this->assertFalse( current_visitor_can_access( array( 'selectedPlanIds' => array( $plan_id ) ), array() ) ); + } + + /** + * When the refresh endpoint returns a transient failure (5xx), the original + * stale token behavior should apply — access denied, cookie left alone. + * + * @return void + */ + public function test_refresh_before_deny_denies_on_transient_failure() { + $users_plans = $this->set_up_users_and_plans(); + $paid_subscriber_id = $users_plans[2]; + $plan_id = $users_plans[3]; + + wp_set_current_user( $paid_subscriber_id ); + $stale_payload = $this->get_payload( true, true, time() - HOUR_IN_SECONDS ); + $this->set_returned_token( $stale_payload ); + + // Refresh endpoint returns a 500 (the default mock behavior). + $this->refresh_response_override = array( + 'response' => array( + 'code' => 500, + 'message' => 'Server Error', + ), + 'body' => '', + 'headers' => array(), + 'cookies' => array(), + ); + + $this->assertFalse( current_visitor_can_access( array( 'selectedPlanIds' => array( $plan_id ) ), array() ) ); + } + + /** + * When the refresh endpoint returns 401 (bad signature), access is denied + * AND the cookie is cleared so the subscriber re-authenticates on next visit. + * + * @return void + */ + public function test_refresh_before_deny_clears_cookie_on_unauthorized() { + $users_plans = $this->set_up_users_and_plans(); + $paid_subscriber_id = $users_plans[2]; + $plan_id = $users_plans[3]; + + wp_set_current_user( $paid_subscriber_id ); + $stale_payload = $this->get_payload( true, true, time() - HOUR_IN_SECONDS ); + $service = subscription_service(); + $token_string = JWT::encode( $stale_payload, $service->get_key() ); + $_COOKIE['wp-jp-premium-content-session'] = $token_string; + $_GET['token'] = $token_string; + + $this->refresh_response_override = array( + 'response' => array( + 'code' => 401, + 'message' => 'Unauthorized', + ), + 'body' => '', + 'headers' => array(), + 'cookies' => array(), + ); + + $this->assertFalse( current_visitor_can_access( array( 'selectedPlanIds' => array( $plan_id ) ), array() ) ); + // Cookie should be cleared from $_COOKIE (cannot assert setcookie header from PHPUnit). + $this->assertArrayNotHasKey( 'wp-jp-premium-content-session', $_COOKIE ); + } + + /** + * A WP_Error from the HTTP layer (e.g. network timeout) is treated as a + * transient failure — deny access, leave cookie alone. + * + * @return void + */ + public function test_refresh_before_deny_treats_wp_error_as_transient() { + $users_plans = $this->set_up_users_and_plans(); + $paid_subscriber_id = $users_plans[2]; + $plan_id = $users_plans[3]; + + wp_set_current_user( $paid_subscriber_id ); + $stale_payload = $this->get_payload( true, true, time() - HOUR_IN_SECONDS ); + $this->set_returned_token( $stale_payload ); + + $this->refresh_response_override = new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' ); + + $this->assertFalse( current_visitor_can_access( array( 'selectedPlanIds' => array( $plan_id ) ), array() ) ); + } + + /** + * An active subscription with a future end_date should not trigger a refresh + * at all — no HTTP call is made. + * + * @return void + */ + public function test_refresh_not_called_when_subscription_is_active() { + $users_plans = $this->set_up_users_and_plans(); + $paid_subscriber_id = $users_plans[2]; + $plan_id = $users_plans[3]; + + wp_set_current_user( $paid_subscriber_id ); + $valid_payload = $this->get_payload( true, true, time() + HOUR_IN_SECONDS ); + $this->set_returned_token( $valid_payload ); + + // If a refresh call were made, this override would throw — proving it is not called. + $this->refresh_response_override = new WP_Error( 'should_not_be_called', 'refresh should not be called for valid token' ); + + $this->assertTrue( current_visitor_can_access( array( 'selectedPlanIds' => array( $plan_id ) ), array() ) ); + } } diff --git a/projects/plugins/jetpack/tests/php/modules/subscriptions/Jetpack_Subscriptions_Test.php b/projects/plugins/jetpack/tests/php/modules/subscriptions/Jetpack_Subscriptions_Test.php index b9a31c71a8fe..56fac5380e10 100644 --- a/projects/plugins/jetpack/tests/php/modules/subscriptions/Jetpack_Subscriptions_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/subscriptions/Jetpack_Subscriptions_Test.php @@ -35,6 +35,9 @@ public function set_up() { parent::set_up(); Jetpack_Subscriptions::init(); add_filter( 'jetpack_is_connection_ready', '__return_true' ); + // Block refresh endpoint HTTP calls in tests by default so stale tokens deny + // access (existing test expectations). Individual refresh tests can override. + add_filter( 'pre_http_request', array( $this, 'block_refresh_endpoint' ), 10, 3 ); $this->set_up_users(); } @@ -42,10 +45,36 @@ public function tear_down() { // Clean up remove_all_filters( 'earn_get_user_subscriptions_for_site_id' ); remove_all_filters( 'jetpack_is_connection_ready' ); + remove_all_filters( 'pre_http_request' ); parent::tear_down(); } + /** + * Default pre_http_request mock for the refresh endpoint — returns a 500 so that + * refresh attempts fail transiently and the existing "expired subscription denies + * access" expectations in the access matrix still hold. + * + * @param mixed $preempt Current preempt value. + * @param array $args Request args. + * @param string $url Request URL. + * @return mixed + */ + public function block_refresh_endpoint( $preempt, $args, $url ) { + if ( false !== strpos( $url, 'memberships/jwt/refresh' ) ) { + return array( + 'response' => array( + 'code' => 500, + 'message' => 'blocked in tests', + ), + 'body' => '', + 'headers' => array(), + 'cookies' => array(), + ); + } + return $preempt; + } + private function set_up_users() { $this->regular_non_subscriber_id = $this->factory->user->create( array(