updated plugin ActivityPub version 9.1.0
This commit is contained in:
@ -97,7 +97,7 @@ class Authorization_Code {
|
||||
// Generate the code.
|
||||
$code = self::generate_code();
|
||||
$code_hash = self::hash_code( $code );
|
||||
$expires_at = time() + self::EXPIRATION;
|
||||
$expires_at = \time() + self::EXPIRATION;
|
||||
|
||||
// Store code data in transient.
|
||||
$code_data = array(
|
||||
@ -108,7 +108,7 @@ class Authorization_Code {
|
||||
'code_challenge' => $code_challenge,
|
||||
'code_challenge_method' => $code_challenge_method,
|
||||
'expires_at' => $expires_at,
|
||||
'created_at' => time(),
|
||||
'created_at' => \time(),
|
||||
);
|
||||
|
||||
$stored = \set_transient(
|
||||
@ -155,7 +155,7 @@ class Authorization_Code {
|
||||
\delete_transient( $transient );
|
||||
|
||||
// Check expiration (belt and suspenders - transient should auto-expire).
|
||||
if ( isset( $code_data['expires_at'] ) && $code_data['expires_at'] < time() ) {
|
||||
if ( isset( $code_data['expires_at'] ) && $code_data['expires_at'] < \time() ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_code_expired',
|
||||
\__( 'Authorization code has expired.', 'activitypub' ),
|
||||
@ -228,7 +228,7 @@ class Authorization_Code {
|
||||
// S256: BASE64URL(SHA256(code_verifier)) == code_challenge.
|
||||
$computed = self::compute_code_challenge( $code_verifier );
|
||||
|
||||
return hash_equals( $code_challenge, $computed );
|
||||
return \hash_equals( $code_challenge, $computed );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -238,9 +238,9 @@ class Authorization_Code {
|
||||
* @return string The code challenge (BASE64URL encoded SHA256 hash).
|
||||
*/
|
||||
public static function compute_code_challenge( $code_verifier ) {
|
||||
$hash = hash( 'sha256', $code_verifier, true );
|
||||
$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 ), '+/', '-_' ), '=' );
|
||||
return \rtrim( \strtr( \base64_encode( $hash ), '+/', '-_' ), '=' );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -249,7 +249,7 @@ class Authorization_Code {
|
||||
* @return string The authorization code.
|
||||
*/
|
||||
public static function generate_code() {
|
||||
return bin2hex( random_bytes( 32 ) );
|
||||
return \bin2hex( \random_bytes( 32 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -259,7 +259,7 @@ class Authorization_Code {
|
||||
* @return string The SHA-256 hash.
|
||||
*/
|
||||
public static function hash_code( $code ) {
|
||||
return hash( 'sha256', $code );
|
||||
return \hash( 'sha256', $code );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -286,7 +286,7 @@ class Authorization_Code {
|
||||
}
|
||||
|
||||
$timeout_prefix = '_transient_timeout_' . self::TRANSIENT_PREFIX;
|
||||
$now = time();
|
||||
$now = \time();
|
||||
|
||||
// Find expired timeout rows for this prefix.
|
||||
$timeout_option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
@ -307,10 +307,10 @@ class Authorization_Code {
|
||||
$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 );
|
||||
$option_names_to_delete[] = \str_replace( '_transient_timeout_', '_transient_', $timeout_name );
|
||||
}
|
||||
|
||||
$placeholders = implode( ', ', array_fill( 0, count( $option_names_to_delete ), '%s' ) );
|
||||
$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(
|
||||
|
||||
@ -107,7 +107,7 @@ class Client {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_redirect_uri',
|
||||
/* translators: %s: The invalid redirect URI */
|
||||
sprintf( \__( 'Invalid redirect URI: %s', 'activitypub' ), $uri ),
|
||||
\sprintf( \__( 'Invalid redirect URI: %s', 'activitypub' ), $uri ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
@ -131,7 +131,7 @@ class Client {
|
||||
'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_redirect_uris' => \array_map( array( Sanitize::class, 'redirect_uri' ), $redirect_uris ),
|
||||
'_activitypub_allowed_scopes' => Scope::validate( $scopes ),
|
||||
'_activitypub_is_public' => (bool) $is_public,
|
||||
),
|
||||
@ -281,7 +281,7 @@ class Client {
|
||||
|
||||
// Get redirect URIs from metadata or derive from client_id origin.
|
||||
$redirect_uris = array();
|
||||
if ( ! empty( $metadata['redirect_uris'] ) && is_array( $metadata['redirect_uris'] ) ) {
|
||||
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(
|
||||
@ -307,7 +307,7 @@ class Client {
|
||||
'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_redirect_uris' => \array_map( array( Sanitize::class, 'redirect_uri' ), $redirect_uris ),
|
||||
'_activitypub_allowed_scopes' => Scope::ALL,
|
||||
'_activitypub_is_public' => true,
|
||||
'_activitypub_discovered' => true,
|
||||
@ -396,7 +396,7 @@ class Client {
|
||||
$body = \wp_remote_retrieve_body( $response );
|
||||
$data = \json_decode( $body, true );
|
||||
|
||||
if ( ! is_array( $data ) ) {
|
||||
if ( ! \is_array( $data ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_client_invalid_metadata',
|
||||
\__( 'Invalid client metadata format.', 'activitypub' ),
|
||||
@ -465,19 +465,19 @@ class Client {
|
||||
$metadata['redirect_uris'] = (array) $data['redirectURI'];
|
||||
}
|
||||
if ( empty( $metadata['logo_uri'] ) && ! empty( $data['icon'] ) ) {
|
||||
if ( is_string( $data['icon'] ) ) {
|
||||
if ( \is_string( $data['icon'] ) ) {
|
||||
$metadata['logo_uri'] = $data['icon'];
|
||||
} elseif ( is_array( $data['icon'] ) && ! empty( $data['icon']['url'] ) ) {
|
||||
} 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'];
|
||||
$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 ) ) {
|
||||
if ( ! empty( $data['type'] ) && \in_array( $data['type'], $actor_types, true ) ) {
|
||||
$metadata['is_actor'] = true;
|
||||
}
|
||||
|
||||
@ -534,7 +534,7 @@ class Client {
|
||||
}
|
||||
|
||||
// Exact match first.
|
||||
if ( in_array( $redirect_uri, $allowed_uris, true ) ) {
|
||||
if ( \in_array( $redirect_uri, $allowed_uris, true ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -668,7 +668,7 @@ class Client {
|
||||
);
|
||||
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
|
||||
return array_map(
|
||||
return \array_map(
|
||||
function ( $post ) {
|
||||
return new self( $post->ID );
|
||||
},
|
||||
@ -734,7 +734,7 @@ class Client {
|
||||
*/
|
||||
public function get_redirect_uris() {
|
||||
$uris = \get_post_meta( $this->post_id, '_activitypub_redirect_uris', true );
|
||||
return is_array( $uris ) ? $uris : array();
|
||||
return \is_array( $uris ) ? $uris : array();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -744,7 +744,7 @@ class Client {
|
||||
*/
|
||||
public function get_allowed_scopes() {
|
||||
$scopes = \get_post_meta( $this->post_id, '_activitypub_allowed_scopes', true );
|
||||
return is_array( $scopes ) ? $scopes : Scope::DEFAULT_SCOPES;
|
||||
return \is_array( $scopes ) ? $scopes : Scope::DEFAULT_SCOPES;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -790,7 +790,7 @@ class Client {
|
||||
$host = \wp_parse_url( $redirect_uris[0], PHP_URL_HOST );
|
||||
|
||||
if ( $scheme && $host ) {
|
||||
return \trailingslashit( sprintf( '%s://%s', $scheme, $host ) );
|
||||
return \trailingslashit( \sprintf( '%s://%s', $scheme, $host ) );
|
||||
}
|
||||
}
|
||||
|
||||
@ -823,7 +823,7 @@ class Client {
|
||||
*/
|
||||
public function filter_scopes( $requested_scopes ) {
|
||||
$allowed = $this->get_allowed_scopes();
|
||||
return array_values( array_intersect( $requested_scopes, $allowed ) );
|
||||
return \array_values( \array_intersect( $requested_scopes, $allowed ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -833,11 +833,11 @@ class Client {
|
||||
*/
|
||||
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.
|
||||
$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 ) );
|
||||
return \vsprintf( '%s%s-%s-%s-%s-%s%s%s', \str_split( \bin2hex( $data ), 4 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -869,7 +869,7 @@ class Client {
|
||||
* 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 ) ) {
|
||||
if ( ! \preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $uri, $matches ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -883,7 +883,7 @@ class Client {
|
||||
|
||||
// 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 ) ) {
|
||||
if ( \in_array( $scheme, $blocked_schemes, true ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -922,7 +922,7 @@ class Client {
|
||||
* Custom schemes must be at least 2 characters to avoid matching
|
||||
* Windows drive letters (e.g., "C:").
|
||||
*/
|
||||
return strlen( $scheme ) >= 2;
|
||||
return \strlen( $scheme ) >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -949,7 +949,7 @@ class Client {
|
||||
// Also revoke all tokens stored in user meta.
|
||||
Token::revoke_all();
|
||||
|
||||
return count( $post_ids );
|
||||
return \count( $post_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -51,6 +51,23 @@ class Scope {
|
||||
self::PROFILE,
|
||||
);
|
||||
|
||||
/**
|
||||
* SWICG ActivityPub API Basic Profile canonical scope aliases.
|
||||
*
|
||||
* Advertised in OAuth metadata so Basic Profile clients can discover them,
|
||||
* and accepted in scope requests (any `activitypub:read:*` collapses to
|
||||
* `read`, any `activitypub:write:*` collapses to `write`). Enforcement
|
||||
* stays coarse: there is no per-activity-type access control yet.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
const CANONICAL_ALIASES = array(
|
||||
'activitypub:read:all',
|
||||
'activitypub:write:all',
|
||||
);
|
||||
|
||||
/**
|
||||
* Human-readable descriptions for each scope.
|
||||
*
|
||||
@ -79,25 +96,83 @@ class Scope {
|
||||
/**
|
||||
* Validate and filter requested scopes.
|
||||
*
|
||||
* Canonical SWICG ActivityPub API Basic Profile scope names of the form
|
||||
* `activitypub:read:*` and `activitypub:write:*` are normalized to the
|
||||
* plugin's internal `read` and `write` scopes before validation.
|
||||
*
|
||||
* @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 ) ) {
|
||||
if ( \is_string( $scopes ) ) {
|
||||
$scopes = self::parse( $scopes );
|
||||
}
|
||||
|
||||
if ( ! is_array( $scopes ) ) {
|
||||
if ( ! \is_array( $scopes ) ) {
|
||||
return self::DEFAULT_SCOPES;
|
||||
}
|
||||
|
||||
$valid_scopes = array_intersect( $scopes, self::ALL );
|
||||
$scopes = self::normalize( $scopes );
|
||||
$valid_scopes = \array_intersect( $scopes, self::ALL );
|
||||
|
||||
if ( empty( $valid_scopes ) ) {
|
||||
return self::DEFAULT_SCOPES;
|
||||
}
|
||||
|
||||
return array_values( $valid_scopes );
|
||||
return \array_values( \array_unique( $valid_scopes ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize canonical Basic Profile scope names to internal scopes.
|
||||
*
|
||||
* Maps any `activitypub:read:*` to {@see self::READ} and any
|
||||
* `activitypub:write:*` to {@see self::WRITE}. Unknown values pass through
|
||||
* unchanged so they can be filtered out by the caller.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @param array $scopes Requested scope strings.
|
||||
* @return array Normalized scope strings.
|
||||
*/
|
||||
public static function normalize( $scopes ) {
|
||||
if ( ! \is_array( $scopes ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$normalized = array();
|
||||
foreach ( $scopes as $scope ) {
|
||||
if ( ! \is_string( $scope ) || '' === $scope ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( 0 === \strpos( $scope, 'activitypub:read:' ) ) {
|
||||
$normalized[] = self::READ;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( 0 === \strpos( $scope, 'activitypub:write:' ) ) {
|
||||
$normalized[] = self::WRITE;
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $scope;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the scope identifiers advertised in OAuth authorization-server metadata.
|
||||
*
|
||||
* Includes the plugin's internal scopes plus the SWICG Basic Profile
|
||||
* canonical aliases so spec-aware clients can discover them.
|
||||
*
|
||||
* @since 9.0.0
|
||||
*
|
||||
* @return array Scope identifiers.
|
||||
*/
|
||||
public static function supported() {
|
||||
return \array_merge( self::ALL, self::CANONICAL_ALIASES );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -107,13 +182,13 @@ class Scope {
|
||||
* @return array Scope array.
|
||||
*/
|
||||
public static function parse( $scope_string ) {
|
||||
if ( empty( $scope_string ) || ! is_string( $scope_string ) ) {
|
||||
if ( empty( $scope_string ) || ! \is_string( $scope_string ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$scopes = preg_split( '/\s+/', trim( $scope_string ) );
|
||||
$scopes = \preg_split( '/\s+/', \trim( $scope_string ) );
|
||||
|
||||
return array_filter( array_map( 'trim', $scopes ) );
|
||||
return \array_filter( \array_map( 'trim', $scopes ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -123,11 +198,11 @@ class Scope {
|
||||
* @return string Space-separated scope string.
|
||||
*/
|
||||
public static function to_string( $scopes ) {
|
||||
if ( ! is_array( $scopes ) ) {
|
||||
if ( ! \is_array( $scopes ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode( ' ', $scopes );
|
||||
return \implode( ' ', $scopes );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -137,7 +212,7 @@ class Scope {
|
||||
* @return bool True if valid, false otherwise.
|
||||
*/
|
||||
public static function is_valid( $scope ) {
|
||||
return in_array( $scope, self::ALL, true );
|
||||
return \in_array( $scope, self::ALL, true );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -167,7 +242,7 @@ class Scope {
|
||||
* @return bool True if the scope is present.
|
||||
*/
|
||||
public static function contains( $scopes, $scope ) {
|
||||
return is_array( $scopes ) && in_array( $scope, $scopes, true );
|
||||
return \is_array( $scopes ) && \in_array( $scope, $scopes, true );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -177,11 +252,11 @@ class Scope {
|
||||
* @return array Sanitized scopes array.
|
||||
*/
|
||||
public static function sanitize( $value ) {
|
||||
if ( is_string( $value ) ) {
|
||||
if ( \is_string( $value ) ) {
|
||||
$value = self::parse( $value );
|
||||
}
|
||||
|
||||
if ( ! is_array( $value ) ) {
|
||||
if ( ! \is_array( $value ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ class Server {
|
||||
|
||||
// Schedule cleanup cron.
|
||||
if ( ! \wp_next_scheduled( 'activitypub_oauth_cleanup' ) ) {
|
||||
\wp_schedule_event( time(), 'daily', 'activitypub_oauth_cleanup' );
|
||||
\wp_schedule_event( \time(), 'daily', 'activitypub_oauth_cleanup' );
|
||||
}
|
||||
\add_action( 'activitypub_oauth_cleanup', array( self::class, 'cleanup' ) );
|
||||
}
|
||||
@ -56,6 +56,22 @@ class Server {
|
||||
return $result;
|
||||
}
|
||||
|
||||
/*
|
||||
* Only honor OAuth bearer tokens on the plugin's own REST API.
|
||||
*
|
||||
* These are scoped ActivityPub C2S grants. Honoring them on core routes
|
||||
* (e.g. POST /wp/v2/posts) would establish a full-capability session that
|
||||
* ignores the token's granted scope, since scope is only enforced where
|
||||
* the plugin opts in via check_oauth_permission() (CWE-863). Every C2S
|
||||
* endpoint lives under ACTIVITYPUB_REST_NAMESPACE, so restricting here
|
||||
* does not affect legitimate clients. Direct callers (outbox permalinks,
|
||||
* SSE query-param auth) invoke this method outside REST dispatch and are
|
||||
* trusted to have scoped the context themselves.
|
||||
*/
|
||||
if ( ! self::is_authenticatable_request() ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$validated = Token::validate( $token );
|
||||
|
||||
if ( \is_wp_error( $validated ) ) {
|
||||
@ -68,6 +84,63 @@ class Server {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current request may be authenticated with an OAuth bearer token.
|
||||
*
|
||||
* Bearer tokens are scoped ActivityPub C2S grants and must only authenticate
|
||||
* the plugin's own REST API. During REST dispatch we therefore require the
|
||||
* requested route to live under {@see ACTIVITYPUB_REST_NAMESPACE}; a token
|
||||
* presented to a core route such as `/wp/v2/posts` is ignored so its granted
|
||||
* scope cannot be bypassed. Outside REST dispatch (e.g. outbox permalinks
|
||||
* that authenticate explicitly) there is no route to scope against, so the
|
||||
* request is allowed through to the caller's own checks.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return bool True if the request may be OAuth-authenticated.
|
||||
*/
|
||||
private static function is_authenticatable_request() {
|
||||
global $wp;
|
||||
|
||||
$route = isset( $wp->query_vars['rest_route'] ) ? (string) $wp->query_vars['rest_route'] : '';
|
||||
|
||||
if ( '' === $route ) {
|
||||
// No REST route in context: only trust non-REST (direct/permalink) callers.
|
||||
return ! \wp_is_serving_rest_request();
|
||||
}
|
||||
|
||||
$route = '/' . \ltrim( $route, '/' );
|
||||
$namespace = '/' . \trim( ACTIVITYPUB_REST_NAMESPACE, '/' );
|
||||
|
||||
return $route === $namespace || 0 === \strpos( $route, $namespace . '/' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject requests that authenticated with an OAuth bearer token.
|
||||
*
|
||||
* OAuth bearer tokens are scoped ActivityPub C2S grants and must not reach
|
||||
* the plugin's admin and management endpoints, which authorize by WordPress
|
||||
* capability rather than by OAuth scope. Without this guard a token consented
|
||||
* to for a narrow scope (e.g. read) could drive privileged, capability-gated
|
||||
* actions such as moderation. Cookie-authenticated admin-UI requests do not
|
||||
* establish an OAuth session and are unaffected.
|
||||
*
|
||||
* @since 9.1.0
|
||||
*
|
||||
* @return \WP_Error|null WP_Error when the request is OAuth-authenticated, null otherwise.
|
||||
*/
|
||||
public static function deny_if_oauth() {
|
||||
if ( ! self::is_oauth_request() ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new \WP_Error(
|
||||
'activitypub_oauth_not_allowed',
|
||||
\__( 'OAuth authentication is not allowed for this endpoint.', 'activitypub' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current OAuth token from the request.
|
||||
*
|
||||
@ -113,11 +186,11 @@ class Server {
|
||||
}
|
||||
|
||||
// Check for Bearer token.
|
||||
if ( 0 !== strpos( $auth_header, 'Bearer ' ) ) {
|
||||
if ( 0 !== \strpos( $auth_header, 'Bearer ' ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return substr( $auth_header, 7 );
|
||||
return \substr( $auth_header, 7 );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -142,14 +215,14 @@ class Server {
|
||||
// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
|
||||
// Fallback: read from Apache's own header API (case-insensitive).
|
||||
if ( ! function_exists( 'apache_request_headers' ) ) {
|
||||
if ( ! \function_exists( 'apache_request_headers' ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$headers = apache_request_headers();
|
||||
$headers = \apache_request_headers();
|
||||
|
||||
foreach ( $headers as $key => $value ) {
|
||||
if ( 'authorization' === strtolower( $key ) ) {
|
||||
if ( 'authorization' === \strtolower( $key ) ) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -214,7 +287,7 @@ class Server {
|
||||
return new \WP_Error(
|
||||
'activitypub_insufficient_scope',
|
||||
/* translators: %s: The required scope */
|
||||
sprintf( \__( 'This action requires the "%s" scope.', 'activitypub' ), $scope ),
|
||||
\sprintf( \__( 'This action requires the "%s" scope.', 'activitypub' ), $scope ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
@ -248,7 +321,7 @@ class Server {
|
||||
'revocation_endpoint' => $base_url . 'oauth/revoke',
|
||||
'introspection_endpoint' => $base_url . 'oauth/introspect',
|
||||
'registration_endpoint' => $base_url . 'oauth/clients',
|
||||
'scopes_supported' => Scope::ALL,
|
||||
'scopes_supported' => Scope::supported(),
|
||||
'response_types_supported' => array( 'code' ),
|
||||
'response_modes_supported' => array( 'query' ),
|
||||
'grant_types_supported' => array( 'authorization_code', 'refresh_token' ),
|
||||
@ -337,7 +410,7 @@ class Server {
|
||||
// Build form action URL.
|
||||
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
$form_url = \add_query_arg(
|
||||
array_merge( array( 'action' => 'activitypub_authorize' ), $authorize_params ),
|
||||
\array_merge( array( 'action' => 'activitypub_authorize' ), $authorize_params ),
|
||||
\wp_login_url()
|
||||
);
|
||||
|
||||
@ -448,7 +521,7 @@ class Server {
|
||||
$url = Sanitize::redirect_uri( \add_query_arg( $params, $redirect_uri ) );
|
||||
|
||||
\nocache_headers();
|
||||
header( 'Location: ' . $url, true, 303 );
|
||||
\header( 'Location: ' . $url, true, 303 );
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,8 +102,8 @@ class Token {
|
||||
$refresh_token = self::generate_token();
|
||||
|
||||
// Calculate expirations.
|
||||
$access_expires_at = time() + $expires;
|
||||
$refresh_expires_at = time() + self::REFRESH_EXPIRATION;
|
||||
$access_expires_at = \time() + $expires;
|
||||
$refresh_expires_at = \time() + self::REFRESH_EXPIRATION;
|
||||
|
||||
// Create token data.
|
||||
$token_data = array(
|
||||
@ -113,7 +113,7 @@ class Token {
|
||||
'scopes' => Scope::validate( $scopes ),
|
||||
'expires_at' => $access_expires_at,
|
||||
'refresh_expires_at' => $refresh_expires_at,
|
||||
'created_at' => time(),
|
||||
'created_at' => \time(),
|
||||
'last_used_at' => null,
|
||||
);
|
||||
|
||||
@ -141,7 +141,8 @@ class Token {
|
||||
self::enforce_token_limit( $user_id );
|
||||
|
||||
/*
|
||||
* Get the actor URI for the 'me' parameter (IndieAuth convention).
|
||||
* Get the actor URI for the 'me' parameter (IndieAuth convention) and
|
||||
* `activitypub_actor_id` (SWICG ActivityPub API Basic Profile).
|
||||
* Fall back to blog actor when user actors are disabled.
|
||||
*/
|
||||
$actor = Actors::get_by_id( $user_id );
|
||||
@ -151,12 +152,13 @@ class Token {
|
||||
$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,
|
||||
'access_token' => $access_token,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => $expires,
|
||||
'refresh_token' => $refresh_token,
|
||||
'scope' => Scope::to_string( $token_data['scopes'] ),
|
||||
'me' => $me,
|
||||
'activitypub_actor_id' => $me,
|
||||
);
|
||||
}
|
||||
|
||||
@ -190,7 +192,7 @@ class Token {
|
||||
|
||||
$token_data = \get_user_meta( (int) $user_id, $meta_key, true );
|
||||
|
||||
if ( empty( $token_data ) || ! is_array( $token_data ) ) {
|
||||
if ( empty( $token_data ) || ! \is_array( $token_data ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_token',
|
||||
\__( 'Invalid access token.', 'activitypub' ),
|
||||
@ -200,7 +202,7 @@ class Token {
|
||||
|
||||
// Verify hash matches.
|
||||
if ( ! isset( $token_data['access_token_hash'] ) ||
|
||||
! hash_equals( $token_data['access_token_hash'], $token_hash ) ) {
|
||||
! \hash_equals( $token_data['access_token_hash'], $token_hash ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_token',
|
||||
\__( 'Invalid access token.', 'activitypub' ),
|
||||
@ -209,7 +211,7 @@ class Token {
|
||||
}
|
||||
|
||||
// Check expiration.
|
||||
if ( isset( $token_data['expires_at'] ) && $token_data['expires_at'] < time() ) {
|
||||
if ( isset( $token_data['expires_at'] ) && $token_data['expires_at'] < \time() ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_token_expired',
|
||||
\__( 'Access token has expired.', 'activitypub' ),
|
||||
@ -219,8 +221,8 @@ class Token {
|
||||
|
||||
// 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();
|
||||
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 );
|
||||
}
|
||||
|
||||
@ -272,7 +274,7 @@ class Token {
|
||||
$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 ) ) {
|
||||
if ( empty( $token_data ) || ! \is_array( $token_data ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_refresh_token',
|
||||
\__( 'Invalid refresh token.', 'activitypub' ),
|
||||
@ -282,7 +284,7 @@ class Token {
|
||||
|
||||
// Verify refresh token hash matches.
|
||||
if ( ! isset( $token_data['refresh_token_hash'] ) ||
|
||||
! hash_equals( $token_data['refresh_token_hash'], $refresh_hash ) ) {
|
||||
! \hash_equals( $token_data['refresh_token_hash'], $refresh_hash ) ) {
|
||||
return new \WP_Error(
|
||||
'activitypub_invalid_refresh_token',
|
||||
\__( 'Invalid refresh token.', 'activitypub' ),
|
||||
@ -301,7 +303,7 @@ class Token {
|
||||
|
||||
// Check refresh token expiration.
|
||||
if ( isset( $token_data['refresh_expires_at'] ) &&
|
||||
$token_data['refresh_expires_at'] < time() ) {
|
||||
$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 );
|
||||
@ -353,7 +355,7 @@ class Token {
|
||||
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'] ?? '' ) : '';
|
||||
$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;
|
||||
@ -363,7 +365,7 @@ class 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'] ) ) {
|
||||
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 );
|
||||
}
|
||||
@ -388,7 +390,7 @@ class Token {
|
||||
|
||||
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'] ?? '' ) : '';
|
||||
$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 ) ) {
|
||||
@ -482,22 +484,22 @@ class Token {
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
// Delete token entries and collect client IDs.
|
||||
if ( 0 === strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
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'] ) ) {
|
||||
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 ) ) {
|
||||
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 ) {
|
||||
foreach ( \array_unique( $client_ids ) as $client_id ) {
|
||||
self::untrack_user( $user_id, $client_id );
|
||||
}
|
||||
|
||||
@ -536,13 +538,13 @@ class Token {
|
||||
$all_meta = \get_user_meta( $user_id );
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
if ( 0 !== \strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
|
||||
if ( ! is_array( $token_data ) ) {
|
||||
if ( ! \is_array( $token_data ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -575,13 +577,13 @@ class Token {
|
||||
$tokens = array();
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
if ( 0 !== \strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
|
||||
if ( is_array( $token_data ) ) {
|
||||
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.
|
||||
@ -645,7 +647,7 @@ class Token {
|
||||
* @return bool True if expired.
|
||||
*/
|
||||
public function is_expired() {
|
||||
return $this->get_expires_at() < time();
|
||||
return $this->get_expires_at() < \time();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -673,7 +675,7 @@ class Token {
|
||||
* @return string The random token as a hex string.
|
||||
*/
|
||||
public static function generate_token( $length = 32 ) {
|
||||
return bin2hex( random_bytes( $length ) );
|
||||
return \bin2hex( \random_bytes( $length ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -683,7 +685,7 @@ class Token {
|
||||
* @return string The SHA-256 hash.
|
||||
*/
|
||||
public static function hash_token( $token ) {
|
||||
return hash( 'sha256', $token );
|
||||
return \hash( 'sha256', $token );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -705,7 +707,7 @@ class Token {
|
||||
$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 ) ) {
|
||||
if ( ! \in_array( $user_id, \array_map( 'intval', $existing ), true ) ) {
|
||||
\add_post_meta( $post_id, self::USER_META_KEY, $user_id );
|
||||
}
|
||||
}
|
||||
@ -722,30 +724,30 @@ class Token {
|
||||
$tokens = array();
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
if ( 0 !== \strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
|
||||
if ( is_array( $token_data ) ) {
|
||||
if ( \is_array( $token_data ) ) {
|
||||
$tokens[ $meta_key ] = $token_data;
|
||||
}
|
||||
}
|
||||
|
||||
if ( count( $tokens ) <= self::MAX_TOKENS_PER_USER ) {
|
||||
if ( \count( $tokens ) <= self::MAX_TOKENS_PER_USER ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by created_at ascending (oldest first).
|
||||
uasort(
|
||||
\uasort(
|
||||
$tokens,
|
||||
function ( $a, $b ) {
|
||||
return ( $a['created_at'] ?? 0 ) - ( $b['created_at'] ?? 0 );
|
||||
}
|
||||
);
|
||||
|
||||
$to_remove = count( $tokens ) - self::MAX_TOKENS_PER_USER;
|
||||
$to_remove = \count( $tokens ) - self::MAX_TOKENS_PER_USER;
|
||||
|
||||
foreach ( $tokens as $meta_key => $token_data ) {
|
||||
if ( $to_remove <= 0 ) {
|
||||
@ -809,7 +811,7 @@ class Token {
|
||||
|
||||
$user_ids = \get_post_meta( $client->get_post_id(), self::USER_META_KEY, false );
|
||||
|
||||
return array_map( 'intval', $user_ids );
|
||||
return \array_map( 'intval', $user_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -831,7 +833,7 @@ class Token {
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( 'intval', $user_ids );
|
||||
return \array_map( 'intval', $user_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
@ -850,13 +852,13 @@ class Token {
|
||||
$client_ids = array();
|
||||
|
||||
foreach ( $all_meta as $meta_key => $meta_values ) {
|
||||
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
if ( 0 !== \strpos( $meta_key, self::META_PREFIX ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token_data = \maybe_unserialize( $meta_values[0] );
|
||||
|
||||
if ( ! is_array( $token_data ) ) {
|
||||
if ( ! \is_array( $token_data ) ) {
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
++$count;
|
||||
continue;
|
||||
@ -864,9 +866,9 @@ class Token {
|
||||
|
||||
// Check if both access and refresh tokens are expired.
|
||||
$access_expired = isset( $token_data['expires_at'] ) &&
|
||||
$token_data['expires_at'] < time() - DAY_IN_SECONDS;
|
||||
$token_data['expires_at'] < \time() - DAY_IN_SECONDS;
|
||||
$refresh_expired = isset( $token_data['refresh_expires_at'] ) &&
|
||||
$token_data['refresh_expires_at'] < time();
|
||||
$token_data['refresh_expires_at'] < \time();
|
||||
|
||||
if ( $access_expired && $refresh_expired ) {
|
||||
\delete_user_meta( $user_id, $meta_key );
|
||||
@ -883,7 +885,7 @@ class Token {
|
||||
}
|
||||
|
||||
// Untrack user from clients where all tokens were removed.
|
||||
foreach ( array_unique( $client_ids ) as $client_id ) {
|
||||
foreach ( \array_unique( $client_ids ) as $client_id ) {
|
||||
self::maybe_untrack_user( $user_id, $client_id );
|
||||
}
|
||||
}
|
||||
@ -919,15 +921,16 @@ class Token {
|
||||
$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,
|
||||
'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,
|
||||
'activitypub_actor_id' => $me,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user