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
woocommerce-pos / includes / Services / Auth.php

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

999 lines 28.8 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\Services\Settings\Access_Section;
14 use WP_Error;
15 use WP_User;
16 use const DAY_IN_SECONDS;
17 use const HOUR_IN_SECONDS;
18
19 /**
20 * Auth Service class.
21 */
22 class Auth {
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 /**
45 * The single instance of the class.
46 *
47 * @var null|Auth
48 */
49 private static $instance = null;
50
51 /**
52 * Session storage.
53 *
54 * @var Session_Registry
55 */
56 private $sessions;
57
58 /**
59 * Constructor is private to prevent direct instantiation.
60 * Or Auth::instance() instead.
61 */
62 public function __construct() {
63 $this->sessions = new Session_Registry();
64 }
65
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 /**
76 * Gets the singleton instance.
77 *
78 * @return Auth
79 */
80 public static function instance(): self {
81 if ( null === self::$instance ) {
82 self::$instance = new self();
83 }
84
85 return self::$instance;
86 }
87
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 /**
159 * Generate a secret key if it doesn't exist, or return the existing one.
160 *
161 * @return string
162 */
163 public function get_secret_key(): string {
164 $secret_key = get_option( 'woocommerce_pos_secret_key' );
165 if ( false === $secret_key || empty( $secret_key ) ) {
166 $secret_key = wp_generate_password( 64, true, true );
167 update_option( 'woocommerce_pos_secret_key', $secret_key );
168 }
169
170 return $secret_key;
171 }
172
173 /**
174 * Get refresh token secret key (separate from access token key for security).
175 *
176 * @return string
177 */
178 public function get_refresh_secret_key(): string {
179 $secret_key = get_option( 'woocommerce_pos_refresh_secret_key' );
180 if ( false === $secret_key || empty( $secret_key ) ) {
181 $secret_key = wp_generate_password( 64, true, true );
182 update_option( 'woocommerce_pos_refresh_secret_key', $secret_key );
183 }
184
185 return $secret_key;
186 }
187
188 /**
189 * Validate the provided JWT token.
190 *
191 * @param string $token The JWT token.
192 * @param string $token_type The token type: 'access' or 'refresh'.
193 *
194 * @return object|WP_Error
195 */
196 public function validate_token( $token = '', $token_type = 'access' ) {
197 try {
198 $secret_key = 'refresh' === $token_type ? $this->get_refresh_secret_key() : $this->get_secret_key();
199 $decoded_token = JWT::decode( $token, new Key( $secret_key, 'HS256' ) ); // @phpstan-ignore-line
200
201 // The Token is decoded now validate the iss.
202 if ( get_bloginfo( 'url' ) != $decoded_token->iss ) {
203 // The iss do not match, return error.
204 return new WP_Error(
205 'woocommmerce_pos_auth_bad_iss',
206 'The iss do not match with this server',
207 array( 'status' => 403 )
208 );
209 }
210
211 // Validate token type.
212 if ( ! isset( $decoded_token->type ) || $decoded_token->type !== $token_type ) {
213 return new WP_Error(
214 'woocommmerce_pos_auth_invalid_token_type',
215 'Invalid token type',
216 array( 'status' => 403 )
217 );
218 }
219
220 // So far so good, validate the user id in the token.
221 if ( ! isset( $decoded_token->data->user->id ) ) {
222 // No user id in the token, abort!!
223 return new WP_Error(
224 'woocommmerce_pos_auth_bad_request',
225 'User ID not found in the token',
226 array(
227 'status' => 403,
228 )
229 );
230 }
231
232 // Check if access token is blacklisted (for instant revocation)
233 // We check both the access token's own JTI and its parent refresh_jti.
234 if ( 'access' === $token_type ) {
235 // Check if this specific access token is blacklisted.
236 if ( isset( $decoded_token->jti ) && $this->is_token_blacklisted( $decoded_token->jti ) ) {
237 return new WP_Error(
238 'woocommerce_pos_auth_token_revoked',
239 'Access token has been revoked',
240 array( 'status' => 403 )
241 );
242 }
243
244 // Check if the parent session (refresh token) is blacklisted
245 // This catches ALL access tokens for a revoked session.
246 if ( isset( $decoded_token->refresh_jti ) && $this->is_token_blacklisted( $decoded_token->refresh_jti ) ) {
247 return new WP_Error(
248 'woocommerce_pos_auth_session_revoked',
249 'Session has been revoked',
250 array( 'status' => 403 )
251 );
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 }
262 }
263
264 // Everything looks good return the decoded token.
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 );
272 } catch ( Exception $e ) {
273 // Something is wrong trying to decode the token, send back the error.
274 return new WP_Error(
275 'woocommmerce_pos_auth_invalid_token',
276 $e->getMessage(),
277 array(
278 'status' => 403,
279 )
280 );
281 }
282 }
283
284 /**
285 * Generate an access token for the provided user (short-lived).
286 *
287 * @param WP_User $user The user object.
288 * @param string $refresh_jti Optional refresh token JTI to link access token to session.
289 *
290 * @return string|WP_Error
291 */
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 = '' ) {
311 // First thing, check the secret key if not exist return a error.
312 if ( ! $this->get_secret_key() ) {
313 return new WP_Error(
314 'woocommerce_pos_jwt_auth_bad_config',
315 __( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ),
316 array(
317 'status' => 403,
318 )
319 );
320 }
321
322 /** Valid credentials, the user exists create the according Token */
323 $issued_at = time();
324 $expire = $this->get_access_token_expire( $issued_at );
325
326 // Generate unique JTI for access token.
327 $jti = wp_generate_uuid4();
328
329 $token = array(
330 'iss' => get_bloginfo( 'url' ),
331 'iat' => $issued_at,
332 'exp' => $expire,
333 'jti' => $jti,
334 'type' => 'access',
335 'data' => array(
336 'user' => array(
337 'id' => $user->data->ID,
338 ),
339 ),
340 );
341
342 // Link to refresh token if provided.
343 if ( ! empty( $refresh_jti ) ) {
344 $token['refresh_jti'] = $refresh_jti;
345 }
346
347 /*
348 * Let the user modify the access token data before the sign.
349 *
350 * @param {array} $token
351 * @param {WP_User} $user
352 *
353 * @returns {array} Token
354 *
355 * @since 1.8.0
356 *
357 * @hook woocommerce_pos_jwt_access_token_before_sign
358 */
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 );
380 }
381
382 /**
383 * Generate a refresh token for the provided user (long-lived).
384 *
385 * @param WP_User $user The user object.
386 *
387 * @return string|WP_Error
388 */
389 public function generate_refresh_token( WP_User $user ) {
390 // First thing, check the secret key if not exist return a error.
391 if ( ! $this->get_refresh_secret_key() ) {
392 return new WP_Error(
393 'woocommerce_pos_jwt_auth_bad_config',
394 __( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ),
395 array(
396 'status' => 403,
397 )
398 );
399 }
400
401 /** Valid credentials, the user exists create the according Token */
402 $issued_at = time();
403 $expire = $this->get_refresh_token_expire( $issued_at );
404
405 // Generate unique JTI (JWT ID) for refresh token tracking.
406 $jti = wp_generate_uuid4();
407
408 $token = array(
409 'iss' => get_bloginfo( 'url' ),
410 'iat' => $issued_at,
411 'exp' => $expire,
412 'jti' => $jti,
413 'type' => 'refresh',
414 'data' => array(
415 'user' => array(
416 'id' => $user->data->ID,
417 ),
418 ),
419 );
420
421 /**
422 * Let the user modify the refresh token data before the sign.
423 *
424 * @param array $token
425 * @param WP_User $user
426 *
427 * @returns array Token
428 *
429 * @since 1.8.0
430 *
431 * @hook woocommerce_pos_jwt_refresh_token_before_sign
432 */
433 $token = JWT::encode( apply_filters( 'woocommerce_pos_jwt_refresh_token_before_sign', $token, $user ), $this->get_refresh_secret_key(), 'HS256' );
434
435 // Store refresh token JTI for potential revocation.
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 }
454
455 return $token;
456 }
457
458 /**
459 * Generate both access and refresh tokens.
460 *
461 * @param WP_User $user The user object.
462 *
463 * @return array|WP_Error
464 */
465 public function generate_token_pair( WP_User $user ) {
466 // Generate refresh token first to get its JTI.
467 $refresh_token = $this->generate_refresh_token( $user );
468 if ( is_wp_error( $refresh_token ) ) {
469 return $refresh_token;
470 }
471
472 // Decode to get the JTI.
473 $decoded_refresh = $this->validate_token( $refresh_token, 'refresh' );
474 if ( is_wp_error( $decoded_refresh ) ) {
475 return $decoded_refresh;
476 }
477
478 // Generate access token with link to refresh 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;
482 }
483
484 return array(
485 'access_token' => $access_token_data['token'],
486 'refresh_token' => $refresh_token,
487 'token_type' => 'Bearer',
488 'expires_at' => (int) $access_token_data['expires_at'],
489 );
490 }
491
492 /**
493 * Legacy method for backward compatibility.
494 *
495 * @deprecated Use generate_access_token() instead
496 *
497 * @param WP_User $user The user object.
498 *
499 * @return string|WP_Error
500 */
501 public function generate_token( WP_User $user ) {
502 return $this->generate_access_token( $user );
503 }
504
505 /**
506 * Get user's data (minimal set for security).
507 *
508 * @param WP_User $user The user object.
509 * @param bool $is_web_frontend Whether this is the web frontend context.
510 * When true, manages web session cookie to prevent
511 * session proliferation on page refresh.
512 *
513 * @return array
514 */
515 public function get_user_data( WP_User $user, bool $is_web_frontend = false ): array {
516 // For web frontend, revoke previous session to prevent proliferation on page refresh.
517 if ( $is_web_frontend ) {
518 $this->cleanup_previous_web_session( $user->ID );
519 }
520
521 $tokens = $this->generate_token_pair( $user );
522 if ( is_wp_error( $tokens ) ) {
523 return array();
524 }
525
526 // For web frontend, store the new session JTI in a cookie for cleanup on next page load.
527 if ( $is_web_frontend ) {
528 $this->set_web_session_cookie( $tokens['refresh_token'] );
529 }
530
531 return array(
532 'uuid' => Cashier::instance()->get_cashier_uuid( $user ),
533 'id' => $user->ID,
534 'username' => $user->user_login,
535 'email' => $user->user_email,
536 'first_name' => $user->user_firstname,
537 'last_name' => $user->user_lastname,
538 'nice_name' => $user->user_nicename,
539 'display_name' => $user->display_name,
540 'roles' => array_values( $user->roles ),
541 // The helper reports effective grants, including role-editor denies.
542 'capabilities' => Access_Section::effective_capabilities( $user ),
543 'avatar_url' => get_avatar_url( $user->ID ),
544 // Token data.
545 'access_token' => $tokens['access_token'],
546 'refresh_token' => $tokens['refresh_token'],
547 'token_type' => $tokens['token_type'],
548 'expires_at' => $tokens['expires_at'],
549 );
550 }
551
552 /**
553 * Get minimal user data for redirect (security-focused).
554 *
555 * @param WP_User $user The user object.
556 *
557 * @return array
558 */
559 public function get_redirect_data( WP_User $user ): array {
560 $tokens = $this->generate_token_pair( $user );
561 if ( is_wp_error( $tokens ) ) {
562 return array();
563 }
564
565 // Only return essential data for redirect URL.
566 return array(
567 'access_token' => $tokens['access_token'],
568 'refresh_token' => $tokens['refresh_token'],
569 'token_type' => $tokens['token_type'],
570 'expires_at' => $tokens['expires_at'],
571 // Get basic user data for display, other data will be fetched from the server.
572 'uuid' => Cashier::instance()->get_cashier_uuid( $user ),
573 'id' => $user->ID,
574 'display_name' => $user->display_name,
575 );
576 }
577
578 /**
579 * Refresh an access token using a valid refresh token.
580 *
581 * @param string $refresh_token The refresh token.
582 *
583 * @return array|WP_Error
584 */
585 public function refresh_access_token( string $refresh_token ) {
586 $decoded = $this->validate_token( $refresh_token, 'refresh' );
587 if ( is_wp_error( $decoded ) ) {
588 return $decoded;
589 }
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
599 // Check if refresh token is still valid (not revoked).
600 if ( ! $this->sessions->is_live( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
601 return new WP_Error(
602 'woocommerce_pos_auth_refresh_token_revoked',
603 'Refresh token has been revoked',
604 array( 'status' => 403 )
605 );
606 }
607
608 $user = get_user_by( 'id', $decoded->data->user->id );
609 if ( ! $user ) {
610 return new WP_Error(
611 'woocommerce_pos_auth_user_not_found',
612 'User not found',
613 array( 'status' => 404 )
614 );
615 }
616
617 // Update last_active timestamp for this session.
618 $this->update_session_activity( $decoded->data->user->id, $decoded->jti ?? '' );
619
620 // Generate new access token with link to refresh token (refresh token stays the same).
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;
624 }
625
626 return array(
627 'access_token' => $new_access_token_data['token'],
628 'token_type' => 'Bearer',
629 'expires_at' => (int) $new_access_token_data['expires_at'],
630 );
631 }
632
633 /**
634 * Revoke JWT Token by JTI.
635 *
636 * @param int $user_id The user ID.
637 * @param string $jti The token JTI.
638 *
639 * @return bool
640 */
641 public function revoke_refresh_token( int $user_id, string $jti ): bool {
642 return $this->sessions->revoke( $user_id, $jti );
643 }
644
645 /**
646 * Revoke all refresh tokens for a user.
647 *
648 * @param int $user_id The user ID.
649 *
650 * @return bool
651 */
652 /**
653 * Revoke all refresh tokens for a user with blacklisting.
654 *
655 * @param int $user_id The user ID.
656 *
657 * @return bool
658 */
659 public function revoke_all_refresh_tokens( int $user_id ): bool {
660 $refresh_tokens = $this->sessions->entries( $user_id );
661
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 );
667
668 foreach ( $refresh_tokens as $jti => $token_data ) {
669 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
670 $this->blacklist_token( $jti, $ttl );
671 }
672 }
673
674 return $this->sessions->revoke_all( $user_id );
675 }
676
677 /**
678 * Get all active sessions for a user.
679 *
680 * @param int $user_id The user ID.
681 *
682 * @return array
683 */
684 public function get_user_sessions( int $user_id ): array {
685 return $this->sessions->list( $user_id );
686 }
687
688 /**
689 * Revoke a specific session by JTI (alias for revoke_refresh_token for clarity).
690 *
691 * @param int $user_id The user ID.
692 * @param string $jti The token JTI.
693 *
694 * @return bool
695 */
696 public function revoke_session( int $user_id, string $jti ): bool {
697 return $this->revoke_refresh_token( $user_id, $jti );
698 }
699
700 /**
701 * Revoke all sessions except the current one.
702 *
703 * @param int $user_id The user ID.
704 * @param string $current_jti The current token JTI.
705 *
706 * @return bool
707 */
708 /**
709 * Revoke all sessions except the current one, with blacklisting.
710 *
711 * @param int $user_id The user ID.
712 * @param string $current_jti The current token JTI.
713 *
714 * @return bool
715 */
716 public function revoke_all_sessions_except( int $user_id, string $current_jti ): bool {
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.
720 return false;
721 }
722
723 // Blacklist all sessions except current for instant access token invalidation.
724 $issued_at = time();
725 $access_expire = $this->get_access_token_expire( $issued_at );
726
727 foreach ( $refresh_tokens as $jti => $token_data ) {
728 if ( $jti !== $current_jti ) {
729 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
730 $this->blacklist_token( $jti, $ttl );
731 }
732 }
733
734 return $this->sessions->keep_only( $user_id, $current_jti );
735 }
736
737 /**
738 * Update last_active timestamp for a session.
739 *
740 * @param int $user_id The user ID.
741 * @param string $jti The token JTI.
742 *
743 * @return bool
744 */
745 public function update_session_activity( int $user_id, string $jti ): bool {
746 return $this->sessions->refresh_activity( $user_id, $jti );
747 }
748
749 /**
750 * Check if the current user can manage sessions for the target user.
751 *
752 * @param int $target_user_id The target user ID.
753 *
754 * @return bool
755 */
756 public function can_manage_user_sessions( int $target_user_id ): bool {
757 $current_user_id = get_current_user_id();
758
759 // User can manage their own sessions.
760 if ( $current_user_id === $target_user_id ) {
761 return true;
762 }
763
764 // Administrators can manage anyone's sessions.
765 if ( current_user_can( 'manage_options' ) ) {
766 return true;
767 }
768
769 // Shop managers can manage anyone's sessions.
770 if ( current_user_can( 'manage_woocommerce' ) ) {
771 return true;
772 }
773
774 return false;
775 }
776
777 /**
778 * Blacklist a token JTI (for instant revocation).
779 *
780 * Can be used for access token JTIs or refresh token JTIs (session).
781 * When a refresh_jti is blacklisted, all access tokens linked to it
782 * become invalid.
783 *
784 * @param string $jti Token JTI to blacklist.
785 * @param int $ttl Time to live in seconds.
786 *
787 * @return bool
788 */
789 public function blacklist_token( string $jti, int $ttl ): bool {
790 if ( empty( $jti ) ) {
791 return false;
792 }
793
794 // Use transient with TTL matching token expiration.
795 return set_transient( "wcpos_blacklist_{$jti}", true, $ttl );
796 }
797
798 /**
799 * Revoke session and blacklist it for instant access token invalidation.
800 *
801 * By blacklisting the refresh_jti, ALL access tokens linked to this session
802 * become immediately invalid (they contain refresh_jti in their payload).
803 *
804 * @param int $user_id The user ID.
805 * @param string $refresh_jti Refresh token JTI (session identifier).
806 *
807 * @return bool
808 */
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
813 // Revoke the refresh token (session) from user meta.
814 $revoked = $this->revoke_session( $user_id, $refresh_jti );
815
816 if ( $revoked ) {
817 // Blacklist the session JTI - this invalidates ALL access tokens for this session
818 // TTL covers the current policy and any access token expiry recorded for the session.
819 $this->blacklist_token( $refresh_jti, $ttl );
820 }
821
822 return $revoked;
823 }
824
825 /**
826 * The last moment an access token minted against a session can still validate.
827 *
828 * @param array $token_data Stored session record.
829 *
830 * @return int Unix timestamp; 0 when the session carries no usable timestamp at all.
831 */
832 private function access_token_horizon( array $token_data ): int {
833 if ( isset( $token_data['access_expires'] ) ) {
834 return (int) $token_data['access_expires'];
835 }
836
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 );
841
842 return $last_seen > 0 ? $this->get_access_token_expire( $last_seen ) : 0;
843 }
844
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 );
859 }
860
861 /**
862 * Filters the JWT refresh token expire time.
863 * Default: 30 days for refresh tokens.
864 *
865 * @param int $issued_at Token issued timestamp.
866 *
867 * @return int Expire time.
868 *
869 * @since 1.8.0
870 *
871 * @hook woocommerce_pos_jwt_refresh_token_expire
872 */
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 );
875 }
876
877 /**
878 * Read a top-level claim from a JWT payload array/object.
879 *
880 * @param mixed $payload The filtered JWT payload.
881 * @param string $claim The claim name.
882 *
883 * @return mixed|null
884 */
885 private function get_payload_claim( $payload, string $claim ) {
886 if ( \is_array( $payload ) && array_key_exists( $claim, $payload ) ) {
887 return $payload[ $claim ];
888 }
889
890 if ( \is_object( $payload ) && isset( $payload->{$claim} ) ) {
891 return $payload->{$claim};
892 }
893
894 return null;
895 }
896
897 /**
898 * Calculate blacklist TTL for a session.
899 *
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.
903 *
904 * @return int
905 */
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;
913
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'] );
918 }
919
920 return max( 0, $access_expire - $issued_at );
921 }
922
923 /**
924 * Check if a token JTI is blacklisted.
925 *
926 * Works for both access token JTIs and refresh token JTIs (sessions).
927 *
928 * @param string $jti Token JTI to check.
929 *
930 * @return bool
931 */
932 private function is_token_blacklisted( string $jti ): bool {
933 if ( empty( $jti ) ) {
934 return false;
935 }
936
937 // Check transient.
938 return false !== get_transient( "wcpos_blacklist_{$jti}" );
939 }
940
941 /**
942 * Clean up previous web session to prevent session proliferation.
943 *
944 * The web application generates new tokens on every page load. This method
945 * revokes the previous session (stored in a cookie) so only one web session
946 * exists per browser at a time.
947 *
948 * @param int $user_id The user ID.
949 */
950 private function cleanup_previous_web_session( int $user_id ): void {
951 $cookie_name = 'wcpos_web_session_jti';
952
953 if ( ! isset( $_COOKIE[ $cookie_name ] ) ) {
954 return;
955 }
956
957 $previous_jti = sanitize_text_field( wp_unslash( $_COOKIE[ $cookie_name ] ) );
958
959 if ( empty( $previous_jti ) ) {
960 return;
961 }
962
963 // Revoke the previous session (silently - don't care if it fails).
964 $this->revoke_session( $user_id, $previous_jti );
965 }
966
967 /**
968 * Set a cookie to track the current web session JTI.
969 *
970 * @param string $refresh_token The refresh token to extract JTI from.
971 */
972 private function set_web_session_cookie( string $refresh_token ): void {
973 $decoded = $this->validate_token( $refresh_token, 'refresh' );
974
975 if ( is_wp_error( $decoded ) || empty( $decoded->jti ) ) {
976 return;
977 }
978
979 $cookie_name = 'wcpos_web_session_jti';
980 $jti = $decoded->jti;
981 $expires = $decoded->exp ?? ( time() + DAY_IN_SECONDS * 30 );
982
983 // Set cookie with same expiry as refresh token
984 // Use httponly for security, but not secure flag as POS may run on localhost.
985 setcookie(
986 $cookie_name,
987 $jti,
988 array(
989 'expires' => $expires,
990 'path' => \defined( 'COOKIEPATH' ) ? COOKIEPATH : '/', // @phpstan-ignore-line
991 'domain' => \defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '', // @phpstan-ignore-line
992 'secure' => is_ssl(),
993 'httponly' => true,
994 'samesite' => 'Lax',
995 )
996 );
997 }
998 }
999