# woocommerce-pos/1.10.16/includes/Services/Auth.php

WCPOS – Point of Sale (POS) plugin for WooCommerce, version 1.10.16. 1,557 lines.

- Page: https://pluginprobe.com/plugins/woocommerce-pos/1.10.16/code/includes/Services/Auth.php
- Raw: https://pluginprobe.com/plugins/woocommerce-pos/1.10.16/raw/includes/Services/Auth.php
- Modified: 2026-09-09T17:47:34+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/woocommerce-pos/1.10.16/code/includes/Services/Auth.php#L10-L20`.

```php
<?php
/**
 * Auth.
 *
 * @package WCPOS\WooCommercePOS
 */

namespace WCPOS\WooCommercePOS\Services;

use Exception;
use WCPOS\Vendor\Firebase\JWT\JWT;
use WCPOS\Vendor\Firebase\JWT\Key;
use WCPOS\WooCommercePOS\Logger;
use WCPOS\WooCommercePOS\Services\Settings\Access_Section;
use WP_Error;
use WP_User;
use const DAY_IN_SECONDS;
use const HOUR_IN_SECONDS;
use const MINUTE_IN_SECONDS;

/**
 * Auth Service class.
 */
class Auth {
	/**
	 * Maximum number of refresh-token sessions retained per user.
	 *
	 * Refresh tokens live for weeks and every entry carries a user agent plus parsed
	 * device info, so without a cap the `_woocommerce_pos_refresh_tokens` row grows until
	 * `get_user_meta()` can no longer unserialize it inside the PHP memory limit.
	 *
	 * This is a ceiling on ACCUMULATED CLUTTER, never a limit on how many devices may be
	 * signed in at once: `evict_oldest_sessions()` only ever removes sessions that have
	 * been idle for SESSION_EVICTION_IDLE_SECONDS, and lets the count exceed this number
	 * rather than log a live device out. Two hundred covers a large merchant's real
	 * devices with room to spare, and 200 entries serialize to roughly a hundred
	 * kilobytes.
	 */
	public const MAX_SESSIONS_PER_USER = 200;

	/**
	 * How long a session must have gone unseen before eviction may remove it.
	 *
	 * The cap alone is not a safe eviction rule. A client that authenticates
	 * programmatically mints sessions far faster than a merchant does, so "the oldest of
	 * N" can be a session created minutes ago and still in use — and evicting it
	 * blacklists its access token, logging a working device out mid-request. That is
	 * exactly what happened on the shared E2E cashier after #1798 shipped a 50-session
	 * cap. A week of silence is a long time for a till: a device seen inside that window
	 * is treated as live and is never a candidate, whatever the count.
	 */
	public const SESSION_EVICTION_IDLE_SECONDS = 7 * DAY_IN_SECONDS;

	/**
	 * How stale a session's `last_active` may get before an authenticated request rewrites it.
	 *
	 * `last_active` decides what eviction may touch, so it has to reflect USE, not just
	 * token refreshes — before this, only `refresh_access_token()` moved it, and a device
	 * happily working through a 30-minute access token looked idle the whole time. Every
	 * authenticated request now refreshes it, throttled to one write per session per five
	 * minutes so the POS's request volume does not turn into a write per call.
	 */
	private const SESSION_ACTIVITY_REFRESH_SECONDS = 5 * MINUTE_IN_SECONDS;

	/**
	 * Transient prefix for the per-session "last seen" record.
	 *
	 * Activity is recorded OUTSIDE the session row on purpose. Writing it into the row
	 * meant every authenticated request did a read-modify-write of the whole
	 * `_woocommerce_pos_refresh_tokens` array, which is neither atomic nor cheap: a
	 * request overlapping a login, logout or revoke for the same user could write back a
	 * stale copy and erase the concurrent change — losing a session that had just been
	 * issued, so the new client worked until its access token expired and was then refused
	 * a refresh. Four parallel E2E shards on one cashier do exactly that. A per-session key
	 * cannot collide with another session's write, and reading it costs no row load at all.
	 */
	private const SESSION_SEEN_TRANSIENT_PREFIX = 'wcpos_session_seen_';

	/**
	 * Byte ceiling on the stored session row before it is discarded UNREAD.
	 *
	 * This is a LAST RESORT for a row no longer safe to load, not a tidy-up threshold —
	 * discarding it signs every one of that user's devices out at once. The bar is set
	 * from measurement rather than caution: a 9,216,730-byte row (17,000 sessions) read
	 * fine under the 128 MB limit that produced the #1776 fatal — `get_user_meta()` cost
	 * ~26 MB to fetch and ~38 MB with the unserialize, and it was the WRITE-BACK, at ~42
	 * MB more, that exhausted the request. Six megabytes therefore sits below anything
	 * measured to be unreadable while still catching a row heading for that fatal. The
	 * first release of this guard used one megabyte, which is comfortably readable and
	 * threw away rows that eviction could simply have trimmed.
	 */
	public const MAX_SESSIONS_ROW_BYTES = 6291456;

	/**
	 * The single instance of the class.
	 *
	 * @var null|Auth
	 */
	private static $instance = null;

	/**
	 * Constructor is private to prevent direct instantiation.
	 * Or Auth::instance() instead.
	 */
	public function __construct() {
	}

	/**
	 * Gets the singleton instance.
	 *
	 * @return Auth
	 */
	public static function instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	/**
	 * Extract a WCPOS token from an authorization value.
	 *
	 * @param mixed $auth_value Authorization value.
	 *
	 * @return null|string
	 */
	public function extract_token( $auth_value ): ?string {
		if ( ! \is_string( $auth_value ) || '' === $auth_value ) {
			return null;
		}

		// Match the old sscanf( 'Bearer %s' ) semantics exactly: any run of
		// whitespace after the scheme, token = the next non-whitespace run.
		if ( 1 === preg_match( '/^Bearer\s+(\S+)/', $auth_value, $matches ) ) {
			return $matches[1];
		}

		return 1 === preg_match( '/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $auth_value ) ? $auth_value : null;
	}

	/**
	 * Authenticate the current request from its WCPOS token.
	 *
	 * @return false|int|WP_Error User ID, validation error, or false when no WCPOS token is present.
	 */
	public function authenticate_request() {
		$auth_header = $this->get_auth_header();
		$token       = $this->extract_token( $auth_header );
		if ( null === $token ) {
			return false;
		}

		$decoded_token = $this->validate_token( $token );
		if ( is_wp_error( $decoded_token ) ) {
			return $decoded_token;
		}

		return absint( $decoded_token->data->user->id );
	}

	/**
	 * Get authorization header/param value.
	 *
	 * Checks multiple sources for the authorization token:
	 * 1. HTTP_AUTHORIZATION server variable (standard)
	 * 2. REDIRECT_HTTP_AUTHORIZATION (Apache CGI workaround)
	 * 3. authorization query parameter (for servers that strip auth headers)
	 *
	 * @return false|string The authorization value or false if not found.
	 */
	public function get_auth_header() {
		// Check HTTP_AUTHORIZATION (not empty - htaccess SetEnvIf can set empty value).
		if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
			return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
		}

		// Check REDIRECT_HTTP_AUTHORIZATION (Apache CGI).
		if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
			return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
		}

		// Check authorization query param.
		if ( ! empty( $_GET['authorization'] ) ) {
			return sanitize_text_field( wp_unslash( $_GET['authorization'] ) );
		}

		return false;
	}

	/**
	 * Generate a secret key if it doesn't exist, or return the existing one.
	 *
	 * @return string
	 */
	public function get_secret_key(): string {
		$secret_key = get_option( 'woocommerce_pos_secret_key' );
		if ( false === $secret_key || empty( $secret_key ) ) {
			$secret_key = wp_generate_password( 64, true, true );
			update_option( 'woocommerce_pos_secret_key', $secret_key );
		}

		return $secret_key;
	}

	/**
	 * Get refresh token secret key (separate from access token key for security).
	 *
	 * @return string
	 */
	public function get_refresh_secret_key(): string {
		$secret_key = get_option( 'woocommerce_pos_refresh_secret_key' );
		if ( false === $secret_key || empty( $secret_key ) ) {
			$secret_key = wp_generate_password( 64, true, true );
			update_option( 'woocommerce_pos_refresh_secret_key', $secret_key );
		}

		return $secret_key;
	}

	/**
	 * Validate the provided JWT token.
	 *
	 * @param string $token      The JWT token.
	 * @param string $token_type The token type: 'access' or 'refresh'.
	 *
	 * @return object|WP_Error
	 */
	public function validate_token( $token = '', $token_type = 'access' ) {
		try {
			$secret_key    = 'refresh' === $token_type ? $this->get_refresh_secret_key() : $this->get_secret_key();
			$decoded_token = JWT::decode( $token, new Key( $secret_key, 'HS256' ) ); // @phpstan-ignore-line

			// The Token is decoded now validate the iss.
			if ( get_bloginfo( 'url' ) != $decoded_token->iss ) {
				// The iss do not match, return error.
				return new WP_Error(
					'woocommmerce_pos_auth_bad_iss',
					'The iss do not match with this server',
					array( 'status' => 403 )
				);
			}

			// Validate token type.
			if ( ! isset( $decoded_token->type ) || $decoded_token->type !== $token_type ) {
				return new WP_Error(
					'woocommmerce_pos_auth_invalid_token_type',
					'Invalid token type',
					array( 'status' => 403 )
				);
			}

			// So far so good, validate the user id in the token.
			if ( ! isset( $decoded_token->data->user->id ) ) {
				// No user id in the token, abort!!
				return new WP_Error(
					'woocommmerce_pos_auth_bad_request',
					'User ID not found in the token',
					array(
						'status' => 403,
					)
				);
			}

			// Check if access token is blacklisted (for instant revocation)
			// We check both the access token's own JTI and its parent refresh_jti.
			if ( 'access' === $token_type ) {
				// Check if this specific access token is blacklisted.
				if ( isset( $decoded_token->jti ) && $this->is_token_blacklisted( $decoded_token->jti ) ) {
					return new WP_Error(
						'woocommerce_pos_auth_token_revoked',
						'Access token has been revoked',
						array( 'status' => 403 )
					);
				}

				// Check if the parent session (refresh token) is blacklisted
				// This catches ALL access tokens for a revoked session.
				if ( isset( $decoded_token->refresh_jti ) && $this->is_token_blacklisted( $decoded_token->refresh_jti ) ) {
					return new WP_Error(
						'woocommerce_pos_auth_session_revoked',
						'Session has been revoked',
						array( 'status' => 403 )
					);
				}

				// The session is live: record that, so eviction can tell a device that is
				// working right now from one that has not been seen in a week.
				if ( isset( $decoded_token->refresh_jti ) ) {
					$this->touch_session_activity(
						absint( $decoded_token->data->user->id ),
						(string) $decoded_token->refresh_jti
					);
				}
			}

			// Everything looks good return the decoded token.
			return $decoded_token;
		} catch ( Exception $e ) {
			// Something is wrong trying to decode the token, send back the error.
			return new WP_Error(
				'woocommmerce_pos_auth_invalid_token',
				$e->getMessage(),
				array(
					'status' => 403,
				)
			);
		}
	}

	/**
	 * Generate an access token for the provided user (short-lived).
	 *
	 * @param WP_User $user        The user object.
	 * @param string  $refresh_jti Optional refresh token JTI to link access token to session.
	 *
	 * @return string|WP_Error
	 */
	public function generate_access_token( WP_User $user, string $refresh_jti = '' ) {
		$token_data = $this->generate_access_token_data( $user, $refresh_jti );

		if ( is_wp_error( $token_data ) ) {
			return $token_data;
		}

		return $token_data['token'];
	}

	/**
	 * Generate an access token and return the token metadata used by callers.
	 *
	 * @param WP_User $user        The user object.
	 * @param string  $refresh_jti Optional refresh token JTI to link access token to session.
	 *
	 * @return array|WP_Error
	 */
	private function generate_access_token_data( WP_User $user, string $refresh_jti = '' ) {
		// First thing, check the secret key if not exist return a error.
		if ( ! $this->get_secret_key() ) {
			return new WP_Error(
				'woocommerce_pos_jwt_auth_bad_config',
				__( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ),
				array(
					'status' => 403,
				)
			);
		}

		/** Valid credentials, the user exists create the according Token */
		$issued_at = time();
		$expire    = $this->get_access_token_expire( $issued_at );

		// Generate unique JTI for access token.
		$jti = wp_generate_uuid4();

		$token = array(
			'iss'  => get_bloginfo( 'url' ),
			'iat'  => $issued_at,
			'exp'  => $expire,
			'jti'  => $jti,
			'type' => 'access',
			'data' => array(
				'user' => array(
					'id' => $user->data->ID,
				),
			),
		);

		// Link to refresh token if provided.
		if ( ! empty( $refresh_jti ) ) {
			$token['refresh_jti'] = $refresh_jti;
		}

		/*
		 * Let the user modify the access token data before the sign.
		 *
		 * @param {array} $token
		 * @param {WP_User} $user
		 *
		 * @returns {array} Token
		 *
		 * @since 1.8.0
		 *
		 * @hook woocommerce_pos_jwt_access_token_before_sign
		 */
		$payload = apply_filters( 'woocommerce_pos_jwt_access_token_before_sign', $token, $user );
		$token   = JWT::encode( $payload, $this->get_secret_key(), 'HS256' );

		$expires_at        = $this->get_payload_claim( $payload, 'exp' );
		$access_jti        = $this->get_payload_claim( $payload, 'jti' );
		$linked_refresh_jti = $this->get_payload_claim( $payload, 'refresh_jti' );

		$expires_at = null === $expires_at ? $expire : (int) $expires_at;
		$access_jti = null === $access_jti ? $jti : (string) $access_jti;

		if ( null !== $linked_refresh_jti ) {
			$linked_refresh_jti = (string) $linked_refresh_jti;
			$this->store_access_token_expiry( $user->ID, $linked_refresh_jti, $expires_at );
		}

		return array(
			'token'       => $token,
			'expires_at'  => $expires_at,
			'jti'         => $access_jti,
			'refresh_jti' => $linked_refresh_jti,
		);
	}

	/**
	 * Generate a refresh token for the provided user (long-lived).
	 *
	 * @param WP_User $user The user object.
	 *
	 * @return string|WP_Error
	 */
	public function generate_refresh_token( WP_User $user ) {
		// First thing, check the secret key if not exist return a error.
		if ( ! $this->get_refresh_secret_key() ) {
			return new WP_Error(
				'woocommerce_pos_jwt_auth_bad_config',
				__( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ),
				array(
					'status' => 403,
				)
			);
		}

		/** Valid credentials, the user exists create the according Token */
		$issued_at = time();
		$expire    = $this->get_refresh_token_expire( $issued_at );

		// Generate unique JTI (JWT ID) for refresh token tracking.
		$jti = wp_generate_uuid4();

		$token = array(
			'iss'  => get_bloginfo( 'url' ),
			'iat'  => $issued_at,
			'exp'  => $expire,
			'jti'  => $jti,
			'type' => 'refresh',
			'data' => array(
				'user' => array(
					'id' => $user->data->ID,
				),
			),
		);

		/**
		 * Let the user modify the refresh token data before the sign.
		 *
		 * @param array $token
		 * @param WP_User $user
		 *
		 * @returns array Token
		 *
		 * @since 1.8.0
		 *
		 * @hook woocommerce_pos_jwt_refresh_token_before_sign
		 */
		$token = JWT::encode( apply_filters( 'woocommerce_pos_jwt_refresh_token_before_sign', $token, $user ), $this->get_refresh_secret_key(), 'HS256' );

		// Store refresh token JTI for potential revocation.
		$this->store_refresh_token_jti( $user->ID, $jti, $expire );

		return $token;
	}

	/**
	 * Generate both access and refresh tokens.
	 *
	 * @param WP_User $user The user object.
	 *
	 * @return array|WP_Error
	 */
	public function generate_token_pair( WP_User $user ) {
		// Generate refresh token first to get its JTI.
		$refresh_token = $this->generate_refresh_token( $user );
		if ( is_wp_error( $refresh_token ) ) {
			return $refresh_token;
		}

		// Decode to get the JTI.
		$decoded_refresh = $this->validate_token( $refresh_token, 'refresh' );
		if ( is_wp_error( $decoded_refresh ) ) {
			return $decoded_refresh;
		}

		// Generate access token with link to refresh token.
		$access_token_data = $this->generate_access_token_data( $user, $decoded_refresh->jti ?? '' );
		if ( is_wp_error( $access_token_data ) ) {
			return $access_token_data;
		}

		return array(
			'access_token'  => $access_token_data['token'],
			'refresh_token' => $refresh_token,
			'token_type'    => 'Bearer',
			'expires_at'    => (int) $access_token_data['expires_at'],
		);
	}

	/**
	 * Legacy method for backward compatibility.
	 *
	 * @deprecated Use generate_access_token() instead
	 *
	 * @param WP_User $user The user object.
	 *
	 * @return string|WP_Error
	 */
	public function generate_token( WP_User $user ) {
		return $this->generate_access_token( $user );
	}

	/**
	 * Get user's data (minimal set for security).
	 *
	 * @param WP_User $user The user object.
	 * @param bool    $is_web_frontend Whether this is the web frontend context.
	 *                                 When true, manages web session cookie to prevent
	 *                                 session proliferation on page refresh.
	 *
	 * @return array
	 */
	public function get_user_data( WP_User $user, bool $is_web_frontend = false ): array {
		// For web frontend, revoke previous session to prevent proliferation on page refresh.
		if ( $is_web_frontend ) {
			$this->cleanup_previous_web_session( $user->ID );
		}

		$tokens = $this->generate_token_pair( $user );
		if ( is_wp_error( $tokens ) ) {
			return array();
		}

		// For web frontend, store the new session JTI in a cookie for cleanup on next page load.
		if ( $is_web_frontend ) {
			$this->set_web_session_cookie( $tokens['refresh_token'] );
		}

		return array(
			'uuid'         => Cashier::instance()->get_cashier_uuid( $user ),
			'id'           => $user->ID,
			'username'     => $user->user_login,
			'email'        => $user->user_email,
			'first_name'   => $user->user_firstname,
			'last_name'    => $user->user_lastname,
			'nice_name'    => $user->user_nicename,
			'display_name' => $user->display_name,
			'roles'        => array_values( $user->roles ),
			// The helper reports effective grants, including role-editor denies.
			'capabilities' => Access_Section::effective_capabilities( $user ),
			'avatar_url'   => get_avatar_url( $user->ID ),
			// Token data.
			'access_token'  => $tokens['access_token'],
			'refresh_token' => $tokens['refresh_token'],
			'token_type'    => $tokens['token_type'],
			'expires_at'    => $tokens['expires_at'],
		);
	}

	/**
	 * Get minimal user data for redirect (security-focused).
	 *
	 * @param WP_User $user The user object.
	 *
	 * @return array
	 */
	public function get_redirect_data( WP_User $user ): array {
		$tokens = $this->generate_token_pair( $user );
		if ( is_wp_error( $tokens ) ) {
			return array();
		}

		// Only return essential data for redirect URL.
		return array(
			'access_token'  => $tokens['access_token'],
			'refresh_token' => $tokens['refresh_token'],
			'token_type'    => $tokens['token_type'],
			'expires_at'    => $tokens['expires_at'],
			// Get basic user data for display, other data will be fetched from the server.
			'uuid'          => Cashier::instance()->get_cashier_uuid( $user ),
			'id'            => $user->ID,
			'display_name'  => $user->display_name,
		);
	}

	/**
	 * Refresh an access token using a valid refresh token.
	 *
	 * @param string $refresh_token The refresh token.
	 *
	 * @return array|WP_Error
	 */
	public function refresh_access_token( string $refresh_token ) {
		$decoded = $this->validate_token( $refresh_token, 'refresh' );
		if ( is_wp_error( $decoded ) ) {
			return $decoded;
		}

		/*
		 * Before the first row read on this path. A refresh loads the whole session row —
		 * `is_refresh_token_valid()` below, then `update_session_activity()` — so it needs
		 * the same protection a login has against a row too large to read (#1776).
		 * Validating an ACCESS token needs no such guard: it no longer touches the row.
		 */
		$this->discard_oversized_session_row( absint( $decoded->data->user->id ) );

		// Check if refresh token is still valid (not revoked).
		if ( ! $this->is_refresh_token_valid( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
			return new WP_Error(
				'woocommerce_pos_auth_refresh_token_revoked',
				'Refresh token has been revoked',
				array( 'status' => 403 )
			);
		}

		$user = get_user_by( 'id', $decoded->data->user->id );
		if ( ! $user ) {
			return new WP_Error(
				'woocommerce_pos_auth_user_not_found',
				'User not found',
				array( 'status' => 404 )
			);
		}

		// Update last_active timestamp for this session.
		$this->update_session_activity( $decoded->data->user->id, $decoded->jti ?? '' );

		// Generate new access token with link to refresh token (refresh token stays the same).
		$new_access_token_data = $this->generate_access_token_data( $user, $decoded->jti ?? '' );
		if ( is_wp_error( $new_access_token_data ) ) {
			return $new_access_token_data;
		}

		return array(
			'access_token' => $new_access_token_data['token'],
			'token_type'   => 'Bearer',
			'expires_at'   => (int) $new_access_token_data['expires_at'],
		);
	}

	/**
	 * Revoke JWT Token by JTI.
	 *
	 * @param int    $user_id The user ID.
	 * @param string $jti            The token JTI.
	 *
	 * @return bool
	 */
	public function revoke_refresh_token( int $user_id, string $jti ): bool {
		$refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
		if ( ! \is_array( $refresh_tokens ) ) {
			return false;
		}

		if ( isset( $refresh_tokens[ $jti ] ) ) {
			unset( $refresh_tokens[ $jti ] );
			update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
			$this->forget_session_activity( $jti );

			return true;
		}

		return false;
	}

	/**
	 * Revoke all refresh tokens for a user.
	 *
	 * @param int $user_id The user ID.
	 *
	 * @return bool
	 */
	/**
	 * Revoke all refresh tokens for a user with blacklisting.
	 *
	 * @param int $user_id The user ID.
	 *
	 * @return bool
	 */
	public function revoke_all_refresh_tokens( int $user_id ): bool {
		$refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );

		// Blacklist all sessions for instant access token invalidation.
		if ( \is_array( $refresh_tokens ) ) {
			$issued_at     = time();
			$access_expire = $this->get_access_token_expire( $issued_at );

			foreach ( $refresh_tokens as $jti => $token_data ) {
				$ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
				$this->blacklist_token( $jti, $ttl );
				$this->forget_session_activity( (string) $jti );
			}
		}

		return delete_user_meta( $user_id, '_woocommerce_pos_refresh_tokens' );
	}

	/**
	 * Get all active sessions for a user.
	 *
	 * @param int $user_id The user ID.
	 *
	 * @return array
	 */
	public function get_user_sessions( int $user_id ): array {
		$refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
		if ( ! \is_array( $refresh_tokens ) ) {
			return array();
		}

		$sessions     = array();
		$current_time = time();

		foreach ( $refresh_tokens as $jti => $token_data ) {
			// Skip expired sessions.
			if ( $token_data['expires'] <= $current_time ) {
				continue;
			}

			$sessions[] = array(
				'jti'         => $jti,
				'created'     => $token_data['created'] ?? $current_time,
				'last_active' => $token_data['last_active'] ?? $token_data['created'] ?? $current_time,
				'expires'     => $token_data['expires'],
				'ip_address'  => $token_data['ip_address'] ?? '',
				'user_agent'  => $token_data['user_agent'] ?? '',
				'device_info' => $token_data['device_info'] ?? array(),
			);
		}

		// Sort by last_active descending (most recent first).
		usort(
			$sessions,
			function ( $a, $b ) {
				return $b['last_active'] - $a['last_active'];
			}
		);

		return $sessions;
	}

	/**
	 * Revoke a specific session by JTI (alias for revoke_refresh_token for clarity).
	 *
	 * @param int    $user_id The user ID.
	 * @param string $jti            The token JTI.
	 *
	 * @return bool
	 */
	public function revoke_session( int $user_id, string $jti ): bool {
		return $this->revoke_refresh_token( $user_id, $jti );
	}

	/**
	 * Revoke all sessions except the current one.
	 *
	 * @param int    $user_id The user ID.
	 * @param string $current_jti The current token JTI.
	 *
	 * @return bool
	 */
	/**
	 * Revoke all sessions except the current one, with blacklisting.
	 *
	 * @param int    $user_id The user ID.
	 * @param string $current_jti The current token JTI.
	 *
	 * @return bool
	 */
	public function revoke_all_sessions_except( int $user_id, string $current_jti ): bool {
		$refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
		if ( ! \is_array( $refresh_tokens ) ) {
			return false;
		}

		// Blacklist all sessions except current for instant access token invalidation.
		$issued_at     = time();
		$access_expire = $this->get_access_token_expire( $issued_at );

		foreach ( $refresh_tokens as $jti => $token_data ) {
			if ( $jti !== $current_jti ) {
				$ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
				$this->blacklist_token( $jti, $ttl );
				$this->forget_session_activity( (string) $jti );
			}
		}

		// Keep only the current session in user meta.
		$refresh_tokens = array_filter(
			$refresh_tokens,
			function ( $_token, $jti ) use ( $current_jti ) {
				return $jti === $current_jti;
			},
			ARRAY_FILTER_USE_BOTH
		);

		return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
	}

	/**
	 * Update last_active timestamp for a session.
	 *
	 * @param int    $user_id The user ID.
	 * @param string $jti            The token JTI.
	 *
	 * @return bool
	 */
	public function update_session_activity( int $user_id, string $jti ): bool {
		// Public surface: any caller reaching the row goes through the size guard first.
		$this->discard_oversized_session_row( $user_id );

		$refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
		if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $jti ] ) ) {
			return false;
		}

		$refresh_tokens[ $jti ]['last_active'] = time();

		return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
	}

	/**
	 * Refresh a session's `last_active`, at most once every few minutes.
	 *
	 * Called from token validation, so it runs on EVERY authenticated request. The
	 * throttle is what makes that affordable: the value only has to be accurate to within
	 * minutes for a rule that asks whether a session has been unseen for a week, and the
	 * read is already in the user's meta cache by this point.
	 *
	 * @param int    $user_id The user ID.
	 * @param string $jti     Refresh token JTI (session identifier).
	 */
	private function touch_session_activity( int $user_id, string $jti ): void {
		if ( 0 === $user_id || '' === $jti ) {
			return;
		}

		$key  = self::SESSION_SEEN_TRANSIENT_PREFIX . $jti;
		$seen = get_transient( $key );

		// The throttle reads the transient, never the session row: this runs on every
		// authenticated request, and the row is the one thing this path must not touch.
		if ( is_numeric( $seen ) && time() - (int) $seen < self::SESSION_ACTIVITY_REFRESH_SECONDS ) {
			return;
		}

		// The TTL IS the idle window, so a missing transient means "not seen in a week".
		set_transient( $key, time(), self::SESSION_EVICTION_IDLE_SECONDS );
	}

	/**
	 * Forget a session's recorded activity.
	 *
	 * @param string $jti Refresh token JTI (session identifier).
	 */
	private function forget_session_activity( string $jti ): void {
		if ( '' !== $jti ) {
			delete_transient( self::SESSION_SEEN_TRANSIENT_PREFIX . $jti );
		}
	}

	/**
	 * Check if the current user can manage sessions for the target user.
	 *
	 * @param int $target_user_id The target user ID.
	 *
	 * @return bool
	 */
	public function can_manage_user_sessions( int $target_user_id ): bool {
		$current_user_id = get_current_user_id();

		// User can manage their own sessions.
		if ( $current_user_id === $target_user_id ) {
			return true;
		}

		// Administrators can manage anyone's sessions.
		if ( current_user_can( 'manage_options' ) ) {
			return true;
		}

		// Shop managers can manage anyone's sessions.
		if ( current_user_can( 'manage_woocommerce' ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Blacklist a token JTI (for instant revocation).
	 *
	 * Can be used for access token JTIs or refresh token JTIs (session).
	 * When a refresh_jti is blacklisted, all access tokens linked to it
	 * become invalid.
	 *
	 * @param string $jti Token JTI to blacklist.
	 * @param int    $ttl Time to live in seconds.
	 *
	 * @return bool
	 */
	public function blacklist_token( string $jti, int $ttl ): bool {
		if ( empty( $jti ) ) {
			return false;
		}

		// Use transient with TTL matching token expiration.
		return set_transient( "wcpos_blacklist_{$jti}", true, $ttl );
	}

	/**
	 * Revoke session and blacklist it for instant access token invalidation.
	 *
	 * By blacklisting the refresh_jti, ALL access tokens linked to this session
	 * become immediately invalid (they contain refresh_jti in their payload).
	 *
	 * @param int    $user_id The user ID.
	 * @param string $refresh_jti Refresh token JTI (session identifier).
	 *
	 * @return bool
	 */
	public function revoke_session_with_blacklist( int $user_id, string $refresh_jti ): bool {
		$refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
		$session_data   = \is_array( $refresh_tokens ) && isset( $refresh_tokens[ $refresh_jti ] ) ? $refresh_tokens[ $refresh_jti ] : array();
		$ttl            = $this->get_access_token_blacklist_ttl( $session_data );

		// Revoke the refresh token (session) from user meta.
		$revoked = $this->revoke_session( $user_id, $refresh_jti );

		if ( $revoked ) {
			// Blacklist the session JTI - this invalidates ALL access tokens for this session
			// TTL covers the current policy and any access token expiry recorded for the session.
			$this->blacklist_token( $refresh_jti, $ttl );
		}

		return $revoked;
	}

	/**
	 * Store refresh token JTI for tracking/revocation.
	 *
	 * @param int                  $user_id The user ID.
	 * @param string               $jti            The token JTI.
	 * @param int                  $expires The expiration timestamp.
	 * @param null|Session_Context $context Request state the session is recorded
	 *                                      against. Defaults to the current request.
	 */
	private function store_refresh_token_jti( int $user_id, string $jti, int $expires, ?Session_Context $context = null ): void {
		$context = null === $context ? Session_Context::from_request() : $context;

		// BEFORE the read: a pre-cap row can be too large to load, and this is the first
		// point in the login flow where WCPOS knows the user id.
		$this->discard_oversized_session_row( $user_id );

		$refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
		if ( ! \is_array( $refresh_tokens ) ) {
			$refresh_tokens = array();
		}

		// Clean up expired tokens.
		$refresh_tokens = array_filter(
			$refresh_tokens,
			function ( $token ) {
				return $token['expires'] > time();
			}
		);

		// Capture session metadata.
		$current_time = time();
		$ip_address   = $context->get_ip();
		$user_agent   = $context->get_user_agent();
		$device_info  = $this->parse_user_agent( $user_agent );

		// Check for explicit platform declaration from native apps (passed as a param in the auth request).
		$platform = $context->get_platform();
		$version  = $context->get_version();
		$build    = $context->get_build();

		// Override app_type if platform was explicitly provided by the client.
		if ( \in_array( $platform, array( 'ios', 'android', 'electron', 'web' ), true ) ) {
			$device_info['app_type'] = 'web' === $platform ? 'web' : $platform . '_app';

			// Set appropriate device type based on platform.
			if ( 'ios' === $platform || 'android' === $platform ) {
				$device_info['device_type'] = 'tablet'; // Default to tablet for mobile apps.
			} elseif ( 'electron' === $platform ) {
				$device_info['device_type'] = 'desktop';
			}

			// Use version from param if provided.
			if ( ! empty( $version ) ) {
				$device_info['browser_version'] = $version;
			}

			// Store build number if provided.
			if ( ! empty( $build ) ) {
				$device_info['build'] = $build;
			}

			// Set browser to WooCommerce POS for native apps.
			if ( 'web' !== $platform ) {
				$device_info['browser'] = 'WooCommerce POS';
			}
		}

		// Add new token with metadata.
		$refresh_tokens[ $jti ] = array(
			'expires'     => $expires,
			'created'     => $current_time,
			'last_active' => $current_time,
			'ip_address'  => $ip_address,
			'user_agent'  => $user_agent,
			'device_info' => $device_info,
		);

		// Cap the number of stored sessions so programmatic clients cannot grow the row without bound.
		$refresh_tokens = $this->evict_oldest_sessions( $refresh_tokens, $jti );

		update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
	}

	/**
	 * Drop the least recently active sessions until the per-user cap is met.
	 *
	 * Evicted sessions are blacklisted the same way revoke_all_sessions_except() does, so the
	 * device that lost its slot is cleanly logged out instead of keeping a working access token
	 * for the remainder of that token's life.
	 *
	 * @param array  $refresh_tokens Stored sessions keyed by refresh token JTI.
	 * @param string $protected_jti  JTI that must never be evicted (the session being stored).
	 *
	 * @return array The sessions to persist.
	 */
	private function evict_oldest_sessions( array $refresh_tokens, string $protected_jti ): array {
		$evict_count = \count( $refresh_tokens ) - self::MAX_SESSIONS_PER_USER;
		if ( $evict_count <= 0 ) {
			return $refresh_tokens;
		}

		$issued_at = time();
		$idle_before = $issued_at - self::SESSION_EVICTION_IDLE_SECONDS;

		/*
		 * Order eviction candidates oldest-first. The insertion index breaks ties explicitly
		 * because usort() is not stable before PHP 8.0 and bulk logins share a timestamp.
		 *
		 * A session seen within SESSION_EVICTION_IDLE_SECONDS is NOT a candidate at any
		 * count. Being the oldest of N says nothing about being unused when N sessions were
		 * minted in an hour, and evicting a live one blacklists a working device's access
		 * token. The cap yields to that: a user whose sessions are all recent keeps them
		 * all, and the row stays bounded by MAX_SESSIONS_ROW_BYTES instead.
		 */
		$candidates = array();
		$index      = 0;
		foreach ( $refresh_tokens as $candidate_jti => $token_data ) {
			$position = $index++;
			if ( (string) $candidate_jti === $protected_jti ) {
				continue;
			}

			// The ROW timestamp is the cheap filter. It is authoritative when it says a
			// session is live, because login and refresh both write it; when it says idle
			// the activity transient still gets the final word, below.
			$activity = $this->session_row_last_seen( $token_data );
			if ( $activity > $idle_before ) {
				continue;
			}

			$candidates[] = array(
				'jti'      => (string) $candidate_jti,
				'activity' => $activity,
				'index'    => $position,
			);
		}

		usort(
			$candidates,
			function ( $a, $b ) {
				if ( $a['activity'] === $b['activity'] ) {
					return $a['index'] <=> $b['index'];
				}

				return $a['activity'] <=> $b['activity'];
			}
		);

		foreach ( $candidates as $candidate ) {
			if ( $evict_count <= 0 ) {
				break;
			}

			// Checked only for rows already stale, so this costs a handful of transient
			// reads rather than one per stored session.
			if ( $this->session_last_seen( $candidate['jti'], $refresh_tokens[ $candidate['jti'] ] ) > $idle_before ) {
				continue;
			}

			/*
			 * Blacklist ONLY a session that can still hold a live access token. An eviction
			 * is not a revoke: clearing a bloated row can drop thousands of long-dead
			 * sessions at once, and a transient for each would guard nothing — an expired
			 * access token is already rejected on its own `exp` claim, and the refresh token
			 * dies with the meta entry (`is_refresh_token_valid()` requires the entry). This
			 * also bounds each transient this path writes to one access-token lifetime,
			 * rather than the refresh-token expiry `get_access_token_blacklist_ttl()` falls
			 * back to for a session with no recorded access-token expiry.
			 */
			$horizon = $this->access_token_horizon( $refresh_tokens[ $candidate['jti'] ] );
			if ( $horizon > $issued_at ) {
				$this->blacklist_token( $candidate['jti'], $horizon - $issued_at );
			}

			$this->forget_session_activity( $candidate['jti'] );
			unset( $refresh_tokens[ $candidate['jti'] ] );
			--$evict_count;
		}

		return $refresh_tokens;
	}

	/**
	 * When a session was last seen, taking the later of the row and the activity record.
	 *
	 * The row is rewritten by login and refresh; the transient is written by ordinary
	 * authenticated requests. Neither alone is the whole picture — a device working through
	 * a long-lived access token has an old row timestamp and a fresh transient, and a
	 * session that has not been used at all has the reverse.
	 *
	 * @param string $jti        Refresh token JTI (session identifier).
	 * @param array  $token_data Stored session record.
	 *
	 * @return int Unix timestamp; 0 when neither source carries a usable timestamp.
	 */
	private function session_last_seen( string $jti, array $token_data ): int {
		$row_seen = $this->session_row_last_seen( $token_data );
		$seen     = '' === $jti ? false : get_transient( self::SESSION_SEEN_TRANSIENT_PREFIX . $jti );

		return is_numeric( $seen ) ? max( $row_seen, (int) $seen ) : $row_seen;
	}

	/**
	 * When the stored record itself says a session was last seen.
	 *
	 * Login and refresh both rewrite `last_active` in the row, so this stays accurate for
	 * everything except the stretch between refreshes — which is what the activity
	 * transient covers.
	 *
	 * @param array $token_data Stored session record.
	 *
	 * @return int Unix timestamp; 0 when the record carries no usable timestamp.
	 */
	private function session_row_last_seen( array $token_data ): int {
		if ( isset( $token_data['last_active'] ) ) {
			return (int) $token_data['last_active'];
		}

		if ( isset( $token_data['created'] ) ) {
			return (int) $token_data['created'];
		}

		return 0;
	}

	/**
	 * The last moment an access token minted against a session can still validate.
	 *
	 * @param array $token_data Stored session record.
	 *
	 * @return int Unix timestamp; 0 when the session carries no usable timestamp at all.
	 */
	private function access_token_horizon( array $token_data ): int {
		if ( isset( $token_data['access_expires'] ) ) {
			return (int) $token_data['access_expires'];
		}

		// Rows written before `access_expires` was recorded. The newest access token such a
		// session can hold was minted no later than its last recorded activity, so one
		// access-token lifetime past that moment is the outside limit.
		$last_seen = $this->session_row_last_seen( $token_data );

		return $last_seen > 0 ? $this->get_access_token_expire( $last_seen ) : 0;
	}

	/**
	 * Drop the stored session row when it is too large to be read safely.
	 *
	 * A LAST RESORT, not a tidy-up: discarding the row signs every one of that user's
	 * devices out at once, so the ceiling is set above anything measured to be readable
	 * (see MAX_SESSIONS_ROW_BYTES) and everything below it is TRIMMED by
	 * `evict_oldest_sessions()` on the same write instead. What this catches is the one
	 * case trimming cannot: a row so large that reading it exhausts the request before any
	 * of the code below runs, which — because that read happens on every login — locks the
	 * user out permanently (#1776). `LENGTH()` lets MySQL answer with a number instead of
	 * the value, so the size is checked without paying for the row.
	 *
	 * @param int $user_id The user ID.
	 */
	private function discard_oversized_session_row( int $user_id ): void {
		global $wpdb;

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT umeta_id, LENGTH(meta_value) AS meta_bytes FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s",
				$user_id,
				'_woocommerce_pos_refresh_tokens'
			)
		);

		if ( empty( $rows ) ) {
			return;
		}

		$bytes = 0;
		foreach ( $rows as $row ) {
			$bytes += (int) $row->meta_bytes;
		}

		if ( $bytes <= self::MAX_SESSIONS_ROW_BYTES ) {
			return;
		}

		foreach ( $rows as $row ) {
			$wpdb->delete( $wpdb->usermeta, array( 'umeta_id' => (int) $row->umeta_id ), array( '%d' ) );
		}

		// The row may already be sitting in the user's meta cache from an earlier
		// `get_user_meta()` in this request; without this the next read serves the value
		// that was just deleted.
		wp_cache_delete( $user_id, 'user_meta' );

		Logger::warning(
			sprintf(
				'Discarded an unreadable WCPOS session row for user %d (%d bytes, ceiling %d). The row was too large to load safely, so every POS session for this user has been logged out once; it is rebuilt, capped, on this login.',
				$user_id,
				$bytes,
				self::MAX_SESSIONS_ROW_BYTES
			)
		);
	}

	/**
	 * Filters the JWT access token expire time.
	 * Default: 30 minutes for access tokens.
	 *
	 * @param int $issued_at Token issued timestamp.
	 *
	 * @return int Expire time.
	 *
	 * @since 1.8.0
	 *
	 * @hook woocommerce_pos_jwt_access_token_expire
	 */
	private function get_access_token_expire( int $issued_at ): int {
		return (int) apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
	}

	/**
	 * Filters the JWT refresh token expire time.
	 * Default: 30 days for refresh tokens.
	 *
	 * @param int $issued_at Token issued timestamp.
	 *
	 * @return int Expire time.
	 *
	 * @since 1.8.0
	 *
	 * @hook woocommerce_pos_jwt_refresh_token_expire
	 */
	private function get_refresh_token_expire( int $issued_at ): int {
		return (int) apply_filters( 'woocommerce_pos_jwt_refresh_token_expire', $issued_at + ( DAY_IN_SECONDS * 30 ), $issued_at );
	}

	/**
	 * Read a top-level claim from a JWT payload array/object.
	 *
	 * @param mixed  $payload The filtered JWT payload.
	 * @param string $claim   The claim name.
	 *
	 * @return mixed|null
	 */
	private function get_payload_claim( $payload, string $claim ) {
		if ( \is_array( $payload ) && array_key_exists( $claim, $payload ) ) {
			return $payload[ $claim ];
		}

		if ( \is_object( $payload ) && isset( $payload->{$claim} ) ) {
			return $payload->{$claim};
		}

		return null;
	}

	/**
	 * Record the latest access token expiry linked to a refresh-token session.
	 *
	 * @param int    $user_id        The user ID.
	 * @param string $refresh_jti    Refresh token JTI.
	 * @param int    $access_expires Access token expiry timestamp.
	 *
	 * @return bool
	 */
	private function store_access_token_expiry( int $user_id, string $refresh_jti, int $access_expires ): bool {
		if ( empty( $refresh_jti ) || $access_expires <= 0 ) {
			return false;
		}

		$refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
		if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $refresh_jti ] ) ) {
			return false;
		}

		$current_access_expires = isset( $refresh_tokens[ $refresh_jti ]['access_expires'] ) ? (int) $refresh_tokens[ $refresh_jti ]['access_expires'] : 0;
		if ( $access_expires <= $current_access_expires ) {
			return true;
		}

		$refresh_tokens[ $refresh_jti ]['access_expires'] = $access_expires;

		return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
	}

	/**
	 * Calculate blacklist TTL for a session.
	 *
	 * @param array    $session_data  Session metadata.
	 * @param null|int $issued_at     Current timestamp.
	 * @param null|int $access_expire Current access token expiry policy value.
	 *
	 * @return int
	 */
	private function get_access_token_blacklist_ttl(
		array $session_data = array(),
		?int $issued_at = null,
		?int $access_expire = null
	): int {
		$issued_at     = null === $issued_at ? time() : $issued_at;
		$access_expire = null === $access_expire ? $this->get_access_token_expire( $issued_at ) : $access_expire;

		if ( isset( $session_data['access_expires'] ) ) {
			$access_expire = max( $access_expire, (int) $session_data['access_expires'] );
		} elseif ( isset( $session_data['expires'] ) ) {
			$access_expire = max( $access_expire, (int) $session_data['expires'] );
		}

		return max( 0, $access_expire - $issued_at );
	}

	/**
	 * Check if refresh token is still valid (not revoked).
	 *
	 * @param int    $user_id The user ID.
	 * @param string $jti            The token JTI.
	 *
	 * @return bool
	 */
	private function is_refresh_token_valid( int $user_id, string $jti ): bool {
		$refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
		if ( ! \is_array( $refresh_tokens ) ) {
			return false;
		}

		return isset( $refresh_tokens[ $jti ] ) && $refresh_tokens[ $jti ]['expires'] > time();
	}

	/**
	 * Parse user agent string to extract device information.
	 *
	 * @param string $user_agent The user agent string.
	 *
	 * @return array
	 */
	private function parse_user_agent( string $user_agent ): array {
		$device_info = array(
			'device_type'     => 'unknown',
			'browser'         => 'unknown',
			'browser_version' => '',
			'os'              => 'unknown',
			'app_type'        => 'web', // web, ios_app, android_app, electron_app.
		);

		if ( empty( $user_agent ) ) {
			return $device_info;
		}

		// Detect WooCommerce POS apps first (custom identifiers)
		// Check for Electron app (including just "WooCommercePOS" in user agent with Electron).
		if ( preg_match( '/Electron/i', $user_agent ) && preg_match( '/WooCommercePOS|WCPOS/i', $user_agent ) ) {
			$device_info['app_type']    = 'electron_app';
			$device_info['browser']     = 'WooCommerce POS';
			$device_info['device_type'] = 'desktop';
			// Try to extract WooCommercePOS version.
			if ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
				$device_info['browser_version'] = $matches[1];
			} elseif ( preg_match( '/WCPOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
				$device_info['browser_version'] = $matches[1];
			}
		} elseif ( preg_match( '/WCPOS[-_]?iOS|WooCommercePOS[-_]?iOS/i', $user_agent ) ) {
			$device_info['app_type']     = 'ios_app';
			$device_info['browser']      = 'WooCommerce POS';
			// Default to tablet unless explicitly detected as phone.
			$device_info['device_type']  = preg_match( '/iphone|ipod/i', $user_agent ) ? 'mobile' : 'tablet';
			if ( preg_match( '/WCPOS[-_]?iOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
				$device_info['browser_version'] = $matches[1];
			} elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
				$device_info['browser_version'] = $matches[1];
			}
		} elseif ( preg_match( '/WCPOS[-_]?Android|WooCommercePOS[-_]?Android/i', $user_agent ) ) {
			$device_info['app_type']     = 'android_app';
			$device_info['browser']      = 'WooCommerce POS';
			// Default to tablet unless explicitly detected as mobile.
			$device_info['device_type']  = preg_match( '/mobile/i', $user_agent ) && ! preg_match( '/tablet/i', $user_agent ) ? 'mobile' : 'tablet';
			if ( preg_match( '/WCPOS[-_]?Android[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
				$device_info['browser_version'] = $matches[1];
			} elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
				$device_info['browser_version'] = $matches[1];
			}
		}

		// Detect standard device type (if not already set by app detection).
		if ( 'web' === $device_info['app_type'] ) {
			if ( preg_match( '/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i', $user_agent ) ) {
				$device_info['device_type'] = 'mobile';
			} elseif ( preg_match( '/tablet|ipad|playbook|silk/i', $user_agent ) ) {
				$device_info['device_type'] = 'tablet';
			} else {
				$device_info['device_type'] = 'desktop';
			}
		}

		// Detect browser (skip if we already detected a WCPOS app).
		if ( 'WooCommerce POS' !== $device_info['browser'] ) {
			if ( preg_match( '/MSIE|Trident/i', $user_agent ) ) {
				$device_info['browser'] = 'Internet Explorer';
				if ( preg_match( '/MSIE ([0-9.]+)/', $user_agent, $matches ) ) {
					$device_info['browser_version'] = $matches[1];
				}
			} elseif ( preg_match( '/Edge\/([0-9.]+)/i', $user_agent, $matches ) ) {
				$device_info['browser']         = 'Edge';
				$device_info['browser_version'] = $matches[1];
			} elseif ( preg_match( '/Edg\/([0-9.]+)/i', $user_agent, $matches ) ) {
				$device_info['browser']         = 'Edge';
				$device_info['browser_version'] = $matches[1];
			} elseif ( preg_match( '/Firefox\/([0-9.]+)/i', $user_agent, $matches ) ) {
				$device_info['browser']         = 'Firefox';
				$device_info['browser_version'] = $matches[1];
			} elseif ( preg_match( '/Chrome\/([0-9.]+)/i', $user_agent, $matches ) ) {
				$device_info['browser']         = 'Chrome';
				$device_info['browser_version'] = $matches[1];
			} elseif ( preg_match( '/Safari\/([0-9.]+)/i', $user_agent, $matches ) ) {
				// Safari should be checked after Chrome because Chrome also contains Safari.
				if ( ! preg_match( '/Chrome/i', $user_agent ) ) {
					$device_info['browser']         = 'Safari';
					$device_info['browser_version'] = $matches[1];
				}
			} elseif ( preg_match( '/Opera\/([0-9.]+)/i', $user_agent, $matches ) ) {
				$device_info['browser']         = 'Opera';
				$device_info['browser_version'] = $matches[1];
			}
		}

		// Detect OS.
		if ( preg_match( '/Windows NT ([0-9.]+)/i', $user_agent, $matches ) ) {
			$device_info['os'] = 'Windows';
		} elseif ( preg_match( '/Mac OS X ([0-9_]+)/i', $user_agent, $matches ) ) {
			$device_info['os'] = 'macOS';
		} elseif ( preg_match( '/Android ([0-9.]+)/i', $user_agent, $matches ) ) {
			$device_info['os'] = 'Android';
		} elseif ( preg_match( '/iPhone OS ([0-9_]+)/i', $user_agent, $matches ) ) {
			$device_info['os'] = 'iOS';
		} elseif ( preg_match( '/iPad.*OS ([0-9_]+)/i', $user_agent, $matches ) ) {
			$device_info['os'] = 'iPadOS';
		} elseif ( preg_match( '/Linux/i', $user_agent ) ) {
			$device_info['os'] = 'Linux';
		}

		return $device_info;
	}

	/**
	 * Check if a token JTI is blacklisted.
	 *
	 * Works for both access token JTIs and refresh token JTIs (sessions).
	 *
	 * @param string $jti Token JTI to check.
	 *
	 * @return bool
	 */
	private function is_token_blacklisted( string $jti ): bool {
		if ( empty( $jti ) ) {
			return false;
		}

		// Check transient.
		return false !== get_transient( "wcpos_blacklist_{$jti}" );
	}

	/**
	 * Clean up previous web session to prevent session proliferation.
	 *
	 * The web application generates new tokens on every page load. This method
	 * revokes the previous session (stored in a cookie) so only one web session
	 * exists per browser at a time.
	 *
	 * @param int $user_id The user ID.
	 */
	private function cleanup_previous_web_session( int $user_id ): void {
		$cookie_name = 'wcpos_web_session_jti';

		if ( ! isset( $_COOKIE[ $cookie_name ] ) ) {
			return;
		}

		$previous_jti = sanitize_text_field( wp_unslash( $_COOKIE[ $cookie_name ] ) );

		if ( empty( $previous_jti ) ) {
			return;
		}

		// Revoke the previous session (silently - don't care if it fails).
		$this->revoke_session( $user_id, $previous_jti );
	}

	/**
	 * Set a cookie to track the current web session JTI.
	 *
	 * @param string $refresh_token The refresh token to extract JTI from.
	 */
	private function set_web_session_cookie( string $refresh_token ): void {
		$decoded = $this->validate_token( $refresh_token, 'refresh' );

		if ( is_wp_error( $decoded ) || empty( $decoded->jti ) ) {
			return;
		}

		$cookie_name = 'wcpos_web_session_jti';
		$jti         = $decoded->jti;
		$expires     = $decoded->exp ?? ( time() + DAY_IN_SECONDS * 30 );

		// Set cookie with same expiry as refresh token
		// Use httponly for security, but not secure flag as POS may run on localhost.
		setcookie(
			$cookie_name,
			$jti,
			array(
				'expires'  => $expires,
				'path'     => \defined( 'COOKIEPATH' ) ? COOKIEPATH : '/', // @phpstan-ignore-line
				'domain'   => \defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '', // @phpstan-ignore-line
				'secure'   => is_ssl(),
				'httponly' => true,
				'samesite' => 'Lax',
			)
		);
	}
}

```
