PluginProbe
ActivityPub / 9.1.0
ActivityPub v9.1.0
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 9.1.0, at includes/oauth/class-token.php

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