Files
laipower/wp-content/plugins/activitypub/includes/oauth/class-client.php

979 lines
28 KiB
PHP

<?php
/**
* OAuth 2.0 Client model for ActivityPub C2S.
*
* @package Activitypub
*/
namespace Activitypub\OAuth;
use Activitypub\Sanitize;
use function Activitypub\get_client_ip;
use function Activitypub\resolve_public_host;
/**
* Client class for managing OAuth 2.0 client registrations.
*
* Supports both manual registration and RFC 7591 dynamic client registration,
* plus Client Identifier Metadata Documents (CIMD) where the `client_id` is
* itself a URL hosting a metadata document.
*
* ## Loopback policy (RFC 8252)
*
* Native apps register loopback redirect URIs to receive the OAuth callback
* on a port the app opened locally. RFC 8252 §7.3 / §8.3 cover this and
* specifically authorise `http://127.0.0.1:{port}/{path}` (IPv4) and
* `http://[::1]:{port}/{path}` (IPv6). `localhost` is permitted by common
* practice, though §8.3 marks it "NOT RECOMMENDED".
*
* `is_loopback()` reflects that scope: it matches `127.0.0.0/8`, `::1`
* (any spelling, normalised via `inet_pton`), `::ffff:127.x.x.x`, `localhost`,
* and `*.localhost`. Reserved-but-not-loopback addresses such as `0.0.0.0`,
* link-local `169.254.0.0/16`, and RFC1918 ranges are explicitly *not*
* treated as loopback and never bypass `wp_safe_remote_get()`.
*
* RFC 8252's loopback allowance applies to redirect URIs only. The CIMD
* document must be served over HTTPS from a publicly resolvable host:
* `client_id` discovery rejects non-`https` URLs up front, and
* `fetch_client_metadata()` resolves the host first and rejects anything
* private or loopback before falling through to `wp_safe_remote_get()`.
* Local development against a loopback CIMD endpoint is intentionally not
* supported.
*
* @see https://datatracker.ietf.org/doc/html/rfc8252 RFC 8252 — OAuth 2.0 for Native Apps
* @see https://datatracker.ietf.org/doc/html/rfc7591 RFC 7591 — OAuth 2.0 Dynamic Client Registration
*/
class Client {
/**
* Post type for OAuth clients.
*/
const POST_TYPE = 'ap_oauth_client';
/**
* The post ID of the client.
*
* @var int
*/
private $post_id;
/**
* Constructor.
*
* @param int $post_id The post ID of the client.
*/
public function __construct( $post_id ) {
$this->post_id = $post_id;
}
/**
* Register a new OAuth client.
*
* @param array $data Client registration data.
* - name: Client name (required).
* - redirect_uris: Array of redirect URIs (required).
* - description: Client description (optional).
* - is_public: Whether client is public/PKCE-only (default true).
* - scopes: Allowed scopes (optional, defaults to all).
* @return array|\WP_Error Client credentials or error.
*/
public static function register( $data ) {
$name = $data['name'] ?? '';
$redirect_uris = $data['redirect_uris'] ?? array();
$description = $data['description'] ?? '';
$is_public = $data['is_public'] ?? true;
$scopes = $data['scopes'] ?? Scope::ALL;
// Validate required fields.
if ( empty( $name ) ) {
return new \WP_Error(
'activitypub_missing_client_name',
\__( 'Client name is required.', 'activitypub' ),
array( 'status' => 400 )
);
}
if ( empty( $redirect_uris ) ) {
return new \WP_Error(
'activitypub_missing_redirect_uri',
\__( 'At least one redirect URI is required.', 'activitypub' ),
array( 'status' => 400 )
);
}
// Validate redirect URIs.
foreach ( $redirect_uris as $uri ) {
if ( ! self::validate_uri_format( $uri ) ) {
return new \WP_Error(
'activitypub_invalid_redirect_uri',
/* translators: %s: The invalid redirect URI */
sprintf( \__( 'Invalid redirect URI: %s', 'activitypub' ), $uri ),
array( 'status' => 400 )
);
}
}
// Generate client credentials.
$client_id = self::generate_client_id();
$client_secret = null;
if ( ! $is_public ) {
$client_secret = self::generate_client_secret();
}
// Create the client post.
$post_id = \wp_insert_post(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'post_title' => $name,
'post_content' => $description,
'meta_input' => array(
'_activitypub_client_id' => $client_id,
'_activitypub_client_secret_hash' => $client_secret ? \wp_hash_password( $client_secret ) : '',
'_activitypub_redirect_uris' => array_map( array( Sanitize::class, 'redirect_uri' ), $redirect_uris ),
'_activitypub_allowed_scopes' => Scope::validate( $scopes ),
'_activitypub_is_public' => (bool) $is_public,
),
),
true
);
if ( \is_wp_error( $post_id ) ) {
return $post_id;
}
$result = array(
'client_id' => $client_id,
);
if ( $client_secret ) {
$result['client_secret'] = $client_secret;
}
return $result;
}
/**
* Get client by client_id.
*
* Supports auto-discovery: if client_id is a URL and not found locally,
* fetches the Client ID Metadata Document (CIMD) and auto-registers.
*
* @param string $client_id The client ID.
* @return Client|\WP_Error The client or error.
*/
public static function get( $client_id ) {
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Client lookup by ID is necessary.
$posts = \get_posts(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'meta_key' => '_activitypub_client_id',
'meta_value' => $client_id,
'numberposts' => 1,
)
);
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
if ( ! empty( $posts ) ) {
$client = new self( $posts[0]->ID );
/*
* Re-discover stale auto-discovered clients that have no redirect URIs.
* This can happen when a previous discovery failed to parse the metadata
* correctly (e.g. before ActivityStreams vocabulary support was added).
*/
if ( $client->is_discovered() && empty( $client->get_redirect_uris() ) && self::is_discoverable_url( $client_id ) ) {
\wp_delete_post( $posts[0]->ID, true );
return self::discover_and_register( $client_id );
}
return $client;
}
// If client_id is a discoverable URL (HTTPS), try auto-discovery.
if ( self::is_discoverable_url( $client_id ) ) {
return self::discover_and_register( $client_id );
}
return new \WP_Error(
'activitypub_client_not_found',
\__( 'OAuth client not found.', 'activitypub' ),
array( 'status' => 404 )
);
}
/**
* Determine whether a client_id is a discoverable URL.
*
* Only HTTPS URLs are eligible. The CIMD draft requires HTTPS for
* production, and accepting cleartext URLs would let a network-position
* attacker rewrite the metadata response and inject attacker-controlled
* redirect URIs that preserve the same client_id.
*
* @param string $client_id The client ID to check.
* @return bool True if the client_id is an HTTPS URL eligible for discovery.
*/
private static function is_discoverable_url( $client_id ) {
if ( ! \filter_var( $client_id, FILTER_VALIDATE_URL ) ) {
return false;
}
return 'https' === \strtolower( (string) \wp_parse_url( $client_id, PHP_URL_SCHEME ) );
}
/**
* Discover client metadata from URL and auto-register.
*
* Fetches the Client ID Metadata Document (CIMD) from the client_id URL.
* Rate-limited via transients to prevent SSRF abuse.
*
* @param string $client_id The client ID URL.
* @return Client|\WP_Error The client or error.
*/
private static function discover_and_register( $client_id ) {
// Rate-limit auto-discovery to prevent SSRF abuse (max 10 per minute per IP).
$ip = get_client_ip();
if ( '' === $ip ) {
return new \WP_Error(
'activitypub_rate_limited',
\__( 'Too many client discovery requests. Please try again later.', 'activitypub' ),
array( 'status' => 429 )
);
}
$transient_key = 'ap_oauth_disc_' . \md5( $ip );
$count = (int) \get_transient( $transient_key );
if ( $count >= 10 ) {
return new \WP_Error(
'activitypub_rate_limited',
\__( 'Too many client discovery requests. Please try again later.', 'activitypub' ),
array( 'status' => 429 )
);
}
\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
$metadata = self::fetch_client_metadata( $client_id );
if ( \is_wp_error( $metadata ) ) {
return $metadata;
}
// Validate client_id is present and matches.
// A missing client_id allows client impersonation through redirects.
if ( empty( $metadata['client_id'] ) ) {
return new \WP_Error(
'activitypub_missing_client_id',
\__( 'Client metadata must contain a client_id property.', 'activitypub' ),
array( 'status' => 400 )
);
}
if ( $metadata['client_id'] !== $client_id ) {
return new \WP_Error(
'activitypub_client_id_mismatch',
\__( 'Client ID in metadata does not match request.', 'activitypub' ),
array( 'status' => 400 )
);
}
// Get redirect URIs from metadata or derive from client_id origin.
$redirect_uris = array();
if ( ! empty( $metadata['redirect_uris'] ) && is_array( $metadata['redirect_uris'] ) ) {
foreach ( $metadata['redirect_uris'] as $uri ) {
if ( ! self::validate_uri_format( $uri ) ) {
return new \WP_Error(
'activitypub_invalid_redirect_uri',
/* translators: %s: The invalid redirect URI */
\sprintf( \__( 'Invalid redirect URI: %s', 'activitypub' ), $uri ),
array( 'status' => 400 )
);
}
}
$redirect_uris = $metadata['redirect_uris'];
}
// Register the discovered client.
$name = ! empty( $metadata['client_name'] ) ? $metadata['client_name'] : $client_id;
$post_id = \wp_insert_post(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'post_title' => $name,
'post_content' => '',
'meta_input' => array(
'_activitypub_client_id' => $client_id,
'_activitypub_client_secret_hash' => '', // Public client.
'_activitypub_redirect_uris' => array_map( array( Sanitize::class, 'redirect_uri' ), $redirect_uris ),
'_activitypub_allowed_scopes' => Scope::ALL,
'_activitypub_is_public' => true,
'_activitypub_discovered' => true,
'_activitypub_logo_uri' => ! empty( $metadata['logo_uri'] ) ? \sanitize_url( $metadata['logo_uri'] ) : '',
'_activitypub_client_uri' => ! empty( $metadata['client_uri'] ) ? \sanitize_url( $metadata['client_uri'] ) : '',
),
),
true
);
if ( \is_wp_error( $post_id ) ) {
return $post_id;
}
return new self( $post_id );
}
/**
* Fetch client metadata from URL.
*
* Supports both CIMD JSON format and ActivityPub Application objects.
*
* @param string $url The client ID URL to fetch.
* @return array|\WP_Error Metadata array or error.
*/
private static function fetch_client_metadata( $url ) {
/*
* Resolve the host explicitly and reject private/loopback addresses.
* wp_safe_remote_get() also performs URL validation but has a same-host
* carve-out (it allows requests to the WordPress site's own host even
* when that host is loopback/private). The CIMD document is meant to
* be a publicly resolvable HTTPS URL, so close that gap up front.
*/
$host = \wp_parse_url( $url, PHP_URL_HOST );
if ( ! $host || false === resolve_public_host( $host ) ) {
return new \WP_Error(
'activitypub_client_unsafe_host',
\__( 'The client metadata URL host is not allowed.', 'activitypub' ),
array( 'status' => 400 )
);
}
$args = array(
'timeout' => 10,
'headers' => array(
'Accept' => 'application/cimd+json, application/json, application/ld+json, application/activity+json',
),
'redirection' => 0, // CIMDs prohibit following redirects to prevent client impersonation.
);
/*
* Always use wp_safe_remote_get for the metadata document fetch. RFC 8252's
* loopback allowance applies to redirect URIs (Section 7.3), not to the
* client metadata document — that's expected to be a publicly resolvable
* HTTPS URL.
*/
$response = \wp_safe_remote_get( $url, $args );
if ( \is_wp_error( $response ) ) {
return new \WP_Error(
'activitypub_client_fetch_failed',
\sprintf(
/* translators: 1: The client metadata URL, 2: The error message from the HTTP request */
\__( 'Could not reach the application at %1$s: %2$s', 'activitypub' ),
$url,
$response->get_error_message()
),
array( 'status' => 502 )
);
}
$code = \wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
return new \WP_Error(
'activitypub_client_fetch_failed',
\sprintf(
/* translators: 1: The client metadata URL, 2: HTTP status code */
\__( 'The application at %1$s returned an unexpected response (HTTP %2$d).', 'activitypub' ),
$url,
$code
),
array( 'status' => 502 )
);
}
$body = \wp_remote_retrieve_body( $response );
$data = \json_decode( $body, true );
if ( ! is_array( $data ) ) {
return new \WP_Error(
'activitypub_client_invalid_metadata',
\__( 'Invalid client metadata format.', 'activitypub' ),
array( 'status' => 400 )
);
}
// Normalize ActivityPub Application format to CIMD format.
return self::normalize_client_metadata( $data );
}
/**
* Normalize client metadata from various formats to standard format.
*
* Supports:
* - CIMD (Client ID Metadata Document)
* - ActivityPub Application objects
*
* @param array $data The raw metadata.
* @return array Normalized metadata.
*/
private static function normalize_client_metadata( $data ) {
$metadata = array(
'client_name' => '',
'redirect_uris' => array(),
'logo_uri' => '',
'client_uri' => '',
);
// CIMD format fields.
if ( ! empty( $data['client_id'] ) ) {
$metadata['client_id'] = $data['client_id'];
}
if ( ! empty( $data['client_name'] ) ) {
$metadata['client_name'] = $data['client_name'];
}
if ( ! empty( $data['redirect_uris'] ) ) {
$metadata['redirect_uris'] = (array) $data['redirect_uris'];
}
if ( ! empty( $data['logo_uri'] ) ) {
$metadata['logo_uri'] = $data['logo_uri'];
}
if ( ! empty( $data['client_uri'] ) ) {
$metadata['client_uri'] = $data['client_uri'];
}
/*
* ActivityStreams vocabulary fallbacks.
*
* Client ID Metadata Documents may use ActivityStreams context
* (e.g. "id" instead of "client_id", "name" instead of "client_name",
* "redirectURI" instead of "redirect_uris"). These are used as
* fallbacks when the CIMD-specific fields are not present.
*/
if ( empty( $metadata['client_id'] ) && ! empty( $data['id'] ) ) {
$metadata['client_id'] = $data['id'];
}
if ( empty( $metadata['client_name'] ) ) {
if ( ! empty( $data['name'] ) ) {
$metadata['client_name'] = $data['name'];
} elseif ( ! empty( $data['preferredUsername'] ) ) {
$metadata['client_name'] = $data['preferredUsername'];
}
}
if ( empty( $metadata['redirect_uris'] ) && ! empty( $data['redirectURI'] ) ) {
$metadata['redirect_uris'] = (array) $data['redirectURI'];
}
if ( empty( $metadata['logo_uri'] ) && ! empty( $data['icon'] ) ) {
if ( is_string( $data['icon'] ) ) {
$metadata['logo_uri'] = $data['icon'];
} elseif ( is_array( $data['icon'] ) && ! empty( $data['icon']['url'] ) ) {
$metadata['logo_uri'] = $data['icon']['url'];
}
}
if ( empty( $metadata['client_uri'] ) && ! empty( $data['url'] ) ) {
$metadata['client_uri'] = is_array( $data['url'] ) ? $data['url'][0] : $data['url'];
}
// Mark ActivityPub actor-typed clients for lenient redirect validation.
$actor_types = array( 'Application', 'Person', 'Service', 'Group', 'Organization' );
if ( ! empty( $data['type'] ) && in_array( $data['type'], $actor_types, true ) ) {
$metadata['is_actor'] = true;
}
return $metadata;
}
/**
* Validate client credentials.
*
* @param string $client_id The client ID.
* @param string|null $client_secret The client secret (optional for public clients).
* @return bool True if valid.
*/
public static function validate( $client_id, $client_secret = null ) {
$client = self::get( $client_id );
if ( \is_wp_error( $client ) ) {
return false;
}
// Public clients don't need secret validation.
if ( $client->is_public() ) {
return true;
}
// Confidential clients require a valid secret.
if ( empty( $client_secret ) ) {
return false;
}
$stored_hash = \get_post_meta( $client->post_id, '_activitypub_client_secret_hash', true );
return \wp_check_password( $client_secret, $stored_hash );
}
/**
* Check if redirect URI is valid for this client.
*
* Requires an exact match against registered redirect URIs,
* with RFC 8252 loopback port flexibility.
*
* Clients must have at least one registered redirect URI.
* Same-origin fallback is intentionally not supported to
* prevent open redirector vulnerabilities.
*
* @param string $redirect_uri The redirect URI to validate.
* @return bool True if valid.
*/
public function is_valid_redirect_uri( $redirect_uri ) {
$allowed_uris = $this->get_redirect_uris();
if ( empty( $allowed_uris ) ) {
return false;
}
// Exact match first.
if ( in_array( $redirect_uri, $allowed_uris, true ) ) {
return true;
}
/*
* RFC 8252 Section 7.3: For loopback redirects, allow any port.
* Compare scheme, host, and path - ignore port for 127.0.0.1 and localhost.
*/
foreach ( $allowed_uris as $allowed_uri ) {
if ( self::is_loopback_redirect_match( $allowed_uri, $redirect_uri ) ) {
return true;
}
}
return false;
}
/**
* Check if two URIs match under RFC 8252 loopback rules.
*
* For loopback addresses, the port is ignored per RFC 8252 Section 7.3.
*
* @param string $allowed_uri The registered redirect URI.
* @param string $redirect_uri The requested redirect URI.
* @return bool True if they match under loopback rules.
*/
private static function is_loopback_redirect_match( $allowed_uri, $redirect_uri ) {
$allowed_parts = \wp_parse_url( $allowed_uri );
$redirect_parts = \wp_parse_url( $redirect_uri );
// Must have same scheme.
if ( ( $allowed_parts['scheme'] ?? '' ) !== ( $redirect_parts['scheme'] ?? '' ) ) {
return false;
}
$allowed_host = $allowed_parts['host'] ?? '';
$redirect_host = $redirect_parts['host'] ?? '';
// Must have same host.
if ( $allowed_host !== $redirect_host ) {
return false;
}
// Only apply port flexibility for loopback addresses.
if ( ! self::is_loopback( $allowed_host ) ) {
// Not loopback - require exact match including port.
return $allowed_uri === $redirect_uri;
}
// For loopback, compare path (ignore port).
$allowed_path = $allowed_parts['path'] ?? '/';
$redirect_path = $redirect_parts['path'] ?? '/';
return $allowed_path === $redirect_path;
}
/**
* Check if a host is a loopback address.
*
* Supports:
* - "localhost" (common in practice for native app development)
* - IPv4 loopback range 127.0.0.0/8 (RFC 1122 Section 3.2.1.3)
* - IPv6 loopback ::1 (RFC 4291 Section 2.5.3)
* - IPv4-mapped IPv6 loopback ::ffff:127.x.x.x (RFC 4291 Section 2.5.5.2)
*
* @param string $host The host to check (as returned by wp_parse_url).
* @return bool True if loopback.
*/
private static function is_loopback( $host ) {
$host = \strtolower( $host );
// Match "localhost" and any subdomain of localhost (RFC 6761 Section 6.3).
if ( 'localhost' === $host || '.localhost' === \substr( $host, -\strlen( '.localhost' ) ) ) {
return true;
}
// Strip brackets from IPv6 (parse_url returns "[::1]").
$ip = \trim( $host, '[]' );
if ( ! \filter_var( $ip, FILTER_VALIDATE_IP ) ) {
return false;
}
// IPv4 loopback 127.0.0.0/8 (RFC 1122 Section 3.2.1.3).
if ( \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
return 0 === \strpos( $ip, '127.' );
}
/*
* IPv6 loopback ::1 (RFC 4291 Section 2.5.3). Normalised via inet_pton
* so equivalents like 0:0:0:0:0:0:0:1 and ::0001 also match.
*/
$packed = \inet_pton( $ip );
if ( false !== $packed && "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\1" === $packed ) {
return true;
}
// IPv4-mapped IPv6 loopback ::ffff:127.x.x.x (RFC 4291 Section 2.5.5.2).
return 0 === \strpos( $ip, '::ffff:127.' );
}
/**
* Get all manually registered (non-discovered) clients.
*
* @since 8.1.0
*
* @return Client[] Array of Client objects.
*/
public static function get_manually_registered() {
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Necessary to filter out discovered clients.
$posts = \get_posts(
array(
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'numberposts' => 100,
'meta_query' => array(
'relation' => 'OR',
array(
'key' => '_activitypub_discovered',
'compare' => 'NOT EXISTS',
),
array(
'key' => '_activitypub_discovered',
'value' => '',
),
array(
'key' => '_activitypub_discovered',
'value' => '0',
),
),
)
);
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
return array_map(
function ( $post ) {
return new self( $post->ID );
},
$posts
);
}
/**
* Get the post ID of the client.
*
* @since 8.1.0
*
* @return int The post ID.
*/
public function get_post_id() {
return $this->post_id;
}
/**
* Get client name.
*
* @return string The client name.
*/
public function get_name() {
$post = \get_post( $this->post_id );
return $post ? $post->post_title : '';
}
/**
* Get client display name, falling back to client ID.
*
* @since 8.1.0
*
* @return string The display name.
*/
public function get_display_name() {
return $this->get_name() ?: $this->get_client_id();
}
/**
* Get client description.
*
* @return string The client description.
*/
public function get_description() {
$post = \get_post( $this->post_id );
return $post ? $post->post_content : '';
}
/**
* Get client ID.
*
* @return string The client ID.
*/
public function get_client_id() {
return \get_post_meta( $this->post_id, '_activitypub_client_id', true );
}
/**
* Get allowed redirect URIs.
*
* @return array The redirect URIs.
*/
public function get_redirect_uris() {
$uris = \get_post_meta( $this->post_id, '_activitypub_redirect_uris', true );
return is_array( $uris ) ? $uris : array();
}
/**
* Get allowed scopes for this client.
*
* @return array The allowed scopes.
*/
public function get_allowed_scopes() {
$scopes = \get_post_meta( $this->post_id, '_activitypub_allowed_scopes', true );
return is_array( $scopes ) ? $scopes : Scope::DEFAULT_SCOPES;
}
/**
* Get client logo URI.
*
* @return string The logo URI or empty string.
*/
public function get_logo_uri() {
return \get_post_meta( $this->post_id, '_activitypub_logo_uri', true ) ?: '';
}
/**
* Get client URI (homepage).
*
* @return string The client URI or empty string.
*/
public function get_client_uri() {
return \get_post_meta( $this->post_id, '_activitypub_client_uri', true ) ?: '';
}
/**
* Get a URL suitable for linking to this client.
*
* Uses client_uri (the client's homepage) rather than client_id,
* since the client_id URL typically serves a JSON document (CIMD)
* not intended for end-users.
*
* @since 8.1.0
*
* @return string A URL for the client, or empty string if none available.
*/
public function get_link_url() {
$client_uri = $this->get_client_uri();
if ( $client_uri ) {
return $client_uri;
}
$redirect_uris = $this->get_redirect_uris();
if ( ! empty( $redirect_uris ) ) {
$scheme = \wp_parse_url( $redirect_uris[0], PHP_URL_SCHEME );
$host = \wp_parse_url( $redirect_uris[0], PHP_URL_HOST );
if ( $scheme && $host ) {
return \trailingslashit( sprintf( '%s://%s', $scheme, $host ) );
}
}
return '';
}
/**
* Check if this client was auto-discovered.
*
* @return bool True if discovered.
*/
public function is_discovered() {
return (bool) \get_post_meta( $this->post_id, '_activitypub_discovered', true );
}
/**
* Check if this is a public client.
*
* @return bool True if public.
*/
public function is_public() {
return (bool) \get_post_meta( $this->post_id, '_activitypub_is_public', true );
}
/**
* Filter requested scopes to only those allowed for this client.
*
* @param array $requested_scopes The requested scopes.
* @return array Filtered scopes.
*/
public function filter_scopes( $requested_scopes ) {
$allowed = $this->get_allowed_scopes();
return array_values( array_intersect( $requested_scopes, $allowed ) );
}
/**
* Generate a unique client ID.
*
* @return string UUID v4.
*/
public static function generate_client_id() {
// Generate UUID v4.
$data = random_bytes( 16 );
$data[6] = chr( ord( $data[6] ) & 0x0f | 0x40 ); // Version 4.
$data[8] = chr( ord( $data[8] ) & 0x3f | 0x80 ); // Variant.
return vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $data ), 4 ) );
}
/**
* Generate a client secret.
*
* @return string The client secret.
*/
public static function generate_client_secret() {
return Token::generate_token( 32 );
}
/**
* Validate a redirect URI format.
*
* Supports:
* - https:// URIs (production)
* - http:// URIs (localhost only, for development)
* - Custom URI schemes for native apps (RFC 8252 Section 7.1)
*
* @param string $uri The URI to validate.
* @return bool True if valid.
*/
private static function validate_uri_format( $uri ) {
/*
* Extract scheme manually first because wp_parse_url() returns false
* for some custom scheme URIs (e.g. "myapp:/callback").
*
* Note: per RFC 2396, custom scheme URIs use a single slash ("myapp:/path"),
* but double-slash forms ("myapp://host") are common in practice, so both
* are accepted.
*/
if ( ! preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $uri, $matches ) ) {
return false;
}
$scheme = \strtolower( $matches[1] );
$parsed = \wp_parse_url( $uri );
if ( ! $parsed ) {
// wp_parse_url fails for "scheme://" — still valid for custom schemes.
$parsed = array( 'scheme' => $scheme );
}
// Block dangerous schemes (see OWASP XSS prevention).
$blocked_schemes = array( 'javascript', 'data', 'vbscript', 'blob', 'file', 'mhtml', 'cid', 'jar', 'view-source' );
if ( in_array( $scheme, $blocked_schemes, true ) ) {
return false;
}
/*
* Allow http only for loopback addresses (RFC 8252 Section 8.3).
* Native apps use loopback redirects during the OAuth flow.
*
* Non-loopback http URIs are rejected by default but can be
* allowed via the activitypub_oauth_allow_http_redirect_uri filter
* for local development environments.
*
* @param bool $allowed Whether to allow this http redirect URI.
* @param string $uri The redirect URI being validated.
* @param array $parsed The parsed URI components.
*/
if ( 'http' === $scheme ) {
if ( empty( $parsed['host'] ) ) {
return false;
}
if ( self::is_loopback( $parsed['host'] ) ) {
return true;
}
return (bool) \apply_filters( 'activitypub_oauth_allow_http_redirect_uri', false, $uri, $parsed );
}
// Allow https with any host.
if ( 'https' === $scheme ) {
return ! empty( $parsed['host'] );
}
/*
* Allow custom URI schemes for native/mobile apps (RFC 8252 Section 7.1).
* Examples: com.example.app:/oauth, myapp:/callback
* Custom schemes must be at least 2 characters to avoid matching
* Windows drive letters (e.g., "C:").
*/
return strlen( $scheme ) >= 2;
}
/**
* Delete all OAuth clients and their associated tokens.
*
* Used during plugin uninstall to clean up all OAuth data.
*
* @return int The number of clients deleted.
*/
public static function delete_all() {
$post_ids = \get_posts(
array(
'post_type' => self::POST_TYPE,
'post_status' => array( 'any', 'trash', 'auto-draft' ),
'fields' => 'ids',
'numberposts' => -1,
)
);
foreach ( $post_ids as $post_id ) {
\wp_delete_post( $post_id, true );
}
// Also revoke all tokens stored in user meta.
Token::revoke_all();
return count( $post_ids );
}
/**
* Delete a client and all its tokens.
*
* @param string $client_id The client ID to delete.
* @return bool True on success.
*/
public static function delete( $client_id ) {
$client = self::get( $client_id );
if ( \is_wp_error( $client ) ) {
return false;
}
/*
* Delete all tokens for this client (tokens are stored in user meta).
* Authorization codes are transient-based and auto-expire within 10 minutes,
* so they don't need explicit revocation here.
*/
Token::revoke_for_client( $client_id );
// Delete the client.
return (bool) \wp_delete_post( $client->post_id, true );
}
}