PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.22
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.22
1.10.22 1.10.21 1.10.20 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 All 166 releases
woocommerce-pos / includes / Services / Auth.php

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

1,048 lines 30.7 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 * On `wp_logout`: end the web POS session named by this browser's cookie.
90 *
91 * Sessions of native apps and other browsers stay live. The cookie itself is left alone.
92 *
93 * @param mixed $user_id ID of the user logging out.
94 */
95 public static function revoke_web_session_on_logout( $user_id ): void {
96 if ( absint( $user_id ) > 0 ) {
97 self::instance()->cleanup_previous_web_session( absint( $user_id ) );
98 }
99 }
100
101 /**
102 * On `password_reset`: end every POS session of the user.
103 *
104 * @param mixed $user User whose password is being reset.
105 */
106 public static function revoke_sessions_on_password_reset( $user ): void {
107 if ( $user instanceof WP_User ) {
108 self::instance()->revoke_all_refresh_tokens( $user->ID );
109 }
110 }
111
112 /**
113 * On `profile_update`: end every POS session of the user when the password changed.
114 *
115 * @param mixed $user_id ID of the updated user.
116 * @param mixed $old_user_data User data before the update.
117 */
118 public static function revoke_sessions_on_password_change( $user_id, $old_user_data = null ): void {
119 $user = get_userdata( absint( $user_id ) );
120
121 if ( $old_user_data instanceof WP_User && $user instanceof WP_User && $old_user_data->user_pass !== $user->user_pass ) {
122 self::instance()->revoke_all_refresh_tokens( $user->ID );
123 }
124 }
125
126 /**
127 * Extract a WCPOS token from an authorization value.
128 *
129 * @param mixed $auth_value Authorization value.
130 *
131 * @return null|string
132 */
133 public function extract_token( $auth_value ): ?string {
134 if ( ! \is_string( $auth_value ) || '' === $auth_value ) {
135 return null;
136 }
137
138 // Match the old sscanf( 'Bearer %s' ) semantics exactly: any run of
139 // whitespace after the scheme, token = the next non-whitespace run.
140 if ( 1 === preg_match( '/^Bearer\s+(\S+)/', $auth_value, $matches ) ) {
141 return $matches[1];
142 }
143
144 return 1 === preg_match( '/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $auth_value ) ? $auth_value : null;
145 }
146
147 /**
148 * Authenticate the current request from its WCPOS token.
149 *
150 * @return false|int|WP_Error User ID, validation error, or false when no WCPOS token is present.
151 */
152 public function authenticate_request() {
153 $auth_header = $this->get_auth_header();
154 $token = $this->extract_token( $auth_header );
155 if ( null === $token ) {
156 return false;
157 }
158
159 $decoded_token = $this->validate_token( $token );
160 if ( is_wp_error( $decoded_token ) ) {
161 return $decoded_token;
162 }
163
164 return absint( $decoded_token->data->user->id );
165 }
166
167 /**
168 * Get authorization header/param value.
169 *
170 * Checks multiple sources for the authorization token:
171 * 1. HTTP_AUTHORIZATION server variable (standard)
172 * 2. REDIRECT_HTTP_AUTHORIZATION (Apache CGI workaround)
173 * 3. authorization query parameter (for servers that strip auth headers)
174 *
175 * @return false|string The authorization value or false if not found.
176 */
177 public function get_auth_header() {
178 // Check HTTP_AUTHORIZATION (not empty - htaccess SetEnvIf can set empty value).
179 if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
180 return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
181 }
182
183 // Check REDIRECT_HTTP_AUTHORIZATION (Apache CGI).
184 if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
185 return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
186 }
187
188 // Check authorization query param.
189 if ( ! empty( $_GET['authorization'] ) ) {
190 return sanitize_text_field( wp_unslash( $_GET['authorization'] ) );
191 }
192
193 return false;
194 }
195
196 /**
197 * Generate a secret key if it doesn't exist, or return the existing one.
198 *
199 * @return string
200 */
201 public function get_secret_key(): string {
202 $secret_key = get_option( 'woocommerce_pos_secret_key' );
203 if ( false === $secret_key || empty( $secret_key ) ) {
204 $secret_key = wp_generate_password( 64, true, true );
205 update_option( 'woocommerce_pos_secret_key', $secret_key );
206 }
207
208 return $secret_key;
209 }
210
211 /**
212 * Get refresh token secret key (separate from access token key for security).
213 *
214 * @return string
215 */
216 public function get_refresh_secret_key(): string {
217 $secret_key = get_option( 'woocommerce_pos_refresh_secret_key' );
218 if ( false === $secret_key || empty( $secret_key ) ) {
219 $secret_key = wp_generate_password( 64, true, true );
220 update_option( 'woocommerce_pos_refresh_secret_key', $secret_key );
221 }
222
223 return $secret_key;
224 }
225
226 /**
227 * Validate the provided JWT token.
228 *
229 * @param string $token The JWT token.
230 * @param string $token_type The token type: 'access' or 'refresh'.
231 *
232 * @return object|WP_Error
233 */
234 public function validate_token( $token = '', $token_type = 'access' ) {
235 try {
236 $secret_key = 'refresh' === $token_type ? $this->get_refresh_secret_key() : $this->get_secret_key();
237 $decoded_token = JWT::decode( $token, new Key( $secret_key, 'HS256' ) ); // @phpstan-ignore-line
238
239 // The Token is decoded now validate the iss.
240 if ( get_bloginfo( 'url' ) != $decoded_token->iss ) {
241 // The iss do not match, return error.
242 return new WP_Error(
243 'woocommmerce_pos_auth_bad_iss',
244 'The iss do not match with this server',
245 array( 'status' => 403 )
246 );
247 }
248
249 // Validate token type.
250 if ( ! isset( $decoded_token->type ) || $decoded_token->type !== $token_type ) {
251 return new WP_Error(
252 'woocommmerce_pos_auth_invalid_token_type',
253 'Invalid token type',
254 array( 'status' => 403 )
255 );
256 }
257
258 // So far so good, validate the user id in the token.
259 if ( ! isset( $decoded_token->data->user->id ) ) {
260 // No user id in the token, abort!!
261 return new WP_Error(
262 'woocommmerce_pos_auth_bad_request',
263 'User ID not found in the token',
264 array(
265 'status' => 403,
266 )
267 );
268 }
269
270 // Check if access token is blacklisted (for instant revocation)
271 // We check both the access token's own JTI and its parent refresh_jti.
272 if ( 'access' === $token_type ) {
273 // Check if this specific access token is blacklisted.
274 if ( isset( $decoded_token->jti ) && $this->is_token_blacklisted( $decoded_token->jti ) ) {
275 return new WP_Error(
276 'woocommerce_pos_auth_token_revoked',
277 'Access token has been revoked',
278 array( 'status' => 403 )
279 );
280 }
281
282 // Check if the parent session (refresh token) is blacklisted
283 // This catches ALL access tokens for a revoked session.
284 if ( isset( $decoded_token->refresh_jti ) && $this->is_token_blacklisted( $decoded_token->refresh_jti ) ) {
285 return new WP_Error(
286 'woocommerce_pos_auth_session_revoked',
287 'Session has been revoked',
288 array( 'status' => 403 )
289 );
290 }
291
292 // The session registry is authoritative; the blacklist transient above is only a
293 // fast path that can be evicted or purged. Once the session is live, record that,
294 // so eviction can tell a device working right now from one unseen for a week.
295 if ( isset( $decoded_token->refresh_jti ) ) {
296 $user_id = absint( $decoded_token->data->user->id );
297 $refresh_jti = (string) $decoded_token->refresh_jti;
298
299 if ( ! $this->sessions->is_live( $user_id, $refresh_jti ) ) {
300 return new WP_Error(
301 'woocommerce_pos_auth_session_revoked',
302 'Session has been revoked',
303 array( 'status' => 403 )
304 );
305 }
306
307 $this->sessions->touch( $user_id, $refresh_jti );
308 }
309 }
310
311 // Everything looks good return the decoded token.
312 return $decoded_token;
313 } catch ( \WCPOS\Vendor\Firebase\JWT\ExpiredException $e ) {
314 return new WP_Error(
315 'woocommerce_pos_auth_token_expired',
316 'Token expired',
317 array( 'status' => 403 )
318 );
319 } catch ( Exception $e ) {
320 // Something is wrong trying to decode the token, send back the error.
321 return new WP_Error(
322 'woocommmerce_pos_auth_invalid_token',
323 $e->getMessage(),
324 array(
325 'status' => 403,
326 )
327 );
328 }
329 }
330
331 /**
332 * Generate an access token for the provided user (short-lived).
333 *
334 * @param WP_User $user The user object.
335 * @param string $refresh_jti Optional refresh token JTI to link access token to session.
336 *
337 * @return string|WP_Error
338 */
339 public function generate_access_token( WP_User $user, string $refresh_jti = '' ) {
340 $token_data = $this->generate_access_token_data( $user, $refresh_jti );
341
342 if ( is_wp_error( $token_data ) ) {
343 return $token_data;
344 }
345
346 return $token_data['token'];
347 }
348
349 /**
350 * Generate an access token and return the token metadata used by callers.
351 *
352 * @param WP_User $user The user object.
353 * @param string $refresh_jti Optional refresh token JTI to link access token to session.
354 *
355 * @return array|WP_Error
356 */
357 private function generate_access_token_data( WP_User $user, string $refresh_jti = '' ) {
358 // First thing, check the secret key if not exist return a error.
359 if ( ! $this->get_secret_key() ) {
360 return new WP_Error(
361 'woocommerce_pos_jwt_auth_bad_config',
362 __( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ),
363 array(
364 'status' => 403,
365 )
366 );
367 }
368
369 /** Valid credentials, the user exists create the according Token */
370 $issued_at = time();
371 $expire = $this->get_access_token_expire( $issued_at );
372
373 // Generate unique JTI for access token.
374 $jti = wp_generate_uuid4();
375
376 $token = array(
377 'iss' => get_bloginfo( 'url' ),
378 'iat' => $issued_at,
379 'exp' => $expire,
380 'jti' => $jti,
381 'type' => 'access',
382 'data' => array(
383 'user' => array(
384 'id' => $user->data->ID,
385 ),
386 ),
387 );
388
389 // Link to refresh token if provided.
390 if ( ! empty( $refresh_jti ) ) {
391 $token['refresh_jti'] = $refresh_jti;
392 }
393
394 /*
395 * Let the user modify the access token data before the sign.
396 *
397 * @param {array} $token
398 * @param {WP_User} $user
399 *
400 * @returns {array} Token
401 *
402 * @since 1.8.0
403 *
404 * @hook woocommerce_pos_jwt_access_token_before_sign
405 */
406 $payload = apply_filters( 'woocommerce_pos_jwt_access_token_before_sign', $token, $user );
407 $token = JWT::encode( $payload, $this->get_secret_key(), 'HS256' );
408
409 $expires_at = $this->get_payload_claim( $payload, 'exp' );
410 $access_jti = $this->get_payload_claim( $payload, 'jti' );
411 $linked_refresh_jti = $this->get_payload_claim( $payload, 'refresh_jti' );
412
413 $expires_at = null === $expires_at ? $expire : (int) $expires_at;
414 $access_jti = null === $access_jti ? $jti : (string) $access_jti;
415
416 if ( null !== $linked_refresh_jti ) {
417 $linked_refresh_jti = (string) $linked_refresh_jti;
418 $this->sessions->record_access_expiry( $user->ID, $linked_refresh_jti, $expires_at );
419 }
420
421 return array(
422 'token' => $token,
423 'expires_at' => $expires_at,
424 'jti' => $access_jti,
425 'refresh_jti' => $linked_refresh_jti,
426 );
427 }
428
429 /**
430 * Generate a refresh token for the provided user (long-lived).
431 *
432 * @param WP_User $user The user object.
433 *
434 * @return string|WP_Error
435 */
436 public function generate_refresh_token( WP_User $user ) {
437 // First thing, check the secret key if not exist return a error.
438 if ( ! $this->get_refresh_secret_key() ) {
439 return new WP_Error(
440 'woocommerce_pos_jwt_auth_bad_config',
441 __( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ),
442 array(
443 'status' => 403,
444 )
445 );
446 }
447
448 /** Valid credentials, the user exists create the according Token */
449 $issued_at = time();
450 $expire = $this->get_refresh_token_expire( $issued_at );
451
452 // Generate unique JTI (JWT ID) for refresh token tracking.
453 $jti = wp_generate_uuid4();
454
455 $token = array(
456 'iss' => get_bloginfo( 'url' ),
457 'iat' => $issued_at,
458 'exp' => $expire,
459 'jti' => $jti,
460 'type' => 'refresh',
461 'data' => array(
462 'user' => array(
463 'id' => $user->data->ID,
464 ),
465 ),
466 );
467
468 /**
469 * Let the user modify the refresh token data before the sign.
470 *
471 * @param array $token
472 * @param WP_User $user
473 *
474 * @returns array Token
475 *
476 * @since 1.8.0
477 *
478 * @hook woocommerce_pos_jwt_refresh_token_before_sign
479 */
480 $token = JWT::encode( apply_filters( 'woocommerce_pos_jwt_refresh_token_before_sign', $token, $user ), $this->get_refresh_secret_key(), 'HS256' );
481
482 // Store refresh token JTI for potential revocation.
483 $evicted = $this->sessions->record( $user->ID, $jti, $expire, Session_Context::from_request() );
484 $issued_at = time();
485 foreach ( $evicted as $evicted_jti => $token_data ) {
486 /*
487 * Blacklist ONLY a session that can still hold a live access token. An eviction
488 * is not a revoke: clearing a bloated row can drop thousands of long-dead
489 * sessions at once, and a transient for each would guard nothing — an expired
490 * access token is already rejected on its own `exp` claim, and the refresh token
491 * dies with the meta entry (`is_live()` requires the entry). This
492 * also bounds each transient this path writes to one access-token lifetime,
493 * rather than the refresh-token expiry `get_access_token_blacklist_ttl()` falls
494 * back to for a session with no recorded access-token expiry.
495 */
496 $horizon = $this->access_token_horizon( $token_data );
497 if ( $horizon > $issued_at ) {
498 $this->blacklist_token( $evicted_jti, $horizon - $issued_at );
499 }
500 }
501
502 return $token;
503 }
504
505 /**
506 * Generate both access and refresh tokens.
507 *
508 * @param WP_User $user The user object.
509 *
510 * @return array|WP_Error
511 */
512 public function generate_token_pair( WP_User $user ) {
513 // Generate refresh token first to get its JTI.
514 $refresh_token = $this->generate_refresh_token( $user );
515 if ( is_wp_error( $refresh_token ) ) {
516 return $refresh_token;
517 }
518
519 // Decode to get the JTI.
520 $decoded_refresh = $this->validate_token( $refresh_token, 'refresh' );
521 if ( is_wp_error( $decoded_refresh ) ) {
522 return $decoded_refresh;
523 }
524
525 // Generate access token with link to refresh token.
526 $access_token_data = $this->generate_access_token_data( $user, $decoded_refresh->jti ?? '' );
527 if ( is_wp_error( $access_token_data ) ) {
528 return $access_token_data;
529 }
530
531 return array(
532 'access_token' => $access_token_data['token'],
533 'refresh_token' => $refresh_token,
534 'token_type' => 'Bearer',
535 'expires_at' => (int) $access_token_data['expires_at'],
536 );
537 }
538
539 /**
540 * Legacy method for backward compatibility.
541 *
542 * @deprecated Use generate_access_token() instead
543 *
544 * @param WP_User $user The user object.
545 *
546 * @return string|WP_Error
547 */
548 public function generate_token( WP_User $user ) {
549 return $this->generate_access_token( $user );
550 }
551
552 /**
553 * Get user's data (minimal set for security).
554 *
555 * @param WP_User $user The user object.
556 * @param bool $is_web_frontend Whether this is the web frontend context.
557 * When true, manages web session cookie to prevent
558 * session proliferation on page refresh.
559 *
560 * @return array
561 */
562 public function get_user_data( WP_User $user, bool $is_web_frontend = false ): array {
563 // For web frontend, revoke previous session to prevent proliferation on page refresh.
564 if ( $is_web_frontend ) {
565 $this->cleanup_previous_web_session( $user->ID );
566 }
567
568 $tokens = $this->generate_token_pair( $user );
569 if ( is_wp_error( $tokens ) ) {
570 return array();
571 }
572
573 // For web frontend, store the new session JTI in a cookie for cleanup on next page load.
574 if ( $is_web_frontend ) {
575 $this->set_web_session_cookie( $tokens['refresh_token'] );
576 }
577
578 return array(
579 'uuid' => Cashier::instance()->get_cashier_uuid( $user ),
580 'id' => $user->ID,
581 'username' => $user->user_login,
582 'email' => $user->user_email,
583 'first_name' => $user->user_firstname,
584 'last_name' => $user->user_lastname,
585 'nice_name' => $user->user_nicename,
586 'display_name' => $user->display_name,
587 'roles' => array_values( $user->roles ),
588 // The helper reports effective grants, including role-editor denies.
589 'capabilities' => Access_Section::effective_capabilities( $user ),
590 'avatar_url' => get_avatar_url( $user->ID ),
591 // Token data.
592 'access_token' => $tokens['access_token'],
593 'refresh_token' => $tokens['refresh_token'],
594 'token_type' => $tokens['token_type'],
595 'expires_at' => $tokens['expires_at'],
596 );
597 }
598
599 /**
600 * Get minimal user data for redirect (security-focused).
601 *
602 * @param WP_User $user The user object.
603 *
604 * @return array
605 */
606 public function get_redirect_data( WP_User $user ): array {
607 $tokens = $this->generate_token_pair( $user );
608 if ( is_wp_error( $tokens ) ) {
609 return array();
610 }
611
612 // Only return essential data for redirect URL.
613 return array(
614 'access_token' => $tokens['access_token'],
615 'refresh_token' => $tokens['refresh_token'],
616 'token_type' => $tokens['token_type'],
617 'expires_at' => $tokens['expires_at'],
618 // Get basic user data for display, other data will be fetched from the server.
619 'uuid' => Cashier::instance()->get_cashier_uuid( $user ),
620 'id' => $user->ID,
621 'display_name' => $user->display_name,
622 );
623 }
624
625 /**
626 * Refresh an access token using a valid refresh token.
627 *
628 * @param string $refresh_token The refresh token.
629 *
630 * @return array|WP_Error
631 */
632 public function refresh_access_token( string $refresh_token ) {
633 $decoded = $this->validate_token( $refresh_token, 'refresh' );
634 if ( is_wp_error( $decoded ) ) {
635 return $decoded;
636 }
637
638 /*
639 * Before the first row read on this path. A refresh loads the whole session row —
640 * `is_live()` below, then `refresh_activity()` — so it needs
641 * the same protection a login has against a row too large to read (#1776).
642 * Validating an ACCESS token READS the row through `is_live()` but never writes it,
643 * and runs no guard because `guard_row()` can write; the read primes the user meta
644 * cache that WordPress fills anyway to read the user's capabilities, so it adds no query.
645 */
646 $this->sessions->guard_row( absint( $decoded->data->user->id ) );
647
648 // Check if refresh token is still valid (not revoked).
649 if ( ! $this->sessions->is_live( $decoded->data->user->id, $decoded->jti ?? '' ) ) {
650 return new WP_Error(
651 'woocommerce_pos_auth_refresh_token_revoked',
652 'Refresh token has been revoked',
653 array( 'status' => 403 )
654 );
655 }
656
657 $user = get_user_by( 'id', $decoded->data->user->id );
658 if ( ! $user ) {
659 return new WP_Error(
660 'woocommerce_pos_auth_user_not_found',
661 'User not found',
662 array( 'status' => 404 )
663 );
664 }
665
666 // Update last_active timestamp for this session.
667 $this->update_session_activity( $decoded->data->user->id, $decoded->jti ?? '' );
668
669 // Generate new access token with link to refresh token (refresh token stays the same).
670 $new_access_token_data = $this->generate_access_token_data( $user, $decoded->jti ?? '' );
671 if ( is_wp_error( $new_access_token_data ) ) {
672 return $new_access_token_data;
673 }
674
675 return array(
676 'access_token' => $new_access_token_data['token'],
677 'token_type' => 'Bearer',
678 'expires_at' => (int) $new_access_token_data['expires_at'],
679 );
680 }
681
682 /**
683 * Revoke JWT Token by JTI.
684 *
685 * @param int $user_id The user ID.
686 * @param string $jti The token JTI.
687 *
688 * @return bool
689 */
690 public function revoke_refresh_token( int $user_id, string $jti ): bool {
691 return $this->sessions->revoke( $user_id, $jti );
692 }
693
694 /**
695 * Revoke all refresh tokens for a user.
696 *
697 * @param int $user_id The user ID.
698 *
699 * @return bool
700 */
701 /**
702 * Revoke all refresh tokens for a user with blacklisting.
703 *
704 * @param int $user_id The user ID.
705 *
706 * @return bool
707 */
708 public function revoke_all_refresh_tokens( int $user_id ): bool {
709 $refresh_tokens = $this->sessions->entries( $user_id );
710
711 // Blacklist all sessions for instant access token invalidation. The expiry
712 // policy is only consulted when there is something to blacklist.
713 if ( array() !== $refresh_tokens ) {
714 $issued_at = time();
715 $access_expire = $this->get_access_token_expire( $issued_at );
716
717 foreach ( $refresh_tokens as $jti => $token_data ) {
718 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
719 $this->blacklist_token( $jti, $ttl );
720 }
721 }
722
723 return $this->sessions->revoke_all( $user_id );
724 }
725
726 /**
727 * Get all active sessions for a user.
728 *
729 * @param int $user_id The user ID.
730 *
731 * @return array
732 */
733 public function get_user_sessions( int $user_id ): array {
734 return $this->sessions->list( $user_id );
735 }
736
737 /**
738 * Revoke a specific session by JTI (alias for revoke_refresh_token for clarity).
739 *
740 * @param int $user_id The user ID.
741 * @param string $jti The token JTI.
742 *
743 * @return bool
744 */
745 public function revoke_session( int $user_id, string $jti ): bool {
746 return $this->revoke_refresh_token( $user_id, $jti );
747 }
748
749 /**
750 * Revoke all sessions except the current one.
751 *
752 * @param int $user_id The user ID.
753 * @param string $current_jti The current token JTI.
754 *
755 * @return bool
756 */
757 /**
758 * Revoke all sessions except the current one, with blacklisting.
759 *
760 * @param int $user_id The user ID.
761 * @param string $current_jti The current token JTI.
762 *
763 * @return bool
764 */
765 public function revoke_all_sessions_except( int $user_id, string $current_jti ): bool {
766 $refresh_tokens = $this->sessions->entries( $user_id );
767 if ( array() === $refresh_tokens ) {
768 // No row (or nothing in it): nothing to blacklist, nothing to rewrite.
769 return false;
770 }
771
772 // Blacklist all sessions except current for instant access token invalidation.
773 $issued_at = time();
774 $access_expire = $this->get_access_token_expire( $issued_at );
775
776 foreach ( $refresh_tokens as $jti => $token_data ) {
777 if ( $jti !== $current_jti ) {
778 $ttl = $this->get_access_token_blacklist_ttl( $token_data, $issued_at, $access_expire );
779 $this->blacklist_token( $jti, $ttl );
780 }
781 }
782
783 return $this->sessions->keep_only( $user_id, $current_jti );
784 }
785
786 /**
787 * Update last_active timestamp for a session.
788 *
789 * @param int $user_id The user ID.
790 * @param string $jti The token JTI.
791 *
792 * @return bool
793 */
794 public function update_session_activity( int $user_id, string $jti ): bool {
795 return $this->sessions->refresh_activity( $user_id, $jti );
796 }
797
798 /**
799 * Check if the current user can manage sessions for the target user.
800 *
801 * @param int $target_user_id The target user ID.
802 *
803 * @return bool
804 */
805 public function can_manage_user_sessions( int $target_user_id ): bool {
806 $current_user_id = get_current_user_id();
807
808 // User can manage their own sessions.
809 if ( $current_user_id === $target_user_id ) {
810 return true;
811 }
812
813 // Administrators can manage anyone's sessions.
814 if ( current_user_can( 'manage_options' ) ) {
815 return true;
816 }
817
818 // Shop managers can manage anyone's sessions.
819 if ( current_user_can( 'manage_woocommerce' ) ) {
820 return true;
821 }
822
823 return false;
824 }
825
826 /**
827 * Blacklist a token JTI (for instant revocation).
828 *
829 * Can be used for access token JTIs or refresh token JTIs (session).
830 * When a refresh_jti is blacklisted, all access tokens linked to it
831 * become invalid.
832 *
833 * @param string $jti Token JTI to blacklist.
834 * @param int $ttl Time to live in seconds.
835 *
836 * @return bool
837 */
838 public function blacklist_token( string $jti, int $ttl ): bool {
839 if ( empty( $jti ) ) {
840 return false;
841 }
842
843 // Use transient with TTL matching token expiration.
844 return set_transient( "wcpos_blacklist_{$jti}", true, $ttl );
845 }
846
847 /**
848 * Revoke session and blacklist it for instant access token invalidation.
849 *
850 * By blacklisting the refresh_jti, ALL access tokens linked to this session
851 * become immediately invalid (they contain refresh_jti in their payload).
852 *
853 * @param int $user_id The user ID.
854 * @param string $refresh_jti Refresh token JTI (session identifier).
855 *
856 * @return bool
857 */
858 public function revoke_session_with_blacklist( int $user_id, string $refresh_jti ): bool {
859 $session_data = $this->sessions->entry( $user_id, $refresh_jti );
860 $ttl = $this->get_access_token_blacklist_ttl( $session_data );
861
862 // Revoke the refresh token (session) from user meta.
863 $revoked = $this->revoke_session( $user_id, $refresh_jti );
864
865 if ( $revoked ) {
866 // Blacklist the session JTI - this invalidates ALL access tokens for this session
867 // TTL covers the current policy and any access token expiry recorded for the session.
868 $this->blacklist_token( $refresh_jti, $ttl );
869 }
870
871 return $revoked;
872 }
873
874 /**
875 * The last moment an access token minted against a session can still validate.
876 *
877 * @param array $token_data Stored session record.
878 *
879 * @return int Unix timestamp; 0 when the session carries no usable timestamp at all.
880 */
881 private function access_token_horizon( array $token_data ): int {
882 if ( isset( $token_data['access_expires'] ) ) {
883 return (int) $token_data['access_expires'];
884 }
885
886 // Rows written before `access_expires` was recorded. The newest access token such a
887 // session can hold was minted no later than its last recorded activity, so one
888 // access-token lifetime past that moment is the outside limit.
889 $last_seen = (int) ( $token_data['last_active'] ?? $token_data['created'] ?? 0 );
890
891 return $last_seen > 0 ? $this->get_access_token_expire( $last_seen ) : 0;
892 }
893
894 /**
895 * Filters the JWT access token expire time.
896 * Default: 30 minutes for access tokens.
897 *
898 * @param int $issued_at Token issued timestamp.
899 *
900 * @return int Expire time.
901 *
902 * @since 1.8.0
903 *
904 * @hook woocommerce_pos_jwt_access_token_expire
905 */
906 private function get_access_token_expire( int $issued_at ): int {
907 return (int) apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
908 }
909
910 /**
911 * Filters the JWT refresh token expire time.
912 * Default: 30 days for refresh tokens.
913 *
914 * @param int $issued_at Token issued timestamp.
915 *
916 * @return int Expire time.
917 *
918 * @since 1.8.0
919 *
920 * @hook woocommerce_pos_jwt_refresh_token_expire
921 */
922 private function get_refresh_token_expire( int $issued_at ): int {
923 return (int) apply_filters( 'woocommerce_pos_jwt_refresh_token_expire', $issued_at + ( DAY_IN_SECONDS * 30 ), $issued_at );
924 }
925
926 /**
927 * Read a top-level claim from a JWT payload array/object.
928 *
929 * @param mixed $payload The filtered JWT payload.
930 * @param string $claim The claim name.
931 *
932 * @return mixed|null
933 */
934 private function get_payload_claim( $payload, string $claim ) {
935 if ( \is_array( $payload ) && array_key_exists( $claim, $payload ) ) {
936 return $payload[ $claim ];
937 }
938
939 if ( \is_object( $payload ) && isset( $payload->{$claim} ) ) {
940 return $payload->{$claim};
941 }
942
943 return null;
944 }
945
946 /**
947 * Calculate blacklist TTL for a session.
948 *
949 * @param array $session_data Session metadata.
950 * @param null|int $issued_at Current timestamp.
951 * @param null|int $access_expire Current access token expiry policy value.
952 *
953 * @return int
954 */
955 private function get_access_token_blacklist_ttl(
956 array $session_data = array(),
957 ?int $issued_at = null,
958 ?int $access_expire = null
959 ): int {
960 $issued_at = null === $issued_at ? time() : $issued_at;
961 $access_expire = null === $access_expire ? $this->get_access_token_expire( $issued_at ) : $access_expire;
962
963 if ( isset( $session_data['access_expires'] ) ) {
964 $access_expire = max( $access_expire, (int) $session_data['access_expires'] );
965 } elseif ( isset( $session_data['expires'] ) ) {
966 $access_expire = max( $access_expire, (int) $session_data['expires'] );
967 }
968
969 return max( 0, $access_expire - $issued_at );
970 }
971
972 /**
973 * Check if a token JTI is blacklisted.
974 *
975 * Works for both access token JTIs and refresh token JTIs (sessions).
976 *
977 * @param string $jti Token JTI to check.
978 *
979 * @return bool
980 */
981 private function is_token_blacklisted( string $jti ): bool {
982 if ( empty( $jti ) ) {
983 return false;
984 }
985
986 // Check transient.
987 return false !== get_transient( "wcpos_blacklist_{$jti}" );
988 }
989
990 /**
991 * Clean up previous web session to prevent session proliferation.
992 *
993 * The web application generates new tokens on every page load. This method
994 * revokes the previous session (stored in a cookie) so only one web session
995 * exists per browser at a time.
996 *
997 * @param int $user_id The user ID.
998 */
999 private function cleanup_previous_web_session( int $user_id ): void {
1000 $cookie_name = 'wcpos_web_session_jti';
1001
1002 if ( ! isset( $_COOKIE[ $cookie_name ] ) ) {
1003 return;
1004 }
1005
1006 $previous_jti = sanitize_text_field( wp_unslash( $_COOKIE[ $cookie_name ] ) );
1007
1008 if ( empty( $previous_jti ) ) {
1009 return;
1010 }
1011
1012 // Revoke the previous session (silently - don't care if it fails).
1013 $this->revoke_session( $user_id, $previous_jti );
1014 }
1015
1016 /**
1017 * Set a cookie to track the current web session JTI.
1018 *
1019 * @param string $refresh_token The refresh token to extract JTI from.
1020 */
1021 private function set_web_session_cookie( string $refresh_token ): void {
1022 $decoded = $this->validate_token( $refresh_token, 'refresh' );
1023
1024 if ( is_wp_error( $decoded ) || empty( $decoded->jti ) ) {
1025 return;
1026 }
1027
1028 $cookie_name = 'wcpos_web_session_jti';
1029 $jti = $decoded->jti;
1030 $expires = $decoded->exp ?? ( time() + DAY_IN_SECONDS * 30 );
1031
1032 // Set cookie with same expiry as refresh token
1033 // Use httponly for security, but not secure flag as POS may run on localhost.
1034 setcookie(
1035 $cookie_name,
1036 $jti,
1037 array(
1038 'expires' => $expires,
1039 'path' => \defined( 'COOKIEPATH' ) ? COOKIEPATH : '/', // @phpstan-ignore-line
1040 'domain' => \defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '', // @phpstan-ignore-line
1041 'secure' => is_ssl(),
1042 'httponly' => true,
1043 'samesite' => 'Lax',
1044 )
1045 );
1046 }
1047 }
1048