PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
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 +285 -338 1.9.161.10.18 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,8 +249,17 @@
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;
@@ -165,8 +283,26 @@
165 283 *
166 284 * @return string|WP_Error
167 285 */
168 286 public function generate_access_token( WP_User $user, string $refresh_jti = '' ) {
287 + $token_data = $this->generate_access_token_data( $user, $refresh_jti );
288 +
289 + if ( is_wp_error( $token_data ) ) {
290 + return $token_data;
291 + }
292 +
293 + return $token_data['token'];
294 + }
295 +
296 + /**
297 + * Generate an access token and return the token metadata used by callers.
298 + *
299 + * @param WP_User $user The user object.
300 + * @param string $refresh_jti Optional refresh token JTI to link access token to session.
301 + *
302 + * @return array|WP_Error
303 + */
304 + private function generate_access_token_data( WP_User $user, string $refresh_jti = '' ) {
169 305 // First thing, check the secret key if not exist return a error.
170 306 if ( ! $this->get_secret_key() ) {
171 307 return new WP_Error(
172 308 'woocommerce_pos_jwt_auth_bad_config',
@@ -178,24 +314,10 @@
178 314 }
179 315
180 316 /** Valid credentials, the user exists create the according Token */
181 317 $issued_at = time();
318 + $expire = $this->get_access_token_expire( $issued_at );
182 319
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 320 // Generate unique JTI for access token.
199 321 $jti = wp_generate_uuid4();
200 322
201 323 $token = array(
@@ -227,9 +349,29 @@
227 349 * @since 1.8.0
228 350 *
229 351 * @hook woocommerce_pos_jwt_access_token_before_sign
230 352 */
231 - return JWT::encode( apply_filters( 'woocommerce_pos_jwt_access_token_before_sign', $token, $user ), $this->get_secret_key(), 'HS256' );
353 + $payload = apply_filters( 'woocommerce_pos_jwt_access_token_before_sign', $token, $user );
354 + $token = JWT::encode( $payload, $this->get_secret_key(), 'HS256' );
355 +
356 + $expires_at = $this->get_payload_claim( $payload, 'exp' );
357 + $access_jti = $this->get_payload_claim( $payload, 'jti' );
358 + $linked_refresh_jti = $this->get_payload_claim( $payload, 'refresh_jti' );
359 +
360 + $expires_at = null === $expires_at ? $expire : (int) $expires_at;
361 + $access_jti = null === $access_jti ? $jti : (string) $access_jti;
362 +
363 + if ( null !== $linked_refresh_jti ) {
364 + $linked_refresh_jti = (string) $linked_refresh_jti;
365 + $this->sessions->record_access_expiry( $user->ID, $linked_refresh_jti, $expires_at );
366 + }
367 +
368 + return array(
369 + 'token' => $token,
370 + 'expires_at' => $expires_at,
371 + 'jti' => $access_jti,
372 + 'refresh_jti' => $linked_refresh_jti,
373 + );
232 374 }
233 375
234 376 /**
235 377 * Generate a refresh token for the provided user (long-lived).
@@ -251,24 +393,10 @@
251 393 }
252 394
253 395 /** Valid credentials, the user exists create the according Token */
254 396 $issued_at = time();
397 + $expire = $this->get_refresh_token_expire( $issued_at );
255 398
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 399 // Generate unique JTI (JWT ID) for refresh token tracking.
272 400 $jti = wp_generate_uuid4();
273 401
274 402 $token = array(
@@ -298,9 +426,26 @@
298 426 */
299 427 $token = JWT::encode( apply_filters( 'woocommerce_pos_jwt_refresh_token_before_sign', $token, $user ), $this->get_refresh_secret_key(), 'HS256' );
300 428
301 429 // Store refresh token JTI for potential revocation.
302 - $this->store_refresh_token_jti( $user->ID, $jti, $expire );
430 + $evicted = $this->sessions->record( $user->ID, $jti, $expire, Session_Context::from_request() );
431 + $issued_at = time();
432 + foreach ( $evicted as $evicted_jti => $token_data ) {
433 + /*
434 + * Blacklist ONLY a session that can still hold a live access token. An eviction
435 + * is not a revoke: clearing a bloated row can drop thousands of long-dead
436 + * sessions at once, and a transient for each would guard nothing — an expired
437 + * access token is already rejected on its own `exp` claim, and the refresh token
438 + * dies with the meta entry (`is_live()` requires the entry). This
439 + * also bounds each transient this path writes to one access-token lifetime,
440 + * rather than the refresh-token expiry `get_access_token_blacklist_ttl()` falls
441 + * back to for a session with no recorded access-token expiry.
442 + */
443 + $horizon = $this->access_token_horizon( $token_data );
444 + if ( $horizon > $issued_at ) {
445 + $this->blacklist_token( $evicted_jti, $horizon - $issued_at );
446 + }
447 + }
303 448
304 449 return $token;
305 450 }
306 451
@@ -324,21 +469,18 @@
324 469 return $decoded_refresh;
325 470 }
326 471
327 472 // 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;
473 + $access_token_data = $this->generate_access_token_data( $user, $decoded_refresh->jti ?? '' );
474 + if ( is_wp_error( $access_token_data ) ) {
475 + return $access_token_data;
331 476 }
332 477
333 - $issued_at = time();
334 - $expire = apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
335 -
336 478 return array(
337 - 'access_token' => $access_token,
479 + 'access_token' => $access_token_data['token'],
338 480 'refresh_token' => $refresh_token,
339 481 'token_type' => 'Bearer',
340 - 'expires_at' => (int) $expire,
482 + 'expires_at' => (int) $access_token_data['expires_at'],
341 483 );
342 484 }
343 485
344 486 /**
@@ -389,8 +531,10 @@
389 531 'last_name' => $user->user_lastname,
390 532 'nice_name' => $user->user_nicename,
391 533 'display_name' => $user->display_name,
392 534 'roles' => array_values( $user->roles ),
535 + // The helper reports effective grants, including role-editor denies.
536 + 'capabilities' => Access_Section::effective_capabilities( $user ),
393 537 'avatar_url' => get_avatar_url( $user->ID ),
394 538 // Token data.
395 539 'access_token' => $tokens['access_token'],
396 540 'refresh_token' => $tokens['refresh_token'],
@@ -437,10 +581,18 @@
437 581 if ( is_wp_error( $decoded ) ) {
438 582 return $decoded;
439 583 }
440 584
585 + /*
586 + * Before the first row read on this path. A refresh loads the whole session row —
587 + * `is_live()` below, then `refresh_activity()` — so it needs
588 + * the same protection a login has against a row too large to read (#1776).
589 + * Validating an ACCESS token needs no such guard: it no longer touches the row.
590 + */
591 + $this->sessions->guard_row( absint( $decoded->data->user->id ) );
592 +
441 593 // Check if refresh token is still valid (not revoked).
442 - if ( ! $this->is_refresh_token_valid( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
594 + if ( ! $this->sessions->is_live( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
443 595 return new WP_Error(
444 596 'woocommerce_pos_auth_refresh_token_revoked',
445 597 'Refresh token has been revoked',
446 598 array( 'status' => 403 )
@@ -459,20 +611,17 @@
459 611 // Update last_active timestamp for this session.
460 612 $this->update_session_activity( $decoded->data->user->id, $decoded->jti ?? '' );
461 613
462 614 // 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;
615 + $new_access_token_data = $this->generate_access_token_data( $user, $decoded->jti ?? '' );
616 + if ( is_wp_error( $new_access_token_data ) ) {
617 + return $new_access_token_data;
466 618 }
467 619
468 - $issued_at = time();
469 - $expire = apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
470 -
471 620 return array(
472 - 'access_token' => $new_access_token,
621 + 'access_token' => $new_access_token_data['token'],
473 622 'token_type' => 'Bearer',
474 - 'expires_at' => (int) $expire,
623 + 'expires_at' => (int) $new_access_token_data['expires_at'],
475 624 );
476 625 }
477 626
478 627 /**
@@ -483,21 +632,9 @@
483 632 *
484 633 * @return bool
485 634 */
486 635 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;
636 + return $this->sessions->revoke( $user_id, $jti );
500 637 }
501 638
502 639 /**
503 640 * Revoke all refresh tokens for a user.
@@ -513,22 +650,23 @@
513 650 *
514 651 * @return bool
515 652 */
516 653 public function revoke_all_refresh_tokens( int $user_id ): bool {
517 - $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
654 + $refresh_tokens = $this->sessions->entries( $user_id );
518 655
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 );
656 + // Blacklist all sessions for instant access token invalidation. The expiry
657 + // policy is only consulted when there is something to blacklist.
658 + if ( array() !== $refresh_tokens ) {
659 + $issued_at = time();
660 + $access_expire = $this->get_access_token_expire( $issued_at );
524 661
525 662 foreach ( $refresh_tokens as $jti => $token_data ) {
663 + $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
526 664 $this->blacklist_token( $jti, $ttl );
527 665 }
528 666 }
529 667
530 - return delete_user_meta( $user_id, '_woocommerce_pos_refresh_tokens' );
668 + return $this->sessions->revoke_all( $user_id );
531 669 }
532 670
533 671 /**
534 672 * Get all active sessions for a user.
@@ -537,42 +675,9 @@
537 675 *
538 676 * @return array
539 677 */
540 678 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;
679 + return $this->sessions->list( $user_id );
575 680 }
576 681
577 682 /**
578 683 * Revoke a specific session by JTI (alias for revoke_refresh_token for clarity).
@@ -602,34 +707,26 @@
602 707 *
603 708 * @return bool
604 709 */
605 710 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 ) ) {
711 + $refresh_tokens = $this->sessions->entries( $user_id );
712 + if ( array() === $refresh_tokens ) {
713 + // No row (or nothing in it): nothing to blacklist, nothing to rewrite.
608 714 return false;
609 715 }
610 716
611 717 // 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 );
718 + $issued_at = time();
719 + $access_expire = $this->get_access_token_expire( $issued_at );
615 720
616 721 foreach ( $refresh_tokens as $jti => $token_data ) {
617 722 if ( $jti !== $current_jti ) {
723 + $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
618 724 $this->blacklist_token( $jti, $ttl );
619 725 }
620 726 }
621 727
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 );
728 + return $this->sessions->keep_only( $user_id, $current_jti );
632 729 }
633 730
634 731 /**
635 732 * Update last_active timestamp for a session.
@@ -639,16 +736,9 @@
639 736 *
640 737 * @return bool
641 738 */
642 739 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 );
740 + return $this->sessions->refresh_activity( $user_id, $jti );
651 741 }
652 742
653 743 /**
654 744 * Check if the current user can manage sessions for the target user.
@@ -710,18 +800,17 @@
710 800 *
711 801 * @return bool
712 802 */
713 803 public function revoke_session_with_blacklist( int $user_id, string $refresh_jti ): bool {
804 + $session_data = $this->sessions->entry( $user_id, $refresh_jti );
805 + $ttl = $this->get_access_token_blacklist_ttl( $session_data );
806 +
714 807 // Revoke the refresh token (session) from user meta.
715 808 $revoked = $this->revoke_session( $user_id, $refresh_jti );
716 809
717 810 if ( $revoked ) {
718 811 // 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 -
812 + // TTL covers the current policy and any access token expiry recorded for the session.
724 813 $this->blacklist_token( $refresh_jti, $ttl );
725 814 }
726 815
727 816 return $revoked;
@@ -727,245 +816,103 @@
727 816 return $revoked;
728 817 }
729 818
730 819 /**
731 - * Store refresh token JTI for tracking/revocation.
820 + * The last moment an access token minted against a session can still validate.
732 821 *
733 - * @param int $user_id The user ID.
734 - * @param string $jti The token JTI.
735 - * @param int $expires The expiration timestamp.
822 + * @param array $token_data Stored session record.
823 + *
824 + * @return int Unix timestamp; 0 when the session carries no usable timestamp at all.
736 825 */
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();
826 + private function access_token_horizon( array $token_data ): int {
827 + if ( isset( $token_data['access_expires'] ) ) {
828 + return (int) $token_data['access_expires'];
741 829 }
742 830
743 - // Clean up expired tokens.
744 - $refresh_tokens = array_filter(
745 - $refresh_tokens,
746 - function ( $token ) {
747 - return $token['expires'] > time();
748 - }
749 - );
831 + // Rows written before `access_expires` was recorded. The newest access token such a
832 + // session can hold was minted no later than its last recorded activity, so one
833 + // access-token lifetime past that moment is the outside limit.
834 + $last_seen = (int) ( $token_data['last_active'] ?? $token_data['created'] ?? 0 );
750 835
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 );
836 + return $last_seen > 0 ? $this->get_access_token_expire( $last_seen ) : 0;
837 + }
756 838
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 );
839 + /**
840 + * Filters the JWT access token expire time.
841 + * Default: 30 minutes for access tokens.
842 + *
843 + * @param int $issued_at Token issued timestamp.
844 + *
845 + * @return int Expire time.
846 + *
847 + * @since 1.8.0
848 + *
849 + * @hook woocommerce_pos_jwt_access_token_expire
850 + */
851 + private function get_access_token_expire( int $issued_at ): int {
852 + return (int) apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
800 853 }
801 854
802 855 /**
803 - * Check if refresh token is still valid (not revoked).
856 + * Filters the JWT refresh token expire time.
857 + * Default: 30 days for refresh tokens.
804 858 *
805 - * @param int $user_id The user ID.
806 - * @param string $jti The token JTI.
859 + * @param int $issued_at Token issued timestamp.
807 860 *
808 - * @return bool
861 + * @return int Expire time.
862 + *
863 + * @since 1.8.0
864 + *
865 + * @hook woocommerce_pos_jwt_refresh_token_expire
809 866 */
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();
867 + private function get_refresh_token_expire( int $issued_at ): int {
868 + return (int) apply_filters( 'woocommerce_pos_jwt_refresh_token_expire', $issued_at + ( DAY_IN_SECONDS * 30 ), $issued_at );
817 869 }
818 870
819 871 /**
820 - * Get client IP address.
872 + * Read a top-level claim from a JWT payload array/object.
821 873 *
822 - * @return string
874 + * @param mixed $payload The filtered JWT payload.
875 + * @param string $claim The claim name.
876 + *
877 + * @return mixed|null
823 878 */
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 - }
879 + private function get_payload_claim( $payload, string $claim ) {
880 + if ( \is_array( $payload ) && array_key_exists( $claim, $payload ) ) {
881 + return $payload[ $claim ];
846 882 }
847 883
848 - // Validate and sanitize IP.
849 - if ( filter_var( $ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 ) ) {
850 - return $ip_address;
884 + if ( \is_object( $payload ) && isset( $payload->{$claim} ) ) {
885 + return $payload->{$claim};
851 886 }
852 887
853 - return '';
888 + return null;
854 889 }
855 890
856 891 /**
857 - * Parse user agent string to extract device information.
892 + * Calculate blacklist TTL for a session.
858 893 *
859 - * @param string $user_agent The user agent string.
894 + * @param array $session_data Session metadata.
895 + * @param null|int $issued_at Current timestamp.
896 + * @param null|int $access_expire Current access token expiry policy value.
860 897 *
861 - * @return array
898 + * @return int
862 899 */
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 - );
900 + private function get_access_token_blacklist_ttl(
901 + array $session_data = array(),
902 + ?int $issued_at = null,
903 + ?int $access_expire = null
904 + ): int {
905 + $issued_at = null === $issued_at ? time() : $issued_at;
906 + $access_expire = null === $access_expire ? $this->get_access_token_expire( $issued_at ) : $access_expire;
871 907
872 - if ( empty( $user_agent ) ) {
873 - return $device_info;
908 + if ( isset( $session_data['access_expires'] ) ) {
909 + $access_expire = max( $access_expire, (int) $session_data['access_expires'] );
910 + } elseif ( isset( $session_data['expires'] ) ) {
911 + $access_expire = max( $access_expire, (int) $session_data['expires'] );
874 912 }
875 913
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;
914 + return max( 0, $access_expire - $issued_at );
968 915 }
969 916
970 917 /**
971 918 * Check if a token JTI is blacklisted.