PluginProbe
ActivityPub / 8.2.1
ActivityPub v8.2.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.2.1, at includes/oauth/class-token.php

934 lines 25.7 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 * When `$caller_user_id` or `$caller_client_id` is provided, the token
328 * is only deleted if it was issued to that user or that client, per
329 * RFC 7009 Section 2.1. A mismatch is treated as a successful no-op so
330 * the caller cannot probe for token existence belonging to others.
331 *
332 * @since 8.2.0 The `$caller_user_id` and `$caller_client_id` parameters were added.
333 *
334 * @param string $token The token to revoke (access or refresh).
335 * @param int|null $caller_user_id Optional. User ID of the caller. Null disables the user check.
336 * @param string|null $caller_client_id Optional. OAuth client ID of the caller. Null disables the client check.
337 * @return bool True on success (always returns true per RFC 7009).
338 */
339 public static function revoke( $token, $caller_user_id = null, $caller_client_id = null ) {
340 global $wpdb;
341
342 $token_hash = self::hash_token( $token );
343
344 // Try as access token first (O(1) lookup).
345 $access_meta_key = self::META_PREFIX . $token_hash;
346 $user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
347 $wpdb->prepare(
348 "SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
349 $access_meta_key
350 )
351 );
352
353 if ( $user_id ) {
354 $user_id = (int) $user_id;
355 $token_data = \get_user_meta( $user_id, $access_meta_key, true );
356 $client_id = is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : '';
357
358 if ( ! self::caller_owns_token( $user_id, $client_id, $caller_user_id, $caller_client_id ) ) {
359 return true;
360 }
361
362 // Delete the token.
363 \delete_user_meta( $user_id, $access_meta_key );
364
365 // Also delete the refresh token index if it exists.
366 if ( is_array( $token_data ) && isset( $token_data['refresh_token_hash'] ) ) {
367 $refresh_index_key = self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'];
368 \delete_user_meta( $user_id, $refresh_index_key );
369 }
370
371 self::maybe_untrack_user( $user_id, $client_id );
372 return true;
373 }
374
375 // Try as refresh token (O(1) lookup via index).
376 $refresh_index_key = self::REFRESH_INDEX_PREFIX . $token_hash;
377 $user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
378 $wpdb->prepare(
379 "SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
380 $refresh_index_key
381 )
382 );
383
384 if ( $user_id ) {
385 $user_id = (int) $user_id;
386 $access_hash = \get_user_meta( $user_id, $refresh_index_key, true );
387 $client_id = '';
388
389 if ( $access_hash ) {
390 $token_data = \get_user_meta( $user_id, self::META_PREFIX . $access_hash, true );
391 $client_id = is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : '';
392 }
393
394 if ( ! self::caller_owns_token( $user_id, $client_id, $caller_user_id, $caller_client_id ) ) {
395 return true;
396 }
397
398 if ( $access_hash ) {
399 \delete_user_meta( $user_id, self::META_PREFIX . $access_hash );
400 }
401 \delete_user_meta( $user_id, $refresh_index_key );
402
403 self::maybe_untrack_user( $user_id, $client_id );
404 return true;
405 }
406
407 // Token doesn't exist or already revoked - that's fine per RFC 7009.
408 return true;
409 }
410
411 /**
412 * Decide whether a caller is permitted to revoke a specific token.
413 *
414 * A null caller user and null caller client disable the check entirely,
415 * preserving the pre-RFC-7009-enforcement behavior for internal callers
416 * that already know they have authority (admin unlink, uninstall, etc.).
417 *
418 * When either caller parameter is provided, the token is considered
419 * owned if it matches the caller user OR the caller client. Matching
420 * client alone is enough to let an OAuth client clean up any token it
421 * issued, regardless of which user granted consent.
422 *
423 * @param int $token_user_id User ID the token was issued to.
424 * @param string $token_client_id OAuth client ID the token was issued to.
425 * @param int|null $caller_user_id Caller user ID, or null to skip the user check.
426 * @param string|null $caller_client_id Caller client ID, or null to skip the client check.
427 * @return bool True if the caller may revoke, false otherwise.
428 */
429 private static function caller_owns_token( $token_user_id, $token_client_id, $caller_user_id, $caller_client_id ) {
430 if ( null === $caller_user_id && null === $caller_client_id ) {
431 return true;
432 }
433
434 if ( null !== $caller_user_id && $token_user_id === $caller_user_id ) {
435 return true;
436 }
437
438 /*
439 * Require a real client_id on the token. An empty string on both
440 * sides would otherwise match and let an un-attributed token be
441 * revoked by any caller presenting an empty client claim.
442 */
443 if ( null !== $caller_client_id && '' !== $token_client_id && $token_client_id === $caller_client_id ) {
444 return true;
445 }
446
447 return false;
448 }
449
450 /**
451 * Untrack user from a client if they have no remaining tokens for that client.
452 *
453 * @param int $user_id The user ID.
454 * @param string $client_id The OAuth client ID.
455 */
456 private static function maybe_untrack_user( $user_id, $client_id ) {
457 if ( empty( $client_id ) ) {
458 return;
459 }
460
461 // Check if user has any remaining tokens for this client.
462 $tokens = self::get_all_for_user( $user_id );
463 foreach ( $tokens as $token_data ) {
464 if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) {
465 return; // Still has tokens for this client.
466 }
467 }
468
469 self::untrack_user( $user_id, $client_id );
470 }
471
472 /**
473 * Revoke all tokens for a user.
474 *
475 * @param int $user_id WordPress user ID.
476 * @return int Number of tokens revoked.
477 */
478 public static function revoke_all_for_user( $user_id ) {
479 $all_meta = \get_user_meta( $user_id );
480 $count = 0;
481 $client_ids = array();
482
483 foreach ( $all_meta as $meta_key => $meta_values ) {
484 // Delete token entries and collect client IDs.
485 if ( 0 === strpos( $meta_key, self::META_PREFIX ) ) {
486 $token_data = \maybe_unserialize( $meta_values[0] );
487 if ( is_array( $token_data ) && ! empty( $token_data['client_id'] ) ) {
488 $client_ids[] = $token_data['client_id'];
489 }
490 \delete_user_meta( $user_id, $meta_key );
491 ++$count;
492 }
493 // Delete refresh token indices.
494 if ( 0 === strpos( $meta_key, self::REFRESH_INDEX_PREFIX ) ) {
495 \delete_user_meta( $user_id, $meta_key );
496 }
497 }
498
499 // Remove user from all client tracking.
500 foreach ( array_unique( $client_ids ) as $client_id ) {
501 self::untrack_user( $user_id, $client_id );
502 }
503
504 return $count;
505 }
506
507 /**
508 * Revoke all tokens for all users.
509 *
510 * Used during plugin uninstall to clean up all OAuth token data.
511 *
512 * @return int Number of tokens revoked.
513 */
514 public static function revoke_all() {
515 $user_ids = self::get_all_tracked_users();
516 $count = 0;
517
518 foreach ( $user_ids as $user_id ) {
519 $count += self::revoke_all_for_user( $user_id );
520 }
521
522 return $count;
523 }
524
525 /**
526 * Revoke all tokens for a specific client.
527 *
528 * @param string $client_id OAuth client ID.
529 * @return int Number of tokens revoked.
530 */
531 public static function revoke_for_client( $client_id ) {
532 $user_ids = self::get_tracked_users( $client_id );
533 $count = 0;
534
535 foreach ( $user_ids as $user_id ) {
536 $all_meta = \get_user_meta( $user_id );
537
538 foreach ( $all_meta as $meta_key => $meta_values ) {
539 if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
540 continue;
541 }
542
543 $token_data = \maybe_unserialize( $meta_values[0] );
544
545 if ( ! is_array( $token_data ) ) {
546 continue;
547 }
548
549 // Only revoke tokens belonging to this client.
550 if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) {
551 \delete_user_meta( $user_id, $meta_key );
552 // Also delete refresh token index.
553 if ( isset( $token_data['refresh_token_hash'] ) ) {
554 \delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] );
555 }
556 ++$count;
557 }
558 }
559 }
560
561 // Remove all user tracking for this client.
562 self::untrack_all_users( $client_id );
563
564 return $count;
565 }
566
567 /**
568 * Get all tokens for a user.
569 *
570 * @param int $user_id WordPress user ID.
571 * @return array Array of token data.
572 */
573 public static function get_all_for_user( $user_id ) {
574 $all_meta = \get_user_meta( $user_id );
575 $tokens = array();
576
577 foreach ( $all_meta as $meta_key => $meta_values ) {
578 if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
579 continue;
580 }
581
582 $token_data = \maybe_unserialize( $meta_values[0] );
583
584 if ( is_array( $token_data ) ) {
585 // Don't expose hashes.
586 unset( $token_data['access_token_hash'], $token_data['refresh_token_hash'] );
587 $token_data['meta_key'] = $meta_key; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Not a DB query, just array key.
588 $tokens[] = $token_data;
589 }
590 }
591
592 return $tokens;
593 }
594
595 /**
596 * Check if token has a specific scope.
597 *
598 * @param string $scope The scope to check.
599 * @return bool True if token has scope.
600 */
601 public function has_scope( $scope ) {
602 $scopes = $this->get_scopes();
603 return Scope::contains( $scopes, $scope );
604 }
605
606 /**
607 * Get the user ID associated with this token.
608 *
609 * @return int The WordPress user ID.
610 */
611 public function get_user_id() {
612 return $this->user_id;
613 }
614
615 /**
616 * Get the client ID associated with this token.
617 *
618 * @return string The OAuth client ID.
619 */
620 public function get_client_id() {
621 return $this->data['client_id'] ?? '';
622 }
623
624 /**
625 * Get the scopes for this token.
626 *
627 * @return array The granted scopes.
628 */
629 public function get_scopes() {
630 return $this->data['scopes'] ?? array();
631 }
632
633 /**
634 * Get the expiration timestamp.
635 *
636 * @return int Unix timestamp.
637 */
638 public function get_expires_at() {
639 return $this->data['expires_at'] ?? 0;
640 }
641
642 /**
643 * Check if the token is expired.
644 *
645 * @return bool True if expired.
646 */
647 public function is_expired() {
648 return $this->get_expires_at() < time();
649 }
650
651 /**
652 * Get the creation timestamp.
653 *
654 * @return int Unix timestamp.
655 */
656 public function get_created_at() {
657 return $this->data['created_at'] ?? 0;
658 }
659
660 /**
661 * Get the last used timestamp.
662 *
663 * @return int|null Unix timestamp or null if never used.
664 */
665 public function get_last_used_at() {
666 return $this->data['last_used_at'] ?? null;
667 }
668
669 /**
670 * Generate a cryptographically secure random token.
671 *
672 * @param int $length The length of the token in bytes (default 32 = 64 hex chars).
673 * @return string The random token as a hex string.
674 */
675 public static function generate_token( $length = 32 ) {
676 return bin2hex( random_bytes( $length ) );
677 }
678
679 /**
680 * Hash a token for secure storage.
681 *
682 * @param string $token The token to hash.
683 * @return string The SHA-256 hash.
684 */
685 public static function hash_token( $token ) {
686 return hash( 'sha256', $token );
687 }
688
689 /**
690 * Track a user as having tokens for a client.
691 *
692 * Stores user ID as non-unique post meta on the client post,
693 * following the same pattern as _activitypub_following on ap_actor posts.
694 *
695 * @param int $user_id The user ID.
696 * @param string $client_id The OAuth client ID.
697 */
698 private static function track_user( $user_id, $client_id ) {
699 $client = Client::get( $client_id );
700
701 if ( \is_wp_error( $client ) ) {
702 return;
703 }
704
705 $post_id = $client->get_post_id();
706 $existing = \get_post_meta( $post_id, self::USER_META_KEY, false );
707
708 if ( ! in_array( $user_id, array_map( 'intval', $existing ), true ) ) {
709 \add_post_meta( $post_id, self::USER_META_KEY, $user_id );
710 }
711 }
712
713 /**
714 * Enforce per-user token limit by revoking oldest tokens.
715 *
716 * @since 8.1.0
717 *
718 * @param int $user_id The user ID.
719 */
720 private static function enforce_token_limit( $user_id ) {
721 $all_meta = \get_user_meta( $user_id );
722 $tokens = array();
723
724 foreach ( $all_meta as $meta_key => $meta_values ) {
725 if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
726 continue;
727 }
728
729 $token_data = \maybe_unserialize( $meta_values[0] );
730
731 if ( is_array( $token_data ) ) {
732 $tokens[ $meta_key ] = $token_data;
733 }
734 }
735
736 if ( count( $tokens ) <= self::MAX_TOKENS_PER_USER ) {
737 return;
738 }
739
740 // Sort by created_at ascending (oldest first).
741 uasort(
742 $tokens,
743 function ( $a, $b ) {
744 return ( $a['created_at'] ?? 0 ) - ( $b['created_at'] ?? 0 );
745 }
746 );
747
748 $to_remove = count( $tokens ) - self::MAX_TOKENS_PER_USER;
749
750 foreach ( $tokens as $meta_key => $token_data ) {
751 if ( $to_remove <= 0 ) {
752 break;
753 }
754
755 \delete_user_meta( $user_id, $meta_key );
756
757 // Also delete the refresh token index.
758 if ( isset( $token_data['refresh_token_hash'] ) ) {
759 \delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] );
760 }
761
762 --$to_remove;
763 }
764 }
765
766 /**
767 * Untrack a user from a specific client.
768 *
769 * @param int $user_id The user ID.
770 * @param string $client_id The OAuth client ID.
771 */
772 private static function untrack_user( $user_id, $client_id ) {
773 $client = Client::get( $client_id );
774
775 if ( \is_wp_error( $client ) ) {
776 return;
777 }
778
779 \delete_post_meta( $client->get_post_id(), self::USER_META_KEY, $user_id );
780 }
781
782 /**
783 * Untrack all users from a specific client.
784 *
785 * @param string $client_id The OAuth client ID.
786 */
787 private static function untrack_all_users( $client_id ) {
788 $client = Client::get( $client_id );
789
790 if ( \is_wp_error( $client ) ) {
791 return;
792 }
793
794 \delete_post_meta( $client->get_post_id(), self::USER_META_KEY );
795 }
796
797 /**
798 * Get tracked users for a specific client.
799 *
800 * @param string $client_id The OAuth client ID.
801 * @return array User IDs.
802 */
803 private static function get_tracked_users( $client_id ) {
804 $client = Client::get( $client_id );
805
806 if ( \is_wp_error( $client ) ) {
807 return array();
808 }
809
810 $user_ids = \get_post_meta( $client->get_post_id(), self::USER_META_KEY, false );
811
812 return array_map( 'intval', $user_ids );
813 }
814
815 /**
816 * Get all user IDs with tokens across all clients.
817 *
818 * @return array Unique user IDs.
819 */
820 private static function get_all_tracked_users() {
821 global $wpdb;
822
823 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
824 $user_ids = $wpdb->get_col(
825 $wpdb->prepare(
826 "SELECT DISTINCT pm.meta_value FROM $wpdb->postmeta pm
827 INNER JOIN $wpdb->posts p ON pm.post_id = p.ID
828 WHERE p.post_type = %s AND pm.meta_key = %s",
829 Client::POST_TYPE,
830 self::USER_META_KEY
831 )
832 );
833
834 return array_map( 'intval', $user_ids );
835 }
836
837 /**
838 * Clean up expired tokens.
839 *
840 * Should be called periodically via cron.
841 *
842 * @return int Number of tokens deleted.
843 */
844 public static function cleanup_expired() {
845 $user_ids = self::get_all_tracked_users();
846 $count = 0;
847
848 foreach ( $user_ids as $user_id ) {
849 $all_meta = \get_user_meta( $user_id );
850 $client_ids = array();
851
852 foreach ( $all_meta as $meta_key => $meta_values ) {
853 if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
854 continue;
855 }
856
857 $token_data = \maybe_unserialize( $meta_values[0] );
858
859 if ( ! is_array( $token_data ) ) {
860 \delete_user_meta( $user_id, $meta_key );
861 ++$count;
862 continue;
863 }
864
865 // Check if both access and refresh tokens are expired.
866 $access_expired = isset( $token_data['expires_at'] ) &&
867 $token_data['expires_at'] < time() - DAY_IN_SECONDS;
868 $refresh_expired = isset( $token_data['refresh_expires_at'] ) &&
869 $token_data['refresh_expires_at'] < time();
870
871 if ( $access_expired && $refresh_expired ) {
872 \delete_user_meta( $user_id, $meta_key );
873 // Also delete refresh token index.
874 if ( isset( $token_data['refresh_token_hash'] ) ) {
875 \delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] );
876 }
877 ++$count;
878
879 if ( ! empty( $token_data['client_id'] ) ) {
880 $client_ids[] = $token_data['client_id'];
881 }
882 }
883 }
884
885 // Untrack user from clients where all tokens were removed.
886 foreach ( array_unique( $client_ids ) as $client_id ) {
887 self::maybe_untrack_user( $user_id, $client_id );
888 }
889 }
890
891 return $count;
892 }
893
894 /**
895 * Introspect a token (RFC 7662).
896 *
897 * @param string $token The token to introspect.
898 * @return array Token introspection response.
899 */
900 public static function introspect( $token ) {
901 $validated = self::validate( $token );
902
903 if ( \is_wp_error( $validated ) ) {
904 // Return inactive for invalid/expired tokens.
905 return array( 'active' => false );
906 }
907
908 $user_id = $validated->get_user_id();
909 $user = \get_userdata( $user_id );
910
911 /*
912 * Get the actor URI for the 'me' parameter (IndieAuth convention).
913 * Fall back to blog actor when user actors are disabled.
914 */
915 $actor = Actors::get_by_id( $user_id );
916 if ( \is_wp_error( $actor ) ) {
917 $actor = Actors::get_by_id( Actors::BLOG_USER_ID );
918 }
919 $me = ! \is_wp_error( $actor ) ? $actor->get_id() : null;
920
921 return array(
922 'active' => true,
923 'scope' => Scope::to_string( $validated->get_scopes() ),
924 'client_id' => $validated->get_client_id(),
925 'username' => $user ? $user->user_login : null,
926 'token_type' => 'Bearer',
927 'exp' => $validated->get_expires_at(),
928 'iat' => $validated->get_created_at(),
929 'sub' => (string) $user_id,
930 'me' => $me,
931 );
932 }
933 }
934