PluginProbe
ActivityPub / 8.1.1
ActivityPub v8.1.1
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / oauth / class-token.php

class-token.php in ActivityPub 8.1.1, at includes/oauth/class-token.php

876 lines 23.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OAuth 2.0 Token model for ActivityPub C2S.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\OAuth;
9
10 use Activitypub\Collection\Actors;
11
12 /**
13 * Token class for managing OAuth 2.0 access and refresh tokens.
14 *
15 * Tokens are stored as user metadata with hashed values for security.
16 * This follows the IndieAuth pattern for efficient token management.
17 */
18 class Token {
19 /**
20 * User meta key prefix for OAuth tokens.
21 */
22 const META_PREFIX = '_activitypub_oauth_token_';
23
24 /**
25 * User meta key prefix for refresh token index (maps refresh hash to access hash).
26 */
27 const REFRESH_INDEX_PREFIX = '_activitypub_oauth_refresh_';
28
29 /**
30 * Post meta key on OAuth client posts to track users with tokens.
31 *
32 * Stored as non-unique post meta (one row per user) on ap_oauth_client posts,
33 * following the same pattern as _activitypub_following on ap_actor posts.
34 */
35 const USER_META_KEY = '_activitypub_user_id';
36
37 /**
38 * Maximum number of active tokens per user.
39 *
40 * When exceeded, the oldest tokens are revoked automatically.
41 *
42 * @since 8.1.0
43 */
44 const MAX_TOKENS_PER_USER = 50;
45
46 /**
47 * Default access token expiration in seconds (1 hour).
48 */
49 const DEFAULT_EXPIRATION = 3600;
50
51 /**
52 * Refresh token expiration in seconds (30 days).
53 */
54 const REFRESH_EXPIRATION = 2592000;
55
56 /**
57 * The token data array.
58 *
59 * @var array
60 */
61 private $data;
62
63 /**
64 * The user ID this token belongs to.
65 *
66 * @var int
67 */
68 private $user_id;
69
70 /**
71 * The token key (hash) used for storage.
72 *
73 * @var string
74 */
75 private $token_key;
76
77 /**
78 * Constructor.
79 *
80 * @param int $user_id The user ID.
81 * @param string $token_key The token key (hash).
82 * @param array $data The token data.
83 */
84 public function __construct( $user_id, $token_key, $data ) {
85 $this->user_id = $user_id;
86 $this->token_key = $token_key;
87 $this->data = $data;
88 }
89
90 /**
91 * Create a new access token.
92 *
93 * @param int $user_id WordPress user ID.
94 * @param string $client_id OAuth client ID.
95 * @param array $scopes Granted scopes.
96 * @param int $expires Expiration time in seconds.
97 * @return array|\WP_Error Token data or error.
98 */
99 public static function create( $user_id, $client_id, $scopes, $expires = self::DEFAULT_EXPIRATION ) {
100 // Generate tokens.
101 $access_token = self::generate_token();
102 $refresh_token = self::generate_token();
103
104 // Calculate expirations.
105 $access_expires_at = time() + $expires;
106 $refresh_expires_at = time() + self::REFRESH_EXPIRATION;
107
108 // Create token data.
109 $token_data = array(
110 'access_token_hash' => self::hash_token( $access_token ),
111 'refresh_token_hash' => self::hash_token( $refresh_token ),
112 'client_id' => $client_id,
113 'scopes' => Scope::validate( $scopes ),
114 'expires_at' => $access_expires_at,
115 'refresh_expires_at' => $refresh_expires_at,
116 'created_at' => time(),
117 'last_used_at' => null,
118 );
119
120 // Store in user meta with access token hash as key.
121 $access_hash = self::hash_token( $access_token );
122 $meta_key = self::META_PREFIX . $access_hash;
123 $result = \update_user_meta( $user_id, $meta_key, $token_data );
124
125 if ( false === $result ) {
126 return new \WP_Error(
127 'activitypub_token_storage_failed',
128 \__( 'Failed to store access token.', 'activitypub' ),
129 array( 'status' => 500 )
130 );
131 }
132
133 // Store refresh token index for O(1) lookup during refresh.
134 $refresh_index_key = self::REFRESH_INDEX_PREFIX . self::hash_token( $refresh_token );
135 \update_user_meta( $user_id, $refresh_index_key, $access_hash );
136
137 // Track user on the client post for cleanup.
138 self::track_user( $user_id, $client_id );
139
140 // Enforce per-user token limit by revoking the oldest tokens.
141 self::enforce_token_limit( $user_id );
142
143 /*
144 * Get the actor URI for the 'me' parameter (IndieAuth convention).
145 * Fall back to blog actor when user actors are disabled.
146 */
147 $actor = Actors::get_by_id( $user_id );
148 if ( \is_wp_error( $actor ) ) {
149 $actor = Actors::get_by_id( Actors::BLOG_USER_ID );
150 }
151 $me = ! \is_wp_error( $actor ) ? $actor->get_id() : null;
152
153 return array(
154 'access_token' => $access_token,
155 'token_type' => 'Bearer',
156 'expires_in' => $expires,
157 'refresh_token' => $refresh_token,
158 'scope' => Scope::to_string( $token_data['scopes'] ),
159 'me' => $me,
160 );
161 }
162
163 /**
164 * Validate an access token.
165 *
166 * @param string $token The access token to validate.
167 * @return Token|\WP_Error The token object or error.
168 */
169 public static function validate( $token ) {
170 global $wpdb;
171
172 $token_hash = self::hash_token( $token );
173 $meta_key = self::META_PREFIX . $token_hash;
174
175 // Direct DB lookup by meta_key - O(1) instead of O(n) users.
176 $user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
177 $wpdb->prepare(
178 "SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
179 $meta_key
180 )
181 );
182
183 if ( empty( $user_id ) ) {
184 return new \WP_Error(
185 'activitypub_invalid_token',
186 \__( 'Invalid access token.', 'activitypub' ),
187 array( 'status' => 401 )
188 );
189 }
190
191 $token_data = \get_user_meta( (int) $user_id, $meta_key, true );
192
193 if ( empty( $token_data ) || ! is_array( $token_data ) ) {
194 return new \WP_Error(
195 'activitypub_invalid_token',
196 \__( 'Invalid access token.', 'activitypub' ),
197 array( 'status' => 401 )
198 );
199 }
200
201 // Verify hash matches.
202 if ( ! isset( $token_data['access_token_hash'] ) ||
203 ! hash_equals( $token_data['access_token_hash'], $token_hash ) ) {
204 return new \WP_Error(
205 'activitypub_invalid_token',
206 \__( 'Invalid access token.', 'activitypub' ),
207 array( 'status' => 401 )
208 );
209 }
210
211 // Check expiration.
212 if ( isset( $token_data['expires_at'] ) && $token_data['expires_at'] < time() ) {
213 return new \WP_Error(
214 'activitypub_token_expired',
215 \__( 'Access token has expired.', 'activitypub' ),
216 array( 'status' => 401 )
217 );
218 }
219
220 // Throttle last_used_at writes to avoid a DB write on every request.
221 $last_used = $token_data['last_used_at'] ?? 0;
222 if ( empty( $last_used ) || ( time() - $last_used ) > 5 * MINUTE_IN_SECONDS ) {
223 $token_data['last_used_at'] = time();
224 \update_user_meta( (int) $user_id, $meta_key, $token_data );
225 }
226
227 return new self( (int) $user_id, $token_hash, $token_data );
228 }
229
230 /**
231 * Refresh an access token using a refresh token.
232 *
233 * @param string $refresh_token The refresh token.
234 * @param string $client_id The client ID (must match original).
235 * @return array|\WP_Error New token data or error.
236 */
237 public static function refresh( $refresh_token, $client_id ) {
238 global $wpdb;
239
240 $refresh_hash = self::hash_token( $refresh_token );
241 $refresh_index_key = self::REFRESH_INDEX_PREFIX . $refresh_hash;
242
243 // Direct DB lookup by refresh token index - O(1) instead of O(n) users.
244 $user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
245 $wpdb->prepare(
246 "SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
247 $refresh_index_key
248 )
249 );
250
251 if ( empty( $user_id ) ) {
252 return new \WP_Error(
253 'activitypub_invalid_refresh_token',
254 \__( 'Invalid refresh token.', 'activitypub' ),
255 array( 'status' => 401 )
256 );
257 }
258
259 $user_id = (int) $user_id;
260
261 // Get the access token hash from the index.
262 $access_hash = \get_user_meta( $user_id, $refresh_index_key, true );
263 if ( empty( $access_hash ) ) {
264 return new \WP_Error(
265 'activitypub_invalid_refresh_token',
266 \__( 'Invalid refresh token.', 'activitypub' ),
267 array( 'status' => 401 )
268 );
269 }
270
271 // Get the full token data.
272 $meta_key = self::META_PREFIX . $access_hash;
273 $token_data = \get_user_meta( $user_id, $meta_key, true );
274
275 if ( empty( $token_data ) || ! is_array( $token_data ) ) {
276 return new \WP_Error(
277 'activitypub_invalid_refresh_token',
278 \__( 'Invalid refresh token.', 'activitypub' ),
279 array( 'status' => 401 )
280 );
281 }
282
283 // Verify refresh token hash matches.
284 if ( ! isset( $token_data['refresh_token_hash'] ) ||
285 ! hash_equals( $token_data['refresh_token_hash'], $refresh_hash ) ) {
286 return new \WP_Error(
287 'activitypub_invalid_refresh_token',
288 \__( 'Invalid refresh token.', 'activitypub' ),
289 array( 'status' => 401 )
290 );
291 }
292
293 // Verify client ID matches.
294 if ( $token_data['client_id'] !== $client_id ) {
295 return new \WP_Error(
296 'activitypub_client_mismatch',
297 \__( 'Client ID does not match.', 'activitypub' ),
298 array( 'status' => 400 )
299 );
300 }
301
302 // Check refresh token expiration.
303 if ( isset( $token_data['refresh_expires_at'] ) &&
304 $token_data['refresh_expires_at'] < time() ) {
305 // Delete the expired token and index.
306 \delete_user_meta( $user_id, $meta_key );
307 \delete_user_meta( $user_id, $refresh_index_key );
308
309 return new \WP_Error(
310 'activitypub_refresh_token_expired',
311 \__( 'Refresh token has expired.', 'activitypub' ),
312 array( 'status' => 401 )
313 );
314 }
315
316 // Delete the old token and index.
317 \delete_user_meta( $user_id, $meta_key );
318 \delete_user_meta( $user_id, $refresh_index_key );
319
320 // Create a new token.
321 return self::create( $user_id, $client_id, $token_data['scopes'] );
322 }
323
324 /**
325 * Revoke a token.
326 *
327 * @param string $token The token to revoke (access or refresh).
328 * @return bool True on success (always returns true per RFC 7009).
329 */
330 public static function revoke( $token ) {
331 global $wpdb;
332
333 $token_hash = self::hash_token( $token );
334
335 // Try as access token first (O(1) lookup).
336 $access_meta_key = self::META_PREFIX . $token_hash;
337 $user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
338 $wpdb->prepare(
339 "SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
340 $access_meta_key
341 )
342 );
343
344 if ( $user_id ) {
345 $user_id = (int) $user_id;
346 $token_data = \get_user_meta( $user_id, $access_meta_key, true );
347 $client_id = is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : '';
348
349 // Delete the token.
350 \delete_user_meta( $user_id, $access_meta_key );
351
352 // Also delete the refresh token index if it exists.
353 if ( is_array( $token_data ) && isset( $token_data['refresh_token_hash'] ) ) {
354 $refresh_index_key = self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'];
355 \delete_user_meta( $user_id, $refresh_index_key );
356 }
357
358 self::maybe_untrack_user( $user_id, $client_id );
359 return true;
360 }
361
362 // Try as refresh token (O(1) lookup via index).
363 $refresh_index_key = self::REFRESH_INDEX_PREFIX . $token_hash;
364 $user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
365 $wpdb->prepare(
366 "SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
367 $refresh_index_key
368 )
369 );
370
371 if ( $user_id ) {
372 $user_id = (int) $user_id;
373 $access_hash = \get_user_meta( $user_id, $refresh_index_key, true );
374 $client_id = '';
375
376 // Delete the token and index.
377 if ( $access_hash ) {
378 $token_data = \get_user_meta( $user_id, self::META_PREFIX . $access_hash, true );
379 $client_id = is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : '';
380 \delete_user_meta( $user_id, self::META_PREFIX . $access_hash );
381 }
382 \delete_user_meta( $user_id, $refresh_index_key );
383
384 self::maybe_untrack_user( $user_id, $client_id );
385 return true;
386 }
387
388 // Token doesn't exist or already revoked - that's fine per RFC 7009.
389 return true;
390 }
391
392 /**
393 * Untrack user from a client if they have no remaining tokens for that client.
394 *
395 * @param int $user_id The user ID.
396 * @param string $client_id The OAuth client ID.
397 */
398 private static function maybe_untrack_user( $user_id, $client_id ) {
399 if ( empty( $client_id ) ) {
400 return;
401 }
402
403 // Check if user has any remaining tokens for this client.
404 $tokens = self::get_all_for_user( $user_id );
405 foreach ( $tokens as $token_data ) {
406 if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) {
407 return; // Still has tokens for this client.
408 }
409 }
410
411 self::untrack_user( $user_id, $client_id );
412 }
413
414 /**
415 * Revoke all tokens for a user.
416 *
417 * @param int $user_id WordPress user ID.
418 * @return int Number of tokens revoked.
419 */
420 public static function revoke_all_for_user( $user_id ) {
421 $all_meta = \get_user_meta( $user_id );
422 $count = 0;
423 $client_ids = array();
424
425 foreach ( $all_meta as $meta_key => $meta_values ) {
426 // Delete token entries and collect client IDs.
427 if ( 0 === strpos( $meta_key, self::META_PREFIX ) ) {
428 $token_data = \maybe_unserialize( $meta_values[0] );
429 if ( is_array( $token_data ) && ! empty( $token_data['client_id'] ) ) {
430 $client_ids[] = $token_data['client_id'];
431 }
432 \delete_user_meta( $user_id, $meta_key );
433 ++$count;
434 }
435 // Delete refresh token indices.
436 if ( 0 === strpos( $meta_key, self::REFRESH_INDEX_PREFIX ) ) {
437 \delete_user_meta( $user_id, $meta_key );
438 }
439 }
440
441 // Remove user from all client tracking.
442 foreach ( array_unique( $client_ids ) as $client_id ) {
443 self::untrack_user( $user_id, $client_id );
444 }
445
446 return $count;
447 }
448
449 /**
450 * Revoke all tokens for all users.
451 *
452 * Used during plugin uninstall to clean up all OAuth token data.
453 *
454 * @return int Number of tokens revoked.
455 */
456 public static function revoke_all() {
457 $user_ids = self::get_all_tracked_users();
458 $count = 0;
459
460 foreach ( $user_ids as $user_id ) {
461 $count += self::revoke_all_for_user( $user_id );
462 }
463
464 return $count;
465 }
466
467 /**
468 * Revoke all tokens for a specific client.
469 *
470 * @param string $client_id OAuth client ID.
471 * @return int Number of tokens revoked.
472 */
473 public static function revoke_for_client( $client_id ) {
474 $user_ids = self::get_tracked_users( $client_id );
475 $count = 0;
476
477 foreach ( $user_ids as $user_id ) {
478 $all_meta = \get_user_meta( $user_id );
479
480 foreach ( $all_meta as $meta_key => $meta_values ) {
481 if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
482 continue;
483 }
484
485 $token_data = \maybe_unserialize( $meta_values[0] );
486
487 if ( ! is_array( $token_data ) ) {
488 continue;
489 }
490
491 // Only revoke tokens belonging to this client.
492 if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) {
493 \delete_user_meta( $user_id, $meta_key );
494 // Also delete refresh token index.
495 if ( isset( $token_data['refresh_token_hash'] ) ) {
496 \delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] );
497 }
498 ++$count;
499 }
500 }
501 }
502
503 // Remove all user tracking for this client.
504 self::untrack_all_users( $client_id );
505
506 return $count;
507 }
508
509 /**
510 * Get all tokens for a user.
511 *
512 * @param int $user_id WordPress user ID.
513 * @return array Array of token data.
514 */
515 public static function get_all_for_user( $user_id ) {
516 $all_meta = \get_user_meta( $user_id );
517 $tokens = array();
518
519 foreach ( $all_meta as $meta_key => $meta_values ) {
520 if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
521 continue;
522 }
523
524 $token_data = \maybe_unserialize( $meta_values[0] );
525
526 if ( is_array( $token_data ) ) {
527 // Don't expose hashes.
528 unset( $token_data['access_token_hash'], $token_data['refresh_token_hash'] );
529 $token_data['meta_key'] = $meta_key; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Not a DB query, just array key.
530 $tokens[] = $token_data;
531 }
532 }
533
534 return $tokens;
535 }
536
537 /**
538 * Check if token has a specific scope.
539 *
540 * @param string $scope The scope to check.
541 * @return bool True if token has scope.
542 */
543 public function has_scope( $scope ) {
544 $scopes = $this->get_scopes();
545 return Scope::contains( $scopes, $scope );
546 }
547
548 /**
549 * Get the user ID associated with this token.
550 *
551 * @return int The WordPress user ID.
552 */
553 public function get_user_id() {
554 return $this->user_id;
555 }
556
557 /**
558 * Get the client ID associated with this token.
559 *
560 * @return string The OAuth client ID.
561 */
562 public function get_client_id() {
563 return $this->data['client_id'] ?? '';
564 }
565
566 /**
567 * Get the scopes for this token.
568 *
569 * @return array The granted scopes.
570 */
571 public function get_scopes() {
572 return $this->data['scopes'] ?? array();
573 }
574
575 /**
576 * Get the expiration timestamp.
577 *
578 * @return int Unix timestamp.
579 */
580 public function get_expires_at() {
581 return $this->data['expires_at'] ?? 0;
582 }
583
584 /**
585 * Check if the token is expired.
586 *
587 * @return bool True if expired.
588 */
589 public function is_expired() {
590 return $this->get_expires_at() < time();
591 }
592
593 /**
594 * Get the creation timestamp.
595 *
596 * @return int Unix timestamp.
597 */
598 public function get_created_at() {
599 return $this->data['created_at'] ?? 0;
600 }
601
602 /**
603 * Get the last used timestamp.
604 *
605 * @return int|null Unix timestamp or null if never used.
606 */
607 public function get_last_used_at() {
608 return $this->data['last_used_at'] ?? null;
609 }
610
611 /**
612 * Generate a cryptographically secure random token.
613 *
614 * @param int $length The length of the token in bytes (default 32 = 64 hex chars).
615 * @return string The random token as a hex string.
616 */
617 public static function generate_token( $length = 32 ) {
618 return bin2hex( random_bytes( $length ) );
619 }
620
621 /**
622 * Hash a token for secure storage.
623 *
624 * @param string $token The token to hash.
625 * @return string The SHA-256 hash.
626 */
627 public static function hash_token( $token ) {
628 return hash( 'sha256', $token );
629 }
630
631 /**
632 * Track a user as having tokens for a client.
633 *
634 * Stores user ID as non-unique post meta on the client post,
635 * following the same pattern as _activitypub_following on ap_actor posts.
636 *
637 * @param int $user_id The user ID.
638 * @param string $client_id The OAuth client ID.
639 */
640 private static function track_user( $user_id, $client_id ) {
641 $client = Client::get( $client_id );
642
643 if ( \is_wp_error( $client ) ) {
644 return;
645 }
646
647 $post_id = $client->get_post_id();
648 $existing = \get_post_meta( $post_id, self::USER_META_KEY, false );
649
650 if ( ! in_array( $user_id, array_map( 'intval', $existing ), true ) ) {
651 \add_post_meta( $post_id, self::USER_META_KEY, $user_id );
652 }
653 }
654
655 /**
656 * Enforce per-user token limit by revoking oldest tokens.
657 *
658 * @since 8.1.0
659 *
660 * @param int $user_id The user ID.
661 */
662 private static function enforce_token_limit( $user_id ) {
663 $all_meta = \get_user_meta( $user_id );
664 $tokens = array();
665
666 foreach ( $all_meta as $meta_key => $meta_values ) {
667 if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
668 continue;
669 }
670
671 $token_data = \maybe_unserialize( $meta_values[0] );
672
673 if ( is_array( $token_data ) ) {
674 $tokens[ $meta_key ] = $token_data;
675 }
676 }
677
678 if ( count( $tokens ) <= self::MAX_TOKENS_PER_USER ) {
679 return;
680 }
681
682 // Sort by created_at ascending (oldest first).
683 uasort(
684 $tokens,
685 function ( $a, $b ) {
686 return ( $a['created_at'] ?? 0 ) - ( $b['created_at'] ?? 0 );
687 }
688 );
689
690 $to_remove = count( $tokens ) - self::MAX_TOKENS_PER_USER;
691
692 foreach ( $tokens as $meta_key => $token_data ) {
693 if ( $to_remove <= 0 ) {
694 break;
695 }
696
697 \delete_user_meta( $user_id, $meta_key );
698
699 // Also delete the refresh token index.
700 if ( isset( $token_data['refresh_token_hash'] ) ) {
701 \delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] );
702 }
703
704 --$to_remove;
705 }
706 }
707
708 /**
709 * Untrack a user from a specific client.
710 *
711 * @param int $user_id The user ID.
712 * @param string $client_id The OAuth client ID.
713 */
714 private static function untrack_user( $user_id, $client_id ) {
715 $client = Client::get( $client_id );
716
717 if ( \is_wp_error( $client ) ) {
718 return;
719 }
720
721 \delete_post_meta( $client->get_post_id(), self::USER_META_KEY, $user_id );
722 }
723
724 /**
725 * Untrack all users from a specific client.
726 *
727 * @param string $client_id The OAuth client ID.
728 */
729 private static function untrack_all_users( $client_id ) {
730 $client = Client::get( $client_id );
731
732 if ( \is_wp_error( $client ) ) {
733 return;
734 }
735
736 \delete_post_meta( $client->get_post_id(), self::USER_META_KEY );
737 }
738
739 /**
740 * Get tracked users for a specific client.
741 *
742 * @param string $client_id The OAuth client ID.
743 * @return array User IDs.
744 */
745 private static function get_tracked_users( $client_id ) {
746 $client = Client::get( $client_id );
747
748 if ( \is_wp_error( $client ) ) {
749 return array();
750 }
751
752 $user_ids = \get_post_meta( $client->get_post_id(), self::USER_META_KEY, false );
753
754 return array_map( 'intval', $user_ids );
755 }
756
757 /**
758 * Get all user IDs with tokens across all clients.
759 *
760 * @return array Unique user IDs.
761 */
762 private static function get_all_tracked_users() {
763 global $wpdb;
764
765 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
766 $user_ids = $wpdb->get_col(
767 $wpdb->prepare(
768 "SELECT DISTINCT pm.meta_value FROM $wpdb->postmeta pm
769 INNER JOIN $wpdb->posts p ON pm.post_id = p.ID
770 WHERE p.post_type = %s AND pm.meta_key = %s",
771 Client::POST_TYPE,
772 self::USER_META_KEY
773 )
774 );
775
776 return array_map( 'intval', $user_ids );
777 }
778
779 /**
780 * Clean up expired tokens.
781 *
782 * Should be called periodically via cron.
783 *
784 * @return int Number of tokens deleted.
785 */
786 public static function cleanup_expired() {
787 $user_ids = self::get_all_tracked_users();
788 $count = 0;
789
790 foreach ( $user_ids as $user_id ) {
791 $all_meta = \get_user_meta( $user_id );
792 $client_ids = array();
793
794 foreach ( $all_meta as $meta_key => $meta_values ) {
795 if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
796 continue;
797 }
798
799 $token_data = \maybe_unserialize( $meta_values[0] );
800
801 if ( ! is_array( $token_data ) ) {
802 \delete_user_meta( $user_id, $meta_key );
803 ++$count;
804 continue;
805 }
806
807 // Check if both access and refresh tokens are expired.
808 $access_expired = isset( $token_data['expires_at'] ) &&
809 $token_data['expires_at'] < time() - DAY_IN_SECONDS;
810 $refresh_expired = isset( $token_data['refresh_expires_at'] ) &&
811 $token_data['refresh_expires_at'] < time();
812
813 if ( $access_expired && $refresh_expired ) {
814 \delete_user_meta( $user_id, $meta_key );
815 // Also delete refresh token index.
816 if ( isset( $token_data['refresh_token_hash'] ) ) {
817 \delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] );
818 }
819 ++$count;
820
821 if ( ! empty( $token_data['client_id'] ) ) {
822 $client_ids[] = $token_data['client_id'];
823 }
824 }
825 }
826
827 // Untrack user from clients where all tokens were removed.
828 foreach ( array_unique( $client_ids ) as $client_id ) {
829 self::maybe_untrack_user( $user_id, $client_id );
830 }
831 }
832
833 return $count;
834 }
835
836 /**
837 * Introspect a token (RFC 7662).
838 *
839 * @param string $token The token to introspect.
840 * @return array Token introspection response.
841 */
842 public static function introspect( $token ) {
843 $validated = self::validate( $token );
844
845 if ( \is_wp_error( $validated ) ) {
846 // Return inactive for invalid/expired tokens.
847 return array( 'active' => false );
848 }
849
850 $user_id = $validated->get_user_id();
851 $user = \get_userdata( $user_id );
852
853 /*
854 * Get the actor URI for the 'me' parameter (IndieAuth convention).
855 * Fall back to blog actor when user actors are disabled.
856 */
857 $actor = Actors::get_by_id( $user_id );
858 if ( \is_wp_error( $actor ) ) {
859 $actor = Actors::get_by_id( Actors::BLOG_USER_ID );
860 }
861 $me = ! \is_wp_error( $actor ) ? $actor->get_id() : null;
862
863 return array(
864 'active' => true,
865 'scope' => Scope::to_string( $validated->get_scopes() ),
866 'client_id' => $validated->get_client_id(),
867 'username' => $user ? $user->user_login : null,
868 'token_type' => 'Bearer',
869 'exp' => $validated->get_expires_at(),
870 'iat' => $validated->get_created_at(),
871 'sub' => (string) $user_id,
872 'me' => $me,
873 );
874 }
875 }
876