updated plugin ActivityPub version 9.1.0

This commit is contained in:
2026-07-28 15:03:10 +00:00
committed by Gitium
parent bf428f0e45
commit ff806e1811
217 changed files with 6098 additions and 3025 deletions

View File

@ -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(

View File

@ -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 );
}
/**

View File

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

View File

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

View File

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