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

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

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