PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
← All changes | includes/Services/Auth.php +69 -627 1.10.131.10.19 View file →
@@ -9,15 +9,13 @@
9 9
10 10 use Exception;
11 11 use WCPOS\Vendor\Firebase\JWT\JWT;
12 12 use WCPOS\Vendor\Firebase\JWT\Key;
13 -use WCPOS\WooCommercePOS\Logger;
14 13 use WCPOS\WooCommercePOS\Services\Settings\Access_Section;
15 14 use WP_Error;
16 15 use WP_User;
17 16 use const DAY_IN_SECONDS;
18 17 use const HOUR_IN_SECONDS;
19 -use const MINUTE_IN_SECONDS;
20 18
21 19 /**
22 20 * Auth Service class.
23 21 */
@@ -22,88 +20,57 @@
22 20 * Auth Service class.
23 21 */
24 22 class Auth {
25 23 /**
26 - * Maximum number of refresh-token sessions retained per user.
24 + * Maximum retained idle sessions.
27 25 *
28 - * Refresh tokens live for weeks and every entry carries a user agent plus parsed
29 - * device info, so without a cap the `_woocommerce_pos_refresh_tokens` row grows until
30 - * `get_user_meta()` can no longer unserialize it inside the PHP memory limit.
31 - *
32 - * This is a ceiling on ACCUMULATED CLUTTER, never a limit on how many devices may be
33 - * signed in at once: `evict_oldest_sessions()` only ever removes sessions that have
34 - * been idle for SESSION_EVICTION_IDLE_SECONDS, and lets the count exceed this number
35 - * rather than log a live device out. Two hundred covers a large merchant's real
36 - * devices with room to spare, and 200 entries serialize to roughly a hundred
37 - * kilobytes.
26 + * @deprecated Use Session_Registry::MAX_SESSIONS_PER_USER.
38 27 */
39 - public const MAX_SESSIONS_PER_USER = 200;
28 + public const MAX_SESSIONS_PER_USER = Session_Registry::MAX_SESSIONS_PER_USER;
40 29
41 30 /**
42 - * How long a session must have gone unseen before eviction may remove it.
31 + * Minimum idle time before eviction.
43 32 *
44 - * The cap alone is not a safe eviction rule. A client that authenticates
45 - * programmatically mints sessions far faster than a merchant does, so "the oldest of
46 - * N" can be a session created minutes ago and still in use — and evicting it
47 - * blacklists its access token, logging a working device out mid-request. That is
48 - * exactly what happened on the shared E2E cashier after #1798 shipped a 50-session
49 - * cap. A week of silence is a long time for a till: a device seen inside that window
50 - * is treated as live and is never a candidate, whatever the count.
33 + * @deprecated Use Session_Registry::SESSION_EVICTION_IDLE_SECONDS.
51 34 */
52 - public const SESSION_EVICTION_IDLE_SECONDS = 7 * DAY_IN_SECONDS;
35 + public const SESSION_EVICTION_IDLE_SECONDS = Session_Registry::SESSION_EVICTION_IDLE_SECONDS;
53 36
54 37 /**
55 - * How stale a session's `last_active` may get before an authenticated request rewrites it.
38 + * Session row byte ceiling.
56 39 *
57 - * `last_active` decides what eviction may touch, so it has to reflect USE, not just
58 - * token refreshes — before this, only `refresh_access_token()` moved it, and a device
59 - * happily working through a 30-minute access token looked idle the whole time. Every
60 - * authenticated request now refreshes it, throttled to one write per session per five
61 - * minutes so the POS's request volume does not turn into a write per call.
40 + * @deprecated Use Session_Registry::MAX_SESSIONS_ROW_BYTES.
62 41 */
63 - private const SESSION_ACTIVITY_REFRESH_SECONDS = 5 * MINUTE_IN_SECONDS;
42 + public const MAX_SESSIONS_ROW_BYTES = Session_Registry::MAX_SESSIONS_ROW_BYTES;
64 43
65 44 /**
66 - * Transient prefix for the per-session "last seen" record.
45 + * The single instance of the class.
67 46 *
68 - * Activity is recorded OUTSIDE the session row on purpose. Writing it into the row
69 - * meant every authenticated request did a read-modify-write of the whole
70 - * `_woocommerce_pos_refresh_tokens` array, which is neither atomic nor cheap: a
71 - * request overlapping a login, logout or revoke for the same user could write back a
72 - * stale copy and erase the concurrent change — losing a session that had just been
73 - * issued, so the new client worked until its access token expired and was then refused
74 - * a refresh. Four parallel E2E shards on one cashier do exactly that. A per-session key
75 - * cannot collide with another session's write, and reading it costs no row load at all.
47 + * @var null|Auth
76 48 */
77 - private const SESSION_SEEN_TRANSIENT_PREFIX = 'wcpos_session_seen_';
49 + private static $instance = null;
78 50
79 51 /**
80 - * Byte ceiling on the stored session row before it is discarded UNREAD.
52 + * Session storage.
81 53 *
82 - * This is a LAST RESORT for a row no longer safe to load, not a tidy-up threshold —
83 - * discarding it signs every one of that user's devices out at once. The bar is set
84 - * from measurement rather than caution: a 9,216,730-byte row (17,000 sessions) read
85 - * fine under the 128 MB limit that produced the #1776 fatal — `get_user_meta()` cost
86 - * ~26 MB to fetch and ~38 MB with the unserialize, and it was the WRITE-BACK, at ~42
87 - * MB more, that exhausted the request. Six megabytes therefore sits below anything
88 - * measured to be unreadable while still catching a row heading for that fatal. The
89 - * first release of this guard used one megabyte, which is comfortably readable and
90 - * threw away rows that eviction could simply have trimmed.
54 + * @var Session_Registry
91 55 */
92 - public const MAX_SESSIONS_ROW_BYTES = 6291456;
56 + private $sessions;
93 57
94 58 /**
95 - * The single instance of the class.
96 - *
97 - * @var null|Auth
59 + * Constructor is private to prevent direct instantiation.
60 + * Or Auth::instance() instead.
98 61 */
99 - private static $instance = null;
62 + public function __construct() {
63 + $this->sessions = new Session_Registry();
64 + }
100 65
101 66 /**
102 - * Constructor is private to prevent direct instantiation.
103 - * Or Auth::instance() instead.
67 + * Get the session registry.
68 + *
69 + * @return Session_Registry
104 70 */
105 - public function __construct() {
71 + public function sessions(): Session_Registry {
72 + return $this->sessions;
106 73 }
107 74
108 75 /**
109 76 * Gets the singleton instance.
@@ -286,9 +253,9 @@
286 253
287 254 // The session is live: record that, so eviction can tell a device that is
288 255 // working right now from one that has not been seen in a week.
289 256 if ( isset( $decoded_token->refresh_jti ) ) {
290 - $this->touch_session_activity(
257 + $this->sessions->touch(
291 258 absint( $decoded_token->data->user->id ),
292 259 (string) $decoded_token->refresh_jti
293 260 );
294 261 }
@@ -295,8 +262,14 @@
295 262 }
296 263
297 264 // Everything looks good return the decoded token.
298 265 return $decoded_token;
266 + } catch ( \WCPOS\Vendor\Firebase\JWT\ExpiredException $e ) {
267 + return new WP_Error(
268 + 'woocommerce_pos_auth_token_expired',
269 + 'Token expired',
270 + array( 'status' => 403 )
271 + );
299 272 } catch ( Exception $e ) {
300 273 // Something is wrong trying to decode the token, send back the error.
301 274 return new WP_Error(
302 275 'woocommmerce_pos_auth_invalid_token',
@@ -394,9 +367,9 @@
394 367 $access_jti = null === $access_jti ? $jti : (string) $access_jti;
395 368
396 369 if ( null !== $linked_refresh_jti ) {
397 370 $linked_refresh_jti = (string) $linked_refresh_jti;
398 - $this->store_access_token_expiry( $user->ID, $linked_refresh_jti, $expires_at );
371 + $this->sessions->record_access_expiry( $user->ID, $linked_refresh_jti, $expires_at );
399 372 }
400 373
401 374 return array(
402 375 'token' => $token,
@@ -459,9 +432,26 @@
459 432 */
460 433 $token = JWT::encode( apply_filters( 'woocommerce_pos_jwt_refresh_token_before_sign', $token, $user ), $this->get_refresh_secret_key(), 'HS256' );
461 434
462 435 // Store refresh token JTI for potential revocation.
463 - $this->store_refresh_token_jti( $user->ID, $jti, $expire );
436 + $evicted = $this->sessions->record( $user->ID, $jti, $expire, Session_Context::from_request() );
437 + $issued_at = time();
438 + foreach ( $evicted as $evicted_jti => $token_data ) {
439 + /*
440 + * Blacklist ONLY a session that can still hold a live access token. An eviction
441 + * is not a revoke: clearing a bloated row can drop thousands of long-dead
442 + * sessions at once, and a transient for each would guard nothing — an expired
443 + * access token is already rejected on its own `exp` claim, and the refresh token
444 + * dies with the meta entry (`is_live()` requires the entry). This
445 + * also bounds each transient this path writes to one access-token lifetime,
446 + * rather than the refresh-token expiry `get_access_token_blacklist_ttl()` falls
447 + * back to for a session with no recorded access-token expiry.
448 + */
449 + $horizon = $this->access_token_horizon( $token_data );
450 + if ( $horizon > $issued_at ) {
451 + $this->blacklist_token( $evicted_jti, $horizon - $issued_at );
452 + }
453 + }
464 454
465 455 return $token;
466 456 }
467 457
@@ -599,16 +589,16 @@
599 589 }
600 590
601 591 /*
602 592 * Before the first row read on this path. A refresh loads the whole session row —
603 - * `is_refresh_token_valid()` below, then `update_session_activity()` — so it needs
593 + * `is_live()` below, then `refresh_activity()` — so it needs
604 594 * the same protection a login has against a row too large to read (#1776).
605 595 * Validating an ACCESS token needs no such guard: it no longer touches the row.
606 596 */
607 - $this->discard_oversized_session_row( absint( $decoded->data->user->id ) );
597 + $this->sessions->guard_row( absint( $decoded->data->user->id ) );
608 598
609 599 // Check if refresh token is still valid (not revoked).
610 - if ( ! $this->is_refresh_token_valid( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
600 + if ( ! $this->sessions->is_live( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
611 601 return new WP_Error(
612 602 'woocommerce_pos_auth_refresh_token_revoked',
613 603 'Refresh token has been revoked',
614 604 array( 'status' => 403 )
@@ -648,22 +638,9 @@
648 638 *
649 639 * @return bool
650 640 */
651 641 public function revoke_refresh_token( int $user_id, string $jti ): bool {
652 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
653 - if ( ! \is_array( $refresh_tokens ) ) {
654 - return false;
655 - }
656 -
657 - if ( isset( $refresh_tokens[ $jti ] ) ) {
658 - unset( $refresh_tokens[ $jti ] );
659 - update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
660 - $this->forget_session_activity( $jti );
661 -
662 - return true;
663 - }
664 -
665 - return false;
642 + return $this->sessions->revoke( $user_id, $jti );
666 643 }
667 644
668 645 /**
669 646 * Revoke all refresh tokens for a user.
@@ -679,12 +656,13 @@
679 656 *
680 657 * @return bool
681 658 */
682 659 public function revoke_all_refresh_tokens( int $user_id ): bool {
683 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
660 + $refresh_tokens = $this->sessions->entries( $user_id );
684 661
685 - // Blacklist all sessions for instant access token invalidation.
686 - if ( \is_array( $refresh_tokens ) ) {
662 + // Blacklist all sessions for instant access token invalidation. The expiry
663 + // policy is only consulted when there is something to blacklist.
664 + if ( array() !== $refresh_tokens ) {
687 665 $issued_at = time();
688 666 $access_expire = $this->get_access_token_expire( $issued_at );
689 667
690 668 foreach ( $refresh_tokens as $jti => $token_data ) {
@@ -689,13 +667,12 @@
689 667
690 668 foreach ( $refresh_tokens as $jti => $token_data ) {
691 669 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
692 670 $this->blacklist_token( $jti, $ttl );
693 - $this->forget_session_activity( (string) $jti );
694 671 }
695 672 }
696 673
697 - return delete_user_meta( $user_id, '_woocommerce_pos_refresh_tokens' );
674 + return $this->sessions->revoke_all( $user_id );
698 675 }
699 676
700 677 /**
701 678 * Get all active sessions for a user.
@@ -704,42 +681,9 @@
704 681 *
705 682 * @return array
706 683 */
707 684 public function get_user_sessions( int $user_id ): array {
708 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
709 - if ( ! \is_array( $refresh_tokens ) ) {
710 - return array();
711 - }
712 -
713 - $sessions = array();
714 - $current_time = time();
715 -
716 - foreach ( $refresh_tokens as $jti => $token_data ) {
717 - // Skip expired sessions.
718 - if ( $token_data['expires'] <= $current_time ) {
719 - continue;
720 - }
721 -
722 - $sessions[] = array(
723 - 'jti' => $jti,
724 - 'created' => $token_data['created'] ?? $current_time,
725 - 'last_active' => $token_data['last_active'] ?? $token_data['created'] ?? $current_time,
726 - 'expires' => $token_data['expires'],
727 - 'ip_address' => $token_data['ip_address'] ?? '',
728 - 'user_agent' => $token_data['user_agent'] ?? '',
729 - 'device_info' => $token_data['device_info'] ?? array(),
730 - );
731 - }
732 -
733 - // Sort by last_active descending (most recent first).
734 - usort(
735 - $sessions,
736 - function ( $a, $b ) {
737 - return $b['last_active'] - $a['last_active'];
738 - }
739 - );
740 -
741 - return $sessions;
685 + return $this->sessions->list( $user_id );
742 686 }
743 687
744 688 /**
745 689 * Revoke a specific session by JTI (alias for revoke_refresh_token for clarity).
@@ -769,10 +713,11 @@
769 713 *
770 714 * @return bool
771 715 */
772 716 public function revoke_all_sessions_except( int $user_id, string $current_jti ): bool {
773 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
774 - if ( ! \is_array( $refresh_tokens ) ) {
717 + $refresh_tokens = $this->sessions->entries( $user_id );
718 + if ( array() === $refresh_tokens ) {
719 + // No row (or nothing in it): nothing to blacklist, nothing to rewrite.
775 720 return false;
776 721 }
777 722
778 723 // Blacklist all sessions except current for instant access token invalidation.
@@ -782,22 +727,12 @@
782 727 foreach ( $refresh_tokens as $jti => $token_data ) {
783 728 if ( $jti !== $current_jti ) {
784 729 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
785 730 $this->blacklist_token( $jti, $ttl );
786 - $this->forget_session_activity( (string) $jti );
787 731 }
788 732 }
789 733
790 - // Keep only the current session in user meta.
791 - $refresh_tokens = array_filter(
792 - $refresh_tokens,
793 - function ( $_token, $jti ) use ( $current_jti ) {
794 - return $jti === $current_jti;
795 - },
796 - ARRAY_FILTER_USE_BOTH
797 - );
798 -
799 - return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
734 + return $this->sessions->keep_only( $user_id, $current_jti );
800 735 }
801 736
802 737 /**
803 738 * Update last_active timestamp for a session.
@@ -807,62 +742,12 @@
807 742 *
808 743 * @return bool
809 744 */
810 745 public function update_session_activity( int $user_id, string $jti ): bool {
811 - // Public surface: any caller reaching the row goes through the size guard first.
812 - $this->discard_oversized_session_row( $user_id );
813 -
814 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
815 - if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $jti ] ) ) {
816 - return false;
817 - }
818 -
819 - $refresh_tokens[ $jti ]['last_active'] = time();
820 -
821 - return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
746 + return $this->sessions->refresh_activity( $user_id, $jti );
822 747 }
823 748
824 749 /**
825 - * Refresh a session's `last_active`, at most once every few minutes.
826 - *
827 - * Called from token validation, so it runs on EVERY authenticated request. The
828 - * throttle is what makes that affordable: the value only has to be accurate to within
829 - * minutes for a rule that asks whether a session has been unseen for a week, and the
830 - * read is already in the user's meta cache by this point.
831 - *
832 - * @param int $user_id The user ID.
833 - * @param string $jti Refresh token JTI (session identifier).
834 - */
835 - private function touch_session_activity( int $user_id, string $jti ): void {
836 - if ( 0 === $user_id || '' === $jti ) {
837 - return;
838 - }
839 -
840 - $key = self::SESSION_SEEN_TRANSIENT_PREFIX . $jti;
841 - $seen = get_transient( $key );
842 -
843 - // The throttle reads the transient, never the session row: this runs on every
844 - // authenticated request, and the row is the one thing this path must not touch.
845 - if ( is_numeric( $seen ) && time() - (int) $seen < self::SESSION_ACTIVITY_REFRESH_SECONDS ) {
846 - return;
847 - }
848 -
849 - // The TTL IS the idle window, so a missing transient means "not seen in a week".
850 - set_transient( $key, time(), self::SESSION_EVICTION_IDLE_SECONDS );
851 - }
852 -
853 - /**
854 - * Forget a session's recorded activity.
855 - *
856 - * @param string $jti Refresh token JTI (session identifier).
857 - */
858 - private function forget_session_activity( string $jti ): void {
859 - if ( '' !== $jti ) {
860 - delete_transient( self::SESSION_SEEN_TRANSIENT_PREFIX . $jti );
861 - }
862 - }
863 -
864 - /**
865 750 * Check if the current user can manage sessions for the target user.
866 751 *
867 752 * @param int $target_user_id The target user ID.
868 753 *
@@ -921,11 +806,10 @@
921 806 *
922 807 * @return bool
923 808 */
924 809 public function revoke_session_with_blacklist( int $user_id, string $refresh_jti ): bool {
925 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
926 - $session_data = \is_array( $refresh_tokens ) && isset( $refresh_tokens[ $refresh_jti ] ) ? $refresh_tokens[ $refresh_jti ] : array();
927 - $ttl = $this->get_access_token_blacklist_ttl( $session_data );
810 + $session_data = $this->sessions->entry( $user_id, $refresh_jti );
811 + $ttl = $this->get_access_token_blacklist_ttl( $session_data );
928 812
929 813 // Revoke the refresh token (session) from user meta.
930 814 $revoked = $this->revoke_session( $user_id, $refresh_jti );
931 815
@@ -938,233 +822,8 @@
938 822 return $revoked;
939 823 }
940 824
941 825 /**
942 - * Store refresh token JTI for tracking/revocation.
943 - *
944 - * @param int $user_id The user ID.
945 - * @param string $jti The token JTI.
946 - * @param int $expires The expiration timestamp.
947 - * @param null|Session_Context $context Request state the session is recorded
948 - * against. Defaults to the current request.
949 - */
950 - private function store_refresh_token_jti( int $user_id, string $jti, int $expires, ?Session_Context $context = null ): void {
951 - $context = null === $context ? Session_Context::from_request() : $context;
952 -
953 - // BEFORE the read: a pre-cap row can be too large to load, and this is the first
954 - // point in the login flow where WCPOS knows the user id.
955 - $this->discard_oversized_session_row( $user_id );
956 -
957 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
958 - if ( ! \is_array( $refresh_tokens ) ) {
959 - $refresh_tokens = array();
960 - }
961 -
962 - // Clean up expired tokens.
963 - $refresh_tokens = array_filter(
964 - $refresh_tokens,
965 - function ( $token ) {
966 - return $token['expires'] > time();
967 - }
968 - );
969 -
970 - // Capture session metadata.
971 - $current_time = time();
972 - $ip_address = $context->get_ip();
973 - $user_agent = $context->get_user_agent();
974 - $device_info = $this->parse_user_agent( $user_agent );
975 -
976 - // Check for explicit platform declaration from native apps (passed as a param in the auth request).
977 - $platform = $context->get_platform();
978 - $version = $context->get_version();
979 - $build = $context->get_build();
980 -
981 - // Override app_type if platform was explicitly provided by the client.
982 - if ( \in_array( $platform, array( 'ios', 'android', 'electron', 'web' ), true ) ) {
983 - $device_info['app_type'] = 'web' === $platform ? 'web' : $platform . '_app';
984 -
985 - // Set appropriate device type based on platform.
986 - if ( 'ios' === $platform || 'android' === $platform ) {
987 - $device_info['device_type'] = 'tablet'; // Default to tablet for mobile apps.
988 - } elseif ( 'electron' === $platform ) {
989 - $device_info['device_type'] = 'desktop';
990 - }
991 -
992 - // Use version from param if provided.
993 - if ( ! empty( $version ) ) {
994 - $device_info['browser_version'] = $version;
995 - }
996 -
997 - // Store build number if provided.
998 - if ( ! empty( $build ) ) {
999 - $device_info['build'] = $build;
1000 - }
1001 -
1002 - // Set browser to WooCommerce POS for native apps.
1003 - if ( 'web' !== $platform ) {
1004 - $device_info['browser'] = 'WooCommerce POS';
1005 - }
1006 - }
1007 -
1008 - // Add new token with metadata.
1009 - $refresh_tokens[ $jti ] = array(
1010 - 'expires' => $expires,
1011 - 'created' => $current_time,
1012 - 'last_active' => $current_time,
1013 - 'ip_address' => $ip_address,
1014 - 'user_agent' => $user_agent,
1015 - 'device_info' => $device_info,
1016 - );
1017 -
1018 - // Cap the number of stored sessions so programmatic clients cannot grow the row without bound.
1019 - $refresh_tokens = $this->evict_oldest_sessions( $refresh_tokens, $jti );
1020 -
1021 - update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
1022 - }
1023 -
1024 - /**
1025 - * Drop the least recently active sessions until the per-user cap is met.
1026 - *
1027 - * Evicted sessions are blacklisted the same way revoke_all_sessions_except() does, so the
1028 - * device that lost its slot is cleanly logged out instead of keeping a working access token
1029 - * for the remainder of that token's life.
1030 - *
1031 - * @param array $refresh_tokens Stored sessions keyed by refresh token JTI.
1032 - * @param string $protected_jti JTI that must never be evicted (the session being stored).
1033 - *
1034 - * @return array The sessions to persist.
1035 - */
1036 - private function evict_oldest_sessions( array $refresh_tokens, string $protected_jti ): array {
1037 - $evict_count = \count( $refresh_tokens ) - self::MAX_SESSIONS_PER_USER;
1038 - if ( $evict_count <= 0 ) {
1039 - return $refresh_tokens;
1040 - }
1041 -
1042 - $issued_at = time();
1043 - $idle_before = $issued_at - self::SESSION_EVICTION_IDLE_SECONDS;
1044 -
1045 - /*
1046 - * Order eviction candidates oldest-first. The insertion index breaks ties explicitly
1047 - * because usort() is not stable before PHP 8.0 and bulk logins share a timestamp.
1048 - *
1049 - * A session seen within SESSION_EVICTION_IDLE_SECONDS is NOT a candidate at any
1050 - * count. Being the oldest of N says nothing about being unused when N sessions were
1051 - * minted in an hour, and evicting a live one blacklists a working device's access
1052 - * token. The cap yields to that: a user whose sessions are all recent keeps them
1053 - * all, and the row stays bounded by MAX_SESSIONS_ROW_BYTES instead.
1054 - */
1055 - $candidates = array();
1056 - $index = 0;
1057 - foreach ( $refresh_tokens as $candidate_jti => $token_data ) {
1058 - $position = $index++;
1059 - if ( (string) $candidate_jti === $protected_jti ) {
1060 - continue;
1061 - }
1062 -
1063 - // The ROW timestamp is the cheap filter. It is authoritative when it says a
1064 - // session is live, because login and refresh both write it; when it says idle
1065 - // the activity transient still gets the final word, below.
1066 - $activity = $this->session_row_last_seen( $token_data );
1067 - if ( $activity > $idle_before ) {
1068 - continue;
1069 - }
1070 -
1071 - $candidates[] = array(
1072 - 'jti' => (string) $candidate_jti,
1073 - 'activity' => $activity,
1074 - 'index' => $position,
1075 - );
1076 - }
1077 -
1078 - usort(
1079 - $candidates,
1080 - function ( $a, $b ) {
1081 - if ( $a['activity'] === $b['activity'] ) {
1082 - return $a['index'] <=> $b['index'];
1083 - }
1084 -
1085 - return $a['activity'] <=> $b['activity'];
1086 - }
1087 - );
1088 -
1089 - foreach ( $candidates as $candidate ) {
1090 - if ( $evict_count <= 0 ) {
1091 - break;
1092 - }
1093 -
1094 - // Checked only for rows already stale, so this costs a handful of transient
1095 - // reads rather than one per stored session.
1096 - if ( $this->session_last_seen( $candidate['jti'], $refresh_tokens[ $candidate['jti'] ] ) > $idle_before ) {
1097 - continue;
1098 - }
1099 -
1100 - /*
1101 - * Blacklist ONLY a session that can still hold a live access token. An eviction
1102 - * is not a revoke: clearing a bloated row can drop thousands of long-dead
1103 - * sessions at once, and a transient for each would guard nothing — an expired
1104 - * access token is already rejected on its own `exp` claim, and the refresh token
1105 - * dies with the meta entry (`is_refresh_token_valid()` requires the entry). This
1106 - * also bounds each transient this path writes to one access-token lifetime,
1107 - * rather than the refresh-token expiry `get_access_token_blacklist_ttl()` falls
1108 - * back to for a session with no recorded access-token expiry.
1109 - */
1110 - $horizon = $this->access_token_horizon( $refresh_tokens[ $candidate['jti'] ] );
1111 - if ( $horizon > $issued_at ) {
1112 - $this->blacklist_token( $candidate['jti'], $horizon - $issued_at );
1113 - }
1114 -
1115 - $this->forget_session_activity( $candidate['jti'] );
1116 - unset( $refresh_tokens[ $candidate['jti'] ] );
1117 - --$evict_count;
1118 - }
1119 -
1120 - return $refresh_tokens;
1121 - }
1122 -
1123 - /**
1124 - * When a session was last seen, taking the later of the row and the activity record.
1125 - *
1126 - * The row is rewritten by login and refresh; the transient is written by ordinary
1127 - * authenticated requests. Neither alone is the whole picture — a device working through
1128 - * a long-lived access token has an old row timestamp and a fresh transient, and a
1129 - * session that has not been used at all has the reverse.
1130 - *
1131 - * @param string $jti Refresh token JTI (session identifier).
1132 - * @param array $token_data Stored session record.
1133 - *
1134 - * @return int Unix timestamp; 0 when neither source carries a usable timestamp.
1135 - */
1136 - private function session_last_seen( string $jti, array $token_data ): int {
1137 - $row_seen = $this->session_row_last_seen( $token_data );
1138 - $seen = '' === $jti ? false : get_transient( self::SESSION_SEEN_TRANSIENT_PREFIX . $jti );
1139 -
1140 - return is_numeric( $seen ) ? max( $row_seen, (int) $seen ) : $row_seen;
1141 - }
1142 -
1143 - /**
1144 - * When the stored record itself says a session was last seen.
1145 - *
1146 - * Login and refresh both rewrite `last_active` in the row, so this stays accurate for
1147 - * everything except the stretch between refreshes — which is what the activity
1148 - * transient covers.
1149 - *
1150 - * @param array $token_data Stored session record.
1151 - *
1152 - * @return int Unix timestamp; 0 when the record carries no usable timestamp.
1153 - */
1154 - private function session_row_last_seen( array $token_data ): int {
1155 - if ( isset( $token_data['last_active'] ) ) {
1156 - return (int) $token_data['last_active'];
1157 - }
1158 -
1159 - if ( isset( $token_data['created'] ) ) {
1160 - return (int) $token_data['created'];
1161 - }
1162 -
1163 - return 0;
1164 - }
1165 -
1166 - /**
1167 826 * The last moment an access token minted against a session can still validate.
1168 827 *
1169 828 * @param array $token_data Stored session record.
1170 829 *
@@ -1177,71 +836,14 @@
1177 836
1178 837 // Rows written before `access_expires` was recorded. The newest access token such a
1179 838 // session can hold was minted no later than its last recorded activity, so one
1180 839 // access-token lifetime past that moment is the outside limit.
1181 - $last_seen = $this->session_row_last_seen( $token_data );
840 + $last_seen = (int) ( $token_data['last_active'] ?? $token_data['created'] ?? 0 );
1182 841
1183 842 return $last_seen > 0 ? $this->get_access_token_expire( $last_seen ) : 0;
1184 843 }
1185 844
1186 845 /**
1187 - * Drop the stored session row when it is too large to be read safely.
1188 - *
1189 - * A LAST RESORT, not a tidy-up: discarding the row signs every one of that user's
1190 - * devices out at once, so the ceiling is set above anything measured to be readable
1191 - * (see MAX_SESSIONS_ROW_BYTES) and everything below it is TRIMMED by
1192 - * `evict_oldest_sessions()` on the same write instead. What this catches is the one
1193 - * case trimming cannot: a row so large that reading it exhausts the request before any
1194 - * of the code below runs, which — because that read happens on every login — locks the
1195 - * user out permanently (#1776). `LENGTH()` lets MySQL answer with a number instead of
1196 - * the value, so the size is checked without paying for the row.
1197 - *
1198 - * @param int $user_id The user ID.
1199 - */
1200 - private function discard_oversized_session_row( int $user_id ): void {
1201 - global $wpdb;
1202 -
1203 - $rows = $wpdb->get_results(
1204 - $wpdb->prepare(
1205 - "SELECT umeta_id, LENGTH(meta_value) AS meta_bytes FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s",
1206 - $user_id,
1207 - '_woocommerce_pos_refresh_tokens'
1208 - )
1209 - );
1210 -
1211 - if ( empty( $rows ) ) {
1212 - return;
1213 - }
1214 -
1215 - $bytes = 0;
1216 - foreach ( $rows as $row ) {
1217 - $bytes += (int) $row->meta_bytes;
1218 - }
1219 -
1220 - if ( $bytes <= self::MAX_SESSIONS_ROW_BYTES ) {
1221 - return;
1222 - }
1223 -
1224 - foreach ( $rows as $row ) {
1225 - $wpdb->delete( $wpdb->usermeta, array( 'umeta_id' => (int) $row->umeta_id ), array( '%d' ) );
1226 - }
1227 -
1228 - // The row may already be sitting in the user's meta cache from an earlier
1229 - // `get_user_meta()` in this request; without this the next read serves the value
1230 - // that was just deleted.
1231 - wp_cache_delete( $user_id, 'user_meta' );
1232 -
1233 - Logger::warning(
1234 - sprintf(
1235 - '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.',
1236 - $user_id,
1237 - $bytes,
1238 - self::MAX_SESSIONS_ROW_BYTES
1239 - )
1240 - );
1241 - }
1242 -
1243 - /**
1244 846 * Filters the JWT access token expire time.
1245 847 * Default: 30 minutes for access tokens.
1246 848 *
1247 849 * @param int $issued_at Token issued timestamp.
@@ -1292,37 +894,8 @@
1292 894 return null;
1293 895 }
1294 896
1295 897 /**
1296 - * Record the latest access token expiry linked to a refresh-token session.
1297 - *
1298 - * @param int $user_id The user ID.
1299 - * @param string $refresh_jti Refresh token JTI.
1300 - * @param int $access_expires Access token expiry timestamp.
1301 - *
1302 - * @return bool
1303 - */
1304 - private function store_access_token_expiry( int $user_id, string $refresh_jti, int $access_expires ): bool {
1305 - if ( empty( $refresh_jti ) || $access_expires <= 0 ) {
1306 - return false;
1307 - }
1308 -
1309 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
1310 - if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $refresh_jti ] ) ) {
1311 - return false;
1312 - }
1313 -
1314 - $current_access_expires = isset( $refresh_tokens[ $refresh_jti ]['access_expires'] ) ? (int) $refresh_tokens[ $refresh_jti ]['access_expires'] : 0;
1315 - if ( $access_expires <= $current_access_expires ) {
1316 - return true;
1317 - }
1318 -
1319 - $refresh_tokens[ $refresh_jti ]['access_expires'] = $access_expires;
1320 -
1321 - return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
1322 - }
1323 -
1324 - /**
1325 898 * Calculate blacklist TTL for a session.
1326 899 *
1327 900 * @param array $session_data Session metadata.
1328 901 * @param null|int $issued_at Current timestamp.
@@ -1344,139 +917,8 @@
1344 917 $access_expire = max( $access_expire, (int) $session_data['expires'] );
1345 918 }
1346 919
1347 920 return max( 0, $access_expire - $issued_at );
1348 - }
1349 -
1350 - /**
1351 - * Check if refresh token is still valid (not revoked).
1352 - *
1353 - * @param int $user_id The user ID.
1354 - * @param string $jti The token JTI.
1355 - *
1356 - * @return bool
1357 - */
1358 - private function is_refresh_token_valid( int $user_id, string $jti ): bool {
1359 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
1360 - if ( ! \is_array( $refresh_tokens ) ) {
1361 - return false;
1362 - }
1363 -
1364 - return isset( $refresh_tokens[ $jti ] ) && $refresh_tokens[ $jti ]['expires'] > time();
1365 - }
1366 -
1367 - /**
1368 - * Parse user agent string to extract device information.
1369 - *
1370 - * @param string $user_agent The user agent string.
1371 - *
1372 - * @return array
1373 - */
1374 - private function parse_user_agent( string $user_agent ): array {
1375 - $device_info = array(
1376 - 'device_type' => 'unknown',
1377 - 'browser' => 'unknown',
1378 - 'browser_version' => '',
1379 - 'os' => 'unknown',
1380 - 'app_type' => 'web', // web, ios_app, android_app, electron_app.
1381 - );
1382 -
1383 - if ( empty( $user_agent ) ) {
1384 - return $device_info;
1385 - }
1386 -
1387 - // Detect WooCommerce POS apps first (custom identifiers)
1388 - // Check for Electron app (including just "WooCommercePOS" in user agent with Electron).
1389 - if ( preg_match( '/Electron/i', $user_agent ) && preg_match( '/WooCommercePOS|WCPOS/i', $user_agent ) ) {
1390 - $device_info['app_type'] = 'electron_app';
1391 - $device_info['browser'] = 'WooCommerce POS';
1392 - $device_info['device_type'] = 'desktop';
1393 - // Try to extract WooCommercePOS version.
1394 - if ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1395 - $device_info['browser_version'] = $matches[1];
1396 - } elseif ( preg_match( '/WCPOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1397 - $device_info['browser_version'] = $matches[1];
1398 - }
1399 - } elseif ( preg_match( '/WCPOS[-_]?iOS|WooCommercePOS[-_]?iOS/i', $user_agent ) ) {
1400 - $device_info['app_type'] = 'ios_app';
1401 - $device_info['browser'] = 'WooCommerce POS';
1402 - // Default to tablet unless explicitly detected as phone.
1403 - $device_info['device_type'] = preg_match( '/iphone|ipod/i', $user_agent ) ? 'mobile' : 'tablet';
1404 - if ( preg_match( '/WCPOS[-_]?iOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1405 - $device_info['browser_version'] = $matches[1];
1406 - } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1407 - $device_info['browser_version'] = $matches[1];
1408 - }
1409 - } elseif ( preg_match( '/WCPOS[-_]?Android|WooCommercePOS[-_]?Android/i', $user_agent ) ) {
1410 - $device_info['app_type'] = 'android_app';
1411 - $device_info['browser'] = 'WooCommerce POS';
1412 - // Default to tablet unless explicitly detected as mobile.
1413 - $device_info['device_type'] = preg_match( '/mobile/i', $user_agent ) && ! preg_match( '/tablet/i', $user_agent ) ? 'mobile' : 'tablet';
1414 - if ( preg_match( '/WCPOS[-_]?Android[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1415 - $device_info['browser_version'] = $matches[1];
1416 - } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1417 - $device_info['browser_version'] = $matches[1];
1418 - }
1419 - }
1420 -
1421 - // Detect standard device type (if not already set by app detection).
1422 - if ( 'web' === $device_info['app_type'] ) {
1423 - if ( preg_match( '/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i', $user_agent ) ) {
1424 - $device_info['device_type'] = 'mobile';
1425 - } elseif ( preg_match( '/tablet|ipad|playbook|silk/i', $user_agent ) ) {
1426 - $device_info['device_type'] = 'tablet';
1427 - } else {
1428 - $device_info['device_type'] = 'desktop';
1429 - }
1430 - }
1431 -
1432 - // Detect browser (skip if we already detected a WCPOS app).
1433 - if ( 'WooCommerce POS' !== $device_info['browser'] ) {
1434 - if ( preg_match( '/MSIE|Trident/i', $user_agent ) ) {
1435 - $device_info['browser'] = 'Internet Explorer';
1436 - if ( preg_match( '/MSIE ([0-9.]+)/', $user_agent, $matches ) ) {
1437 - $device_info['browser_version'] = $matches[1];
1438 - }
1439 - } elseif ( preg_match( '/Edge\/([0-9.]+)/i', $user_agent, $matches ) ) {
1440 - $device_info['browser'] = 'Edge';
1441 - $device_info['browser_version'] = $matches[1];
1442 - } elseif ( preg_match( '/Edg\/([0-9.]+)/i', $user_agent, $matches ) ) {
1443 - $device_info['browser'] = 'Edge';
1444 - $device_info['browser_version'] = $matches[1];
1445 - } elseif ( preg_match( '/Firefox\/([0-9.]+)/i', $user_agent, $matches ) ) {
1446 - $device_info['browser'] = 'Firefox';
1447 - $device_info['browser_version'] = $matches[1];
1448 - } elseif ( preg_match( '/Chrome\/([0-9.]+)/i', $user_agent, $matches ) ) {
1449 - $device_info['browser'] = 'Chrome';
1450 - $device_info['browser_version'] = $matches[1];
1451 - } elseif ( preg_match( '/Safari\/([0-9.]+)/i', $user_agent, $matches ) ) {
1452 - // Safari should be checked after Chrome because Chrome also contains Safari.
1453 - if ( ! preg_match( '/Chrome/i', $user_agent ) ) {
1454 - $device_info['browser'] = 'Safari';
1455 - $device_info['browser_version'] = $matches[1];
1456 - }
1457 - } elseif ( preg_match( '/Opera\/([0-9.]+)/i', $user_agent, $matches ) ) {
1458 - $device_info['browser'] = 'Opera';
1459 - $device_info['browser_version'] = $matches[1];
1460 - }
1461 - }
1462 -
1463 - // Detect OS.
1464 - if ( preg_match( '/Windows NT ([0-9.]+)/i', $user_agent, $matches ) ) {
1465 - $device_info['os'] = 'Windows';
1466 - } elseif ( preg_match( '/Mac OS X ([0-9_]+)/i', $user_agent, $matches ) ) {
1467 - $device_info['os'] = 'macOS';
1468 - } elseif ( preg_match( '/Android ([0-9.]+)/i', $user_agent, $matches ) ) {
1469 - $device_info['os'] = 'Android';
1470 - } elseif ( preg_match( '/iPhone OS ([0-9_]+)/i', $user_agent, $matches ) ) {
1471 - $device_info['os'] = 'iOS';
1472 - } elseif ( preg_match( '/iPad.*OS ([0-9_]+)/i', $user_agent, $matches ) ) {
1473 - $device_info['os'] = 'iPadOS';
1474 - } elseif ( preg_match( '/Linux/i', $user_agent ) ) {
1475 - $device_info['os'] = 'Linux';
1476 - }
1477 -
1478 - return $device_info;
1479 921 }
1480 922
1481 923 /**
1482 924 * Check if a token JTI is blacklisted.