Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
*
Expand Down Expand Up @@ -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 );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@

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();
Expand All @@ -32,16 +40,48 @@
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() {
// Clean up
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
*
Expand Down Expand Up @@ -195,4 +235,198 @@
$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' );

Check failure on line 407 in projects/plugins/jetpack/tests/php/extensions/blocks/premium-content/Jetpack_Premium_Content_Test.php

View workflow job for this annotation

GitHub Actions / Static analysis

TypeError PhanTypeMismatchPropertyProbablyReal Assigning new WP_Error('http_request_failed', 'cURL error 28: Operation timed out') of type \WP_Error to property but \Jetpack_Premium_Content_Test->refresh_response_override is array|null (no real type) (the inferred real assigned type has nothing in common with the declared phpdoc property type) FAQ on Phan issues: pdWQjU-Jb-p2

$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' );

Check failure on line 428 in projects/plugins/jetpack/tests/php/extensions/blocks/premium-content/Jetpack_Premium_Content_Test.php

View workflow job for this annotation

GitHub Actions / Static analysis

TypeError PhanTypeMismatchPropertyProbablyReal Assigning new WP_Error('should_not_be_called', 'refresh should not be called for valid token') of type \WP_Error to property but \Jetpack_Premium_Content_Test->refresh_response_override is array|null (no real type) (the inferred real assigned type has nothing in common with the declared phpdoc property type) FAQ on Phan issues: pdWQjU-Jb-p2

$this->assertTrue( current_visitor_can_access( array( 'selectedPlanIds' => array( $plan_id ) ), array() ) );
}
}
Loading
Loading