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

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