Skip to content

Commit 60f3c28

Browse files
committed
Add ability to revoke the access token
1 parent f89abe8 commit 60f3c28

File tree

4 files changed

+341
-10
lines changed

4 files changed

+341
-10
lines changed

includes/API/Auth.php

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,12 @@ public function delete_session( WP_REST_Request $request ): WP_REST_Response {
324324
}
325325

326326
$auth_service = AuthService::instance();
327-
$result = $auth_service->revoke_session( (int) $user_id, $jti );
327+
328+
// Try to get the access token JTI from the request to blacklist it
329+
$access_jti = $this->get_current_access_jti_from_request( $request );
330+
331+
// Revoke session and blacklist current access token
332+
$result = $auth_service->revoke_session_with_blacklist( (int) $user_id, $jti, $access_jti );
328333

329334
if ( $result ) {
330335
return rest_ensure_response( array(
@@ -528,4 +533,42 @@ private function get_current_jti_from_request( WP_REST_Request $request ): ?stri
528533

529534
return $decoded->jti ?? null;
530535
}
536+
537+
/**
538+
* Get current access token JTI from the request's authorization token.
539+
*
540+
* @param WP_REST_Request $request The REST request object.
541+
*
542+
* @return string
543+
*/
544+
private function get_current_access_jti_from_request( WP_REST_Request $request ): string {
545+
// Try to get the token from Authorization header
546+
$auth_header = $request->get_header( 'authorization' );
547+
548+
if ( empty( $auth_header ) ) {
549+
// Try query parameter
550+
$auth_header = $request->get_param( 'authorization' );
551+
}
552+
553+
if ( empty( $auth_header ) ) {
554+
return '';
555+
}
556+
557+
// Extract token from "Bearer TOKEN"
558+
$token = str_replace( 'Bearer ', '', $auth_header );
559+
560+
if ( empty( $token ) ) {
561+
return '';
562+
}
563+
564+
// Decode the access token to get its JTI
565+
$auth_service = AuthService::instance();
566+
$decoded = $auth_service->validate_token( $token, 'access' );
567+
568+
if ( is_wp_error( $decoded ) ) {
569+
return '';
570+
}
571+
572+
return $decoded->jti ?? '';
573+
}
531574
}

includes/Server.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@
99

1010
namespace WCPOS\WooCommercePOS;
1111

12+
use WCPOS\WooCommercePOS\Services\Auth;
1213
use WP_REST_Request;
14+
use WP_REST_Server;
1315

1416
class Server {
1517
public function __construct() {
1618
add_filter( 'woocommerce_rest_check_permissions', array( $this, 'check_permissions' ) );
19+
add_filter( 'rest_pre_dispatch', array( $this, 'track_session_activity' ), 10, 3 );
1720
}
1821

1922
/**
@@ -73,4 +76,92 @@ protected function get_json_last_error() {
7376

7477
return json_last_error_msg();
7578
}
79+
80+
/**
81+
* Track session activity on every authenticated REST API request.
82+
*
83+
* @param mixed $result Response to replace the requested version with.
84+
* @param WP_REST_Server $server Server instance.
85+
* @param WP_REST_Request $request Request used to generate the response.
86+
*
87+
* @return mixed
88+
*/
89+
public function track_session_activity( $result, $server, $request ) {
90+
// Only track for WCPOS API requests
91+
$route = $request->get_route();
92+
if ( ! str_starts_with( $route, '/wcpos/' ) ) {
93+
return $result;
94+
}
95+
96+
// Skip for auth endpoints (they handle their own tracking)
97+
if ( str_starts_with( $route, '/wcpos/v1/auth/' ) ) {
98+
return $result;
99+
}
100+
101+
// Get authorization token
102+
$token = $this->extract_token_from_request( $request );
103+
if ( empty( $token ) ) {
104+
return $result;
105+
}
106+
107+
// Validate and extract refresh_jti from access token
108+
$auth_service = Auth::instance();
109+
$decoded = $auth_service->validate_token( $token, 'access' );
110+
111+
if ( is_wp_error( $decoded ) ) {
112+
return $result;
113+
}
114+
115+
// Update session activity if we have refresh_jti
116+
if ( ! empty( $decoded->refresh_jti ) && ! empty( $decoded->data->user->id ) ) {
117+
$this->update_session_activity_throttled(
118+
$decoded->data->user->id,
119+
$decoded->refresh_jti
120+
);
121+
}
122+
123+
return $result;
124+
}
125+
126+
/**
127+
* Extract token from request (header or query param).
128+
*
129+
* @param WP_REST_Request $request The REST request.
130+
*
131+
* @return string
132+
*/
133+
private function extract_token_from_request( WP_REST_Request $request ): string {
134+
// Try Authorization header
135+
$auth_header = $request->get_header( 'authorization' );
136+
137+
if ( empty( $auth_header ) ) {
138+
// Try query parameter
139+
$auth_header = $request->get_param( 'authorization' );
140+
}
141+
142+
if ( empty( $auth_header ) ) {
143+
return '';
144+
}
145+
146+
// Extract token from "Bearer TOKEN"
147+
return str_replace( 'Bearer ', '', $auth_header );
148+
}
149+
150+
/**
151+
* Update session activity with rate limiting (once per minute).
152+
*
153+
* @param int $user_id User ID.
154+
* @param string $jti Refresh token JTI.
155+
*/
156+
private function update_session_activity_throttled( int $user_id, string $jti ): void {
157+
// Rate limit: only update once per minute per session
158+
$cache_key = "wcpos_session_activity_{$user_id}_{$jti}";
159+
$last_update = wp_cache_get( $cache_key, 'wcpos' );
160+
161+
// If never updated or last update was more than 60 seconds ago
162+
if ( false === $last_update || ( time() - $last_update ) > 60 ) {
163+
Auth::instance()->update_session_activity( $user_id, $jti );
164+
wp_cache_set( $cache_key, time(), 'wcpos', 60 );
165+
}
166+
}
76167
}

includes/Services/Auth.php

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@ public function validate_token( $token = '', $token_type = 'access' ) {
115115
);
116116
}
117117

