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 +291 -338 1.9.141.10.19 View file →
@@ -9,8 +9,9 @@
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\Services\Settings\Access_Section;
13 14 use WP_Error;
14 15 use WP_User;
15 16 use const DAY_IN_SECONDS;
16 17 use const HOUR_IN_SECONDS;
@@ -19,8 +20,29 @@
19 20 * Auth Service class.
20 21 */
21 22 class Auth {
22 23 /**
24 + * Maximum retained idle sessions.
25 + *
26 + * @deprecated Use Session_Registry::MAX_SESSIONS_PER_USER.
27 + */
28 + public const MAX_SESSIONS_PER_USER = Session_Registry::MAX_SESSIONS_PER_USER;
29 +
30 + /**
31 + * Minimum idle time before eviction.
32 + *
33 + * @deprecated Use Session_Registry::SESSION_EVICTION_IDLE_SECONDS.
34 + */
35 + public const SESSION_EVICTION_IDLE_SECONDS = Session_Registry::SESSION_EVICTION_IDLE_SECONDS;
36 +
37 + /**
38 + * Session row byte ceiling.
39 + *
40 + * @deprecated Use Session_Registry::MAX_SESSIONS_ROW_BYTES.
41 + */
42 + public const MAX_SESSIONS_ROW_BYTES = Session_Registry::MAX_SESSIONS_ROW_BYTES;
43 +
44 + /**
23 45 * The single instance of the class.
24 46 *
25 47 * @var null|Auth
26 48 */
@@ -26,15 +48,32 @@
26 48 */
27 49 private static $instance = null;
28 50
29 51 /**
52 + * Session storage.
53 + *
54 + * @var Session_Registry
55 + */
56 + private $sessions;
57 +
58 + /**
30 59 * Constructor is private to prevent direct instantiation.
31 60 * Or Auth::instance() instead.
32 61 */
33 62 public function __construct() {
63 + $this->sessions = new Session_Registry();
34 64 }
35 65
36 66 /**
67 + * Get the session registry.
68 + *
69 + * @return Session_Registry
70 + */
71 + public function sessions(): Session_Registry {
72 + return $this->sessions;
73 + }
74 +
75 + /**
37 76 * Gets the singleton instance.
38 77 *
39 78 * @return Auth
40 79 */
@@ -46,8 +85,78 @@
46 85 return self::$instance;
47 86 }
48 87
49 88 /**
89 + * Extract a WCPOS token from an authorization value.
90 + *
91 + * @param mixed $auth_value Authorization value.
92 + *
93 + * @return null|string
94 + */
95 + public function extract_token( $auth_value ): ?string {
96 + if ( ! \is_string( $auth_value ) || '' === $auth_value ) {
97 + return null;
98 + }
99 +
100 + // Match the old sscanf( 'Bearer %s' ) semantics exactly: any run of
101 + // whitespace after the scheme, token = the next non-whitespace run.
102 + if ( 1 === preg_match( '/^Bearer\s+(\S+)/', $auth_value, $matches ) ) {
103 + return $matches[1];
104 + }
105 +
106 + return 1 === preg_match( '/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $auth_value ) ? $auth_value : null;
107 + }
108 +
109 + /**
110 + * Authenticate the current request from its WCPOS token.
111 + *
112 + * @return false|int|WP_Error User ID, validation error, or false when no WCPOS token is present.
113 + */
114 + public function authenticate_request() {
115 + $auth_header = $this->get_auth_header();
116 + $token = $this->extract_token( $auth_header );
117 + if ( null === $token ) {
118 + return false;
119 + }
120 +
121 + $decoded_token = $this->validate_token( $token );
122 + if ( is_wp_error( $decoded_token ) ) {
123 + return $decoded_token;
124 + }
125 +
126 + return absint( $decoded_token->data->user->id );
127 + }
128 +
129 + /**
130 + * Get authorization header/param value.
131 + *
132 + * Checks multiple sources for the authorization token:
133 + * 1. HTTP_AUTHORIZATION server variable (standard)
134 + * 2. REDIRECT_HTTP_AUTHORIZATION (Apache CGI workaround)
135 + * 3. authorization query parameter (for servers that strip auth headers)
136 + *
137 + * @return false|string The authorization value or false if not found.
138 + */
139 + public function get_auth_header() {
140 + // Check HTTP_AUTHORIZATION (not empty - htaccess SetEnvIf can set empty value).
141 + if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
142 + return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
143 + }
144 +
145 + // Check REDIRECT_HTTP_AUTHORIZATION (Apache CGI).
146 + if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
147 + return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
148 + }
149 +
150 + // Check authorization query param.
151 + if ( ! empty( $_GET['authorization'] ) ) {
152 + return sanitize_text_field( wp_unslash( $_GET['authorization'] ) );
153 + }
154 +
155 + return false;
156 + }
157 +
158 + /**
50 159 * Generate a secret key if it doesn't exist, or return the existing one.
51 160 *
52 161 * @return string
53 162 */
@@ -140,12 +249,27 @@
140 249 'Session has been revoked',
141 250 array( 'status' => 403 )
142 251 );
143 252 }
253 +
254 + // The session is live: record that, so eviction can tell a device that is
255 + // working right now from one that has not been seen in a week.
256 + if ( isset( $decoded_token->refresh_jti ) ) {
257 + $this->sessions->touch(
258 + absint( $decoded_token->data->user->id ),
259 + (string) $decoded_token->refresh_jti
260 + );
261 + }
144 262 }
145 263
146 264 // Everything looks good return the decoded token.
147 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 + );
148 272 } catch ( Exception $e ) {
149 273 // Something is wrong trying to decode the token, send back the error.
150 274 return new WP_Error(
151 275 'woocommmerce_pos_auth_invalid_token',
@@ -165,8 +289,26 @@
165 289 *
166 290 * @return string|WP_Error
167 291 */
168 292 public function generate_access_token( WP_User $user, string $refresh_jti = '' ) {
293 + $token_data = $this->generate_access_token_data( $user, $refresh_jti );
294 +
295 + if ( is_wp_error( $token_data ) ) {
296 + return $token_data;
297 + }
298 +
299 + return $token_data['token'];
300 + }
301 +
302 + /**
303 + * Generate an access token and return the token metadata used by callers.
304 + *
305 + * @param WP_User $user The user object.
306 + * @param string $refresh_jti Optional refresh token JTI to link access token to session.
307 + *
308 + * @return array|WP_Error
309 + */
310 + private function generate_access_token_data( WP_User $user, string $refresh_jti = '' ) {
169 311 // First thing, check the secret key if not exist return a error.
170 312 if ( ! $this->get_secret_key() ) {
171 313 return new WP_Error(
172 314 'woocommerce_pos_jwt_auth_bad_config',
@@ -178,24 +320,10 @@
178 320 }
179 321
180 322 /** Valid credentials, the user exists create the according Token */
181 323 $issued_at = time();
324 + $expire = $this->get_access_token_expire( $issued_at );
182 325
183 - /**
184 - * Filters the JWT access token expire time.
185 - * Default: 30 minutes for access tokens.
186 - *
187 - * @param int $expire_time
188 - * @param int $issued_at
189 - *
190 - * @returns int Expire time
191 - *
192 - * @since 1.8.0
193 - *
194 - * @hook woocommerce_pos_jwt_access_token_expire
195 - */
196 - $expire = apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
197 -
198 326 // Generate unique JTI for access token.
199 327 $jti = wp_generate_uuid4();
200 328
201 329 $token = array(
@@ -227,9 +355,29 @@
227 355 * @since 1.8.0
228 356 *
229 357 * @hook woocommerce_pos_jwt_access_token_before_sign
230 358 */
231 - return JWT::encode( apply_filters( 'woocommerce_pos_jwt_access_token_before_sign', $token, $user ), $this->get_secret_key(), 'HS256' );
359 + $payload = apply_filters( 'woocommerce_pos_jwt_access_token_before_sign', $token, $user );
360 + $token = JWT::encode( $payload, $this->get_secret_key(), 'HS256' );
361 +
362 + $expires_at = $this->get_payload_claim( $payload, 'exp' );
363 + $access_jti = $this->get_payload_claim( $payload, 'jti' );
364 + $linked_refresh_jti = $this->get_payload_claim( $payload, 'refresh_jti' );
365 +
366 + $expires_at = null === $expires_at ? $expire : (int) $expires_at;
367 + $access_jti = null === $access_jti ? $jti : (string) $access_jti;
368 +
369 + if ( null !== $linked_refresh_jti ) {
370 + $linked_refresh_jti = (string) $linked_refresh_jti;
371 + $this->sessions->record_access_expiry( $user->ID, $linked_refresh_jti, $expires_at );
372 + }
373 +
374 + return array(
375 + 'token' => $token,
376 + 'expires_at' => $expires_at,
377 + 'jti' => $access_jti,
378 + 'refresh_jti' => $linked_refresh_jti,
379 + );
232 380 }
233 381
234 382 /**
235 383 * Generate a refresh token for the provided user (long-lived).
@@ -251,24 +399,10 @@
251 399 }
252 400
253 401 /** Valid credentials, the user exists create the according Token */
254 402 $issued_at = time();
403 + $expire = $this->get_refresh_token_expire( $issued_at );
255 404
256 - /**
257 - * Filters the JWT refresh token expire time.
258 - * Default: 30 days for refresh tokens.
259 - *
260 - * @param int $expire_time
261 - * @param int $issued_at
262 - *
263 - * @returns int Expire time
264 - *
265 - * @since 1.8.0
266 - *
267 - * @hook woocommerce_pos_jwt_refresh_token_expire
268 - */
269 - $expire = apply_filters( 'woocommerce_pos_jwt_refresh_token_expire', $issued_at + ( DAY_IN_SECONDS * 30 ), $issued_at );
270 -
271 405 // Generate unique JTI (JWT ID) for refresh token tracking.
272 406 $jti = wp_generate_uuid4();
273 407
274 408 $token = array(
@@ -298,9 +432,26 @@
298 432 */
299 433 $token = JWT::encode( apply_filters( 'woocommerce_pos_jwt_refresh_token_before_sign', $token, $user ), $this->get_refresh_secret_key(), 'HS256' );
300 434
301 435 // Store refresh token JTI for potential revocation.
302 - $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 + }
303 454
304 455 return $token;
305 456 }
306 457
@@ -324,21 +475,18 @@
324 475 return $decoded_refresh;
325 476 }
326 477
327 478 // Generate access token with link to refresh token.
328 - $access_token = $this->generate_access_token( $user, $decoded_refresh->jti ?? '' );
329 - if ( is_wp_error( $access_token ) ) {
330 - return $access_token;
479 + $access_token_data = $this->generate_access_token_data( $user, $decoded_refresh->jti ?? '' );
480 + if ( is_wp_error( $access_token_data ) ) {
481 + return $access_token_data;
331 482 }
332 483
333 - $issued_at = time();
334 - $expire = apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
335 -
336 484 return array(
337 - 'access_token' => $access_token,
485 + 'access_token' => $access_token_data['token'],
338 486 'refresh_token' => $refresh_token,
339 487 'token_type' => 'Bearer',
340 - 'expires_at' => (int) $expire,
488 + 'expires_at' => (int) $access_token_data['expires_at'],
341 489 );
342 490 }
343 491
344 492 /**
@@ -389,8 +537,10 @@
389 537 'last_name' => $user->user_lastname,
390 538 'nice_name' => $user->user_nicename,
391 539 'display_name' => $user->display_name,
392 540 'roles' => array_values( $user->roles ),
541 + // The helper reports effective grants, including role-editor denies.
542 + 'capabilities' => Access_Section::effective_capabilities( $user ),
393 543 'avatar_url' => get_avatar_url( $user->ID ),
394 544 // Token data.
395 545 'access_token' => $tokens['access_token'],
396 546 'refresh_token' => $tokens['refresh_token'],
@@ -437,10 +587,18 @@
437 587 if ( is_wp_error( $decoded ) ) {
438 588 return $decoded;
439 589 }
440 590
591 + /*
592 + * Before the first row read on this path. A refresh loads the whole session row —
593 + * `is_live()` below, then `refresh_activity()` — so it needs
594 + * the same protection a login has against a row too large to read (#1776).
595 + * Validating an ACCESS token needs no such guard: it no longer touches the row.
596 + */
597 + $this->sessions->guard_row( absint( $decoded->data->user->id ) );
598 +
441 599 // Check if refresh token is still valid (not revoked).
442 - if ( ! $this->is_refresh_token_valid( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
600 + if ( ! $this->sessions->is_live( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
443 601 return new WP_Error(
444 602 'woocommerce_pos_auth_refresh_token_revoked',
445 603 'Refresh token has been revoked',
446 604 array( 'status' => 403 )
@@ -459,20 +617,17 @@
459 617 // Update last_active timestamp for this session.
460 618 $this->update_session_activity( $decoded->data->user->id, $decoded->jti ?? '' );
461 619
462 620 // Generate new access token with link to refresh token (refresh token stays the same).
463 - $new_access_token = $this->generate_access_token( $user, $decoded->jti ?? '' );
464 - if ( is_wp_error( $new_access_token ) ) {
465 - return $new_access_token;
621 + $new_access_token_data = $this->generate_access_token_data( $user, $decoded->jti ?? '' );
622 + if ( is_wp_error( $new_access_token_data ) ) {
623 + return $new_access_token_data;
466 624 }
467 625
468 - $issued_at = time();
469 - $expire = apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
470 -
471 626 return array(
472 - 'access_token' => $new_access_token,
627 + 'access_token' => $new_access_token_data['token'],
473 628 'token_type' => 'Bearer',
474 - 'expires_at' => (int) $expire,
629 + 'expires_at' => (int) $new_access_token_data['expires_at'],
475 630 );
476 631 }
477 632
478 633 /**
@@ -483,21 +638,9 @@
483 638 *
484 639 * @return bool
485 640 */
486 641 public function revoke_refresh_token( int $user_id, string $jti ): bool {
487 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
488 - if ( ! \is_array( $refresh_tokens ) ) {
489 - return false;
490 - }
491 -
492 - if ( isset( $refresh_tokens[ $jti ] ) ) {
493 - unset( $refresh_tokens[ $jti ] );
494 - update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
495 -
496 - return true;
497 - }
498 -
499 - return false;
642 + return $this->sessions->revoke( $user_id, $jti );
500 643 }
501 644
502 645 /**
503 646 * Revoke all refresh tokens for a user.
@@ -513,22 +656,23 @@
513 656 *
514 657 * @return bool
515 658 */
516 659 public function revoke_all_refresh_tokens( int $user_id ): bool {
517 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
660 + $refresh_tokens = $this->sessions->entries( $user_id );
518 661
519 - // Blacklist all sessions for instant access token invalidation.
520 - if ( \is_array( $refresh_tokens ) ) {
521 - $issued_at = time();
522 - $expire = apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
523 - $ttl = max( 0, $expire - $issued_at );
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 ) {
665 + $issued_at = time();
666 + $access_expire = $this->get_access_token_expire( $issued_at );
524 667
525 668 foreach ( $refresh_tokens as $jti => $token_data ) {
669 + $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
526 670 $this->blacklist_token( $jti, $ttl );
527 671 }
528 672 }
529 673
530 - return delete_user_meta( $user_id, '_woocommerce_pos_refresh_tokens' );
674 + return $this->sessions->revoke_all( $user_id );
531 675 }
532 676
533 677 /**
534 678 * Get all active sessions for a user.
@@ -537,42 +681,9 @@
537 681 *
538 682 * @return array
539 683 */
540 684 public function get_user_sessions( int $user_id ): array {
541 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
542 - if ( ! \is_array( $refresh_tokens ) ) {
543 - return array();
544 - }
545 -
546 - $sessions = array();
547 - $current_time = time();
548 -
549 - foreach ( $refresh_tokens as $jti => $token_data ) {
550 - // Skip expired sessions.
551 - if ( $token_data['expires'] <= $current_time ) {
552 - continue;
553 - }
554 -
555 - $sessions[] = array(
556 - 'jti' => $jti,
557 - 'created' => $token_data['created'] ?? $current_time,
558 - 'last_active' => $token_data['last_active'] ?? $token_data['created'] ?? $current_time,
559 - 'expires' => $token_data['expires'],
560 - 'ip_address' => $token_data['ip_address'] ?? '',
561 - 'user_agent' => $token_data['user_agent'] ?? '',
562 - 'device_info' => $token_data['device_info'] ?? array(),
563 - );
564 - }
565 -
566 - // Sort by last_active descending (most recent first).
567 - usort(
568 - $sessions,
569 - function ( $a, $b ) {
570 - return $b['last_active'] - $a['last_active'];
571 - }
572 - );
573 -
574 - return $sessions;
685 + return $this->sessions->list( $user_id );
575 686 }
576 687
577 688 /**
578 689 * Revoke a specific session by JTI (alias for revoke_refresh_token for clarity).
@@ -602,34 +713,26 @@
602 713 *
603 714 * @return bool
604 715 */
605 716 public function revoke_all_sessions_except( int $user_id, string $current_jti ): bool {
606 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
607 - 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.
608 720 return false;
609 721 }
610 722
611 723 // Blacklist all sessions except current for instant access token invalidation.
612 - $issued_at = time();
613 - $expire = apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
614 - $ttl = max( 0, $expire - $issued_at );
724 + $issued_at = time();
725 + $access_expire = $this->get_access_token_expire( $issued_at );
615 726
616 727 foreach ( $refresh_tokens as $jti => $token_data ) {
617 728 if ( $jti !== $current_jti ) {
729 + $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
618 730 $this->blacklist_token( $jti, $ttl );
619 731 }
620 732 }
621 733
622 - // Keep only the current session in user meta.
623 - $refresh_tokens = array_filter(
624 - $refresh_tokens,
625 - function ( $_token, $jti ) use ( $current_jti ) {
626 - return $jti === $current_jti;
627 - },
628 - ARRAY_FILTER_USE_BOTH
629 - );
630 -
631 - return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
734 + return $this->sessions->keep_only( $user_id, $current_jti );
632 735 }
633 736
634 737 /**
635 738 * Update last_active timestamp for a session.
@@ -639,16 +742,9 @@
639 742 *
640 743 * @return bool
641 744 */
642 745 public function update_session_activity( int $user_id, string $jti ): bool {
643 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
644 - if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $jti ] ) ) {
645 - return false;
646 - }
647 -
648 - $refresh_tokens[ $jti ]['last_active'] = time();
649 -
650 - return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
746 + return $this->sessions->refresh_activity( $user_id, $jti );
651 747 }
652 748
653 749 /**
654 750 * Check if the current user can manage sessions for the target user.
@@ -710,18 +806,17 @@
710 806 *
711 807 * @return bool
712 808 */
713 809 public function revoke_session_with_blacklist( int $user_id, string $refresh_jti ): bool {
810 + $session_data = $this->sessions->entry( $user_id, $refresh_jti );
811 + $ttl = $this->get_access_token_blacklist_ttl( $session_data );
812 +
714 813 // Revoke the refresh token (session) from user meta.
715 814 $revoked = $this->revoke_session( $user_id, $refresh_jti );
716 815
717 816 if ( $revoked ) {
718 817 // Blacklist the session JTI - this invalidates ALL access tokens for this session
719 - // TTL matches access token expiry (30 min default) since that's how long we need to block.
720 - $issued_at = time();
721 - $expire = apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
722 - $ttl = max( 0, $expire - $issued_at );
723 -
818 + // TTL covers the current policy and any access token expiry recorded for the session.
724 819 $this->blacklist_token( $refresh_jti, $ttl );
725 820 }
726 821
727 822 return $revoked;
@@ -727,245 +822,103 @@
727 822 return $revoked;
728 823 }
729 824
730 825 /**
731 - * Store refresh token JTI for tracking/revocation.
826 + * The last moment an access token minted against a session can still validate.
732 827 *
733 - * @param int $user_id The user ID.
734 - * @param string $jti The token JTI.
735 - * @param int $expires The expiration timestamp.
828 + * @param array $token_data Stored session record.
829 + *
830 + * @return int Unix timestamp; 0 when the session carries no usable timestamp at all.
736 831 */
737 - private function store_refresh_token_jti( int $user_id, string $jti, int $expires ): void {
738 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
739 - if ( ! \is_array( $refresh_tokens ) ) {
740 - $refresh_tokens = array();
832 + private function access_token_horizon( array $token_data ): int {
833 + if ( isset( $token_data['access_expires'] ) ) {
834 + return (int) $token_data['access_expires'];
741 835 }
742 836
743 - // Clean up expired tokens.
744 - $refresh_tokens = array_filter(
745 - $refresh_tokens,
746 - function ( $token ) {
747 - return $token['expires'] > time();
748 - }
749 - );
837 + // Rows written before `access_expires` was recorded. The newest access token such a
838 + // session can hold was minted no later than its last recorded activity, so one
839 + // access-token lifetime past that moment is the outside limit.
840 + $last_seen = (int) ( $token_data['last_active'] ?? $token_data['created'] ?? 0 );
750 841
751 - // Capture session metadata.
752 - $current_time = time();
753 - $ip_address = $this->get_client_ip();
754 - $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
755 - $device_info = $this->parse_user_agent( $user_agent );
842 + return $last_seen > 0 ? $this->get_access_token_expire( $last_seen ) : 0;
843 + }
756 844
757 - // Check for explicit platform declaration from native apps (passed as query param in auth URL).
758 - $platform = isset( $_REQUEST['platform'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['platform'] ) ) : '';
759 - $version = isset( $_REQUEST['version'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['version'] ) ) : '';
760 - $build = isset( $_REQUEST['build'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['build'] ) ) : '';
761 -
762 - // Override app_type if platform was explicitly provided by the client.
763 - if ( \in_array( $platform, array( 'ios', 'android', 'electron', 'web' ), true ) ) {
764 - $device_info['app_type'] = 'web' === $platform ? 'web' : $platform . '_app';
765 -
766 - // Set appropriate device type based on platform.
767 - if ( 'ios' === $platform || 'android' === $platform ) {
768 - $device_info['device_type'] = 'tablet'; // Default to tablet for mobile apps.
769 - } elseif ( 'electron' === $platform ) {
770 - $device_info['device_type'] = 'desktop';
771 - }
772 -
773 - // Use version from param if provided.
774 - if ( ! empty( $version ) ) {
775 - $device_info['browser_version'] = $version;
776 - }
777 -
778 - // Store build number if provided.
779 - if ( ! empty( $build ) ) {
780 - $device_info['build'] = $build;
781 - }
782 -
783 - // Set browser to WooCommerce POS for native apps.
784 - if ( 'web' !== $platform ) {
785 - $device_info['browser'] = 'WooCommerce POS';
786 - }
787 - }
788 -
789 - // Add new token with metadata.
790 - $refresh_tokens[ $jti ] = array(
791 - 'expires' => $expires,
792 - 'created' => $current_time,
793 - 'last_active' => $current_time,
794 - 'ip_address' => $ip_address,
795 - 'user_agent' => $user_agent,
796 - 'device_info' => $device_info,
797 - );
798 -
799 - update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
845 + /**
846 + * Filters the JWT access token expire time.
847 + * Default: 30 minutes for access tokens.
848 + *
849 + * @param int $issued_at Token issued timestamp.
850 + *
851 + * @return int Expire time.
852 + *
853 + * @since 1.8.0
854 + *
855 + * @hook woocommerce_pos_jwt_access_token_expire
856 + */
857 + private function get_access_token_expire( int $issued_at ): int {
858 + return (int) apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
800 859 }
801 860
802 861 /**
803 - * Check if refresh token is still valid (not revoked).
862 + * Filters the JWT refresh token expire time.
863 + * Default: 30 days for refresh tokens.
804 864 *
805 - * @param int $user_id The user ID.
806 - * @param string $jti The token JTI.
865 + * @param int $issued_at Token issued timestamp.
807 866 *
808 - * @return bool
867 + * @return int Expire time.
868 + *
869 + * @since 1.8.0
870 + *
871 + * @hook woocommerce_pos_jwt_refresh_token_expire
809 872 */
810 - private function is_refresh_token_valid( int $user_id, string $jti ): bool {
811 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
812 - if ( ! \is_array( $refresh_tokens ) ) {
813 - return false;
814 - }
815 -
816 - return isset( $refresh_tokens[ $jti ] ) && $refresh_tokens[ $jti ]['expires'] > time();
873 + private function get_refresh_token_expire( int $issued_at ): int {
874 + return (int) apply_filters( 'woocommerce_pos_jwt_refresh_token_expire', $issued_at + ( DAY_IN_SECONDS * 30 ), $issued_at );
817 875 }
818 876
819 877 /**
820 - * Get client IP address.
878 + * Read a top-level claim from a JWT payload array/object.
821 879 *
822 - * @return string
880 + * @param mixed $payload The filtered JWT payload.
881 + * @param string $claim The claim name.
882 + *
883 + * @return mixed|null
823 884 */
824 - private function get_client_ip(): string {
825 - $ip_address = '';
826 -
827 - // Check for various proxy headers.
828 - $headers = array(
829 - 'HTTP_CF_CONNECTING_IP', // Cloudflare.
830 - 'HTTP_X_FORWARDED_FOR',
831 - 'HTTP_X_REAL_IP',
832 - 'REMOTE_ADDR',
833 - );
834 -
835 - foreach ( $headers as $header ) {
836 - if ( ! empty( $_SERVER[ $header ] ) ) {
837 - $ip_address = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
838 - // Handle comma-separated IPs (X-Forwarded-For can contain multiple IPs).
839 - if ( false !== strpos( $ip_address, ',' ) ) {
840 - $ip_parts = explode( ',', $ip_address );
841 - $ip_address = trim( $ip_parts[0] );
842 - }
843 -
844 - break;
845 - }
885 + private function get_payload_claim( $payload, string $claim ) {
886 + if ( \is_array( $payload ) && array_key_exists( $claim, $payload ) ) {
887 + return $payload[ $claim ];
846 888 }
847 889
848 - // Validate and sanitize IP.
849 - if ( filter_var( $ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 ) ) {
850 - return $ip_address;
890 + if ( \is_object( $payload ) && isset( $payload->{$claim} ) ) {
891 + return $payload->{$claim};
851 892 }
852 893
853 - return '';
894 + return null;
854 895 }
855 896
856 897 /**
857 - * Parse user agent string to extract device information.
898 + * Calculate blacklist TTL for a session.
858 899 *
859 - * @param string $user_agent The user agent string.
900 + * @param array $session_data Session metadata.
901 + * @param null|int $issued_at Current timestamp.
902 + * @param null|int $access_expire Current access token expiry policy value.
860 903 *
861 - * @return array
904 + * @return int
862 905 */
863 - private function parse_user_agent( string $user_agent ): array {
864 - $device_info = array(
865 - 'device_type' => 'unknown',
866 - 'browser' => 'unknown',
867 - 'browser_version' => '',
868 - 'os' => 'unknown',
869 - 'app_type' => 'web', // web, ios_app, android_app, electron_app.
870 - );
906 + private function get_access_token_blacklist_ttl(
907 + array $session_data = array(),
908 + ?int $issued_at = null,
909 + ?int $access_expire = null
910 + ): int {
911 + $issued_at = null === $issued_at ? time() : $issued_at;
912 + $access_expire = null === $access_expire ? $this->get_access_token_expire( $issued_at ) : $access_expire;
871 913
872 - if ( empty( $user_agent ) ) {
873 - return $device_info;
914 + if ( isset( $session_data['access_expires'] ) ) {
915 + $access_expire = max( $access_expire, (int) $session_data['access_expires'] );
916 + } elseif ( isset( $session_data['expires'] ) ) {
917 + $access_expire = max( $access_expire, (int) $session_data['expires'] );
874 918 }
875 919
876 - // Detect WooCommerce POS apps first (custom identifiers)
877 - // Check for Electron app (including just "WooCommercePOS" in user agent with Electron).
878 - if ( preg_match( '/Electron/i', $user_agent ) && preg_match( '/WooCommercePOS|WCPOS/i', $user_agent ) ) {
879 - $device_info['app_type'] = 'electron_app';
880 - $device_info['browser'] = 'WooCommerce POS';
881 - $device_info['device_type'] = 'desktop';
882 - // Try to extract WooCommercePOS version.
883 - if ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
884 - $device_info['browser_version'] = $matches[1];
885 - } elseif ( preg_match( '/WCPOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
886 - $device_info['browser_version'] = $matches[1];
887 - }
888 - } elseif ( preg_match( '/WCPOS[-_]?iOS|WooCommercePOS[-_]?iOS/i', $user_agent ) ) {
889 - $device_info['app_type'] = 'ios_app';
890 - $device_info['browser'] = 'WooCommerce POS';
891 - // Default to tablet unless explicitly detected as phone.
892 - $device_info['device_type'] = preg_match( '/iphone|ipod/i', $user_agent ) ? 'mobile' : 'tablet';
893 - if ( preg_match( '/WCPOS[-_]?iOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
894 - $device_info['browser_version'] = $matches[1];
895 - } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
896 - $device_info['browser_version'] = $matches[1];
897 - }
898 - } elseif ( preg_match( '/WCPOS[-_]?Android|WooCommercePOS[-_]?Android/i', $user_agent ) ) {
899 - $device_info['app_type'] = 'android_app';
900 - $device_info['browser'] = 'WooCommerce POS';
901 - // Default to tablet unless explicitly detected as mobile.
902 - $device_info['device_type'] = preg_match( '/mobile/i', $user_agent ) && ! preg_match( '/tablet/i', $user_agent ) ? 'mobile' : 'tablet';
903 - if ( preg_match( '/WCPOS[-_]?Android[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
904 - $device_info['browser_version'] = $matches[1];
905 - } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
906 - $device_info['browser_version'] = $matches[1];
907 - }
908 - }
909 -
910 - // Detect standard device type (if not already set by app detection).
911 - if ( 'web' === $device_info['app_type'] ) {
912 - if ( preg_match( '/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i', $user_agent ) ) {
913 - $device_info['device_type'] = 'mobile';
914 - } elseif ( preg_match( '/tablet|ipad|playbook|silk/i', $user_agent ) ) {
915 - $device_info['device_type'] = 'tablet';
916 - } else {
917 - $device_info['device_type'] = 'desktop';
918 - }
919 - }
920 -
921 - // Detect browser (skip if we already detected a WCPOS app).
922 - if ( 'WooCommerce POS' !== $device_info['browser'] ) {
923 - if ( preg_match( '/MSIE|Trident/i', $user_agent ) ) {
924 - $device_info['browser'] = 'Internet Explorer';
925 - if ( preg_match( '/MSIE ([0-9.]+)/', $user_agent, $matches ) ) {
926 - $device_info['browser_version'] = $matches[1];
927 - }
928 - } elseif ( preg_match( '/Edge\/([0-9.]+)/i', $user_agent, $matches ) ) {
929 - $device_info['browser'] = 'Edge';
930 - $device_info['browser_version'] = $matches[1];
931 - } elseif ( preg_match( '/Edg\/([0-9.]+)/i', $user_agent, $matches ) ) {
932 - $device_info['browser'] = 'Edge';
933 - $device_info['browser_version'] = $matches[1];
934 - } elseif ( preg_match( '/Firefox\/([0-9.]+)/i', $user_agent, $matches ) ) {
935 - $device_info['browser'] = 'Firefox';
936 - $device_info['browser_version'] = $matches[1];
937 - } elseif ( preg_match( '/Chrome\/([0-9.]+)/i', $user_agent, $matches ) ) {
938 - $device_info['browser'] = 'Chrome';
939 - $device_info['browser_version'] = $matches[1];
940 - } elseif ( preg_match( '/Safari\/([0-9.]+)/i', $user_agent, $matches ) ) {
941 - // Safari should be checked after Chrome because Chrome also contains Safari.
942 - if ( ! preg_match( '/Chrome/i', $user_agent ) ) {
943 - $device_info['browser'] = 'Safari';
944 - $device_info['browser_version'] = $matches[1];
945 - }
946 - } elseif ( preg_match( '/Opera\/([0-9.]+)/i', $user_agent, $matches ) ) {
947 - $device_info['browser'] = 'Opera';
948 - $device_info['browser_version'] = $matches[1];
949 - }
950 - }
951 -
952 - // Detect OS.
953 - if ( preg_match( '/Windows NT ([0-9.]+)/i', $user_agent, $matches ) ) {
954 - $device_info['os'] = 'Windows';
955 - } elseif ( preg_match( '/Mac OS X ([0-9_]+)/i', $user_agent, $matches ) ) {
956 - $device_info['os'] = 'macOS';
957 - } elseif ( preg_match( '/Android ([0-9.]+)/i', $user_agent, $matches ) ) {
958 - $device_info['os'] = 'Android';
959 - } elseif ( preg_match( '/iPhone OS ([0-9_]+)/i', $user_agent, $matches ) ) {
960 - $device_info['os'] = 'iOS';
961 - } elseif ( preg_match( '/iPad.*OS ([0-9_]+)/i', $user_agent, $matches ) ) {
962 - $device_info['os'] = 'iPadOS';
963 - } elseif ( preg_match( '/Linux/i', $user_agent ) ) {
964 - $device_info['os'] = 'Linux';
965 - }
966 -
967 - return $device_info;
920 + return max( 0, $access_expire - $issued_at );
968 921 }
969 922
970 923 /**
971 924 * Check if a token JTI is blacklisted.