PluginProbe
ActivityPub / 9.2.2
ActivityPub v9.2.2
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-client.php

class-client.php in ActivityPub 9.2.2, at includes/oauth/class-client.php

979 lines 28.5 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 Client model for ActivityPub C2S.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\OAuth;
9
10 use Activitypub\Sanitize;
11
12 use function Activitypub\get_client_ip;
13 use function Activitypub\resolve_public_host;
14
15 /**
16 * Client class for managing OAuth 2.0 client registrations.
17 *
18 * Supports both manual registration and RFC 7591 dynamic client registration,
19 * plus Client Identifier Metadata Documents (CIMD) where the `client_id` is
20 * itself a URL hosting a metadata document.
21 *
22 * ## Loopback policy (RFC 8252)
23 *
24 * Native apps register loopback redirect URIs to receive the OAuth callback
25 * on a port the app opened locally. RFC 8252 §7.3 / §8.3 cover this and
26 * specifically authorise `http://127.0.0.1:{port}/{path}` (IPv4) and
27 * `http://[::1]:{port}/{path}` (IPv6). `localhost` is permitted by common
28 * practice, though §8.3 marks it "NOT RECOMMENDED".
29 *
30 * `is_loopback()` reflects that scope: it matches `127.0.0.0/8`, `::1`
31 * (any spelling, normalised via `inet_pton`), `::ffff:127.x.x.x`, `localhost`,
32 * and `*.localhost`. Reserved-but-not-loopback addresses such as `0.0.0.0`,
33 * link-local `169.254.0.0/16`, and RFC1918 ranges are explicitly *not*
34 * treated as loopback and never bypass `wp_safe_remote_get()`.
35 *
36 * RFC 8252's loopback allowance applies to redirect URIs only. The CIMD
37 * document must be served over HTTPS from a publicly resolvable host:
38 * `client_id` discovery rejects non-`https` URLs up front, and
39 * `fetch_client_metadata()` resolves the host first and rejects anything
40 * private or loopback before falling through to `wp_safe_remote_get()`.
41 * Local development against a loopback CIMD endpoint is intentionally not
42 * supported.
43 *
44 * @see https://datatracker.ietf.org/doc/html/rfc8252 RFC 8252 — OAuth 2.0 for Native Apps
45 * @see https://datatracker.ietf.org/doc/html/rfc7591 RFC 7591 — OAuth 2.0 Dynamic Client Registration
46 */
47 class Client {
48 /**
49 * Post type for OAuth clients.
50 */
51 const POST_TYPE = 'ap_oauth_client';
52
53 /**
54 * The post ID of the client.
55 *
56 * @var int
57 */
58 private $post_id;
59
60 /**
61 * Constructor.
62 *
63 * @param int $post_id The post ID of the client.
64 */
65 public function __construct( $post_id ) {
66 $this->post_id = $post_id;
67 }
68
69 /**
70 * Register a new OAuth client.
71 *
72 * @param array $data Client registration data.
73 * - name: Client name (required).
74 * - redirect_uris: Array of redirect URIs (required).
75 * - description: Client description (optional).
76 * - is_public: Whether client is public/PKCE-only (default true).
77 * - scopes: Allowed scopes (optional, defaults to all).
78 * @return array|\WP_Error Client credentials or error.
79 */
80 public static function register( $data ) {
81 $name = $data['name'] ?? '';
82 $redirect_uris = $data['redirect_uris'] ?? array();
83 $description = $data['description'] ?? '';
84 $is_public = $data['is_public'] ?? true;
85 $scopes = $data['scopes'] ?? Scope::ALL;
86
87 // Validate required fields.
88 if ( empty( $name ) ) {
89 return new \WP_Error(
90 'activitypub_missing_client_name',
91 \__( 'Client name is required.', 'activitypub' ),
92 array( 'status' => 400 )
93 );
94 }
95
96 if ( empty( $redirect_uris ) ) {
97 return new \WP_Error(
98 'activitypub_missing_redirect_uri',
99 \__( 'At least one redirect URI is required.', 'activitypub' ),
100 array( 'status' => 400 )
101 );
102 }
103
104 // Validate redirect URIs.
105 foreach ( $redirect_uris as $uri ) {
106 if ( ! self::validate_uri_format( $uri ) ) {
107 return new \WP_Error(
108 'activitypub_invalid_redirect_uri',
109 /* translators: %s: The invalid redirect URI */
110 \sprintf( \__( 'Invalid redirect URI: %s', 'activitypub' ), $uri ),
111 array( 'status' => 400 )
112 );
113 }
114 }
115
116 // Generate client credentials.
117 $client_id = self::generate_client_id();
118 $client_secret = null;
119
120 if ( ! $is_public ) {
121 $client_secret = self::generate_client_secret();
122 }
123
124 // Create the client post.
125 $post_id = \wp_insert_post(
126 array(
127 'post_type' => self::POST_TYPE,
128 'post_status' => 'publish',
129 'post_title' => $name,
130 'post_content' => $description,
131 'meta_input' => array(
132 '_activitypub_client_id' => $client_id,
133 '_activitypub_client_secret_hash' => $client_secret ? \wp_hash_password( $client_secret ) : '',
134 '_activitypub_redirect_uris' => \array_map( array( Sanitize::class, 'redirect_uri' ), $redirect_uris ),
135 '_activitypub_allowed_scopes' => Scope::validate( $scopes ),
136 '_activitypub_is_public' => (bool) $is_public,
137 ),
138 ),
139 true
140 );
141
142 if ( \is_wp_error( $post_id ) ) {
143 return $post_id;
144 }
145
146 $result = array(
147 'client_id' => $client_id,
148 );
149
150 if ( $client_secret ) {
151 $result['client_secret'] = $client_secret;
152 }
153
154 return $result;
155 }
156
157 /**
158 * Get client by client_id.
159 *
160 * Supports auto-discovery: if client_id is a URL and not found locally,
161 * fetches the Client ID Metadata Document (CIMD) and auto-registers.
162 *
163 * @param string $client_id The client ID.
164 * @return Client|\WP_Error The client or error.
165 */
166 public static function get( $client_id ) {
167 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Client lookup by ID is necessary.
168 $posts = \get_posts(
169 array(
170 'post_type' => self::POST_TYPE,
171 'post_status' => 'publish',
172 'meta_key' => '_activitypub_client_id',
173 'meta_value' => $client_id,
174 'numberposts' => 1,
175 )
176 );
177 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
178
179 if ( ! empty( $posts ) ) {
180 $client = new self( $posts[0]->ID );
181
182 /*
183 * Re-discover stale auto-discovered clients that have no redirect URIs.
184 * This can happen when a previous discovery failed to parse the metadata
185 * correctly (e.g. before ActivityStreams vocabulary support was added).
186 */
187 if ( $client->is_discovered() && empty( $client->get_redirect_uris() ) && self::is_discoverable_url( $client_id ) ) {
188 \wp_delete_post( $posts[0]->ID, true );
189 return self::discover_and_register( $client_id );
190 }
191
192 return $client;
193 }
194
195 // If client_id is a discoverable URL (HTTPS), try auto-discovery.
196 if ( self::is_discoverable_url( $client_id ) ) {
197 return self::discover_and_register( $client_id );
198 }
199
200 return new \WP_Error(
201 'activitypub_client_not_found',
202 \__( 'OAuth client not found.', 'activitypub' ),
203 array( 'status' => 404 )
204 );
205 }
206
207 /**
208 * Determine whether a client_id is a discoverable URL.
209 *
210 * Only HTTPS URLs are eligible. The CIMD draft requires HTTPS for
211 * production, and accepting cleartext URLs would let a network-position
212 * attacker rewrite the metadata response and inject attacker-controlled
213 * redirect URIs that preserve the same client_id.
214 *
215 * @param string $client_id The client ID to check.
216 * @return bool True if the client_id is an HTTPS URL eligible for discovery.
217 */
218 private static function is_discoverable_url( $client_id ) {
219 if ( ! \filter_var( $client_id, FILTER_VALIDATE_URL ) ) {
220 return false;
221 }
222
223 return 'https' === \strtolower( (string) \wp_parse_url( $client_id, PHP_URL_SCHEME ) );
224 }
225
226 /**
227 * Discover client metadata from URL and auto-register.
228 *
229 * Fetches the Client ID Metadata Document (CIMD) from the client_id URL.
230 * Rate-limited via transients to prevent SSRF abuse.
231 *
232 * @param string $client_id The client ID URL.
233 * @return Client|\WP_Error The client or error.
234 */
235 private static function discover_and_register( $client_id ) {
236 // Rate-limit auto-discovery to prevent SSRF abuse (max 10 per minute per IP).
237 $ip = get_client_ip();
238 if ( '' === $ip ) {
239 return new \WP_Error(
240 'activitypub_rate_limited',
241 \__( 'Too many client discovery requests. Please try again later.', 'activitypub' ),
242 array( 'status' => 429 )
243 );
244 }
245 $transient_key = 'ap_oauth_disc_' . \md5( $ip );
246 $count = (int) \get_transient( $transient_key );
247
248 if ( $count >= 10 ) {
249 return new \WP_Error(
250 'activitypub_rate_limited',
251 \__( 'Too many client discovery requests. Please try again later.', 'activitypub' ),
252 array( 'status' => 429 )
253 );
254 }
255
256 \set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
257
258 $metadata = self::fetch_client_metadata( $client_id );
259
260 if ( \is_wp_error( $metadata ) ) {
261 return $metadata;
262 }
263
264 // Validate client_id is present and matches.
265 // A missing client_id allows client impersonation through redirects.
266 if ( empty( $metadata['client_id'] ) ) {
267 return new \WP_Error(
268 'activitypub_missing_client_id',
269 \__( 'Client metadata must contain a client_id property.', 'activitypub' ),
270 array( 'status' => 400 )
271 );
272 }
273
274 if ( $metadata['client_id'] !== $client_id ) {
275 return new \WP_Error(
276 'activitypub_client_id_mismatch',
277 \__( 'Client ID in metadata does not match request.', 'activitypub' ),
278 array( 'status' => 400 )
279 );
280 }
281
282 // Get redirect URIs from metadata or derive from client_id origin.
283 $redirect_uris = array();
284 if ( ! empty( $metadata['redirect_uris'] ) && \is_array( $metadata['redirect_uris'] ) ) {
285 foreach ( $metadata['redirect_uris'] as $uri ) {
286 if ( ! self::validate_uri_format( $uri ) ) {
287 return new \WP_Error(
288 'activitypub_invalid_redirect_uri',
289 /* translators: %s: The invalid redirect URI */
290 \sprintf( \__( 'Invalid redirect URI: %s', 'activitypub' ), $uri ),
291 array( 'status' => 400 )
292 );
293 }
294 }
295 $redirect_uris = $metadata['redirect_uris'];
296 }
297
298 // Register the discovered client.
299 $name = ! empty( $metadata['client_name'] ) ? $metadata['client_name'] : $client_id;
300
301 $post_id = \wp_insert_post(
302 array(
303 'post_type' => self::POST_TYPE,
304 'post_status' => 'publish',
305 'post_title' => $name,
306 'post_content' => '',
307 'meta_input' => array(
308 '_activitypub_client_id' => $client_id,
309 '_activitypub_client_secret_hash' => '', // Public client.
310 '_activitypub_redirect_uris' => \array_map( array( Sanitize::class, 'redirect_uri' ), $redirect_uris ),
311 '_activitypub_allowed_scopes' => Scope::ALL,
312 '_activitypub_is_public' => true,
313 '_activitypub_discovered' => true,
314 '_activitypub_logo_uri' => ! empty( $metadata['logo_uri'] ) ? \sanitize_url( $metadata['logo_uri'] ) : '',
315 '_activitypub_client_uri' => ! empty( $metadata['client_uri'] ) ? \sanitize_url( $metadata['client_uri'] ) : '',
316 ),
317 ),
318 true
319 );
320
321 if ( \is_wp_error( $post_id ) ) {
322 return $post_id;
323 }
324
325 return new self( $post_id );
326 }
327
328 /**
329 * Fetch client metadata from URL.
330 *
331 * Supports both CIMD JSON format and ActivityPub Application objects.
332 *
333 * @param string $url The client ID URL to fetch.
334 * @return array|\WP_Error Metadata array or error.
335 */
336 private static function fetch_client_metadata( $url ) {
337 /*
338 * Resolve the host explicitly and reject private/loopback addresses.
339 * wp_safe_remote_get() also performs URL validation but has a same-host
340 * carve-out (it allows requests to the WordPress site's own host even
341 * when that host is loopback/private). The CIMD document is meant to
342 * be a publicly resolvable HTTPS URL, so close that gap up front.
343 */
344 $host = \wp_parse_url( $url, PHP_URL_HOST );
345 if ( ! $host || false === resolve_public_host( $host ) ) {
346 return new \WP_Error(
347 'activitypub_client_unsafe_host',
348 \__( 'The client metadata URL host is not allowed.', 'activitypub' ),
349 array( 'status' => 400 )
350 );
351 }
352
353 $args = array(
354 'timeout' => 10,
355 'headers' => array(
356 'Accept' => 'application/cimd+json, application/json, application/ld+json, application/activity+json',
357 ),
358 'redirection' => 0, // CIMDs prohibit following redirects to prevent client impersonation.
359 );
360
361 /*
362 * Always use wp_safe_remote_get for the metadata document fetch. RFC 8252's
363 * loopback allowance applies to redirect URIs (Section 7.3), not to the
364 * client metadata document — that's expected to be a publicly resolvable
365 * HTTPS URL.
366 */
367 $response = \wp_safe_remote_get( $url, $args );
368
369 if ( \is_wp_error( $response ) ) {
370 return new \WP_Error(
371 'activitypub_client_fetch_failed',
372 \sprintf(
373 /* translators: 1: The client metadata URL, 2: The error message from the HTTP request */
374 \__( 'Could not reach the application at %1$s: %2$s', 'activitypub' ),
375 $url,
376 $response->get_error_message()
377 ),
378 array( 'status' => 502 )
379 );
380 }
381
382 $code = \wp_remote_retrieve_response_code( $response );
383 if ( 200 !== $code ) {
384 return new \WP_Error(
385 'activitypub_client_fetch_failed',
386 \sprintf(
387 /* translators: 1: The client metadata URL, 2: HTTP status code */
388 \__( 'The application at %1$s returned an unexpected response (HTTP %2$d).', 'activitypub' ),
389 $url,
390 $code
391 ),
392 array( 'status' => 502 )
393 );
394 }
395
396 $body = \wp_remote_retrieve_body( $response );
397 $data = \json_decode( $body, true );
398
399 if ( ! \is_array( $data ) ) {
400 return new \WP_Error(
401 'activitypub_client_invalid_metadata',
402 \__( 'Invalid client metadata format.', 'activitypub' ),
403 array( 'status' => 400 )
404 );
405 }
406
407 // Normalize ActivityPub Application format to CIMD format.
408 return self::normalize_client_metadata( $data );
409 }
410
411 /**
412 * Normalize client metadata from various formats to standard format.
413 *
414 * Supports:
415 * - CIMD (Client ID Metadata Document)
416 * - ActivityPub Application objects
417 *
418 * @param array $data The raw metadata.
419 * @return array Normalized metadata.
420 */
421 private static function normalize_client_metadata( $data ) {
422 $metadata = array(
423 'client_name' => '',
424 'redirect_uris' => array(),
425 'logo_uri' => '',
426 'client_uri' => '',
427 );
428
429 // CIMD format fields.
430 if ( ! empty( $data['client_id'] ) ) {
431 $metadata['client_id'] = $data['client_id'];
432 }
433 if ( ! empty( $data['client_name'] ) ) {
434 $metadata['client_name'] = $data['client_name'];
435 }
436 if ( ! empty( $data['redirect_uris'] ) ) {
437 $metadata['redirect_uris'] = (array) $data['redirect_uris'];
438 }
439 if ( ! empty( $data['logo_uri'] ) ) {
440 $metadata['logo_uri'] = $data['logo_uri'];
441 }
442 if ( ! empty( $data['client_uri'] ) ) {
443 $metadata['client_uri'] = $data['client_uri'];
444 }
445
446 /*
447 * ActivityStreams vocabulary fallbacks.
448 *
449 * Client ID Metadata Documents may use ActivityStreams context
450 * (e.g. "id" instead of "client_id", "name" instead of "client_name",
451 * "redirectURI" instead of "redirect_uris"). These are used as
452 * fallbacks when the CIMD-specific fields are not present.
453 */
454 if ( empty( $metadata['client_id'] ) && ! empty( $data['id'] ) ) {
455 $metadata['client_id'] = $data['id'];
456 }
457 if ( empty( $metadata['client_name'] ) ) {
458 if ( ! empty( $data['name'] ) ) {
459 $metadata['client_name'] = $data['name'];
460 } elseif ( ! empty( $data['preferredUsername'] ) ) {
461 $metadata['client_name'] = $data['preferredUsername'];
462 }
463 }
464 if ( empty( $metadata['redirect_uris'] ) && ! empty( $data['redirectURI'] ) ) {
465 $metadata['redirect_uris'] = (array) $data['redirectURI'];
466 }
467 if ( empty( $metadata['logo_uri'] ) && ! empty( $data['icon'] ) ) {
468 if ( \is_string( $data['icon'] ) ) {
469 $metadata['logo_uri'] = $data['icon'];
470 } elseif ( \is_array( $data['icon'] ) && ! empty( $data['icon']['url'] ) ) {
471 $metadata['logo_uri'] = $data['icon']['url'];
472 }
473 }
474 if ( empty( $metadata['client_uri'] ) && ! empty( $data['url'] ) ) {
475 $metadata['client_uri'] = \is_array( $data['url'] ) ? $data['url'][0] : $data['url'];
476 }
477
478 // Mark ActivityPub actor-typed clients for lenient redirect validation.
479 $actor_types = array( 'Application', 'Person', 'Service', 'Group', 'Organization' );
480 if ( ! empty( $data['type'] ) && \in_array( $data['type'], $actor_types, true ) ) {
481 $metadata['is_actor'] = true;
482 }
483
484 return $metadata;
485 }
486
487 /**
488 * Validate client credentials.
489 *
490 * @param string $client_id The client ID.
491 * @param string|null $client_secret The client secret (optional for public clients).
492 * @return bool True if valid.
493 */
494 public static function validate( $client_id, $client_secret = null ) {
495 $client = self::get( $client_id );
496
497 if ( \is_wp_error( $client ) ) {
498 return false;
499 }
500
501 // Public clients don't need secret validation.
502 if ( $client->is_public() ) {
503 return true;
504 }
505
506 // Confidential clients require a valid secret.
507 if ( empty( $client_secret ) ) {
508 return false;
509 }
510
511 $stored_hash = \get_post_meta( $client->post_id, '_activitypub_client_secret_hash', true );
512
513 return \wp_check_password( $client_secret, $stored_hash );
514 }
515
516 /**
517 * Check if redirect URI is valid for this client.
518 *
519 * Requires an exact match against registered redirect URIs,
520 * with RFC 8252 loopback port flexibility.
521 *
522 * Clients must have at least one registered redirect URI.
523 * Same-origin fallback is intentionally not supported to
524 * prevent open redirector vulnerabilities.
525 *
526 * @param string $redirect_uri The redirect URI to validate.
527 * @return bool True if valid.
528 */
529 public function is_valid_redirect_uri( $redirect_uri ) {
530 $allowed_uris = $this->get_redirect_uris();
531
532 if ( empty( $allowed_uris ) ) {
533 return false;
534 }
535
536 // Exact match first.
537 if ( \in_array( $redirect_uri, $allowed_uris, true ) ) {
538 return true;
539 }
540
541 /*
542 * RFC 8252 Section 7.3: For loopback redirects, allow any port.
543 * Compare scheme, host, and path - ignore port for 127.0.0.1 and localhost.
544 */
545 foreach ( $allowed_uris as $allowed_uri ) {
546 if ( self::is_loopback_redirect_match( $allowed_uri, $redirect_uri ) ) {
547 return true;
548 }
549 }
550
551 return false;
552 }
553
554 /**
555 * Check if two URIs match under RFC 8252 loopback rules.
556 *
557 * For loopback addresses, the port is ignored per RFC 8252 Section 7.3.
558 *
559 * @param string $allowed_uri The registered redirect URI.
560 * @param string $redirect_uri The requested redirect URI.
561 * @return bool True if they match under loopback rules.
562 */
563 private static function is_loopback_redirect_match( $allowed_uri, $redirect_uri ) {
564 $allowed_parts = \wp_parse_url( $allowed_uri );
565 $redirect_parts = \wp_parse_url( $redirect_uri );
566
567 // Must have same scheme.
568 if ( ( $allowed_parts['scheme'] ?? '' ) !== ( $redirect_parts['scheme'] ?? '' ) ) {
569 return false;
570 }
571
572 $allowed_host = $allowed_parts['host'] ?? '';
573 $redirect_host = $redirect_parts['host'] ?? '';
574
575 // Must have same host.
576 if ( $allowed_host !== $redirect_host ) {
577 return false;
578 }
579
580 // Only apply port flexibility for loopback addresses.
581 if ( ! self::is_loopback( $allowed_host ) ) {
582 // Not loopback - require exact match including port.
583 return $allowed_uri === $redirect_uri;
584 }
585
586 // For loopback, compare path (ignore port).
587 $allowed_path = $allowed_parts['path'] ?? '/';
588 $redirect_path = $redirect_parts['path'] ?? '/';
589
590 return $allowed_path === $redirect_path;
591 }
592
593 /**
594 * Check if a host is a loopback address.
595 *
596 * Supports:
597 * - "localhost" (common in practice for native app development)
598 * - IPv4 loopback range 127.0.0.0/8 (RFC 1122 Section 3.2.1.3)
599 * - IPv6 loopback ::1 (RFC 4291 Section 2.5.3)
600 * - IPv4-mapped IPv6 loopback ::ffff:127.x.x.x (RFC 4291 Section 2.5.5.2)
601 *
602 * @param string $host The host to check (as returned by wp_parse_url).
603 * @return bool True if loopback.
604 */
605 private static function is_loopback( $host ) {
606 $host = \strtolower( $host );
607
608 // Match "localhost" and any subdomain of localhost (RFC 6761 Section 6.3).
609 if ( 'localhost' === $host || '.localhost' === \substr( $host, -\strlen( '.localhost' ) ) ) {
610 return true;
611 }
612
613 // Strip brackets from IPv6 (parse_url returns "[::1]").
614 $ip = \trim( $host, '[]' );
615
616 if ( ! \filter_var( $ip, FILTER_VALIDATE_IP ) ) {
617 return false;
618 }
619
620 // IPv4 loopback 127.0.0.0/8 (RFC 1122 Section 3.2.1.3).
621 if ( \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
622 return 0 === \strpos( $ip, '127.' );
623 }
624
625 /*
626 * IPv6 loopback ::1 (RFC 4291 Section 2.5.3). Normalised via inet_pton
627 * so equivalents like 0:0:0:0:0:0:0:1 and ::0001 also match.
628 */
629 $packed = \inet_pton( $ip );
630 if ( false !== $packed && "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\1" === $packed ) {
631 return true;
632 }
633
634 // IPv4-mapped IPv6 loopback ::ffff:127.x.x.x (RFC 4291 Section 2.5.5.2).
635 return 0 === \strpos( $ip, '::ffff:127.' );
636 }
637
638 /**
639 * Get all manually registered (non-discovered) clients.
640 *
641 * @since 8.1.0
642 *
643 * @return Client[] Array of Client objects.
644 */
645 public static function get_manually_registered() {
646 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Necessary to filter out discovered clients.
647 $posts = \get_posts(
648 array(
649 'post_type' => self::POST_TYPE,
650 'post_status' => 'publish',
651 'numberposts' => 100,
652 'meta_query' => array(
653 'relation' => 'OR',
654 array(
655 'key' => '_activitypub_discovered',
656 'compare' => 'NOT EXISTS',
657 ),
658 array(
659 'key' => '_activitypub_discovered',
660 'value' => '',
661 ),
662 array(
663 'key' => '_activitypub_discovered',
664 'value' => '0',
665 ),
666 ),
667 )
668 );
669 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
670
671 return \array_map(
672 function ( $post ) {
673 return new self( $post->ID );
674 },
675 $posts
676 );
677 }
678
679 /**
680 * Get the post ID of the client.
681 *
682 * @since 8.1.0
683 *
684 * @return int The post ID.
685 */
686 public function get_post_id() {
687 return $this->post_id;
688 }
689
690 /**
691 * Get client name.
692 *
693 * @return string The client name.
694 */
695 public function get_name() {
696 $post = \get_post( $this->post_id );
697 return $post ? $post->post_title : '';
698 }
699
700 /**
701 * Get client display name, falling back to client ID.
702 *
703 * @since 8.1.0
704 *
705 * @return string The display name.
706 */
707 public function get_display_name() {
708 return $this->get_name() ?: $this->get_client_id();
709 }
710
711 /**
712 * Get client description.
713 *
714 * @return string The client description.
715 */
716 public function get_description() {
717 $post = \get_post( $this->post_id );
718 return $post ? $post->post_content : '';
719 }
720
721 /**
722 * Get client ID.
723 *
724 * @return string The client ID.
725 */
726 public function get_client_id() {
727 return \get_post_meta( $this->post_id, '_activitypub_client_id', true );
728 }
729
730 /**
731 * Get allowed redirect URIs.
732 *
733 * @return array The redirect URIs.
734 */
735 public function get_redirect_uris() {
736 $uris = \get_post_meta( $this->post_id, '_activitypub_redirect_uris', true );
737 return \is_array( $uris ) ? $uris : array();
738 }
739
740 /**
741 * Get allowed scopes for this client.
742 *
743 * @return array The allowed scopes.
744 */
745 public function get_allowed_scopes() {
746 $scopes = \get_post_meta( $this->post_id, '_activitypub_allowed_scopes', true );
747 return \is_array( $scopes ) ? $scopes : Scope::DEFAULT_SCOPES;
748 }
749
750 /**
751 * Get client logo URI.
752 *
753 * @return string The logo URI or empty string.
754 */
755 public function get_logo_uri() {
756 return \get_post_meta( $this->post_id, '_activitypub_logo_uri', true ) ?: '';
757 }
758
759 /**
760 * Get client URI (homepage).
761 *
762 * @return string The client URI or empty string.
763 */
764 public function get_client_uri() {
765 return \get_post_meta( $this->post_id, '_activitypub_client_uri', true ) ?: '';
766 }
767
768 /**
769 * Get a URL suitable for linking to this client.
770 *
771 * Uses client_uri (the client's homepage) rather than client_id,
772 * since the client_id URL typically serves a JSON document (CIMD)
773 * not intended for end-users.
774 *
775 * @since 8.1.0
776 *
777 * @return string A URL for the client, or empty string if none available.
778 */
779 public function get_link_url() {
780 $client_uri = $this->get_client_uri();
781
782 if ( $client_uri ) {
783 return $client_uri;
784 }
785
786 $redirect_uris = $this->get_redirect_uris();
787
788 if ( ! empty( $redirect_uris ) ) {
789 $scheme = \wp_parse_url( $redirect_uris[0], PHP_URL_SCHEME );
790 $host = \wp_parse_url( $redirect_uris[0], PHP_URL_HOST );
791
792 if ( $scheme && $host ) {
793 return \trailingslashit( \sprintf( '%s://%s', $scheme, $host ) );
794 }
795 }
796
797 return '';
798 }
799
800 /**
801 * Check if this client was auto-discovered.
802 *
803 * @return bool True if discovered.
804 */
805 public function is_discovered() {
806 return (bool) \get_post_meta( $this->post_id, '_activitypub_discovered', true );
807 }
808
809 /**
810 * Check if this is a public client.
811 *
812 * @return bool True if public.
813 */
814 public function is_public() {
815 return (bool) \get_post_meta( $this->post_id, '_activitypub_is_public', true );
816 }
817
818 /**
819 * Filter requested scopes to only those allowed for this client.
820 *
821 * @param array $requested_scopes The requested scopes.
822 * @return array Filtered scopes.
823 */
824 public function filter_scopes( $requested_scopes ) {
825 $allowed = $this->get_allowed_scopes();
826 return \array_values( \array_intersect( $requested_scopes, $allowed ) );
827 }
828
829 /**
830 * Generate a unique client ID.
831 *
832 * @return string UUID v4.
833 */
834 public static function generate_client_id() {
835 // Generate UUID v4.
836 $data = \random_bytes( 16 );
837 $data[6] = \chr( \ord( $data[6] ) & 0x0f | 0x40 ); // Version 4.
838 $data[8] = \chr( \ord( $data[8] ) & 0x3f | 0x80 ); // Variant.
839
840 return \vsprintf( '%s%s-%s-%s-%s-%s%s%s', \str_split( \bin2hex( $data ), 4 ) );
841 }
842
843 /**
844 * Generate a client secret.
845 *
846 * @return string The client secret.
847 */
848 public static function generate_client_secret() {
849 return Token::generate_token( 32 );
850 }
851
852 /**
853 * Validate a redirect URI format.
854 *
855 * Supports:
856 * - https:// URIs (production)
857 * - http:// URIs (localhost only, for development)
858 * - Custom URI schemes for native apps (RFC 8252 Section 7.1)
859 *
860 * @param string $uri The URI to validate.
861 * @return bool True if valid.
862 */
863 private static function validate_uri_format( $uri ) {
864 /*
865 * Extract scheme manually first because wp_parse_url() returns false
866 * for some custom scheme URIs (e.g. "myapp:/callback").
867 *
868 * Note: per RFC 2396, custom scheme URIs use a single slash ("myapp:/path"),
869 * but double-slash forms ("myapp://host") are common in practice, so both
870 * are accepted.
871 */
872 if ( ! \preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $uri, $matches ) ) {
873 return false;
874 }
875
876 $scheme = \strtolower( $matches[1] );
877 $parsed = \wp_parse_url( $uri );
878
879 if ( ! $parsed ) {
880 // wp_parse_url fails for "scheme://" — still valid for custom schemes.
881 $parsed = array( 'scheme' => $scheme );
882 }
883
884 // Block dangerous schemes (see OWASP XSS prevention).
885 $blocked_schemes = array( 'javascript', 'data', 'vbscript', 'blob', 'file', 'mhtml', 'cid', 'jar', 'view-source' );
886 if ( \in_array( $scheme, $blocked_schemes, true ) ) {
887 return false;
888 }
889
890 /*
891 * Allow http only for loopback addresses (RFC 8252 Section 8.3).
892 * Native apps use loopback redirects during the OAuth flow.
893 *
894 * Non-loopback http URIs are rejected by default but can be
895 * allowed via the activitypub_oauth_allow_http_redirect_uri filter
896 * for local development environments.
897 *
898 * @param bool $allowed Whether to allow this http redirect URI.
899 * @param string $uri The redirect URI being validated.
900 * @param array $parsed The parsed URI components.
901 */
902 if ( 'http' === $scheme ) {
903 if ( empty( $parsed['host'] ) ) {
904 return false;
905 }
906
907 if ( self::is_loopback( $parsed['host'] ) ) {
908 return true;
909 }
910
911 return (bool) \apply_filters( 'activitypub_oauth_allow_http_redirect_uri', false, $uri, $parsed );
912 }
913
914 // Allow https with any host.
915 if ( 'https' === $scheme ) {
916 return ! empty( $parsed['host'] );
917 }
918
919 /*
920 * Allow custom URI schemes for native/mobile apps (RFC 8252 Section 7.1).
921 * Examples: com.example.app:/oauth, myapp:/callback
922 * Custom schemes must be at least 2 characters to avoid matching
923 * Windows drive letters (e.g., "C:").
924 */
925 return \strlen( $scheme ) >= 2;
926 }
927
928 /**
929 * Delete all OAuth clients and their associated tokens.
930 *
931 * Used during plugin uninstall to clean up all OAuth data.
932 *
933 * @return int The number of clients deleted.
934 */
935 public static function delete_all() {
936 $post_ids = \get_posts(
937 array(
938 'post_type' => self::POST_TYPE,
939 'post_status' => array( 'any', 'trash', 'auto-draft' ),
940 'fields' => 'ids',
941 'numberposts' => -1,
942 )
943 );
944
945 foreach ( $post_ids as $post_id ) {
946 \wp_delete_post( $post_id, true );
947 }
948
949 // Also revoke all tokens stored in user meta.
950 Token::revoke_all();
951
952 return \count( $post_ids );
953 }
954
955 /**
956 * Delete a client and all its tokens.
957 *
958 * @param string $client_id The client ID to delete.
959 * @return bool True on success.
960 */
961 public static function delete( $client_id ) {
962 $client = self::get( $client_id );
963
964 if ( \is_wp_error( $client ) ) {
965 return false;
966 }
967
968 /*
969 * Delete all tokens for this client (tokens are stored in user meta).
970 * Authorization codes are transient-based and auto-expire within 10 minutes,
971 * so they don't need explicit revocation here.
972 */
973 Token::revoke_for_client( $client_id );
974
975 // Delete the client.
976 return (bool) \wp_delete_post( $client->post_id, true );
977 }
978 }
979