Skip to content

Paid Content block: refresh JWT on-demand instead of denying on stale end_date#48008

Draft
allilevine wants to merge 2 commits intotrunkfrom
fix/premium-content-refresh-before-deny
Draft

Paid Content block: refresh JWT on-demand instead of denying on stale end_date#48008
allilevine wants to merge 2 commits intotrunkfrom
fix/premium-content-refresh-before-deny

Conversation

@allilevine
Copy link
Copy Markdown
Member

@allilevine allilevine commented Apr 8, 2026

Fixes NL-546

Proposed changes

  • Add a refresh-before-deny path to Abstract_Token_Subscription_Service::visitor_can_view_content(): when the token's subscriptions do not satisfy the required plans, POST the existing token to a new WordPress.com refresh endpoint (subscribe.wordpress.com/memberships/jwt/refresh) that re-queries billing and returns a fresh token reflecting current subscription state.
  • On successful refresh, replace the cookie with the fresh token and re-run validation. The subscriber regains access with no visible interruption.
  • Add new methods refresh_token_payload() and fetch_refreshed_token() on the abstract service class. Declare the previously implicit get_site_id() contract as abstract since the refresh call needs it.
  • Add PHPUnit tests covering each branch of the response matrix.

Response handling

Response Action
200 + valid jwt_token Set cookie, re-run validation (may grant or deny based on fresh payload)
400 (iat expired) / 401 (bad signature) Clear cookie, deny — subscriber re-authenticates naturally on next visit
5xx / network error / timeout Leave cookie, deny — treat as transient so an endpoint outage does not mass-log-out subscribers
200 with empty subscriptions Cookie replaced with the empty fresh token → validation denies cleanly on this and future requests (no denial loop)

Other information

⚠️ This PR must not merge until the WordPress.com refresh endpoint (memberships/jwt/refresh) is live in production and serving 200s. If merged first, Jetpack will hit a 404 on every stale-token request and fall back to the current (broken) behavior. Leaving this PR as a draft until the server-side work is verified.

Root cause: When a paid subscriber's subscription renews, Billing system records the new end_date, but the JWT cookie still contains the old snapshot. validate_subscriptions() checks end_date > time() against the stale cookie value and denies access. The subscriber sees a "subscribe again" prompt but is told they're already subscribed.

Scale: ~10,953 subscribers renewing in the next 30 days across 1,421 sites are affected. Reports from at least 7 different sites over 5 years.

Why this over the grace period approach: A 1-day grace period (PR #47773) would cover the renewal window but leaves the underlying architecture unchanged — the JWT remains a static snapshot that never learns about billing events. This approach self-heals on any stale-end_date condition, not just renewal windows, and sets up the path to eventually drop end_date from the token entirely.

Related product discussion/links

Does this pull request change what data or activity we track or use?

No new data. Adds an outbound HTTP POST to subscribe.wordpress.com/memberships/jwt/refresh carrying the existing JWT cookie and the site ID — both already known to WordPress.com.

Testing instructions

Prerequisite

The memberships/jwt/refresh endpoint must be live on WordPress.com. Confirm by checking that a POST with a valid signed JWT returns 200 with a fresh jwt_token in the response body.

Manual test — successful refresh

  1. On a WoA site with a Paid Content block, subscribe with a test account and verify access.
  2. Generate a stale token (past end_date) and append it as ?token= to the URL:
    wp eval "
    require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/class-jwt.php';
    use Automattic\Jetpack\Extensions\Premium_Content\JWT;
    \$key = (new Automattic\Jetpack\Connection\Tokens())->get_access_token()->secret;
    \$payload = [
        'blog_sub' => 'active',
        'subscriptions' => [ YOUR_PRODUCT_ID => [ 'status' => 'active', 'end_date' => time() - 3600, 'product_id' => YOUR_PRODUCT_ID ] ],
    ];
    echo JWT::encode(\$payload, \$key);
    "
    
  3. Visit the premium post with ?token=<output>.
  4. Expected: The page loads with premium content visible. Jetpack detected the stale end_date, called the refresh endpoint, received a fresh token, and granted access. Check the cookie — it should contain a new JWT.

Manual test — genuine cancellation

  1. Subscribe, then cancel the subscription in billing.
  2. Wait for the stale token scenario (or craft one as above).
  3. Visit the premium post.
  4. Expected: Refresh endpoint returns 200 with empty subscriptions. Access is denied. Subsequent visits deny cleanly without repeated refresh calls.

PHPUnit tests

New tests in Jetpack_Premium_Content_Test.php:

  • test_refresh_before_deny_grants_access_on_successful_refresh
  • test_refresh_before_deny_denies_when_fresh_token_has_no_subscription
  • test_refresh_before_deny_denies_on_transient_failure
  • test_refresh_before_deny_clears_cookie_on_unauthorized
  • test_refresh_before_deny_treats_wp_error_as_transient
  • test_refresh_not_called_when_subscription_is_active

All tests mock the refresh endpoint via the pre_http_request filter to exercise each response branch without making real HTTP calls.

When a paid subscriber's subscription renews, the JWT cookie still
contains the old end_date snapshot from before the renewal.
validate_subscriptions() denies access because the stale date is now
in the past, even though the subscription is active in billing.

This change adds a refresh-before-deny path to visitor_can_view_content():
when the token's subscriptions do not satisfy the required plans, the
service POSTs the existing token to a new WordPress.com refresh endpoint
that re-queries billing and returns a fresh token. On success, the
cookie is replaced with the fresh token and access is re-evaluated.

Response handling:
- 200 with jwt_token: replace cookie, re-run validation
- 400 (iat expired) / 401 (bad signature): clear cookie, deny
- 5xx / network error / timeout: leave cookie, deny (transient)

Note: the refresh endpoint (memberships/jwt/refresh) must be deployed
on WordPress.com before this change ships. This PR should be held as
a draft until the endpoint is live and serving 200s in production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack), and enable the fix/premium-content-refresh-before-deny branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack fix/premium-content-refresh-before-deny

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions github-actions bot added [Block] Paid Content aka Premium Content [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ [Tests] Includes Tests labels Apr 8, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 8, 2026

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!


Jetpack plugin:

No scheduled milestone found for this plugin.

If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack.

@jp-launch-control
Copy link
Copy Markdown

jp-launch-control bot commented Apr 8, 2026

Code Coverage Summary

Cannot generate coverage summary while tests are failing. 🤐

Please fix the tests, or re-run the Code coverage job if it was something being flaky.

Full summary · PHP report · JS report

The WordPress.com refresh endpoint (wpcom#210997) returns the fresh
token as { token: "<jwt>" } in the response body. Update fetch_refreshed_token()
and the test mocks to read/write "token" instead of "jwt_token".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Block] Paid Content aka Premium Content [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ [Status] In Progress [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant