summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service')
-rw-r--r--plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jetpack-token-subscription-service.php50
-rw-r--r--plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jwt.php441
-rw-r--r--plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-subscription-service.php57
-rw-r--r--plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription-service.php264
-rw-r--r--plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription.php23
-rw-r--r--plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-unconfigured-subscription-service.php58
-rw-r--r--plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-offline-subscription-service.php77
-rw-r--r--plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-token-subscription-service.php46
-rw-r--r--plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/include.php82
9 files changed, 1098 insertions, 0 deletions
diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jetpack-token-subscription-service.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jetpack-token-subscription-service.php
new file mode 100644
index 00000000..2dc8cfb3
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jetpack-token-subscription-service.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * A paywall that exchanges JWT tokens from WordPress.com to allow
+ * a current visitor to view content that has been deemed "Premium content".
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content
+ */
+
+namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
+
+use Automattic\Jetpack\Connection\Tokens;
+
+/**
+ * Class Jetpack_Token_Subscription_Service
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
+ */
+class Jetpack_Token_Subscription_Service extends Token_Subscription_Service {
+
+ /**
+ * Is the Jetpack_Options class available?
+ *
+ * @return bool Whether Jetpack_Options class exists.
+ */
+ public static function available() {
+ return class_exists( '\Jetpack_Options' );
+ }
+
+ /**
+ * Get the site ID.
+ *
+ * @return int The site ID.
+ */
+ public function get_site_id() {
+ return \Jetpack_Options::get_option( 'id' );
+ }
+
+ /**
+ * Get the key.
+ *
+ * @return string The key.
+ */
+ public function get_key() {
+ $token = ( new Tokens() )->get_access_token();
+ if ( ! isset( $token->secret ) ) {
+ return false;
+ }
+ return $token->secret;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jwt.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jwt.php
new file mode 100644
index 00000000..f2d1f1d8
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jwt.php
@@ -0,0 +1,441 @@
+<?php
+/**
+ * JSON Web Token implementation, based on this spec:
+ * https://tools.ietf.org/html/rfc7519
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content
+ */
+
+namespace Automattic\Jetpack\Extensions\Premium_Content;
+
+use \DateTime;
+use \DomainException;
+use \InvalidArgumentException;
+use \UnexpectedValueException;
+
+/**
+ * JSON Web Token implementation, based on this spec:
+ * https://tools.ietf.org/html/rfc7519
+ *
+ * PHP version 5
+ *
+ * @category Authentication
+ * @package Authentication_JWT
+ * @author Neuman Vong <neuman@twilio.com>
+ * @author Anant Narayanan <anant@php.net>
+ * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD
+ * @link https://github.com/firebase/php-jwt
+ */
+class JWT {
+ /**
+ * When checking nbf, iat or expiration times,
+ * we want to provide some extra leeway time to
+ * account for clock skew.
+ *
+ * @var int $leeway The leeway value.
+ */
+ public static $leeway = 0;
+
+ /**
+ * Allow the current timestamp to be specified.
+ * Useful for fixing a value within unit testing.
+ *
+ * Will default to PHP time() value if null.
+ *
+ * @var string $timestamp The timestamp.
+ */
+ public static $timestamp = null;
+
+ /**
+ * Supported algorithms.
+ *
+ * @var array $supported_algs Supported algorithms.
+ */
+ public static $supported_algs = array(
+ 'HS256' => array( 'hash_hmac', 'SHA256' ),
+ 'HS512' => array( 'hash_hmac', 'SHA512' ),
+ 'HS384' => array( 'hash_hmac', 'SHA384' ),
+ 'RS256' => array( 'openssl', 'SHA256' ),
+ 'RS384' => array( 'openssl', 'SHA384' ),
+ 'RS512' => array( 'openssl', 'SHA512' ),
+ );
+
+ /**
+ * Decodes a JWT string into a PHP object.
+ *
+ * @param string $jwt The JWT.
+ * @param string|array $key The key, or map of keys.
+ * If the algorithm used is asymmetric, this is the public key.
+ * @param array $allowed_algs List of supported verification algorithms.
+ * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256'.
+ *
+ * @return object The JWT's payload as a PHP object
+ *
+ * @throws UnexpectedValueException Provided JWT was invalid.
+ * @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed.
+ * @throws InvalidArgumentException Provided JWT is trying to be used before it's eligible as defined by 'nbf'.
+ * @throws BeforeValidException Provided JWT is trying to be used before it's been created as defined by 'iat'.
+ * @throws ExpiredException Provided JWT has since expired, as defined by the 'exp' claim.
+ *
+ * @uses json_decode
+ * @uses urlsafe_b64_decode
+ */
+ public static function decode( $jwt, $key, array $allowed_algs = array() ) {
+ $timestamp = is_null( static::$timestamp ) ? time() : static::$timestamp;
+
+ if ( empty( $key ) ) {
+ throw new InvalidArgumentException( 'Key may not be empty' );
+ }
+
+ $tks = explode( '.', $jwt );
+ if ( count( $tks ) !== 3 ) {
+ throw new UnexpectedValueException( 'Wrong number of segments' );
+ }
+
+ list( $headb64, $bodyb64, $cryptob64 ) = $tks;
+
+ $header = static::json_decode( static::urlsafe_b64_decode( $headb64 ) );
+ if ( null === $header ) {
+ throw new UnexpectedValueException( 'Invalid header encoding' );
+ }
+
+ $payload = static::json_decode( static::urlsafe_b64_decode( $bodyb64 ) );
+ if ( null === $payload ) {
+ throw new UnexpectedValueException( 'Invalid claims encoding' );
+ }
+
+ $sig = static::urlsafe_b64_decode( $cryptob64 );
+ if ( false === $sig ) {
+ throw new UnexpectedValueException( 'Invalid signature encoding' );
+ }
+
+ if ( empty( $header->alg ) ) {
+ throw new UnexpectedValueException( 'Empty algorithm' );
+ }
+
+ if ( empty( static::$supported_algs[ $header->alg ] ) ) {
+ throw new UnexpectedValueException( 'Algorithm not supported' );
+ }
+
+ if ( ! in_array( $header->alg, $allowed_algs, true ) ) {
+ throw new UnexpectedValueException( 'Algorithm not allowed' );
+ }
+
+ if ( is_array( $key ) || $key instanceof \ArrayAccess ) {
+ if ( isset( $header->kid ) ) {
+ if ( ! isset( $key[ $header->kid ] ) ) {
+ throw new UnexpectedValueException( '"kid" invalid, unable to lookup correct key' );
+ }
+ $key = $key[ $header->kid ];
+ } else {
+ throw new UnexpectedValueException( '"kid" empty, unable to lookup correct key' );
+ }
+ }
+
+ // Check the signature.
+ if ( ! static::verify( "$headb64.$bodyb64", $sig, $key, $header->alg ) ) {
+ throw new SignatureInvalidException( 'Signature verification failed' );
+ }
+
+ // Check if the nbf if it is defined. This is the time that the
+ // token can actually be used. If it's not yet that time, abort.
+ if ( isset( $payload->nbf ) && $payload->nbf > ( $timestamp + static::$leeway ) ) {
+ throw new BeforeValidException(
+ 'Cannot handle token prior to ' . gmdate( DateTime::ISO8601, $payload->nbf )
+ );
+ }
+
+ // Check that this token has been created before 'now'. This prevents
+ // using tokens that have been created for later use (and haven't
+ // correctly used the nbf claim).
+ if ( isset( $payload->iat ) && $payload->iat > ( $timestamp + static::$leeway ) ) {
+ throw new BeforeValidException(
+ 'Cannot handle token prior to ' . gmdate( DateTime::ISO8601, $payload->iat )
+ );
+ }
+
+ // Check if this token has expired.
+ if ( isset( $payload->exp ) && ( $timestamp - static::$leeway ) >= $payload->exp ) {
+ throw new ExpiredException( 'Expired token' );
+ }
+
+ return $payload;
+ }
+
+ /**
+ * Converts and signs a PHP object or array into a JWT string.
+ *
+ * @param object|array $payload PHP object or array.
+ * @param string $key The secret key.
+ * If the algorithm used is asymmetric, this is the private key.
+ * @param string $alg The signing algorithm.
+ * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256'.
+ * @param mixed $key_id The key ID.
+ * @param array $head An array with header elements to attach.
+ *
+ * @return string A signed JWT
+ *
+ * @uses json_encode
+ * @uses urlsafe_b64_decode
+ */
+ public static function encode( $payload, $key, $alg = 'HS256', $key_id = null, $head = null ) {
+ $header = array(
+ 'typ' => 'JWT',
+ 'alg' => $alg,
+ );
+
+ if ( null !== $key_id ) {
+ $header['kid'] = $key_id;
+ }
+
+ if ( isset( $head ) && is_array( $head ) ) {
+ $header = array_merge( $head, $header );
+ }
+
+ $segments = array();
+ $segments[] = static::urlsafe_b64_decode( static::json_encode( $header ) );
+ $segments[] = static::urlsafe_b64_decode( static::json_encode( $payload ) );
+ $signing_input = implode( '.', $segments );
+
+ $signature = static::sign( $signing_input, $key, $alg );
+ $segments[] = static::urlsafe_b64_decode( $signature );
+
+ return implode( '.', $segments );
+ }
+
+ /**
+ * Sign a string with a given key and algorithm.
+ *
+ * @param string $msg The message to sign.
+ * @param string|resource $key The secret key.
+ * @param string $alg The signing algorithm.
+ * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256'.
+ *
+ * @return string An encrypted message
+ *
+ * @throws DomainException Unsupported algorithm was specified.
+ */
+ public static function sign( $msg, $key, $alg = 'HS256' ) {
+ if ( empty( static::$supported_algs[ $alg ] ) ) {
+ throw new DomainException( 'Algorithm not supported' );
+ }
+ list($function, $algorithm) = static::$supported_algs[ $alg ];
+ switch ( $function ) {
+ case 'hash_hmac':
+ return hash_hmac( $algorithm, $msg, $key, true );
+ case 'openssl':
+ $signature = '';
+ $success = openssl_sign( $msg, $signature, $key, $algorithm );
+ if ( ! $success ) {
+ throw new DomainException( 'OpenSSL unable to sign data' );
+ } else {
+ return $signature;
+ }
+ }
+ }
+
+ /**
+ * Verify a signature with the message, key and method. Not all methods
+ * are symmetric, so we must have a separate verify and sign method.
+ *
+ * @param string $msg The original message (header and body).
+ * @param string $signature The original signature.
+ * @param string|resource $key For HS*, a string key works. for RS*, must be a resource of an openssl public key.
+ * @param string $alg The algorithm.
+ *
+ * @return bool
+ *
+ * @throws DomainException Invalid Algorithm or OpenSSL failure.
+ */
+ private static function verify( $msg, $signature, $key, $alg ) {
+ if ( empty( static::$supported_algs[ $alg ] ) ) {
+ throw new DomainException( 'Algorithm not supported' );
+ }
+
+ list($function, $algorithm) = static::$supported_algs[ $alg ];
+ switch ( $function ) {
+ case 'openssl':
+ $success = openssl_verify( $msg, $signature, $key, $algorithm );
+
+ if ( 1 === $success ) {
+ return true;
+ } elseif ( 0 === $success ) {
+ return false;
+ }
+
+ // returns 1 on success, 0 on failure, -1 on error.
+ throw new DomainException(
+ 'OpenSSL error: ' . openssl_error_string()
+ );
+ case 'hash_hmac':
+ default:
+ $hash = hash_hmac( $algorithm, $msg, $key, true );
+
+ if ( function_exists( 'hash_equals' ) ) {
+ return hash_equals( $signature, $hash );
+ }
+
+ $len = min( static::safe_strlen( $signature ), static::safe_strlen( $hash ) );
+
+ $status = 0;
+
+ for ( $i = 0; $i < $len; $i++ ) {
+ $status |= ( ord( $signature[ $i ] ) ^ ord( $hash[ $i ] ) );
+ }
+
+ $status |= ( static::safe_strlen( $signature ) ^ static::safe_strlen( $hash ) );
+
+ return ( 0 === $status );
+ }
+ }
+
+ /**
+ * Decode a JSON string into a PHP object.
+ *
+ * @param string $input JSON string.
+ *
+ * @return object Object representation of JSON string
+ *
+ * @throws DomainException Provided string was invalid JSON.
+ */
+ public static function json_decode( $input ) {
+ if ( version_compare( PHP_VERSION, '5.4.0', '>=' ) && ! ( defined( 'JSON_C_VERSION' ) && PHP_INT_SIZE > 4 ) ) {
+ /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you
+ * to specify that large ints (like Steam Transaction IDs) should be treated as
+ * strings, rather than the PHP default behaviour of converting them to floats.
+ */
+ $obj = json_decode( $input, false, 512, JSON_BIGINT_AS_STRING );
+ } else {
+ /** Not all servers will support that, however, so for older versions we must
+ * manually detect large ints in the JSON string and quote them (thus converting
+ *them to strings) before decoding, hence the preg_replace() call.
+ */
+ $max_int_length = strlen( (string) PHP_INT_MAX ) - 1;
+ $json_without_bigints = preg_replace( '/:\s*(-?\d{' . $max_int_length . ',})/', ': "$1"', $input );
+ $obj = json_decode( $json_without_bigints );
+ }
+
+ $errno = json_last_error();
+
+ if ( $errno && function_exists( 'json_last_error' ) ) {
+ static::handle_json_error( $errno );
+ } elseif ( null === $obj && 'null' !== $input ) {
+ throw new DomainException( 'Null result with non-null input' );
+ }
+ return $obj;
+ }
+
+ /**
+ * Encode a PHP object into a JSON string.
+ *
+ * @param object|array $input A PHP object or array.
+ *
+ * @return string JSON representation of the PHP object or array.
+ *
+ * @throws DomainException Provided object could not be encoded to valid JSON.
+ */
+ public static function json_encode( $input ) {
+ $json = wp_json_encode( $input );
+ $errno = json_last_error();
+
+ if ( $errno && function_exists( 'json_last_error' ) ) {
+ static::handle_json_error( $errno );
+ } elseif ( 'null' === $json && null !== $input ) {
+ throw new DomainException( 'Null result with non-null input' );
+ }
+ return $json;
+ }
+
+ /**
+ * Decode a string with URL-safe Base64.
+ *
+ * @param string $input A Base64 encoded string.
+ *
+ * @return string A decoded string
+ */
+ public static function urlsafe_b64_decode( $input ) {
+ $remainder = strlen( $input ) % 4;
+ if ( $remainder ) {
+ $padlen = 4 - $remainder;
+ $input .= str_repeat( '=', $padlen );
+ }
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
+ return base64_decode( strtr( $input, '-_', '+/' ) );
+ }
+
+ /**
+ * Encode a string with URL-safe Base64.
+ *
+ * @param string $input The string you want encoded.
+ *
+ * @return string The base64 encode of what you passed in
+ */
+ public static function urlsafe_b64_encode( $input ) {
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
+ return str_replace( '=', '', strtr( base64_encode( $input ), '+/', '-_' ) );
+ }
+
+ /**
+ * Helper method to create a JSON error.
+ *
+ * @param int $errno An error number from json_last_error().
+ * @throws DomainException .
+ *
+ * @return void
+ */
+ private static function handle_json_error( $errno ) {
+ $messages = array(
+ JSON_ERROR_DEPTH => 'Maximum stack depth exceeded',
+ JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON',
+ JSON_ERROR_CTRL_CHAR => 'Unexpected control character found',
+ JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON',
+ JSON_ERROR_UTF8 => 'Malformed UTF-8 characters',
+ );
+ throw new DomainException(
+ isset( $messages[ $errno ] )
+ ? $messages[ $errno ]
+ : 'Unknown JSON error: ' . $errno
+ );
+ }
+
+ /**
+ * Get the number of bytes in cryptographic strings.
+ *
+ * @param string $str .
+ *
+ * @return int
+ */
+ private static function safe_strlen( $str ) {
+ if ( function_exists( 'mb_strlen' ) ) {
+ return mb_strlen( $str, '8bit' );
+ }
+ return strlen( $str );
+ }
+}
+
+// phpcs:disable
+if ( ! class_exists( 'SignatureInvalidException' ) ) {
+ /**
+ * SignatureInvalidException
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content
+ */
+ class SignatureInvalidException extends \UnexpectedValueException { }
+}
+if ( ! class_exists( 'ExpiredException' ) ) {
+ /**
+ * ExpiredException
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content
+ */
+ class ExpiredException extends \UnexpectedValueException { }
+}
+if ( ! class_exists( 'BeforeValidException' ) ) {
+ /**
+ * BeforeValidException
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content
+ */
+ class BeforeValidException extends \UnexpectedValueException { }
+}
+// phpcs:enable
diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-subscription-service.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-subscription-service.php
new file mode 100644
index 00000000..b433182a
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-subscription-service.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * The Subscription Service represents the entity responsible for making sure a visitor
+ * can see blocks that are considered premium content.
+ *
+ * If a visitor is not allowed to see they need to be given a way gain access.
+ *
+ * It is assumed that it will be a monetary exchange but that is up to the host
+ * that brokers the content exchange.
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content;
+ */
+
+namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
+
+interface Subscription_Service {
+
+ /**
+ * The subscription service can be used.
+ *
+ * @return boolean
+ */
+ public static function available();
+
+ /**
+ * Allows a Subscription Service to setup anything it needs to provide its features.
+ *
+ * This is called during an `init` action hook callback.
+ *
+ * Examples of things a Service may want to do here:
+ * - Determine a visitor is arriving with a new token to unlock content and
+ * store the token for future browsing (e.g. in a cookie)
+ * - Set up WP-API endpoints necessary for the function to work
+ * - Token refreshes
+ *
+ * @return void
+ */
+ public function initialize();
+
+ /**
+ * Given a token (this could be from a cookie, a querystring, or some other means)
+ * can the visitor see the premium content?
+ *
+ * @param array $valid_plan_ids .
+ *
+ * @return boolean
+ */
+ public function visitor_can_view_content( $valid_plan_ids );
+
+ /**
+ * The current visitor would like to obtain access. Where do they go?
+ *
+ * @param string $mode .
+ * @return string
+ */
+ public function access_url( $mode = 'subscribe' );
+}
diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription-service.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription-service.php
new file mode 100644
index 00000000..05791022
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription-service.php
@@ -0,0 +1,264 @@
+<?php
+/**
+ * A paywall that exchanges JWT tokens from WordPress.com to allow
+ * a current visitor to view content that has been deemed "Premium content".
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content
+ */
+
+namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
+
+use Automattic\Jetpack\Extensions\Premium_Content\JWT;
+
+/**
+ * Class Token_Subscription_Service
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
+ */
+abstract class Token_Subscription_Service implements Subscription_Service {
+
+ const JWT_AUTH_TOKEN_COOKIE_NAME = 'jp-premium-content-session';
+ const DECODE_EXCEPTION_FEATURE = 'memberships';
+ const DECODE_EXCEPTION_MESSAGE = 'Problem decoding provided token';
+ const REST_URL_ORIGIN = 'https://subscribe.wordpress.com/';
+
+ /**
+ * Initialize the token subscription service.
+ *
+ * @inheritDoc
+ */
+ public function initialize() {
+ $token = $this->token_from_request();
+ if ( null !== $token ) {
+ $this->set_token_cookie( $token );
+ }
+ }
+
+ /**
+ * The user is visiting with a subscriber token cookie.
+ *
+ * This is theoretically where the cookie JWT signature verification
+ * thing will happen.
+ *
+ * How to obtain one of these (or what exactly it is) is
+ * still a WIP (see api/auth branch)
+ *
+ * @inheritDoc
+ *
+ * @param array $valid_plan_ids List of valid plan IDs.
+ */
+ public function visitor_can_view_content( $valid_plan_ids ) {
+
+ // URL token always has a precedence, so it can overwrite the cookie when new data available.
+ $token = $this->token_from_request();
+ if ( $token ) {
+ $this->set_token_cookie( $token );
+ } else {
+ $token = $this->token_from_cookie();
+ }
+
+ $is_valid_token = true;
+
+ if ( empty( $token ) ) {
+ // no token, no access.
+ $is_valid_token = false;
+ } else {
+ $payload = $this->decode_token( $token );
+ if ( empty( $payload ) ) {
+ $is_valid_token = false;
+ }
+ }
+
+ if ( $is_valid_token ) {
+ $subscriptions = (array) $payload['subscriptions'];
+ } elseif ( is_user_logged_in() ) {
+ /*
+ * If there is no token, but the user is logged in,
+ * get current subscriptions and determine if the user has
+ * a valid subscription to match the plan ID.
+ */
+
+ /**
+ * Filter the subscriptions attached to a specific user on a given site.
+ *
+ * @since 9.4.0
+ *
+ * @param array $subscriptions Array of subscriptions.
+ * @param int $user_id The user's ID.
+ * @param int $site_id ID of the current site.
+ */
+ $subscriptions = apply_filters(
+ 'earn_get_user_subscriptions_for_site_id',
+ array(),
+ wp_get_current_user()->ID,
+ $this->get_site_id()
+ );
+
+ if ( empty( $subscriptions ) ) {
+ return false;
+ }
+ // format the subscriptions so that they can be validated.
+ $subscriptions = self::abbreviate_subscriptions( $subscriptions );
+ } else {
+ return false;
+ }
+
+ return $this->validate_subscriptions( $valid_plan_ids, $subscriptions );
+ }
+
+ /**
+ * Decode the given token.
+ *
+ * @param string $token Token to decode.
+ *
+ * @return array|false
+ */
+ public function decode_token( $token ) {
+ try {
+ $key = $this->get_key();
+ return $key ? (array) JWT::decode( $token, $key, array( 'HS256' ) ) : false;
+ } catch ( \Exception $exception ) {
+ return false;
+ }
+ }
+
+ /**
+ * Get the key for decoding the auth token.
+ *
+ * @return string|false
+ */
+ abstract public function get_key();
+
+ /**
+ * Get the ID of the current site.
+ *
+ * @return int
+ */
+ abstract public function get_site_id();
+
+ // phpcs:disable
+ /**
+ * Get the URL to access the protected content.
+ *
+ * @param string $mode Access mode (either "subscribe" or "login").
+ */
+ public function access_url( $mode = 'subscribe' ) {
+ global $wp;
+ $permalink = get_permalink();
+ if ( empty( $permalink ) ) {
+ $permalink = add_query_arg( $wp->query_vars, home_url( $wp->request ) );
+ }
+
+ $login_url = $this->get_rest_api_token_url( $this->get_site_id(), $permalink );
+ return $login_url;
+ }
+ // phpcs:enable
+
+ /**
+ * Get the token stored in the auth cookie.
+ *
+ * @return ?string
+ */
+ private function token_from_cookie() {
+ if ( isset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] ) ) {
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ return $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ];
+ }
+ }
+
+ /**
+ * Store the auth cookie.
+ *
+ * @param string $token Auth token.
+ * @return void
+ */
+ private function set_token_cookie( $token ) {
+ if ( ! empty( $token ) ) {
+ setcookie( self::JWT_AUTH_TOKEN_COOKIE_NAME, $token, 0, '/' );
+ }
+ }
+
+ /**
+ * Get the token if present in the current request.
+ *
+ * @return ?string
+ */
+ private function token_from_request() {
+ $token = null;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET['token'] ) ) {
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
+ if ( preg_match( '/^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$/', $_GET['token'], $matches ) ) {
+ // token matches a valid JWT token pattern.
+ $token = reset( $matches );
+ }
+ }
+ return $token;
+ }
+
+ /**
+ * Return true if any ID/date pairs are valid. Otherwise false.
+ *
+ * @param int[] $valid_plan_ids List of valid plan IDs.
+ * @param array<int, Token_Subscription> $token_subscriptions : ID must exist in the provided <code>$valid_subscriptions</code> parameter.
+ * The provided end date needs to be greater than <code>now()</code>.
+ *
+ * @return bool
+ */
+ protected function validate_subscriptions( $valid_plan_ids, $token_subscriptions ) {
+ // Create a list of product_ids to compare against.
+ $product_ids = array();
+ foreach ( $valid_plan_ids as $plan_id ) {
+ $product_id = (int) get_post_meta( $plan_id, 'jetpack_memberships_product_id', true );
+ if ( isset( $product_id ) ) {
+ $product_ids[] = $product_id;
+ }
+ }
+
+ foreach ( $token_subscriptions as $product_id => $token_subscription ) {
+ if ( in_array( $product_id, $product_ids, true ) ) {
+ $end = is_int( $token_subscription->end_date ) ? $token_subscription->end_date : strtotime( $token_subscription->end_date );
+ if ( $end > time() ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get the URL of the JWT endpoint.
+ *
+ * @param int $site_id Site ID.
+ * @param string $redirect_url URL to redirect after checking the token validity.
+ * @return string URL of the JWT endpoint.
+ */
+ private function get_rest_api_token_url( $site_id, $redirect_url ) {
+ return sprintf( '%smemberships/jwt?site_id=%d&redirect_url=%s', self::REST_URL_ORIGIN, $site_id, rawurlencode( $redirect_url ) );
+ }
+
+ /**
+ * Report the subscriptions as an ID => [ 'end_date' => ]. mapping
+ *
+ * @param array $subscriptions_from_bd List of subscriptions from BD.
+ *
+ * @return array<int, array>
+ */
+ public static function abbreviate_subscriptions( $subscriptions_from_bd ) {
+ $subscriptions = array();
+ foreach ( $subscriptions_from_bd as $subscription ) {
+ // We are picking the expiry date that is the most in the future.
+ if (
+ 'active' === $subscription['status'] && (
+ ! isset( $subscriptions[ $subscription['product_id'] ] ) ||
+ empty( $subscription['end_date'] ) || // Special condition when subscription has no expiry date - we will default to a year from now for the purposes of the token.
+ strtotime( $subscription['end_date'] ) > strtotime( (string) $subscriptions[ $subscription['product_id'] ]->end_date )
+ )
+ ) {
+ $subscriptions[ $subscription['product_id'] ] = new \stdClass();
+ $subscriptions[ $subscription['product_id'] ]->end_date = empty( $subscription['end_date'] ) ? ( time() + 365 * 24 * 3600 ) : $subscription['end_date'];
+ }
+ }
+ return $subscriptions;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription.php
new file mode 100644
index 00000000..d9c81e8c
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription.php
@@ -0,0 +1,23 @@
+<?php
+/**
+ * Token subscription management.
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content
+ */
+
+namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
+
+/**
+ * Class Token_Subscription
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
+ */
+class Token_Subscription {
+
+ /**
+ * End date.
+ *
+ * @var string $end_date .
+ */
+ public $end_date = '';
+}
diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-unconfigured-subscription-service.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-unconfigured-subscription-service.php
new file mode 100644
index 00000000..a89662a2
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-unconfigured-subscription-service.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * The environment does not have a subscription service available.
+ * This represents this scenario.
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content
+ */
+
+namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
+
+use function site_url;
+// phpcs:disable
+
+/**
+ * Class Unconfigured_Subscription_Service
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
+ */
+class Unconfigured_Subscription_Service implements Subscription_Service {
+
+ /**
+ * Is always available because it is the fallback.
+ *
+ * @inheritDoc
+ */
+ public static function available() {
+ return true;
+ }
+
+ /**
+ * Function: initialize()
+ *
+ * @inheritDoc
+ */
+ public function initialize() {
+ // noop.
+ }
+
+ /**
+ * No subscription service available, no users can see this content.
+ *
+ * @param array $valid_plan_ids .
+ */
+ public function visitor_can_view_content( $valid_plan_ids ) {
+ return false;
+ }
+
+ /**
+ * The current visitor would like to obtain access. Where do they go?
+ *
+ * @param string $mode .
+ */
+ public function access_url( $mode = 'subscribe' ) {
+ return site_url();
+ }
+
+}
+// phpcs:enable
diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-offline-subscription-service.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-offline-subscription-service.php
new file mode 100644
index 00000000..12283a96
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-offline-subscription-service.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * This subscription service is used when a subscriber is offline and a token is not available.
+ * This subscription service will be used when rendering content in email and reader on WPCOM only.
+ * When content is being rendered, the current user and site are set.
+ * This allows us to lookup a users subscriptions and determine if the
+ * offline visitor can view content that has been deemed "Premium content".
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content
+ */
+
+namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
+
+/**
+ * Class WPCOM_Offline_Subscription_Service
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
+ */
+class WPCOM_Offline_Subscription_Service extends WPCOM_Token_Subscription_Service {
+
+ /**
+ * Is available()
+ *
+ * @return bool
+ */
+ public static function available() {
+ // Return available if the user is logged in and either
+ // running a job (sending email subscription) OR
+ // handling API request on WPCOM (reader).
+ return (
+ ( defined( 'WPCOM_JOBS' ) && WPCOM_JOBS ) ||
+ ( defined( 'IS_WPCOM' ) && IS_WPCOM === true && ( defined( 'REST_API_REQUEST' ) && REST_API_REQUEST ) )
+ ) && is_user_logged_in();
+ }
+
+ /**
+ * Lookup users subscriptions for a site and determine if the user has a valid subscription to match the plan ID
+ *
+ * @param array $valid_plan_ids .
+ * @return bool
+ */
+ public function visitor_can_view_content( $valid_plan_ids ) {
+ /** This filter is already documented in projects/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription-service.php */
+ $subscriptions = apply_filters( 'earn_get_user_subscriptions_for_site_id', array(), wp_get_current_user()->ID, $this->get_site_id() );
+ if ( empty( $subscriptions ) ) {
+ return false;
+ }
+ // format the subscriptions so that they can be validated.
+ $subscriptions = self::abbreviate_subscriptions( $subscriptions );
+ return $this->validate_subscriptions( $valid_plan_ids, $subscriptions );
+ }
+
+ /**
+ * Report the subscriptions as an ID => [ 'end_date' => ]. mapping
+ *
+ * @param array $subscriptions_from_bd .
+ *
+ * @return array<int, array>
+ */
+ public static function abbreviate_subscriptions( $subscriptions_from_bd ) {
+ $subscriptions = array();
+ foreach ( $subscriptions_from_bd as $subscription ) {
+ // We are picking the expiry date that is the most in the future.
+ if (
+ 'active' === $subscription['status'] && (
+ ! isset( $subscriptions[ $subscription['product_id'] ] ) ||
+ empty( $subscription['end_date'] ) || // Special condition when subscription has no expiry date - we will default to a year from now for the purposes of the token.
+ strtotime( $subscription['end_date'] ) > strtotime( (string) $subscriptions[ $subscription['product_id'] ]->end_date )
+ )
+ ) {
+ $subscriptions[ $subscription['product_id'] ] = new \stdClass();
+ $subscriptions[ $subscription['product_id'] ]->end_date = empty( $subscription['end_date'] ) ? ( time() + 365 * 24 * 3600 ) : $subscription['end_date'];
+ }
+ }
+ return $subscriptions;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-token-subscription-service.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-token-subscription-service.php
new file mode 100644
index 00000000..1bb80b6b
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-token-subscription-service.php
@@ -0,0 +1,46 @@
+<?php
+/**
+ * A paywall that exchanges JWT tokens from WordPress.com to allow
+ * a current visitor to view content that has been deemed "Premium content".
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content
+ */
+
+namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
+
+/**
+ * Class WPCOM_Token_Subscription_Service
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
+ */
+class WPCOM_Token_Subscription_Service extends Token_Subscription_Service {
+
+ /**
+ * Is available()
+ *
+ * @inheritDoc
+ */
+ public static function available() {
+ // phpcs:ignore ImportDetection.Imports.RequireImports.Symbol
+ return defined( 'IS_WPCOM' ) && IS_WPCOM === true;
+ }
+
+ /**
+ * Is get_site_id()
+ *
+ * @inheritDoc
+ */
+ public function get_site_id() {
+ return get_current_blog_id();
+ }
+
+ /**
+ * Is get_key()
+ *
+ * @inheritDoc
+ */
+ public function get_key() {
+ // phpcs:ignore ImportDetection.Imports.RequireImports.Symbol
+ return defined( 'EARN_JWT_SIGNING_KEY' ) ? EARN_JWT_SIGNING_KEY : false;
+ }
+}
diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/include.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/include.php
new file mode 100644
index 00000000..34bf3f20
--- /dev/null
+++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/include.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * Subcription service includes to build out the service.
+ *
+ * @package Automattic\Jetpack\Extensions\Premium_Content
+ */
+
+namespace Automattic\Jetpack\Extensions\Premium_Content;
+
+require_once __DIR__ . '/class-jwt.php';
+require_once __DIR__ . '/class-subscription-service.php';
+require_once __DIR__ . '/class-token-subscription.php';
+require_once __DIR__ . '/class-token-subscription-service.php';
+require_once __DIR__ . '/class-wpcom-token-subscription-service.php';
+require_once __DIR__ . '/class-wpcom-offline-subscription-service.php';
+require_once __DIR__ . '/class-jetpack-token-subscription-service.php';
+require_once __DIR__ . '/class-unconfigured-subscription-service.php';
+
+use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Jetpack_Token_Subscription_Service;
+use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Unconfigured_Subscription_Service;
+use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\WPCOM_Offline_Subscription_Service;
+use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\WPCOM_Token_Subscription_Service;
+
+const PAYWALL_FILTER = 'earn_premium_content_subscription_service';
+
+/**
+ * Initializes the premium content subscription service.
+ */
+function paywall_initialize() {
+ $paywall = subscription_service();
+ if ( $paywall ) {
+ $paywall->initialize();
+ }
+}
+add_action( 'init', 'Automattic\Jetpack\Extensions\Premium_Content\paywall_initialize', 9 );
+
+/**
+ * Gets the service handling the premium content subscriptions.
+ *
+ * @return Subscription_Service Service that will handle the premium content subscriptions.
+ */
+function subscription_service() {
+ /**
+ * Filter the Jetpack_Token_Subscription_Service class.
+ *
+ * @since 9.4.0
+ *
+ * @param null|Jetpack_Token_Subscription_Service $interface Registered Subscription_Service.
+ */
+ $interface = apply_filters( PAYWALL_FILTER, null );
+ if ( ! $interface instanceof Jetpack_Token_Subscription_Service ) {
+ _doing_it_wrong( __FUNCTION__, 'No Subscription_Service registered for the ' . esc_html( PAYWALL_FILTER ) . ' filter', 'jetpack' );
+ }
+ return $interface;
+}
+
+/**
+ * Gets the default service handling the premium content.
+ *
+ * @param Subscription_Service $service If set, this service will be used by default.
+ * @return Subscription_Service Service that will handle the premium content.
+ */
+function default_service( $service ) {
+ if ( null !== $service ) {
+ return $service;
+ }
+
+ if ( WPCOM_Offline_Subscription_Service::available() ) {
+ return new WPCOM_Offline_Subscription_Service();
+ }
+
+ if ( WPCOM_Token_Subscription_Service::available() ) {
+ return new WPCOM_Token_Subscription_Service();
+ }
+
+ if ( Jetpack_Token_Subscription_Service::available() ) {
+ return new Jetpack_Token_Subscription_Service();
+ }
+
+ return new Unconfigured_Subscription_Service();
+}
+add_filter( PAYWALL_FILTER, 'Automattic\Jetpack\Extensions\Premium_Content\default_service' );