updated plugin ActivityPub version 8.3.0
This commit is contained in:
@ -0,0 +1,327 @@
|
||||
<?php
|
||||
/**
|
||||
* OAuth 2.0 Authorization Code model for ActivityPub C2S.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\OAuth;
|
||||
|
||||
use Activitypub\Sanitize;
|
||||
|
||||
/**
|
||||
* Authorization_Code class for managing OAuth 2.0 authorization codes.
|
||||
*
|
||||
* Authorization codes are short-lived (10 minutes) and stored as transients.
|
||||
* This is more efficient than CPT for temporary data.
|
||||
*/
|
||||
class Authorization_Code {
|
||||
/**
|
||||
* Transient prefix for authorization codes.
|
||||
*/
|
||||
const TRANSIENT_PREFIX = 'activitypub_oauth_code_';
|
||||
|
||||
/**
|
||||
* Authorization code expiration in seconds (10 minutes).
|
||||
*/
|
||||
const EXPIRATION = 600;
|
||||
|
||||
/**
|
||||
* Create a new authorization code.
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @param string $client_id OAuth client ID.
|
||||
* @param string $redirect_uri The redirect URI.
|
||||
* @param array $scopes Requested scopes.
|
||||
* @param string $code_challenge PKCE code challenge.
|
||||
* @param string $code_challenge_method PKCE method (only S256 is supported).
|
||||
* @return string|\WP_Error The authorization code or error.
|
||||
*/
|
||||
public static function create(
|
||||
$user_id,
|
||||
$client_id,
|
||||
$redirect_uri,
|
||||
$scopes,
|
||||
$code_challenge,
|
||||
$code_challenge_method = 'S256'
|
||||
) {
|
||||
$redirect_uri = Sanitize::redirect_uri( $redirect_uri );
|
||||
|
||||
// Validate client.
|
||||
$client = Client::get( $client_id );
|
||||
if ( \is_wp_error( $client ) ) {
|
||||
return $client;
|
||||
}
|
||||
|
||||
// Validate redirect URI.
|
||||
if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_redirect_uri',
|
||||
\__( 'Invalid redirect URI for this client.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* PKCE is strongly recommended for public clients (RFC 7636) and
|
||||
* mandatory in the OAuth 2.1 draft. It is enforced by default; site
|
||||
* operators who must support pre-PKCE clients can opt out via the
|
||||
* `activitypub_oauth_require_pkce` filter.
|
||||
*/
|
||||
if ( empty( $code_challenge ) && $client->is_public() ) {
|
||||
/**
|
||||
* Filter whether PKCE is required for public OAuth clients.
|
||||
*
|
||||
* Return false to relax the default and allow public clients to
|
||||
* complete the authorization code grant without PKCE. This is
|
||||
* not recommended.
|
||||
*
|
||||
* @since 8.1.0
|
||||
* @since 8.2.0 Default changed from false to true.
|
||||
*
|
||||
* @param bool $require Whether to require PKCE. Default true.
|
||||
* @param string $client_id The OAuth client ID.
|
||||
*/
|
||||
if ( \apply_filters( 'activitypub_oauth_require_pkce', true, $client_id ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_pkce_required',
|
||||
\__( 'PKCE is required for public clients. Please include a code_challenge parameter.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter scopes to only allowed ones.
|
||||
$filtered_scopes = $client->filter_scopes( Scope::validate( $scopes ) );
|
||||
|
||||
// Generate the code.
|
||||
$code = self::generate_code();
|
||||
$code_hash = self::hash_code( $code );
|
||||
$expires_at = time() + self::EXPIRATION;
|
||||
|
||||
// Store code data in transient.
|
||||
$code_data = array(
|
||||
'user_id' => $user_id,
|
||||
'client_id' => $client_id,
|
||||
'redirect_uri' => $redirect_uri,
|
||||
'scopes' => $filtered_scopes,
|
||||
'code_challenge' => $code_challenge,
|
||||
'code_challenge_method' => $code_challenge_method,
|
||||
'expires_at' => $expires_at,
|
||||
'created_at' => time(),
|
||||
);
|
||||
|
||||
$stored = \set_transient(
|
||||
self::TRANSIENT_PREFIX . $code_hash,
|
||||
$code_data,
|
||||
self::EXPIRATION
|
||||
);
|
||||
|
||||
if ( ! $stored ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_code_storage_failed',
|
||||
\__( 'Failed to store authorization code.', 'activitypub' ),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens.
|
||||
*
|
||||
* @param string $code The authorization code.
|
||||
* @param string $client_id The client ID.
|
||||
* @param string $redirect_uri The redirect URI (must match original).
|
||||
* @param string $code_verifier The PKCE code verifier.
|
||||
* @return array|\WP_Error Token data or error.
|
||||
*/
|
||||
public static function exchange( $code, $client_id, $redirect_uri, $code_verifier ) {
|
||||
$redirect_uri = Sanitize::redirect_uri( $redirect_uri );
|
||||
$code_hash = self::hash_code( $code );
|
||||
$transient = self::TRANSIENT_PREFIX . $code_hash;
|
||||
$code_data = \get_transient( $transient );
|
||||
|
||||
if ( false === $code_data ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_code',
|
||||
\__( 'Invalid or expired authorization code.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Immediately delete the code (single use).
|
||||
\delete_transient( $transient );
|
||||
|
||||
// Check expiration (belt and suspenders - transient should auto-expire).
|
||||
if ( isset( $code_data['expires_at'] ) && $code_data['expires_at'] < time() ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_code_expired',
|
||||
\__( 'Authorization code has expired.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Verify client ID matches.
|
||||
if ( $code_data['client_id'] !== $client_id ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_client_mismatch',
|
||||
\__( 'Client ID does not match.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Verify redirect URI matches.
|
||||
if ( $code_data['redirect_uri'] !== $redirect_uri ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_redirect_uri_mismatch',
|
||||
\__( 'Redirect URI does not match.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Verify PKCE.
|
||||
$code_challenge = $code_data['code_challenge'] ?? '';
|
||||
$code_challenge_method = $code_data['code_challenge_method'] ?? 'S256';
|
||||
|
||||
if ( ! self::verify_pkce( $code_verifier, $code_challenge, $code_challenge_method ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_pkce',
|
||||
\__( 'Invalid PKCE code verifier.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Create and return the tokens.
|
||||
return Token::create(
|
||||
$code_data['user_id'],
|
||||
$client_id,
|
||||
$code_data['scopes']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify PKCE code_verifier against code_challenge.
|
||||
*
|
||||
* @param string $code_verifier The PKCE code verifier.
|
||||
* @param string $code_challenge The stored code challenge.
|
||||
* @param string $method The challenge method (only S256 is supported).
|
||||
* @return bool True if valid.
|
||||
*/
|
||||
public static function verify_pkce( $code_verifier, $code_challenge, $method = 'S256' ) {
|
||||
// If PKCE wasn't used during authorization (no challenge stored), skip verification.
|
||||
if ( empty( $code_challenge ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If challenge was provided but verifier is missing, fail.
|
||||
if ( empty( $code_verifier ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only S256 is supported; reject anything else.
|
||||
if ( 'S256' !== $method ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// S256: BASE64URL(SHA256(code_verifier)) == code_challenge.
|
||||
$computed = self::compute_code_challenge( $code_verifier );
|
||||
|
||||
return hash_equals( $code_challenge, $computed );
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a PKCE code challenge from a code verifier.
|
||||
*
|
||||
* @param string $code_verifier The code verifier.
|
||||
* @return string The code challenge (BASE64URL encoded SHA256 hash).
|
||||
*/
|
||||
public static function compute_code_challenge( $code_verifier ) {
|
||||
$hash = hash( 'sha256', $code_verifier, true );
|
||||
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PKCE BASE64URL encoding per RFC 7636.
|
||||
return rtrim( strtr( base64_encode( $hash ), '+/', '-_' ), '=' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random authorization code.
|
||||
*
|
||||
* @return string The authorization code.
|
||||
*/
|
||||
public static function generate_code() {
|
||||
return bin2hex( random_bytes( 32 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an authorization code for storage lookup.
|
||||
*
|
||||
* @param string $code The authorization code.
|
||||
* @return string The SHA-256 hash.
|
||||
*/
|
||||
public static function hash_code( $code ) {
|
||||
return hash( 'sha256', $code );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired authorization codes.
|
||||
*
|
||||
* Only deletes transients that have actually expired, to avoid breaking
|
||||
* in-progress authorization flows.
|
||||
*
|
||||
* Note: Transients auto-expire, but this cleans up any orphaned ones.
|
||||
* Should be called periodically via cron.
|
||||
*
|
||||
* @return int Number of codes deleted.
|
||||
*/
|
||||
public static function cleanup() {
|
||||
global $wpdb;
|
||||
|
||||
/*
|
||||
* When an external object cache is active, transients are stored in
|
||||
* the cache backend (Redis, Memcached, etc.) and auto-expire there.
|
||||
* The direct SQL below only targets the options table, so skip it.
|
||||
*/
|
||||
if ( \wp_using_ext_object_cache() ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$timeout_prefix = '_transient_timeout_' . self::TRANSIENT_PREFIX;
|
||||
$now = time();
|
||||
|
||||
// Find expired timeout rows for this prefix.
|
||||
$timeout_option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prepare(
|
||||
"SELECT option_name FROM {$wpdb->options}
|
||||
WHERE option_name LIKE %s
|
||||
AND option_value < %d",
|
||||
$wpdb->esc_like( $timeout_prefix ) . '%',
|
||||
$now
|
||||
)
|
||||
);
|
||||
|
||||
if ( empty( $timeout_option_names ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Build list of timeout and corresponding value option names to delete.
|
||||
$option_names_to_delete = array();
|
||||
foreach ( $timeout_option_names as $timeout_name ) {
|
||||
$option_names_to_delete[] = $timeout_name;
|
||||
$option_names_to_delete[] = str_replace( '_transient_timeout_', '_transient_', $timeout_name );
|
||||
}
|
||||
|
||||
$placeholders = implode( ', ', array_fill( 0, count( $option_names_to_delete ), '%s' ) );
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery
|
||||
$count = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$wpdb->options} WHERE option_name IN ( {$placeholders} )",
|
||||
$option_names_to_delete
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery
|
||||
|
||||
// Each transient has 2 rows (value + timeout).
|
||||
return $count ? (int) ( $count / 2 ) : 0;
|
||||
}
|
||||
}
|
||||
978
wp-content/plugins/activitypub/includes/oauth/class-client.php
Normal file
978
wp-content/plugins/activitypub/includes/oauth/class-client.php
Normal file
@ -0,0 +1,978 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
190
wp-content/plugins/activitypub/includes/oauth/class-scope.php
Normal file
190
wp-content/plugins/activitypub/includes/oauth/class-scope.php
Normal file
@ -0,0 +1,190 @@
|
||||
<?php
|
||||
/**
|
||||
* OAuth 2.0 Scope definitions for ActivityPub C2S.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\OAuth;
|
||||
|
||||
/**
|
||||
* Scope class for OAuth 2.0 scope management.
|
||||
*
|
||||
* Defines available scopes and provides validation methods.
|
||||
*/
|
||||
class Scope {
|
||||
/**
|
||||
* Read access scope - read actor profile, collections, and objects.
|
||||
*/
|
||||
const READ = 'read';
|
||||
|
||||
/**
|
||||
* Write access scope - create activities via POST to outbox.
|
||||
*/
|
||||
const WRITE = 'write';
|
||||
|
||||
/**
|
||||
* Follow access scope - manage following relationships.
|
||||
*/
|
||||
const FOLLOW = 'follow';
|
||||
|
||||
/**
|
||||
* Push access scope - subscribe to SSE streams.
|
||||
*/
|
||||
const PUSH = 'push';
|
||||
|
||||
/**
|
||||
* Profile access scope - edit actor profile.
|
||||
*/
|
||||
const PROFILE = 'profile';
|
||||
|
||||
/**
|
||||
* All available scopes.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
const ALL = array(
|
||||
self::READ,
|
||||
self::WRITE,
|
||||
self::FOLLOW,
|
||||
self::PUSH,
|
||||
self::PROFILE,
|
||||
);
|
||||
|
||||
/**
|
||||
* Human-readable descriptions for each scope.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
const DESCRIPTIONS = array(
|
||||
self::READ => 'Read actor profile, collections, and objects',
|
||||
self::WRITE => 'Create activities via POST to outbox',
|
||||
self::FOLLOW => 'Manage following relationships',
|
||||
self::PUSH => 'Subscribe to real-time event streams',
|
||||
self::PROFILE => 'Edit actor profile',
|
||||
);
|
||||
|
||||
/**
|
||||
* Default scopes when none are requested.
|
||||
*
|
||||
* Defaults to read-only to prevent granting write access without
|
||||
* explicit scope request (fail-closed on access control).
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
const DEFAULT_SCOPES = array(
|
||||
self::READ,
|
||||
);
|
||||
|
||||
/**
|
||||
* Validate and filter requested scopes.
|
||||
*
|
||||
* @param string|array $scopes The requested scopes (space-separated string or array).
|
||||
* @return array Valid scopes.
|
||||
*/
|
||||
public static function validate( $scopes ) {
|
||||
if ( is_string( $scopes ) ) {
|
||||
$scopes = self::parse( $scopes );
|
||||
}
|
||||
|
||||
if ( ! is_array( $scopes ) ) {
|
||||
return self::DEFAULT_SCOPES;
|
||||
}
|
||||
|
||||
$valid_scopes = array_intersect( $scopes, self::ALL );
|
||||
|
||||
if ( empty( $valid_scopes ) ) {
|
||||
return self::DEFAULT_SCOPES;
|
||||
}
|
||||
|
||||
return array_values( $valid_scopes );
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a space-separated scope string to array.
|
||||
*
|
||||
* @param string $scope_string Space-separated scopes.
|
||||
* @return array Scope array.
|
||||
*/
|
||||
public static function parse( $scope_string ) {
|
||||
if ( empty( $scope_string ) || ! is_string( $scope_string ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$scopes = preg_split( '/\s+/', trim( $scope_string ) );
|
||||
|
||||
return array_filter( array_map( 'trim', $scopes ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert scopes array to space-separated string.
|
||||
*
|
||||
* @param array $scopes The scopes array.
|
||||
* @return string Space-separated scope string.
|
||||
*/
|
||||
public static function to_string( $scopes ) {
|
||||
if ( ! is_array( $scopes ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode( ' ', $scopes );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a scope is valid.
|
||||
*
|
||||
* @param string $scope The scope to check.
|
||||
* @return bool True if valid, false otherwise.
|
||||
*/
|
||||
public static function is_valid( $scope ) {
|
||||
return in_array( $scope, self::ALL, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the description for a scope.
|
||||
*
|
||||
* @param string $scope The scope.
|
||||
* @return string The description or empty string if not found.
|
||||
*/
|
||||
public static function get_description( $scope ) {
|
||||
return self::DESCRIPTIONS[ $scope ] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scopes with their descriptions.
|
||||
*
|
||||
* @return array Associative array of scope => description.
|
||||
*/
|
||||
public static function get_all_with_descriptions() {
|
||||
return self::DESCRIPTIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if scopes contain a specific scope.
|
||||
*
|
||||
* @param array $scopes The scopes to check.
|
||||
* @param string $scope The scope to look for.
|
||||
* @return bool True if the scope is present.
|
||||
*/
|
||||
public static function contains( $scopes, $scope ) {
|
||||
return is_array( $scopes ) && in_array( $scope, $scopes, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize callback for scope storage.
|
||||
*
|
||||
* @param mixed $value The value to sanitize.
|
||||
* @return array Sanitized scopes array.
|
||||
*/
|
||||
public static function sanitize( $value ) {
|
||||
if ( is_string( $value ) ) {
|
||||
$value = self::parse( $value );
|
||||
}
|
||||
|
||||
if ( ! is_array( $value ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return self::validate( $value );
|
||||
}
|
||||
}
|
||||
454
wp-content/plugins/activitypub/includes/oauth/class-server.php
Normal file
454
wp-content/plugins/activitypub/includes/oauth/class-server.php
Normal file
@ -0,0 +1,454 @@
|
||||
<?php
|
||||
/**
|
||||
* OAuth 2.0 Server for ActivityPub C2S.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\OAuth;
|
||||
|
||||
use Activitypub\Sanitize;
|
||||
|
||||
/**
|
||||
* Server class for OAuth 2.0 authentication and PKCE verification.
|
||||
*
|
||||
* Integrates with WordPress REST API authentication system.
|
||||
*/
|
||||
class Server {
|
||||
/**
|
||||
* The current validated token for this request.
|
||||
*
|
||||
* @var Token|null
|
||||
*/
|
||||
private static $current_token = null;
|
||||
|
||||
/**
|
||||
* Initialize the OAuth server.
|
||||
*/
|
||||
public static function init() {
|
||||
// Hook into REST authentication - priority 20 to run after default auth.
|
||||
\add_filter( 'rest_authentication_errors', array( self::class, 'authenticate_oauth' ), 20 );
|
||||
|
||||
// Schedule cleanup cron.
|
||||
if ( ! \wp_next_scheduled( 'activitypub_oauth_cleanup' ) ) {
|
||||
\wp_schedule_event( time(), 'daily', 'activitypub_oauth_cleanup' );
|
||||
}
|
||||
\add_action( 'activitypub_oauth_cleanup', array( self::class, 'cleanup' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate OAuth Bearer token for REST API requests.
|
||||
*
|
||||
* @param \WP_Error|null|bool $result Authentication result from previous filters.
|
||||
* @return \WP_Error|null|bool Authentication result.
|
||||
*/
|
||||
public static function authenticate_oauth( $result ) {
|
||||
/*
|
||||
* Reset OAuth state at the start of each authentication to prevent
|
||||
* leaking state between multiple REST dispatches in the same process.
|
||||
*/
|
||||
self::$current_token = null;
|
||||
|
||||
$token = self::get_bearer_token();
|
||||
|
||||
if ( ! $token ) {
|
||||
// No Bearer token — respect errors from earlier auth filters.
|
||||
return $result;
|
||||
}
|
||||
|
||||
$validated = Token::validate( $token );
|
||||
|
||||
if ( \is_wp_error( $validated ) ) {
|
||||
return $validated;
|
||||
}
|
||||
|
||||
self::$current_token = $validated;
|
||||
\wp_set_current_user( $validated->get_user_id() );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current OAuth token from the request.
|
||||
*
|
||||
* @return Token|null The validated token or null.
|
||||
*/
|
||||
public static function get_current_token() {
|
||||
return self::$current_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current request is authenticated via OAuth.
|
||||
*
|
||||
* @return bool True if OAuth authenticated.
|
||||
*/
|
||||
public static function is_oauth_request() {
|
||||
return null !== self::$current_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current token has a specific scope.
|
||||
*
|
||||
* @param string $scope The scope to check.
|
||||
* @return bool True if the current token has the scope.
|
||||
*/
|
||||
public static function has_scope( $scope ) {
|
||||
if ( ! self::$current_token ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::$current_token->has_scope( $scope );
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Bearer token from Authorization header.
|
||||
*
|
||||
* @return string|null The token string or null.
|
||||
*/
|
||||
public static function get_bearer_token() {
|
||||
$auth_header = self::get_authorization_header();
|
||||
|
||||
if ( ! $auth_header ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for Bearer token.
|
||||
if ( 0 !== strpos( $auth_header, 'Bearer ' ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return substr( $auth_header, 7 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Authorization header.
|
||||
*
|
||||
* @return string|null The authorization header value or null.
|
||||
*/
|
||||
private static function get_authorization_header() {
|
||||
/*
|
||||
* Only wp_unslash() is used here — sanitize_text_field() could
|
||||
* corrupt opaque bearer tokens by stripping characters.
|
||||
*/
|
||||
|
||||
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Opaque auth token, must not be altered.
|
||||
if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
|
||||
return \wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] );
|
||||
}
|
||||
|
||||
if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
|
||||
return \wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] );
|
||||
}
|
||||
// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
|
||||
// Fallback: read from Apache's own header API (case-insensitive).
|
||||
if ( ! function_exists( 'apache_request_headers' ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$headers = apache_request_headers();
|
||||
|
||||
foreach ( $headers as $key => $value ) {
|
||||
if ( 'authorization' === strtolower( $key ) ) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify PKCE code_verifier against code_challenge.
|
||||
*
|
||||
* @param string $code_verifier The PKCE code verifier.
|
||||
* @param string $code_challenge The stored code challenge.
|
||||
* @param string $method The challenge method (only S256 is supported).
|
||||
* @return bool True if valid.
|
||||
*/
|
||||
public static function verify_pkce( $code_verifier, $code_challenge, $method = 'S256' ) {
|
||||
return Authorization_Code::verify_pkce( $code_verifier, $code_challenge, $method );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure random string.
|
||||
*
|
||||
* @param int $length The length of the string in bytes.
|
||||
* @return string The random string as hex.
|
||||
*/
|
||||
public static function generate_token( $length = 32 ) {
|
||||
return Token::generate_token( $length );
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission callback for OAuth-protected endpoints.
|
||||
*
|
||||
* @param \WP_REST_Request $request The REST request.
|
||||
* @param string $scope Required scope (optional).
|
||||
* @return bool|\WP_Error True if authorized, error otherwise.
|
||||
*/
|
||||
public static function check_oauth_permission( $request, $scope = null ) {
|
||||
/**
|
||||
* Filter to override OAuth permission check.
|
||||
*
|
||||
* Useful for testing. Return true to bypass OAuth check, false to continue.
|
||||
*
|
||||
* @param bool|null $result The permission result. Null to continue normal check.
|
||||
* @param \WP_REST_Request $request The REST request.
|
||||
* @param string|null $scope Required scope.
|
||||
*/
|
||||
$override = \apply_filters( 'activitypub_oauth_check_permission', null, $request, $scope );
|
||||
|
||||
if ( null !== $override ) {
|
||||
return $override;
|
||||
}
|
||||
|
||||
if ( ! self::is_oauth_request() ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_oauth_required',
|
||||
\__( 'OAuth authentication required.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
if ( $scope && ! self::has_scope( $scope ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_insufficient_scope',
|
||||
/* translators: %s: The required scope */
|
||||
sprintf( \__( 'This action requires the "%s" scope.', 'activitypub' ), $scope ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run cleanup tasks for OAuth data.
|
||||
*/
|
||||
public static function cleanup() {
|
||||
// Clean up expired tokens.
|
||||
Token::cleanup_expired();
|
||||
|
||||
// Clean up expired authorization codes.
|
||||
Authorization_Code::cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth server metadata for discovery.
|
||||
*
|
||||
* @return array OAuth server metadata.
|
||||
*/
|
||||
public static function get_metadata() {
|
||||
$base_url = \trailingslashit( \get_rest_url( null, ACTIVITYPUB_REST_NAMESPACE ) );
|
||||
|
||||
return array(
|
||||
'issuer' => \home_url(),
|
||||
'authorization_endpoint' => $base_url . 'oauth/authorize',
|
||||
'token_endpoint' => $base_url . 'oauth/token',
|
||||
'revocation_endpoint' => $base_url . 'oauth/revoke',
|
||||
'introspection_endpoint' => $base_url . 'oauth/introspect',
|
||||
'registration_endpoint' => $base_url . 'oauth/clients',
|
||||
'scopes_supported' => Scope::ALL,
|
||||
'response_types_supported' => array( 'code' ),
|
||||
'response_modes_supported' => array( 'query' ),
|
||||
'grant_types_supported' => array( 'authorization_code', 'refresh_token' ),
|
||||
'token_endpoint_auth_methods_supported' => array( 'none', 'client_secret_post', 'client_secret_basic' ),
|
||||
'introspection_endpoint_auth_methods_supported' => array( 'bearer' ),
|
||||
'code_challenge_methods_supported' => array( 'S256' ),
|
||||
'service_documentation' => 'https://github.com/swicg/activitypub-api',
|
||||
'client_id_metadata_document_supported' => true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth authorization consent page via wp-login.php.
|
||||
*
|
||||
* This is triggered by wp-login.php?action=activitypub_authorize
|
||||
*/
|
||||
public static function login_form_authorize() {
|
||||
// Require user to be logged in.
|
||||
if ( ! \is_user_logged_in() ) {
|
||||
\auth_redirect();
|
||||
}
|
||||
|
||||
$request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
|
||||
|
||||
if ( 'GET' === $request_method ) {
|
||||
self::render_authorize_form();
|
||||
} elseif ( 'POST' === $request_method ) {
|
||||
self::process_authorize_form();
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the OAuth authorization consent form.
|
||||
*/
|
||||
private static function render_authorize_form() {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Initial form display, nonce checked on POST.
|
||||
|
||||
// Check for error token (redirected from REST authorization endpoint).
|
||||
if ( isset( $_GET['auth_error'] ) ) {
|
||||
$token = \sanitize_text_field( \wp_unslash( $_GET['auth_error'] ) );
|
||||
$error_message = \get_transient( 'ap_oauth_err_' . $token ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
|
||||
\delete_transient( 'ap_oauth_err_' . $token );
|
||||
|
||||
if ( ! $error_message ) {
|
||||
$error_message = \__( 'An authorization error occurred. Please try again.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
|
||||
}
|
||||
|
||||
include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
|
||||
return;
|
||||
}
|
||||
|
||||
$authorize_params = array(
|
||||
'client_id' => isset( $_GET['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_GET['client_id'] ) ) : '',
|
||||
'redirect_uri' => isset( $_GET['redirect_uri'] ) ? Sanitize::redirect_uri( \wp_unslash( $_GET['redirect_uri'] ) ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via Sanitize::redirect_uri().
|
||||
'scope' => isset( $_GET['scope'] ) ? \sanitize_text_field( \wp_unslash( $_GET['scope'] ) ) : '',
|
||||
'state' => isset( $_GET['state'] ) ? \wp_unslash( $_GET['state'] ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- OAuth state is opaque; must be round-tripped exactly.
|
||||
'code_challenge' => isset( $_GET['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge'] ) ) : '',
|
||||
'code_challenge_method' => isset( $_GET['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_GET['code_challenge_method'] ) ) : 'S256',
|
||||
);
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
// Validate client.
|
||||
$client = Client::get( $authorize_params['client_id'] );
|
||||
if ( \is_wp_error( $client ) ) {
|
||||
$error_message = $client->get_error_message(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
|
||||
include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate redirect URI.
|
||||
if ( ! $client->is_valid_redirect_uri( $authorize_params['redirect_uri'] ) ) {
|
||||
$error_message = \__( 'Invalid redirect URI for this client.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
|
||||
include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the canonical client ID (may differ from the raw input for discovered clients).
|
||||
$authorize_params['client_id'] = $client->get_client_id();
|
||||
|
||||
// These variables are used in the template.
|
||||
$current_user = \wp_get_current_user(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
$scopes = Scope::validate( Scope::parse( $authorize_params['scope'] ) ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
|
||||
// Build form action URL.
|
||||
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
$form_url = \add_query_arg(
|
||||
array_merge( array( 'action' => 'activitypub_authorize' ), $authorize_params ),
|
||||
\wp_login_url()
|
||||
);
|
||||
|
||||
// Include the template.
|
||||
include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-authorize.php'; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- $authorize_params used in template.
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the OAuth authorization consent form submission.
|
||||
*/
|
||||
private static function process_authorize_form() {
|
||||
// Verify nonce.
|
||||
if ( ! isset( $_POST['_wpnonce'] ) || ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ) ), 'activitypub_oauth_authorize' ) ) {
|
||||
$error_message = \__( 'Security check failed. Please try again.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
|
||||
include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified above.
|
||||
$client_id = isset( $_POST['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_POST['client_id'] ) ) : '';
|
||||
$redirect_uri = isset( $_POST['redirect_uri'] ) ? Sanitize::redirect_uri( \wp_unslash( $_POST['redirect_uri'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via Sanitize::redirect_uri().
|
||||
$scope = isset( $_POST['scope'] ) ? \sanitize_text_field( \wp_unslash( $_POST['scope'] ) ) : '';
|
||||
$state = isset( $_POST['state'] ) ? \wp_unslash( $_POST['state'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- OAuth state is opaque; must be round-tripped exactly.
|
||||
$code_challenge = isset( $_POST['code_challenge'] ) ? \sanitize_text_field( \wp_unslash( $_POST['code_challenge'] ) ) : '';
|
||||
$code_challenge_method = isset( $_POST['code_challenge_method'] ) ? \sanitize_text_field( \wp_unslash( $_POST['code_challenge_method'] ) ) : 'S256';
|
||||
$approve = isset( $_POST['approve'] );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
// Only S256 is supported; normalize empty/missing values and reject anything else.
|
||||
if ( empty( $code_challenge_method ) ) {
|
||||
$code_challenge_method = 'S256';
|
||||
} elseif ( 'S256' !== $code_challenge_method ) {
|
||||
$error_message = \__( 'Only S256 is supported as PKCE code challenge method.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
|
||||
include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Re-validate client and redirect URI (form fields could be tampered with).
|
||||
$client = Client::get( $client_id );
|
||||
|
||||
if ( \is_wp_error( $client ) ) {
|
||||
$error_message = $client->get_error_message(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
|
||||
include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) {
|
||||
$error_message = \__( 'Invalid redirect URI for this client.', 'activitypub' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Used in template.
|
||||
include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-error.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
// User denied authorization.
|
||||
if ( ! $approve ) {
|
||||
self::redirect_to_client(
|
||||
$redirect_uri,
|
||||
array(
|
||||
'error' => 'access_denied',
|
||||
'error_description' => 'The user denied the authorization request.',
|
||||
'state' => $state,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Create authorization code.
|
||||
$scopes = Scope::validate( Scope::parse( $scope ) );
|
||||
$code = Authorization_Code::create(
|
||||
\get_current_user_id(),
|
||||
$client_id,
|
||||
$redirect_uri,
|
||||
$scopes,
|
||||
$code_challenge,
|
||||
$code_challenge_method
|
||||
);
|
||||
|
||||
if ( \is_wp_error( $code ) ) {
|
||||
self::redirect_to_client(
|
||||
$redirect_uri,
|
||||
array(
|
||||
'error' => 'server_error',
|
||||
'error_description' => $code->get_error_message(),
|
||||
'state' => $state,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
self::redirect_to_client(
|
||||
$redirect_uri,
|
||||
array(
|
||||
'code' => $code,
|
||||
'state' => $state,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to an OAuth client's redirect URI with query parameters.
|
||||
*
|
||||
* Uses a manual Location header because wp_redirect() strips custom
|
||||
* URI schemes used by native/mobile apps (RFC 8252 Section 7.1).
|
||||
* The URI is pre-validated against the registered client's redirect_uris
|
||||
* before this method is called.
|
||||
*
|
||||
* @param string $redirect_uri The client's redirect URI.
|
||||
* @param array $params Query parameters to append.
|
||||
*/
|
||||
private static function redirect_to_client( $redirect_uri, $params ) {
|
||||
$url = Sanitize::redirect_uri( \add_query_arg( $params, $redirect_uri ) );
|
||||
|
||||
\nocache_headers();
|
||||
header( 'Location: ' . $url, true, 303 );
|
||||
exit;
|
||||
}
|
||||
}
|
||||
933
wp-content/plugins/activitypub/includes/oauth/class-token.php
Normal file
933
wp-content/plugins/activitypub/includes/oauth/class-token.php
Normal file
@ -0,0 +1,933 @@
|
||||
<?php
|
||||
/**
|
||||
* OAuth 2.0 Token model for ActivityPub C2S.
|
||||
*
|
||||
* @package Activitypub
|
||||
*/
|
||||
|
||||
namespace Activitypub\OAuth;
|
||||
|
||||
use Activitypub\Collection\Actors;
|
||||
|
||||
/**
|
||||
* Token class for managing OAuth 2.0 access and refresh tokens.
|
||||
*
|
||||
* Tokens are stored as user metadata with hashed values for security.
|
||||
* This follows the IndieAuth pattern for efficient token management.
|
||||
*/
|
||||
class Token {
|
||||
/**
|
||||
* User meta key prefix for OAuth tokens.
|
||||
*/
|
||||
const META_PREFIX = '_activitypub_oauth_token_';
|
||||
|
||||
/**
|
||||
* User meta key prefix for refresh token index (maps refresh hash to access hash).
|
||||
*/
|
||||
const REFRESH_INDEX_PREFIX = '_activitypub_oauth_refresh_';
|
||||
|
||||
/**
|
||||
* Post meta key on OAuth client posts to track users with tokens.
|
||||
*
|
||||
* Stored as non-unique post meta (one row per user) on ap_oauth_client posts,
|
||||
* following the same pattern as _activitypub_following on ap_actor posts.
|
||||
*/
|
||||
const USER_META_KEY = '_activitypub_user_id';
|
||||
|
||||
/**
|
||||
* Maximum number of active tokens per user.
|
||||
*
|
||||
* When exceeded, the oldest tokens are revoked automatically.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*/
|
||||
const MAX_TOKENS_PER_USER = 50;
|
||||
|
||||
/**
|
||||
* Default access token expiration in seconds (1 hour).
|
||||
*/
|
||||
const DEFAULT_EXPIRATION = 3600;
|
||||
|
||||
/**
|
||||
* Refresh token expiration in seconds (30 days).
|
||||
*/
|
||||
const REFRESH_EXPIRATION = 2592000;
|
||||
|
||||
/**
|
||||
* The token data array.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $data;
|
||||
|
||||
/**
|
||||
* The user ID this token belongs to.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $user_id;
|
||||
|
||||
/**
|
||||
* The token key (hash) used for storage.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $token_key;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param string $token_key The token key (hash).
|
||||
* @param array $data The token data.
|
||||
*/
|
||||
public function __construct( $user_id, $token_key, $data ) {
|
||||
$this->user_id = $user_id;
|
||||
$this->token_key = $token_key;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new access token.
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @param string $client_id OAuth client ID.
|
||||
* @param array $scopes Granted scopes.
|
||||
* @param int $expires Expiration time in seconds.
|
||||
* @return array|\WP_Error Token data or error.
|
||||
*/
|
||||
public static function create( $user_id, $client_id, $scopes, $expires = self::DEFAULT_EXPIRATION ) {
|
||||
// Generate tokens.
|
||||
$access_token = self::generate_token();
|
||||
$refresh_token = self::generate_token();
|
||||
|
||||
// Calculate expirations.
|
||||
$access_expires_at = time() + $expires;
|
||||
$refresh_expires_at = time() + self::REFRESH_EXPIRATION;
|
||||
|
||||
// Create token data.
|
||||
$token_data = array(
|
||||
'access_token_hash' => self::hash_token( $access_token ),
|
||||
'refresh_token_hash' => self::hash_token( $refresh_token ),
|
||||
'client_id' => $client_id,
|
||||
'scopes' => Scope::validate( $scopes ),
|
||||
'expires_at' => $access_expires_at,
|
||||
'refresh_expires_at' => $refresh_expires_at,
|
||||
'created_at' => time(),
|
||||
'last_used_at' => null,
|
||||
);
|
||||
|
||||
// Store in user meta with access token hash as key.
|
||||
$access_hash = self::hash_token( $access_token );
|
||||
$meta_key = self::META_PREFIX . $access_hash;
|
||||
$result = \update_user_meta( $user_id, $meta_key, $token_data );
|
||||
|
||||
if ( false === $result ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_token_storage_failed',
|
||||
\__( 'Failed to store access token.', 'activitypub' ),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
// Store refresh token index for O(1) lookup during refresh.
|
||||
$refresh_index_key = self::REFRESH_INDEX_PREFIX . self::hash_token( $refresh_token );
|
||||
\update_user_meta( $user_id, $refresh_index_key, $access_hash );
|
||||
|
||||
// Track user on the client post for cleanup.
|
||||
self::track_user( $user_id, $client_id );
|
||||
|
||||
// Enforce per-user token limit by revoking the oldest tokens.
|
||||
self::enforce_token_limit( $user_id );
|
||||
|
||||
/*
|
||||
* Get the actor URI for the 'me' parameter (IndieAuth convention).
|
||||
* Fall back to blog actor when user actors are disabled.
|
||||
*/
|
||||
$actor = Actors::get_by_id( $user_id );
|
||||
if ( \is_wp_error( $actor ) ) {
|
||||
$actor = Actors::get_by_id( Actors::BLOG_USER_ID );
|
||||
}
|
||||
$me = ! \is_wp_error( $actor ) ? $actor->get_id() : null;
|
||||
|
||||
return array(
|
||||
'access_token' => $access_token,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => $expires,
|
||||
'refresh_token' => $refresh_token,
|
||||
'scope' => Scope::to_string( $token_data['scopes'] ),
|
||||
'me' => $me,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an access token.
|
||||
*
|
||||
* @param string $token The access token to validate.
|
||||
* @return Token|\WP_Error The token object or error.
|
||||
*/
|
||||
public static function validate( $token ) {
|
||||
global $wpdb;
|
||||
|
||||
$token_hash = self::hash_token( $token );
|
||||
$meta_key = self::META_PREFIX . $token_hash;
|
||||
|
||||
// Direct DB lookup by meta_key - O(1) instead of O(n) users.
|
||||
$user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prepare(
|
||||
"SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
|
||||
$meta_key
|
||||
)
|
||||
);
|
||||
|
||||
if ( empty( $user_id ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_token',
|
||||
\__( 'Invalid access token.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
$token_data = \get_user_meta( (int) $user_id, $meta_key, true );
|
||||
|
||||
if ( empty( $token_data ) || ! is_array( $token_data ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_token',
|
||||
\__( 'Invalid access token.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Verify hash matches.
|
||||
if ( ! isset( $token_data['access_token_hash'] ) ||
|
||||
! hash_equals( $token_data['access_token_hash'], $token_hash ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_token',
|
||||
\__( 'Invalid access token.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check expiration.
|
||||
if ( isset( $token_data['expires_at'] ) && $token_data['expires_at'] < time() ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_token_expired',
|
||||
\__( 'Access token has expired.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Throttle last_used_at writes to avoid a DB write on every request.
|
||||
$last_used = $token_data['last_used_at'] ?? 0;
|
||||
if ( empty( $last_used ) || ( time() - $last_used ) > 5 * MINUTE_IN_SECONDS ) {
|
||||
$token_data['last_used_at'] = time();
|
||||
\update_user_meta( (int) $user_id, $meta_key, $token_data );
|
||||
}
|
||||
|
||||
return new self( (int) $user_id, $token_hash, $token_data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token using a refresh token.
|
||||
*
|
||||
* @param string $refresh_token The refresh token.
|
||||
* @param string $client_id The client ID (must match original).
|
||||
* @return array|\WP_Error New token data or error.
|
||||
*/
|
||||
public static function refresh( $refresh_token, $client_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$refresh_hash = self::hash_token( $refresh_token );
|
||||
$refresh_index_key = self::REFRESH_INDEX_PREFIX . $refresh_hash;
|
||||
|
||||
// Direct DB lookup by refresh token index - O(1) instead of O(n) users.
|
||||
$user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prepare(
|
||||
"SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
|
||||
$refresh_index_key
|
||||
)
|
||||
);
|
||||
|
||||
if ( empty( $user_id ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_refresh_token',
|
||||
\__( 'Invalid refresh token.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
$user_id = (int) $user_id;
|
||||
|
||||
// Get the access token hash from the index.
|
||||
$access_hash = \get_user_meta( $user_id, $refresh_index_key, true );
|
||||
if ( empty( $access_hash ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_refresh_token',
|
||||
\__( 'Invalid refresh token.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Get the full token data.
|
||||
$meta_key = self::META_PREFIX . $access_hash;
|
||||
$token_data = \get_user_meta( $user_id, $meta_key, true );
|
||||
|
||||
if ( empty( $token_data ) || ! is_array( $token_data ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_refresh_token',
|
||||
\__( 'Invalid refresh token.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Verify refresh token hash matches.
|
||||
if ( ! isset( $token_data['refresh_token_hash'] ) ||
|
||||
! hash_equals( $token_data['refresh_token_hash'], $refresh_hash ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_refresh_token',
|
||||
\__( 'Invalid refresh token.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Verify client ID matches.
|
||||
if ( $token_data['client_id'] !== $client_id ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_client_mismatch',
|
||||
\__( 'Client ID does not match.', 'activitypub' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Check refresh token expiration.
|
||||
if ( isset( $token_data['refresh_expires_at'] ) &&
|
||||
$token_data['refresh_expires_at'] < time() ) {
|
||||
// Delete the expired token and index.
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
\delete_user_meta( $user_id, $refresh_index_key );
|
||||
|
||||
return new \WP_Error(
|
||||
'activitypub_refresh_token_expired',
|
||||
\__( 'Refresh token has expired.', 'activitypub' ),
|
||||
array( 'status' => 401 )
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the old token and index.
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
\delete_user_meta( $user_id, $refresh_index_key );
|
||||
|
||||
// Create a new token.
|
||||
return self::create( $user_id, $client_id, $token_data['scopes'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a token.
|
||||
*
|
||||
* When `$caller_user_id` or `$caller_client_id` is provided, the token
|
||||
* is only deleted if it was issued to that user or that client, per
|
||||
* RFC 7009 Section 2.1. A mismatch is treated as a successful no-op so
|
||||
* the caller cannot probe for token existence belonging to others.
|
||||
*
|
||||
* @since 8.2.0 The `$caller_user_id` and `$caller_client_id` parameters were added.
|
||||
*
|
||||
* @param string $token The token to revoke (access or refresh).
|
||||
* @param int|null $caller_user_id Optional. User ID of the caller. Null disables the user check.
|
||||
* @param string|null $caller_client_id Optional. OAuth client ID of the caller. Null disables the client check.
|
||||
* @return bool True on success (always returns true per RFC 7009).
|
||||
*/
|
||||
public static function revoke( $token, $caller_user_id = null, $caller_client_id = null ) {
|
||||
global $wpdb;
|
||||
|
||||
$token_hash = self::hash_token( $token );
|
||||
|
||||
// Try as access token first (O(1) lookup).
|
||||
$access_meta_key = self::META_PREFIX . $token_hash;
|
||||
$user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prepare(
|
||||
"SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
|
||||
$access_meta_key
|
||||
)
|
||||
);
|
||||
|
||||
if ( $user_id ) {
|
||||
$user_id = (int) $user_id;
|
||||
$token_data = \get_user_meta( $user_id, $access_meta_key, true );
|
||||
$client_id = is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : '';
|
||||
|
||||
if ( ! self::caller_owns_token( $user_id, $client_id, $caller_user_id, $caller_client_id ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Delete the token.
|
||||
\delete_user_meta( $user_id, $access_meta_key );
|
||||
|
||||
// Also delete the refresh token index if it exists.
|
||||
if ( is_array( $token_data ) && isset( $token_data['refresh_token_hash'] ) ) {
|
||||
$refresh_index_key = self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'];
|
||||
\delete_user_meta( $user_id, $refresh_index_key );
|
||||
}
|
||||
|
||||
self::maybe_untrack_user( $user_id, $client_id );
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try as refresh token (O(1) lookup via index).
|
||||
$refresh_index_key = self::REFRESH_INDEX_PREFIX . $token_hash;
|
||||
$user_id = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prepare(
|
||||
"SELECT user_id FROM $wpdb->usermeta WHERE meta_key = %s LIMIT 1",
|
||||
$refresh_index_key
|
||||
)
|
||||
);
|
||||
|
||||
if ( $user_id ) {
|
||||
$user_id = (int) $user_id;
|
||||
$access_hash = \get_user_meta( $user_id, $refresh_index_key, true );
|
||||
$client_id = '';
|
||||
|
||||
if ( $access_hash ) {
|
||||
$token_data = \get_user_meta( $user_id, self::META_PREFIX . $access_hash, true );
|
||||
$client_id = is_array( $token_data ) ? ( $token_data['client_id'] ?? '' ) : '';
|
||||
}
|
||||
|
||||
if ( ! self::caller_owns_token( $user_id, $client_id, $caller_user_id, $caller_client_id ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( $access_hash ) {
|
||||
\delete_user_meta( $user_id, self::META_PREFIX . $access_hash );
|
||||
}
|
||||
\delete_user_meta( $user_id, $refresh_index_key );
|
||||
|
||||
self::maybe_untrack_user( $user_id, $client_id );
|
||||
return true;
|
||||
}
|
||||
|
||||
// Token doesn't exist or already revoked - that's fine per RFC 7009.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a caller is permitted to revoke a specific token.
|
||||
*
|
||||
* A null caller user and null caller client disable the check entirely,
|
||||
* preserving the pre-RFC-7009-enforcement behavior for internal callers
|
||||
* that already know they have authority (admin unlink, uninstall, etc.).
|
||||
*
|
||||
* When either caller parameter is provided, the token is considered
|
||||
* owned if it matches the caller user OR the caller client. Matching
|
||||
* client alone is enough to let an OAuth client clean up any token it
|
||||
* issued, regardless of which user granted consent.
|
||||
*
|
||||
* @param int $token_user_id User ID the token was issued to.
|
||||
* @param string $token_client_id OAuth client ID the token was issued to.
|
||||
* @param int|null $caller_user_id Caller user ID, or null to skip the user check.
|
||||
* @param string|null $caller_client_id Caller client ID, or null to skip the client check.
|
||||
* @return bool True if the caller may revoke, false otherwise.
|
||||
*/
|
||||
private static function caller_owns_token( $token_user_id, $token_client_id, $caller_user_id, $caller_client_id ) {
|
||||
if ( null === $caller_user_id && null === $caller_client_id ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( null !== $caller_user_id && $token_user_id === $caller_user_id ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* Require a real client_id on the token. An empty string on both
|
||||
* sides would otherwise match and let an un-attributed token be
|
||||
* revoked by any caller presenting an empty client claim.
|
||||
*/
|
||||
if ( null !== $caller_client_id && '' !== $token_client_id && $token_client_id === $caller_client_id ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Untrack user from a client if they have no remaining tokens for that client.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param string $client_id The OAuth client ID.
|
||||
*/
|
||||
private static function maybe_untrack_user( $user_id, $client_id ) {
|
||||
if ( empty( $client_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has any remaining tokens for this client.
|
||||
$tokens = self::get_all_for_user( $user_id );
|
||||
foreach ( $tokens as $token_data ) {
|
||||
if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) {
|
||||
return; // Still has tokens for this client.
|
||||
}
|
||||
}
|
||||
|
||||
self::untrack_user( $user_id, $client_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all tokens for a user.
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @return int Number of tokens revoked.
|
||||
*/
|
||||
public static function revoke_all_for_user( $user_id ) {
|
||||
$all_meta = \get_user_meta( $user_id );
|
||||
$count = 0;
|
||||
$client_ids = array();
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
// Delete token entries and collect client IDs.
|
||||
if ( 0 === strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
if ( is_array( $token_data ) && ! empty( $token_data['client_id'] ) ) {
|
||||
$client_ids[] = $token_data['client_id'];
|
||||
}
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
++$count;
|
||||
}
|
||||
// Delete refresh token indices.
|
||||
if ( 0 === strpos( $meta_key, self::REFRESH_INDEX_PREFIX ) ) {
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
}
|
||||
}
|
||||
|
||||
// Remove user from all client tracking.
|
||||
foreach ( array_unique( $client_ids ) as $client_id ) {
|
||||
self::untrack_user( $user_id, $client_id );
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all tokens for all users.
|
||||
*
|
||||
* Used during plugin uninstall to clean up all OAuth token data.
|
||||
*
|
||||
* @return int Number of tokens revoked.
|
||||
*/
|
||||
public static function revoke_all() {
|
||||
$user_ids = self::get_all_tracked_users();
|
||||
$count = 0;
|
||||
|
||||
foreach ( $user_ids as $user_id ) {
|
||||
$count += self::revoke_all_for_user( $user_id );
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all tokens for a specific client.
|
||||
*
|
||||
* @param string $client_id OAuth client ID.
|
||||
* @return int Number of tokens revoked.
|
||||
*/
|
||||
public static function revoke_for_client( $client_id ) {
|
||||
$user_ids = self::get_tracked_users( $client_id );
|
||||
$count = 0;
|
||||
|
||||
foreach ( $user_ids as $user_id ) {
|
||||
$all_meta = \get_user_meta( $user_id );
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
|
||||
if ( ! is_array( $token_data ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only revoke tokens belonging to this client.
|
||||
if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) {
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
// Also delete refresh token index.
|
||||
if ( isset( $token_data['refresh_token_hash'] ) ) {
|
||||
\delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] );
|
||||
}
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all user tracking for this client.
|
||||
self::untrack_all_users( $client_id );
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tokens for a user.
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @return array Array of token data.
|
||||
*/
|
||||
public static function get_all_for_user( $user_id ) {
|
||||
$all_meta = \get_user_meta( $user_id );
|
||||
$tokens = array();
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
|
||||
if ( is_array( $token_data ) ) {
|
||||
// Don't expose hashes.
|
||||
unset( $token_data['access_token_hash'], $token_data['refresh_token_hash'] );
|
||||
$token_data['meta_key'] = $meta_key; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Not a DB query, just array key.
|
||||
$tokens[] = $token_data;
|
||||
}
|
||||
}
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token has a specific scope.
|
||||
*
|
||||
* @param string $scope The scope to check.
|
||||
* @return bool True if token has scope.
|
||||
*/
|
||||
public function has_scope( $scope ) {
|
||||
$scopes = $this->get_scopes();
|
||||
return Scope::contains( $scopes, $scope );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user ID associated with this token.
|
||||
*
|
||||
* @return int The WordPress user ID.
|
||||
*/
|
||||
public function get_user_id() {
|
||||
return $this->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client ID associated with this token.
|
||||
*
|
||||
* @return string The OAuth client ID.
|
||||
*/
|
||||
public function get_client_id() {
|
||||
return $this->data['client_id'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scopes for this token.
|
||||
*
|
||||
* @return array The granted scopes.
|
||||
*/
|
||||
public function get_scopes() {
|
||||
return $this->data['scopes'] ?? array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expiration timestamp.
|
||||
*
|
||||
* @return int Unix timestamp.
|
||||
*/
|
||||
public function get_expires_at() {
|
||||
return $this->data['expires_at'] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the token is expired.
|
||||
*
|
||||
* @return bool True if expired.
|
||||
*/
|
||||
public function is_expired() {
|
||||
return $this->get_expires_at() < time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the creation timestamp.
|
||||
*
|
||||
* @return int Unix timestamp.
|
||||
*/
|
||||
public function get_created_at() {
|
||||
return $this->data['created_at'] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last used timestamp.
|
||||
*
|
||||
* @return int|null Unix timestamp or null if never used.
|
||||
*/
|
||||
public function get_last_used_at() {
|
||||
return $this->data['last_used_at'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure random token.
|
||||
*
|
||||
* @param int $length The length of the token in bytes (default 32 = 64 hex chars).
|
||||
* @return string The random token as a hex string.
|
||||
*/
|
||||
public static function generate_token( $length = 32 ) {
|
||||
return bin2hex( random_bytes( $length ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a token for secure storage.
|
||||
*
|
||||
* @param string $token The token to hash.
|
||||
* @return string The SHA-256 hash.
|
||||
*/
|
||||
public static function hash_token( $token ) {
|
||||
return hash( 'sha256', $token );
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a user as having tokens for a client.
|
||||
*
|
||||
* Stores user ID as non-unique post meta on the client post,
|
||||
* following the same pattern as _activitypub_following on ap_actor posts.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param string $client_id The OAuth client ID.
|
||||
*/
|
||||
private static function track_user( $user_id, $client_id ) {
|
||||
$client = Client::get( $client_id );
|
||||
|
||||
if ( \is_wp_error( $client ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post_id = $client->get_post_id();
|
||||
$existing = \get_post_meta( $post_id, self::USER_META_KEY, false );
|
||||
|
||||
if ( ! in_array( $user_id, array_map( 'intval', $existing ), true ) ) {
|
||||
\add_post_meta( $post_id, self::USER_META_KEY, $user_id );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce per-user token limit by revoking oldest tokens.
|
||||
*
|
||||
* @since 8.1.0
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
*/
|
||||
private static function enforce_token_limit( $user_id ) {
|
||||
$all_meta = \get_user_meta( $user_id );
|
||||
$tokens = array();
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
|
||||
if ( is_array( $token_data ) ) {
|
||||
$tokens[ $meta_key ] = $token_data;
|
||||
}
|
||||
}
|
||||
|
||||
if ( count( $tokens ) <= self::MAX_TOKENS_PER_USER ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by created_at ascending (oldest first).
|
||||
uasort(
|
||||
$tokens,
|
||||
function ( $a, $b ) {
|
||||
return ( $a['created_at'] ?? 0 ) - ( $b['created_at'] ?? 0 );
|
||||
}
|
||||
);
|
||||
|
||||
$to_remove = count( $tokens ) - self::MAX_TOKENS_PER_USER;
|
||||
|
||||
foreach ( $tokens as $meta_key => $token_data ) {
|
||||
if ( $to_remove <= 0 ) {
|
||||
break;
|
||||
}
|
||||
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
|
||||
// Also delete the refresh token index.
|
||||
if ( isset( $token_data['refresh_token_hash'] ) ) {
|
||||
\delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] );
|
||||
}
|
||||
|
||||
--$to_remove;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Untrack a user from a specific client.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param string $client_id The OAuth client ID.
|
||||
*/
|
||||
private static function untrack_user( $user_id, $client_id ) {
|
||||
$client = Client::get( $client_id );
|
||||
|
||||
if ( \is_wp_error( $client ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
\delete_post_meta( $client->get_post_id(), self::USER_META_KEY, $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Untrack all users from a specific client.
|
||||
*
|
||||
* @param string $client_id The OAuth client ID.
|
||||
*/
|
||||
private static function untrack_all_users( $client_id ) {
|
||||
$client = Client::get( $client_id );
|
||||
|
||||
if ( \is_wp_error( $client ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
\delete_post_meta( $client->get_post_id(), self::USER_META_KEY );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tracked users for a specific client.
|
||||
*
|
||||
* @param string $client_id The OAuth client ID.
|
||||
* @return array User IDs.
|
||||
*/
|
||||
private static function get_tracked_users( $client_id ) {
|
||||
$client = Client::get( $client_id );
|
||||
|
||||
if ( \is_wp_error( $client ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$user_ids = \get_post_meta( $client->get_post_id(), self::USER_META_KEY, false );
|
||||
|
||||
return array_map( 'intval', $user_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user IDs with tokens across all clients.
|
||||
*
|
||||
* @return array Unique user IDs.
|
||||
*/
|
||||
private static function get_all_tracked_users() {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$user_ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT pm.meta_value FROM $wpdb->postmeta pm
|
||||
INNER JOIN $wpdb->posts p ON pm.post_id = p.ID
|
||||
WHERE p.post_type = %s AND pm.meta_key = %s",
|
||||
Client::POST_TYPE,
|
||||
self::USER_META_KEY
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( 'intval', $user_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired tokens.
|
||||
*
|
||||
* Should be called periodically via cron.
|
||||
*
|
||||
* @return int Number of tokens deleted.
|
||||
*/
|
||||
public static function cleanup_expired() {
|
||||
$user_ids = self::get_all_tracked_users();
|
||||
$count = 0;
|
||||
|
||||
foreach ( $user_ids as $user_id ) {
|
||||
$all_meta = \get_user_meta( $user_id );
|
||||
$client_ids = array();
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
|
||||
if ( ! is_array( $token_data ) ) {
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
++$count;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if both access and refresh tokens are expired.
|
||||
$access_expired = isset( $token_data['expires_at'] ) &&
|
||||
$token_data['expires_at'] < time() - DAY_IN_SECONDS;
|
||||
$refresh_expired = isset( $token_data['refresh_expires_at'] ) &&
|
||||
$token_data['refresh_expires_at'] < time();
|
||||
|
||||
if ( $access_expired && $refresh_expired ) {
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
// Also delete refresh token index.
|
||||
if ( isset( $token_data['refresh_token_hash'] ) ) {
|
||||
\delete_user_meta( $user_id, self::REFRESH_INDEX_PREFIX . $token_data['refresh_token_hash'] );
|
||||
}
|
||||
++$count;
|
||||
|
||||
if ( ! empty( $token_data['client_id'] ) ) {
|
||||
$client_ids[] = $token_data['client_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Untrack user from clients where all tokens were removed.
|
||||
foreach ( array_unique( $client_ids ) as $client_id ) {
|
||||
self::maybe_untrack_user( $user_id, $client_id );
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Introspect a token (RFC 7662).
|
||||
*
|
||||
* @param string $token The token to introspect.
|
||||
* @return array Token introspection response.
|
||||
*/
|
||||
public static function introspect( $token ) {
|
||||
$validated = self::validate( $token );
|
||||
|
||||
if ( \is_wp_error( $validated ) ) {
|
||||
// Return inactive for invalid/expired tokens.
|
||||
return array( 'active' => false );
|
||||
}
|
||||
|
||||
$user_id = $validated->get_user_id();
|
||||
$user = \get_userdata( $user_id );
|
||||
|
||||
/*
|
||||
* Get the actor URI for the 'me' parameter (IndieAuth convention).
|
||||
* Fall back to blog actor when user actors are disabled.
|
||||
*/
|
||||
$actor = Actors::get_by_id( $user_id );
|
||||
if ( \is_wp_error( $actor ) ) {
|
||||
$actor = Actors::get_by_id( Actors::BLOG_USER_ID );
|
||||
}
|
||||
$me = ! \is_wp_error( $actor ) ? $actor->get_id() : null;
|
||||
|
||||
return array(
|
||||
'active' => true,
|
||||
'scope' => Scope::to_string( $validated->get_scopes() ),
|
||||
'client_id' => $validated->get_client_id(),
|
||||
'username' => $user ? $user->user_login : null,
|
||||
'token_type' => 'Bearer',
|
||||
'exp' => $validated->get_expires_at(),
|
||||
'iat' => $validated->get_created_at(),
|
||||
'sub' => (string) $user_id,
|
||||
'me' => $me,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user