118+
// Check if access token is blacklisted (for instant revocation)
119+
if ( 'access' === $token_type && isset( $decoded_token->jti ) ) {
120+
if ( $this->is_access_token_blacklisted( $decoded_token->jti ) ) {
121+
return new WP_Error(
122+
'woocommerce_pos_auth_token_revoked',
123+
'Access token has been revoked',
124+
array( 'status' => 403 )
125+
);
126+
}
127+
}
128+
118129
// Everything looks good return the decoded token
119130
return $decoded_token;
120131
} catch ( Exception $e ) {
@@ -133,10 +144,11 @@ public function validate_token( $token = '', $token_type = 'access' ) {
133144
* Generate an access token for the provided user (short-lived).
134145
*
135146
* @param WP_User $user
147+
* @param string $refresh_jti Optional refresh token JTI to link access token to session.
136148
*
137149
* @return string|WP_Error
138150
*/
139-
public function generate_access_token( WP_User $user ) {
151+
public function generate_access_token( WP_User $user, string $refresh_jti = '' ) {
140152
// First thing, check the secret key if not exist return a error
141153
if ( ! $this->get_secret_key() ) {
142154
return new WP_Error(
@@ -166,10 +178,14 @@ public function generate_access_token( WP_User $user ) {
166178
*/
167179
$expire = apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
168180

181+
// Generate unique JTI for access token
182+
$jti = wp_generate_uuid4();
183+
169184
$token = array(
170185
'iss' => get_bloginfo( 'url' ),
171186
'iat' => $issued_at,
172187
'exp' => $expire,
188+
'jti' => $jti,
173189
'type' => 'access',
174190
'data' => array(
175191
'user' => array(
@@ -178,6 +194,11 @@ public function generate_access_token( WP_User $user ) {
178194
),
179195
);
180196

197+
// Link to refresh token if provided
198+
if ( ! empty( $refresh_jti ) ) {
199+
$token['refresh_jti'] = $refresh_jti;
200+
}
201+
181202
/*
182203
* Let the user modify the access token data before the sign.
183204
*
@@ -274,16 +295,24 @@ public function generate_refresh_token( WP_User $user ) {
274295
* @return array|WP_Error
275296
*/
276297
public function generate_token_pair( WP_User $user ) {
277-
$access_token = $this->generate_access_token( $user );
278-
if ( is_wp_error( $access_token ) ) {
279-
return $access_token;
280-
}
281-
298+
// Generate refresh token first to get its JTI
282299
$refresh_token = $this->generate_refresh_token( $user );
283300
if ( is_wp_error( $refresh_token ) ) {
284301
return $refresh_token;
285302
}
286303

304+
// Decode to get the JTI
305+
$decoded_refresh = $this->validate_token( $refresh_token, 'refresh' );
306+
if ( is_wp_error( $decoded_refresh ) ) {
307+
return $decoded_refresh;
308+
}
309+
310+
// Generate access token with link to refresh token
311+
$access_token = $this->generate_access_token( $user, $decoded_refresh->jti ?? '' );
312+
if ( is_wp_error( $access_token ) ) {
313+
return $access_token;
314+
}
315+
287316
$issued_at = time();
288317
$expire = apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
289318

@@ -399,8 +428,8 @@ public function refresh_access_token( string $refresh_token ) {
399428
// Update last_active timestamp for this session
400429
$this->update_session_activity( $decoded->data->user->id, $decoded->jti ?? '' );
401430

402-
// Generate new access token (refresh token stays the same)
403-
$new_access_token = $this->generate_access_token( $user );
431+
// Generate new access token with link to refresh token (refresh token stays the same)
432+
$new_access_token = $this->generate_access_token( $user, $decoded->jti ?? '' );
404433
if ( is_wp_error( $new_access_token ) ) {
405434
return $new_access_token;
406435
}
@@ -587,7 +616,7 @@ public function revoke_all_sessions_except( int $user_id, string $current_jti ):
587616
*
588617
* @return bool
589618
*/
590-
private function update_session_activity( int $user_id, string $jti ): bool {
619+
public function update_session_activity( int $user_id, string $jti ): bool {
591620
$refresh_tokens = get_user_meta( $user_id, '_woocommerce_pos_refresh_tokens', true );
592621
if ( ! \is_array( $refresh_tokens ) || ! isset( $refresh_tokens[ $jti ] ) ) {
593622
return false;
@@ -768,4 +797,63 @@ private function parse_user_agent( string $user_agent ): array {
768797

769798
return $device_info;
770799
}
800+
801+
/**
802+
* Blacklist an access token by JTI (for instant revocation).
803+
*
804+
* @param string $jti Access token JTI.
805+
* @param int $ttl Time to live in seconds (until token expires).
806+
*
807+
* @return bool
808+
*/
809+
public function blacklist_access_token( string $jti, int $ttl ): bool {
810+
if ( empty( $jti ) ) {
811+
return false;
812+
}
813+
814+
// Use transient with TTL matching token expiration
815+
return set_transient( "wcpos_blacklist_{$jti}", true, $ttl );
816+
}
817+
818+
/**
819+
* Check if an access token is blacklisted.
820+
*
821+
* @param string $jti Access token JTI.
822+
*
823+
* @return bool
824+
*/
825+
private function is_access_token_blacklisted( string $jti ): bool {
826+
if ( empty( $jti ) ) {
827+
return false;
828+
}
829+
830+
// Check transient
831+
return false !== get_transient( "wcpos_blacklist_{$jti}" );
832+
}
833+
834+
/**
835+
* Revoke session and blacklist current access token.
836+
*
837+
* @param int $user_id
838+
* @param string $refresh_jti Refresh token JTI.
839+
* @param string $access_jti Optional access token JTI to blacklist immediately.
840+
*
841+
* @return bool
842+
*/
843+
public function revoke_session_with_blacklist( int $user_id, string $refresh_jti, string $access_jti = '' ): bool {
844+
// Revoke the refresh token (session)
845+
$revoked = $this->revoke_session( $user_id, $refresh_jti );
846+
847+
// Blacklist the current access token if provided
848+
if ( $revoked && ! empty( $access_jti ) ) {
849+
// Calculate TTL - access tokens expire in 30 minutes by default
850+
$issued_at = time();
851+
$expire = apply_filters( 'woocommerce_pos_jwt_access_token_expire', $issued_at + ( HOUR_IN_SECONDS / 2 ), $issued_at );
852+
$ttl = max( 0, $expire - $issued_at );
853+
854+
$this->blacklist_access_token( $access_jti, $ttl );
855+
}
856+
857+
return $revoked;
858+
}
771859
}

0 commit comments

Comments
 (0)