Files
wp-graphql-woocommerce/includes/utils/class-ql-session-handler.php
Geoff TaylorandGitHub 088d337ef5 fix: address WordPress.org plugin review (rename + prefixing + headers) (#1019)
* fix: address WordPress.org plugin review feedback

- Prefix the session transaction queue transient with the plugin's
  graphql_woocommerce_ namespace instead of the generic "woo_" word, to
  avoid collisions (Plugin Directory: prefix data storage).
- Declare the WooCommerce dependency via the "Requires Plugins: woocommerce"
  plugin header.
- Bump README.txt "Tested up to" to 7.0.
- Ship composer.json in the distributed plugin (drop it, and composer.lock,
  from composer archive excludes) so the build is reproducible/reviewable.

* chore: rename plugin to "GraphQL for eCommerce" for trademark compliance

The WordPress.org plugin review flagged the display name/slug for beginning
with the "WPGraphQL" trademark (and the "WooGraphQL" portmanteau of the
WooCommerce mark), which can imply official affiliation.

- Display name (plugin header + readme title) -> "GraphQL for eCommerce".
- Slug/text domain -> "graphql-for-ecommerce" (header, all i18n string
  literals, and the PHPCS WordPress.WP.I18n text_domain config).
- Update user-facing notices/errors that named the old plugin.

"WooGraphQL" remains the project's informal nickname (repo, docs, community),
just not in the WordPress.org directory's official name/slug. Internal file
names and GitHub URLs are unchanged.

* fix: keep test-only dev deps out of the committed composer.json

The committed manifest mirrors develop (lint/stan dev deps only); CI adds the
test suite deps at runtime via `composer installTestEnv`. A previous commit
captured the installTestEnv-modified composer.json, desyncing it from
composer.lock and breaking `composer install` in CI.

* chore: regenerate composer.lock (refresh dev dependencies)

Regenerate the lock from the manifest so it is in sync (fixes the CI
`composer install` failure) and refresh dependencies in the process —
firebase/php-jwt v7.0.4 -> v7.1.0 plus 11 others, with vendor-prefixed
re-strauss'd to match. Full wpunit suite passes against the updated deps
(305 tests, 835 assertions).

* chore: rename text domain in createdVia/attribution strings from #1018

#1018 (createdVia + order attribution) merged into develop after the rename
commit was authored, so its new i18n strings still used the old
'wp-graphql-woocommerce' text domain. Update them to 'graphql-for-ecommerce'
to match the rename.
2026-06-30 10:16:21 -04:00

754 lines
22 KiB
PHP

<?php
/**
* Handles data for the current customers session.
*
* @package WPGraphQL\WooCommerce\Utils
* @since 0.1.2
*/
namespace WPGraphQL\WooCommerce\Utils;
use WC_Session_Handler;
use WPGraphQL\Router;
use WPGraphQL\WooCommerce\Vendor\Firebase\JWT\JWT;
use WPGraphQL\WooCommerce\Vendor\Firebase\JWT\Key;
/**
* Class - QL_Session_Handler
*
* @property int $_session_expiring
* @property int $_session_expiration
* @property int|string $_customer_id
*/
class QL_Session_Handler extends WC_Session_Handler {
/**
* Stores the name of the HTTP header used to pass the session token.
*
* @var string $_token
*/
protected $_token; // @codingStandardsIgnoreLine
/**
* Stores Timestamp of when the session token was issued.
*
* @var float $_session_issued
*/
protected $_session_issued; // @codingStandardsIgnoreLine
/**
* True when the token exists.
*
* @var bool $_has_token
*/
protected $_has_token = false; // @codingStandardsIgnoreLine
/**
* True when a new session token has been issued.
*
* @var bool $_issuing_new_token
*/
protected $_issuing_new_token = false; // @codingStandardsIgnoreLine
/**
* True when a new session cookie has been issued.
*
* @var bool $_issuing_new_cookie
*/
protected $_issuing_new_cookie = false; // @codingStandardsIgnoreLine
/**
* Constructor for the session class.
*/
public function __construct() {
parent::__construct();
$this->_token = apply_filters( 'graphql_woocommerce_cart_session_http_header', 'woocommerce-session' );
}
/**
* Returns formatted $_SERVER index from provided string.
*
* @param string $header String to be formatted.
*
* @return string
*/
private function get_server_key( $header = null ) {
/**
* Server key.
*
* @var string $server_key
*/
$server_key = preg_replace( '#[^A-z0-9]#', '_', ! empty( $header ) ? $header : $this->_token );
return null !== $server_key
? 'HTTP_' . strtoupper( $server_key )
: '';
}
/**
* This returns the secret key, using the defined constant if defined, and passing it through a filter to
* allow for the config to be able to be set via another method other than a defined constant, such as an
* admin UI that allows the key to be updated/changed/revoked at any time without touching server files
*
* @return mixed|null|string
*/
private function get_secret_key() {
// Use the defined secret key, if it exists. Fallback must be at least
// 32 bytes to satisfy php-jwt v7's HS256 minimum key length requirement.
$secret_key = defined( 'GRAPHQL_WOOCOMMERCE_SECRET_KEY' ) && GRAPHQL_WOOCOMMERCE_SECRET_KEY !== false && GRAPHQL_WOOCOMMERCE_SECRET_KEY !== ''
? GRAPHQL_WOOCOMMERCE_SECRET_KEY :
wp_salt();
return apply_filters( 'graphql_woocommerce_secret_key', $secret_key );
}
/**
* Init hooks and session data.
*
* @return void
*/
public function init() {
$this->init_session_token();
Session_Transaction_Manager::get( $this );
/**
* Necessary since Session_Transaction_Manager applies to the reference.
*
* @var self $this
*/
if ( Router::is_graphql_http_request() ) {
add_action( 'woocommerce_set_cart_cookies', [ $this, 'set_customer_session_token' ], 10 );
add_action( 'woographql_update_session', [ $this, 'set_customer_session_token' ], 10 );
add_action( 'shutdown', [ $this, 'save_data' ] );
add_filter( 'graphql_jwt_auth_after_authenticate', [ $this, 'reinitialize_session_token' ], 10 );
add_filter( 'graphql_login_payload', [ $this, 'reinitialize_session_token' ], 10 );
} else {
add_action( 'woocommerce_set_cart_cookies', [ $this, 'set_customer_session_cookie' ], 10 );
add_action( 'shutdown', [ $this, 'save_data' ], 20 );
add_action( 'wp_logout', [ $this, 'destroy_session' ] );
if ( ! is_user_logged_in() ) {
add_filter( 'nonce_user_logged_out', [ $this, 'maybe_update_nonce_user_logged_out' ], 10, 2 );
}
}
}
/**
* Mark the session as dirty.
*
* To trigger a save of the session data.
*
* @return void
*/
public function mark_dirty() {
$this->_dirty = true;
}
/**
* Setup token and customer ID.
*
* @throws \GraphQL\Error\UserError Invalid token.
*
* @return void
*/
public function init_session_token() {
/**
* @var object{ iat: int, exp: int, data: object{ customer_id: string } }|false|\WP_Error $token
*/
$token = $this->get_session_token();
// Process existing session if not expired or invalid.
if ( $token && is_object( $token ) && ! is_wp_error( $token ) ) {
$this->_customer_id = $token->data->customer_id;
$this->_session_issued = $token->iat;
$this->_session_expiration = $token->exp;
$this->_session_expiring = $token->exp - ( 3600 );
$this->_has_token = true;
$this->_data = $this->get_session_data();
// If the user logs in, update session.
if ( is_user_logged_in() && strval( get_current_user_id() ) !== $this->_customer_id ) {
$guest_session_id = $this->_customer_id;
$guest_data = $this->_data;
$this->_customer_id = strval( get_current_user_id() );
$this->_dirty = true;
$existing_user_data = $this->get_session_data();
$transfer_behavior = woographql_setting( 'session_transfer_behavior', 'keep_new_fallback_old' );
switch ( $transfer_behavior ) {
case 'keep_new':
$this->_data = $guest_data;
break;
case 'keep_old':
$this->_data = ! empty( $existing_user_data ) ? $existing_user_data : $guest_data;
break;
case 'keep_new_fallback_old':
default:
$this->_data = ! empty( $guest_data ) ? $guest_data : $existing_user_data;
break;
}
// @phpstan-ignore-next-line
$this->save_data( $guest_session_id );
Router::is_graphql_http_request()
? $this->set_customer_session_token( true )
: $this->set_customer_session_cookie( true );
}
// Update session expiration on each action.
$this->set_session_expiration();
if ( $token->exp < $this->_session_expiration ) {
$this->update_session_timestamp( (string) $this->_customer_id, $this->_session_expiration );
}
} elseif ( Router::is_graphql_http_request() && is_wp_error( $token ) ) {
add_filter(
'graphql_woocommerce_session_token_errors',
static function ( $errors ) use ( $token ) {
$errors = $token->get_error_code() . ': ' . $token->get_error_message();
return $errors;
}
);
}
$start_new_session = ! $token || is_wp_error( $token );
if ( ! $start_new_session ) {
return;
}
// Distribute new session token on GraphQL requests, otherwise distribute a new session cookie.
if ( Router::is_graphql_http_request() ) {
// Start new session.
$this->set_session_expiration();
// Get Customer ID.
$this->_customer_id = is_user_logged_in() ? get_current_user_id() : $this->generate_customer_id();
$this->_data = $this->get_session_data();
$this->set_customer_session_token( true );
} else {
$this->init_session_cookie();
}
}
/**
* Reinitialize session token in response after authentication in GraphQL.
*
* @param array $response The authentication response.
*
* @return array
*/
public function reinitialize_session_token( $response ) {
$this->init_session_token();
$token = $this->build_token();
if ( $token ) {
$response['session_token'] = $token;
}
// Add Store API Cart-Token if enabled.
$cart_token = $this->build_cart_token();
if ( ! empty( $cart_token ) ) {
$response['cart_token'] = $cart_token;
}
return $response;
}
/**
* Retrieve and decrypt the session data from session, if set. Otherwise return false.
*
* Session cookies without a customer ID are invalid.
*
* @throws \Exception Invalid token.
* @return false|\WP_Error|object{ iat: int, exp: int, data: object{ customer_id: string } }
*/
public function get_session_token() {
// Get the Auth header.
$session_header = $this->get_session_header();
if ( empty( $session_header ) ) {
return false;
}
// Determine token type by checking for "Session " prefix.
$is_legacy_token = 0 === strpos( $session_header, 'Session ' );
if ( $is_legacy_token ) {
return $this->validate_legacy_token( $session_header );
}
return $this->validate_cart_token( $session_header );
}
/**
* Validate legacy GraphQL session token
*
* @param string $session_header The session header value.
*
* @throws \Exception Invalid token.
*
* @return object{ iat: int, exp: int, data: object{ customer_id: string } }|\WP_Error|false
*/
protected function validate_legacy_token( $session_header ) {
// Get the token from the header.
$token_string = sscanf( $session_header, 'Session %s' );
if ( empty( $token_string ) ) {
return false;
}
list( $token ) = $token_string;
/**
* Try to decode the token
*/
try {
JWT::$leeway = 60;
$secret = $this->get_secret_key();
$key = new Key( $secret, 'HS256' );
/**
* Decode the token
*
* @var null|object{ iat: int, exp: int, data: object{ customer_id: string }, iss: string } $token
*/
$token = ! empty( $token ) ? JWT::decode( $token, $key ) : null;
// Check if token was successful decoded.
if ( ! $token ) {
throw new \Exception( __( 'Failed to decode session token', 'graphql-for-ecommerce' ) );
}
// The Token is decoded now validate the iss.
if ( empty( $token->iss ) || get_bloginfo( 'url' ) !== $token->iss ) {
throw new \Exception( __( 'The iss do not match with this server', 'graphql-for-ecommerce' ) );
}
// Validate the customer id in the token.
if ( empty( $token->data ) || empty( $token->data->customer_id ) ) {
throw new \Exception( __( 'Customer ID not found in the token', 'graphql-for-ecommerce' ) );
}
} catch ( \Throwable $error ) {
return new \WP_Error( 'invalid_token', $error->getMessage() );
}//end try
return $token;
}
/**
* Validate Store API Cart-Token
*
* @param string $cart_token The Cart-Token value.
*
* @throws \Exception Invalid token.
*
* @return object{ iat: int, exp: int, data: object{ customer_id: string } }|\WP_Error|false
*/
protected function validate_cart_token( $cart_token ) {
// Validate Cart-Token using WooCommerce's JsonWebToken utility if available.
if ( ! $this->supports_store_api() ) {
return new \WP_Error( 'store_api_not_supported', __( 'Store API not available', 'graphql-for-ecommerce' ) );
}
try {
$secret = '@' . wp_salt();
$is_valid = \Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken::validate(
$cart_token,
$secret
);
if ( ! $is_valid ) {
throw new \Exception( __( 'Invalid Cart-Token', 'graphql-for-ecommerce' ) );
}
// Decode the token to get the payload.
/** @var object{ payload: object{ user_id: string, iat: int, exp: int } } $parts */
$parts = \Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken::get_parts( $cart_token );
// Transform to match legacy token structure for compatibility.
/** @var object{ iat: int, exp: int, data: object{ customer_id: string } } $payload */
$payload = (object) [
'iat' => $parts->payload->iat,
'exp' => $parts->payload->exp,
'data' => (object) [ 'customer_id' => $parts->payload->user_id ?? '' ],
];
} catch ( \Throwable $error ) {
return new \WP_Error( 'invalid_cart_token', $error->getMessage() );
}//end try
return $payload;
}
/**
* Get the value of the cart session header from the $_SERVER super global
*
* @return mixed|string
*/
public function get_session_header() {
$token_type = woographql_setting( 'set_session_token_type', 'legacy' );
// Check for Cart-Token header first if Store API mode is enabled.
if ( in_array( $token_type, [ 'store-api', 'both' ], true ) && isset( $_SERVER['HTTP_CART_TOKEN'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$cart_token = $_SERVER['HTTP_CART_TOKEN']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
/**
* Return the cart session header, passed through a filter
*
* @param string $session_header The header used to identify a user's cart session token.
*/
return apply_filters( 'graphql_woocommerce_cart_session_header', $cart_token );
}
// Fall back to legacy woocommerce-session header.
$session_header_key = $this->get_server_key();
// Looking for the cart session header.
$session_header = isset( $_SERVER[ $session_header_key ] )
? $_SERVER[ $session_header_key ] //@codingStandardsIgnoreLine
: false;
/**
* Return the cart session header, passed through a filter
*
* @param string $session_header The header used to identify a user's cart session token.
*/
return apply_filters( 'graphql_woocommerce_cart_session_header', $session_header );
}
/**
* Determine if a JWT is being sent in the page response.
*
* @return bool
*/
public function sending_token() {
return $this->_has_token || $this->_issuing_new_token;
}
/**
* Determine if a HTTP cookie is being sent in the page response.
*
* @return bool
*/
public function sending_cookie() {
return $this->_has_cookie || $this->_issuing_new_cookie;
}
/**
* Creates JSON Web Token for customer session.
*
* @return false|string
*/
public function build_token() {
if ( empty( $this->_session_issued ) || ! $this->sending_token() ) {
return false;
}
// Check if legacy GraphQL token generation is enabled.
$token_type = woographql_setting( 'set_session_token_type', 'legacy' );
if ( ! in_array( $token_type, [ 'legacy', 'both' ], true ) ) {
return false;
}
/**
* Determine the "not before" value for use in the token
*
* @param float $issued The timestamp of token was issued.
* @param int|string $customer_id Customer ID.
* @param array $session_data Cart session data.
*/
$not_before = apply_filters(
'graphql_woo_cart_session_not_before',
$this->_session_issued,
$this->_customer_id,
$this->_data
);
// Configure the token array, which will be encoded.
$token = [
'iss' => get_bloginfo( 'url' ),
'iat' => $this->_session_issued,
'nbf' => $not_before,
'exp' => $this->_session_expiration,
'data' => [
'customer_id' => $this->_customer_id,
],
];
/**
* Filter the token, allowing for individual systems to configure the token as needed
*
* @param array $token The token array that will be encoded
* @param int|string $customer_id ID of customer associated with token.
* @param array $session_data Session data associated with token.
*/
$token = apply_filters(
'graphql_woocommerce_cart_session_before_token_sign',
$token,
$this->_customer_id,
$this->_data
);
// Encode the token.
JWT::$leeway = 60;
$token = JWT::encode( $token, $this->get_secret_key(), 'HS256' );
/**
* Filter the token before returning it, allowing for individual systems to override what's returned.
*
* For example, if the user should not be granted a token for whatever reason, a filter could have the token return null.
*
* @param string $token The signed JWT token that will be returned
* @param int|string $customer_id ID of customer associated with token.
* @param array $session_data Session data associated with token.
*/
$token = apply_filters(
'graphql_woocommerce_cart_session_signed_token',
$token,
$this->_customer_id,
$this->_data
);
return $token;
}
/**
* Build a Store API compatible Cart-Token JWT.
*
* Generates a JWT token compatible with WooCommerce Store API (used by WooCommerce Blocks).
* This enables session sharing between GraphQL mutations and WooCommerce Blocks cart/checkout.
*
* @since 0.22.0
*
* @return string|null Cart-Token JWT or null if feature disabled or unavailable.
*/
public function build_cart_token() {
// Check if Store API token generation is enabled.
$token_type = woographql_setting( 'set_session_token_type', 'legacy' );
if ( ! in_array( $token_type, [ 'store-api', 'both' ], true ) ) {
return null;
}
// Ensure session is active.
if ( empty( $this->_session_issued ) || ! $this->sending_token() ) {
return null;
}
// Check if WooCommerce Store API utilities are available.
if ( ! $this->supports_store_api() ) {
return null;
}
// Generate Cart-Token using WooCommerce's Store API pattern.
try {
$token = \Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken::create(
[
'user_id' => $this->_customer_id,
'exp' => $this->_session_expiration,
'iss' => 'store-api',
],
'@' . wp_salt()
);
/**
* Filter the Store API Cart-Token before returning.
*
* @since 0.22.0
*
* @param string $token The signed Cart-Token JWT
* @param int|string $customer_id ID of customer associated with token
* @param array $session_data Session data associated with token
*/
$token = apply_filters(
'graphql_woocommerce_store_api_cart_token',
$token,
$this->_customer_id,
$this->_data
);
return $token;
} catch ( \Throwable $e ) {
// Log error but don't break GraphQL response.
do_action( 'graphql_debug', sprintf( 'Failed to generate Cart-Token: %s', $e->getMessage() ) );
return null;
}
}
/**
* Check if WooCommerce version supports Store API.
*
* Store API Cart-Token functionality requires WooCommerce 5.5.0+.
*
* @since 0.22.0
*
* @return bool
*/
protected function supports_store_api() {
// Check WooCommerce is active.
if ( ! defined( 'WC_VERSION' ) ) {
return false;
}
// Store API CartTokenUtils introduced in WC 5.5.0.
if ( version_compare( WC_VERSION, '5.5.0', '<' ) ) {
return false;
}
// Check if Store API JWT class is available.
if ( ! class_exists( '\Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken' ) ) {
return false;
}
return true;
}
/**
* Sets the session header on-demand (usually after adding an item to the cart).
*
* Warning: Headers will only be set if this is called before the headers are sent.
*
* @param bool $set Should the session cookie be set.
*
* @return void
*/
public function set_customer_session_token( $set ) {
if ( ! empty( $this->_session_issued ) && $set ) {
/**
* Set callback session token(s) for use in the HTTP response headers.
* Depending on the session token type setting, this may send:
* - Legacy GraphQL session token (woocommerce-session header)
* - Store API Cart-Token header
* - Both headers
*/
add_filter(
'graphql_response_headers_to_send',
function ( $headers ) {
// Add legacy GraphQL session token if enabled.
$token = $this->build_token();
if ( $token ) {
$headers[ $this->_token ] = $token;
}
// Add Store API Cart-Token if enabled.
$cart_token = $this->build_cart_token();
if ( ! empty( $cart_token ) ) {
$headers['Cart-Token'] = $cart_token;
}
return $headers;
},
10
);
$this->_issuing_new_token = true;
}
}
/**
* {@inheritDoc}
*
* @return void
*/
public function set_customer_session_cookie( $set ) {
parent::set_customer_session_cookie( $set );
if ( $set ) {
$this->_issuing_new_cookie = true;
}
}
/**
* Return true if the current user has an active session, i.e. a cookie to retrieve values.
*
* @return bool
*/
public function has_session() {
// @codingStandardsIgnoreLine.
return $this->_issuing_new_token || $this->_has_token || parent::has_session();
}
/**
* Set session expiration.
*
* @return void
*/
public function set_session_expiration() {
$this->_session_issued = time();
parent::set_session_expiration();
$this->_session_expiration = apply_filters_deprecated(
'graphql_woocommerce_cart_session_expire',
[ $this->_session_expiration ],
'0.21.0',
'wc_session_expiration'
);
}
/**
* Save any changes to database after a session mutations has been run.
*
* @return void
*/
public function save_if_dirty() {
// Update if user recently authenticated.
if ( is_user_logged_in() && get_current_user_id() !== $this->_customer_id ) {
$this->_customer_id = get_current_user_id();
$this->_dirty = true;
}
// Bail if no changes.
if ( ! $this->_dirty ) {
return;
}
$this->save_data();
}
/**
* For refreshing session data mid-request when changes occur in concurrent requests.
*
* @return void
*/
public function reload_data() {
\WC_Cache_Helper::invalidate_cache_group( WC_SESSION_CACHE_GROUP );
// Get session data.
$data = $this->get_session( (string) $this->_customer_id );
if ( is_array( $data ) ) {
$this->_data = $data;
}
}
/**
* Returns "client_session_id". "client_session_id_expiration" is used
* to keep "client_session_id" as fresh as possible.
*
* For the most strict level of security it's highly recommend these values
* be set client-side using the `updateSession` mutation.
* "client_session_id" in particular should be salted with some
* kind of client identifier like the end-user "IP" or "user-agent"
* then hashed parodying the tokens generated by
* WP's WP_Session_Tokens class.
*
* @return string
*/
public function get_client_session_id() {
// Get client session ID.
$client_session_id = $this->get( 'client_session_id', false );
$client_session_id_expiration = absint( $this->get( 'client_session_id_expiration', 0 ) );
// If client session ID valid return it.
if ( false !== $client_session_id && time() < $client_session_id_expiration ) {
// @phpstan-ignore-next-line
return $client_session_id;
}
// Generate a new client session ID.
$client_session_id = uniqid();
$client_session_id_expiration = time() + 3600;
$this->set( 'client_session_id', $client_session_id );
$this->set( 'client_session_id_expiration', $client_session_id_expiration );
$this->save_data();
// Return new client session ID.
return $client_session_id;
}
}