_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; } }