updated plugin ActivityPub version 8.3.0

This commit is contained in:
2026-06-03 21:28:46 +00:00
committed by Gitium
parent a4b78ec277
commit 6fe182458a
340 changed files with 43232 additions and 7568 deletions

View File

@ -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;
}
}

View 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 );
}
}

View 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 );
}
}

View 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;
}
}

View 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,
);
}
}