PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.9
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.9
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
woocommerce-pos / includes / Services / Auth.php

Auth.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.9, at includes/Services/Auth.php

1,561 lines 51.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Auth.
4 *
5 * @package WCPOS\WooCommercePOS
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 use Exception;
11 use WCPOS\Vendor\Firebase\JWT\JWT;
12 use WCPOS\Vendor\Firebase\JWT\Key;
13 use WCPOS\WooCommercePOS\Logger;
14 use WCPOS\WooCommercePOS\Services\Settings\Access_Section;
15 use WP_Error;
16 use WP_User;
17 use const DAY_IN_SECONDS;
18 use const HOUR_IN_SECONDS;
19 use const MINUTE_IN_SECONDS;
20
21 /**
22 * Auth Service class.
23 */
24 class Auth {
25 /**
26 * Maximum number of refresh-token sessions retained per user.
27 *
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.
38 */
39 public const MAX_SESSIONS_PER_USER = 200;
40
41 /**
42 * How long a session must have gone unseen before eviction may remove it.
43 *
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.
51 */
52 public const SESSION_EVICTION_IDLE_SECONDS = 7 * DAY_IN_SECONDS;
53
54 /**
55 * How stale a session's `last_active` may get before an authenticated request rewrites it.
56 *
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.
62 */
63 private const SESSION_ACTIVITY_REFRESH_SECONDS = 5 * MINUTE_IN_SECONDS;
64
65 /**
66 * Transient prefix for the per-session "last seen" record.
67 *
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.
76 */
77 private const SESSION_SEEN_TRANSIENT_PREFIX = 'wcpos_session_seen_';
78
79 /**
80 * Byte ceiling on the stored session row before it is discarded UNREAD.
81 *
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.
91 */
92 public const MAX_SESSIONS_ROW_BYTES = 6291456;
93
94 /**
95 * The single instance of the class.
96 *
97 * @var null|Auth
98 */
99 private static $instance = null;
100
101 /**
102 * Constructor is private to prevent direct instantiation.
103 * Or Auth::instance() instead.
104 */
105 public function __construct() {
106 }
107
108 /**
109 * Gets the singleton instance.
110 *
111 * @return Auth
112 */
113 public static function instance(): self {
114 if ( null === self::$instance ) {
115 self::$instance = new self();
116 }
117
118 return self::$instance;
119 }
120
121 /**
122 * Extract a WCPOS token from an authorization value.
123 *
124 * @param mixed $auth_value Authorization value.
125 *
126 * @return null|string
127 */
128 public function extract_token( $auth_value ): ?string {
129 if ( ! \is_string( $auth_value ) || '' === $auth_value ) {
130 return null;
131 }
132
133 // Match the old sscanf( 'Bearer %s' ) semantics exactly: any run of
134 // whitespace after the scheme, token = the next non-whitespace run.
135 if ( 1 === preg_match( '/^Bearer\s+(\S+)/', $auth_value, $matches ) ) {
136 return $matches[1];
137 }
138
139 return 1 === preg_match( '/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $auth_value ) ? $auth_value : null;
140 }
141
142 /**
143 * Authenticate the current request from its WCPOS token.
144 *
145 * @return false|int|WP_Error User ID, validation error, or false when no WCPOS token is present.
146 */
147 public function authenticate_request() {
148 $auth_header = $this->get_auth_header();
149 $token = $this->extract_token( $auth_header );
150 if ( null === $token ) {
151 return false;
152 }
153
154 $decoded_token = $this->validate_token( $token );
155 if ( is_wp_error( $decoded_token ) ) {
156 return $decoded_token;
157 }
158
159 return absint( $decoded_token->data->user->id );
160 }
161
162 /**
163 * Get authorization header/param value.
164 *
165 * Checks multiple sources for the authorization token:
166 * 1. HTTP_AUTHORIZATION server variable (standard)
167 * 2. REDIRECT_HTTP_AUTHORIZATION (Apache CGI workaround)
168 * 3. authorization query parameter (for servers that strip auth headers)
169 *
170 * @return false|string The authorization value or false if not found.
171 */
172 public function get_auth_header() {
173 // Check HTTP_AUTHORIZATION (not empty - htaccess SetEnvIf can set empty value).
174 if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
175 return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
176 }
177
178 // Check REDIRECT_HTTP_AUTHORIZATION (Apache CGI).
179 if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
180 return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
181 }
182
183 // Check authorization query param.
184 if ( ! empty( $_GET['authorization'] ) ) {
185 return sanitize_text_field( wp_unslash( $_GET['authorization'] ) );
186 }
187
188 return false;
189 }
190
191 /**
192 * Generate a secret key if it doesn't exist, or return the existing one.
193 *
194 * @return string
195 */
196 public function get_secret_key(): string {
197 $secret_key = get_option( 'woocommerce_pos_secret_key' );
198 if ( false === $secret_key || empty( $secret_key ) ) {
199 $secret_key = wp_generate_password( 64, true, true );
200 update_option( 'woocommerce_pos_secret_key', $secret_key );
201 }
202
203 return $secret_key;
204 }
205
206 /**
207 * Get refresh token secret key (separate from access token key for security).
208 *
209 * @return string
210 */
211 public function get_refresh_secret_key(): string {
212 $secret_key = get_option( 'woocommerce_pos_refresh_secret_key' );
213 if ( false === $secret_key || empty( $secret_key ) ) {
214 $secret_key = wp_generate_password( 64, true, true );
215 update_option( 'woocommerce_pos_refresh_secret_key', $secret_key );
216 }
217
218 return $secret_key;
219 }
220
221 /**
222 * Validate the provided JWT token.
223 *
224 * @param string $token The JWT token.
225 * @param string $token_type The token type: 'access' or 'refresh'.
226 *
227 * @return object|WP_Error
228 */
229 public function validate_token( $token = '', $token_type = 'access' ) {
230 try {
231 $secret_key = 'refresh' === $token_type ? $this->get_refresh_secret_key() : $this->get_secret_key();
232 $decoded_token = JWT::decode( $token, new Key( $secret_key, 'HS256' ) ); // @phpstan-ignore-line
233
234 // The Token is decoded now validate the iss.
235 if ( get_bloginfo( 'url' ) != $decoded_token->iss ) {
236 // The iss do not match, return error.
237 return new WP_Error(
238 'woocommmerce_pos_auth_bad_iss',
239 'The iss do not match with this server',
240 array( 'status' => 403 )
241 );
242 }
243
244 // Validate token type.
245 if ( ! isset( $decoded_token->type ) || $decoded_token->type !== $token_type ) {
246 return new WP_Error(
247 'woocommmerce_pos_auth_invalid_token_type',
248 'Invalid token type',
249 array( 'status' => 403 )
250 );
251 }
252
253 // So far so good, validate the user id in the token.
254 if ( ! isset( $decoded_token->data->user->id ) ) {
255 // No user id in the token, abort!!
256 return new WP_Error(
257 'woocommmerce_pos_auth_bad_request',
258 'User ID not found in the token',
259 array(
260 'status' => 403,
261 )
262 );
263 }
264
265 // Check if access token is blacklisted (for instant revocation)
266 // We check both the access token's own JTI and its parent refresh_jti.
267 if ( 'access' === $token_type ) {
268 // Check if this specific access token is blacklisted.
269 if ( isset( $decoded_token->jti ) && $this->is_token_blacklisted( $decoded_token->jti ) ) {
270 return new WP_Error(
271 'woocommerce_pos_auth_token_revoked',
272 'Access token has been revoked',
273 array( 'status' => 403 )
274 );
275 }
276
277 // Check if the parent session (refresh token) is blacklisted
278 // This catches ALL access tokens for a revoked session.
279 if ( isset( $decoded_token->refresh_jti ) && $this->is_token_blacklisted( $decoded_token->refresh_jti ) ) {
280 return new WP_Error(
281 'woocommerce_pos_auth_session_revoked',
282 'Session has been revoked',
283 array( 'status' => 403 )
284 );
285 }
286
287 // The session is live: record that, so eviction can tell a device that is
288 // working right now from one that has not been seen in a week.
289 if ( isset( $decoded_token->refresh_jti ) ) {
290 $this->touch_session_activity(
291 absint( $decoded_token->data->user->id ),
292 (string) $decoded_token->refresh_jti
293 );
294 }
295 }
296
297 // Everything looks good return the decoded token.
298 return $decoded_token;
299 } catch ( Exception $e ) {
300 // Something is wrong trying to decode the token, send back the error.
301 return new WP_Error(
302 'woocommmerce_pos_auth_invalid_token',
303 $e->getMessage(),
304 array(
305 'status' => 403,
306 )
307 );
308 }
309 }
310
311 /**
312 * Generate an access token for the provided user (short-lived).
313 *
314 * @param WP_User $user The user object.
315 * @param string $refresh_jti Optional refresh token JTI to link access token to session.
316 *
317 * @return string|WP_Error
318 */
319 public function generate_access_token( WP_User $user, string $refresh_jti = '' ) {
320 $token_data = $this->generate_access_token_data( $user, $refresh_jti );
321
322 if ( is_wp_error( $token_data ) ) {
323 return $token_data;
324 }
325
326 return $token_data['token'];
327 }
328
329 /**
330 * Generate an access token and return the token metadata used by callers.
331 *
332 * @param WP_User $user The user object.
333 * @param string $refresh_jti Optional refresh token JTI to link access token to session.
334 *
335 * @return array|WP_Error
336 */
337 private function generate_access_token_data( WP_User $user, string $refresh_jti = '' ) {
338 // First thing, check the secret key if not exist return a error.
339 if ( ! $this->get_secret_key() ) {
340 return new WP_Error(
341 'woocommerce_pos_jwt_auth_bad_config',
342 __( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ),
343 array(
344 'status' => 403,
345 )
346 );
347 }
348
349 /** Valid credentials, the user exists create the according Token */
350 $issued_at = time();
351 $expire = $this->get_access_token_expire( $issued_at );
352
353 // Generate unique JTI for access token.
354 $jti = wp_generate_uuid4();
355
356 $token = array(
357 'iss' => get_bloginfo( 'url' ),
358 'iat' => $issued_at,
359 'exp' => $expire,
360 'jti' => $jti,
361 'type' => 'access',
362 'data' => array(
363 'user' => array(
364 'id' => $user->data->ID,
365 ),
366 ),
367 );
368
369 // Link to refresh token if provided.
370 if ( ! empty( $refresh_jti ) ) {
371 $token['refresh_jti'] = $refresh_jti;
372 }
373
374 /*
375 * Let the user modify the access token data before the sign.
376 *
377 * @param {array} $token
378 * @param {WP_User} $user
379 *
380 * @returns {array} Token
381 *
382 * @since 1.8.0
383 *
384 * @hook woocommerce_pos_jwt_access_token_before_sign
385 */
386 $payload = apply_filters( 'woocommerce_pos_jwt_access_token_before_sign', $token, $user );
387 $token = JWT::encode( $payload, $this->get_secret_key(), 'HS256' );
388
389 $expires_at = $this->get_payload_claim( $payload, 'exp' );
390 $access_jti = $this->get_payload_claim( $payload, 'jti' );
391 $linked_refresh_jti = $this->get_payload_claim( $payload, 'refresh_jti' );
392
393 $expires_at = null === $expires_at ? $expire : (int) $expires_at;
394 $access_jti = null === $access_jti ? $jti : (string) $access_jti;
395
396 if ( null !== $linked_refresh_jti ) {
397 $linked_refresh_jti = (string) $linked_refresh_jti;
398 $this->store_access_token_expiry( $user->ID, $linked_refresh_jti, $expires_at );
399 }
400
401 return array(
402 'token' => $token,
403 'expires_at' => $expires_at,
404 'jti' => $access_jti,
405 'refresh_jti' => $linked_refresh_jti,
406 );
407 }
408
409 /**
410 * Generate a refresh token for the provided user (long-lived).
411 *
412 * @param WP_User $user The user object.
413 *
414 * @return string|WP_Error
415 */
416 public function generate_refresh_token( WP_User $user ) {
417 // First thing, check the secret key if not exist return a error.
418 if ( ! $this->get_refresh_secret_key() ) {
419 return new WP_Error(
420 'woocommerce_pos_jwt_auth_bad_config',
421 __( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ),
422 array(
423 'status' => 403,
424 )
425 );
426 }
427
428 /** Valid credentials, the user exists create the according Token */
429 $issued_at = time();
430 $expire = $this->get_refresh_token_expire( $issued_at );
431
432 // Generate unique JTI (JWT ID) for refresh token tracking.
433 $jti = wp_generate_uuid4();
434
435 $token = array(
436 'iss' => get_bloginfo( 'url' ),
437 'iat' => $issued_at,
438 'exp' => $expire,
439 'jti' => $jti,
440 'type' => 'refresh',
441 'data' => array(
442 'user' => array(
443 'id' => $user->data->ID,
444 ),
445 ),
446 );
447
448 /**
449 * Let the user modify the refresh token data before the sign.
450 *
451 * @param array $token
452 * @param WP_User $user
453 *
454 * @returns array Token
455 *
456 * @since 1.8.0
457 *
458 * @hook woocommerce_pos_jwt_refresh_token_before_sign
459 */
460 $token = JWT::encode( apply_filters( 'woocommerce_pos_jwt_refresh_token_before_sign', $token, $user ), $this->get_refresh_secret_key(), 'HS256' );
461
462 // Store refresh token JTI for potential revocation.
463 $this->store_refresh_token_jti( $user->ID, $jti, $expire );
464
465 return $token;
466 }
467
468 /**
469 * Generate both access and refresh tokens.
470 *
471 * @param WP_User $user The user object.
472 *
473 * @return array|WP_Error
474 */
475 public function generate_token_pair( WP_User $user ) {
476 // Generate refresh token first to get its JTI.
477 $refresh_token = $this->generate_refresh_token( $user );
478 if ( is_wp_error( $refresh_token ) ) {
479 return $refresh_token;
480 }
481
482 // Decode to get the JTI.
483 $decoded_refresh = $this->validate_token( $refresh_token, 'refresh' );
484 if ( is_wp_error( $decoded_refresh ) ) {
485 return $decoded_refresh;
486 }
487
488 // Generate access token with link to refresh token.
489 $access_token_data = $this->generate_access_token_data( $user, $decoded_refresh->jti ?? '' );
490 if ( is_wp_error( $access_token_data ) ) {
491 return $access_token_data;
492 }
493
494 return array(
495 'access_token' => $access_token_data['token'],
496 'refresh_token' => $refresh_token,
497 'token_type' => 'Bearer',
498 'expires_at' => (int) $access_token_data['expires_at'],
499 );
500 }
501
502 /**
503 * Legacy method for backward compatibility.
504 *
505 * @deprecated Use generate_access_token() instead
506 *
507 * @param WP_User $user The user object.
508 *
509 * @return string|WP_Error
510 */
511 public function generate_token( WP_User $user ) {
512 return $this->generate_access_token( $user );
513 }
514
515 /**
516 * Get user's data (minimal set for security).
517 *
518 * @param WP_User $user The user object.
519 * @param bool $is_web_frontend Whether this is the web frontend context.
520 * When true, manages web session cookie to prevent
521 * session proliferation on page refresh.
522 *
523 * @return array
524 */
525 public function get_user_data( WP_User $user, bool $is_web_frontend = false ): array {
526 // For web frontend, revoke previous session to prevent proliferation on page refresh.
527 if ( $is_web_frontend ) {
528 $this->cleanup_previous_web_session( $user->ID );
529 }
530
531 $tokens = $this->generate_token_pair( $user );
532 if ( is_wp_error( $tokens ) ) {
533 return array();
534 }
535
536 // For web frontend, store the new session JTI in a cookie for cleanup on next page load.
537 if ( $is_web_frontend ) {
538 $this->set_web_session_cookie( $tokens['refresh_token'] );
539 }
540
541 return array(
542 'uuid' => Cashier::instance()->get_cashier_uuid( $user ),
543 'id' => $user->ID,
544 'username' => $user->user_login,
545 'email' => $user->user_email,
546 'first_name' => $user->user_firstname,
547 'last_name' => $user->user_lastname,
548 'nice_name' => $user->user_nicename,
549 'display_name' => $user->display_name,
550 'roles' => array_values( $user->roles ),
551 // Raw grants (role + user), the same vocabulary the POS Access settings
552 // screen reads and writes. user_can() is wrong here: the singular meta
553 // caps (edit_product, delete_product) cannot be checked without a post.
554 'capabilities' => array_values(
555 array_filter( Access_Section::capability_names(), fn( $cap ) => ! empty( $user->allcaps[ $cap ] ) )
556 ),
557 'avatar_url' => get_avatar_url( $user->ID ),
558 // Token data.
559 'access_token' => $tokens['access_token'],
560 'refresh_token' => $tokens['refresh_token'],
561 'token_type' => $tokens['token_type'],
562 'expires_at' => $tokens['expires_at'],
563 );
564 }
565
566 /**
567 * Get minimal user data for redirect (security-focused).
568 *
569 * @param WP_User $user The user object.
570 *
571 * @return array
572 */
573 public function get_redirect_data( WP_User $user ): array {
574 $tokens = $this->generate_token_pair( $user );
575 if ( is_wp_error( $tokens ) ) {
576 return array();
577 }
578
579 // Only return essential data for redirect URL.
580 return array(
581 'access_token' => $tokens['access_token'],
582 'refresh_token' => $tokens['refresh_token'],
583 'token_type' => $tokens['token_type'],
584 'expires_at' => $tokens['expires_at'],
585 // Get basic user data for display, other data will be fetched from the server.
586 'uuid' => Cashier::instance()->get_cashier_uuid( $user ),
587 'id' => $user->ID,
588 'display_name' => $user->display_name,
589 );
590 }
591
592 /**
593 * Refresh an access token using a valid refresh token.
594 *
595 * @param string $refresh_token The refresh token.
596 *
597 * @return array|WP_Error
598 */
599 public function refresh_access_token( string $refresh_token ) {
600 $decoded = $this->validate_token( $refresh_token, 'refresh' );
601 if ( is_wp_error( $decoded ) ) {
602 return $decoded;
603 }
604
605 /*
606 * Before the first row read on this path. A refresh loads the whole session row —
607 * `is_refresh_token_valid()` below, then `update_session_activity()` — so it needs
608 * the same protection a login has against a row too large to read (#1776).
609 * Validating an ACCESS token needs no such guard: it no longer touches the row.
610 */
611 $this->discard_oversized_session_row( absint( $decoded->data->user->id ) );
612
613 // Check if refresh token is still valid (not revoked).
614 if ( ! $this->is_refresh_token_valid( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
615 return new WP_Error(
616 'woocommerce_pos_auth_refresh_token_revoked',
617 'Refresh token has been revoked',
618 array( 'status' => 403 )
619 );
620 }
621
622 $user = get_user_by( 'id', $decoded->data->user->id );
623 if ( ! $user ) {
624 return new WP_Error(
625 'woocommerce_pos_auth_user_not_found',
626 'User not found',
627 array( 'status' => 404 )
628 );
629 }
630
631 // Update last_active timestamp for this session.
632 $this->update_session_activity( $decoded->data->user->id, $decoded->jti ?? '' );
633
634 // Generate new access token with link to refresh token (refresh token stays the same).
635 $new_access_token_data = $this->generate_access_token_data( $user, $decoded->jti ?? '' );
636 if ( is_wp_error( $new_access_token_data ) ) {
637 return $new_access_token_data;
638 }
639
640 return array(
641 'access_token' => $new_access_token_data['token'],
642 'token_type' => 'Bearer',
643 'expires_at' => (int) $new_access_token_data['expires_at'],
644 );
645 }
646
647 /**
648 * Revoke JWT Token by JTI.
649 *
650 * @param int $user_id The user ID.
651 * @param string $jti The token JTI.
652 *
653 * @return bool
654 */
655 public function revoke_refresh_token( int $user_id, string $jti ): bool {
656 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
657 if ( ! \is_array( $refresh_tokens ) ) {
658 return false;
659 }
660
661 if ( isset( $refresh_tokens[ $jti ] ) ) {
662 unset( $refresh_tokens[ $jti ] );
663 update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
664 $this->forget_session_activity( $jti );
665
666 return true;
667 }
668
669 return false;
670 }
671
672 /**
673 * Revoke all refresh tokens for a user.
674 *
675 * @param int $user_id The user ID.
676 *
677 * @return bool
678 */
679 /**
680 * Revoke all refresh tokens for a user with blacklisting.
681 *
682 * @param int $user_id The user ID.
683 *
684 * @return bool
685 */
686 public function revoke_all_refresh_tokens( int $user_id ): bool {
687 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
688
689 // Blacklist all sessions for instant access token invalidation.
690 if ( \is_array( $refresh_tokens ) ) {
691 $issued_at = time();
692 $access_expire = $this->get_access_token_expire( $issued_at );
693
694 foreach ( $refresh_tokens as $jti => $token_data ) {
695 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
696 $this->blacklist_token( $jti, $ttl );
697 $this->forget_session_activity( (string) $jti );
698 }
699 }
700
701 return delete_user_meta( $user_id, '_woocommerce_pos_refresh_tokens' );
702 }
703
704 /**
705 * Get all active sessions for a user.
706 *
707 * @param int $user_id The user ID.
708 *
709 * @return array
710 */
711 public function get_user_sessions( int $user_id ): array {
712 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
713 if ( ! \is_array( $refresh_tokens ) ) {
714 return array();
715 }
716
717 $sessions = array();
718 $current_time = time();
719
720 foreach ( $refresh_tokens as $jti => $token_data ) {
721 // Skip expired sessions.
722 if ( $token_data['expires'] <= $current_time ) {
723 continue;
724 }
725
726 $sessions[] = array(
727 'jti' => $jti,
728 'created' => $token_data['created'] ?? $current_time,
729 'last_active' => $token_data['last_active'] ?? $token_data['created'] ?? $current_time,
730 'expires' => $token_data['expires'],
731 'ip_address' => $token_data['ip_address'] ?? '',
732 'user_agent' => $token_data['user_agent'] ?? '',
733 'device_info' => $token_data['device_info'] ?? array(),
734 );
735 }
736
737 // Sort by last_active descending (most recent first).
738 usort(
739 $sessions,
740 function ( $a, $b ) {
741 return $b['last_active'] - $a['last_active'];
742 }
743 );
744
745 return $sessions;
746 }
747
748 /**
749 * Revoke a specific session by JTI (alias for revoke_refresh_token for clarity).
750 *
751 * @param int $user_id The user ID.
752 * @param string $jti The token JTI.
753 *
754 * @return bool
755 */
756 public function revoke_session( int $user_id, string $jti ): bool {
757 return $this->revoke_refresh_token( $user_id, $jti );
758 }
759
760 /**
761 * Revoke all sessions except the current one.
762 *
763 * @param int $user_id The user ID.
764 * @param string $current_jti The current token JTI.
765 *
766 * @return bool
767 */
768 /**
769 * Revoke all sessions except the current one, with blacklisting.
770 *
771 * @param int $user_id The user ID.
772 * @param string $current_jti The current token JTI.
773 *
774 * @return bool
775 */
776 public function revoke_all_sessions_except( int $user_id, string $current_jti ): bool {
777 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
778 if ( ! \is_array( $refresh_tokens ) ) {
779 return false;
780 }
781
782 // Blacklist all sessions except current for instant access token invalidation.
783 $issued_at = time();
784 $access_expire = $this->get_access_token_expire( $issued_at );
785
786 foreach ( $refresh_tokens as $jti => $token_data ) {
787 if ( $jti !== $current_jti ) {
788 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
789 $this->blacklist_token( $jti, $ttl );
790 $this->forget_session_activity( (string) $jti );
791 }
792 }
793
794 // Keep only the current session in user meta.
795 $refresh_tokens = array_filter(
796 $refresh_tokens,
797 function ( $_token, $jti ) use ( $current_jti ) {
798 return $jti === $current_jti;
799 },
800 ARRAY_FILTER_USE_BOTH
801 );
802
803 return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
804 }
805
806 /**
807 * Update last_active timestamp for a session.
808 *
809 * @param int $user_id The user ID.
810 * @param string $jti The token JTI.
811 *
812 * @return bool
813 */
814 public function update_session_activity( int $user_id, string $jti ): bool {
815 // Public surface: any caller reaching the row goes through the size guard first.
816 $this->discard_oversized_session_row( $user_id );
817
818 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
819 if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $jti ] ) ) {
820 return false;
821 }
822
823 $refresh_tokens[ $jti ]['last_active'] = time();
824
825 return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
826 }
827
828 /**
829 * Refresh a session's `last_active`, at most once every few minutes.
830 *
831 * Called from token validation, so it runs on EVERY authenticated request. The
832 * throttle is what makes that affordable: the value only has to be accurate to within
833 * minutes for a rule that asks whether a session has been unseen for a week, and the
834 * read is already in the user's meta cache by this point.
835 *
836 * @param int $user_id The user ID.
837 * @param string $jti Refresh token JTI (session identifier).
838 */
839 private function touch_session_activity( int $user_id, string $jti ): void {
840 if ( 0 === $user_id || '' === $jti ) {
841 return;
842 }
843
844 $key = self::SESSION_SEEN_TRANSIENT_PREFIX . $jti;
845 $seen = get_transient( $key );
846
847 // The throttle reads the transient, never the session row: this runs on every
848 // authenticated request, and the row is the one thing this path must not touch.
849 if ( is_numeric( $seen ) && time() - (int) $seen < self::SESSION_ACTIVITY_REFRESH_SECONDS ) {
850 return;
851 }
852
853 // The TTL IS the idle window, so a missing transient means "not seen in a week".
854 set_transient( $key, time(), self::SESSION_EVICTION_IDLE_SECONDS );
855 }
856
857 /**
858 * Forget a session's recorded activity.
859 *
860 * @param string $jti Refresh token JTI (session identifier).
861 */
862 private function forget_session_activity( string $jti ): void {
863 if ( '' !== $jti ) {
864 delete_transient( self::SESSION_SEEN_TRANSIENT_PREFIX . $jti );
865 }
866 }
867
868 /**
869 * Check if the current user can manage sessions for the target user.
870 *
871 * @param int $target_user_id The target user ID.
872 *
873 * @return bool
874 */
875 public function can_manage_user_sessions( int $target_user_id ): bool {
876 $current_user_id = get_current_user_id();
877
878 // User can manage their own sessions.
879 if ( $current_user_id === $target_user_id ) {
880 return true;
881 }
882
883 // Administrators can manage anyone's sessions.
884 if ( current_user_can( 'manage_options' ) ) {
885 return true;
886 }
887
888 // Shop managers can manage anyone's sessions.
889 if ( current_user_can( 'manage_woocommerce' ) ) {
890 return true;
891 }
892
893 return false;
894 }
895
896 /**
897 * Blacklist a token JTI (for instant revocation).
898 *
899 * Can be used for access token JTIs or refresh token JTIs (session).
900 * When a refresh_jti is blacklisted, all access tokens linked to it
901 * become invalid.
902 *
903 * @param string $jti Token JTI to blacklist.
904 * @param int $ttl Time to live in seconds.
905 *
906 * @return bool
907 */
908 public function blacklist_token( string $jti, int $ttl ): bool {
909 if ( empty( $jti ) ) {
910 return false;
911 }
912
913 // Use transient with TTL matching token expiration.
914 return set_transient( "wcpos_blacklist_{$jti}", true, $ttl );
915 }
916
917 /**
918 * Revoke session and blacklist it for instant access token invalidation.
919 *
920 * By blacklisting the refresh_jti, ALL access tokens linked to this session
921 * become immediately invalid (they contain refresh_jti in their payload).
922 *
923 * @param int $user_id The user ID.
924 * @param string $refresh_jti Refresh token JTI (session identifier).
925 *
926 * @return bool
927 */
928 public function revoke_session_with_blacklist( int $user_id, string $refresh_jti ): bool {
929 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
930 $session_data = \is_array( $refresh_tokens ) && isset( $refresh_tokens[ $refresh_jti ] ) ? $refresh_tokens[ $refresh_jti ] : array();
931 $ttl = $this->get_access_token_blacklist_ttl( $session_data );
932
933 // Revoke the refresh token (session) from user meta.
934 $revoked = $this->revoke_session( $user_id, $refresh_jti );
935
936 if ( $revoked ) {
937 // Blacklist the session JTI - this invalidates ALL access tokens for this session
938 // TTL covers the current policy and any access token expiry recorded for the session.
939 $this->blacklist_token( $refresh_jti, $ttl );
940 }
941
942 return $revoked;
943 }
944
945 /**
946 * Store refresh token JTI for tracking/revocation.
947 *
948 * @param int $user_id The user ID.
949 * @param string $jti The token JTI.
950 * @param int $expires The expiration timestamp.
951 * @param null|Session_Context $context Request state the session is recorded
952 * against. Defaults to the current request.
953 */
954 private function store_refresh_token_jti( int $user_id, string $jti, int $expires, ?Session_Context $context = null ): void {
955 $context = null === $context ? Session_Context::from_request() : $context;
956
957 // BEFORE the read: a pre-cap row can be too large to load, and this is the first
958 // point in the login flow where WCPOS knows the user id.
959 $this->discard_oversized_session_row( $user_id );
960
961 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
962 if ( ! \is_array( $refresh_tokens ) ) {
963 $refresh_tokens = array();
964 }
965
966 // Clean up expired tokens.
967 $refresh_tokens = array_filter(
968 $refresh_tokens,
969 function ( $token ) {
970 return $token['expires'] > time();
971 }
972 );
973
974 // Capture session metadata.
975 $current_time = time();
976 $ip_address = $context->get_ip();
977 $user_agent = $context->get_user_agent();
978 $device_info = $this->parse_user_agent( $user_agent );
979
980 // Check for explicit platform declaration from native apps (passed as a param in the auth request).
981 $platform = $context->get_platform();
982 $version = $context->get_version();
983 $build = $context->get_build();
984
985 // Override app_type if platform was explicitly provided by the client.
986 if ( \in_array( $platform, array( 'ios', 'android', 'electron', 'web' ), true ) ) {
987 $device_info['app_type'] = 'web' === $platform ? 'web' : $platform . '_app';
988
989 // Set appropriate device type based on platform.
990 if ( 'ios' === $platform || 'android' === $platform ) {
991 $device_info['device_type'] = 'tablet'; // Default to tablet for mobile apps.
992 } elseif ( 'electron' === $platform ) {
993 $device_info['device_type'] = 'desktop';
994 }
995
996 // Use version from param if provided.
997 if ( ! empty( $version ) ) {
998 $device_info['browser_version'] = $version;
999 }
1000
1001 // Store build number if provided.
1002 if ( ! empty( $build ) ) {
1003 $device_info['build'] = $build;
1004 }
1005
1006 // Set browser to WooCommerce POS for native apps.
1007 if ( 'web' !== $platform ) {
1008 $device_info['browser'] = 'WooCommerce POS';
1009 }
1010 }
1011
1012 // Add new token with metadata.
1013 $refresh_tokens[ $jti ] = array(
1014 'expires' => $expires,
1015 'created' => $current_time,
1016 'last_active' => $current_time,
1017 'ip_address' => $ip_address,
1018 'user_agent' => $user_agent,
1019 'device_info' => $device_info,
1020 );
1021
1022 // Cap the number of stored sessions so programmatic clients cannot grow the row without bound.
1023 $refresh_tokens = $this->evict_oldest_sessions( $refresh_tokens, $jti );
1024
1025 update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
1026 }
1027
1028 /**
1029 * Drop the least recently active sessions until the per-user cap is met.
1030 *
1031 * Evicted sessions are blacklisted the same way revoke_all_sessions_except() does, so the
1032 * device that lost its slot is cleanly logged out instead of keeping a working access token
1033 * for the remainder of that token's life.
1034 *
1035 * @param array $refresh_tokens Stored sessions keyed by refresh token JTI.
1036 * @param string $protected_jti JTI that must never be evicted (the session being stored).
1037 *
1038 * @return array The sessions to persist.
1039 */
1040 private function evict_oldest_sessions( array $refresh_tokens, string $protected_jti ): array {
1041 $evict_count = \count( $refresh_tokens ) - self::MAX_SESSIONS_PER_USER;
1042 if ( $evict_count <= 0 ) {
1043 return $refresh_tokens;
1044 }
1045
1046 $issued_at = time();
1047 $idle_before = $issued_at - self::SESSION_EVICTION_IDLE_SECONDS;
1048
1049 /*
1050 * Order eviction candidates oldest-first. The insertion index breaks ties explicitly
1051 * because usort() is not stable before PHP 8.0 and bulk logins share a timestamp.
1052 *
1053 * A session seen within SESSION_EVICTION_IDLE_SECONDS is NOT a candidate at any
1054 * count. Being the oldest of N says nothing about being unused when N sessions were
1055 * minted in an hour, and evicting a live one blacklists a working device's access
1056 * token. The cap yields to that: a user whose sessions are all recent keeps them
1057 * all, and the row stays bounded by MAX_SESSIONS_ROW_BYTES instead.
1058 */
1059 $candidates = array();
1060 $index = 0;
1061 foreach ( $refresh_tokens as $candidate_jti => $token_data ) {
1062 $position = $index++;
1063 if ( (string) $candidate_jti === $protected_jti ) {
1064 continue;
1065 }
1066
1067 // The ROW timestamp is the cheap filter. It is authoritative when it says a
1068 // session is live, because login and refresh both write it; when it says idle
1069 // the activity transient still gets the final word, below.
1070 $activity = $this->session_row_last_seen( $token_data );
1071 if ( $activity > $idle_before ) {
1072 continue;
1073 }
1074
1075 $candidates[] = array(
1076 'jti' => (string) $candidate_jti,
1077 'activity' => $activity,
1078 'index' => $position,
1079 );
1080 }
1081
1082 usort(
1083 $candidates,
1084 function ( $a, $b ) {
1085 if ( $a['activity'] === $b['activity'] ) {
1086 return $a['index'] <=> $b['index'];
1087 }
1088
1089 return $a['activity'] <=> $b['activity'];
1090 }
1091 );
1092
1093 foreach ( $candidates as $candidate ) {
1094 if ( $evict_count <= 0 ) {
1095 break;
1096 }
1097
1098 // Checked only for rows already stale, so this costs a handful of transient
1099 // reads rather than one per stored session.
1100 if ( $this->session_last_seen( $candidate['jti'], $refresh_tokens[ $candidate['jti'] ] ) > $idle_before ) {
1101 continue;
1102 }
1103
1104 /*
1105 * Blacklist ONLY a session that can still hold a live access token. An eviction
1106 * is not a revoke: clearing a bloated row can drop thousands of long-dead
1107 * sessions at once, and a transient for each would guard nothing — an expired
1108 * access token is already rejected on its own `exp` claim, and the refresh token
1109 * dies with the meta entry (`is_refresh_token_valid()` requires the entry). This
1110 * also bounds each transient this path writes to one access-token lifetime,
1111 * rather than the refresh-token expiry `get_access_token_blacklist_ttl()` falls
1112 * back to for a session with no recorded access-token expiry.
1113 */
1114 $horizon = $this->access_token_horizon( $refresh_tokens[ $candidate['jti'] ] );
1115 if ( $horizon > $issued_at ) {
1116 $this->blacklist_token( $candidate['jti'], $horizon - $issued_at );
1117 }
1118
1119 $this->forget_session_activity( $candidate['jti'] );
1120 unset( $refresh_tokens[ $candidate['jti'] ] );
1121 --$evict_count;
1122 }
1123
1124 return $refresh_tokens;
1125 }
1126
1127 /**
1128 * When a session was last seen, taking the later of the row and the activity record.
1129 *
1130 * The row is rewritten by login and refresh; the transient is written by ordinary
1131 * authenticated requests. Neither alone is the whole picture — a device working through
1132 * a long-lived access token has an old row timestamp and a fresh transient, and a
1133 * session that has not been used at all has the reverse.
1134 *
1135 * @param string $jti Refresh token JTI (session identifier).
1136 * @param array $token_data Stored session record.
1137 *
1138 * @return int Unix timestamp; 0 when neither source carries a usable timestamp.
1139 */
1140 private function session_last_seen( string $jti, array $token_data ): int {
1141 $row_seen = $this->session_row_last_seen( $token_data );
1142 $seen = '' === $jti ? false : get_transient( self::SESSION_SEEN_TRANSIENT_PREFIX . $jti );
1143
1144 return is_numeric( $seen ) ? max( $row_seen, (int) $seen ) : $row_seen;
1145 }
1146
1147 /**
1148 * When the stored record itself says a session was last seen.
1149 *
1150 * Login and refresh both rewrite `last_active` in the row, so this stays accurate for
1151 * everything except the stretch between refreshes — which is what the activity
1152 * transient covers.
1153 *
1154 * @param array $token_data Stored session record.
1155 *
1156 * @return int Unix timestamp; 0 when the record carries no usable timestamp.
1157 */
1158 private function session_row_last_seen( array $token_data ): int {
1159 if ( isset( $token_data['last_active'] ) ) {
1160 return (int) $token_data['last_active'];
1161 }
1162
1163 if ( isset( $token_data['created'] ) ) {
1164 return (int) $token_data['created'];
1165 }
1166
1167 return 0;
1168 }
1169
1170 /**
1171 * The last moment an access token minted against a session can still validate.
1172 *
1173 * @param array $token_data Stored session record.
1174 *
1175 * @return int Unix timestamp; 0 when the session carries no usable timestamp at all.
1176 */
1177 private function access_token_horizon( array $token_data ): int {
1178 if ( isset( $token_data['access_expires'] ) ) {
1179 return (int) $token_data['access_expires'];
1180 }
1181
1182 // Rows written before `access_expires` was recorded. The newest access token such a
1183 // session can hold was minted no later than its last recorded activity, so one
1184 // access-token lifetime past that moment is the outside limit.
1185 $last_seen = $this->session_row_last_seen( $token_data );
1186
1187 return $last_seen > 0 ? $this->get_access_token_expire( $last_seen ) : 0;
1188 }
1189
1190 /**
1191 * Drop the stored session row when it is too large to be read safely.
1192 *
1193 * A LAST RESORT, not a tidy-up: discarding the row signs every one of that user's
1194 * devices out at once, so the ceiling is set above anything measured to be readable
1195 * (see MAX_SESSIONS_ROW_BYTES) and everything below it is TRIMMED by
1196 * `evict_oldest_sessions()` on the same write instead. What this catches is the one
1197 * case trimming cannot: a row so large that reading it exhausts the request before any
1198 * of the code below runs, which — because that read happens on every login — locks the
1199 * user out permanently (#1776). `LENGTH()` lets MySQL answer with a number instead of
1200 * the value, so the size is checked without paying for the row.
1201 *
1202 * @param int $user_id The user ID.
1203 */
1204 private function discard_oversized_session_row( int $user_id ): void {
1205 global $wpdb;
1206
1207 $rows = $wpdb->get_results(
1208 $wpdb->prepare(
1209 "SELECT umeta_id, LENGTH(meta_value) AS meta_bytes FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s",
1210 $user_id,
1211 '_woocommerce_pos_refresh_tokens'
1212 )
1213 );
1214
1215 if ( empty( $rows ) ) {
1216 return;
1217 }
1218
1219 $bytes = 0;
1220 foreach ( $rows as $row ) {
1221 $bytes += (int) $row->meta_bytes;
1222 }
1223
1224 if ( $bytes <= self::MAX_SESSIONS_ROW_BYTES ) {
1225 return;
1226 }
1227
1228 foreach ( $rows as $row ) {
1229 $wpdb->delete( $wpdb->usermeta, array( 'umeta_id' => (int) $row->umeta_id ), array( '%d' ) );
1230 }
1231
1232 // The row may already be sitting in the user's meta cache from an earlier
1233 // `get_user_meta()` in this request; without this the next read serves the value
1234 // that was just deleted.
1235 wp_cache_delete( $user_id, 'user_meta' );
1236
1237 Logger::warning(
1238 sprintf(
1239 '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.',
1240 $user_id,
1241 $bytes,
1242 self::MAX_SESSIONS_ROW_BYTES
1243 )
1244 );
1245 }
1246
1247 /**
1248 * Filters the JWT access token expire time.
1249 * Default: 30 minutes for access tokens.
1250 *
1251 * @param int $issued_at Token issued timestamp.
1252 *
1253 * @return int Expire time.
1254 *
1255 * @since 1.8.0
1256 *
1257 * @hook woocommerce_pos_jwt_access_token_expire
1258 */
1259 private function get_access_token_expire( int $issued_at ): int {
1260 return (int) apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
1261 }
1262
1263 /**
1264 * Filters the JWT refresh token expire time.
1265 * Default: 30 days for refresh tokens.
1266 *
1267 * @param int $issued_at Token issued timestamp.
1268 *
1269 * @return int Expire time.
1270 *
1271 * @since 1.8.0
1272 *
1273 * @hook woocommerce_pos_jwt_refresh_token_expire
1274 */
1275 private function get_refresh_token_expire( int $issued_at ): int {
1276 return (int) apply_filters( 'woocommerce_pos_jwt_refresh_token_expire', $issued_at + ( DAY_IN_SECONDS * 30 ), $issued_at );
1277 }
1278
1279 /**
1280 * Read a top-level claim from a JWT payload array/object.
1281 *
1282 * @param mixed $payload The filtered JWT payload.
1283 * @param string $claim The claim name.
1284 *
1285 * @return mixed|null
1286 */
1287 private function get_payload_claim( $payload, string $claim ) {
1288 if ( \is_array( $payload ) && array_key_exists( $claim, $payload ) ) {
1289 return $payload[ $claim ];
1290 }
1291
1292 if ( \is_object( $payload ) && isset( $payload->{$claim} ) ) {
1293 return $payload->{$claim};
1294 }
1295
1296 return null;
1297 }
1298
1299 /**
1300 * Record the latest access token expiry linked to a refresh-token session.
1301 *
1302 * @param int $user_id The user ID.
1303 * @param string $refresh_jti Refresh token JTI.
1304 * @param int $access_expires Access token expiry timestamp.
1305 *
1306 * @return bool
1307 */
1308 private function store_access_token_expiry( int $user_id, string $refresh_jti, int $access_expires ): bool {
1309 if ( empty( $refresh_jti ) || $access_expires <= 0 ) {
1310 return false;
1311 }
1312
1313 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
1314 if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $refresh_jti ] ) ) {
1315 return false;
1316 }
1317
1318 $current_access_expires = isset( $refresh_tokens[ $refresh_jti ]['access_expires'] ) ? (int) $refresh_tokens[ $refresh_jti ]['access_expires'] : 0;
1319 if ( $access_expires <= $current_access_expires ) {
1320 return true;
1321 }
1322
1323 $refresh_tokens[ $refresh_jti ]['access_expires'] = $access_expires;
1324
1325 return update_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', $refresh_tokens );
1326 }
1327
1328 /**
1329 * Calculate blacklist TTL for a session.
1330 *
1331 * @param array $session_data Session metadata.
1332 * @param null|int $issued_at Current timestamp.
1333 * @param null|int $access_expire Current access token expiry policy value.
1334 *
1335 * @return int
1336 */
1337 private function get_access_token_blacklist_ttl(
1338 array $session_data = array(),
1339 ?int $issued_at = null,
1340 ?int $access_expire = null
1341 ): int {
1342 $issued_at = null === $issued_at ? time() : $issued_at;
1343 $access_expire = null === $access_expire ? $this->get_access_token_expire( $issued_at ) : $access_expire;
1344
1345 if ( isset( $session_data['access_expires'] ) ) {
1346 $access_expire = max( $access_expire, (int) $session_data['access_expires'] );
1347 } elseif ( isset( $session_data['expires'] ) ) {
1348 $access_expire = max( $access_expire, (int) $session_data['expires'] );
1349 }
1350
1351 return max( 0, $access_expire - $issued_at );
1352 }
1353
1354 /**
1355 * Check if refresh token is still valid (not revoked).
1356 *
1357 * @param int $user_id The user ID.
1358 * @param string $jti The token JTI.
1359 *
1360 * @return bool
1361 */
1362 private function is_refresh_token_valid( int $user_id, string $jti ): bool {
1363 $refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
1364 if ( ! \is_array( $refresh_tokens ) ) {
1365 return false;
1366 }
1367
1368 return isset( $refresh_tokens[ $jti ] ) && $refresh_tokens[ $jti ]['expires'] > time();
1369 }
1370
1371 /**
1372 * Parse user agent string to extract device information.
1373 *
1374 * @param string $user_agent The user agent string.
1375 *
1376 * @return array
1377 */
1378 private function parse_user_agent( string $user_agent ): array {
1379 $device_info = array(
1380 'device_type' => 'unknown',
1381 'browser' => 'unknown',
1382 'browser_version' => '',
1383 'os' => 'unknown',
1384 'app_type' => 'web', // web, ios_app, android_app, electron_app.
1385 );
1386
1387 if ( empty( $user_agent ) ) {
1388 return $device_info;
1389 }
1390
1391 // Detect WooCommerce POS apps first (custom identifiers)
1392 // Check for Electron app (including just "WooCommercePOS" in user agent with Electron).
1393 if ( preg_match( '/Electron/i', $user_agent ) && preg_match( '/WooCommercePOS|WCPOS/i', $user_agent ) ) {
1394 $device_info['app_type'] = 'electron_app';
1395 $device_info['browser'] = 'WooCommerce POS';
1396 $device_info['device_type'] = 'desktop';
1397 // Try to extract WooCommercePOS version.
1398 if ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1399 $device_info['browser_version'] = $matches[1];
1400 } elseif ( preg_match( '/WCPOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1401 $device_info['browser_version'] = $matches[1];
1402 }
1403 } elseif ( preg_match( '/WCPOS[-_]?iOS|WooCommercePOS[-_]?iOS/i', $user_agent ) ) {
1404 $device_info['app_type'] = 'ios_app';
1405 $device_info['browser'] = 'WooCommerce POS';
1406 // Default to tablet unless explicitly detected as phone.
1407 $device_info['device_type'] = preg_match( '/iphone|ipod/i', $user_agent ) ? 'mobile' : 'tablet';
1408 if ( preg_match( '/WCPOS[-_]?iOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1409 $device_info['browser_version'] = $matches[1];
1410 } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1411 $device_info['browser_version'] = $matches[1];
1412 }
1413 } elseif ( preg_match( '/WCPOS[-_]?Android|WooCommercePOS[-_]?Android/i', $user_agent ) ) {
1414 $device_info['app_type'] = 'android_app';
1415 $device_info['browser'] = 'WooCommerce POS';
1416 // Default to tablet unless explicitly detected as mobile.
1417 $device_info['device_type'] = preg_match( '/mobile/i', $user_agent ) && ! preg_match( '/tablet/i', $user_agent ) ? 'mobile' : 'tablet';
1418 if ( preg_match( '/WCPOS[-_]?Android[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1419 $device_info['browser_version'] = $matches[1];
1420 } elseif ( preg_match( '/WooCommercePOS[\/\s]([0-9.]+)/i', $user_agent, $matches ) ) {
1421 $device_info['browser_version'] = $matches[1];
1422 }
1423 }
1424
1425 // Detect standard device type (if not already set by app detection).
1426 if ( 'web' === $device_info['app_type'] ) {
1427 if ( preg_match( '/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i', $user_agent ) ) {
1428 $device_info['device_type'] = 'mobile';
1429 } elseif ( preg_match( '/tablet|ipad|playbook|silk/i', $user_agent ) ) {
1430 $device_info['device_type'] = 'tablet';
1431 } else {
1432 $device_info['device_type'] = 'desktop';
1433 }
1434 }
1435
1436 // Detect browser (skip if we already detected a WCPOS app).
1437 if ( 'WooCommerce POS' !== $device_info['browser'] ) {
1438 if ( preg_match( '/MSIE|Trident/i', $user_agent ) ) {
1439 $device_info['browser'] = 'Internet Explorer';
1440 if ( preg_match( '/MSIE ([0-9.]+)/', $user_agent, $matches ) ) {
1441 $device_info['browser_version'] = $matches[1];
1442 }
1443 } elseif ( preg_match( '/Edge\/([0-9.]+)/i', $user_agent, $matches ) ) {
1444 $device_info['browser'] = 'Edge';
1445 $device_info['browser_version'] = $matches[1];
1446 } elseif ( preg_match( '/Edg\/([0-9.]+)/i', $user_agent, $matches ) ) {
1447 $device_info['browser'] = 'Edge';
1448 $device_info['browser_version'] = $matches[1];
1449 } elseif ( preg_match( '/Firefox\/([0-9.]+)/i', $user_agent, $matches ) ) {
1450 $device_info['browser'] = 'Firefox';
1451 $device_info['browser_version'] = $matches[1];
1452 } elseif ( preg_match( '/Chrome\/([0-9.]+)/i', $user_agent, $matches ) ) {
1453 $device_info['browser'] = 'Chrome';
1454 $device_info['browser_version'] = $matches[1];
1455 } elseif ( preg_match( '/Safari\/([0-9.]+)/i', $user_agent, $matches ) ) {
1456 // Safari should be checked after Chrome because Chrome also contains Safari.
1457 if ( ! preg_match( '/Chrome/i', $user_agent ) ) {
1458 $device_info['browser'] = 'Safari';
1459 $device_info['browser_version'] = $matches[1];
1460 }
1461 } elseif ( preg_match( '/Opera\/([0-9.]+)/i', $user_agent, $matches ) ) {
1462 $device_info['browser'] = 'Opera';
1463 $device_info['browser_version'] = $matches[1];
1464 }
1465 }
1466
1467 // Detect OS.
1468 if ( preg_match( '/Windows NT ([0-9.]+)/i', $user_agent, $matches ) ) {
1469 $device_info['os'] = 'Windows';
1470 } elseif ( preg_match( '/Mac OS X ([0-9_]+)/i', $user_agent, $matches ) ) {
1471 $device_info['os'] = 'macOS';
1472 } elseif ( preg_match( '/Android ([0-9.]+)/i', $user_agent, $matches ) ) {
1473 $device_info['os'] = 'Android';
1474 } elseif ( preg_match( '/iPhone OS ([0-9_]+)/i', $user_agent, $matches ) ) {
1475 $device_info['os'] = 'iOS';
1476 } elseif ( preg_match( '/iPad.*OS ([0-9_]+)/i', $user_agent, $matches ) ) {
1477 $device_info['os'] = 'iPadOS';
1478 } elseif ( preg_match( '/Linux/i', $user_agent ) ) {
1479 $device_info['os'] = 'Linux';
1480 }
1481
1482 return $device_info;
1483 }
1484
1485 /**
1486 * Check if a token JTI is blacklisted.
1487 *
1488 * Works for both access token JTIs and refresh token JTIs (sessions).
1489 *
1490 * @param string $jti Token JTI to check.
1491 *
1492 * @return bool
1493 */
1494 private function is_token_blacklisted( string $jti ): bool {
1495 if ( empty( $jti ) ) {
1496 return false;
1497 }
1498
1499 // Check transient.
1500 return false !== get_transient( "wcpos_blacklist_{$jti}" );
1501 }
1502
1503 /**
1504 * Clean up previous web session to prevent session proliferation.
1505 *
1506 * The web application generates new tokens on every page load. This method
1507 * revokes the previous session (stored in a cookie) so only one web session
1508 * exists per browser at a time.
1509 *
1510 * @param int $user_id The user ID.
1511 */
1512 private function cleanup_previous_web_session( int $user_id ): void {
1513 $cookie_name = 'wcpos_web_session_jti';
1514
1515 if ( ! isset( $_COOKIE[ $cookie_name ] ) ) {
1516 return;
1517 }
1518
1519 $previous_jti = sanitize_text_field( wp_unslash( $_COOKIE[ $cookie_name ] ) );
1520
1521 if ( empty( $previous_jti ) ) {
1522 return;
1523 }
1524
1525 // Revoke the previous session (silently - don't care if it fails).
1526 $this->revoke_session( $user_id, $previous_jti );
1527 }
1528
1529 /**
1530 * Set a cookie to track the current web session JTI.
1531 *
1532 * @param string $refresh_token The refresh token to extract JTI from.
1533 */
1534 private function set_web_session_cookie( string $refresh_token ): void {
1535 $decoded = $this->validate_token( $refresh_token, 'refresh' );
1536
1537 if ( is_wp_error( $decoded ) || empty( $decoded->jti ) ) {
1538 return;
1539 }
1540
1541 $cookie_name = 'wcpos_web_session_jti';
1542 $jti = $decoded->jti;
1543 $expires = $decoded->exp ?? ( time() + DAY_IN_SECONDS * 30 );
1544
1545 // Set cookie with same expiry as refresh token
1546 // Use httponly for security, but not secure flag as POS may run on localhost.
1547 setcookie(
1548 $cookie_name,
1549 $jti,
1550 array(
1551 'expires' => $expires,
1552 'path' => \defined( 'COOKIEPATH' ) ? COOKIEPATH : '/', // @phpstan-ignore-line
1553 'domain' => \defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '', // @phpstan-ignore-line
1554 'secure' => is_ssl(),
1555 'httponly' => true,
1556 'samesite' => 'Lax',
1557 )
1558 );
1559 }
1560 }
1561